PK     [1]L.	  	    Integrations/WPConsentAPI.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Integrations;

use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Traits\ScriptDebug;
use WP_CONSENT_API;

/**
 * Class WPConsentAPI
 *
 * @since 8.5.0
 */
class WPConsentAPI {

	use ScriptDebug;


	/**
	 * Identifier of the consent category used for order attribution.
	 *
	 * @var string
	 */
	public static $consent_category = 'marketing';

	/**
	 * Register the consent API.
	 *
	 * @return void
	 */
	public function register() {
		add_action(
			'init',
			function() {
				$this->on_init();
			},
			20 // After OrderAttributionController.
		);
	}

	/**
	 * Register our hooks on init.
	 *
	 * @return void
	 */
	protected function on_init() {
		// Include integration to WP Consent Level API if available.
		if ( ! $this->is_wp_consent_api_active() ) {
			return;
		}

		$plugin = plugin_basename( WC_PLUGIN_FILE );
		add_filter( "wp_consent_api_registered_{$plugin}", '__return_true' );
		add_action(
			'wp_enqueue_scripts',
			function() {
				$this->enqueue_consent_api_scripts();
			}
		);

		/**
		 * Modify the "allowTracking" flag consent if the user has consented to marketing.
		 *
		 * Wp-consent-api will initialize the modules on "init" with priority 9,
		 * So this code needs to be run after that.
		 */
		add_filter(
			'wc_order_attribution_allow_tracking',
			function() {
				return function_exists( 'wp_has_consent' ) && wp_has_consent( self::$consent_category );
			}
		);
	}

	/**
	 * Check if WP Cookie Consent API is active
	 *
	 * @return bool
	 */
	protected function is_wp_consent_api_active() {
		return class_exists( WP_CONSENT_API::class );
	}

	/**
	 * Enqueue JS for integration with WP Consent Level API
	 *
	 * @return void
	 */
	private function enqueue_consent_api_scripts() {
		wp_enqueue_script(
			'wp-consent-api-integration',
			plugins_url(
				"assets/js/frontend/wp-consent-api-integration{$this->get_script_suffix()}.js",
				WC_PLUGIN_FILE
			),
			array( 'wp-consent-api', 'wc-order-attribution' ),
			Constants::get_constant( 'WC_VERSION' ),
			true
		);

		// Add data for the script above. `wp_enqueue_script` API does not allow data attributes,
		// so we need a separate script tag and pollute the global scope.
		wp_add_inline_script(
			'wp-consent-api-integration',
			sprintf(
				'window.wc_order_attribution.params.consentCategory = %s;',
				wp_json_encode( self::$consent_category, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES )
			),
			'before'
		);
	}
}
PK     [1]E~<       Integrations/WPPostsImporter.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Integrations;

/**
 * Class WPPostsImporter
 *
 * @since 10.1.0
 */
class WPPostsImporter {

	/**
	 * Register the WP Posts importer.
	 *
	 * @return void
	 */
	public function register() {
		add_action( 'wp_import_posts', array( $this, 'register_product_attribute_taxonomies' ), 100, 1 );
	}

	/**
	 * Register product attribute taxonomies when importing posts via the WXR importer.
	 *
	 * @since 10.1.0
	 *
	 * @param array $posts The posts to process.
	 * @return array
	 */
	public function register_product_attribute_taxonomies( $posts ) {
		if ( ! is_array( $posts ) || empty( $posts ) ) {
			return $posts;
		}

		foreach ( $posts as $post ) {

			if ( 'product' !== $post['post_type'] || empty( $post['terms'] ) ) {
				continue;
			}

			foreach ( $post['terms'] as $term ) {
				if ( ! strstr( $term['domain'], 'pa_' ) ) {
					continue;
				}

				if ( taxonomy_exists( $term['domain'] ) ) {
					continue;
				}

				$attribute_name = wc_attribute_taxonomy_slug( $term['domain'] );

				// Create the taxonomy.
				if ( ! in_array( $attribute_name, wc_get_attribute_taxonomies(), true ) ) {
					wc_create_attribute(
						array(
							'name'         => $attribute_name,
							'slug'         => $attribute_name,
							'type'         => 'select',
							'order_by'     => 'menu_order',
							'has_archives' => false,
						)
					);
				}

				// Register the taxonomy so that the import works.
				register_taxonomy(
					$term['domain'],
					// phpcs:ignore
					apply_filters( 'woocommerce_taxonomy_objects_' . $term['domain'], array( 'product' ) ),
					// phpcs:ignore
					apply_filters(
						'woocommerce_taxonomy_args_' . $term['domain'],
						array(
							'hierarchical' => true,
							'show_ui'      => false,
							'query_var'    => true,
							'rewrite'      => false,
						)
					)
				);
			}
		}

		return $posts;
	}
}
PK     [1]|        Utilities/URLException.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Utilities;

use Exception;

/**
 * Used to represent a problem encountered when processing a URL.
 */
class URLException extends Exception {}
PK     [1]_F8  8    Utilities/PluginInstaller.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Utilities;

use Automattic\WooCommerce\Internal\RegisterHooksInterface;
use Automattic\WooCommerce\Utilities\{ PluginUtil, StringUtil };

/**
 * This class allows installing a plugin programmatically.
 *
 * Information about plugins installed in that way will be stored in a 'woocommerce_autoinstalled_plugins' option,
 * and a notice will be shown under the plugin name in the plugins list indicating that it was automatically
 * installed (these notices can be disabled with the 'woocommerce_show_autoinstalled_plugin_notices' hook).
 *
 * Currently it's only possible to install new plugins, not to upgrade or reinstall already installed plugins.
 *
 * The 'upgrader_process_complete' hook is used to remove the autoinstall information from any plugin that is later
 * upgraded or reinstalled by any means other than the usage of this class.
 */
class PluginInstaller implements RegisterHooksInterface {

	/**
	 * Flag indicating that a plugin install is in progress, so the upgrader_process_complete hook must be ignored.
	 *
	 * @var bool
	 */
	private bool $installing_plugin = false;

	/**
	 * Attach hooks used by the class.
	 */
	public function register() {
		add_action( 'after_plugin_row', array( $this, 'handle_plugin_list_rows' ), 10, 2 );
		add_action( 'upgrader_process_complete', array( $this, 'handle_upgrader_process_complete' ), 10, 2 );
	}

	/**
	 * Programmatically installs a plugin. Upgrade/reinstall of already existing plugins is not supported.
	 * The plugin source must be the WordPress.org plugins directory.
	 *
	 * $metadata can contain anything, but the following keys are recognized by the code that renders the notice
	 * in the plugins list:
	 *
	 * - 'installed_by': defaults to 'WooCommerce' if not present.
	 * - 'info_link': if present, a "More information" link will be included in the notice.
	 *
	 * If 'installed_by' is supplied and it's not 'WooCommerce' (case-insensitive), an exception will be thrown
	 * if the code calling this method is not in a WooCommerce core file (in 'includes' or in 'src').
	 *
	 * Information about plugins successfully installed with this method will be kept in an option named
	 * 'woocommerce_autoinstalled_plugins'. Keys will be the plugin name and values will be associative arrays
	 * with these keys: 'plugin_name', 'version', 'date' and 'metadata' (same meaning as in the returned array).
	 *
	 * A log entry will be created with the result of the process and all the installer messages
	 * (source: 'plugin_auto_installs'). In multisite this log entry will be created on each site.
	 *
	 * The returned array will contain the following (only 'install_ok' and 'messages' if the installation fails):
	 *
	 * - 'install_ok', a boolean.
	 * - 'messages', all the messages generated by the installer.
	 * - 'plugin_name', in the form of 'directory/file.php' (taken from the instance of PluginInstaller used).
	 * - 'version', of the plugin that has been installed.
	 * - 'date', ISO-formatted installation date.
	 * - 'metadata', as supplied (except the 'plugin_name' key) and only if not empty.
	 *
	 * If the plugin is already in the process of being installed (can happen in multisite), the returned array
	 * will contain only one key: 'already_installing', with a value of true.
	 *
	 * @param string $plugin_url URL or file path of the plugin to install.
	 * @param array  $metadata Metadata to store if the installation succeeds.
	 * @return array Information about the installation result.
	 * @throws \InvalidArgumentException Source doesn't start with 'https://downloads.wordpress.org/', or installer name is 'WooCommerce' but caller is not WooCommerce core code.
	 */
	public function install_plugin( string $plugin_url, array $metadata = array() ): array {
		$this->installing_plugin = true;

		$plugins_being_installed = get_site_option( 'woocommerce_autoinstalling_plugins', array() );
		if ( in_array( $plugin_url, $plugins_being_installed, true ) ) {
			return array( 'already_installing' => true );
		}
		$plugins_being_installed[] = $plugin_url;
		update_site_option( 'woocommerce_autoinstalling_plugins', $plugins_being_installed );

		try {
			return $this->install_plugin_core( $plugin_url, $metadata );
		} finally {
			$plugins_being_installed = array_diff( $plugins_being_installed, array( $plugin_url ) );
			if ( empty( $plugins_being_installed ) ) {
				delete_site_option( 'woocommerce_autoinstalling_plugins' );
			} else {
				update_site_option( 'woocommerce_autoinstalling_plugins', $plugins_being_installed );
			}

			$this->installing_plugin = false;
		}
	}

	/**
	 * Core version of 'install_plugin' (it doesn't handle the $installing_plugin flag).
	 *
	 * @param string $plugin_url URL or file path of the plugin to install.
	 * @param array  $metadata Metadata to store if the installation succeeds.
	 * @return array Information about the installation result.
	 * @throws \InvalidArgumentException Source doesn't start with 'https://downloads.wordpress.org/', or installer name is 'WooCommerce' but caller is not WooCommerce core code.
	 */
	private function install_plugin_core( string $plugin_url, array $metadata ): array {
		if ( ! StringUtil::starts_with( $plugin_url, 'https://downloads.wordpress.org/', false ) ) {
			throw new \InvalidArgumentException( "Only installs from the WordPress.org plugins directory (plugin URL starting with 'https://downloads.wordpress.org/') are allowed." );
		}

		$installed_by = $metadata['installed_by'] ?? 'WooCommerce';
		if ( 0 === strcasecmp( 'WooCommerce', $installed_by ) ) {
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace
			$calling_file = StringUtil::normalize_local_path_slashes( debug_backtrace()[1]['file'] ?? '' ); // [1], not [0], because the immediate caller is the install_plugin method.
			if ( ! StringUtil::starts_with( $calling_file, StringUtil::normalize_local_path_slashes( WC_ABSPATH . 'includes/' ) ) && ! StringUtil::starts_with( $calling_file, StringUtil::normalize_local_path_slashes( WC_ABSPATH . 'src/' ) ) ) {
				throw new \InvalidArgumentException( "If the value of 'installed_by' is 'WooCommerce', the caller of the method must be a WooCommerce core class or function." );
			}
		}

		if ( ! class_exists( \Automatic_Upgrader_Skin::class ) ) {
			include_once ABSPATH . 'wp-admin/includes/class-wp-upgrader-skin.php';
			include_once ABSPATH . 'wp-admin/includes/class-automatic-upgrader-skin.php';
		}
		$skin = new \Automatic_Upgrader_Skin();

		if ( ! class_exists( \Plugin_Upgrader::class ) ) {
			include_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
		}
		$upgrader = new \Plugin_Upgrader( $skin );

		$install_ok = $upgrader->install( $plugin_url );

		$result = array( 'messages' => $skin->get_upgrade_messages() );

		if ( $install_ok ) {
			if ( ! function_exists( 'get_plugins' ) ) {
				require_once ABSPATH . 'wp-admin/includes/plugin.php';
			}
			$plugin_name    = $upgrader->plugin_info();
			$plugin_version = get_plugins()[ $plugin_name ]['Version'];

			$result['plugin_name'] = $plugin_name;
			$plugin_data           = array(
				'version' => $plugin_version,
				'date'    => current_time( 'mysql' ),
			);
			if ( ! empty( $metadata ) ) {
				$plugin_data['metadata'] = $metadata;
			}

			$auto_installed_plugins                 = get_site_option( 'woocommerce_autoinstalled_plugins', array() );
			$auto_installed_plugins[ $plugin_name ] = $plugin_data;
			update_site_option( 'woocommerce_autoinstalled_plugins', $auto_installed_plugins );

			$auto_installed_plugins_history = get_site_option( 'woocommerce_history_of_autoinstalled_plugins', array() );
			if ( ! isset( $auto_installed_plugins_history[ $plugin_name ] ) ) {
				$auto_installed_plugins_history[ $plugin_name ] = $plugin_data;
				update_site_option( 'woocommerce_history_of_autoinstalled_plugins', $auto_installed_plugins_history );
			}

			$post_install = function () use ( $plugin_name, $plugin_version, $installed_by, $plugin_url, $plugin_data ) {
				$log_context = array(
					'source'        => 'plugin_auto_installs',
					'recorded_data' => $plugin_data,
				);

				wc_get_logger()->info( "Plugin $plugin_name v{$plugin_version} installed by $installed_by, source: $plugin_url", $log_context );
			};
		} else {
			$messages     = $skin->get_upgrade_messages();
			$post_install = function () use ( $plugin_url, $installed_by, $messages ) {
				$log_context = array(
					'source'             => 'plugin_auto_installs',
					'installer_messages' => $messages,
				);
				wc_get_logger()->error( "$installed_by failed to install plugin from source: $plugin_url", $log_context );
			};
		}

		if ( is_multisite() ) {
			// We log the install in the main site, unless the main site doesn't have WooCommerce installed;
			// in that case we fallback to logging in the current site.
			switch_to_blog( get_main_site_id() );
			if ( self::woocommerce_is_active_in_current_site() ) {
				$post_install();
				restore_current_blog();
			} else {
				restore_current_blog();
				$post_install();
			}
		} else {
			$post_install();
		}

		$result['install_ok'] = $install_ok ?? false;
		return $result;
	}

	/**
	 * Check if WooCommerce is installed and active in the current blog.
	 * This is useful for multisite installs when a blog other than the one running this code is selected with 'switch_to_blog'.
	 *
	 * @return bool True if WooCommerce is installed and active in the current blog, false otherwise.
	 */
	private static function woocommerce_is_active_in_current_site(): bool {
		$active_valid_plugins = wc_get_container()->get( PluginUtil::class )->get_all_active_valid_plugins();

		return ! empty(
			array_filter(
				$active_valid_plugins,
				fn( $plugin ) => substr_compare( $plugin, '/woocommerce.php', -strlen( '/woocommerce.php' ) ) === 0
			)
		);
	}

	/**
	 * Handler for the 'plugin_list_rows' hook, it will display a notice under the name of the plugins
	 * that have been installed using this class (unless the 'woocommerce_show_autoinstalled_plugin_notices' filter
	 * returns false) in the plugins list page.
	 *
	 * @param string $plugin_file Name of the plugin.
	 * @param array  $plugin_data Plugin data.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function handle_plugin_list_rows( $plugin_file, $plugin_data ) {
		global $wp_list_table;

		if ( is_null( $wp_list_table ) ) {
			return;
		}

		/**
		 * Filter to suppress the notice about autoinstalled plugins in the plugins list page.
		 *
		 * @since 8.8.0
		 *
		 * @param bool $display_notice Whether notices should be displayed or not.
		 * @returns bool
		 */
		if ( ! apply_filters( 'woocommerce_show_autoinstalled_plugin_notices', '__return_true' ) ) {
			return;
		}

		$auto_installed_plugins_info = get_site_option( 'woocommerce_autoinstalled_plugins', array() );
		$current_plugin_info         = $auto_installed_plugins_info[ $plugin_file ] ?? null;
		if ( is_null( $current_plugin_info ) || $current_plugin_info['version'] !== $plugin_data['Version'] ) {
			return;
		}

		$installed_by = $current_plugin_info['metadata']['installed_by'] ?? 'WooCommerce';
		$info_link    = $current_plugin_info['metadata']['info_link'] ?? null;
		if ( $info_link ) {
			/* translators: 1 = who installed the plugin, 2 = ISO-formatted date and time, 3 = URL */
			$message = sprintf( __( 'Plugin installed by %1$s on %2$s. <a target="_blank" href="%3$s">More information</a>', 'woocommerce' ), $installed_by, $current_plugin_info['date'], $info_link );
		} else {
			/* translators: 1 = who installed the plugin, 2 = ISO-formatted date and time */
			$message = sprintf( __( 'Plugin installed by %1$s on %2$s.', 'woocommerce' ), $installed_by, $current_plugin_info['date'] );
		}

		$columns_count      = $wp_list_table->get_column_count();
		$is_active          = is_plugin_active( $plugin_file );
		$is_active_class    = $is_active ? 'active' : 'inactive';
		$is_active_td_style = $is_active ? "style='border-left: 4px solid #72aee6;'" : '';

		// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
		?>
		<tr class='plugin-update-tr update <?php echo $is_active_class; ?>' data-plugin='<?php echo $plugin_file; ?>' data-plugin-row-type='feature-incomp-warn'>
			<td colspan='<?php echo $columns_count; ?>' class='plugin-update'<?php echo $is_active_td_style; ?>>
				<div class='notice inline notice-success notice-alt'>
					<p>
						ℹ️ <?php echo $message; ?>
					</p>
				</div>
			</td>
		</tr>
		<?php
		// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
	}

	/**
	 * Handler for the 'upgrader_process_complete' hook. It's used to remove the autoinstalled plugin information
	 * for plugins that are upgraded or reinstalled manually (or more generally, by using any install method
	 * other than this class).
	 *
	 * @param \WP_Upgrader $upgrader The upgrader class that has performed the plugin upgrade/reinstall.
	 * @param array        $hook_extra Extra information about the upgrade process.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function handle_upgrader_process_complete( \WP_Upgrader $upgrader, array $hook_extra ) {
		if ( $this->installing_plugin || ! ( $upgrader instanceof \Plugin_Upgrader ) || ( 'plugin' !== ( $hook_extra['type'] ?? null ) ) ) {
			return;
		}

		$auto_installed_plugins = get_site_option( 'woocommerce_autoinstalled_plugins' );
		if ( ! $auto_installed_plugins ) {
			return;
		}

		if ( $hook_extra['bulk'] ?? false ) {
			$updated_plugin_names = $hook_extra['plugins'] ?? array();
		} else {
			$updated_plugin_names = array( $upgrader->plugin_info() );
		}

		$auto_installed_plugin_names         = array_keys( $auto_installed_plugins );
		$updated_auto_installed_plugin_names = array_intersect( $auto_installed_plugin_names, $updated_plugin_names );

		if ( empty( $updated_auto_installed_plugin_names ) ) {
			return;
		}

		$new_auto_installed_plugins = array_diff_key( $auto_installed_plugins, array_flip( $updated_auto_installed_plugin_names ) );

		if ( empty( $new_auto_installed_plugins ) ) {
			delete_site_option( 'woocommerce_autoinstalled_plugins' );
		} else {
			update_site_option( 'woocommerce_autoinstalled_plugins', $new_auto_installed_plugins );
		}
	}
}
PK     [1]R`      Utilities/FilesystemUtil.phpnu         <?php
declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\Utilities;

use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Exception;
use WP_Filesystem_Base;

/**
 * FilesystemUtil class.
 */
class FilesystemUtil {
	/**
	 * Wrapper to retrieve the class instance contained in the $wp_filesystem global, after initializing if necessary.
	 *
	 * @return WP_Filesystem_Base
	 * @throws Exception Thrown when the filesystem fails to initialize.
	 */
	public static function get_wp_filesystem(): WP_Filesystem_Base {
		global $wp_filesystem;

		if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
			$initialized = self::initialize_wp_filesystem();

			if ( false === $initialized ) {
				throw new Exception( 'The WordPress filesystem could not be initialized.' );
			}
		}

		return $wp_filesystem;
	}

	/**
	 * Get the WP filesystem method, with a fallback to 'direct' if no FS_METHOD constant exists and there are not FTP related options/credentials set.
	 *
	 * @return string|false The name of the WP filesystem method to use.
	 */
	public static function get_wp_filesystem_method_or_direct() {
		$proxy = wc_get_container()->get( LegacyProxy::class );
		if ( ! self::constant_exists( 'FS_METHOD' ) && false === $proxy->call_function( 'get_option', 'ftp_credentials' ) && ! self::constant_exists( 'FTP_HOST' ) ) {
			return 'direct';
		}

		$method = $proxy->call_function( 'get_filesystem_method' );
		if ( $method ) {
			return $method;
		}

		return 'direct';
	}

	/**
	 * Check if a constant exists and is not null.
	 *
	 * @param string $name Constant name.
	 * @return bool True if the constant exists and its value is not null.
	 */
	private static function constant_exists( string $name ): bool {
		return Constants::is_defined( $name ) && ! is_null( Constants::get_constant( $name ) );
	}

	/**
	 * Recursively creates a directory (if it doesn't exist) and adds an empty index.html and a .htaccess to prevent
	 * directory listing.
	 *
	 * @since 9.3.0
	 *
	 * @param string $path Directory to create.
	 * @param bool   $allow_file_access Whether to allow file access while preventing directory listing. Default false (deny all access).
	 * @throws \Exception In case of error.
	 */
	public static function mkdir_p_not_indexable( string $path, bool $allow_file_access = false ): void {
		$wp_fs = self::get_wp_filesystem();

		if ( $wp_fs->is_dir( $path ) ) {
			return;
		}

		if ( ! wp_mkdir_p( $path ) ) {
			throw new \Exception( esc_html( sprintf( 'Could not create directory: %s.', wp_basename( $path ) ) ) );
		}

		$htaccess_content = $allow_file_access ? 'Options -Indexes' : 'deny from all';

		$files = array(
			'.htaccess'  => $htaccess_content,
			'index.html' => '',
		);

		foreach ( $files as $name => $content ) {
			$wp_fs->put_contents( trailingslashit( $path ) . $name, $content );
		}
	}

	/**
	 * Wrapper to initialize the WP filesystem with defined credentials if they are available.
	 *
	 * @return bool True if the $wp_filesystem global was successfully initialized.
	 */
	protected static function initialize_wp_filesystem(): bool {
		global $wp_filesystem;

		if ( $wp_filesystem instanceof WP_Filesystem_Base ) {
			return true;
		}

		require_once ABSPATH . 'wp-admin/includes/file.php';

		$method      = self::get_wp_filesystem_method_or_direct();
		$initialized = false;

		if ( 'direct' === $method ) {
			$initialized = WP_Filesystem();
		} elseif ( false !== $method ) {
			// See https://core.trac.wordpress.org/changeset/56341.
			ob_start();
			$credentials = request_filesystem_credentials( '' );
			ob_end_clean();

			$initialized = $credentials && WP_Filesystem( $credentials );
		}

		return is_null( $initialized ) ? false : $initialized;
	}

	/**
	 * Validate that a file path is a valid upload path.
	 *
	 * @param string $path The path to validate.
	 * @throws \Exception If the file path is not a valid upload path.
	 */
	public static function validate_upload_file_path( string $path ): void {
		$wp_filesystem = self::get_wp_filesystem();

		// File must exist and be readable.
		$is_valid_file = $wp_filesystem->is_readable( $path );

		// Check that file is within an allowed location.
		if ( $is_valid_file ) {
			$is_valid_file = self::file_is_in_directory( $path, $wp_filesystem->abspath() );
			if ( ! $is_valid_file ) {
				$upload_dir    = wp_get_upload_dir();
				$is_valid_file = false === $upload_dir['error'] && self::file_is_in_directory( $path, $upload_dir['basedir'] );
			}
		}

		if ( ! $is_valid_file ) {
			throw new \Exception( esc_html__( 'File path is not a valid upload path.', 'woocommerce' ) );
		}
	}

	/**
	 * Check if a given file is inside a given directory.
	 *
	 * @param string $file_path The full path of the file to check.
	 * @param string $directory The path of the directory to check.
	 * @return bool True if the file is inside the directory.
	 */
	private static function file_is_in_directory( string $file_path, string $directory ): bool {
		// Extract protocol if it exists.
		$protocol = '';
		if ( preg_match( '#^([a-z0-9]+://)#i', $file_path, $matches ) ) {
			$protocol  = $matches[1];
			$file_path = preg_replace( '#^[a-z0-9]+://#i', '', $file_path );
		}

		$file_path = (string) new URL( $file_path ); // This resolves '/../' sequences.
		$file_path = preg_replace( '/^file:\\/\\//', $protocol, $file_path );
		$file_path = preg_replace( '/^file:\\/\\//', '', $file_path );

		return 0 === stripos( wp_normalize_path( $file_path ), trailingslashit( wp_normalize_path( $directory ) ) );
	}
}
PK     [1],~      Utilities/LegacyRestApiStub.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Utilities;

use Automattic\WooCommerce\Internal\RegisterHooksInterface;
use Automattic\WooCommerce\Utilities\RestApiUtil;

/**
 * The Legacy REST API was removed in WooCommerce 9.0 and is now available as a dedicated extension.
 * A stub is kept in WooCommerce core that acts when the extension is not installed and has two purposes:
 *
 * 1. Return a "The WooCommerce API is disabled on this site" error for any request to the Legacy REST API endpoints.
 *
 * 2. Provide the not-endpoint related utility methods that were previously supplied by the WC_API class,
 *    this is achieved by setting the value of WooCommerce::api (typically accessed via 'WC()->api') to an instance of this class.
 *
 * DO NOT add any additional public method to this class unless the method existed with the same signature in the old WC_API class.
 *
 * See: https://developer.woocommerce.com/2023/10/03/the-legacy-rest-api-will-move-to-a-dedicated-extension-in-woocommerce-9-0/
 */
class LegacyRestApiStub implements RegisterHooksInterface {

	/**
	 * The instance of RestApiUtil to use.
	 *
	 * @var RestApiUtil
	 */
	private RestApiUtil $rest_api_util;

	/**
	 * Set up the Legacy REST API endpoints stub.
	 */
	public function register() {
		add_action( 'init', array( __CLASS__, 'add_rewrite_rules_for_legacy_rest_api_stub' ), 0 );
		add_action( 'query_vars', array( __CLASS__, 'add_query_vars_for_legacy_rest_api_stub' ), 0 );
		add_action( 'parse_request', array( __CLASS__, 'parse_legacy_rest_api_request' ), 0 );
	}

	/**
	 * Initialize the class dependencies.
	 *
	 * @internal
	 * @param RestApiUtil $rest_api_util The instance of RestApiUtil to use.
	 */
	final public function init( RestApiUtil $rest_api_util ) {
		$this->rest_api_util = $rest_api_util;
	}

	/**
	 * Add the necessary rewrite rules for the Legacy REST API
	 * (either the dedicated extension if it's installed, or the stub otherwise).
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public static function add_rewrite_rules_for_legacy_rest_api_stub() {
		add_rewrite_rule( '^wc-api/v([1-3]{1})/?$', 'index.php?wc-api-version=$matches[1]&wc-api-route=/', 'top' );
		add_rewrite_rule( '^wc-api/v([1-3]{1})(.*)?', 'index.php?wc-api-version=$matches[1]&wc-api-route=$matches[2]', 'top' );
		add_rewrite_endpoint( 'wc-api', EP_ALL );
	}

	/**
	 * Add the necessary request query variables for the Legacy REST API
	 * (either the dedicated extension if it's installed, or the stub otherwise).
	 *
	 * @param array $vars The query variables array to extend.
	 * @return array The extended query variables array.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public static function add_query_vars_for_legacy_rest_api_stub( $vars ) {
		$vars[] = 'wc-api-version';
		$vars[] = 'wc-api-route';
		$vars[] = 'wc-api';
		return $vars;
	}

	/**
	 * Process an incoming request for the Legacy REST API.
	 *
	 * If the dedicated Legacy REST API extension is installed and active, this method does nothing.
	 * Otherwise it returns a "The WooCommerce API is disabled on this site" error,
	 * unless the request contains a "wc-api" variable and the appropriate
	 * "woocommerce_api_*" hook is set.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public static function parse_legacy_rest_api_request() {
		global $wp;

		// The WC_Legacy_REST_API_Plugin class existence means that the Legacy REST API extension is installed and active.
		if ( class_exists( 'WC_Legacy_REST_API_Plugin' ) ) {
			return;
		}

		self::maybe_process_wc_api_query_var();

		// phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput

		if ( ! empty( $_GET['wc-api-version'] ) ) {
			$wp->query_vars['wc-api-version'] = $_GET['wc-api-version'];
		}

		if ( ! empty( $_GET['wc-api-route'] ) ) {
			$wp->query_vars['wc-api-route'] = $_GET['wc-api-route'];
		}

		if ( ! empty( $wp->query_vars['wc-api-version'] ) && ! empty( $wp->query_vars['wc-api-route'] ) ) {
			header(
				sprintf(
					'Content-Type: %s; charset=%s',
					isset( $_GET['_jsonp'] ) ? 'application/javascript' : 'application/json',
					get_option( 'blog_charset' )
				)
			);
			status_header( 404 );
			echo wp_json_encode(
				array(
					'errors' => array(
						'code'    => 'woocommerce_api_disabled',
						'message' => 'The WooCommerce API is disabled on this site',
					),
				)
			);
			exit;
		}

		// phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
	}

	/**
	 * Process a "wc-api" variable if present in the query, by triggering the appropriate hooks.
	 */
	private static function maybe_process_wc_api_query_var() {
		global $wp;

		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		if ( ! empty( $_GET['wc-api'] ) ) {
			$wp->query_vars['wc-api'] = sanitize_key( wp_unslash( $_GET['wc-api'] ) );
		}
		// phpcs:enable WordPress.Security.NonceVerification.Recommended

		// wc-api endpoint requests.
		if ( ! empty( $wp->query_vars['wc-api'] ) ) {

			// Buffer, we won't want any output here.
			ob_start();

			// No cache headers.
			wc_nocache_headers();

			// Clean the API request.
			$api_request = strtolower( wc_clean( $wp->query_vars['wc-api'] ) );

			// Make sure gateways are available for request.
			WC()->payment_gateways();

			// phpcs:disable WooCommerce.Commenting.CommentHooks.HookCommentWrongStyle

			// Trigger generic action before request hook.
			do_action( 'woocommerce_api_request', $api_request );

			// Is there actually something hooked into this API request? If not trigger 400 - Bad request.
			status_header( has_action( 'woocommerce_api_' . $api_request ) ? 200 : 400 );

			// Trigger an action which plugins can hook into to fulfill the request.
			do_action( 'woocommerce_api_' . $api_request );

			// phpcs:enable WooCommerce.Commenting.CommentHooks.HookCommentWrongStyle

			// Done, clear buffer and exit.
			ob_end_clean();
			die( '-1' );
		}
	}

	/**
	 * Get data from a WooCommerce API endpoint.
	 * This method used to be part of the WooCommerce Legacy REST API.
	 *
	 * @since 9.1.0
	 *
	 * @param string $endpoint Endpoint.
	 * @param array  $params Params to pass with request.
	 * @return array|\WP_Error
	 */
	public function get_endpoint_data( $endpoint, $params = array() ) {
		wc_doing_it_wrong(
			'get_endpoint_data',
			"'WC()->api->get_endpoint_data' is deprecated, please use the following instead: wc_get_container()->get(Automattic\WooCommerce\Utilities\RestApiUtil::class)->get_endpoint_data",
			'9.1.0'
		);

		return $this->rest_api_util->get_endpoint_data( $endpoint, $params );
	}
}
PK     [1]=k4  k4    Utilities/URL.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Utilities;

/**
 * Provides an easy method of assessing URLs, including filepaths (which will be silently
 * converted to a file:// URL if provided).
 */
class URL {
	/**
	 * Components of the URL being assessed.
	 *
	 * The keys match those potentially returned by the parse_url() function, except
	 * that they are always defined and 'drive' (Windows drive letter) has been added.
	 *
	 * @var string|null[]
	 */
	private $components = array(
		'drive'    => null,
		'fragment' => null,
		'host'     => null,
		'pass'     => null,
		'path'     => null,
		'port'     => null,
		'query'    => null,
		'scheme'   => null,
		'user'     => null,
	);

	/**
	 * If the URL (or filepath) is absolute.
	 *
	 * @var bool
	 */
	private $is_absolute;

	/**
	 * If the URL (or filepath) represents a directory other than the root directory.
	 *
	 * This is useful at different points in the process, when deciding whether to re-apply
	 * a trailing slash at the end of processing or when we need to calculate how many
	 * directory traversals are needed to form a (grand-)parent URL.
	 *
	 * @var bool
	 */
	private $is_non_root_directory;

	/**
	 * The components of the URL's path.
	 *
	 * For instance, in the case of "file:///srv/www/wp.site" (noting that a file URL has
	 * no host component) this would contain:
	 *
	 *     [ "srv", "www", "wp.site" ]
	 *
	 * In the case of a non-file URL such as "https://example.com/foo/bar/baz" (noting the
	 * host is not part of the path) it would contain:
	 *
	 *    [ "foo", "bar", "baz" ]
	 *
	 * @var array
	 */
	private $path_parts = array();

	/**
	 * The URL.
	 *
	 * @var string
	 */
	private $url;

	/**
	 * Creates and processes the provided URL (or filepath).
	 *
	 * @throws URLException If the URL (or filepath) is seriously malformed.
	 *
	 * @param string $url The URL (or filepath).
	 */
	public function __construct( string $url ) {
		$this->url = $url;
		$this->preprocess();
		$this->process_path();
	}

	/**
	 * Makes all slashes forward slashes, converts filepaths to file:// URLs, and
	 * other processing to help with comprehension of filepaths.
	 *
	 * @throws URLException If the URL is seriously malformed.
	 */
	private function preprocess() {
		// For consistency, all slashes should be forward slashes.
		$this->url = str_replace( '\\', '/', $this->url );

		// Windows: capture the drive letter if provided.
		if ( preg_match( '#^(file://)?([a-z]):/(?!/).*#i', $this->url, $matches ) ) {
			$this->components['drive'] = $matches[2];
		}

		/*
		 * If there is no scheme, assume and prepend "file://". An exception is made for cases where the URL simply
		 * starts with exactly two forward slashes, which indicates 'any scheme' (most commonly, that is used when
		 * there is freedom to switch between 'http' and 'https').
		 */
		if ( ! preg_match( '#^[a-z]+://#i', $this->url ) && ! preg_match( '#^//(?!/)#', $this->url ) ) {
			$this->url = 'file://' . $this->url;
		}

		$parsed_components = wp_parse_url( $this->url );

		// If we received a really badly formed URL, let's go no further.
		if ( false === $parsed_components ) {
			throw new URLException(
				sprintf(
				/* translators: %s is the URL. */
					__( '%s is not a valid URL.', 'woocommerce' ),
					$this->url
				)
			);
		}

		$this->components = array_merge( $this->components, $parsed_components );

		// File URLs cannot have a host. However, the initial path segment *or* the Windows drive letter
		// (if present) may be incorrectly be interpreted as the host name.
		if ( 'file' === $this->components['scheme'] && ! empty( $this->components['host'] ) ) {
			// If we do not have a drive letter, then simply merge the host and the path together.
			if ( null === $this->components['drive'] ) {
				$this->components['path'] = $this->components['host'] . ( $this->components['path'] ?? '' );
			}

			// Restore the host to null in this situation.
			$this->components['host'] = null;
		}
	}

	/**
	 * Simplifies the path if possible, by resolving directory traversals to the extent possible
	 * without touching the filesystem.
	 */
	private function process_path() {
		$segments                    = explode( '/', $this->components['path'] );
		$this->is_absolute           = substr( $this->components['path'], 0, 1 ) === '/' || ! empty( $this->components['host'] );
		$this->is_non_root_directory = substr( $this->components['path'], -1, 1 ) === '/' && strlen( $this->components['path'] ) > 1;
		$resolve_traversals          = 'file' !== $this->components['scheme'] || $this->is_absolute;
		$retain_traversals           = false;

		// Clean the path.
		foreach ( $segments as $part ) {
			// Drop empty segments.
			if ( strlen( $part ) === 0 || '.' === $part ) {
				continue;
			}

			// Directory traversals created with percent-encoding syntax should also be detected.
			$is_traversal = str_ireplace( '%2e', '.', $part ) === '..';

			// Resolve directory traversals (if allowed: see further comment relating to this).
			if ( $resolve_traversals && $is_traversal ) {
				if ( count( $this->path_parts ) > 0 && ! $retain_traversals ) {
					$this->path_parts = array_slice( $this->path_parts, 0, count( $this->path_parts ) - 1 );
					continue;
				} elseif ( $this->is_absolute ) {
					continue;
				}
			}

			/*
			 * Consider allowing directory traversals to be resolved (ie, the process that converts 'foo/bar/../baz' to
			 * 'foo/baz').
			 *
			 * 1. For this decision point, we are only concerned with relative filepaths (in all other cases,
			 *    $resolve_traversals will already be true).
			 * 2. This is a 'one time' and unidirectional operation. We only wish to flip from false to true, and we
			 *    never wish to do this more than once.
			 * 3. We only flip the switch after we have examined all leading '..' traversal segments.
			 */
			if ( false === $resolve_traversals && '..' !== $part && 'file' === $this->components['scheme'] && ! $this->is_absolute ) {
				$resolve_traversals = true;
			}

			/*
			 * Set a flag indicating that traversals should be retained. This is done to ensure we don't prematurely
			 * discard traversals at the start of the path.
			 */
			$retain_traversals = $resolve_traversals && '..' === $part;

			// Retain this part of the path.
			$this->path_parts[] = $part;
		}

		// Protect against empty relative paths.
		if ( count( $this->path_parts ) === 0 && ! $this->is_absolute ) {
			$this->path_parts = array( '.' );
			$this->is_non_root_directory = true;
		}

		// Reform the path from the processed segments, appending a leading slash if it is absolute and restoring
		// the Windows drive letter if we have one.
		$this->components['path'] = ( $this->is_absolute ? '/' : '' ) . implode( '/', $this->path_parts ) . ( $this->is_non_root_directory ? '/' : '' );
	}

	/**
	 * Returns the processed URL as a string.
	 *
	 * @return string
	 */
	public function __toString(): string {
		return $this->get_url();
	}

	/**
	 * Returns all possible parent URLs for the current URL.
	 *
	 * @return string[]
	 */
	public function get_all_parent_urls(): array {
		$max_parent = count( $this->path_parts );
		$parents    = array();

		/*
		 * If we are looking at a relative path that begins with at least one traversal (example: "../../foo")
		 * then we should only return one parent URL (otherwise, we'd potentially have to return an infinite
		 * number of parent URLs since we can't know how far the tree extends).
		 */
		if ( $max_parent > 0 && ! $this->is_absolute && '..' === $this->path_parts[0] ) {
			$max_parent = 1;
		}

		for ( $level = 1; $level <= $max_parent; $level++ ) {
			$parents[] = $this->get_parent_url( $level );
		}

		return $parents;
	}

	/**
	 * Outputs the parent URL.
	 *
	 * For example, if $this->get_url() returns "https://example.com/foo/bar/baz" then
	 * this method will return "https://example.com/foo/bar/".
	 *
	 * When a grand-parent is needed, the optional $level parameter can be used. By default
	 * this is set to 1 (parent). 2 will yield the grand-parent, 3 will yield the great
	 * grand-parent, etc.
	 *
	 * If a level is specified that exceeds the number of path segments, this method will
	 * return false.
	 *
	 * @param int $level Used to indicate the level of parent.
	 *
	 * @return string|false
	 */
	public function get_parent_url( int $level = 1 ) {
		if ( $level < 1 ) {
			$level = 1;
		}

		$parts_count               = count( $this->path_parts );
		$parent_path_parts_to_keep = $parts_count - $level;

		/*
		 * With the exception of file URLs, we do not allow obtaining (grand-)parent directories that require
		 * us to describe them using directory traversals. For example, given "http://hostname/foo/bar/baz.png" we do
		 * not permit determining anything more than 2 levels up (we cannot go beyond "http://hostname/").
		 */
		if ( 'file' !== $this->components['scheme'] && $parent_path_parts_to_keep < 0 ) {
			return false;
		}

		// In the specific case of an absolute filepath describing the root directory, there can be no parent.
		if ( 'file' === $this->components['scheme'] && $this->is_absolute && empty( $this->path_parts ) ) {
			return false;
		}

		// Handle cases where the path starts with one or more 'dot segments'. Since the path has already been
		// processed, we can be confident that any such segments are at the start of the path.
		if ( $parts_count > 0 && ( '.' === $this->path_parts[0] || '..' === $this->path_parts[0] ) ) {
			// Determine the index of the last dot segment (ex: given the path '/../../foo' it would be 1).
			$single_dots   = array_keys( $this->path_parts, '.', true );
			$double_dots   = array_keys( $this->path_parts, '..', true );
			$max_dot_index = max( array_merge( $single_dots, $double_dots ) );

			// Prepend the required number of traversals and discard unnecessary trailing segments.
			$last_traversal = $max_dot_index + ( $this->is_non_root_directory ? 1 : 0 );
			$parent_path    = str_repeat( '../', $level ) . join( '/', array_slice( $this->path_parts, 0, $last_traversal ) );
		} elseif ( $parent_path_parts_to_keep < 0 ) {
			// For relative filepaths only, we use traversals to describe the requested parent.
			$parent_path = untrailingslashit( str_repeat( '../', $parent_path_parts_to_keep * -1 ) );
		} else {
			// Otherwise, in a very simple case, we just remove existing parts.
			$parent_path = implode( '/', array_slice( $this->path_parts, 0, $parent_path_parts_to_keep ) );
		}

		if ( $this->is_relative() && '' === $parent_path ) {
			$parent_path = '.';
		}

		// Append a trailing slash, since a parent is always a directory. The only exception is the current working directory.
		$parent_path .= '/';

		// For absolute paths, apply a leading slash (does not apply if we have a root path).
		if ( $this->is_absolute && 0 !== strpos( $parent_path, '/' ) ) {
			$parent_path = '/' . $parent_path;
		}

		// Form the parent URL (ditching the query and fragment, if set).
		$parent_url = $this->get_url(
			array(
				'path'     => $parent_path,
				'query'    => null,
				'fragment' => null,
			)
		);

		// We process the parent URL through a fresh instance of this class, for consistency.
		return ( new self( $parent_url ) )->get_url();
	}

	/**
	 * Outputs the processed URL.
	 *
	 * Borrows from https://www.php.net/manual/en/function.parse-url.php#106731
	 *
	 * @param array $component_overrides If provided, these will override values set in $this->components.
	 *
	 * @return string
	 */
	public function get_url( array $component_overrides = array() ): string {
		$components = array_merge( $this->components, $component_overrides );

		$scheme = null !== $components['scheme'] ? $components['scheme'] . '://' : '//';
		$host   = null !== $components['host'] ? $components['host'] : '';
		$port   = null !== $components['port'] ? ':' . $components['port'] : '';
		$path   = $this->get_path( $components['path'] );

		// Special handling for hostless URLs (typically, filepaths) referencing the current working directory.
		if ( '' === $host && ( '' === $path || '.' === $path ) ) {
			$path = './';
		}

		$user      = null !== $components['user'] ? $components['user'] : '';
		$pass      = null !== $components['pass'] ? ':' . $components['pass'] : '';
		$user_pass = ( ! empty( $user ) || ! empty( $pass ) ) ? $user . $pass . '@' : '';

		$query    = null !== $components['query'] ? '?' . $components['query'] : '';
		$fragment = null !== $components['fragment'] ? '#' . $components['fragment'] : '';

		return $scheme . $user_pass . $host . $port . $path . $query . $fragment;
	}

	/**
	 * Outputs the path. Especially useful if it was a a regular filepath that was passed in originally.
	 *
	 * @param string|null $path_override If provided this will be used as the URL path. Does not impact drive letter.
	 *
	 * @return string
	 */
	public function get_path( ?string $path_override = null ): string {
		return ( $this->components['drive'] ? $this->components['drive'] . ':' : '' ) . ( $path_override ?? $this->components['path'] );
	}

	/**
	 * Indicates if the URL or filepath was absolute.
	 *
	 * @return bool True if absolute, else false.
	 */
	public function is_absolute(): bool {
		return $this->is_absolute;
	}

	/**
	 * Indicates if the URL or filepath was relative.
	 *
	 * @return bool True if relative, else false.
	 */
	public function is_relative(): bool {
		return ! $this->is_absolute;
	}
}
PK     [1])aP%  P%    Utilities/Users.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Utilities;

use Automattic\WooCommerce\Proxies\LegacyProxy;
use WP_Error, WP_User;

/**
 * Helper functions for working with users.
 */
class Users {
	/**
	 * Indicates if the user qualifies as site administrator.
	 *
	 * In the context of multisite networks, this means that they must have the `manage_sites`
	 * capability. In all other cases, they must have the `manage_options` capability.
	 *
	 * @param int $user_id Optional, used to specify a specific user (otherwise we look at the current user).
	 *
	 * @return bool
	 */
	public static function is_site_administrator( int $user_id = 0 ): bool {
		$user = 0 === $user_id ? wp_get_current_user() : get_user_by( 'id', $user_id );

		if ( false === $user ) {
			return false;
		}

		return is_multisite() ? $user->has_cap( 'manage_sites' ) : $user->has_cap( 'manage_options' );
	}

	/**
	 * Get a user from a valid user ID, but only if the active user is able to see them.
	 *
	 * In a multisite context, that may mean that they both must be members of the current blog, or else the active
	 * user must either have special permissions (manage_network_users) or else a special legacy mode
	 * (woocommerce_network_wide_customers) is enabled.
	 *
	 * @param int      $user_id            The ID of the desired user.
	 * @param int|null $requesting_user_id The ID of the user making the request. Optional, defaults to the current user.
	 *
	 * @return WP_User|WP_Error
	 */
	public static function get_user_in_current_site( $user_id, ?int $requesting_user_id = null ) {
		// User ID is expected to be an integer. Cast it if we can (avoiding additional runtime warnings), else treat it as 0.
		$user_id = is_numeric( $user_id ) ? (int) $user_id : 0;

		$legacy_proxy       = wc_get_container()->get( LegacyProxy::class );
		$requesting_user_id = $requesting_user_id > 0 ? $requesting_user_id : wp_get_current_user()->ID;
		$error              = new WP_Error( 'wc_user_invalid_id', __( 'Invalid user ID.', 'woocommerce' ) );

		if ( $user_id <= 0 ) {
			return $error;
		}

		$user = get_userdata( $user_id );
		if ( ! $user instanceof WP_User || ! $user->exists() ) {
			return $error;
		}

		if (
			$legacy_proxy->call_function( 'is_multisite' )
			&& ! $legacy_proxy->call_function( 'is_user_member_of_blog', $user->ID )
			&& ! $legacy_proxy->call_function( 'user_can', $requesting_user_id, 'manage_network_users' )
			&& get_site_option( 'woocommerce_network_wide_customers', 'no' ) !== 'yes'
		) {
			return $error;
		}

		return $user;
	}

	/**
	 * Check if the email is valid.
	 *
	 * @param int    $order_id Order ID.
	 * @param string $supplied_email Supplied email.
	 * @param string $context Context in which we are checking the email.
	 * @return bool
	 */
	public static function should_user_verify_order_email( $order_id, $supplied_email = null, $context = 'view' ) {
		$order         = wc_get_order( $order_id );
		$billing_email = $order->get_billing_email();
		$customer_id   = $order->get_customer_id();

		// If we do not have a billing email for the order (could happen in the order is created manually, or if the
		// requirement for this has been removed from the checkout flow), email verification does not make sense.
		if ( empty( $billing_email ) ) {
			return false;
		}

		// No verification step is needed if the user is logged in and is already associated with the order.
		if ( $customer_id && get_current_user_id() === $customer_id ) {
			return false;
		}

		/**
		 * Controls the grace period within which we do not require any sort of email verification step before rendering
		 * the 'order received' or 'order pay' pages.
		 *
		 * To eliminate the grace period, set to zero (or to a negative value). Note that this filter is not invoked
		 * at all if email verification is deemed to be unnecessary (in other words, it cannot be used to force
		 * verification in *all* cases).
		 *
		 * @since 8.0.0
		 *
		 * @param int      $grace_period Time in seconds after an order is placed before email verification may be required.
		 * @param WC_Order $this         The order for which this grace period is being assessed.
		 * @param string   $context      Indicates the context in which we might verify the email address. Typically 'order-pay' or 'order-received'.
		 */
		$verification_grace_period = (int) apply_filters( 'woocommerce_order_email_verification_grace_period', 10 * MINUTE_IN_SECONDS, $order, $context );
		$date_created              = $order->get_date_created();

		// We do not need to verify the email address if we are within the grace period immediately following order creation.
		if (
			is_a( $date_created, \WC_DateTime::class, true )
			&& time() - $date_created->getTimestamp() <= $verification_grace_period
		) {
			return false;
		}

		$session       = wc()->session;
		$session_email = '';

		if ( is_a( $session, \WC_Session::class ) ) {
			$customer      = $session->get( 'customer' );
			$session_email = is_array( $customer ) && isset( $customer['email'] ) ? $customer['email'] : '';
		}

		// Email verification is required if the user cannot be identified, or if they supplied an email address but the nonce check failed.
		$can_view_orders      = current_user_can( 'read_private_shop_orders' );
		$session_email_match  = $session_email === $billing_email;
		$supplied_email_match = $supplied_email === $billing_email;

		$email_verification_required = ! $session_email_match && ! $supplied_email_match && ! $can_view_orders;

		/**
		 * Provides an opportunity to override the (potential) requirement for shoppers to verify their email address
		 * before we show information such as the order summary, or order payment page.
		 *
		 * Note that this hook is not always triggered, therefore it is (for example) unsuitable as a way of forcing
		 * email verification across all order confirmation/order payment scenarios. Instead, the filter primarily
		 * exists as a way to *remove* the email verification step.
		 *
		 * @since 7.9.0
		 *
		 * @param bool     $email_verification_required If email verification is required.
		 * @param WC_Order $order                       The relevant order.
		 * @param string   $context                     The context under which we are performing this check.
		 */
		return (bool) apply_filters( 'woocommerce_order_email_verification_required', $email_verification_required, $order, $context );
	}

	/**
	 * Site-specific method of retrieving the requested user meta.
	 *
	 * This is a multisite-aware wrapper around WordPress's own `get_user_meta()` function, and works by prefixing the
	 * supplied meta key with a blog-specific meta key.
	 *
	 * @param int    $user_id User ID.
	 * @param string $key     Optional. The meta key to retrieve. By default, returns data for all keys.
	 * @param bool   $single  Optional. Whether to return a single value. This parameter has no effect if `$key` is not
	 *                        specified. Default false.
	 *
	 * @return mixed An array of values if `$single` is false. The value of meta data field if `$single` is true.
	 *               False for an invalid `$user_id` (non-numeric, zero, or negative value). An empty string if a valid
	 *               but non-existing user ID is passed.
	 */
	public static function get_site_user_meta( int $user_id, string $key = '', bool $single = false ) {
		global $wpdb;
		$site_specific_key = $key . '_' . rtrim( $wpdb->get_blog_prefix( get_current_blog_id() ), '_' );
		return get_user_meta( $user_id, $site_specific_key, true );
	}

	/**
	 * Site-specific means of updating user meta.
	 *
	 * This is a multisite-aware wrapper around WordPress's own `update_user_meta()` function, and works by prefixing
	 * the supplied meta key with a blog-specific meta key.
	 *
	 * @param int    $user_id    User ID.
	 * @param string $meta_key   Metadata key.
	 * @param mixed  $meta_value Metadata value. Must be serializable if non-scalar.
	 * @param mixed  $prev_value Optional. Previous value to check before updating. If specified, only update existing
	 *                           metadata entries with this value. Otherwise, update all entries. Default empty.
	 *
	 * @return int|bool Meta ID if the key didn't exist, true on successful update, false on failure or if the value
	 *                  passed to the function is the same as the one that is already in the database.
	 */
	public static function update_site_user_meta( int $user_id, string $meta_key, $meta_value, $prev_value = '' ) {
		global $wpdb;
		$site_specific_key = $meta_key . '_' . rtrim( $wpdb->get_blog_prefix( get_current_blog_id() ), '_' );
		return update_user_meta( $user_id, $site_specific_key, $meta_value, $prev_value );
	}

	/**
	 * Site-specific means of deleting user meta.
	 *
	 * This is a multisite-aware wrapper around WordPress's own `delete_user_meta()` function, and works by prefixing
	 * the supplied meta key with a blog-specific meta key.
	 *
	 * @param int    $user_id    User ID.
	 * @param string $meta_key   Metadata name.
	 * @param mixed  $meta_value Optional. Metadata value. If provided, rows will only be removed that match the value.
	 *                           Must be serializable if non-scalar. Default empty.
	 *
	 * @return bool True on success, false on failure.
	 * /
	 */
	public static function delete_site_user_meta(  $user_id, $meta_key, $meta_value = '' ) {
		global $wpdb;
		$site_specific_key = $meta_key . '_' . rtrim( $wpdb->get_blog_prefix(), '_' );
		return delete_user_meta( $user_id, $site_specific_key, $meta_value );
	}
}
PK     [1]      Utilities/Types.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\Utilities;

use InvalidArgumentException;

/**
 * Utilities to help ensure type safety.
 */
class Types {
	/**
	 * Checks if $thing is an instance of $desired_type.
	 *
	 * If the check succeeds, $thing will be returned without further modification. If the check fails, then either
	 * an exception will be thrown or, if an $on_failure callback was supplied, it will be invoked to either generate
	 * an appropriate return value or to throw a more specific exception.
	 *
	 * Please note that the failure handler will be passed two arguments:
	 *
	 *     $on_failure( $object, $desired_type )
	 *
	 * @since 9.1.0
	 * @throws InvalidArgumentException If $object does not match $desired_type, and an $on_failure callback was not supplied.
	 *
	 * @param mixed     $thing        The value or reference to be assessed.
	 * @param string    $desired_type What we expect the return type to be, if it is not a WP_Error.
	 * @param ?callable $on_failure   If provided, and if evaluation fails, this will be invoked to generate a return value.
	 *
	 * @return mixed
	 */
	public static function ensure_instance_of( $thing, string $desired_type, ?callable $on_failure = null ) {
		// If everything looks good, return early.
		if ( $thing instanceof $desired_type ) {
			return $thing;
		}

		// Summarize the error for use in logging and in case we have to throw an exception.
		$summary = sprintf(
			'Object was not of expected type %1$s.',
			$desired_type
		);

		// Otherwise, let's log the problem so the site operator has a record of where things went wrong.
		$logger = wc_get_logger();

		if ( $logger ) {
			$logger->error(
				$summary,
				array(
					'source'    => 'wc-type-check-utility',
					'backtrace' => true,
				)
			);
		}

		// Invoke the $on_failure handler, if specified.
		if ( null !== $on_failure ) {
			return $on_failure( $thing, $desired_type );
		}

		throw new InvalidArgumentException( esc_html( $summary ) );
	}
}
PK     [1]kc@  c@    Utilities/DatabaseUtil.phpnu         <?php
/**
 * DatabaseUtil class file.
 */

namespace Automattic\WooCommerce\Internal\Utilities;

use DateTime;
use DateTimeZone;
use Vtiful\Kernel\Format;

/**
 * A class of utilities for dealing with the database.
 */
class DatabaseUtil {

	/**
	 * Wrapper for the WordPress dbDelta function, allows to execute a series of SQL queries.
	 *
	 * @param string $queries The SQL queries to execute.
	 * @param bool   $execute Ture to actually execute the queries, false to only simulate the execution.
	 * @return array The result of the execution (or simulation) from dbDelta.
	 */
	public function dbdelta( string $queries = '', bool $execute = true ): array {
		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		return dbDelta( $queries, $execute );
	}

	/**
	 * Given a set of table creation SQL statements, check which of the tables are currently missing in the database.
	 *
	 * @param string $creation_queries The SQL queries to execute ("CREATE TABLE" statements, same format as for dbDelta).
	 * @return array An array containing the names of the tables that currently don't exist in the database.
	 */
	public function get_missing_tables( string $creation_queries ): array {
		global $wpdb;
		$suppress_errors = $wpdb->suppress_errors( true );
		$dbdelta_output  = $this->dbdelta( $creation_queries, false );
		$wpdb->suppress_errors( $suppress_errors );
		$parsed_output = $this->parse_dbdelta_output( $dbdelta_output );
		return $parsed_output['created_tables'];
	}

	/**
	 * Parses the output given by dbdelta and returns information about it.
	 *
	 * @param array $dbdelta_output The output from the execution of dbdelta.
	 * @return array[] An array containing a 'created_tables' key whose value is an array with the names of the tables that have been (or would have been) created.
	 */
	public function parse_dbdelta_output( array $dbdelta_output ): array {
		$created_tables = array();

		foreach ( $dbdelta_output as $table_name => $result ) {
			if ( "Created table $table_name" === $result ) {
				$created_tables[] = str_replace( '(', '', $table_name );
			}
		}

		return array( 'created_tables' => $created_tables );
	}

	/**
	 * Drops a database table.
	 *
	 * @param string $table_name The name of the table to drop.
	 * @param bool   $add_prefix True if the table name passed needs to be prefixed with $wpdb->prefix before processing.
	 * @return bool True on success, false on error.
	 */
	public function drop_database_table( string $table_name, bool $add_prefix = false ) {
		global $wpdb;

		if ( $add_prefix ) {
			$table_name = $wpdb->prefix . $table_name;
		}

		//phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		return $wpdb->query( "DROP TABLE IF EXISTS `{$table_name}`" );
	}

	/**
	 * Drops a table index, if both the table and the index exist.
	 *
	 * @param string $table_name The name of the table that contains the index.
	 * @param string $index_name The name of the index to be dropped.
	 * @return bool True if the index has been dropped, false if either the table or the index don't exist.
	 */
	public function drop_table_index( string $table_name, string $index_name ): bool {
		global $wpdb;

		if ( empty( $this->get_index_columns( $table_name, $index_name ) ) ) {
			return false;
		}

		// phpcs:ignore WordPress.DB.PreparedSQL
		$wpdb->query( "ALTER TABLE $table_name DROP INDEX $index_name" );
		return true;
	}

	/**
	 * Create a primary key for a table, only if the table doesn't have a primary key already.
	 *
	 * @param string $table_name Table name.
	 * @param array  $columns An array with the index column names.
	 * @return bool True if the key has been created, false if the table already had a primary key.
	 */
	public function create_primary_key( string $table_name, array $columns ) {
		global $wpdb;

		if ( ! empty( $this->get_index_columns( $table_name ) ) ) {
			return false;
		}

		// phpcs:ignore WordPress.DB.PreparedSQL
		$wpdb->query( "ALTER TABLE $table_name ADD PRIMARY KEY(`" . join( '`,`', $columns ) . '`)' );
		return true;
	}

	/**
	 * Get the columns of a given table index, or of the primary key.
	 *
	 * @param string $table_name Table name.
	 * @param string $index_name Index name, empty string for the primary key.
	 * @return array The index columns. Empty array if the table or the index don't exist.
	 */
	public function get_index_columns( string $table_name, string $index_name = '' ): array {
		global $wpdb;

		if ( empty( $index_name ) ) {
			$index_name = 'PRIMARY';
		}

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$results = $wpdb->get_results( $wpdb->prepare( "SHOW INDEX FROM $table_name WHERE Key_name = %s", $index_name ) );

		if ( empty( $results ) ) {
			return array();
		}

		return array_column( $results, 'Column_name' );
	}

	/**
	 * Formats an object value of type `$type` for inclusion in the database.
	 *
	 * @param mixed  $value Raw value.
	 * @param string $type  Data type.
	 * @return mixed
	 * @throws \Exception When an invalid type is passed.
	 */
	public function format_object_value_for_db( $value, string $type ) {
		switch ( $type ) {
			case 'decimal':
				$value = wc_format_decimal( $value, false, true );
				break;
			case 'int':
				$value = (int) $value;
				break;
			case 'bool':
				$value = wc_string_to_bool( $value );
				break;
			case 'string':
				$value = strval( $value );
				break;
			case 'date':
				// Date properties are converted to the WP timezone (see WC_Data::set_date_prop() method), however
				// for our own tables we persist dates in GMT.
				$value = $value ? ( new DateTime( $value ) )->setTimezone( new DateTimeZone( '+00:00' ) )->format( 'Y-m-d H:i:s' ) : null;
				break;
			case 'date_epoch':
				$value = $value ? ( new DateTime( "@{$value}" ) )->format( 'Y-m-d H:i:s' ) : null;
				break;
			default:
				throw new \Exception( esc_html( 'Invalid type received: ' . $type ) );
		}

		return $value;
	}

	/**
	 * Returns the `$wpdb` placeholder to use for data type `$type`.
	 *
	 * @param string $type Data type.
	 * @return string
	 * @throws \Exception When an invalid type is passed.
	 */
	public function get_wpdb_format_for_type( string $type ) {
		static $wpdb_placeholder_for_type = array(
			'int'        => '%d',
			'decimal'    => '%f',
			'string'     => '%s',
			'date'       => '%s',
			'date_epoch' => '%s',
			'bool'       => '%d',
		);

		if ( ! isset( $wpdb_placeholder_for_type[ $type ] ) ) {
			throw new \Exception( esc_html( 'Invalid column type: ' . $type ) );
		}

		return $wpdb_placeholder_for_type[ $type ];
	}

	/**
	 * Generates ON DUPLICATE KEY UPDATE clause to be used in migration.
	 *
	 * @param array $columns List of column names.
	 *
	 * @return string SQL clause for INSERT...ON DUPLICATE KEY UPDATE
	 */
	public function generate_on_duplicate_statement_clause( array $columns ): string {
		$update_value_statements = array();
		foreach ( $columns as $column ) {
			$update_value_statements[] = "`$column` = VALUES( `$column` )";
		}
		$update_value_clause = implode( ', ', $update_value_statements );

		return "ON DUPLICATE KEY UPDATE $update_value_clause";
	}

	/**
	 * Hybrid of $wpdb->update and $wpdb->insert. It will try to update a row, and if it doesn't exist, it will insert it. This needs unique constraints to be set on the table on all ID columns.
	 *
	 * You can use this function only when:
	 * 1. There is only one unique constraint on the table. The constraint can contain multiple columns, but it must be the only one unique constraint.
	 * 2. The complete unique constraint must be part of the $data array.
	 * 3. You do not need the LAST_INSERT_ID() value.
	 *
	 * @param string $table_name Table name.
	 * @param array  $data Unescaped data to update (in column => value pairs).
	 * @param array  $format An array of formats to be mapped to each of the values in $data.
	 *
	 * @return int Returns the value of DB's  ON DUPLICATE KEY UPDATE clause.
	 */
	public function insert_on_duplicate_key_update( $table_name, $data, $format ): int {
		global $wpdb;
		if ( empty( $data ) ) {
			return 0;
		}

		$columns      = array_keys( $data );
		$value_format = array();
		$values       = array();
		$index        = 0;
		// Directly use NULL for placeholder if the value is NULL, since otherwise $wpdb->prepare will convert it to empty string.
		foreach ( $data as $key => $value ) {
			if ( is_null( $value ) ) {
				$value_format[] = 'NULL';
			} else {
				$values[]       = $value;
				$value_format[] = $format[ $index ];
			}
			++$index;
		}
		$column_clause       = '`' . implode( '`, `', $columns ) . '`';
		$value_format_clause = implode( ', ', $value_format );
		$on_duplicate_clause = $this->generate_on_duplicate_statement_clause( $columns );
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Values are escaped in $wpdb->prepare.
		$sql = $wpdb->prepare(
			"
INSERT INTO $table_name ( $column_clause )
VALUES ( $value_format_clause )
$on_duplicate_clause
",
			$values
		);
		// phpcs:enable
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is prepared.
		return $wpdb->query( $sql );
	}

	/**
	 * Hybrid of $wpdb->update and $wpdb->insert. It will try to update a row, and if it doesn't exist, it will insert it. Unlike `insert_on_duplicate_key_update` it does not require a unique constraint, but also does not guarantee uniqueness on its own.
	 *
	 * When a unique constraint is present, it will perform better than the `insert_on_duplicate_key_update` since it needs fewer locks.
	 *
	 * Note that it will only update at max just 1 database row, unlike `wpdb->update` which updates everything that matches the `$where` criteria. This is also why it needs a primary_key_column.
	 *
	 * @param string $table_name Table Name.
	 * @param array  $data Data to insert update in array($column_name => $value) format.
	 * @param array  $where Update conditions in array($column_name => $value) format. Conditions will be joined by AND.
	 * @param array  $format Format strings for data. Unlike $wpdb->update/insert, this method won't guess the format, and has to be provided explicitly.
	 * @param array  $where_format Format strings for where conditions. Unlike $wpdb->update/insert, this method won't guess the format, and has to be provided explicitly.
	 * @param string $primary_key_column Name of the Primary key column.
	 * @param string $primary_key_format Format for primary key.
	 *
	 * @return bool|int Number of rows affected. Boolean false on error.
	 */
	public function insert_or_update( $table_name, $data, $where, $format, $where_format, $primary_key_column = 'id', $primary_key_format = '%d' ) {
		global $wpdb;
		if ( empty( $data ) || empty( $where ) ) {
			return 0;
		}

		// Build select query.
		$values     = array();
		$index      = 0;
		$conditions = array();
		foreach ( $where as $column => $value ) {
			if ( is_null( $value ) ) {
				$conditions[] = "`$column` IS NULL";
				continue;
			}
			$conditions[] = "`$column` = " . $where_format[ $index ];
			$values[]     = $value;
			++$index;
		}

		$conditions = implode( ' AND ', $conditions );
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $primary_key_column and $table_name are hardcoded. $conditions is being prepared.
		$query = $wpdb->prepare( "SELECT `$primary_key_column` FROM `$table_name` WHERE $conditions LIMIT 1", $values );

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $query is prepared above.
		$row_id = $wpdb->get_var( $query );

		if ( $row_id ) {
			// Update the row.
			$result = $wpdb->update( $table_name, $data, array( $primary_key_column => $row_id ), $format, array( $primary_key_format ) );
		} else {
			// Insert the row.
			$result = $wpdb->insert( $table_name, $data, $format );
		}

		return $result;
	}

	/**
	 * Get max index length.
	 *
	 * @return int Max index length.
	 */
	public function get_max_index_length(): int {
		/**
		 * Filters the maximum index length in the database.
		 *
		 * Indexes have a maximum size of 767 bytes. Historically, we haven't need to be concerned about that.
		 * As of WP 4.2, however, they moved to utf8mb4, which uses 4 bytes per character. This means that an index which
		 * used to have room for floor(767/3) = 255 characters, now only has room for floor(767/4) = 191 characters.
		 *
		 * Additionally, MyISAM engine also limits the index size to 1000 bytes. We add this filter so that interested folks on InnoDB engine can increase the size till allowed 3071 bytes.
		 *
		 * @param int $max_index_length Maximum index length. Default 191.
		 *
		 * @since 8.0.0
		 */
		$max_index_length = apply_filters( 'woocommerce_database_max_index_length', 191 );
		// Index length cannot be more than 768, which is 3078 bytes in utf8mb4 and max allowed by InnoDB engine.
		return min( absint( $max_index_length ), 767 );
	}

	/**
	 * Create a fulltext index on order address table.
	 *
	 * @return void
	 */
	public function create_fts_index_order_address_table(): void {
		global $wpdb;
		$address_table = $wpdb->prefix . 'wc_order_addresses';
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $address_table is hardcoded.
		$wpdb->query( "CREATE FULLTEXT INDEX order_addresses_fts ON $address_table (first_name, last_name, company, address_1, address_2, city, state, postcode, country, email, phone)" );
	}

	/**
	 * Helper method to drop the fulltext index on order address table.
	 *
	 * @since 9.4.0
	 *
	 * @return void
	 */
	public function drop_fts_index_order_address_table(): void {
		global $wpdb;
		$address_table = $wpdb->prefix . 'wc_order_addresses';
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $address_table is hardcoded.
		$wpdb->query( "ALTER TABLE $address_table DROP INDEX order_addresses_fts;" );
	}

	/**
	 * Sanitize FTS Search params to remove relevancy operators for performance, and add partial matches. Useful when the sorting is already happening based on some other conditions, so relevancy calculation is not needed.
	 *
	 * @since 9.4.0
	 *
	 * @param string $param Search term.
	 *
	 * @return string Sanitized search term.
	 */
	public function sanitise_boolean_fts_search_term( string $param ): string {
		// Remove any operator to prevent incorrect query and fatals, such as search starting with `++`. We can allow this in the future if we have proper validation for FTS search operators.
		// Space is allowed to provide multiple words.
		$sanitized_param = preg_replace( '/[^\p{L}\p{N}_]+/u', ' ', $param );
		if ( $sanitized_param !== $param ) {
			$param = str_replace( '"', '', $param );
			return '"' . $param . '"';
		}
		// Split the search phrase into words so that we can add operators when needed.
		$words           = explode( ' ', $param );
		$sanitized_words = array();
		foreach ( $words as $word ) {
			// Add `*` as suffix to every term so that partial matches happens.
			$word              = $word . '*';
			$sanitized_words[] = $word;
		}
		return implode( ' ', $sanitized_words );
	}

	/**
	 * Check if fulltext index with key `order_addresses_fts` on order address table exists.
	 *
	 * @return bool
	 */
	public function fts_index_on_order_address_table_exists(): bool {
		global $wpdb;
		$address_table = $wpdb->prefix . 'wc_order_addresses';
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $address_table is hardcoded.
		return ! empty( $wpdb->get_results( "SHOW INDEX FROM $address_table WHERE Key_name = 'order_addresses_fts'" ) );
	}

	/**
	 * Create a fulltext index on order item table.
	 *
	 * @return void
	 */
	public function create_fts_index_order_item_table(): void {
		global $wpdb;
		$order_item_table = $wpdb->prefix . 'woocommerce_order_items';
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_item_table is hardcoded.
		$wpdb->query( "CREATE FULLTEXT INDEX order_item_fts ON $order_item_table (order_item_name)" );
	}

	/**
	 * Check if fulltext index with key `order_item_fts` on order item table exists.
	 *
	 * @return bool
	 */
	public function fts_index_on_order_item_table_exists(): bool {
		global $wpdb;
		$order_item_table = $wpdb->prefix . 'woocommerce_order_items';
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_item_table is hardcoded.
		return ! empty( $wpdb->get_results( "SHOW INDEX FROM $order_item_table WHERE Key_name = 'order_item_fts'" ) );
	}
}
PK     [1]ަC  C    Utilities/ArrayUtil.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Utilities;

/**
 * A class of utilities for dealing with arrays.
 */
class ArrayUtil {

	/**
	 * Determines if the given array is a list.
	 *
	 * An array is considered a list if its keys consist of consecutive numbers from 0 to count($array)-1.
	 *
	 * Polyfill for array_is_list() in PHP 8.1.
	 *
	 * @param array $arr The array being evaluated.
	 *
	 * @return bool True if array is a list, false otherwise.
	 */
	public static function array_is_list( array $arr ): bool {
		if ( function_exists( 'array_is_list' ) ) {
			return array_is_list( $arr );
		}

		if ( ( array() === $arr ) || ( array_values( $arr ) === $arr ) ) {
			return true;
		}

		$next_key = -1;

		foreach ( $arr as $k => $v ) {
			if ( ++$next_key !== $k ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Merge two lists of associative arrays by a key.
	 *
	 * @param array  $arr1 The first array.
	 * @param array  $arr2 The second array.
	 * @param string $key  The key to merge by.
	 *
	 * @return array The merged list sorted by the key values.
	 */
	public static function merge_by_key( array $arr1, array $arr2, string $key ): array {
		$merged = array();
		// Overwrite items in $arr1 with items in $arr2 if they have the same key entry value.
		// The rest of items in $arr1 will be appended.
		foreach ( $arr1 as $item1 ) {
			$found = false;
			foreach ( $arr2 as $item2 ) {
				if ( $item1[ $key ] === $item2[ $key ] ) {
					$merged[] = array_merge( $item1, $item2 );
					$found    = true;
					break;
				}
			}
			if ( ! $found ) {
				$merged[] = $item1;
			}
		}

		// Append items from $arr2 that are don't have a corresponding key entry value in $arr1.
		foreach ( $arr2 as $item2 ) {
			$found = false;
			foreach ( $arr1 as $item1 ) {
				if ( $item1[ $key ] === $item2[ $key ] ) {
					$found = true;
					break;
				}
			}
			if ( ! $found ) {
				$merged[] = $item2;
			}
		}

		// Sort the merged list by the key values.
		usort(
			$merged,
			function ( $a, $b ) use ( $key ) {
				return $a[ $key ] <=> $b[ $key ];
			}
		);

		return array_values( $merged );
	}

	/**
	 * Recursively filters null values from an array.
	 *
	 * This method removes all null values from the array, including nested arrays.
	 * Array keys are preserved for associative arrays. For lists (sequential numeric
	 * keys starting from 0), the array is reindexed to maintain the list structure.
	 *
	 * @param array $arr The array to filter.
	 *
	 * @return array The filtered array with null values removed.
	 */
	public static function filter_null_values_recursive( array $arr ): array {
		$is_list  = self::array_is_list( $arr );
		$filtered = array();

		foreach ( $arr as $key => $value ) {
			// Skip null values.
			if ( is_null( $value ) ) {
				continue;
			}

			// Recursively filter nested arrays.
			if ( is_array( $value ) ) {
				$filtered[ $key ] = self::filter_null_values_recursive( $value );
			} else {
				$filtered[ $key ] = $value;
			}
		}

		// Reindex if the original array was a list.
		return $is_list ? array_values( $filtered ) : $filtered;
	}
}
PK     [1]W"o	  o	    Utilities/BlocksUtil.phpnu         <?php
declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\Utilities;

/**
 * Helper functions for working with blocks.
 */
class BlocksUtil {

	/**
	 * Return blocks with their inner blocks flattened.
	 *
	 * @param array $blocks Array of blocks as returned by parse_blocks().
	 * @return array All blocks.
	 */
	public static function flatten_blocks( $blocks ) {
		return array_reduce(
			$blocks,
			function ( $carry, $block ) {
				array_push( $carry, array_diff_key( $block, array_flip( array( 'innerBlocks' ) ) ) );
				if ( isset( $block['innerBlocks'] ) ) {
					$inner_blocks = self::flatten_blocks( $block['innerBlocks'] );
					return array_merge( $carry, $inner_blocks );
				}

				return $carry;
			},
			array()
		);
	}

	/**
	 * Get all instances of the specified block from the widget area.
	 *
	 * @param string $block_name The name (id) of a block, e.g. `woocommerce/mini-cart`.
	 * @return array Array of blocks as returned by parse_blocks().
	 */
	public static function get_blocks_from_widget_area( $block_name ) {
		$blocks = get_option( 'widget_block' );

		if ( ! is_array( $blocks ) || empty( $blocks ) ) {
			return array();
		}

		return array_reduce(
			$blocks,
			function ( $acc, $block ) use ( $block_name ) {
				$parsed_blocks = ! empty( $block['content'] ) ? parse_blocks( $block['content'] ) : array();
				if ( ! empty( $parsed_blocks ) && $block_name === $parsed_blocks[0]['blockName'] ) {
					array_push( $acc, $parsed_blocks[0] );
				}
				return $acc;
			},
			array()
		);
	}

	/**
	 * Get all instances of the specified block on a specific template part.
	 *
	 * @param string $block_name The name (id) of a block, e.g. `woocommerce/mini-cart`.
	 * @param string $template_part_slug The woo page to search, e.g. `header`.
	 * @return array Array of blocks as returned by parse_blocks().
	 */
	public static function get_block_from_template_part( $block_name, $template_part_slug ) {
		$template = get_block_template( get_stylesheet() . '//' . $template_part_slug, 'wp_template_part' );

		if ( ! $template || null === $template->content ) {
			return array();
		}

		$blocks = parse_blocks( $template->content );

		$flatten_blocks = self::flatten_blocks( $blocks );

		return array_values(
			array_filter(
				$flatten_blocks,
				function ( $block ) use ( $block_name ) {
					return ( $block_name === $block['blockName'] );
				}
			)
		);
	}
}
PK     [1]c  c    Utilities/HtmlSanitizer.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Utilities;

/**
 * Utility for re-using WP Kses-based sanitization rules.
 */
class HtmlSanitizer {
	/**
	 * Rules for allowing minimal HTML (breaks, images, paragraphs and spans) without any links.
	 */
	public const LOW_HTML_BALANCED_TAGS_NO_LINKS = array(
		'pre_processors'  => array(
			'stripslashes',
			'force_balance_tags',
		),
		'wp_kses_rules'   => array(
			'br'   => true,
			'img'  => array(
				'alt'   => true,
				'class' => true,
				'src'   => true,
				'title' => true,
			),
			'p'    => array(
				'class' => true,
			),
			'span' => array(
				'class' => true,
				'title' => true,
			),
		),
	);

	/**
	 * Sanitizes a chunk of HTML, by following the same rules as `wp_kses_post()` but also allowing
	 * the style element to be supplied.
	 *
	 * @param string $html The HTML to be sanitized.
	 *
	 * @return string
	 */
	public function styled_post_content( string $html ): string {
		$rules          = wp_kses_allowed_html( 'post' );
		$rules['style'] = true;
		return wp_kses( $html, $rules );
	}

	/**
	 * Sanitizes the HTML according to the provided rules.
	 *
	 * @see wp_kses()
	 *
	 * @param string $html HTML string to be sanitized.
	 * @param array  $sanitizer_rules {
	 *     Optional and defaults to self::TRIMMED_BALANCED_LOW_HTML_NO_LINKS. Otherwise, one or more of the following
	 *     keys should be set.
	 *
	 *     @type array $pre_processors  Callbacks to run before invoking `wp_kses()`.
	 *     @type array $wp_kses_rules   Element names and attributes to allow, per `wp_kses()`.
	 * }
	 *
	 * @return string
	 */
	public function sanitize( string $html, array $sanitizer_rules = self::LOW_HTML_BALANCED_TAGS_NO_LINKS ): string {
		if ( isset( $sanitizer_rules['pre_processors'] ) && is_array( $sanitizer_rules['pre_processors'] ) ) {
			$html = $this->apply_string_callbacks( $sanitizer_rules['pre_processors'], $html );
		}

		// If no KSES rules are specified, assume all HTML should be stripped.
		$kses_rules = isset( $sanitizer_rules['wp_kses_rules'] ) && is_array( $sanitizer_rules['wp_kses_rules'] )
			? $sanitizer_rules['wp_kses_rules']
			: array();

		return wp_kses( $html, $kses_rules );
	}

	/**
	 * Applies callbacks used to process the string before and after wp_kses().
	 *
	 * If a callback is invalid we will short-circuit and return an empty string, on the grounds that it is better to
	 * output nothing than risky HTML. We also call the problem out via _doing_it_wrong() to highlight the problem (and
	 * increase the chances of this being caught during development).
	 *
	 * @param callable[] $callbacks The callbacks used to mutate the string.
	 * @param string     $string    The string being processed.
	 *
	 * @return string
	 */
	private function apply_string_callbacks( array $callbacks, string $string ): string {
		foreach ( $callbacks as $callback ) {
			if ( ! is_callable( $callback ) ) {
				_doing_it_wrong( __CLASS__ . '::apply', esc_html__( 'String processors must be an array of valid callbacks.', 'woocommerce' ), esc_html( WC()->version ) );
				return '';
			}

			$string = (string) $callback( $string );
		}

		return $string;
	}
}
PK     [1]^&  &    Utilities/COTMigrationUtil.phpnu         <?php
/**
 * Utility functions meant for helping in migration from posts tables to custom order tables.
 */

namespace Automattic\WooCommerce\Internal\Utilities;

use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\{ DataSynchronizer, OrdersTableDataStore };
use WC_Order;
use WP_Post;

/**
 * Utility functions meant for helping in migration from posts tables to custom order tables.
 */
class COTMigrationUtil {

	/**
	 * Custom order table controller.
	 *
	 * @var CustomOrdersTableController
	 */
	private $table_controller;

	/**
	 * Data synchronizer.
	 *
	 * @var DataSynchronizer
	 */
	private $data_synchronizer;

	/**
	 * Initialize method, invoked by the DI container.
	 *
	 * @internal Automatically called by the container.
	 * @param CustomOrdersTableController $table_controller Custom order table controller.
	 * @param DataSynchronizer            $data_synchronizer Data synchronizer.
	 *
	 * @return void
	 */
	final public function init( CustomOrdersTableController $table_controller, DataSynchronizer $data_synchronizer ) {
		$this->table_controller  = $table_controller;
		$this->data_synchronizer = $data_synchronizer;
	}

	/**
	 * Helper function to get screen name of orders page in wp-admin.
	 *
	 * @throws \Exception If called from outside of wp-admin.
	 *
	 * @return string
	 */
	public function get_order_admin_screen() : string {
		if ( ! is_admin() ) {
			throw new \Exception( 'This function should only be called in admin.' );
		}
		return $this->custom_orders_table_usage_is_enabled() && function_exists( 'wc_get_page_screen_id' )
			? wc_get_page_screen_id( 'shop-order' )
			: 'shop_order';
	}

	/**
	 * Helper function to get whether custom order tables are enabled or not.
	 *
	 * @return bool
	 */
	private function custom_orders_table_usage_is_enabled() : bool {
		return $this->table_controller->custom_orders_table_usage_is_enabled();
	}

	/**
	 * Checks if posts and order custom table sync is enabled and there are no pending orders.
	 *
	 * @return bool
	 */
	public function is_custom_order_tables_in_sync() : bool {
		if ( ! $this->data_synchronizer->data_sync_is_enabled() ) {
			return false;
		}

		return ! $this->data_synchronizer->has_orders_pending_sync();
	}

	/**
	 * Gets value of a meta key from WC_Data object if passed, otherwise from the post object.
	 * This helper function support backward compatibility for meta box functions, when moving from posts based store to custom tables.
	 *
	 * @param WP_Post|null  $post Post object, meta will be fetched from this only when `$data` is not passed.
	 * @param \WC_Data|null $data WC_Data object, will be preferred over post object when passed.
	 * @param string        $key Key to fetch metadata for.
	 * @param bool          $single Whether metadata is single.
	 *
	 * @return array|mixed|string Value of the meta key.
	 */
	public function get_post_or_object_meta( ?WP_Post $post, ?\WC_Data $data, string $key, bool $single ) {
		if ( isset( $data ) ) {
			if ( method_exists( $data, "get$key" ) ) {
				return $data->{"get$key"}();
			}
			return $data->get_meta( $key, $single );
		} else {
			return isset( $post->ID ) ? get_post_meta( $post->ID, $key, $single ) : false;
		}
	}

	/**
	 * Helper function to initialize the global $theorder object, mostly used during order meta boxes rendering.
	 *
	 * @param WC_Order|WP_Post $post_or_order_object Post or order object.
	 *
	 * @return bool|WC_Order|WC_Order_Refund WC_Order object.
	 */
	public function init_theorder_object( $post_or_order_object ) {
		global $theorder;
		if ( $theorder instanceof WC_Order ) {
			return $theorder;
		}

		if ( $post_or_order_object instanceof WC_Order ) {
			$theorder = $post_or_order_object;
		} else {
			$theorder = wc_get_order( $post_or_order_object->ID );
		}
		return $theorder;
	}

	/**
	 * Helper function to get ID from a post or order object.
	 *
	 * @param WP_Post/WC_Order $post_or_order_object WP_Post/WC_Order object to get ID for.
	 *
	 * @return int Order or post ID.
	 */
	public function get_post_or_order_id( $post_or_order_object ) : int {
		if ( is_numeric( $post_or_order_object ) ) {
			return (int) $post_or_order_object;
		} elseif ( $post_or_order_object instanceof WC_Order ) {
			return $post_or_order_object->get_id();
		} elseif ( $post_or_order_object instanceof WP_Post ) {
			return $post_or_order_object->ID;
		}
		return 0;
	}

	/**
	 * Checks if passed id, post or order object is a WC_Order object.
	 *
	 * @param int|WP_Post|WC_Order $order_id Order ID, post object or order object.
	 * @param string[]             $types    Types to match against.
	 *
	 * @return bool Whether the passed param is an order.
	 */
	public function is_order( $order_id, array $types = array( 'shop_order' ) ) : bool {
		$order_id         = $this->get_post_or_order_id( $order_id );
		$order_data_store = \WC_Data_Store::load( 'order' );
		return in_array( $order_data_store->get_order_type( $order_id ), $types, true );
	}

	/**
	 * Returns type pf passed id, post or order object.
	 *
	 * @param int|WP_Post|WC_Order $order_id Order ID, post object or order object.
	 *
	 * @return string|null Type of the order.
	 */
	public function get_order_type( $order_id ) {
		$order_id         = $this->get_post_or_order_id( $order_id );
		$order_data_store = \WC_Data_Store::load( 'order' );
		return $order_data_store->get_order_type( $order_id );
	}

	/**
	 * Get the name of the database table that's currently in use for orders.
	 *
	 * @return string
	 */
	public function get_table_for_orders() {
		if ( $this->custom_orders_table_usage_is_enabled() ) {
			$table_name = OrdersTableDataStore::get_orders_table_name();
		} else {
			global $wpdb;
			$table_name = $wpdb->posts;
		}

		return $table_name;
	}

	/**
	 * Get the name of the database table that's currently in use for orders.
	 *
	 * @return string
	 */
	public function get_table_for_order_meta() {
		if ( $this->custom_orders_table_usage_is_enabled() ) {
			$table_name = OrdersTableDataStore::get_meta_table_name();
		} else {
			global $wpdb;
			$table_name = $wpdb->postmeta;
		}

		return $table_name;
	}
}
PK     [1]ZD      Utilities/ProductUtil.phpnu         <?php

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Utilities;

/**
 * Class with general utility methods related to products.
 */
class ProductUtil {
	/**
	 * Delete the transients related to a specific product.
	 * If the product is a variation, delete the transients for the parent too.
	 *
	 * @param WC_Product|int $product_or_id The product or the product id.
	 * @return void
	 */
	public function delete_product_specific_transients( $product_or_id ) {
		$parent_id = 0;
		if ( $product_or_id instanceof \WC_Product ) {
			$product    = $product_or_id;
			$product_id = $product->get_id();
		} else {
			$product_id = $product_or_id;
			$product    = wc_get_product( $product_id );
		}

		if ( $product instanceof \WC_Product_Variation ) {
			$parent_id = $product->get_parent_id();
		}

		$product_specific_transient_names = array(
			'wc_product_children_',
			'wc_var_prices_',
			'wc_related_',
			'wc_child_has_weight_',
			'wc_child_has_dimensions_',
		);

		foreach ( $product_specific_transient_names as $transient ) {
			delete_transient( $transient . $product_id );
			if ( $parent_id ) {
				delete_transient( $transient . $parent_id );
			}
		}
	}
}
PK     [1]Йy  y    Utilities/WebhookUtil.phpnu         <?php
/**
 * WebhookUtil class file.
 */

namespace Automattic\WooCommerce\Internal\Utilities;

use WC_Cache_Helper;

/**
 * Class with utility methods for dealing with webhooks.
 */
class WebhookUtil {

	/**
	 * Creates a new instance of the class.
	 */
	public function __construct() {
		add_action( 'deleted_user', array( $this, 'reassign_webhooks_to_new_user_id' ), 10, 2 );
		add_action( 'delete_user_form', array( $this, 'maybe_render_user_with_webhooks_warning' ), 10, 2 );
	}

	/**
	 * Whenever a user is deleted, re-assign their webhooks to the new user.
	 *
	 * If re-assignment isn't selected during deletion, assign the webhooks to user_id 0,
	 * so that an admin can edit and re-save them in order to get them to be assigned to a valid user.
	 *
	 * @param int      $old_user_id ID of the deleted user.
	 * @param int|null $new_user_id ID of the user to reassign existing data to, or null if no re-assignment is requested.
	 *
	 * @return void
	 * @since 7.8.0
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function reassign_webhooks_to_new_user_id( int $old_user_id, ?int $new_user_id ): void {
		$webhook_ids = $this->get_webhook_ids_for_user( $old_user_id );

		foreach ( $webhook_ids as $webhook_id ) {
			$webhook = new \WC_Webhook( $webhook_id );
			$webhook->set_user_id( $new_user_id ?? 0 );
			$webhook->save();
		}
	}

	/**
	 * When users are about to be deleted show an informative text if they have webhooks assigned.
	 *
	 * @param \WP_User $current_user The current logged in user.
	 * @param array    $userids Array with the ids of the users that are about to be deleted.
	 * @return void
	 * @since 7.8.0
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function maybe_render_user_with_webhooks_warning( \WP_User $current_user, array $userids ): void {
		global $wpdb;

		$at_least_one_user_with_webhooks = false;

		foreach ( $userids as $user_id ) {
			$webhook_ids = $this->get_webhook_ids_for_user( $user_id );
			if ( empty( $webhook_ids ) ) {
				continue;
			}

			$at_least_one_user_with_webhooks = true;

			$user_data      = get_userdata( $user_id );
			$user_login     = false === $user_data ? '' : $user_data->user_login;
			$webhooks_count = count( $webhook_ids );

			$text = sprintf(
				/* translators: 1 = user id, 2 = user login, 3 = webhooks count */
				_nx(
					'User #%1$s %2$s has created %3$d WooCommerce webhook.',
					'User #%1$s %2$s has created %3$d WooCommerce webhooks.',
					$webhooks_count,
					'user webhook count',
					'woocommerce'
				),
				$user_id,
				$user_login,
				$webhooks_count
			);

			echo '<p>' . esc_html( $text ) . '</p>';
		}

		if ( ! $at_least_one_user_with_webhooks ) {
			return;
		}

		$webhooks_settings_url = esc_url_raw( admin_url( 'admin.php?page=wc-settings&tab=advanced&section=webhooks' ) );

		// This block of code is copied from WordPress' users.php.
		// phpcs:disable WooCommerce.Commenting.CommentHooks, WordPress.DB.PreparedSQL.NotPrepared
		$users_have_content = (bool) apply_filters( 'users_have_additional_content', false, $userids );
		if ( ! $users_have_content ) {
			if ( $wpdb->get_var( "SELECT ID FROM {$wpdb->posts} WHERE post_author IN( " . implode( ',', $userids ) . ' ) LIMIT 1' ) ) {
				$users_have_content = true;
			} elseif ( $wpdb->get_var( "SELECT link_id FROM {$wpdb->links} WHERE link_owner IN( " . implode( ',', $userids ) . ' ) LIMIT 1' ) ) {
				$users_have_content = true;
			}
		}
		// phpcs:enable WooCommerce.Commenting.CommentHooks, WordPress.DB.PreparedSQL.NotPrepared

		if ( $users_have_content ) {
			$text = __( 'If the "Delete all content" option is selected, the affected WooCommerce webhooks will <b>not</b> be deleted and will be attributed to user id 0.<br/>', 'woocommerce' );
		} else {
			$text = __( 'The affected WooCommerce webhooks will <b>not</b> be deleted and will be attributed to user id 0.<br/>', 'woocommerce' );
		}

		$text .= sprintf(
			/* translators: 1 = url of the WooCommerce webhooks settings page */
			__( 'After that they can be reassigned to the logged-in user by going to the <a href="%1$s">WooCommerce webhooks settings page</a> and re-saving them.', 'woocommerce' ),
			$webhooks_settings_url
		);

		echo '<p>' . wp_kses_post( $text ) . '</p>';
	}

	/**
	 * Get the ids of the webhooks assigned to a given user.
	 *
	 * @param int $user_id User id.
	 * @return int[] Array of webhook ids.
	 */
	private function get_webhook_ids_for_user( int $user_id ): array {
		$data_store = \WC_Data_Store::load( 'webhook' );
		return $data_store->search_webhooks(
			array(
				'user_id' => $user_id,
			)
		);
	}

	/**
	 * Gets the count of webhooks that are configured to use the Legacy REST API to compose their payloads.
	 *
	 * @param bool $clear_cache If true, the previously cached value of the count will be discarded if it exists.
	 *
	 * @return int
	 */
	public function get_legacy_webhooks_count( bool $clear_cache = false ): int {
		global $wpdb;

		$cache_key = WC_Cache_Helper::get_cache_prefix( 'webhooks' ) . 'legacy_count';
		if ( $clear_cache ) {
			wp_cache_delete( $cache_key, 'webhooks' );
		}

		$count = wp_cache_get( $cache_key, 'webhooks' );

		if ( false === $count ) {
			$count = absint( $wpdb->get_var( "SELECT count( webhook_id ) FROM {$wpdb->prefix}wc_webhooks WHERE `api_version` < 1;" ) );
			wp_cache_add( $cache_key, $count, 'webhooks' );
		}

		return $count;
	}
}
PK     [1]u&P!  P!    OrderCouponDataMigrator.phpnu         <?php

namespace Automattic\WooCommerce\Internal;

use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessorInterface;
use Automattic\WooCommerce\Utilities\StringUtil;
use \Exception;

/**
 * This class is intended to be used with BatchProcessingController and converts verbose
 * 'coupon_data' metadata entries in coupon line items (corresponding to coupons applied to orders)
 * into simplified 'coupon_info' entries. See WC_Coupon::get_short_info.
 *
 * Additionally, this class manages the "Convert order coupon data" tool.
 */
class OrderCouponDataMigrator implements BatchProcessorInterface, RegisterHooksInterface {

	/**
	 * Register hooks for the class.
	 */
	public function register() {
		add_filter( 'woocommerce_debug_tools', array( $this, 'handle_woocommerce_debug_tools' ), 999, 1 );
	}

	/**
	 * Get a user-friendly name for this processor.
	 *
	 * @return string Name of the processor.
	 */
	public function get_name(): string {
		return "Coupon line item 'coupon_data' to 'coupon_info' metadata migrator";
	}

	/**
	 * Get a user-friendly description for this processor.
	 *
	 * @return string Description of what this processor does.
	 */
	public function get_description(): string {
		return "Migrates verbose metadata about coupons applied to an order ('coupon_data' metadata key in coupon line items) to simplified metadata ('coupon_info' keys)";
	}

	/**
	 * Get the total number of pending items that require processing.
	 *
	 * @return int Number of items pending processing.
	 */
	public function get_total_pending_count(): int {
		global $wpdb;

		return $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$wpdb->prefix}woocommerce_order_itemmeta WHERE meta_key=%s",
				'coupon_data'
			)
		);
	}

	/**
	 * Returns the next batch of items that need to be processed.
	 * A batch in this context is a list of 'meta_id' values from the wp_woocommerce_order_itemmeta table.
	 *
	 * @param int $size Maximum size of the batch to be returned.
	 *
	 * @return array Batch of items to process, containing $size or less items.
	 */
	public function get_next_batch_to_process( int $size ): array {
		global $wpdb;

		$meta_ids = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT meta_id FROM {$wpdb->prefix}woocommerce_order_itemmeta WHERE meta_key=%s ORDER BY meta_id ASC LIMIT %d",
				'coupon_data',
				$size
			)
		);

		return array_map( 'absint', $meta_ids );
	}

	/**
	 * Process data for the supplied batch. See the convert_item method.
	 *
	 * @throw \Exception Something went wrong while processing the batch.
	 *
	 * @param array $batch Batch to process, as returned by 'get_next_batch_to_process'.
	 */
	public function process_batch( array $batch ): void {
		global $wpdb;

		if ( empty( $batch ) ) {
			return;
		}

		$meta_ids = StringUtil::to_sql_list( $batch );

		$meta_ids_and_values = $wpdb->get_results(
			//phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			"SELECT meta_id,meta_value FROM {$wpdb->prefix}woocommerce_order_itemmeta WHERE meta_id IN $meta_ids",
			ARRAY_N
		);

		foreach ( $meta_ids_and_values as $meta_id_and_value ) {
			try {
				$this->convert_item( (int) $meta_id_and_value[0], $meta_id_and_value[1] );
			} catch ( Exception $ex ) {
				wc_get_logger()->error( StringUtil::class_name_without_namespace( self::class ) . ": when converting meta row with id {$meta_id_and_value[0]}: {$ex->getMessage()}" );
			}
		}
	}

	/**
	 * Convert one verbose 'coupon_data' entry into a simplified 'coupon_info' entry.
	 *
	 * The existing database row is updated in place, both the 'meta_key' and the 'meta_value' columns.
	 *
	 * @param int    $meta_id Value of 'meta_id' of the row being converted.
	 * @param string $meta_value Value of 'meta_value' of the row being converted.
	 * @throws Exception Database error.
	 */
	private function convert_item( int $meta_id, string $meta_value ) {
		global $wpdb;

		//phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
		$coupon_data = unserialize( $meta_value );

		$temp_coupon = new \WC_Coupon();
		$temp_coupon->set_props( $coupon_data );

		//phpcs:disable WordPress.DB.SlowDBQuery
		$wpdb->update(
			"{$wpdb->prefix}woocommerce_order_itemmeta",
			array(
				'meta_key'   => 'coupon_info',
				'meta_value' => $temp_coupon->get_short_info(),
			),
			array( 'meta_id' => $meta_id )
		);
		//phpcs:enable WordPress.DB.SlowDBQuery

		if ( $wpdb->last_error ) {
			throw new Exception( $wpdb->last_error );
		}
	}

	/**
	 * Default (preferred) batch size to pass to 'get_next_batch_to_process'.
	 *
	 * @return int Default batch size.
	 */
	public function get_default_batch_size(): int {
		return 1000;
	}

	/**
	 * Add the tool to start or stop the background process that converts order coupon metadata entries.
	 *
	 * @param array $tools Old tools array.
	 * @return array Updated tools array.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function handle_woocommerce_debug_tools( array $tools ): array {
		$batch_processor = wc_get_container()->get( BatchProcessingController::class );
		$pending_count   = $this->get_total_pending_count();

		if ( 0 === $pending_count ) {
			$tools['start_convert_order_coupon_data'] = array(
				'name'     => __( 'Start converting order coupon data to the simplified format', 'woocommerce' ),
				'button'   => __( 'Start converting', 'woocommerce' ),
				'disabled' => true,
				'desc'     => __( 'This will convert <code>coupon_data</code> order item meta entries to simplified <code>coupon_info</code> entries. The conversion will happen overtime in the background (via Action Scheduler). There are currently no entries to convert.', 'woocommerce' ),
			);
		} elseif ( $batch_processor->is_enqueued( self::class ) ) {
			$tools['stop_convert_order_coupon_data'] = array(
				'name'     => __( 'Stop converting order coupon data to the simplified format', 'woocommerce' ),
				'button'   => __( 'Stop converting', 'woocommerce' ),
				'desc'     =>
					/* translators: %d=count of entries pending conversion */
					sprintf( __( 'This will stop the background process that converts <code>coupon_data</code> order item meta entries to simplified <code>coupon_info</code> entries. There are currently %d entries that can be converted.', 'woocommerce' ), $pending_count ),
				'callback' => array( $this, 'dequeue' ),
			);
		} else {
			$tools['start_converting_order_coupon_data'] = array(
				'name'     => __( 'Convert order coupon data to the simplified format', 'woocommerce' ),
				'button'   => __( 'Start converting', 'woocommerce' ),
				'desc'     =>
					/* translators: %d=count of entries pending conversion */
					sprintf( __( 'This will convert <code>coupon_data</code> order item meta entries to simplified <code>coupon_info</code> entries. The conversion will happen overtime in the background (via Action Scheduler). There are currently %d entries that can be converted.', 'woocommerce' ), $pending_count ),
				'callback' => array( $this, 'enqueue' ),
			);
		}

		return $tools;
	}

	/**
	 * Start the background process for coupon data conversion.
	 *
	 * @return string Informative string to show after the tool is triggered in UI.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function enqueue(): string {
		$batch_processor = wc_get_container()->get( BatchProcessingController::class );
		if ( $batch_processor->is_enqueued( self::class ) ) {
			return __( 'Background process for coupon meta conversion already started, nothing done.', 'woocommerce' );
		}

		$batch_processor->enqueue_processor( self::class );
		return __( 'Background process for coupon meta conversion started', 'woocommerce' );
	}

	/**
	 * Stop the background process for coupon data conversion.
	 *
	 * @return string Informative string to show after the tool is triggered in UI.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function dequeue(): string {
		$batch_processor = wc_get_container()->get( BatchProcessingController::class );
		if ( ! $batch_processor->is_enqueued( self::class ) ) {
			return __( 'Background process for coupon meta conversion not started, nothing done.', 'woocommerce' );
		}

		$batch_processor->remove_processor( self::class );
		return __( 'Background process for coupon meta conversion stopped', 'woocommerce' );
	}
}
PK     [1]jMr0  0  +  FraudProtection/SessionClearanceManager.phpnu         <?php
/**
 * SessionClearanceManager class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\FraudProtection;

defined( 'ABSPATH' ) || exit;

/**
 * Manages session clearance state for fraud protection.
 *
 * This class handles the session status tracking for fraud protection decisions,
 * managing three possible states: pending, allowed, and blocked. It integrates
 * with WooCommerce sessions and uses the FraudProtectionController logging helper
 * to maintain consistent audit logs.
 *
 * @since 10.5.0
 * @internal This class is part of the internal API and is subject to change without notice.
 */
class SessionClearanceManager {

	/**
	 * Session key for storing clearance status.
	 */
	private const SESSION_KEY = '_fraud_protection_clearance_status';

	/**
	 * Session status: pending clearance.
	 */
	public const STATUS_PENDING = 'pending';

	/**
	 * Session status: allowed.
	 */
	public const STATUS_ALLOWED = 'allowed';

	/**
	 * Session status: blocked.
	 */
	public const STATUS_BLOCKED = 'blocked';

	/**
	 * Default session status.
	 */
	public const DEFAULT_STATUS = self::STATUS_ALLOWED;

	/**
	 * Check if the current session is allowed.
	 *
	 * @return bool True if session is allowed, false otherwise.
	 */
	public function is_session_allowed(): bool {
		$status = $this->get_session_status();
		return self::STATUS_ALLOWED === $status;
	}

	/**
	 * Check if the current session is blocked.
	 *
	 * @return bool True if session is blocked, false otherwise.
	 */
	public function is_session_blocked(): bool {
		$status = $this->get_session_status();
		return self::STATUS_BLOCKED === $status;
	}

	/**
	 * Mark the current session as allowed.
	 *
	 * @return void
	 */
	public function allow_session(): void {
		$this->set_session_status( self::STATUS_ALLOWED );
		$this->log_session_update_event( 'allowed' );
	}

	/**
	 * Mark the current session as pending (challenge required).
	 *
	 * @return void
	 */
	public function challenge_session(): void {
		$this->set_session_status( self::STATUS_PENDING );
		$this->log_session_update_event( 'challenged' );
	}

	/**
	 * Mark the current session as blocked.
	 *
	 * @return void
	 */
	public function block_session(): void {
		$this->set_session_status( self::STATUS_BLOCKED );
		$this->log_session_update_event( 'blocked' );
		$this->empty_cart();
	}

	/**
	 * Get the current session clearance status.
	 *
	 * @return string One of: pending, allowed, blocked.
	 */
	public function get_session_status(): string {
		if ( ! $this->is_session_available() ) {
			return self::DEFAULT_STATUS;
		}

		$status = WC()->session->get( self::SESSION_KEY, self::DEFAULT_STATUS );

		// Validate status value - return default for invalid values.
		if ( ! in_array( $status, array( self::STATUS_PENDING, self::STATUS_ALLOWED, self::STATUS_BLOCKED ), true ) ) {
			return self::DEFAULT_STATUS;
		}

		return $status;
	}

	/**
	 * Set the session clearance status.
	 *
	 * @param string $status One of: pending, allowed, blocked.
	 * @return void
	 */
	private function set_session_status( string $status ): void {
		if ( ! $this->is_session_available() ) {
			return;
		}

		WC()->session->set( self::SESSION_KEY, $status );

		// Ensure session cookie is set so the session persists across page loads.
		// This is important because fraud protection may set session status before
		// any cart action triggers the cookie to be set.
		// Skip cookie setting if headers have already been sent (e.g., in test environment).
		if ( WC()->session instanceof \WC_Session_Handler ) {
			WC()->session->set_customer_session_cookie( true );
		}
	}

	/**
	 * Reset the session clearance status to default (allowed).
	 *
	 * @return void
	 */
	public function reset_session(): void {
		$this->set_session_status( self::DEFAULT_STATUS );
	}

	/**
	 * Ensure cart and session are available.
	 *
	 * Loads cart if not already loaded, which initializes session for both
	 * traditional (cookie) and Store API (token) flows.
	 *
	 * @return void
	 */
	public function ensure_cart_loaded(): void {
		if ( ! did_action( 'woocommerce_load_cart_from_session' ) && function_exists( 'wc_load_cart' ) ) {
			WC()->call_function( 'wc_load_cart' );
		}
	}

	/**
	 * Check if WooCommerce session is available.
	 *
	 * @return bool True if session is available.
	 */
	private function is_session_available(): bool {
		$this->ensure_cart_loaded();
		return WC()->session instanceof \WC_Session;
	}

	/**
	 * Get a unique identifier for the current session.
	 *
	 * @return string Session identifier.
	 */
	public function get_session_id(): string {
		if ( ! $this->is_session_available() ) {
			return 'no-session';
		}

		// Use or generate a stable session ID for tracking consistency.
		$fraud_customer_session_id = WC()->session->get( '_fraud_protection_customer_session_id' );
		if ( ! $fraud_customer_session_id ) {
			$fraud_customer_session_id = WC()->call_function( 'wc_rand_hash', 'customer_', 30 );
			WC()->session->set( '_fraud_protection_customer_session_id', $fraud_customer_session_id );
		}
		return $fraud_customer_session_id;
	}

	/**
	 * Empty the cart.
	 *
	 * @return void
	 */
	private function empty_cart(): void {
		if ( function_exists( 'WC' ) && WC()->cart ) {
			WC()->cart->empty_cart();
		}
	}

	/**
	 * Log a session update event using FraudProtectionController's logging helper.
	 *
	 * @param string $action The action taken (allowed, challenged, or blocked).
	 * @return void
	 */
	private function log_session_update_event( string $action ): void {
		$session_id = $this->get_session_id();
		$user_id    = get_current_user_id();
		$user_info  = $user_id ? "User: {$user_id}" : 'User: guest';
		$timestamp  = current_time( 'mysql' );

		$message = sprintf(
			'Session updated: %s | %s | Action: %s | Timestamp: %s',
			$session_id,
			$user_info,
			$action,
			$timestamp
		);

		FraudProtectionController::log( 'info', $message );
	}
}
PK     [1]U  U  (  FraudProtection/SessionDataCollector.phpnu         <?php
/**
 * SessionDataCollector class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\FraudProtection;

defined( 'ABSPATH' ) || exit;

/**
 * Collects comprehensive session and order data for fraud protection analysis.
 *
 * This class provides manual data collection for fraud protection events, gathering
 * session, customer, order, address, and payment information in the exact nested format
 * required by the WPCOM fraud protection service. All data collection is designed to
 * degrade gracefully when fields are unavailable, ensuring checkout never fails due to
 * missing fraud protection data.
 *
 * @since 10.5.0
 * @internal This class is part of the internal API and is subject to change without notice.
 */
class SessionDataCollector {

	/**
	 * SessionClearanceManager instance.
	 *
	 * @var SessionClearanceManager
	 */
	private SessionClearanceManager $session_clearance_manager;

	/**
	 * Initialize with dependencies.
	 *
	 * @internal
	 *
	 * @param SessionClearanceManager $session_clearance_manager The session clearance manager instance.
	 */
	final public function init( SessionClearanceManager $session_clearance_manager ): void {
		$this->session_clearance_manager = $session_clearance_manager;
	}

	/**
	 * Collect comprehensive session and order data for fraud protection.
	 *
	 * This method is called manually at specific points in the checkout/payment flow
	 * to gather all relevant data for fraud analysis. It returns data in the nested
	 * format expected by the WPCOM fraud protection service.
	 *
	 * @since 10.5.0
	 *
	 * @param string|null $event_type Optional event type identifier (e.g., 'checkout_started', 'payment_attempt').
	 * @param array       $event_data Optional event-specific additional context data (may include 'order_id').
	 * @return array Nested array containing all collected fraud protection data.
	 */
	public function collect( ?string $event_type = null, array $event_data = array() ): array {
		// Ensure cart and session are loaded.
		$this->session_clearance_manager->ensure_cart_loaded();

		// Extract order ID from event_data if provided.
		// There seem to be no universal way to get order id from session data, so we may start with passing it as a parameter when calling this method.
		$order_id_from_event = $event_data['order_id'] ?? null;

		return array(
			'event_type'       => $event_type,
			'timestamp'        => gmdate( 'Y-m-d H:i:s' ),
			'wc_version'       => WC()->version,
			'session'          => $this->get_session_data(),
			'customer'         => $this->get_customer_data(),
			'order'            => $this->get_order_data( $order_id_from_event ),
			'shipping_address' => $this->get_shipping_address(),
			'billing_address'  => $this->get_billing_address(),
			'event_data'       => $event_data,
		);
	}

	/**
	 * Get current billing country from customer data.
	 *
	 * Reuses the same logic as get_billing_address() but returns only the country.
	 * Tries WC_Customer first, falls back to session data, with graceful error handling.
	 *
	 * @since 10.5.0
	 *
	 * @return string|null Current billing country code or null if unavailable.
	 */
	public function get_current_billing_country(): ?string {
		try {
			if ( WC()->customer instanceof \WC_Customer ) {
				$country = WC()->customer->get_billing_country();
				return ! empty( $country ) ? \sanitize_text_field( $country ) : null;
			} elseif ( WC()->session instanceof \WC_Session ) {
				$customer_data = WC()->session->get( 'customer' );
				if ( is_array( $customer_data ) && ! empty( $customer_data['country'] ) ) {
					return \sanitize_text_field( $customer_data['country'] );
				}
			}
		} catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Graceful degradation.
		}
		return null;
	}

	/**
	 * Get current shipping country from customer data.
	 *
	 * Reuses the same logic as get_shipping_address() but returns only the country.
	 * Tries WC_Customer first, falls back to session data, with graceful error handling.
	 *
	 * @since 10.5.0
	 *
	 * @return string|null Current shipping country code or null if unavailable.
	 */
	public function get_current_shipping_country(): ?string {
		try {
			if ( WC()->customer instanceof \WC_Customer ) {
				$country = WC()->customer->get_shipping_country();
				return ! empty( $country ) ? \sanitize_text_field( $country ) : null;
			} elseif ( WC()->session instanceof \WC_Session ) {
				$customer_data = WC()->session->get( 'customer' );
				if ( is_array( $customer_data ) && ! empty( $customer_data['shipping_country'] ) ) {
					return \sanitize_text_field( $customer_data['shipping_country'] );
				}
			}
		} catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Graceful degradation.
		}
		return null;
	}

	/**
	 * Get session data including session ID, IP address, email, and user agent.
	 *
	 * Collects session identification and tracking data with graceful degradation
	 * for unavailable fields. Email collection follows the fallback chain:
	 * logged-in user email → session customer data → WC_Customer billing email.
	 *
	 * @since 10.5.0
	 *
	 * @return array Session data array with 6 keys.
	 */
	private function get_session_data(): array {
		try {
			$session_id = $this->session_clearance_manager->get_session_id();
			$ip_address = $this->get_ip_address();
			$email      = $this->get_email();
			$user_agent = $this->get_user_agent();

			/**
			 * $is_user_session is flag that we have a real browser session vs API-based interaction.
			 * We start with a very basic check, but we might need a more sophisticated way to detect it in the future.
			 */
			$is_user_session = 'no-session' !== $session_id;

			return array(
				'session_id'      => $session_id,
				'ip_address'      => $ip_address,
				'email'           => $email,
				'ja3_hash'        => null,
				'user_agent'      => $user_agent,
				'is_user_session' => $is_user_session,
			);
		} catch ( \Exception $e ) {
			// Graceful degradation - return structure with null values.
			return array(
				'session_id'      => null,
				'ip_address'      => null,
				'email'           => null,
				'ja3_hash'        => null,
				'user_agent'      => null,
				'is_user_session' => false,
			);
		}
	}

	/**
	 * Get customer data including name, billing email, and order history.
	 *
	 * Collects customer identification and history data with graceful degradation.
	 * Tries WC_Customer object first, then falls back to session data if values are empty.
	 * Includes lifetime_order_count which counts all orders regardless of status.
	 *
	 * @since 10.5.0
	 *
	 * @return array Customer data array with 4 keys.
	 */
	private function get_customer_data(): array {
		$customer_data = array(
			'first_name'           => null,
			'last_name'            => null,
			'billing_email'        => null,
			'lifetime_order_count' => 0,
		);
		try {
			$lifetime_order_count = 0;

			// Try WC_Customer object first.
			if ( WC()->customer instanceof \WC_Customer ) {
				if ( WC()->customer->get_id() > 0 ) {
					// We need to reload the customer so it uses the correct data store to count the orders.
					$customer             = new \WC_Customer( WC()->customer->get_id() );
					$lifetime_order_count = $customer->get_order_count();
				}

				$customer_data = array_merge(
					$customer_data,
					array(
						'first_name'           => \sanitize_text_field( WC()->customer->get_billing_first_name() ),
						'last_name'            => \sanitize_text_field( WC()->customer->get_billing_last_name() ),
						'billing_email'        => \sanitize_email( \WC()->customer->get_billing_email() ),
						'lifetime_order_count' => $lifetime_order_count,
					)
				);

			} elseif ( WC()->session instanceof \WC_Session ) {
				// Fallback to session customer data if WC_Customer not available.
				$customer_session_data = WC()->session->get( 'customer' );
				if ( is_array( $customer_session_data ) ) {
					$customer_data = array_merge(
						$customer_data,
						array(
							'first_name'    => \sanitize_text_field( $customer_session_data['first_name'] ?? null ),
							'last_name'     => \sanitize_text_field( $customer_session_data['last_name'] ?? null ),
							'billing_email' => \sanitize_email( $customer_session_data['email'] ?? null ),
						)
					);
				}
			}
		} catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Graceful degradation - return as much data as possible.
		}

		return $customer_data;
	}

	/**
	 * Get order data including totals, currency, cart hash, and cart items.
	 *
	 * Collects comprehensive order information from the cart with graceful degradation.
	 * Calculates shipping_tax_rate from shipping tax and shipping total. Sets customer_id
	 * to 'guest' for non-logged-in users.
	 *
	 * @since 10.5.0
	 *
	 * @param int|null $order_id_from_event Optional order ID from event data.
	 * @return array Order data array with 11 keys including items array.
	 */
	private function get_order_data( ?int $order_id_from_event = null ): array {
		try {
			// Initialize default values.
			$order_id          = $order_id_from_event;
			$customer_id       = 'guest';
			$total             = 0;
			$items_total       = 0;
			$shipping_total    = 0;
			$tax_total         = 0;
			$shipping_tax_rate = null;
			$discount_total    = 0;
			$currency          = WC()->call_function( 'get_woocommerce_currency' );
			$cart_hash         = null;
			$items             = array();

			// Get customer ID from WooCommerce customer object if available.
			// We don't need to fallback to session data here, because customer id won't be stored there.
			if ( WC()->customer instanceof \WC_Customer ) {
				$id = WC()->customer->get_id();
				if ( $id ) {
					$customer_id = $id;
				}
			}
			// Get cart data if available.
			if ( WC()->cart instanceof \WC_Cart ) {
				$items_total    = (float) WC()->cart->get_subtotal();
				$shipping_total = (float) WC()->cart->get_shipping_total();
				$tax_total      = (float) WC()->cart->get_cart_contents_tax();
				$discount_total = (float) WC()->cart->get_discount_total();
				$cart_hash      = WC()->cart->get_cart_hash();
				$items          = $this->get_cart_items();
				$total          = (float) WC()->cart->get_total( 'edit' );

				// Calculate shipping_tax_rate.
				$shipping_tax = (float) WC()->cart->get_shipping_tax();
				if ( $shipping_total > 0 && $shipping_tax > 0 ) {
					$shipping_tax_rate = $shipping_tax / $shipping_total;
				}
			}

			return array(
				'order_id'          => $order_id,
				'customer_id'       => $customer_id,
				'total'             => $total,
				'items_total'       => $items_total,
				'shipping_total'    => $shipping_total,
				'tax_total'         => $tax_total,
				'shipping_tax_rate' => $shipping_tax_rate,
				'discount_total'    => $discount_total,
				'currency'          => $currency,
				'cart_hash'         => $cart_hash,
				'items'             => $items,
			);
		} catch ( \Exception $e ) {
			// Graceful degradation - return structure with default values.
			return array(
				'order_id'          => null,
				'customer_id'       => 'guest',
				'total'             => 0,
				'items_total'       => 0,
				'shipping_total'    => 0,
				'tax_total'         => 0,
				'shipping_tax_rate' => null,
				'discount_total'    => 0,
				'currency'          => WC()->call_function( 'get_woocommerce_currency' ),
				'cart_hash'         => null,
				'items'             => array(),
			);
		}
	}

	/**
	 * Get cart items with detailed product information.
	 *
	 * Iterates through cart items and extracts comprehensive product data including
	 * name, description, category, SKU, pricing, quantities, and WooCommerce-specific
	 * attributes. Returns array of item objects with 12 fields each.
	 *
	 * @since 10.5.0
	 *
	 * @return array Array of cart item objects with detailed product information.
	 */
	private function get_cart_items(): array {
		$items = array();

		try {
			if ( ! WC()->cart instanceof \WC_Cart ) {
				return $items;
			}

			foreach ( WC()->cart->get_cart() as $cart_item ) {
				try {
					$product = $cart_item['data'] ?? null;

					if ( ! $product instanceof \WC_Product ) {
						continue;
					}

					$quantity = $cart_item['quantity'] ?? 1;

					// Calculate per-unit amounts.
					$unit_price           = (float) $product->get_price();
					$line_tax             = $cart_item['line_tax'] ?? 0;
					$unit_tax_amount      = $quantity > 0 ? ( (float) $line_tax / $quantity ) : 0;
					$line_discount        = $cart_item['line_subtotal'] - $cart_item['line_total'];
					$unit_discount_amount = $quantity > 0 ? ( (float) $line_discount / $quantity ) : 0;
					$category             = $this->get_product_category_names( $product );

					$items[] = array(
						'name'                 => $product->get_name() ? $product->get_name() : null,
						'description'          => $product->get_description() ? $product->get_description() : null,
						'category'             => $category,
						'sku'                  => $product->get_sku() ? $product->get_sku() : null,
						'quantity'             => $quantity,
						'unit_price'           => $unit_price,
						'unit_tax_amount'      => $unit_tax_amount,
						'unit_discount_amount' => $unit_discount_amount,
						'product_type'         => $product->get_type() ? $product->get_type() : null,
						'is_virtual'           => $product->is_virtual(),
						'is_downloadable'      => $product->is_downloadable(),
						'attributes'           => $product->get_attributes() ? $product->get_attributes() : array(),
					);
				} catch ( \Exception $e ) {
					// Skip this item if there's an error, continue with next item.
					continue;
				}
			}
		} catch ( \Exception $e ) {
			// Return empty array on error.
			return array();
		}

		return $items;
	}

	/**
	 * Get billing address from customer data.
	 *
	 * Collects billing address fields from WC_Customer object with graceful degradation.
	 * Returns array with 6 address fields, sanitized with sanitize_text_field().
	 *
	 * @since 10.5.0
	 *
	 * @return array Billing address array with 6 keys.
	 */
	private function get_billing_address(): array {
		$billing_data = array(
			'first_name' => null,
			'last_name'  => null,
			'address'    => null,
			'address_1'  => null,
			'address_2'  => null,
			'city'       => null,
			'state'      => null,
			'country'    => null,
			'phone'      => null,
			'postcode'   => null,
		);

		try {
			// Try WC_Customer object first.
			if ( WC()->customer instanceof \WC_Customer ) {
				$billing_data = array_merge(
					$billing_data,
					array(
						'first_name' => \sanitize_text_field( WC()->customer->get_billing_first_name() ),
						'last_name'  => \sanitize_text_field( WC()->customer->get_billing_last_name() ),
						'address_1'  => \sanitize_text_field( WC()->customer->get_billing_address_1() ),
						'address_2'  => \sanitize_text_field( WC()->customer->get_billing_address_2() ),
						'city'       => \sanitize_text_field( WC()->customer->get_billing_city() ),
						'state'      => \sanitize_text_field( WC()->customer->get_billing_state() ),
						'country'    => \sanitize_text_field( WC()->customer->get_billing_country() ),
						'phone'      => \sanitize_text_field( WC()->customer->get_billing_phone() ),
						'postcode'   => \sanitize_text_field( WC()->customer->get_billing_postcode() ),
					)
				);
			} elseif ( WC()->session instanceof \WC_Session ) {
				// Fallback to session customer data if WC_Customer not available.
				$customer_data = WC()->session->get( 'customer' );
				if ( is_array( $customer_data ) ) {
					$billing_data = array_merge(
						$billing_data,
						array(
							'first_name' => \sanitize_text_field( $customer_data['first_name'] ?? null ),
							'last_name'  => \sanitize_text_field( $customer_data['last_name'] ?? null ),
							'address'    => \sanitize_text_field( $customer_data['address'] ?? null ),
							'address_1'  => \sanitize_text_field( $customer_data['address_1'] ?? null ),
							'address_2'  => \sanitize_text_field( $customer_data['address_2'] ?? null ),
							'city'       => \sanitize_text_field( $customer_data['city'] ?? null ),
							'state'      => \sanitize_text_field( $customer_data['state'] ?? null ),
							'country'    => \sanitize_text_field( $customer_data['country'] ?? null ),
							'phone'      => \sanitize_text_field( $customer_data['phone'] ?? null ),
							'postcode'   => \sanitize_text_field( $customer_data['postcode'] ?? null ),
						)
					);
				}
			}
		} catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Graceful degradation - prevents any errors from being thrown.
		}

		return $billing_data;
	}

	/**
	 * Get shipping address from customer data.
	 *
	 * Collects shipping address fields from WC_Customer object with graceful degradation.
	 * Returns array with 6 address fields, sanitized with sanitize_text_field().
	 *
	 * @since 10.5.0
	 *
	 * @return array Shipping address array with 6 keys.
	 */
	private function get_shipping_address(): array {
		$shipping_data = array(
			'first_name' => null,
			'last_name'  => null,
			'address'    => null,
			'address_1'  => null,
			'address_2'  => null,
			'city'       => null,
			'state'      => null,
			'postcode'   => null,
			'country'    => null,
		);
		try {
			if ( WC()->customer instanceof \WC_Customer ) {
				$shipping_data = array_merge(
					$shipping_data,
					array(
						'first_name' => \sanitize_text_field( WC()->customer->get_shipping_first_name() ),
						'last_name'  => \sanitize_text_field( WC()->customer->get_shipping_last_name() ),
						'address_1'  => \sanitize_text_field( WC()->customer->get_shipping_address_1() ),
						'address_2'  => \sanitize_text_field( WC()->customer->get_shipping_address_2() ),
						'city'       => \sanitize_text_field( WC()->customer->get_shipping_city() ),
						'state'      => \sanitize_text_field( WC()->customer->get_shipping_state() ),
						'postcode'   => \sanitize_text_field( WC()->customer->get_shipping_postcode() ),
						'country'    => \sanitize_text_field( WC()->customer->get_shipping_country() ),
					)
				);
			} elseif ( WC()->session instanceof \WC_Session ) {
				// Fallback to session customer data if WC_Customer not available.
				$customer_data = WC()->session->get( 'customer' );
				if ( is_array( $customer_data ) ) {
					$shipping_data = array_merge(
						$shipping_data,
						array(
							'first_name' => \sanitize_text_field( $customer_data['shipping_first_name'] ?? null ),
							'last_name'  => \sanitize_text_field( $customer_data['shipping_last_name'] ?? null ),
							'address_1'  => \sanitize_text_field( $customer_data['shipping_address_1'] ?? null ),
							'address_2'  => \sanitize_text_field( $customer_data['shipping_address_2'] ?? null ),
							'city'       => \sanitize_text_field( $customer_data['shipping_city'] ?? null ),
							'state'      => \sanitize_text_field( $customer_data['shipping_state'] ?? null ),
							'postcode'   => \sanitize_text_field( $customer_data['shipping_postcode'] ?? null ),
							'country'    => \sanitize_text_field( $customer_data['shipping_country'] ?? null ),
						)
					);
				}
			}
		} catch ( \Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Graceful degradation - returns as much data as possible.
		}

		return $shipping_data;
	}

	/**
	 * Get client IP address using WooCommerce geolocation utility.
	 *
	 * @since 10.5.0
	 *
	 * @return string|null IP address or null if not available.
	 */
	private function get_ip_address(): ?string {
		if ( class_exists( 'WC_Geolocation' ) ) {
			$ip = \WC_Geolocation::get_ip_address();
			return $ip ? $ip : null;
		}
		return null;
	}

	/**
	 * Get customer email with fallback chain.
	 *
	 * Tries logged-in user email first, then WC_Customer billing email,
	 * then session customer data as fallback.
	 *
	 * @since 10.5.0
	 *
	 * @return string|null Email address or null if not available.
	 */
	private function get_email(): ?string {
		// Try logged-in user first.
		if ( \is_user_logged_in() ) {
			$user = \wp_get_current_user();
			if ( $user && $user->user_email ) {
				return \sanitize_email( $user->user_email );
			}
		}

		// Try WC_Customer object.
		if ( WC()->customer instanceof \WC_Customer ) {
			$email = WC()->customer->get_billing_email();
			if ( $email ) {
				return \sanitize_email( $email );
			}
		}

		// Fallback to session customer data if WC_Customer not available.
		if ( WC()->session instanceof \WC_Session ) {
			$customer_data = WC()->session->get( 'customer' );
			if ( is_array( $customer_data ) && ! empty( $customer_data['email'] ) ) {
				return \sanitize_email( $customer_data['email'] );
			}
		}

		return null;
	}

	/**
	 * Get user agent string from HTTP headers.
	 *
	 * @since 10.5.0
	 *
	 * @return string|null User agent or null if not available.
	 */
	private function get_user_agent(): ?string {
		if ( isset( $_SERVER['HTTP_USER_AGENT'] ) ) {
			return sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) );
		}
		return null;
	}

	/**
	 * Get product category names as comma-separated list.
	 *
	 * Uses WooCommerce helper with caching for better performance.
	 * Returns all categories for the product, not just the primary one.
	 *
	 * @since 10.5.0
	 *
	 * @param \WC_Product $product The product object.
	 * @return string|null Comma-separated category names or null if none.
	 */
	private function get_product_category_names( \WC_Product $product ): ?string {
		$terms = WC()->call_function( 'wc_get_product_terms', $product->get_id(), 'product_cat' );
		if ( empty( $terms ) || ! is_array( $terms ) ) {
			return null;
		}
		$category_names = array_map(
			function ( $term ) {
				return $term->name;
			},
			$terms
		);
		return implode( ', ', $category_names );
	}
}
PK     [1]|  |  ,  FraudProtection/JetpackConnectionManager.phpnu         <?php
/**
 * JetpackConnectionManager class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\FraudProtection;

use Automattic\WooCommerce\Internal\Jetpack\JetpackConnection;

defined( 'ABSPATH' ) || exit;

/**
 * Manages Jetpack connection status and validation for fraud protection.
 *
 * Provides centralized methods to check connection status, validate requirements,
 * and handle connection-related errors gracefully.
 *
 * @since 10.5.0
 */
class JetpackConnectionManager {

	/**
	 * Get the Jetpack blog ID.
	 *
	 * @return int|null Blog ID if available, null otherwise.
	 */
	public function get_blog_id(): ?int {
		// Get blog ID from Jetpack options.
		$blog_id = \Jetpack_Options::get_option( 'id' );

		return $blog_id ? (int) $blog_id : null;
	}

	/**
	 * Get connection status with detailed error information.
	 *
	 * Returns an array with connection status and any error details.
	 *
	 * @return array {
	 *     Connection status information.
	 *
	 *     @type bool   $connected    Whether the site is connected.
	 *     @type string $error        Error message if not connected.
	 *     @type string $error_code   Error code if not connected.
	 *     @type int    $blog_id      Blog ID if available.
	 * }
	 */
	public function get_connection_status(): array {
		$status = array(
			'connected'  => false,
			'error'      => '',
			'error_code' => '',
			'blog_id'    => null,
		);

		// Check if connected.
		if ( ! JetpackConnection::get_manager()->is_connected() ) {
			$status['error']      = __( 'Site is not connected to WordPress.com. Please connect your site to enable fraud protection.', 'woocommerce' );
			$status['error_code'] = 'not_connected';
			return $status;
		}

		// Get blog ID.
		$blog_id = $this->get_blog_id();
		if ( ! $blog_id ) {
			$status['error']      = __( 'Jetpack blog ID not found. Please reconnect your site to WordPress.com.', 'woocommerce' );
			$status['error_code'] = 'no_blog_id';
			return $status;
		}

		// All checks passed.
		$status['connected'] = true;
		$status['blog_id']   = $blog_id;

		return $status;
	}

	/**
	 * Get the Jetpack authorization URL for connecting the site.
	 *
	 * @param string $redirect_url URL to redirect to after authorization.
	 * @return string|null Authorization URL or null on error.
	 */
	public function get_authorization_url( string $redirect_url = '' ): ?string {
		// If no redirect URL provided, use current admin URL.
		if ( empty( $redirect_url ) ) {
			$redirect_url = admin_url( 'admin.php?page=wc-settings&tab=advanced&section=features' );
		}

		$authorization_data = JetpackConnection::get_authorization_url( $redirect_url, 'woocommerce-fraud-protection' );

		if ( ! $authorization_data['success'] ) {
			FraudProtectionController::log(
				'error',
				'Failed to get Jetpack authorization URL.',
				$authorization_data['errors']
			);
			return null;
		}

		return $authorization_data['url'];
	}
}
PK     [1]!    (  FraudProtection/AdminSettingsHandler.phpnu         <?php
/**
 * AdminSettingsHandler class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\FraudProtection;

defined( 'ABSPATH' ) || exit;

/**
 * Handles admin settings for fraud protection.
 *
 * @since 10.5.0
 */
class AdminSettingsHandler {

	/**
	 * Jetpack connection manager instance.
	 *
	 * @var JetpackConnectionManager
	 */
	private $connection_manager;

	/**
	 * Register hooks.
	 */
	public function register(): void {
		add_filter( 'woocommerce_get_settings_advanced', array( $this, 'add_jetpack_connection_field' ), 100, 2 );
		add_action( 'woocommerce_admin_field_jetpack_connection', array( $this, 'handle_output_jetpack_connection_field' ), 10, 1 );
	}


	/**
	 * Initialize the class with dependencies.
	 *
	 * @internal
	 *
	 * @param JetpackConnectionManager $connection_manager Jetpack connection manager instance.
	 * @return void
	 */
	final public function init( JetpackConnectionManager $connection_manager ): void {
		$this->connection_manager = $connection_manager;
	}

	/**
	 * Add Jetpack connection field to fraud protection settings.
	 *
	 * @internal
	 *
	 * @param array  $settings Existing settings.
	 * @param string $current_section Current section name.
	 * @return array Modified settings.
	 */
	public function add_jetpack_connection_field( $settings, $current_section ): array {
		// Only add on the features section.
		if ( 'features' !== $current_section ) {
			return $settings;
		}

		// Check if field already exists to prevent duplicates.
		foreach ( $settings as $setting ) {
			if ( isset( $setting['id'] ) && 'woocommerce_fraud_protection_jetpack_connection' === $setting['id'] ) {
				return $settings;
			}
		}

		// Find the fraud_protection field and add Jetpack connection field after it.
		$new_settings = array();
		foreach ( $settings as $setting ) {
			$new_settings[] = $setting;

			// Add Jetpack connection field after fraud_protection checkbox.
			if ( isset( $setting['id'] ) && 'woocommerce_feature_fraud_protection_enabled' === $setting['id'] ) {
				$new_settings[] = array(
					'id'    => 'woocommerce_fraud_protection_jetpack_connection',
					'type'  => 'jetpack_connection',
					'title' => __( 'Jetpack Connection', 'woocommerce' ),
					'desc'  => __( 'Connect your site to Jetpack to enable fraud protection features.', 'woocommerce' ),
				);
			}
		}

		return $new_settings;
	}

	/**
	 * Output the Jetpack connection field.
	 *
	 * @internal
	 *
	 * @param array $value Field configuration.
	 * @return void
	 */
	public function handle_output_jetpack_connection_field( $value ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
		// Only show Jetpack connection when fraud protection is enabled.
		if ( 'yes' !== get_option( 'woocommerce_feature_fraud_protection_enabled', 'no' ) ) {
			return;
		}

		$this->output_jetpack_connection_status();
	}

	/**
	 * Output the Jetpack connection status and button.
	 *
	 * @internal
	 *
	 * @return void
	 */
	private function output_jetpack_connection_status(): void {
		// Get connection status from connection manager.
		$connection_status = $this->connection_manager->get_connection_status();
		?>
		<tr valign="top">
			<th scope="row" class="titledesc">
				<label><?php esc_html_e( 'Jetpack Connection', 'woocommerce' ); ?></label>
			</th>
			<td class="forminp forminp-button">
				<?php if ( ! $connection_status['connected'] ) : ?>
					<?php
					// Get authorization URL for connecting.
					$redirect_url   = admin_url( 'admin.php?page=wc-settings&tab=advanced&section=features' );
					$connection_url = $this->connection_manager->get_authorization_url( $redirect_url );

					// If we couldn't get authorization URL, show error message.
					if ( ! $connection_url ) :
						?>
						<p class="description" style="color: #dc3232;">
							<?php echo esc_html( $connection_status['error'] ); ?>
						</p>
					<?php else : ?>
						<a href="<?php echo esc_url( $connection_url ); ?>" class="button button-secondary jetpack_connection_button">
							<?php esc_html_e( 'Connect to Jetpack', 'woocommerce' ); ?>
						</a>
						<p class="description">
							<?php esc_html_e( 'Connect your site to Jetpack to enable fraud protection features.', 'woocommerce' ); ?>
						</p>
					<?php endif; ?>
				<?php else : ?>
					<span class="dashicons dashicons-yes-alt" style="color: #46b450;"></span>
					<span><?php esc_html_e( 'Connected to Jetpack', 'woocommerce' ); ?></span>
					<p class="description">
						<?php
						printf(
							/* translators: %d: Blog ID */
							esc_html__( 'Site ID: %d', 'woocommerce' ),
							(int) $connection_status['blog_id']
						);
						?>
					</p>
				<?php endif; ?>
			</td>
		</tr>
		<?php
	}
}
PK     [1]ّ    *  FraudProtection/FraudProtectionTracker.phpnu         <?php
/**
 * FraudProtectionTracker class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\FraudProtection;

defined( 'ABSPATH' ) || exit;

/**
 * Centralized fraud protection event tracker.
 *
 * This class provides a unified interface for tracking fraud protection events.
 * It logs events for the fraud protection service using already-collected data.
 *
 * @since 10.5.0
 * @internal This class is part of the internal API and is subject to change without notice.
 */
class FraudProtectionTracker {

	/**
	 * Track fraud protection event with already-collected data.
	 *
	 * This method accepts fully-collected event data (including session context)
	 * and logs it for the fraud protection service.
	 *
	 * The method implements graceful degradation - any errors during tracking
	 * will be logged but will not break the functionality.
	 *
	 * @param string $event_type     Event type identifier (e.g., 'cart_item_added').
	 * @param array  $collected_data Fully-collected event data including session context.
	 * @return void
	 */
	public function track_event( string $event_type, array $collected_data ): void {
		try {
			// phpcs:ignore Generic.Commenting.Todo.TaskFound
			// TODO: Once EventTracker/API client is implemented (WOOSUBS-1249), call it here:
			// $event_tracker = wc_get_container()->get( EventTracker::class );
			// $event_tracker->track( $event_type, $collected_data );
			//
			// For now, log the event for debugging and verification.
			FraudProtectionController::log(
				'info',
				sprintf(
					'Fraud protection event tracked: %s | Session ID: %s',
					$event_type,
					$collected_data['session']['session_id'] ?? 'N/A'
				),
				array(
					'event_type'     => $event_type,
					'collected_data' => $collected_data,
				)
			);
		} catch ( \Exception $e ) {
			// Gracefully handle errors - fraud protection should never break functionality.
			FraudProtectionController::log(
				'error',
				sprintf(
					'Failed to track fraud protection event: %s | Error: %s',
					$event_type,
					$e->getMessage()
				),
				array(
					'event_type' => $event_type,
					'exception'  => $e,
				)
			);
		}
	}
}
PK     [1]U      FraudProtection/ApiClient.phpnu         <?php
/**
 * ApiClient class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\FraudProtection;

use Automattic\Jetpack\Connection\Client as Jetpack_Connection_Client;

defined( 'ABSPATH' ) || exit;

/**
 * Handles communication with the WPCOM fraud protection endpoint.
 *
 * Uses Jetpack Connection for authenticated requests to the WPCOM endpoint
 * to get fraud protection decisions (allow, block, or challenge).
 *
 * This class implements a fail-open pattern: if the endpoint is unreachable,
 * times out, or returns an error, it returns an "allow" decision to ensure
 * legitimate transactions are never blocked due to service issues.
 *
 * @since 10.5.0
 * @internal This class is part of the internal API and is subject to change without notice.
 */
class ApiClient {

	/**
	 * Default timeout for API requests in seconds.
	 */
	private const DEFAULT_TIMEOUT = 30;

	/**
	 * WPCOM API version.
	 */
	private const WPCOM_API_VERSION = '2';

	/**
	 * WPCOM fraud protection events endpoint path within Transact platform.
	 */
	private const EVENTS_ENDPOINT = 'transact/fraud_protection/events';

	/**
	 * Decision type: allow session.
	 */
	public const DECISION_ALLOW = 'allow';

	/**
	 * Decision type: block session.
	 */
	public const DECISION_BLOCK = 'block';

	/**
	 * Decision type: challenge session.
	 */
	public const DECISION_CHALLENGE = 'challenge';

	/**
	 * Valid decision values that can be returned by the API.
	 *
	 * @var array<string>
	 */
	public const VALID_DECISIONS = array(
		self::DECISION_ALLOW,
		self::DECISION_BLOCK,
	);

	/**
	 * Send a fraud protection event and get a decision from WPCOM endpoint.
	 *
	 * Implements fail-open pattern: if the endpoint is unreachable or times out,
	 * returns "allow" decision and logs the error.
	 *
	 * @since 10.5.0
	 *
	 * @param string               $event_type Type of event being sent (e.g., 'cart_updated', 'checkout_started').
	 * @param array<string, mixed> $event_data Event data to send to the endpoint.
	 * @return string Decision: "allow" or "block".
	 */
	public function send_event( string $event_type, array $event_data ): string {
		$payload = array_merge(
			array( 'event_type' => $event_type ),
			array_filter( $event_data, fn( $value ) => null !== $value )
		);

		FraudProtectionController::log(
			'info',
			sprintf( 'Sending fraud protection event: %s', $event_type ),
			array( 'payload' => $payload )
		);

		$response = $this->make_request( 'POST', self::EVENTS_ENDPOINT, $payload );

		if ( is_wp_error( $response ) ) {
			$error_data = $response->get_error_data() ?? array();
			$error_data = is_array( $error_data ) ? $error_data : array( 'error' => $error_data );
			FraudProtectionController::log(
				'error',
				sprintf(
					'Event track request failed: %s. Failing open with "allow" decision.',
					$response->get_error_message()
				),
				$error_data
			);
			return self::DECISION_ALLOW;
		}

		if ( ! isset( $response['decision'] ) ) {
			FraudProtectionController::log(
				'error',
				'Response missing "decision" field. Failing open with "allow" decision.',
				array( 'response' => $response )
			);
			return self::DECISION_ALLOW;
		}

		$decision = $response['decision'];

		if ( ! in_array( $decision, self::VALID_DECISIONS, true ) ) {
			FraudProtectionController::log(
				'error',
				sprintf(
					'Invalid decision value "%s". Failing open with "allow" decision.',
					$decision
				),
				array( 'response' => $response )
			);
			return self::DECISION_ALLOW;
		}

		$session    = is_array( $event_data['session'] ?? null ) ? $event_data['session'] : array();
		$session_id = $session['session_id'] ?? 'unknown';
		FraudProtectionController::log(
			'info',
			sprintf(
				'Fraud decision received: %s | Event: %s | Session: %s',
				$decision,
				$event_type,
				$session_id
			),
			array( 'response' => $response )
		);

		return $decision;
	}

	/**
	 * Make an HTTP request to a WPCOM endpoint via Jetpack Connection.
	 *
	 * @param string               $method  HTTP method (GET, POST, etc.).
	 * @param string               $path    Endpoint path (relative to sites/{blog_id}/).
	 * @param array<string, mixed> $payload Request payload.
	 * @return array<string, mixed>|\WP_Error Parsed JSON response or WP_Error on failure.
	 */
	private function make_request( string $method, string $path, array $payload ) {
		if ( ! class_exists( Jetpack_Connection_Client::class ) ) {
			return new \WP_Error(
				'jetpack_not_available',
				'Jetpack Connection is not available'
			);
		}

		$blog_id = $this->get_blog_id();
		if ( ! $blog_id ) {
			return new \WP_Error(
				'no_blog_id',
				'Jetpack blog ID not found. Is the site connected to WordPress.com?'
			);
		}

		$full_path = sprintf( 'sites/%d/%s', $blog_id, $path );

		$body = \wp_json_encode( $payload );

		if ( false === $body ) {
			return new \WP_Error(
				'json_encode_error',
				'Failed to encode payload',
				array( 'payload' => $payload )
			);
		}

		$response = Jetpack_Connection_Client::wpcom_json_api_request_as_blog(
			$full_path,
			self::WPCOM_API_VERSION,
			array(
				'headers' => array( 'Content-Type' => 'application/json' ),
				'method'  => $method,
				'timeout' => self::DEFAULT_TIMEOUT,
			),
			$body,
			'wpcom'
		);

		if ( is_wp_error( $response ) ) {
			return $response;
		}

		/**
		 * Type assertion for PHPStan - Jetpack returns array on success.
		 *
		 * @var array $response
		 */
		$response_code = wp_remote_retrieve_response_code( $response );
		$response_body = wp_remote_retrieve_body( $response );

		$data = json_decode( $response_body, true );

		if ( $response_code >= 300 ) {
			return new \WP_Error(
				'api_error',
				sprintf( 'Endpoint %s returned status code %d', "$method $path", $response_code ),
				array( 'response' => JSON_ERROR_NONE === json_last_error() ? $data : $response_body )
			);
		}

		if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $data ) ) {
			return new \WP_Error(
				'json_decode_error',
				sprintf( 'Failed to decode JSON response: %s', json_last_error_msg() ),
				array( 'response' => $response_body )
			);
		}

		return $data;
	}

	/**
	 * Get the Jetpack blog ID.
	 *
	 * @return int|false Blog ID or false if not available.
	 */
	private function get_blog_id() {
		if ( ! class_exists( \Jetpack_Options::class ) ) {
			return false;
		}
		return \Jetpack_Options::get_option( 'id' );
	}
}
PK     [1]B7    -  FraudProtection/FraudProtectionController.phpnu         <?php
/**
 * FraudProtectionController class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\FraudProtection;

use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\RegisterHooksInterface;

defined( 'ABSPATH' ) || exit;

/**
 * Main controller for fraud protection features.
 *
 * This class orchestrates all fraud protection components and ensures
 * zero-impact when the feature flag is disabled.
 *
 * @since 10.5.0
 * @internal This class is part of the internal API and is subject to change without notice.
 */
class FraudProtectionController implements RegisterHooksInterface {

	/**
	 * Features controller instance.
	 *
	 * @var FeaturesController
	 */
	private FeaturesController $features_controller;

	/**
	 * Jetpack connection manager instance.
	 *
	 * @var JetpackConnectionManager
	 */
	private JetpackConnectionManager $connection_manager;

	/**
	 * Blocked session notice instance.
	 *
	 * @var BlockedSessionNotice
	 */
	private BlockedSessionNotice $blocked_session_notice;

	/**
	 * Register hooks.
	 */
	public function register(): void {
		add_action( 'init', array( $this, 'on_init' ) );
		add_action( 'admin_notices', array( $this, 'on_admin_notices' ) );
	}

	/**
	 * Initialize the instance, runs when the instance is created by the dependency injection container.
	 *
	 * @internal
	 *
	 * @param FeaturesController       $features_controller      The instance of FeaturesController to use.
	 * @param JetpackConnectionManager $connection_manager       The instance of JetpackConnectionManager to use.
	 * @param BlockedSessionNotice     $blocked_session_notice   The instance of BlockedSessionNotice to use.
	 */
	final public function init(
		FeaturesController $features_controller,
		JetpackConnectionManager $connection_manager,
		BlockedSessionNotice $blocked_session_notice
	): void {
		$this->features_controller    = $features_controller;
		$this->connection_manager     = $connection_manager;
		$this->blocked_session_notice = $blocked_session_notice;
	}

	/**
	 * Hook into WordPress on init.
	 *
	 * @internal
	 */
	public function on_init(): void {
		// Bail if the feature is not enabled.
		if ( ! $this->feature_is_enabled() ) {
			return;
		}

		$this->blocked_session_notice->register();
	}

	/**
	 * Display admin notice when Jetpack connection is not available.
	 *
	 * @internal
	 */
	public function on_admin_notices(): void {
		// Only show if feature is enabled.
		if ( ! $this->feature_is_enabled() ) {
			return;
		}

		// Only show on WooCommerce settings page.
		$screen = get_current_screen();
		if ( ! $screen || 'woocommerce_page_wc-settings' !== $screen->id ) {
			return;
		}

		$connection_status = $this->connection_manager->get_connection_status();
		if ( $connection_status['connected'] ) {
			return;
		}

		$settings_url = admin_url( 'admin.php?page=wc-settings&tab=advanced&section=features' );

		?>
		<div class="notice notice-warning is-dismissible">
			<p>
				<strong><?php esc_html_e( 'Fraud protection warning:', 'woocommerce' ); ?></strong>
				<?php echo esc_html( $connection_status['error'] ); ?>
			</p>
			<p>
				<?php
				printf(
					/* translators: %s: Settings page URL */
					wp_kses_post( __( 'Fraud protection will fail open and allow all sessions until connected. <a href="%s">Connect to Jetpack</a>', 'woocommerce' ) ),
					esc_url( $settings_url )
				);
				?>
			</p>
		</div>
		<?php
	}

	/**
	 * Check if fraud protection feature is enabled.
	 *
	 * This method can be used by other fraud protection classes to check
	 * the feature flag status. Returns false (fail-open) if init hasn't run yet.
	 *
	 * @return bool True if enabled, false if not enabled or init hasn't run yet.
	 */
	public function feature_is_enabled(): bool {
		// Fail-open: don't block if init hasn't run yet to avoid FeaturesController translation notices.
		if ( ! did_action( 'init' ) ) {
			return false;
		}
		return $this->features_controller->feature_is_enabled( 'fraud_protection' );
	}

	/**
	 * Log helper method for consistent logging across all fraud protection components.
	 *
	 * This static method ensures all fraud protection logs are written with
	 * the same 'woo-fraud-protection' source for easy filtering in WooCommerce logs.
	 *
	 * @param string $level   Log level (emergency, alert, critical, error, warning, notice, info, debug).
	 * @param string $message Log message.
	 * @param array  $context Optional context data.
	 *
	 * @return void
	 */
	public static function log( string $level, string $message, array $context = array() ): void {
		wc_get_logger()->log(
			$level,
			$message,
			array_merge( $context, array( 'source' => 'woo-fraud-protection' ) )
		);
	}
}
PK     [1]*I=  =  #  FraudProtection/DecisionHandler.phpnu         <?php
/**
 * DecisionHandler class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\FraudProtection;

defined( 'ABSPATH' ) || exit;

/**
 * Handles fraud protection decision application.
 *
 * This class is responsible for:
 * - Applying extension override filters for whitelisting
 * - Coordinating with SessionClearanceManager to apply decisions
 *
 * @since 10.5.0
 * @internal This class is part of the internal API and is subject to change without notice.
 */
class DecisionHandler {

	/**
	 * Session clearance manager instance.
	 *
	 * @var SessionClearanceManager
	 */
	private SessionClearanceManager $session_manager;

	/**
	 * Initialize with dependencies.
	 *
	 * @internal
	 *
	 * @param SessionClearanceManager $session_manager The session clearance manager instance.
	 */
	final public function init( SessionClearanceManager $session_manager ): void {
		$this->session_manager = $session_manager;
	}

	/**
	 * Apply a fraud protection decision.
	 *
	 * This method processes a decision from the API, applies any override filters,
	 * validates the result, and updates the session status accordingly.
	 *
	 * The input decision is expected to be pre-validated by ApiClient.
	 *
	 * The decision flow:
	 * 1. Apply the `woocommerce_fraud_protection_decision` filter for overrides
	 * 2. Validate the filtered decision (third-party filters may return invalid values)
	 * 3. Update session status via SessionClearanceManager
	 *
	 * @since 10.5.0
	 *
	 * @param string               $decision     The decision from the API (allow, block).
	 * @param array<string, mixed> $session_data The session data that was sent to the API.
	 * @return string The final applied decision after any filter overrides.
	 */
	public function apply_decision( string $decision, array $session_data ): string {
		// Validate input decision and fail open if invalid.
		if ( ! $this->is_valid_decision( $decision ) ) {
			FraudProtectionController::log(
				'warning',
				sprintf( 'Invalid decision "%s" received. Defaulting to "allow".', $decision ),
				array( 'session_data' => $session_data )
			);
			$decision = ApiClient::DECISION_ALLOW;
		}

		$original_decision = $decision;

		/**
		 * Filters the fraud protection decision before it is applied.
		 *
		 * This filter allows extensions to override fraud protection decisions
		 * to implement custom whitelisting logic. Common use cases:
		 * - Whitelist specific users (e.g., admins, trusted customers)
		 * - Whitelist specific conditions (e.g., certain IP ranges, logged-in users)
		 * - Integrate with external fraud detection services
		 *
		 * Note: This filter can only change the decision to ApiClient::VALID_DECISIONS.
		 * Any other value will be rejected and the original decision will be used.
		 *
		 * @since 10.5.0
		 *
		 * @param string               $decision     The decision from the API (allow, block).
		 * @param array<string, mixed> $session_data The session data that was analyzed.
		 */
		$decision = apply_filters( 'woocommerce_fraud_protection_decision', $decision, $session_data );

		// Validate filtered decision (third-party filters may return invalid values).
		if ( ! $this->is_valid_decision( $decision ) ) {
			FraudProtectionController::log(
				'warning',
				sprintf( 'Filter `woocommerce_fraud_protection_decision` returned invalid decision "%s". Using original decision "%s".', $decision, $original_decision ),
				array(
					'original_decision' => $original_decision,
					'filtered_decision' => $decision,
					'session_data'      => $session_data,
				)
			);
			$decision = $original_decision;
		}

		// Log if decision was overridden.
		if ( $decision !== $original_decision ) {
			FraudProtectionController::log(
				'info',
				sprintf( 'Decision overridden by filter `woocommerce_fraud_protection_decision`: "%s" -> "%s"', $original_decision, $decision ),
				array(
					'original_decision' => $original_decision,
					'final_decision'    => $decision,
					'session_data'      => $session_data,
				)
			);
		}

		// Apply the decision to the session.
		$this->update_session_status( $decision );

		return $decision;
	}

	/**
	 * Check if a decision value is valid.
	 *
	 * @param mixed $decision The decision to validate.
	 * @return bool True if valid, false otherwise.
	 */
	private function is_valid_decision( $decision ): bool {
		if ( ! is_string( $decision ) ) {
			return false;
		}
		return in_array( $decision, ApiClient::VALID_DECISIONS, true );
	}

	/**
	 * Update the session status based on the decision.
	 *
	 * @param string $decision The validated decision to apply.
	 * @return void
	 */
	private function update_session_status( string $decision ): void {
		switch ( $decision ) {
			case ApiClient::DECISION_ALLOW:
				$this->session_manager->allow_session();
				break;

			case ApiClient::DECISION_BLOCK:
				$this->session_manager->block_session();
				break;
		}
	}
}
PK     [1]2.Z    -  FraudProtection/FraudProtectionDispatcher.phpnu         <?php
/**
 * FraudProtectionDispatcher class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\FraudProtection;

defined( 'ABSPATH' ) || exit;

/**
 * Centralized fraud protection event dispatcher.
 *
 * This class provides a unified interface for dispatching fraud protection events.
 * It coordinates data collection and transmission for fraud protection events by
 * orchestrating ApiClient and DecisionHandler components.
 *
 * @since 10.5.0
 * @internal This class is part of the internal API and is subject to change without notice.
 */
class FraudProtectionDispatcher {

	/**
	 * API client instance.
	 *
	 * @var ApiClient
	 */
	private ApiClient $api_client;

	/**
	 * Decision handler instance.
	 *
	 * @var DecisionHandler
	 */
	private DecisionHandler $decision_handler;

	/**
	 * Fraud protection controller instance.
	 *
	 * @var FraudProtectionController
	 */
	private FraudProtectionController $fraud_protection_controller;

	/**
	 * Session data collector instance.
	 *
	 * @var SessionDataCollector
	 */
	private SessionDataCollector $data_collector;

	/**
	 * Initialize with dependencies.
	 *
	 * @internal
	 *
	 * @param ApiClient                 $api_client                  The API client instance.
	 * @param DecisionHandler           $decision_handler            The decision handler instance.
	 * @param FraudProtectionController $fraud_protection_controller The fraud protection controller instance.
	 * @param SessionDataCollector      $data_collector              The session data collector instance.
	 */
	final public function init(
		ApiClient $api_client,
		DecisionHandler $decision_handler,
		FraudProtectionController $fraud_protection_controller,
		SessionDataCollector $data_collector
	): void {
		$this->api_client                  = $api_client;
		$this->decision_handler            = $decision_handler;
		$this->fraud_protection_controller = $fraud_protection_controller;
		$this->data_collector              = $data_collector;
	}

	/**
	 * Dispatch fraud protection event.
	 *
	 * This method collects session data and dispatches it to the fraud protection service.
	 * It orchestrates the following flow:
	 * 1. Check if feature is enabled (fail-open if not)
	 * 2. Collect comprehensive session data via SessionDataCollector
	 * 3. Apply extension data filter to allow custom data
	 * 4. Send event to API and get decision
	 * 5. Apply decision via DecisionHandler
	 *
	 * The method implements graceful degradation - any errors during tracking
	 * will be logged but will not break the functionality.
	 *
	 * @param string $event_type Event type identifier (e.g., 'cart_item_added').
	 * @param array  $event_data Optional event-specific data to include with session data.
	 * @return void
	 */
	public function dispatch_event( string $event_type, array $event_data = array() ): void {
		try {
			// Check if feature is enabled - fail-open if not.
			if ( ! $this->fraud_protection_controller->feature_is_enabled() ) {
				FraudProtectionController::log(
					'debug',
					sprintf(
						'Fraud protection event not dispatched (feature disabled): %s',
						$event_type
					),
					array( 'event_type' => $event_type )
				);
				return;
			}

			// Collect comprehensive session data.
			$collected_data = $this->data_collector->collect( $event_type, $event_data );

			/**
			 * Filters the fraud protection event data before sending to the API.
			 *
			 * This filter allows extensions to modify or add custom data to fraud protection
			 * events. Common use cases include:
			 * - Adding custom payment gateway data
			 * - Adding subscription-specific context
			 * - Adding custom risk signals
			 *
			 * @since 10.5.0
			 *
			 * @param array  $collected_data Fully-collected event data including session context.
			 * @param string $event_type     Event type identifier (e.g., 'cart_item_added').
			 */
			$collected_data = apply_filters( 'woocommerce_fraud_protection_event_data', $collected_data, $event_type );

			// Send event to API and get decision.
			$decision = $this->api_client->send_event( $event_type, $collected_data );

			// Apply decision via DecisionHandler.
			$this->decision_handler->apply_decision( $decision, $collected_data );
		} catch ( \Exception $e ) {
			// Gracefully handle errors - fraud protection should never break functionality.
			FraudProtectionController::log(
				'error',
				sprintf(
					'Failed to dispatch fraud protection event: %s | Error: %s',
					$event_type,
					$e->getMessage()
				),
				array(
					'event_type' => $event_type,
					'exception'  => $e,
				)
			);
		}
	}
}
PK     [1]
gj"  j"  (  FraudProtection/CheckoutEventTracker.phpnu         <?php
/**
 * CheckoutEventTracker class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\FraudProtection;

defined( 'ABSPATH' ) || exit;

/**
 * Tracks checkout events for fraud protection analysis.
 *
 * This class provides methods to track both WooCommerce Blocks (Store API) and traditional
 * shortcode checkout events for fraud protection event dispatching.
 * Event-specific data is passed to the dispatcher which handles session data collection internally.
 *
 * @since 10.5.0
 * @internal This class is part of the internal API and is subject to change without notice.
 */
class CheckoutEventTracker {

	/**
	 * Fraud protection dispatcher instance.
	 *
	 * @var FraudProtectionDispatcher
	 */
	private FraudProtectionDispatcher $dispatcher;

	/**
	 * Session data collector instance.
	 *
	 * @var SessionDataCollector
	 */
	private SessionDataCollector $session_data_collector;

	/**
	 * Initialize with dependencies.
	 *
	 * @internal
	 *
	 * @param FraudProtectionDispatcher $dispatcher                The fraud protection dispatcher instance.
	 * @param SessionDataCollector      $session_data_collector    The session data collector instance.
	 */
	final public function init( FraudProtectionDispatcher $dispatcher, SessionDataCollector $session_data_collector ): void {
		$this->dispatcher             = $dispatcher;
		$this->session_data_collector = $session_data_collector;
	}

	/**
	 * Track checkout page loaded event.
	 *
	 * Triggers fraud protection event dispatching when the checkout page is initially loaded.
	 * This captures the initial session state before any user interactions.
	 *
	 * @internal
	 * @return void
	 */
	public function track_checkout_page_loaded(): void {
		// Track the page load event. Session data will be collected by the dispatcher.
		$this->dispatcher->dispatch_event( 'checkout_page_loaded', array() );
	}

	/**
	 * Track Store API customer update event (WooCommerce Blocks checkout).
	 *
	 * Triggered when customer information is updated via the Store API endpoint
	 * /wc/store/v1/cart/update-customer during Blocks checkout flow.
	 *
	 * @internal
	 * @return void
	 */
	public function track_blocks_checkout_update(): void {
		// At this point we don't have any payment or shipping data, so we pass an empty array.
		$this->dispatcher->dispatch_event( 'checkout_update', array() );
	}

	/**
	 * Track shortcode checkout field update event.
	 *
	 * Triggered when checkout fields are updated via AJAX (woocommerce_update_order_review).
	 * Only dispatches event when billing or shipping country changes to reduce unnecessary API calls.
	 *
	 * @internal
	 *
	 * @param string $posted_data Serialized checkout form data.
	 * @return void
	 */
	public function track_shortcode_checkout_field_update( $posted_data ): void {
		// Parse the posted data to extract relevant fields.
		$data = array();
		if ( $posted_data ) {
			parse_str( $posted_data, $data );
		}

		// Get current customer countries using SessionDataCollector.
		$current_billing_country  = $this->session_data_collector->get_current_billing_country();
		$current_shipping_country = $this->session_data_collector->get_current_shipping_country();

		// Get posted countries.
		$posted_billing_country  = $data['billing_country'] ?? '';
		$posted_shipping_country = $data['shipping_country'] ?? '';

		// Check if billing country changed.
		$billing_changed = ! empty( $posted_billing_country ) && $posted_billing_country !== $current_billing_country;

		// Check if shipping country changed.
		$ship_to_different = ! empty( $data['ship_to_different_address'] );
		if ( $ship_to_different ) {
			// User wants different shipping address - check if shipping country changed.
			$shipping_changed = ! empty( $posted_shipping_country ) && $posted_shipping_country !== $current_shipping_country;
		} else {
			// User wants same address for billing and shipping.
			// If current shipping country exists and differs from billing country, it's a change.
			$effective_billing_country = ! empty( $posted_billing_country ) ? $posted_billing_country : $current_billing_country;
			$shipping_changed          = ! empty( $current_shipping_country ) && $current_shipping_country !== $effective_billing_country;
		}

		// Only dispatch if either country changed.
		if ( $billing_changed || $shipping_changed ) {
			$event_data = $this->format_checkout_event_data( 'field_update', $data );
			$this->dispatcher->dispatch_event( 'checkout_update', $event_data );
		}
	}

	/**
	 * Build checkout event-specific data.
	 *
	 * Prepares the checkout event data including action type and any changed fields.
	 *
	 * @param string $action Action type (field_update, store_api_update).
	 * @param array  $collected_event_data Posted form data or event context (may include session data).
	 * @return array Checkout event data.
	 */
	private function format_checkout_event_data( string $action, array $collected_event_data ): array {
		$event_data = array( 'action' => $action );

		// Extract and merge all checkout field groups.
		$event_data = array_merge(
			$event_data,
			$this->extract_billing_fields( $collected_event_data ),
			$this->extract_shipping_fields( $collected_event_data ),
			$this->extract_payment_method( $collected_event_data ),
		);

		return $event_data;
	}

	/**
	 * Extract billing fields from posted data.
	 *
	 * @param array $posted_data Posted form data.
	 * @return array Billing fields.
	 */
	private function extract_billing_fields( array $posted_data ): array {
		$field_map = array(
			'billing_email'      => 'sanitize_email',
			'billing_first_name' => 'sanitize_text_field',
			'billing_last_name'  => 'sanitize_text_field',
			'billing_country'    => 'sanitize_text_field',
			'billing_address_1'  => 'sanitize_text_field',
			'billing_address_2'  => 'sanitize_text_field',
			'billing_city'       => 'sanitize_text_field',
			'billing_state'      => 'sanitize_text_field',
			'billing_postcode'   => 'sanitize_text_field',
			'billing_phone'      => 'sanitize_text_field',
		);

		$extracted_fields = $this->extract_fields_by_map( $field_map, $posted_data );

		// Store API uses 'email' instead of 'billing_email'.
		if ( empty( $extracted_fields['billing_email'] ) && ! empty( $posted_data['email'] ) ) {
			$extracted_fields['email'] = sanitize_email( $posted_data['email'] );
		}

		return $extracted_fields;
	}

	/**
	 * Extract shipping fields from posted data.
	 *
	 * @param array $posted_data Posted form data.
	 * @return array Shipping fields.
	 */
	private function extract_shipping_fields( array $posted_data ): array {
		if ( ! isset( $posted_data['ship_to_different_address'] ) || ! $posted_data['ship_to_different_address'] ) {
			return array();
		}

		$field_map = array(
			'shipping_first_name' => 'sanitize_text_field',
			'shipping_last_name'  => 'sanitize_text_field',
			'shipping_country'    => 'sanitize_text_field',
			'shipping_address_1'  => 'sanitize_text_field',
			'shipping_address_2'  => 'sanitize_text_field',
			'shipping_city'       => 'sanitize_text_field',
			'shipping_state'      => 'sanitize_text_field',
			'shipping_postcode'   => 'sanitize_text_field',
		);

		return $this->extract_fields_by_map( $field_map, $posted_data );
	}

	/**
	 * Extract and sanitize fields from posted data using a field map.
	 *
	 * Generic extraction method that iterates through a field map and extracts
	 * non-empty fields from posted data, applying the appropriate sanitization
	 * function to each field.
	 *
	 * @param array $field_map    Map of field names to sanitization functions.
	 * @param array $posted_data  Posted form data.
	 * @return array Extracted and sanitized fields.
	 */
	private function extract_fields_by_map( array $field_map, array $posted_data ): array {
		$extracted_fields = array();

		foreach ( $field_map as $field_name => $sanitize_function ) {
			if ( ! empty( $posted_data[ $field_name ] ) ) {
				$extracted_fields[ $field_name ] = $sanitize_function( wp_unslash( $posted_data[ $field_name ] ) );
			}
		}

		return $extracted_fields;
	}

	/**
	 * Extract payment method data from posted data.
	 *
	 * Extracts payment method ID and retrieves the readable gateway name.
	 *
	 * @param array $posted_data Posted form data.
	 * @return array Payment method data with ID and name, or empty array if not found.
	 */
	private function extract_payment_method( array $posted_data ): array {
		$payment_data = array();

		if ( ! empty( $posted_data['payment_method'] ) ) {
			$payment_gateway_name = WC()->payment_gateways()->get_payment_gateway_name_by_id( $posted_data['payment_method'] );

			$payment_data['payment'] = array(
				'payment_gateway_type' => $posted_data['payment_method'],
				'payment_gateway_name' => $payment_gateway_name,
			);
		}

		return $payment_data;
	}
}
PK     [1]z	P  P  (  FraudProtection/BlockedSessionNotice.phpnu         <?php
/**
 * BlockedSessionNotice class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\FraudProtection;

use Automattic\WooCommerce\Internal\RegisterHooksInterface;

defined( 'ABSPATH' ) || exit;

/**
 * Handles blocked session messaging for fraud protection.
 *
 * This class provides:
 * - Hook into shortcode checkout to display blocked notice
 * - Message generation for both HTML (shortcode) and plaintext (Store API) contexts
 *
 * Note: Store API (block checkout) and payment gateway filtering are handled
 * directly in WC Core classes (Checkout.php and WC_Payment_Gateways).
 *
 * @since 10.5.0
 * @internal This class is part of the internal API and is subject to change without notice.
 */
class BlockedSessionNotice implements RegisterHooksInterface {

	/**
	 * Session clearance manager instance.
	 *
	 * @var SessionClearanceManager
	 */
	private SessionClearanceManager $session_manager;

	/**
	 * Initialize with dependencies.
	 *
	 * @internal
	 *
	 * @param SessionClearanceManager $session_manager The session clearance manager instance.
	 */
	final public function init( SessionClearanceManager $session_manager ): void {
		$this->session_manager = $session_manager;
	}

	/**
	 * Register hooks for displaying blocked notice.
	 *
	 * This method should only be called when fraud protection is enabled.
	 *
	 * @return void
	 */
	public function register(): void {
		add_action( 'woocommerce_before_checkout_form', array( $this, 'display_checkout_blocked_notice' ), 1, 0 );
		add_action( 'before_woocommerce_add_payment_method', array( $this, 'display_generic_blocked_notice' ), 1, 0 );
	}

	/**
	 * Display blocked notice on shortcode checkout page.
	 *
	 * Shows a checkout-specific message explaining that the purchase cannot be
	 * completed online and provides contact information for support.
	 *
	 * @internal
	 *
	 * @return void
	 */
	public function display_checkout_blocked_notice(): void {
		if ( ! $this->session_manager->is_session_blocked() ) {
			return;
		}

		wc_print_notice( $this->get_message_html( 'checkout' ), 'error' );
	}

	/**
	 * Display blocked notice for non-checkout pages.
	 *
	 * Shows a generic message explaining that the request cannot be
	 * processed online and provides contact information for support.
	 *
	 * @internal
	 *
	 * @return void
	 */
	public function display_generic_blocked_notice(): void {
		if ( ! $this->session_manager->is_session_blocked() ) {
			return;
		}

		wc_print_notice( $this->get_message_html(), 'error' );
	}

	/**
	 * Get the blocked session message as HTML.
	 *
	 * Includes a mailto link for the support email.
	 *
	 * @param string $context Message context: 'checkout' for purchase-specific message, 'generic' for general use.
	 * @return string HTML message with mailto link.
	 */
	public function get_message_html( string $context = 'generic' ): string {
		$email = WC()->mailer()->get_from_address();

		if ( 'checkout' === $context ) {
			return sprintf(
				/* translators: %1$s: mailto link, %2$s: email address */
				__( 'We are unable to process this request online. Please <a href="%1$s">contact support (%2$s)</a> to complete your purchase.', 'woocommerce' ),
				esc_url( 'mailto:' . $email ),
				esc_html( $email )
			);
		}

		return sprintf(
			/* translators: %1$s: mailto link, %2$s: email address */
			__( 'We are unable to process this request online. Please <a href="%1$s">contact support (%2$s)</a> for assistance.', 'woocommerce' ),
			esc_url( 'mailto:' . $email ),
			esc_html( $email )
		);
	}

	/**
	 * Get the blocked session message as plaintext.
	 *
	 * Used by Store API responses where HTML is not supported.
	 *
	 * @param string $context Message context: 'checkout' for purchase-specific message, 'generic' for general use.
	 * @return string Plaintext message with email address.
	 */
	public function get_message_plaintext( string $context = 'generic' ): string {
		$email = WC()->mailer()->get_from_address();

		if ( 'checkout' === $context ) {
			return sprintf(
				/* translators: %s: support email address */
				__( 'We are unable to process this request online. Please contact support (%s) to complete your purchase.', 'woocommerce' ),
				$email
			);
		}

		return sprintf(
			/* translators: %s: support email address */
			__( 'We are unable to process this request online. Please contact support (%s) for assistance.', 'woocommerce' ),
			$email
		);
	}
}
PK     [1]    $  FraudProtection/CartEventTracker.phpnu         <?php
/**
 * CartEventTracker class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\FraudProtection;

defined( 'ABSPATH' ) || exit;

/**
 * Tracks cart events for fraud protection analysis.
 *
 * This class provides methods to track cart events (add, update, remove, restore)
 * for fraud protection event dispatching. Event-specific data is passed
 * to the dispatcher which handles session data collection internally.
 *
 * @since 10.5.0
 * @internal This class is part of the internal API and is subject to change without notice.
 */
class CartEventTracker {

	/**
	 * Fraud protection dispatcher instance.
	 *
	 * @var FraudProtectionDispatcher
	 */
	private FraudProtectionDispatcher $dispatcher;

	/**
	 * Initialize with dependencies.
	 *
	 * @internal
	 *
	 * @param FraudProtectionDispatcher $dispatcher The fraud protection dispatcher instance.
	 */
	final public function init( FraudProtectionDispatcher $dispatcher ): void {
		$this->dispatcher = $dispatcher;
	}

	/**
	 * Track cart page loaded event.
	 *
	 * Triggers fraud protection event dispatching when the cart page is initially loaded.
	 * This captures the initial session state before any user interactions.
	 *
	 * @internal
	 * @return void
	 */
	public function track_cart_page_loaded(): void {
		// Track the page load event. Session data will be collected by the dispatcher.
		$this->dispatcher->dispatch_event( 'cart_page_loaded', array() );
	}

	/**
	 * Track cart item added event.
	 *
	 * Triggers fraud protection event dispatching when an item is added to the cart.
	 *
	 * @internal
	 *
	 * @param string $cart_item_key  Cart item key.
	 * @param int    $product_id     Product ID.
	 * @param int    $quantity       Quantity added.
	 * @param int    $variation_id   Variation ID.
	 * @return void
	 */
	public function track_cart_item_added( $cart_item_key, $product_id, $quantity, $variation_id ): void {
		$event_data = $this->build_cart_event_data(
			'item_added',
			$product_id,
			$quantity,
			$variation_id
		);

		// Trigger event dispatching.
		$this->dispatcher->dispatch_event( 'cart_item_added', $event_data );
	}

	/**
	 * Track cart item quantity updated event.
	 *
	 * Triggers fraud protection event dispatching when cart item quantity is updated.
	 *
	 * @internal
	 *
	 * @param string $cart_item_key Cart item key.
	 * @param int    $quantity      New quantity.
	 * @param int    $old_quantity  Old quantity.
	 * @param object $cart          Cart object.
	 * @return void
	 */
	public function track_cart_item_updated( $cart_item_key, $quantity, $old_quantity, $cart ): void {
		$cart_item = $cart->cart_contents[ $cart_item_key ] ?? null;

		if ( (int) $quantity === (int) $old_quantity || ! $cart_item ) {
			return;
		}

		$product_id   = $cart_item['product_id'] ?? 0;
		$variation_id = $cart_item['variation_id'] ?? 0;

		$event_data = $this->build_cart_event_data(
			'item_updated',
			$product_id,
			(int) $quantity,
			$variation_id
		);

		// Add old quantity for context.
		$event_data['old_quantity'] = (int) $old_quantity;

		// Trigger event dispatching.
		$this->dispatcher->dispatch_event( 'cart_item_updated', $event_data );
	}

	/**
	 * Track cart item removed event.
	 *
	 * Triggers fraud protection event dispatching when an item is removed from the cart.
	 *
	 * @internal
	 *
	 * @param string $cart_item_key Cart item key.
	 * @param object $cart          Cart object.
	 * @return void
	 */
	public function track_cart_item_removed( $cart_item_key, $cart ): void {
		$cart_item = $cart->removed_cart_contents[ $cart_item_key ] ?? null;

		if ( ! $cart_item ) {
			return;
		}

		$product_id   = $cart_item['product_id'] ?? 0;
		$variation_id = $cart_item['variation_id'] ?? 0;
		$quantity     = $cart_item['quantity'] ?? 0;

		$event_data = $this->build_cart_event_data(
			'item_removed',
			$product_id,
			$quantity,
			$variation_id
		);

		// Trigger event dispatching.
		$this->dispatcher->dispatch_event( 'cart_item_removed', $event_data );
	}

	/**
	 * Track cart item restored event.
	 *
	 * Triggers fraud protection event dispatching when a removed item is restored to the cart.
	 *
	 * @internal
	 *
	 * @param string $cart_item_key Cart item key.
	 * @param object $cart          Cart object.
	 * @return void
	 */
	public function track_cart_item_restored( $cart_item_key, $cart ): void {
		$cart_item = $cart->cart_contents[ $cart_item_key ] ?? null;

		if ( ! $cart_item ) {
			return;
		}

		$product_id   = $cart_item['product_id'] ?? 0;
		$variation_id = $cart_item['variation_id'] ?? 0;
		$quantity     = $cart_item['quantity'] ?? 0;

		$event_data = $this->build_cart_event_data(
			'item_restored',
			$product_id,
			$quantity,
			$variation_id
		);

		// Trigger event dispatching.
		$this->dispatcher->dispatch_event( 'cart_item_restored', $event_data );
	}

	/**
	 * Build cart event-specific data.
	 *
	 * Prepares the cart event data including action type, product details,
	 * and current cart state. This data will be merged with comprehensive
	 * session data during event dispatching.
	 *
	 * @param string $action       Action type (item_added, item_updated, item_removed, item_restored).
	 * @param int    $product_id   Product ID.
	 * @param int    $quantity     Quantity.
	 * @param int    $variation_id Variation ID.
	 * @return array Cart event data.
	 */
	private function build_cart_event_data( string $action, int $product_id, int $quantity, int $variation_id ): array {
		$cart_item_count = 0;

		// Get current cart item count if cart is available.
		if ( WC()->cart instanceof \WC_Cart ) {
			$cart_item_count = WC()->cart->get_cart_contents_count();
		}

		return array(
			'action'          => $action,
			'product_id'      => $product_id,
			'quantity'        => $quantity,
			'variation_id'    => $variation_id,
			'cart_item_count' => $cart_item_count,
		);
	}
}
PK     [1]b    -  FraudProtection/PaymentMethodEventTracker.phpnu         <?php
/**
 * PaymentMethodEventTracker class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\FraudProtection;

defined( 'ABSPATH' ) || exit;

/**
 * Tracks payment method events for fraud protection analysis.
 *
 * This class provides methods to track events for adding payment methods in My Account page
 * for fraud protection.
 * Event-specific data is passed to the dispatcher which handles session data collection internally.
 *
 * @since 10.5.0
 * @internal This class is part of the internal API and is subject to change without notice.
 */
class PaymentMethodEventTracker {

	/**
	 * Fraud protection dispatcher instance.
	 *
	 * @var FraudProtectionDispatcher
	 */
	private FraudProtectionDispatcher $dispatcher;

	/**
	 * Initialize with dependencies.
	 *
	 * @internal
	 *
	 * @param FraudProtectionDispatcher $dispatcher The fraud protection dispatcher instance.
	 */
	final public function init( FraudProtectionDispatcher $dispatcher ): void {
		$this->dispatcher = $dispatcher;
	}

	/**
	 * Track add payment method page loaded event.
	 *
	 * Triggers fraud protection event dispatching when the add payment method page is initially loaded.
	 * This captures the initial session state before any user interactions.
	 *
	 * @internal
	 * @return void
	 */
	public function track_add_payment_method_page_loaded(): void {
		// Track the page load event. Session data will be collected by the dispatcher.
		$this->dispatcher->dispatch_event( 'add_payment_method_page_loaded', array() );
	}

	/**
	 * Track payment method added event.
	 *
	 * Triggers fraud protection event tracking when a payment method is added.
	 *
	 * @internal
	 *
	 * @param int               $token_id The newly created token ID.
	 * @param \WC_Payment_Token $token    The payment token object.
	 */
	public function track_payment_method_added( $token_id, $token ): void {
		$event_data = $this->build_payment_method_event_data( 'added', $token );

		// Trigger event dispatching.
		$this->dispatcher->dispatch_event( 'payment_method_added', $event_data );
	}

	/**
	 * Build payment method event-specific data.
	 *
	 * Extracts relevant information from the payment token object including
	 * token type, gateway ID, user ID, and card details for card tokens.
	 * This data will be merged with comprehensive session data during event tracking.
	 *
	 * @param string            $action Action type (added, updated, set_default, deleted, add_failed).
	 * @param \WC_Payment_Token $token  The payment token object.
	 * @return array Payment method event data.
	 */
	private function build_payment_method_event_data( string $action, \WC_Payment_Token $token ): array {
		$event_data = array(
			'action'     => $action,
			'token_id'   => $token->get_id(),
			'token_type' => $token->get_type(),
			'gateway_id' => $token->get_gateway_id(),
			'user_id'    => $token->get_user_id(),
			'is_default' => $token->is_default(),
		);

		// Add card-specific details if this is a credit card token.
		if ( $token instanceof \WC_Payment_Token_CC ) {
			$event_data['card_type']    = $token->get_card_type();
			$event_data['card_last4']   = $token->get_last4();
			$event_data['expiry_month'] = $token->get_expiry_month();
			$event_data['expiry_year']  = $token->get_expiry_year();
		}

		return $event_data;
	}
}
PK     [1]`:  :    Traits/ScriptDebug.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Traits;

use Automattic\Jetpack\Constants;

/**
 * Trait ScriptDebug
 *
 * @since 8.5.0
 */
trait ScriptDebug {

	/**
	 * Get the script suffix based on the SCRIPT_DEBUG constant.
	 *
	 * @return string
	 */
	protected function get_script_suffix(): string {
		return $this->is_script_debug_enabled() ? '' : '.min';
	}

	/**
	 * Check if SCRIPT_DEBUG is enabled.
	 *
	 * @return bool
	 */
	protected function is_script_debug_enabled(): bool {
		return Constants::is_true( 'SCRIPT_DEBUG' );
	}
}
PK     [1]
	V  V    Traits/RestApiCache.phpnu         <?php
declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Traits;

use Automattic\WooCommerce\Internal\Caches\VersionStringGenerator;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\CallbackUtil;
use WP_REST_Request;
use WP_REST_Response;

/**
 * This trait provides caching capabilities for REST API endpoints using the WordPress cache.
 *
 * - The output of all the REST API endpoints whose callback declaration is wrapped
 *   in a call to 'with_cache' will be cached using wp_cache_* functions.
 * - Response headers are cached together with the response data, excluding certain fixed
 *   headers (like Set-Cookie) and optionally others specified via configuration
 *   (per-controller or per-endpoint).
 * - For the purposes of caching, a request is uniquely identified by its route,
 *   HTTP method, query string, and user ID.
 * - The VersionStringGenerator class is used to track versions of entities included
 *   in the responses (an "entity" is any object that is uniquely identified by type and id
 *   and contributes with information to be included in the response),
 *   so that when those entities change, the relevant cached responses become invalid.
 *   Modification of entity versions must be done externally by the code that modifies
 *   those entities (via calls to VersionStringGenerator::generate_version).
 * - Various parameters (cached outputs TTL, entity type for a given response, hooks that affect
 *   the response) can be configured globally for the controller (via overriding protected methods)
 *   or per-endpoint (via arguments passed to with_cache).
 * - Caching can be disabled for a given request by adding a '_skip_cache=true|1'
 *   to the query string.
 * - A X-WC-Cache HTTP header is added to responses to indicate cache status:
 *   HIT, MISS, or SKIP.
 *
 * Additionally to caching, this trait also handles the sending of appropriate
 * Cache-Control and ETag headers to instruct clients and proxies on how to cache responses.
 * The ETag is generated based on the cached response data and cache key, and a request
 * containing an If-None-Match header with a matching ETag will receive a 304 Not Modified response.
 *
 * Usage: Wrap endpoint callbacks with the `with_cache()` method when registering routes.
 *
 * Example:
 *
 * class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 *     use RestApiCache;
 *
 *     public function __construct() {
 *         parent::__construct();
 *         $this->initialize_rest_api_cache();  // REQUIRED
 *     }
 *
 *     protected function get_default_response_entity_type(): ?string {
 *         return 'product';  // REQUIRED (or specify entity_type in each with_cache call)
 *     }
 *
 *     public function register_routes() {
 *         register_rest_route(
 *             $this->namespace,
 *             '/' . $this->rest_base . '/(?P<id>[\d]+)',
 *             array(
 *                 'methods'  => WP_REST_Server::READABLE,
 *                 'callback' => $this->with_cache(
 *                     array( $this, 'get_item' ),
 *                     array(
 *                         // String, optional if get_default_response_entity_type() is overridden.
 *                         'entity_type'    => 'product',
 *                         // Optional int, defaults to the controller's get_ttl_for_cached_response().
 *                         'cache_ttl'      => HOUR_IN_SECONDS,
 *                         // Optional array, defaults to the controller's get_hooks_relevant_to_caching().
 *                         'relevant_hooks'  => array( 'filter_name_1', 'filter_name_2' ),
 *                         // Optional bool, defaults to the controller's response_cache_vary_by_user().
 *                         'vary_by_user'    => true,
 *                         // Optional array, defaults to the controller's get_response_headers_to_include_in_caching().
 *                         'include_headers' => array( 'X-Custom-Header' ),
 *                         // Optional array, defaults to the controller's get_response_headers_to_exclude_from_caching().
 *                         'exclude_headers' => array( 'X-Private-Header' ),
 *                         // Optional, this will be passed to all the caching-related methods.
 *                         'endpoint_id'     => 'get_product'
 *                     )
 *                 ),
 *             )
 *         );
 *     }
 * }
 *
 * Override these methods in your controller as needed:
 * - get_default_response_entity_type(): Default entity type for endpoints without explicit config.
 * - response_cache_vary_by_user(): Whether cache should be user-specific.
 * - get_hooks_relevant_to_caching(): Hook names to track for cache invalidation.
 * - get_ttl_for_cached_response(): TTL for cached outputs in seconds.
 * - get_response_headers_to_include_in_caching(): Headers to include in cache (false = use exclusion mode).
 * - get_response_headers_to_exclude_from_caching(): Headers to exclude from cache (when in exclusion mode).
 *
 * Cache invalidation happens when:
 * - Entity versions change (tracked via VersionStringGenerator).
 * - Hook callbacks change
 *   (if the `get_hooks_relevant_to_caching()` call result or the 'relevant_hooks' array isn't empty).
 * - Cached response TTL expires.
 *
 * NOTE: This caching mechanism uses the WordPress cache (wp_cache_* functions).
 * By default caching is only enabled when an external object cache is enabled
 * (checked via call to VersionStringGenerator::can_use()), so the cache is persistent
 * across requests and not just for the current request.
 *
 * @since 10.5.0
 */
trait RestApiCache {
	/**
	 * Cache group name for REST API responses.
	 *
	 * @var string
	 */
	private static string $cache_group = 'woocommerce_rest_api_cache';

	/**
	 * Response headers that are always excluded from caching.
	 *
	 * @var array
	 */
	private static array $always_excluded_headers = array(
		'X-WC-Cache',
		'Set-Cookie',
		'Date',
		'Expires',
		'Last-Modified',
		'Age',
		'ETag',
		'Cache-Control',
		'Pragma',
	);

	/**
	 * The instance of VersionStringGenerator to use, or null if caching is disabled.
	 *
	 * @var VersionStringGenerator|null
	 */
	private ?VersionStringGenerator $version_string_generator = null;

	/**
	 * Whether we are currently handling a cached endpoint.
	 *
	 * @var bool
	 */
	private $is_handling_cached_endpoint = false;

	/**
	 * Whether the REST API caching feature is enabled.
	 *
	 * @var bool
	 */
	private bool $rest_api_caching_feature_enabled = false;

	/**
	 * Initialize the trait.
	 * This MUST be called from the controller's constructor.
	 */
	protected function initialize_rest_api_cache(): void {
		// Guard against early instantiation before WooCommerce is fully initialized.
		// Some third-party plugins instantiate REST controllers during plugin loading,
		// before the WooCommerce container is available.
		if ( ! function_exists( 'wc_get_container' ) ) {
			return;
		}

		$features_controller = wc_get_container()->get( FeaturesController::class );

		$this->rest_api_caching_feature_enabled = $features_controller->feature_is_enabled( 'rest_api_caching' );
		if ( ! $this->rest_api_caching_feature_enabled ) {
			return;
		}

		$generator = wc_get_container()->get( VersionStringGenerator::class );

		$backend_caching_enabled        = 'yes' === get_option( 'woocommerce_rest_api_enable_backend_caching', 'no' );
		$this->version_string_generator = ( $backend_caching_enabled && $generator->can_use() ) ? $generator : null;

		add_filter( 'rest_send_nocache_headers', array( $this, 'handle_rest_send_nocache_headers' ), 10, 1 );
	}

	/**
	 * Wrap an endpoint callback declaration with caching logic.
	 * Usage: `'callback' => $this->with_cache( array( $this, 'endpoint_callback_method' ) )`
	 *        `'callback' => $this->with_cache( array( $this, 'endpoint_callback_method' ), [ 'entity_type' => 'product' ] )`
	 *
	 * @param callable $callback The original endpoint callback.
	 * @param array    $config   Caching configuration:
	 *                           - entity_type: string (falls back to get_default_response_entity_type()).
	 *                           - vary_by_user: bool (defaults to response_cache_vary_by_user()).
	 *                           - endpoint_id: string|null (optional friendly identifier for the endpoint).
	 *                           - cache_ttl: int (defaults to get_ttl_for_cached_response()).
	 *                           - relevant_hooks: array (defaults to get_hooks_relevant_to_caching()).
	 *                           - include_headers: array|false (defaults to get_response_headers_to_include_in_caching()).
	 *                           - exclude_headers: array (defaults to get_response_headers_to_exclude_from_caching()).
	 * @return callable Wrapped callback.
	 */
	protected function with_cache( callable $callback, array $config = array() ): callable {
		return $this->rest_api_caching_feature_enabled
			? fn( $request ) => $this->handle_cacheable_request( $request, $callback, $config )
			: fn( $request ) => call_user_func( $callback, $request );
	}

	/**
	 * Handle a request with caching logic.
	 *
	 * Strategy:
	 * - If backend caching is enabled: Try to use cached response if available, otherwise execute
	 *   the callback and cache the response.
	 * - If only cache headers are enabled: Execute the callback, generate ETag, and return 304
	 *   if the client's ETag matches.
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request  The request object.
	 * @param callable                              $callback The original endpoint callback.
	 * @param array                                 $config   Caching configuration specified for the endpoint.
	 *
	 * @return WP_REST_Response|\WP_Error The response.
	 */
	private function handle_cacheable_request( WP_REST_Request $request, callable $callback, array $config ) { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
		$backend_caching_enabled = ! is_null( $this->version_string_generator );
		$cache_headers_enabled   = 'yes' === get_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' );

		if ( ! $backend_caching_enabled && ! $cache_headers_enabled ) {
			return call_user_func( $callback, $request );
		}

		$cached_config     = null;
		$should_skip_cache = ! $this->should_use_cache_for_request( $request );
		if ( ! $should_skip_cache ) {
			$cached_config     = $this->build_cache_config( $request, $config );
			$should_skip_cache = is_null( $cached_config );
		}

		if ( $should_skip_cache || is_null( $cached_config ) ) {
			$response = call_user_func( $callback, $request );
			if ( ! is_wp_error( $response ) ) {
				$response = rest_ensure_response( $response );
				$response->header( 'X-WC-Cache', 'SKIP' );
			}
			return $response;
		}

		$this->is_handling_cached_endpoint = true;

		if ( $backend_caching_enabled ) {
			$cached_response = $this->get_cached_response( $request, $cached_config, $cache_headers_enabled );

			if ( $cached_response ) {
				$cached_response->header( 'X-WC-Cache', 'HIT' );
				return $cached_response;
			}
		}

		$authoritative_response = call_user_func( $callback, $request );

		return $backend_caching_enabled
			? $this->maybe_cache_response( $request, $authoritative_response, $cached_config, $cache_headers_enabled )
			: $this->maybe_add_cache_headers( $request, $authoritative_response, $cached_config );
	}

	/**
	 * Check if caching should be used for a particular incoming request.
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request The request object.
	 *
	 * @return bool True if caching should be used, false otherwise.
	 */
	private function should_use_cache_for_request( WP_REST_Request $request ): bool { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
		$skip_cache   = $request->get_param( '_skip_cache' );
		$should_cache = ! ( 'true' === $skip_cache || '1' === $skip_cache );

		/**
		 * Filter whether to enable response caching for a given REST API controller.
		 *
		 * @since 10.5.0
		 *
		 * @param bool            $enable_caching Whether to enable response caching (result of !_skip_cache evaluation).
		 * @param object          $controller     The controller instance.
		 * @param WP_REST_Request<array<string, mixed>> $request        The request object.
		 * @return bool True to enable response caching, false to disable.
		 */
		return apply_filters(
			'woocommerce_rest_api_enable_response_caching',
			$should_cache,
			$this,
			$request
		);
	}

	/**
	 * Build the output cache entry configuration from the request and per-endpoint config.
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request The request object.
	 * @param array                                 $config  Raw configuration array passed to with_cache.
	 *
	 * @return array|null Normalized cache config with keys: endpoint_id, entity_type, vary_by_user, cache_ttl, relevant_hooks, include_headers, exclude_headers, cache_key. Returns null if entity type is not available.
	 *
	 * @throws \InvalidArgumentException If include_headers is not false or an array.
	 */
	private function build_cache_config( WP_REST_Request $request, array $config ): ?array { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
		$endpoint_id  = $config['endpoint_id'] ?? null;
		$entity_type  = $config['entity_type'] ?? $this->get_default_response_entity_type();
		$vary_by_user = $config['vary_by_user'] ?? $this->response_cache_vary_by_user( $request, $endpoint_id );

		if ( ! $entity_type ) {
			$legacy_proxy = wc_get_container()->get( LegacyProxy::class );
			$legacy_proxy->call_function(
				'wc_doing_it_wrong',
				__METHOD__,
				'No entity type provided and no default entity type available. Skipping cache.',
				'10.5.0'
			);
			return null;
		}

		$include_headers = $config['include_headers'] ?? $this->get_response_headers_to_include_in_caching( $request, $endpoint_id );
		if ( false !== $include_headers && ! is_array( $include_headers ) ) {
			throw new \InvalidArgumentException(
				'include_headers must be either false or an array, ' . gettype( $include_headers ) . ' given.' // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
			);
		}

		return array(
			'endpoint_id'     => $endpoint_id,
			'entity_type'     => $entity_type,
			'vary_by_user'    => $vary_by_user,
			'cache_ttl'       => $config['cache_ttl'] ?? $this->get_ttl_for_cached_response( $request, $endpoint_id ),
			'relevant_hooks'  => $config['relevant_hooks'] ?? $this->get_hooks_relevant_to_caching( $request, $endpoint_id ),
			'include_headers' => $include_headers,
			'exclude_headers' => $config['exclude_headers'] ?? $this->get_response_headers_to_exclude_from_caching( $request, $endpoint_id ),
			'cache_key'       => $this->get_key_for_cached_response( $request, $entity_type, $vary_by_user, $endpoint_id ),
		);
	}

	/**
	 * Cache the response if it's successful and optionally add cache headers.
	 *
	 * Only caches responses with 2xx status codes. Always adds the X-WC-Cache header
	 * with value MISS if the response was cached, or SKIP if it was not cached.
	 *
	 * Supports both WP_REST_Response objects and raw data (which will be wrapped in a response object).
	 * Error objects are returned as-is without caching.
	 *
	 * @param WP_REST_Request<array<string, mixed>>   $request            The request object.
	 * @param WP_REST_Response|\WP_Error|array|object $response           The response to potentially cache.
	 * @param array                                   $cached_config      Caching configuration from build_cache_config().
	 * @param bool                                    $add_cache_headers  Whether to add cache control headers.
	 *
	 * @return WP_REST_Response|\WP_Error The response with appropriate cache headers.
	 */
	private function maybe_cache_response( WP_REST_Request $request, $response, array $cached_config, bool $add_cache_headers ) { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
		if ( is_wp_error( $response ) ) {
			return $response;
		}

		$response = rest_ensure_response( $response );

		$cached = false;

		$status = $response->get_status();
		if ( $status >= 200 && $status <= 299 ) {
			$data       = $response->get_data();
			$entity_ids = is_array( $data ) ? $this->extract_entity_ids_from_response( $data, $request, $cached_config['endpoint_id'] ) : array();

			$response_headers  = $response->get_headers();
			$cacheable_headers = $this->get_headers_to_cache(
				$response_headers,
				$cached_config['include_headers'],
				$cached_config['exclude_headers'],
				$request,
				$response,
				$cached_config['endpoint_id']
			);

			$etag_data = is_array( $data ) ? $this->get_data_for_etag( $data, $request, $cached_config['endpoint_id'] ) : $data;
			$etag      = '"' . md5( $cached_config['cache_key'] . wp_json_encode( $etag_data ) ) . '"';

			$this->store_cached_response(
				$cached_config['cache_key'],
				$data,
				$status,
				$cached_config['entity_type'],
				$entity_ids,
				$cached_config['cache_ttl'],
				$cached_config['relevant_hooks'],
				$cacheable_headers,
				$etag
			);

			$cached = true;
		}

		$response->header( 'X-WC-Cache', $cached ? 'MISS' : 'SKIP' );

		return $add_cache_headers ?
			$this->maybe_add_cache_headers( $request, $response, $cached_config ) :
			$response;
	}

	/**
	 * Add cache control headers to a response.
	 *
	 * This method generates an ETag from the response data and returns a 304 Not Modified
	 * if the client's If-None-Match header matches. It can be used both with and without
	 * backend caching.
	 *
	 * @param WP_REST_Request<array<string, mixed>>   $request       The request object.
	 * @param WP_REST_Response|\WP_Error|array|object $response      The response to add headers to.
	 * @param array                                   $cached_config Caching configuration from build_cache_config().
	 *
	 * @return WP_REST_Response|\WP_Error The response with cache headers.
	 */
	private function maybe_add_cache_headers( WP_REST_Request $request, $response, array $cached_config ) { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
		if ( is_wp_error( $response ) ) {
			return $response;
		}

		$response = rest_ensure_response( $response );

		$status = $response->get_status();
		if ( $status < 200 || $status > 299 ) {
			return $response;
		}

		$response_data      = $response->get_data();
		$response_etag_data = is_array( $response_data ) ? $this->get_data_for_etag( $response_data, $request, $cached_config['endpoint_id'] ) : $response_data;
		$response_etag      = '"' . md5( $cached_config['cache_key'] . wp_json_encode( $response_etag_data ) ) . '"';

		$request_etag = $request->get_header( 'if-none-match' );

		$legacy_proxy        = wc_get_container()->get( LegacyProxy::class );
		$is_user_logged_in   = $legacy_proxy->call_function( 'is_user_logged_in' );
		$cache_visibility    = $cached_config['vary_by_user'] && $is_user_logged_in ? 'private' : 'public';
		$cache_control_value = $cache_visibility . ', must-revalidate, max-age=' . $cached_config['cache_ttl'];

		if ( $request_etag === $response_etag ) {
			$not_modified_response = $this->create_not_modified_response( $response_etag, $cache_control_value, $request, $cached_config['endpoint_id'] );
			if ( $not_modified_response ) {
				return $not_modified_response;
			}
		}

		$response->header( 'ETag', $response_etag );
		$response->header( 'Cache-Control', $cache_control_value );

		if ( ! array_key_exists( 'X-WC-Cache', $response->get_headers() ) ) {
			$response->header( 'X-WC-Cache', 'HEADERS' );
		}

		return $response;
	}

	/**
	 * Create a 304 Not Modified response if allowed by filters.
	 *
	 * @param string                                $etag                The ETag value.
	 * @param string                                $cache_control_value The Cache-Control header value.
	 * @param WP_REST_Request<array<string, mixed>> $request             The request object.
	 * @param string|null                           $endpoint_id         The endpoint identifier.
	 *
	 * @return WP_REST_Response|null 304 response if allowed, null otherwise.
	 */
	private function create_not_modified_response( string $etag, string $cache_control_value, WP_REST_Request $request, ?string $endpoint_id ): ?WP_REST_Response { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
		$response = new WP_REST_Response( null, 304 );
		$response->header( 'ETag', $etag );
		$response->header( 'Cache-Control', $cache_control_value );
		$response->header( 'X-WC-Cache', 'MATCH' );

		/**
		 * Filter the 304 Not Modified response before sending.
		 *
		 * @since 10.5.0
		 *
		 * @param WP_REST_Response|false $response    The 304 response object, or false to prevent sending it.
		 * @param WP_REST_Request        $request     The request object.
		 * @param string|null            $endpoint_id The endpoint identifier.
		 */
		$filtered_response = apply_filters( 'woocommerce_rest_api_not_modified_response', $response, $request, $endpoint_id );

		return false === $filtered_response ? null : rest_ensure_response( $filtered_response );
	}

	/**
	 * Get the default type for entities included in responses.
	 *
	 * This can be customized per-endpoint via the config array
	 * passed to with_cache() ('entity_type' key).
	 *
	 * @return string|null Entity type (e.g., 'product', 'order'), or null if no controller-wide default.
	 */
	protected function get_default_response_entity_type(): ?string {
		return null;
	}

	/**
	 * Get data for ETag generation.
	 *
	 * Override in classes to exclude fields that change on each request
	 * (e.g., random recommendations, timestamps).
	 *
	 * @param array                                 $data        Response data.
	 * @param WP_REST_Request<array<string, mixed>> $request     The request object.
	 * @param string|null                           $endpoint_id Optional friendly identifier for the endpoint.
	 *
	 * @return array Cleaned data for ETag generation.
	 */
	protected function get_data_for_etag( array $data, WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
		return $data;
	}

	/**
	 * Whether the response cache should vary by user.
	 *
	 * When true, each user gets their own cached version of the response.
	 * When false, the same cached response is shared across all users.
	 *
	 * This can be customized per-endpoint via the config array
	 * passed to with_cache() ('vary_by_user' key).
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request     The request object.
	 * @param string|null                           $endpoint_id Optional friendly identifier for the endpoint.
	 *
	 * @return bool True to make cache user-specific, false otherwise.
	 */
	protected function response_cache_vary_by_user( WP_REST_Request $request, ?string $endpoint_id = null ): bool { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
		return true;
	}

	/**
	 * Get the cache TTL (time to live) for cached responses.
	 *
	 * This can be customized per-endpoint via the config array
	 * passed to with_cache() ('cache_ttl' key).
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request     The request object.
	 * @param string|null                           $endpoint_id Optional friendly identifier for the endpoint.
	 *
	 * @return int Cache TTL in seconds.
	 */
	protected function get_ttl_for_cached_response( WP_REST_Request $request, ?string $endpoint_id = null ): int { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
		return HOUR_IN_SECONDS;
	}

	/**
	 * Get the names of hooks (filters and actions) that can customize the response.
	 *
	 * All the existing instances of add_action/add_filter for these hooks
	 * will be included in the information that gets cached together with the response,
	 * and if any of these has changed when the cached response is retrieved,
	 * the cache entry will be invalidated.
	 *
	 * This can be customized per-endpoint via the config array
	 * passed to with_cache() ('relevant_hooks' key).
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request     Request object.
	 * @param string|null                           $endpoint_id Optional friendly identifier for the endpoint.
	 *
	 * @return array Array of hook names to track.
	 */
	protected function get_hooks_relevant_to_caching( WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
		return array();
	}

	/**
	 * Get the names of response headers to include in caching.
	 *
	 * When this returns an array, ONLY the headers whose names are returned
	 * will be included in the cache (subject to always-excluded headers).
	 * When this returns false, all headers will be included except those returned
	 * by get_response_headers_to_exclude_from_caching().
	 *
	 * This can be customized per-endpoint via the config array
	 * passed to with_cache() ('include_headers' key).
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request     Request object.
	 * @param string|null                           $endpoint_id Optional friendly identifier for the endpoint.
	 *
	 * @return array|false Array of header names to include (case-insensitive), or false to use exclusion logic.
	 */
	protected function get_response_headers_to_include_in_caching( WP_REST_Request $request, ?string $endpoint_id = null ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
		return false;
	}

	/**
	 * Get the names of response headers to exclude from caching.
	 *
	 * These headers will not be stored in the cache, in addition to the
	 * always-excluded headers (X-WC-Cache, Set-Cookie, Date, Expires, Last-Modified,
	 * Age, ETag, Cache-Control, Pragma).
	 *
	 * This is only used when get_response_headers_to_include_in_caching() returns false.
	 *
	 * This can be customized per-endpoint via the config array
	 * passed to with_cache() ('exclude_headers' key).
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request     Request object.
	 * @param string|null                           $endpoint_id Optional friendly identifier for the endpoint.
	 *
	 * @return array Array of header names to exclude (case-insensitive).
	 */
	protected function get_response_headers_to_exclude_from_caching( WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
		return array();
	}

	/**
	 * Extract entity IDs from response data.
	 *
	 * This implementation assumes the response is either:
	 * - An array with an 'id' field (single item)
	 * - An array of arrays each having an 'id' field (collection)
	 *
	 * Controllers can override this method to customize entity ID extraction.
	 *
	 * @param array                                 $response_data Response data.
	 * @param WP_REST_Request<array<string, mixed>> $request       The request object.
	 * @param string|null                           $endpoint_id   Optional friendly identifier for the endpoint.
	 *
	 * @return array Array of entity IDs.
	 */
	protected function extract_entity_ids_from_response( array $response_data, WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
		$ids = array();

		if ( isset( $response_data[0] ) && is_array( $response_data[0] ) ) {
			foreach ( $response_data as $item ) {
				if ( isset( $item['id'] ) ) {
					$ids[] = $item['id'];
				}
			}
		} elseif ( isset( $response_data['id'] ) ) {
			$ids[] = $response_data['id'];
		}

		// Filter out false values but keep 0 and empty strings as they could be valid IDs.
		// Note: null values can't exist here because isset() checks above exclude them.
		return array_unique(
			array_filter( $ids, fn ( $id ) => false !== $id )
		);
	}

	/**
	 * Filter response headers to get only those that should be cached.
	 *
	 * The filtering process follows these steps:
	 * 1. If $include_headers is an array, only those headers are included (case-insensitive).
	 *    If $include_headers is false, all headers are included except those in $exclude_headers.
	 * 2. Always-excluded headers (X-WC-Cache, Set-Cookie, Date, etc.) are removed.
	 * 3. The woocommerce_rest_api_cached_headers filter is applied, receiving both the candidate
	 *    headers list and all available headers. This allows filters to both add and remove
	 *    headers from the caching list.
	 * 4. Always-excluded headers are enforced again post-filter to prevent filters from
	 *    re-introducing dangerous headers like Set-Cookie.
	 * 5. Only headers from the response that are in the filtered list are returned.
	 *
	 * @param array                                 $nominal_headers Response headers.
	 * @param array|false                           $include_headers Header names to include (false to use exclusion logic).
	 * @param array                                 $exclude_headers Header names to exclude (case-insensitive).
	 * @param WP_REST_Request<array<string, mixed>> $request The request object.
	 * @param WP_REST_Response                      $response        The response object.
	 * @param string|null                           $endpoint_id     Optional friendly identifier for the endpoint.
	 *
	 * @return array Filtered headers array.
	 */
	private function get_headers_to_cache( array $nominal_headers, $include_headers, array $exclude_headers, WP_REST_Request $request, WP_REST_Response $response, ?string $endpoint_id ): array { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
		// Step 1: Determine which headers to consider based on include/exclude.
		if ( false !== $include_headers ) {
			$include_headers_lowercase = array_map( 'strtolower', $include_headers );
			$headers_to_cache          = array_filter(
				$nominal_headers,
				fn( $name ) => in_array( strtolower( $name ), $include_headers_lowercase, true ),
				ARRAY_FILTER_USE_KEY
			);
		} else {
			$exclude_headers_lowercase = array_map( 'strtolower', $exclude_headers );
			$headers_to_cache          = array_filter(
				$nominal_headers,
				fn( $name ) => ! in_array( strtolower( $name ), $exclude_headers_lowercase, true ),
				ARRAY_FILTER_USE_KEY
			);
		}

		// Step 2: Remove always-excluded headers.
		$always_exclude_lowercase = array_map( 'strtolower', self::$always_excluded_headers );
		$headers_to_cache         = array_filter(
			$headers_to_cache,
			fn( $name ) => ! in_array( strtolower( $name ), $always_exclude_lowercase, true ),
			ARRAY_FILTER_USE_KEY
		);

		// Step 3: Apply filter to header names.
		$cached_header_names = array_keys( $headers_to_cache );
		$all_header_names    = array_keys( $nominal_headers );

		/**
		 * Filter the list of response header names to cache.
		 *
		 * @since 10.5.0
		 *
		 * @param array            $cached_header_names Candidate list of header names to cache.
		 * @param array            $all_header_names    All header names available in the response.
		 * @param WP_REST_Request  $request             The request object.
		 * @param WP_REST_Response $response            The response object.
		 * @param string|null      $endpoint_id         Optional friendly identifier for the endpoint.
		 * @param object           $controller          The controller instance.
		 *
		 * @return array Filtered list of header names to cache.
		 */
		$filtered_header_names = apply_filters(
			'woocommerce_rest_api_cached_headers',
			$cached_header_names,
			$all_header_names,
			$request,
			$response,
			$endpoint_id,
			$this
		);

		// Step 4: Enforce always-excluded headers post-filter.
		$filtered_header_names_lowercase = array_map( 'strtolower', $filtered_header_names );
		$reintroduced_headers            = array_filter(
			$filtered_header_names,
			fn( $name ) => in_array( strtolower( $name ), $always_exclude_lowercase, true )
		);

		if ( ! empty( $reintroduced_headers ) ) {
			$legacy_proxy = wc_get_container()->get( LegacyProxy::class );
			$legacy_proxy->call_function(
				'wc_doing_it_wrong',
				__METHOD__,
				sprintf(
					/* translators: %s: comma-separated list of header names */
					'The woocommerce_rest_api_cached_headers filter attempted to cache always-excluded headers: %s. These headers have been removed for security reasons.',
					implode( ', ', $reintroduced_headers )
				),
				'10.5.0'
			);

			$filtered_header_names_lowercase = array_filter(
				$filtered_header_names_lowercase,
				fn( $name ) => ! in_array( $name, $always_exclude_lowercase, true )
			);
		}

		// Step 5: Return only the headers that are in the filtered list.
		return array_filter(
			$nominal_headers,
			fn( $name ) => in_array( strtolower( $name ), $filtered_header_names_lowercase, true ),
			ARRAY_FILTER_USE_KEY
		);
	}

	/**
	 * Get cache key information that uniquely identifies a request.
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request      The request object.
	 * @param bool                                  $vary_by_user Whether to include user ID in cache key.
	 * @param string|null                           $endpoint_id  Optional friendly identifier for the endpoint.
	 *
	 * @return array Array of cache key information parts.
	 */
	protected function get_key_info_for_cached_response( WP_REST_Request $request, bool $vary_by_user = false, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
		$request_query_params = $request->get_query_params();
		if ( is_array( $request_query_params ) ) {
			ksort( $request_query_params );
		}

		$cache_key_parts = array(
			$request->get_route(),
			$request->get_method(),
			wp_json_encode( $request_query_params ),
		);

		if ( $vary_by_user ) {
			$legacy_proxy = wc_get_container()->get( LegacyProxy::class );
			// @phpstan-ignore-next-line argument.type -- get_current_user_id returns int at runtime.
			$user_id           = intval( $legacy_proxy->call_function( 'get_current_user_id' ) );
			$cache_key_parts[] = "user_{$user_id}";
		}

		return $cache_key_parts;
	}

	/**
	 * Generate a cache key for a given request.
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request      The request object.
	 * @param string                                $entity_type  The entity type.
	 * @param bool                                  $vary_by_user Whether to include user ID in cache key.
	 * @param string|null                           $endpoint_id  Optional friendly identifier for the endpoint.
	 *
	 * @return string Cache key.
	 */
	private function get_key_for_cached_response( WP_REST_Request $request, string $entity_type, bool $vary_by_user = false, ?string $endpoint_id = null ): string { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
		$cache_key_parts = $this->get_key_info_for_cached_response( $request, $vary_by_user, $endpoint_id );

		/**
		 * Filter the information used to generate the cache key for a REST API request.
		 *
		 * Allows customization of what uniquely identifies a request for caching purposes.
		 *
		 * @since 10.5.0
		 *
		 * @param array           $cache_key_parts Array of cache key information parts.
		 * @param WP_REST_Request<array<string, mixed>> $request         The request object.
		 * @param bool            $vary_by_user    Whether user ID is included in cache key.
		 * @param string|null     $endpoint_id     Optional friendly identifier for the endpoint (passed to with_cache).
		 * @param object          $controller      The controller instance.
		 *
		 * @return array Filtered cache key information parts.
		 */
		$cache_key_parts = apply_filters(
			'woocommerce_rest_api_cache_key_info',
			$cache_key_parts,
			$request,
			$vary_by_user,
			$endpoint_id,
			$this
		);

		$request_hash = md5( implode( '-', $cache_key_parts ) );
		return "wc_rest_api_cache_{$entity_type}-{$request_hash}";
	}

	/**
	 * Generate a hash based on the actual usages of the hooks that affect the response.
	 *
	 * @param array $hook_names Array of hook names to track.
	 *
	 * @return string Hooks hash.
	 */
	private function generate_hooks_hash( array $hook_names ): string {
		if ( empty( $hook_names ) ) {
			return '';
		}

		$cache_hash_data = array();

		foreach ( $hook_names as $hook_name ) {
			$signatures = CallbackUtil::get_hook_callback_signatures( $hook_name );
			if ( ! empty( $signatures ) ) {
				$cache_hash_data[ $hook_name ] = $signatures;
			}
		}

		/**
		 * Filter the data used to generate the hooks hash for REST API response caching.
		 *
		 * @since 10.5.0
		 *
		 * @param array  $cache_hash_data Hook callbacks data used for hash generation.
		 * @param array  $hook_names      Hook names being tracked.
		 * @param object $controller      Controller instance.
		 */
		$cache_hash_data = apply_filters(
			'woocommerce_rest_api_cache_hooks_hash_data',
			$cache_hash_data,
			$hook_names,
			$this
		);

		$json = wp_json_encode( $cache_hash_data );
		return md5( false === $json ? '' : $json );
	}

	/**
	 * Get a cached response, but only if it's valid (otherwise the cached response will be invalidated).
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request              The request object.
	 * @param array                                 $cached_config        Built caching configuration from build_cache_config().
	 * @param bool                                  $cache_headers_enabled Whether to add cache control headers.
	 *
	 * @return WP_REST_Response|null Cached response, or null if not available or has been invalidated.
	 */
	private function get_cached_response( WP_REST_Request $request, array $cached_config, bool $cache_headers_enabled ): ?WP_REST_Response { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
		$cache_key      = $cached_config['cache_key'];
		$entity_type    = $cached_config['entity_type'];
		$cache_ttl      = $cached_config['cache_ttl'];
		$relevant_hooks = $cached_config['relevant_hooks'];

		$found  = false;
		$cached = wp_cache_get( $cache_key, self::$cache_group, false, $found );

		if ( ! $found || ! is_array( $cached ) || ! array_key_exists( 'data', $cached ) || ! isset( $cached['entity_versions'], $cached['created_at'] ) ) {
			return null;
		}

		$legacy_proxy    = wc_get_container()->get( LegacyProxy::class );
		$current_time    = $legacy_proxy->call_function( 'time' );
		$expiration_time = $cached['created_at'] + $cache_ttl;
		if ( $current_time >= $expiration_time ) {
			wp_cache_delete( $cache_key, self::$cache_group );
			return null;
		}

		if ( ! empty( $relevant_hooks ) ) {
			$current_hooks_hash = $this->generate_hooks_hash( $relevant_hooks );
			$cached_hooks_hash  = $cached['hooks_hash'] ?? '';

			if ( $current_hooks_hash !== $cached_hooks_hash ) {
				wp_cache_delete( $cache_key, self::$cache_group );
				return null;
			}
		}

		if ( ! is_null( $this->version_string_generator ) ) {
			foreach ( $cached['entity_versions'] as $entity_id => $cached_version ) {
				$version_id      = "{$entity_type}_{$entity_id}";
				$current_version = $this->version_string_generator->get_version( $version_id );
				if ( $current_version !== $cached_version ) {
					wp_cache_delete( $cache_key, self::$cache_group );
					return null;
				}
			}
		}

		// At this point the cached response is valid.

		// Check if client sent an ETag and it matches - if so, return 304 Not Modified.
		$cached_etag  = $cached['etag'] ?? '';
		$request_etag = $request->get_header( 'if-none-match' );

		$response_headers = array();

		if ( $cache_headers_enabled ) {
			$legacy_proxy      = wc_get_container()->get( LegacyProxy::class );
			$is_user_logged_in = $legacy_proxy->call_function( 'is_user_logged_in' );
			$cache_visibility  = $cached_config['vary_by_user'] && $is_user_logged_in ? 'private' : 'public';

			if ( ! empty( $cached_etag ) ) {
				$response_headers['ETag'] = $cached_etag;
			}
			$response_headers['Cache-Control'] = $cache_visibility . ', must-revalidate, max-age=' . $cache_ttl;

			// If the server adds a 'Date' header by itself there will be two such headers in the response.
			// To help disambiguate them, we add also an 'X-WC-Date' header with the proper value.
			// @phpstan-ignore-next-line argument.type -- created_at is int, stored by store_cached_response.
			$created_at                    = gmdate( 'D, d M Y H:i:s', intval( $cached['created_at'] ) ) . ' GMT';
			$response_headers['Date']      = $created_at;
			$response_headers['X-WC-Date'] = $created_at;

			if ( ! empty( $cached_etag ) && $request_etag === $cached_etag ) {
				$cache_control         = $response_headers['Cache-Control'];
				$not_modified_response = $this->create_not_modified_response( $cached_etag, $cache_control, $request, $cached_config['endpoint_id'] );
				if ( $not_modified_response ) {
					$not_modified_response->header( 'Date', $response_headers['Date'] );
					$not_modified_response->header( 'X-WC-Date', $response_headers['X-WC-Date'] );
					return $not_modified_response;
				}
			}
		}

		$response = new WP_REST_Response( $cached['data'], $cached['status_code'] ?? 200 );

		foreach ( $response_headers as $name => $value ) {
			$response->header( $name, $value );
		}

		if ( ! empty( $cached['headers'] ) ) {
			foreach ( $cached['headers'] as $name => $value ) {
				$response->header( $name, $value );
			}
		}

		return $response;
	}

	/**
	 * Store a response in cache.
	 *
	 * @param string $cache_key      The cache key.
	 * @param mixed  $data           The response data to cache.
	 * @param int    $status_code    The HTTP status code of the response.
	 * @param string $entity_type    The entity type.
	 * @param array  $entity_ids     Array of entity IDs in the response.
	 * @param int    $cache_ttl      Cache TTL in seconds.
	 * @param array  $relevant_hooks Hook names to track for invalidation.
	 * @param array  $headers        Response headers to cache.
	 * @param string $etag           ETag for the response.
	 */
	private function store_cached_response( string $cache_key, $data, int $status_code, string $entity_type, array $entity_ids, int $cache_ttl, array $relevant_hooks, array $headers = array(), string $etag = '' ): void {
		$entity_versions = array();
		if ( ! is_null( $this->version_string_generator ) ) {
			foreach ( $entity_ids as $entity_id ) {
				$version_id = "{$entity_type}_{$entity_id}";
				$version    = $this->version_string_generator->get_version( $version_id );
				if ( $version ) {
					$entity_versions[ $entity_id ] = $version;
				}
			}
		}

		$legacy_proxy = wc_get_container()->get( LegacyProxy::class );
		$cache_data   = array(
			'data'            => $data,
			'entity_versions' => $entity_versions,
			'created_at'      => $legacy_proxy->call_function( 'time' ),
		);

		if ( 200 !== $status_code ) {
			$cache_data['status_code'] = $status_code;
		}

		if ( ! empty( $relevant_hooks ) ) {
			$cache_data['hooks_hash'] = $this->generate_hooks_hash( $relevant_hooks );
		}

		if ( ! empty( $headers ) ) {
			$cache_data['headers'] = $headers;
		}

		if ( ! empty( $etag ) ) {
			$cache_data['etag'] = $etag;
		}

		wp_cache_set( $cache_key, $cache_data, self::$cache_group, $cache_ttl );
	}

	/**
	 * Handle rest_send_nocache_headers filter to prevent WordPress from overriding our cache headers.
	 *
	 * @internal
	 *
	 * @param bool $send_no_cache_headers Whether to send no-cache headers.
	 *
	 * @return bool False if we're handling caching for this request, original value otherwise.
	 */
	public function handle_rest_send_nocache_headers( bool $send_no_cache_headers ): bool {
		if ( ! $this->is_handling_cached_endpoint ) {
			return $send_no_cache_headers;
		}

		$this->is_handling_cached_endpoint = false;
		return false;
	}
}
PK     [1]    #  Traits/AccessiblePrivateMethods.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Traits;

/**
 * DON'T USE THIS TRAIT. It's DEPRECATED and will be REMOVED in a future version of WooCommerce.
 *
 * If you have class methods that are public solely because they are the target of WordPress hooks,
 * make the methods public and mark them with an @internal annotation.
 *
 * @deprecated 9.6.0 Make the hook target methods public and mark them with an @internal annotation. This trait will be REMOVED in a future version of WooCommerce.
 */
trait AccessiblePrivateMethods {
    // phpcs:disable

	private $_accessible_private_methods = array();

	private static $_accessible_static_private_methods = array();

	protected static function add_action( string $hook_name, $callback, int $priority = 10, int $accepted_args = 1 ): void {
		self::process_callback_before_hooking( $callback );
		add_action( $hook_name, $callback, $priority, $accepted_args );
	}

	protected static function add_filter( string $hook_name, $callback, int $priority = 10, int $accepted_args = 1 ): void {
		self::process_callback_before_hooking( $callback );
		add_filter( $hook_name, $callback, $priority, $accepted_args );
	}

	protected static function process_callback_before_hooking( $callback ): void {
		if ( ! is_array( $callback ) || count( $callback ) < 2 ) {
			return;
		}

		$first_item = $callback[0];
		if ( __CLASS__ === $first_item ) {
			static::mark_static_method_as_accessible( $callback[1] );
		} elseif ( is_object( $first_item ) && get_class( $first_item ) === __CLASS__ ) {
			$first_item->mark_method_as_accessible( $callback[1] );
		}
	}

	protected function mark_method_as_accessible( string $method_name ): bool {
		if ( method_exists( $this, $method_name ) ) {
			$this->_accessible_private_methods[ $method_name ] = $method_name;
			return true;
		}

		return false;
	}

	protected static function mark_static_method_as_accessible( string $method_name ): bool {
		if ( method_exists( __CLASS__, $method_name ) ) {
			static::$_accessible_static_private_methods[ $method_name ] = $method_name;
			return true;
		}

		return false;
	}

	public function __call( $name, $arguments ) {
		if ( isset( $this->_accessible_private_methods[ $name ] ) ) {
			return call_user_func_array( array( $this, $name ), $arguments );
		} elseif ( is_callable( array( 'parent', '__call' ) ) ) {
			return parent::__call( $name, $arguments );
		} elseif ( method_exists( $this, $name ) ) {
			throw new \Error( 'Call to private method ' . get_class( $this ) . '::' . $name );
		} else {
			throw new \Error( 'Call to undefined method ' . get_class( $this ) . '::' . $name );
		}
	}

	public static function __callStatic( $name, $arguments ) {
		if ( isset( static::$_accessible_static_private_methods[ $name ] ) ) {
			return call_user_func_array( array( __CLASS__, $name ), $arguments );
		} elseif ( is_callable( array( 'parent', '__callStatic' ) ) ) {
			return parent::__callStatic( $name, $arguments );
		} elseif ( 'add_action' === $name || 'add_filter' === $name ) {
			$proper_method_name = 'add_static_' . substr( $name, 4 );
			throw new \Error( __CLASS__ . '::' . $name . " can't be called statically, did you mean '$proper_method_name'?" );
		} elseif ( method_exists( __CLASS__, $name ) ) {
			throw new \Error( 'Call to private method ' . __CLASS__ . '::' . $name );
		} else {
			throw new \Error( 'Call to undefined method ' . __CLASS__ . '::' . $name );
		}
	}

    // phpcs:enable
}
PK     [1]'  '    Traits/OrderAttributionMeta.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Traits;

use Automattic\WooCommerce\Vendor\Detection\MobileDetect;
use Exception;
use WC_Meta_Data;
use WC_Order;
use WP_Post;

/**
 * Trait OrderAttributionMeta
 *
 * @since 8.5.0
 *
 * phpcs:disable Generic.Commenting.DocComment.MissingShort
 */
trait OrderAttributionMeta {

	/**
	 * The default fields and their sourcebuster accessors,
	 * to show in the source data metabox.
	 *
	 * @var string[]
	 * */
	private $default_fields = array(
		// main fields.
		'source_type'          => 'current.typ',
		'referrer'             => 'current_add.rf',

		// utm fields.
		'utm_campaign'         => 'current.cmp',
		'utm_source'           => 'current.src',
		'utm_medium'           => 'current.mdm',
		'utm_content'          => 'current.cnt',
		'utm_id'               => 'current.id',
		'utm_term'             => 'current.trm',
		'utm_source_platform'  => 'current.plt',
		'utm_creative_format'  => 'current.fmt',
		'utm_marketing_tactic' => 'current.tct',

		// additional fields.
		'session_entry'        => 'current_add.ep',
		'session_start_time'   => 'current_add.fd',
		'session_pages'        => 'session.pgs',
		'session_count'        => 'udata.vst',
		'user_agent'           => 'udata.uag',
	);

	/** @var array */
	private $fields = array();

	/**
	 * Cached `array_keys( $fields )`.
	 *
	 * @var array
	 * */
	private $field_names = array();

	/** @var string */
	private $field_prefix = '';

	/**
	 * Get the device type based on the other meta fields.
	 *
	 * @param array $values The meta values.
	 *
	 * @return string The device type.
	 */
	protected function get_device_type( array $values ): string {
		$detector = new MobileDetect( array(), $values['user_agent'] );

		if ( $detector->isMobile() ) {
			return 'Mobile';
		} elseif ( $detector->isTablet() ) {
			return 'Tablet';
		} else {
			return 'Desktop';
		}
	}

	/**
	 * Set the fields and the field prefix.
	 *
	 * @return void
	 */
	private function set_fields_and_prefix() {
		/**
		 * Filter the fields to show in the source data metabox.
		 *
		 * @since 8.5.0
		 *
		 * @param string[] $fields The fields to show.
		 */
		$this->fields      = (array) apply_filters( 'wc_order_attribution_tracking_fields', $this->default_fields );
		$this->field_names = array_keys( $this->fields );
		$this->set_field_prefix();
	}

	/**
	 * Set the meta prefix for our fields.
	 *
	 * @return void
	 */
	private function set_field_prefix(): void {
		/**
		 * Filter the prefix for the meta fields.
		 *
		 * @since 8.5.0
		 *
		 * @param string $prefix The prefix for the meta fields.
		 */
		$prefix = (string) apply_filters(
			'wc_order_attribution_tracking_field_prefix',
			'wc_order_attribution_'
		);

		// Remove leading and trailing underscores.
		$prefix = trim( $prefix, '_' );

		// Ensure the prefix ends with _, and set the prefix.
		$this->field_prefix = "{$prefix}_";
	}

	/**
	 * Filter an order's meta data to only the keys that we care about.
	 *
	 * Sets the origin value based on the source type.
	 *
	 * @param WC_Meta_Data[] $meta The meta data.
	 *
	 * @return array
	 */
	private function filter_meta_data( array $meta ): array {
		$return = array();
		$prefix = $this->get_meta_prefixed_field_name( '' );

		foreach ( $meta as $item ) {
			if ( str_starts_with( $item->key, $prefix ) ) {
				$return[ $this->unprefix_meta_field_name( $item->key ) ] = $item->value;
			}
		}

		// Determine the device type from the user agent.
		if ( ! array_key_exists( 'device_type', $return ) && array_key_exists( 'user_agent', $return ) ) {
			$return['device_type'] = $this->get_device_type( $return );
		}

		// Determine the origin based on source type and referrer.
		$source_type      = $return['source_type'] ?? '';
		$source           = $return['utm_source'] ?? '';
		$return['origin'] = $this->get_origin_label( $source_type, $source, true );

		return $return;
	}

	/**
	 * Get the field name with the appropriate prefix.
	 *
	 * @param string $name Field name.
	 *
	 * @return string The prefixed field name.
	 */
	private function get_prefixed_field_name( $name ): string {
		return "{$this->field_prefix}{$name}";
	}

	/**
	 * Get the field name with the meta prefix.
	 *
	 * @param string $name The field name.
	 *
	 * @return string The prefixed field name.
	 */
	private function get_meta_prefixed_field_name( string $name ): string {
		return "_{$this->get_prefixed_field_name( $name )}";
	}

	/**
	 * Remove the meta prefix from the field name.
	 *
	 * @param string $name The prefixed fieldname .
	 *
	 * @return string
	 */
	private function unprefix_meta_field_name( string $name ): string {
		return str_replace( "_{$this->field_prefix}", '', $name );
	}

	/**
	 * Get the order object with HPOS compatibility.
	 *
	 * @param WC_Order|WP_Post|int $post_or_order The post ID or object.
	 *
	 * @return WC_Order The order object
	 * @throws Exception When the order isn't found.
	 */
	private function get_hpos_order_object( $post_or_order ) {
		// If we've already got an order object, just return it.
		if ( $post_or_order instanceof WC_Order ) {
			return $post_or_order;
		}

		// If we have a post ID, get the post object.
		if ( is_numeric( $post_or_order ) ) {
			$post_or_order = wc_get_order( $post_or_order );
		}

		// Throw an exception if we don't have an order object.
		if ( ! $post_or_order instanceof WC_Order ) {
			throw new Exception( __( 'Order not found.', 'woocommerce' ) );
		}

		return $post_or_order;
	}


	/**
	 * Map posted, prefixed values to field values.
	 * Used for the classic forms.
	 *
	 * @param array $raw_values The raw values from the POST form.
	 *
	 * @return array
	 */
	private function get_unprefixed_field_values( array $raw_values = array() ): array {
		$values = array();

		// Look through each field in POST data.
		foreach ( $this->field_names as $field_name ) {
			$values[ $field_name ] = $raw_values[ $this->get_prefixed_field_name( $field_name ) ] ?? '(none)';
		}

		return $values;
	}

	/**
	 * Map submitted values to meta values.
	 *
	 * @param array $raw_values The raw (unprefixed) values from the submitted data.
	 *
	 * @return array
	 */
	private function get_source_values( array $raw_values = array() ): array {
		$values = array();

		// Look through each field in given data.
		foreach ( $this->field_names as $field_name ) {
			$value = sanitize_text_field( wp_unslash( $raw_values[ $field_name ] ) );
			if ( '(none)' === $value ) {
				continue;
			}

			$values[ $field_name ] = $value;
		}

		// Set the device type if possible using the user agent.
		if ( array_key_exists( 'user_agent', $values ) && ! empty( $values['user_agent'] ) ) {
			$values['device_type'] = $this->get_device_type( $values );
		}

		return $values;
	}

	/**
	 * Get the label for the Order origin with placeholder where appropriate. Can be
	 * translated (for DB / display) or untranslated (for Tracks).
	 *
	 * @param string $source_type The source type.
	 * @param string $source      The source.
	 * @param bool   $translated  Whether the label should be translated.
	 *
	 * @return string
	 */
	private function get_origin_label( string $source_type, string $source, bool $translated = true ): string {
		// Set up the label based on the source type.
		switch ( $source_type ) {
			case 'utm':
				$label = $translated ?
					/* translators: %s is the source value */
					__( 'Source: %s', 'woocommerce' )
					: 'Source: %s';
				break;
			case 'organic':
				$label = $translated ?
					/* translators: %s is the source value */
					__( 'Organic: %s', 'woocommerce' )
					: 'Organic: %s';
				break;
			case 'referral':
				$label = $translated ?
					/* translators: %s is the source value */
					__( 'Referral: %s', 'woocommerce' )
					: 'Referral: %s';
				break;
			case 'typein':
				$label  = '';
				$source = $translated ?
					__( 'Direct', 'woocommerce' )
					: 'Direct';
				break;
			case 'mobile_app':
				$label  = '';
				$source = $translated ?
					__( 'Mobile app', 'woocommerce' )
					: 'Mobile app';
				break;
			case 'admin':
				$label  = '';
				$source = $translated ?
					__( 'Web admin', 'woocommerce' )
					: 'Web admin';
				break;
			case 'pos':
				$label  = '';
				$source = $translated ?
					__( 'Point of Sale', 'woocommerce' )
					: 'Point of Sale';
				break;

			default:
				$label  = '';
				$source = $translated ?
					__( 'Unknown', 'woocommerce' )
					: 'Unknown';
				break;
		}

		/**
		 * Filter the formatted source for the order origin.
		 *
		 * @since 8.5.0
		 *
		 * @param string $formatted_source The formatted source.
		 * @param string $source           The source.
		 */
		$formatted_source = apply_filters(
			'wc_order_attribution_origin_formatted_source',
			ucfirst( trim( $source, '()' ) ),
			$source
		);

		/**
		 * Filter the label for the order origin.
		 *
		 * This label should have a %s placeholder for the formatted source to be inserted
		 * via sprintf().
		 *
		 * @since 8.5.0
		 *
		 * @param string $label            The label for the order origin.
		 * @param string $source_type      The source type.
		 * @param string $source           The source.
		 * @param string $formatted_source The formatted source.
		 */
		$label = (string) apply_filters(
			'wc_order_attribution_origin_label',
			$label,
			$source_type,
			$source,
			$formatted_source
		);

		if ( false === strpos( $label, '%' ) ) {
			return $formatted_source;
		}

		return sprintf( $label, $formatted_source );
	}

	/**
	 * Get the description for the order attribution field.
	 *
	 * @param string $field_name The field name.
	 *
	 * @return string
	 */
	private function get_field_description( string $field_name ): string {
		/* translators: %s is the field name */
		$description = sprintf( __( 'Order attribution field: %s', 'woocommerce' ), $field_name );

		/**
		 * Filter the description for the order attribution field.
		 *
		 * @since 8.5.0
		 *
		 * @param string $description The description for the order attribution field.
		 * @param string $field_name  The field name.
		 */
		return (string) apply_filters( 'wc_order_attribution_field_description', $description, $field_name );
	}
}
PK     [1]LF       Settings/OptionSanitizer.phpnu         <?php
/**
 * FormatValidator class.
 */

namespace Automattic\WooCommerce\Internal\Settings;

defined( 'ABSPATH' ) || exit;

/**
 * This class handles sanitization of core options that need to conform to certain format.
 *
 * @since 6.6.0
 */
class OptionSanitizer {

	/**
	 * OptionSanitizer constructor.
	 */
	public function __construct() {
		// Sanitize color options.
		$color_options = array(
			'woocommerce_email_base_color',
			'woocommerce_email_background_color',
			'woocommerce_email_body_background_color',
			'woocommerce_email_text_color',
		);

		foreach ( $color_options as $option_name ) {
			add_filter(
				"woocommerce_admin_settings_sanitize_option_{$option_name}",
				array( $this, 'sanitize_color_option' ),
				10,
				2
			);
		}
		// Cast "Out of stock threshold" field to absolute integer to prevent storing empty value.
		add_filter( 'woocommerce_admin_settings_sanitize_option_woocommerce_notify_no_stock_amount', 'absint' );
	}

	/**
	 * Sanitizes values for options of type 'color' before persisting to the database.
	 * Falls back to previous/default value for the option if given an invalid value.
	 *
	 * @since 6.6.0
	 * @param string $value Option value.
	 * @param array  $option Option data.
	 * @return string Color in hex format.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function sanitize_color_option( $value, $option ) {
		$value = sanitize_hex_color( $value );

		// If invalid, try the current value.
		if ( ! $value && ! empty( $option['id'] ) ) {
			$value = sanitize_hex_color( get_option( $option['id'] ) );
		}

		// If still invalid, try the default.
		if ( ! $value && ! empty( $option['default'] ) ) {
			$value = sanitize_hex_color( $option['default'] );
		}

		return (string) $value;
	}
}
PK     [1]m    '  Settings/PointOfSaleDefaultSettings.phpnu         <?php
/**
 * Default settings for Point of Sale.
 *
 * @package WooCommerce\Internal\Settings
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Settings;

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

/**
 * PointOfSaleDefaultSettings class.
 */
class PointOfSaleDefaultSettings {
	/**
	 * Get default store email.
	 *
	 * @return string
	 */
	public static function get_default_store_email() {
		return get_option( 'admin_email' );
	}

	/**
	 * Get default store name.
	 *
	 * @return string
	 */
	public static function get_default_store_name() {
		return get_bloginfo( 'name' );
	}

	/**
	 * Get default store address.
	 *
	 * @return string
	 */
	public static function get_default_store_address() {
		if ( ! WC() || ! WC()->countries ) {
			return '';
		}

		return wp_specialchars_decode(
			WC()->countries->get_formatted_address(
				array(
					'address_1' => WC()->countries->get_base_address(),
					'address_2' => WC()->countries->get_base_address_2(),
					'city'      => WC()->countries->get_base_city(),
					'state'     => WC()->countries->get_base_state(),
					'postcode'  => WC()->countries->get_base_postcode(),
					'country'   => WC()->countries->get_base_country(),
				),
				"\n"
			)
		);
	}
}
PK     [1]̫       ProductImage/MatchImageBySKU.phpnu         <?php
/**
 * MatchImageBySKU class file.
 */

namespace Automattic\WooCommerce\Internal\ProductImage;

defined( 'ABSPATH' ) || exit;

/**
 * Class for the product image matching by SKU.
 */
class MatchImageBySKU {

	/**
	 * The name of the setting for this feature.
	 *
	 * @var string
	 */
	private $setting_name = 'woocommerce_product_match_featured_image_by_sku';

	/**
	 * MatchImageBySKU constructor.
	 */
	public function __construct() {
		$this->init_hooks();
	}

	/**
	 * Initialize the hooks used by the class.
	 */
	private function init_hooks() {
		add_filter( 'woocommerce_get_settings_products', array( $this, 'add_product_image_sku_setting' ), 110, 2 );
	}

	/**
	 * Is this feature enabled.
	 *
	 * @since 8.3.0
	 * @return bool
	 */
	public function is_enabled() {
		return wc_string_to_bool( get_option( $this->setting_name ) );
	}

	/**
	 * Handler for 'woocommerce_get_settings_products', adds the settings related to the product image SKU matching table.
	 *
	 * @param array  $settings Original settings configuration array.
	 * @param string $section_id Settings section identifier.
	 * @return array New settings configuration array.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function add_product_image_sku_setting( array $settings, string $section_id ): array {
		if ( 'advanced' !== $section_id ) {
			return $settings;
		}

		$settings[] = array(
			'title' => __( 'Product image matching by SKU', 'woocommerce' ),
			'type'  => 'title',
		);

		$settings[] = array(
			'title'         => __( 'Match images', 'woocommerce' ),
			'desc'          => __( 'Set product featured image when uploaded image file name matches product SKU.', 'woocommerce' ),
			'id'            => $this->setting_name,
			'default'       => 'no',
			'type'          => 'checkbox',
			'checkboxgroup' => 'start',
		);

		$settings[] = array( 'type' => 'sectionend' );

		return $settings;
	}
}
PK     [1]TS}      AssignDefaultCategory.phpnu         <?php
/**
 * AssignDefaultCategory class file.
 */

namespace Automattic\WooCommerce\Internal;

defined( 'ABSPATH' ) || exit;

/**
 * Class to assign default category to products.
 */
class AssignDefaultCategory {
	/**
	 * Class initialization, to be executed when the class is resolved by the container.
	 *
	 * @internal
	 */
	final public function init() {
		add_action( 'wc_schedule_update_product_default_cat', array( $this, 'maybe_assign_default_product_cat' ) );
	}

	/**
	 * When a product category is deleted, we need to check
	 * if the product has no categories assigned. Then assign
	 * it a default category. We delay this with a scheduled
	 * action job to not block the response.
	 *
	 * @return void
	 */
	public function schedule_action() {
		WC()->queue()->schedule_single(
			time(),
			'wc_schedule_update_product_default_cat',
			array(),
			'wc_update_product_default_cat'
		);
	}

	/**
	 * Assigns default product category for products
	 * that have no categories.
	 *
	 * @return void
	 */
	public function maybe_assign_default_product_cat() {
		global $wpdb;

		$default_category = get_option( 'default_product_cat', 0 );

		if ( $default_category ) {
			$affected_rows = $wpdb->query(
				$wpdb->prepare(
					"INSERT INTO {$wpdb->term_relationships} (object_id, term_taxonomy_id)
					SELECT DISTINCT posts.ID, %s FROM {$wpdb->posts} posts
					LEFT JOIN
						(
							SELECT object_id FROM {$wpdb->term_relationships} term_relationships
							LEFT JOIN {$wpdb->term_taxonomy} term_taxonomy ON term_relationships.term_taxonomy_id = term_taxonomy.term_taxonomy_id
							WHERE term_taxonomy.taxonomy = 'product_cat'
						) AS tax_query
					ON posts.ID = tax_query.object_id
					WHERE posts.post_type = 'product'
					AND tax_query.object_id IS NULL",
					$default_category
				)
			);
			if ( $affected_rows > 0 ) {
				wp_cache_flush();
				delete_transient( 'wc_term_counts' );
				wp_update_term_count_now( array( $default_category ), 'product_cat' );
			}
		}
	}
}
PK     [1]M\    
  Brands.phpnu         <?php
/**
 * Brands class file.
 */

declare( strict_types = 1);

namespace Automattic\WooCommerce\Internal;

defined( 'ABSPATH' ) || exit;

/**
 * Class to initiate Brands functionality in core.
 */
class Brands {

	/**
	 * Class initialization
	 *
	 * @internal
	 */
	final public static function init() {

		if ( ! self::is_enabled() ) {
			return;
		}

		include_once WC_ABSPATH . 'includes/class-wc-brands.php';
		include_once WC_ABSPATH . 'includes/class-wc-brands-coupons.php';
		include_once WC_ABSPATH . 'includes/class-wc-brands-brand-settings-manager.php';
		include_once WC_ABSPATH . 'includes/wc-brands-functions.php';

		if ( is_admin() ) {
			include_once WC_ABSPATH . 'includes/admin/class-wc-admin-brands.php';
		}
	}

	/**
	 * As of WooCommerce 9.6, Brands is enabled for all users.
	 *
	 * @return bool
	 */
	public static function is_enabled() {
		return true;
	}

	/**
	 * If WooCommerce Brands gets activated forcibly, without WooCommerce active (e.g. via '--skip-plugins'),
	 * remove WooCommerce Brands initialization functions early on in the 'plugins_loaded' timeline.
	 */
	public static function prepare() {

		if ( ! self::is_enabled() ) {
			return;
		}

		if ( function_exists( 'wc_brands_init' ) ) {
			remove_action( 'plugins_loaded', 'wc_brands_init', 1 );
		}
	}
}
PK     [1]V    #  Logging/SafeGlobalFunctionProxy.phpnu         <?php

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Logging;

/**
 * SafeGlobalFunctionProxy Class
 *
 * This class creates a wrapper for non-built-in functions for safety.
 *
 * @since 9.4.0
 * @package Automattic\WooCommerce\Internal\Logging
 */
class SafeGlobalFunctionProxy {

	/**
	 * Load missing function if we know where to find it.
	 * Modify this file to add more functions to the map.
	 *
	 * @param string $name The name of the function to load.
	 * @return void
	 * @throws \Exception If the function is missing and could not be loaded.
	 */
	private static function maybe_load_missing_function( $name ) {
		$function_map = array(
			'wp_parse_url'            => ABSPATH . WPINC . '/http.php',
			'home_url'                => ABSPATH . WPINC . '/link-template.php',
			'get_bloginfo'            => ABSPATH . WPINC . '/general-template.php',
			'get_option'              => ABSPATH . WPINC . '/option.php',
			'get_site_transient'      => ABSPATH . WPINC . '/option.php',
			'set_site_transient'      => ABSPATH . WPINC . '/option.php',
			'wp_safe_remote_post'     => ABSPATH . WPINC . '/http.php',
			'is_wp_error'             => ABSPATH . WPINC . '/load.php',
			'get_plugin_updates'      => array( ABSPATH . 'wp-admin/includes/update.php', ABSPATH . 'wp-admin/includes/plugin.php' ),
			'wp_get_environment_type' => ABSPATH . WPINC . '/load.php',
			'wp_json_encode'          => ABSPATH . WPINC . '/functions.php',
			'wc_get_logger'           => WC_ABSPATH . 'includes/class-wc-logger.php',
			'wc_print_r'              => WC_ABSPATH . 'includes/wc-core-functions.php',
		);

		if ( ! function_exists( $name ) ) {
			if ( isset( $function_map[ $name ] ) ) {
				$files = (array) $function_map[ $name ];
				foreach ( $files as $file ) {
					require_once $file;
				}
			} else {
				throw new \Exception( sprintf( 'Function %s does not exist and could not be loaded.', esc_html( $name ) ) );
			}
		}
	}

	/**
	 * Proxy for trapping all calls on SafeGlobalFunctionProxy.
	 * Use this for calling WP and WC global functions safely.
	 * Example usage:
	 *
	 * SafeGlobalFunctionProxy::wp_parse_url('https://example.com', PHP_URL_PATH);
	 *
	 * @since 9.4.0
	 * @param string $name The name of the function to call.
	 * @param array  $arguments The arguments to pass to the function.
	 * @return mixed The result of the function call, or null if an error occurs.
	 */
	public static function __callStatic( $name, $arguments ) {
		// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Custom error handler is necessary to convert errors to exceptions
		set_error_handler(
			static function ( int $type, string $message, string $file, int $line ) {
				if ( __FILE__ === $file ) {
					// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Used to adjust file and line number for accurate error reporting
					$trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 3 );
					$file  = $trace[2]['file'] ?? $file;
					$line  = $trace[2]['line'] ?? $line;
				}
				$sanitized_message = filter_var( $message, FILTER_SANITIZE_FULL_SPECIAL_CHARS );
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- $message sanitised above. we don't want to rely on esc_html since it's not a PHP built-in
				throw new \ErrorException( $sanitized_message, 0, $type, $file, $line );
			}
		);

		try {
			self::maybe_load_missing_function( $name );
			$results = call_user_func_array( $name, $arguments );
		} catch ( \Throwable $e ) {
			self::log_wrapper_error( $name, $e->getMessage(), $arguments );
			$results = null;
		} finally {
			restore_error_handler();
		}

		return $results;
	}

	/**
	 * Log wrapper function errors to "local logging" for debugging.
	 *
	 * @param string $function_name The name of the wrapped function.
	 * @param string $error_message The error message.
	 * @param array  $context       Additional context for the error.
	 */
	protected static function log_wrapper_error( $function_name, $error_message, $context = array() ) {
		self::maybe_load_missing_function( 'wc_get_logger' );

		wc_get_logger()->error(
			'[Wrapper function error] ' . sprintf( 'Error in %s: %s', $function_name, $error_message ),
			array_merge(
				array(
					'function' => $function_name,
					'source'   => 'remote-logging',
				),
				$context
			)
		);
	}
}
PK     [1][["  ["  &  Logging/OrderLogsDeletionProcessor.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Logging;

use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessorInterface;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\StringUtil;

/**
 * Batch processor for deleting log entries of completed orders.
 * It only works when either HPOS is enabled or the orders data store is the old CPT-based one,
 * because otherwise the ability to query orders by meta key is not guaranteed.
 */
class OrderLogsDeletionProcessor implements BatchProcessorInterface {

	/**
	 * Constant representing the default size of the batches to process.
	 */
	public const DEFAULT_BATCH_SIZE = 1000;

	/**
	 * True if HPOS is enabled.
	 *
	 * @var bool
	 */
	private bool $hpos_in_use = false;

	/**
	 * True if HPOS is disabled and the orders data store in use is the old CPT one.
	 *
	 * @var bool
	 */
	private bool $cpt_in_use = false;

	/**
	 * The instance of LegacyProxy to use.
	 *
	 * @var LegacyProxy
	 */
	private LegacyProxy $legacy_proxy;

	/**
	 * The instance of DataSynchronizer to use.
	 *
	 * @var DataSynchronizer
	 */
	private DataSynchronizer $data_synchronizer;

	/**
	 * Initialize the instance.
	 * This is invoked by the dependency injection container.
	 *
	 * @param CustomOrdersTableController $hpos_controller The instance of CustomOrdersTableController to use.
	 * @param LegacyProxy                 $legacy_proxy The instance of LegacyProxy to use.
	 * @param DataSynchronizer            $data_synchronizer The instance of DataSynchronizer to use.
	 *
	 * @internal
	 */
	final public function init( CustomOrdersTableController $hpos_controller, LegacyProxy $legacy_proxy, DataSynchronizer $data_synchronizer ) {
		$this->hpos_in_use = $hpos_controller->custom_orders_table_usage_is_enabled();
		if ( ! $this->hpos_in_use ) {
			$this->cpt_in_use = \WC_Order_Data_Store_CPT::class === \WC_Data_Store::load( 'order' )->get_current_class_name();
		}

		$this->legacy_proxy      = $legacy_proxy;
		$this->data_synchronizer = $data_synchronizer;
	}

	/**
	 * Get the name of the processor.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Order logs deletion process';
	}

	/**
	 * Get a description of the processor.
	 *
	 * @return string
	 */
	public function get_description(): string {
		return 'Deletes debug logs of completed orders.';
	}

	/**
	 * Get the default batch size for this processor.
	 *
	 * @return int
	 */
	public function get_default_batch_size(): int {
		return self::DEFAULT_BATCH_SIZE;
	}

	/**
	 * Get the total count of entries pending processing.
	 *
	 * @return int
	 */
	public function get_total_pending_count(): int {
		if ( $this->hpos_in_use ) {
			return $this->get_total_pending_count_hpos();
		} elseif ( $this->cpt_in_use ) {
			return $this->get_total_pending_count_cpt();
		} else {
			$this->throw_doing_it_wrong( StringUtil::class_name_without_namespace( __CLASS__ ) . '::' . __FUNCTION__ );
			return 0;
		}
	}

	/**
	 * Get the total count of entries pending processing, HPOS version.
	 *
	 * @return int
	 */
	private function get_total_pending_count_hpos(): int {
		global $wpdb;

		return (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*)
                 FROM {$wpdb->prefix}wc_orders_meta
                 WHERE meta_key = %s",
				'_debug_log_source_pending_deletion'
			)
		);
	}

	/**
	 * Get the total count of entries pending processing, CPT datastore version.
	 *
	 * @return int
	 */
	private function get_total_pending_count_cpt(): int {
		global $wpdb;

		return (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*)
                 FROM {$wpdb->postmeta} pm
                 INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID
                 WHERE pm.meta_key = %s
                 AND p.post_type = %s",
				'_debug_log_source_pending_deletion',
				'shop_order'
			)
		);
	}

	/**
	 * Get the next batch of items to process.
	 * An item will be an associative array of 'order_id' and 'meta_value'.
	 *
	 * @param int $size Maximum size of the batch to return.
	 * @return array
	 */
	public function get_next_batch_to_process( int $size ): array {
		if ( $this->hpos_in_use ) {
			return $this->get_next_batch_to_process_hpos( $size );
		} elseif ( $this->cpt_in_use ) {
			return $this->get_next_batch_to_process_cpt( $size );
		} else {
			$this->throw_doing_it_wrong( StringUtil::class_name_without_namespace( __CLASS__ ) . '::' . __FUNCTION__ );
			return array();
		}
	}

	/**
	 * Get the next batch of items to process, HPOS version.
	 *
	 * @param int $size Maximum size of the batch to return.
	 * @return array
	 */
	private function get_next_batch_to_process_hpos( int $size ): array {
		global $wpdb;

		return $wpdb->get_results(
			$wpdb->prepare(
				"SELECT order_id, meta_value
                 FROM {$wpdb->prefix}wc_orders_meta
                 WHERE meta_key = %s
                 ORDER BY order_id
                 LIMIT %d",
				'_debug_log_source_pending_deletion',
				$size
			),
			ARRAY_A
		);
	}

	/**
	 * Get the next batch of items to process, CPT datastore version.
	 *
	 * @param int $size Maximum size of the batch to return.
	 * @return array
	 */
	private function get_next_batch_to_process_cpt( int $size ): array {
		global $wpdb;

		return $wpdb->get_results(
			$wpdb->prepare(
				"SELECT p.ID as order_id, pm.meta_value
                 FROM {$wpdb->postmeta} pm
                 INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID
                 WHERE pm.meta_key = %s
                 AND p.post_type = 'shop_order'
                 ORDER BY p.ID
                 LIMIT %d",
				'_debug_log_source_pending_deletion',
				$size
			),
			ARRAY_A
		);
	}

	/**
	 * Process a batch of items.
	 * Items are expected to be in the format returned by get_next_batch_to_process.
	 *
	 * @param array $batch Batch of items to process.
	 * @throws \Exception Invalid input.
	 */
	public function process_batch( array $batch ): void {
		if ( empty( $batch ) ) {
			return;
		}

		if ( ! $this->hpos_in_use && ! $this->cpt_in_use ) {
			$this->throw_doing_it_wrong( StringUtil::class_name_without_namespace( __CLASS__ ) . '::' . __FUNCTION__ );
			return;
		}

		$logger = $this->legacy_proxy->call_function( 'wc_get_logger' );
		foreach ( $batch as $item ) {
			if ( ! is_array( $item ) || ! isset( $item['meta_value'] ) || ! isset( $item['order_id'] ) ) {
				throw new \Exception( "\$batch must be an array of arrays, each having a 'meta_value' key and an 'order_id' key" );
			}
			$logger->clear( $item['meta_value'] );
		}

		$order_ids = array_map( 'absint', array_column( $batch, 'order_id' ) );

		// Delete from the authoritative meta table.
		$this->delete_debug_log_source_meta_entries( true, $order_ids );

		if ( $this->data_synchronizer->data_sync_is_enabled() ) {
			// When HPOS data sync is enabled we need to manually delete the entries in the backup meta table too,
			// otherwise the next sync process will restore the rows we just deleted from the authoritative meta table.
			$this->delete_debug_log_source_meta_entries( false, $order_ids );
		}
	}

	/**
	 * Delete meta entries for the given order IDs.
	 *
	 * @param bool  $from_authoritative_table True to delete from the authoritative table, false for the backup table.
	 * @param array $order_ids Array of order IDs to delete.
	 */
	private function delete_debug_log_source_meta_entries( bool $from_authoritative_table, array $order_ids ): void {
		global $wpdb;

		$use_hpos_table = $this->hpos_in_use === $from_authoritative_table;
		$table_name     = $use_hpos_table ? "{$wpdb->prefix}wc_orders_meta" : $wpdb->postmeta;
		$id_column_name = $use_hpos_table ? 'order_id' : 'post_id';
		$placeholders   = implode( ',', array_fill( 0, count( $order_ids ), '%d' ) );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$table_name}
				 WHERE {$id_column_name} IN ({$placeholders})
				 AND meta_key = %s",
				array_merge( $order_ids, array( '_debug_log_source_pending_deletion' ) )
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}

	/**
	 * Throw a "doing it wrong" error.
	 *
	 * @param string $function_name Class and function name to include in the error.
	 */
	private function throw_doing_it_wrong( string $function_name ) {
		$this->legacy_proxy->call_function(
			'wc_doing_it_wrong',
			$function_name,
			"This processor shouldn't be enqueued when the orders data store in use is neither the HPOS one nor the CPT one. Just delete the order debug logs directly.",
			'10.3.0'
		);
	}
}
PK     [1]OTS  S    Logging/RemoteLogger.phpnu         <?php
declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\Logging;

use Automattic\WooCommerce\Utilities\FeaturesUtil;
use Automattic\WooCommerce\Utilities\StringUtil;
use Automattic\WooCommerce\Internal\McStats;
use Jetpack_Options;
use WC_Rate_Limiter;
use WC_Log_Levels;
use WC_Site_Tracking;

/**
 * WooCommerce Remote Logger
 *
 * The WooCommerce remote logger class adds functionality to log WooCommerce errors remotely based on if the customer opted in and several other conditions.
 *
 * No personal information is logged, only error information and relevant context.
 *
 * @class RemoteLogger
 * @since 9.2.0
 * @package WooCommerce\Classes
 */
class RemoteLogger extends \WC_Log_Handler {

	const LOG_ENDPOINT             = 'https://public-api.wordpress.com/rest/v1.1/logstash';
	const RATE_LIMIT_ID            = 'woocommerce_remote_logging';
	const RATE_LIMIT_DELAY         = 60; // 1 minute.
	const WC_NEW_VERSION_TRANSIENT = 'woocommerce_new_version';

	/**
	 * Handle a log entry.
	 *
	 * @param int    $timestamp Log timestamp.
	 * @param string $level emergency|alert|critical|error|warning|notice|info|debug.
	 * @param string $message Log message.
	 * @param array  $context Additional information for log handlers.
	 *
	 * @throws \Exception If the remote logging fails. The error is caught and logged locally.
	 *
	 * @return bool False if value was not handled and true if value was handled.
	 */
	public function handle( $timestamp, $level, $message, $context ) {
		try {
			if ( ! $this->should_handle( $level, $message, $context ) ) {
				return false;
			}

			return $this->log( $level, $message, $context );
		} catch ( \Throwable $e ) {
			// Log the error to the local logger so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->error( 'Failed to handle the log: ' . $e->getMessage(), array( 'source' => 'remote-logging' ) );
			return false;
		}
	}

	/**
	 * Get formatted log data to be sent to the remote logging service.
	 *
	 * This method formats the log data by sanitizing the message, adding default fields, and including additional context
	 * such as backtrace, tags, and extra attributes. It also integrates with WC_Tracks to include blog and store details.
	 * The formatted log data is then filtered before being sent to the remote logging service.
	 *
	 * @param string $level   Log level (e.g., 'error', 'warning', 'info').
	 * @param string $message Log message to be recorded.
	 * @param array  $context Optional. Additional information for log handlers, such as 'backtrace', 'tags', 'extra', and 'error'.
	 *
	 * @return array Formatted log data ready to be sent to the remote logging service.
	 */
	public function get_formatted_log( $level, $message, $context = array() ) {
		$log_data = array(
			// Default fields.
			'feature'    => 'woocommerce_core',
			'severity'   => $level,
			'message'    => $this->sanitize( $message ),
			'host'       => SafeGlobalFunctionProxy::wp_parse_url( SafeGlobalFunctionProxy::home_url(), PHP_URL_HOST ) ?? 'Unable to retrieve host',
			'tags'       => array( 'woocommerce', 'php' ),
			'properties' => array(
				'wc_version'  => $this->get_wc_version(),
				'php_version' => phpversion(),
				'wp_version'  => SafeGlobalFunctionProxy::get_bloginfo( 'version' ) ?? 'Unable to retrieve wp version',
				'request_uri' => $this->sanitize_request_uri( filter_input( INPUT_SERVER, 'REQUEST_URI', FILTER_SANITIZE_URL ) ),
				'store_id'    => SafeGlobalFunctionProxy::get_option( \WC_Install::STORE_ID_OPTION, null ) ?? 'Unable to retrieve store id',
			),
		);

		$blog_id = class_exists( 'Jetpack_Options' ) ? Jetpack_Options::get_option( 'id' ) : null;

		if ( ! empty( $blog_id ) && is_int( $blog_id ) ) {
			$log_data['blog_id'] = $blog_id;
		}

		if ( isset( $context['backtrace'] ) ) {
			if ( is_array( $context['backtrace'] ) || is_string( $context['backtrace'] ) ) {
				$log_data['trace'] = $this->sanitize_trace( $context['backtrace'] );
			} elseif ( true === $context['backtrace'] ) {
				$log_data['trace'] = $this->sanitize_trace( self::get_backtrace() );
			}
			unset( $context['backtrace'] );
		}

		if ( isset( $context['tags'] ) && is_array( $context['tags'] ) ) {
			$log_data['tags'] = array_merge( $log_data['tags'], $context['tags'] );
			unset( $context['tags'] );
		}

		if ( isset( $context['error']['file'] ) && is_string( $context['error']['file'] ) && '' !== $context['error']['file'] ) {
			$log_data['file'] = $this->normalize_paths( $context['error']['file'] );
			unset( $context['error']['file'] );
		}

		$extra_attrs = $context['extra'] ?? array();
		unset( $context['extra'] );
		unset( $context['remote-logging'] );

		// Merge the extra attributes with the remaining context since we can't send arbitrary fields to Logstash.
		$log_data['extra'] = array_merge( $extra_attrs, $context );

		/**
		 * Filters the formatted log data before sending it to the remote logging service.
		 * Returning a non-array value will prevent the log from being sent.
		 *
		 * @since 9.2.0
		 *
		 * @param array  $log_data The formatted log data.
		 * @param string $level    The log level (e.g., 'error', 'warning').
		 * @param string $message  The log message.
		 * @param array  $context  The original context array.
		 *
		 * @return array The filtered log data.
		 */
		return apply_filters( 'woocommerce_remote_logger_formatted_log_data', $log_data, $level, $message, $context );
	}

	/**
	 * Determines if remote logging is allowed based on the following conditions:
	 *
	 * 1. The feature flag for remote error logging is enabled.
	 * 2. The user has opted into tracking/logging.
	 * 3. The store is allowed to log based on the variant assignment percentage.
	 * 4. The current WooCommerce version is the latest so we don't log errors that might have been fixed in a newer version.
	 *
	 * @return bool
	 */
	public function is_remote_logging_allowed() {
		if ( ! FeaturesUtil::feature_is_enabled( 'remote_logging' ) ) {
			return false;
		}

		if ( ! WC_Site_Tracking::is_tracking_enabled() ) {
			return false;
		}

		if ( ! $this->should_current_version_be_logged() ) {
			return false;
		}

		return true;
	}

	/**
	 * Determine whether to handle or ignore log.
	 *
	 * @param string $level emergency|alert|critical|error|warning|notice|info|debug.
	 * @param string $message Log message to be recorded.
	 * @param array  $context Additional information for log handlers.
	 *
	 * @return bool True if the log should be handled.
	 */
	protected function should_handle( $level, $message, $context ) {
		// Ignore logs that are not opted in for remote logging.
		if ( ! isset( $context['remote-logging'] ) || false === $context['remote-logging'] ) {
			return false;
		}

		if ( ! $this->is_remote_logging_allowed() ) {
			return false;
		}

		if ( $this->is_third_party_error( (string) $message, (array) $context ) ) {
			return false;
		}

		// Record fatal error stats.
		if ( WC_Log_Levels::get_level_severity( $level ) >= WC_Log_Levels::get_level_severity( WC_Log_Levels::CRITICAL ) ) {
			try {
				$mc_stats = wc_get_container()->get( McStats::class );
				$mc_stats->add( 'error', 'critical-errors' );
				$mc_stats->do_server_side_stats();
			} catch ( \Throwable $e ) {
				error_log( 'Warning: Failed to record fatal error stats: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			}
		}

		if ( WC_Rate_Limiter::retried_too_soon( self::RATE_LIMIT_ID ) ) {
			// Log locally that the remote logging is throttled.
			SafeGlobalFunctionProxy::wc_get_logger()->warning( 'Remote logging throttled.', array( 'source' => 'remote-logging' ) );
			return false;
		}

		return true;
	}


	/**
	 * Send the log to the remote logging service.
	 *
	 * @param string $level   Log level (e.g., 'error', 'warning', 'info').
	 * @param string $message Log message to be recorded.
	 * @param array  $context Optional. Additional information for log handlers, such as 'backtrace', 'tags', 'extra', and 'error'.
	 *
	 * @throws \Exception|\Error If the remote logging fails. The error is caught and logged locally.
	 * @return bool
	 */
	private function log( $level, $message, $context ) {
		$log_data = $this->get_formatted_log( $level, $message, $context );

			// Ensure the log data is valid.
		if ( ! is_array( $log_data ) || empty( $log_data['message'] ) || empty( $log_data['feature'] ) ) {
			return false;
		}

		$body = SafeGlobalFunctionProxy::wp_json_encode( array( 'params' => SafeGlobalFunctionProxy::wp_json_encode( $log_data ) ) );
		if ( is_null( $body ) ) { // if the json encoding fails the API will reject the API call so let's not bother.
			throw new \Error( 'Remote Logger encountered error while attempting to JSON encode $log_data' );
		}

		WC_Rate_Limiter::set_rate_limit( self::RATE_LIMIT_ID, self::RATE_LIMIT_DELAY );

		if ( $this->is_dev_or_local_environment() ) {
			return false;
		}

		$response = SafeGlobalFunctionProxy::wp_safe_remote_post(
			self::LOG_ENDPOINT,
			array(
				'body'     => $body,
				'timeout'  => 3,
				'headers'  => array(
					'Content-Type' => 'application/json',
				),
				'blocking' => false,
			)
		);

		if ( is_null( $response ) ) { // SafeGlobalFunctionProxy will return a null if an error occurs within, so there will be a separate log entry with the details.
			SafeGlobalFunctionProxy::wc_get_logger()->error( 'Failed to call wp_safe_remote_post while sending the log to the remote logging service.', array( 'source' => 'remote-logging' ) );
			return false;
		}

		$is_api_call_error = SafeGlobalFunctionProxy::is_wp_error( $response );

		if ( $is_api_call_error ) {
			SafeGlobalFunctionProxy::wc_get_logger()->error( 'Failed to send the log to the remote logging service: ' . $response->get_error_message(), array( 'source' => 'remote-logging' ) );
			return false;
		} elseif ( is_null( $is_api_call_error ) ) {
			SafeGlobalFunctionProxy::wc_get_logger()->error( 'Failed to parse the response after sending log to the remote logging service. ', array( 'source' => 'remote-logging' ) );
			return false;
		}
		return true;
	}

	/**
	 * Check if the current WooCommerce version is the latest.
	 *
	 * @return bool
	 */
	private function should_current_version_be_logged() {
		$new_version = SafeGlobalFunctionProxy::get_site_transient( self::WC_NEW_VERSION_TRANSIENT ) ?? '';

		if ( false === $new_version ) {
			$new_version = $this->fetch_new_woocommerce_version();
			// Cache the new version for a week since we want to keep logging in with the same version for a while even if the new version is available.
			SafeGlobalFunctionProxy::set_site_transient( self::WC_NEW_VERSION_TRANSIENT, $new_version, WEEK_IN_SECONDS );
		}

		if ( ! is_string( $new_version ) || '' === $new_version ) {
			// If the new version is not available, we consider the current version to be the latest.
			return true;
		}

		// If the current version is the latest, we don't want to log errors.
		return version_compare( $this->get_wc_version(), $new_version, '>=' );
	}

	/**
	 * Get the current WooCommerce version reliably through a series of fallbacks
	 *
	 * @return string The current WooCommerce version.
	 */
	private function get_wc_version() {
		if ( class_exists( '\Automattic\Jetpack\Constants' ) && method_exists( '\Automattic\Jetpack\Constants', 'get_constant' ) ) {
			$wc_version = \Automattic\Jetpack\Constants::get_constant( 'WC_VERSION' );
			if ( $wc_version ) {
				return $wc_version;
			}
		}

		if ( defined( 'WC_VERSION' ) ) {
			return WC_VERSION;
		}

		if ( function_exists( 'WC' ) ) {
			return WC()->version;
		}

		// Return null since none of the above worked.
		return null;
	}

	/**
	 * Check if the error exclusively contains third-party stack frames for fatal-errors source context.
	 *
	 * @param string $message The error message.
	 * @param array  $context The error context.
	 *
	 * @return bool
	 */
	protected function is_third_party_error( string $message, array $context ): bool {
		// Only check for fatal-errors source context.
		if ( ! isset( $context['source'] ) || 'fatal-errors' !== $context['source'] ) {
			return false;
		}

		$wc_plugin_dir = StringUtil::normalize_local_path_slashes( WC_ABSPATH );

		// Check if the error message contains the WooCommerce plugin directory.
		if ( str_contains( $message, $wc_plugin_dir ) ) {
			return false;
		}

		// Without a backtrace, it's impossible to ascertain if the error is third-party. To avoid logging numerous irrelevant errors, we'll consider it a third-party error and ignore it.
		if ( isset( $context['backtrace'] ) && is_array( $context['backtrace'] ) ) {
			$wp_includes_dir = StringUtil::normalize_local_path_slashes( ABSPATH . WPINC );
			$wp_admin_dir    = StringUtil::normalize_local_path_slashes( ABSPATH . 'wp-admin' );

			// Find the first relevant frame that is not from WordPress core and not empty.
			$relevant_frame = null;
			foreach ( $context['backtrace'] as $frame ) {
				if ( empty( $frame ) || ! is_string( $frame ) ) {
					continue;
				}

				// Skip frames from WordPress core.
				if ( strpos( $frame, $wp_includes_dir ) !== false || strpos( $frame, $wp_admin_dir ) !== false ) {
					continue;
				}

				$relevant_frame = $frame;
				break;
			}

			// Check if the relevant frame is from WooCommerce.
			if ( $relevant_frame && strpos( $relevant_frame, $wc_plugin_dir ) !== false ) {
				return false;
			}
		}

		if ( ! function_exists( 'apply_filters' ) ) {
			require_once ABSPATH . WPINC . '/plugin.php';
		}
		/**
		 * Filter to allow other plugins to overwrite the result of the third-party error check for remote logging.
		 *
		 * @since 9.2.0
		 *
		 * @param bool   $is_third_party_error The result of the third-party error check.
		 * @param string $message              The error message.
		 * @param array  $context              The error context.
		 */
		return apply_filters( 'woocommerce_remote_logging_is_third_party_error', true, $message, $context );
	}

	/**
	 * Fetch the new version of WooCommerce from the WordPress API.
	 *
	 * @return string|null New version if an update is available, null otherwise.
	 */
	private function fetch_new_woocommerce_version() {
		$plugin_updates = SafeGlobalFunctionProxy::get_plugin_updates();

		// Check if WooCommerce plugin update information is available.
		if ( ! is_array( $plugin_updates ) || ! isset( $plugin_updates[ WC_PLUGIN_BASENAME ] ) ) {
			return null;
		}

		$wc_plugin_update = $plugin_updates[ WC_PLUGIN_BASENAME ];

		// Ensure the update object exists and has the required information.
		if ( ! $wc_plugin_update || ! isset( $wc_plugin_update->update->new_version ) ) {
			return null;
		}

		$new_version = $wc_plugin_update->update->new_version;
		return is_string( $new_version ) ? $new_version : null;
	}

	/**
	 * Sanitize the content to exclude sensitive data.
	 *
	 * The trace is sanitized by:
	 *
	 * 1. Remove the absolute path to the plugin directory based on WC_ABSPATH. This is more accurate than using WP_PLUGIN_DIR when the plugin is symlinked.
	 * 2. Remove the absolute path to the WordPress root directory.
	 * 3. Redact potential user data such as email addresses and phone numbers.
	 *
	 * For example, the trace:
	 *
	 * /var/www/html/wp-content/plugins/woocommerce/includes/class-wc-remote-logger.php on line 123
	 * will be sanitized to: **\/woocommerce/includes/class-wc-remote-logger.php on line 123
	 *
	 * Additionally, any user data like email addresses or phone numbers will be redacted.
	 *
	 * @param string $content The content to sanitize.
	 *
	 * @return string The sanitized content.
	 */
	private function sanitize( $content ) {
		if ( ! is_string( $content ) ) {
			return $content;
		}

		$sanitized = $this->normalize_paths( $content );
		$sanitized = $this->redact_user_data( $sanitized );

		if ( ! function_exists( 'apply_filters' ) ) {
			require_once ABSPATH . WPINC . '/plugin.php';
		}

		/**
		 * Filter the sanitized log content before it's sent to the remote logging service.
		 *
		 * @since 9.5.0
		 *
		 * @param string $sanitized The sanitized content.
		 * @param string $content The original content.
		 */
		return apply_filters( 'woocommerce_remote_logger_sanitized_content', $sanitized, $content );
	}

	/**
	 * Normalize file paths by replacing absolute paths with relative ones.
	 *
	 * @param string $content The content containing paths to normalize.
	 *
	 * @return string The content with normalized paths.
	 */
	private function normalize_paths( string $content ): string {
		$plugin_path = StringUtil::normalize_local_path_slashes( trailingslashit( dirname( WC_ABSPATH ) ) );
		$wp_path     = StringUtil::normalize_local_path_slashes( trailingslashit( ABSPATH ) );

		return str_replace(
			array( $plugin_path, $wp_path ),
			array( './', './' ),
			$content
		);
	}

	/**
	 * Sanitize the error trace to exclude sensitive data.
	 *
	 * @param array|string $trace The error trace.
	 * @return string The sanitized trace.
	 */
	private function sanitize_trace( $trace ): string {
		if ( is_string( $trace ) ) {
			return $this->sanitize( $trace );
		}

		if ( ! is_array( $trace ) ) {
			return '';
		}

		$sanitized_trace = array_map(
			function ( $trace_item ) {
				if ( is_array( $trace_item ) && isset( $trace_item['file'] ) ) {
					$trace_item['file'] = $this->sanitize( $trace_item['file'] );
					return $trace_item;
				}

				return $this->sanitize( $trace_item );
			},
			$trace
		);

		$is_array_by_file = isset( $sanitized_trace[0]['file'] );
		if ( $is_array_by_file ) {
			return SafeGlobalFunctionProxy::wc_print_r( $sanitized_trace, true );
		}

		return implode( "\n", $sanitized_trace );
	}


	/**
	 * Redact potential user data from the content.
	 *
	 * @param string $content The content to redact.
	 * @return string The redacted message.
	 */
	private function redact_user_data( $content ) {
		// Redact email addresses.
		$content = preg_replace( '/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', '[redacted_email]', $content );

		// Redact potential IP addresses.
		$content = preg_replace( '/\b(?:\d{1,3}\.){3}\d{1,3}\b/', '[redacted_ip]', $content );

		// Redact potential credit card numbers.
		$content = preg_replace( '/(\d{4}[- ]?){3}\d{4}/', '[redacted_credit_card]', $content );

		// API key redaction patterns.
		$api_patterns = array(
			'/\b[A-Za-z0-9]{32,40}\b/',                // Generic API key.
			'/\b[0-9a-f]{32}\b/i',                     // 32 hex characters.
			'/\b(?:[A-Z0-9]{4}-){3,7}[A-Z0-9]{4}\b/i', // Segmented API key (e.g., XXXX-XXXX-XXXX-XXXX).
			'/\bsk_[A-Za-z0-9]{24,}\b/i',              // Stripe keys (starts with sk_).
		);

		foreach ( $api_patterns as $pattern ) {
			$content = preg_replace( $pattern, '[redacted_api_key]', $content );
		}

		/**
		 * Redact potential phone numbers.
		 *
		 * This will match patterns like:
		 * +1 (123) 456 7890 (with parentheses around area code)
		 * +44-123-4567-890 (with area code, no parentheses)
		 * 1234567890 (10 consecutive digits, no area code)
		 * (123) 456-7890 (area code in parentheses, groups)
		 * +91 12345 67890 (international format with space)
		 */
		$content = preg_replace(
			'/(?:(?:\+?\d{1,3}[-\s]?)?\(?\d{3}\)?[-\s]?\d{3}[-\s]?\d{4}|\b\d{10,11}\b)/',
			'[redacted_phone]',
			$content
		);

		return $content;
	}

	/**
	 * Check if the current environment is development or local.
	 *
	 * Creates a helper method so we can easily mock this in tests.
	 *
	 * @return bool
	 */
	protected function is_dev_or_local_environment() {
		return in_array( SafeGlobalFunctionProxy::wp_get_environment_type() ?? 'production', array( 'development', 'local' ), true );
	}
	/**
	 * Sanitize the request URI to only allow certain query parameters.
	 *
	 * @param string $request_uri The request URI to sanitize.
	 * @return string The sanitized request URI.
	 */
	private function sanitize_request_uri( $request_uri ) {
		$default_whitelist = array(
			'path',
			'page',
			'step',
			'task',
			'tab',
			'section',
			'status',
			'post_type',
			'taxonomy',
			'action',
		);

		/**
		 * Filter to allow other plugins to whitelist request_uri query parameter values for unmasked remote logging.
		 *
		 * @since 9.4.0
		 *
		 * @param string   $default_whitelist The default whitelist of query parameters.
		 */
		$whitelist = apply_filters( 'woocommerce_remote_logger_request_uri_whitelist', $default_whitelist );

		$parsed_url = SafeGlobalFunctionProxy::wp_parse_url( $request_uri );
		if ( ! is_array( $parsed_url ) || ! isset( $parsed_url['query'] ) ) {
			return $request_uri;
		}

		parse_str( $parsed_url['query'], $query_params );

		foreach ( $query_params as $key => &$value ) {
			if ( ! in_array( $key, $whitelist, true ) ) {
				$value = 'xxxxxx';
			}
		}

		$parsed_url['query'] = http_build_query( $query_params );
		return $this->build_url( $parsed_url );
	}

	/**
	 * Build a URL from its parsed components.
	 *
	 * @param array $parsed_url The parsed URL components.
	 * @return string The built URL.
	 */
	private function build_url( $parsed_url ) {
		$path     = $parsed_url['path'] ?? '';
		$query    = isset( $parsed_url['query'] ) ? "?{$parsed_url['query']}" : '';
		$fragment = isset( $parsed_url['fragment'] ) ? "#{$parsed_url['fragment']}" : '';

		return "$path$query$fragment";
	}
}
PK     [1]!N*      StockNotifications/Config.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications;

use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Enums\ProductStockStatus;
use Automattic\WooCommerce\Enums\ProductStatus;

/**
 * Configuration class for stock notifications.
 */
class Config {

	/**
	 * Runtime cache for supported product types.
	 *
	 * @var array<string>
	 */
	private static $supported_product_types;

	/**
	 * Runtime cache for supported product statuses.
	 *
	 * @var array<string>
	 */
	private static $supported_product_statuses;

	/**
	 * Runtime cache for eligible stock statuses.
	 *
	 * @var array<string>
	 */
	private static $eligible_stock_statuses;

	/**
	 * Runtime cache for verification expiration time threshold.
	 *
	 * @var int
	 */
	private static $verification_expiration_time_threshold;

	/**
	 * Get the supported product types.
	 *
	 * @return array<string>
	 */
	public static function get_supported_product_types(): array {
		if ( is_array( self::$supported_product_types ) ) {
			return self::$supported_product_types;
		}

		/**
		 * Filter: woocommerce_customer_stock_notifications_supported_product_types
		 *
		 * @since 10.2.0
		 *
		 * @param array $product_types Product types.
		 */
		self::$supported_product_types = (array) apply_filters(
			'woocommerce_customer_stock_notifications_supported_product_types',
			array(
				ProductType::SIMPLE,
				ProductType::VARIABLE,
				ProductType::VARIATION,
			)
		);

		return self::$supported_product_types;
	}

	/**
	 * Get the supported product stock statuses.
	 *
	 * @return array<string>
	 */
	public static function get_supported_product_statuses(): array {
		if ( is_array( self::$supported_product_statuses ) ) {
			return self::$supported_product_statuses;
		}

		/**
		 * Filter: woocommerce_customer_stock_notifications_supported_product_stock_statuses
		 *
		 * @since 10.2.0
		 *
		 * @param array $product_stock_statuses Product stock statuses.
		 */
		self::$supported_product_statuses = (array) apply_filters(
			'woocommerce_customer_stock_notifications_supported_product_stock_statuses',
			array(
				ProductStatus::PUBLISH,
			)
		);

		return self::$supported_product_statuses;
	}

	/**
	 * Get the eligible stock statuses that trigger sending notifications.
	 *
	 * @return array<string>
	 */
	public static function get_eligible_stock_statuses(): array {
		if ( is_array( self::$eligible_stock_statuses ) ) {
			return self::$eligible_stock_statuses;
		}

		/**
		 * Filter: woocommerce_customer_stock_notifications_supported_stock_statuses
		 *
		 * @since 10.2.0
		 *
		 * @param array $stock_statuses Stock statuses.
		 */
		self::$eligible_stock_statuses = (array) apply_filters(
			'woocommerce_customer_stock_notifications_supported_stock_statuses',
			array(
				ProductStockStatus::IN_STOCK,
				ProductStockStatus::ON_BACKORDER,
			)
		);

		return self::$eligible_stock_statuses;
	}

	/**
	 * Get the metadata name for product-level signups.
	 *
	 * @return string
	 */
	public static function get_product_signups_meta_key(): string {
		return 'customer_stock_notifications_enable_signups';
	}

	/**
	 * Check if signups are allowed.
	 *
	 * @return bool
	 */
	public static function allows_signups(): bool {
		return 'yes' === get_option( 'woocommerce_customer_stock_notifications_allow_signups', 'no' );
	}

	/**
	 * Check if double opt-in is required.
	 *
	 * @return bool
	 */
	public static function requires_double_opt_in(): bool {
		return 'yes' === get_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'no' );
	}

	/**
	 * Check if an account is required.
	 *
	 * @return bool
	 */
	public static function requires_account(): bool {
		return 'yes' === get_option( 'woocommerce_customer_stock_notifications_require_account', 'no' );
	}

	/**
	 * Check if an account is created on signup.
	 *
	 * @return bool
	 */
	public static function creates_account_on_signup(): bool {
		return 'yes' === get_option( 'woocommerce_customer_stock_notifications_create_account_on_signup', 'no' );
	}

	/**
	 * How long to keep pending notifications before deleting them (in days).
	 *
	 * @return int
	 */
	public static function get_unverified_deletion_days_threshold(): int {
		return absint(
			get_option(
				'woocommerce_customer_stock_notifications_unverified_deletions_days_threshold',
				0
			)
		);
	}

	/**
	 * Returns verification codes expiration time threshold (in seconds).
	 *
	 * @return int
	 */
	public static function get_verification_expiration_time_threshold(): int {
		if ( ! is_null( self::$verification_expiration_time_threshold ) ) {
			return self::$verification_expiration_time_threshold;
		}

		/**
		 * Filter the verification codes expiration time (in seconds).
		 *
		 * @param int $threshold
		 * @since 10.2.0
		 */
		self::$verification_expiration_time_threshold = (int) apply_filters(
			'woocommerce_customer_stock_notifications_verification_expiration_time_threshold',
			HOUR_IN_SECONDS
		);

		return self::$verification_expiration_time_threshold;
	}
}
PK     [1]˩RJ  J  *  StockNotifications/StockSyncController.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications;

use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EligibilityService;
use Automattic\WooCommerce\Internal\StockNotifications\AsyncTasks\JobManager;
use WC_Product;

/**
 * The controller for the stock events.
 */
class StockSyncController {

	/**
	 * The queue using product IDs as keys.
	 *
	 * @var array<int, bool>
	 */
	private array $queue = array();

	/**
	 * The eligibility service instance.
	 *
	 * @var EligibilityService
	 */
	private EligibilityService $eligibility_service;

	/**
	 * The job manager instance.
	 *
	 * @var JobManager
	 */
	private JobManager $job_manager;

	/**
	 * Logger instance.
	 *
	 * @var \WC_Logger_Interface
	 */
	protected $logger;

	/**
	 * Init.
	 *
	 * @internal
	 *
	 * @param EligibilityService $eligibility_service The eligibility service instance.
	 * @param JobManager         $job_manager         The job manager instance.
	 */
	final public function init(
		EligibilityService $eligibility_service,
		JobManager $job_manager
	): void {
		$this->logger              = \wc_get_logger();
		$this->eligibility_service = $eligibility_service;
		$this->job_manager         = $job_manager;
	}

	/**
	 * Constructor.
	 */
	public function __construct() {
		// Event handlers.
		add_action( 'woocommerce_product_set_stock_status', array( $this, 'handle_product_stock_status_change' ), 100, 3 );
		add_action( 'woocommerce_variation_set_stock_status', array( $this, 'handle_product_stock_status_change' ), 100, 3 );

		// Process the queue on shutdown.
		add_action( 'shutdown', array( $this, 'process_queue' ) );

		// Output the admin notice.
		add_action( 'admin_notices', array( $this, 'output_admin_notice' ) );
	}

	/**
	 * Handle product stock status changes.
	 *
	 * @param int             $product_id   The product ID.
	 * @param string          $stock_status The new stock status.
	 * @param WC_Product|null $product      The product object (optional).
	 * @return void
	 */
	public function handle_product_stock_status_change( $product_id, $stock_status, $product = null ) {

		try {

			if ( ! $this->eligibility_service->is_stock_status_eligible( $stock_status ) ) {
				return;
			}

			if ( null === $product ) {
				$product = \wc_get_product( $product_id );
			}

			if ( ! is_a( $product, 'WC_Product' ) ) {
				return;
			}

			if ( ! $this->eligibility_service->is_product_eligible( $product ) ) {
				return;
			}

			if ( ! $this->eligibility_service->has_active_notifications( $product ) ) {
				return;
			}

			// Add to queue.
			$target_product_ids = $this->eligibility_service->get_target_product_ids( $product );
			foreach ( $target_product_ids as $target_product_id ) {
				$this->queue[ $target_product_id ] = true;
			}

			$this->store_admin_notice( $product->get_id() );

		} catch ( \Throwable $e ) {
			$this->logger->error(
				sprintf( 'StockSyncController: Failed to process product %d: %s', $product_id, $e->getMessage() ),
				array( 'source' => 'wc-customer-stock-notifications' )
			);
		}
	}

	/**
	 * Process the product IDs in the queue.
	 *
	 * Called on shutdown to schedule Action Scheduler jobs
	 * for each product ID in the queue.
	 *
	 * @return void
	 */
	public function process_queue(): void {
		if ( empty( $this->queue ) || ! is_array( $this->queue ) ) {
			$this->queue = array();
			return;
		}

		$product_ids = array_filter( array_keys( $this->queue ) );
		if ( empty( $product_ids ) ) {
			return;
		}

		foreach ( $product_ids as $product_id ) {
			$this->job_manager->schedule_initial_job_for_product( $product_id );
		}

		/**
		 * Allows for additional processing of the product IDs after they have been queued.
		 *
		 * @since 10.2.0
		 *
		 * @param array $product_ids The product IDs to process.
		 */
		do_action( 'woocommerce_customer_stock_notifications_product_sync', $product_ids );
		$this->queue = array();
	}

	/**
	 * Store the admin notice.
	 *
	 * @param int $product_id The product ID to sync.
	 * @return void
	 */
	private function store_admin_notice( $product_id ): void {
		if ( ! is_admin() || ! function_exists( 'wp_admin_notice' ) ) {
			return;
		}

		/* translators: 1 = URL of the Back in Stock Notifications page */
		$notice_message = sprintf( __( 'Back-in-stock notifications for this product are now being processed. Subscribed customers will receive these emails over the next few minutes. You can monitor or manage individual subscriptions on the <a href="%s">Stock Notifications page</a>.', 'woocommerce' ), sprintf( admin_url( 'admin.php?page=wc-customer-stock-notifications&customer_stock_notifications_product_filter=%d&status=active_customer_stock_notifications&filter_action=Filter' ), $product_id ) );

		update_option( 'wc_customer_stock_notifications_product_sync_notice', $notice_message );
	}

	/**
	 * Add admin notices.
	 *
	 * @return void
	 */
	public function output_admin_notice(): void {
		if ( ! function_exists( 'wp_admin_notice' ) ) {
			return;
		}

		$notice_message = get_option( 'wc_customer_stock_notifications_product_sync_notice' );
		if ( empty( $notice_message ) ) {
			return;
		}

		\wp_admin_notice(
			$notice_message,
			array(
				'type'        => 'info',
				'id'          => 'woocommerce_customer_stock_notifications_product_sync_notice',
				'dismissible' => false,
			)
		);

		delete_option( 'wc_customer_stock_notifications_product_sync_notice' );
	}
}
PK     [1]܆;!  ;!  B  StockNotifications/Emails/CustomerStockNotificationVerifyEmail.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\Emails;

use Automattic\WooCommerce\Internal\StockNotifications\Config;
use Automattic\WooCommerce\Internal\StockNotifications\Notification;
use Automattic\WooCommerce\Internal\StockNotifications\Factory;
use WC_Email;

/**
 * Back in stock notification email class.
 */
class CustomerStockNotificationVerifyEmail extends WC_Email {

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->id             = 'customer_stock_notification_verify';
		$this->customer_email = true;

		$this->title       = __( 'Back in stock sign-up verification', 'woocommerce' );
		$this->description = __( 'Verification e-mail sent to customers, as part of the double opt-in sign-up process.', 'woocommerce' );

		$this->template_html  = 'emails/customer-stock-notification-verify.php';
		$this->template_plain = 'emails/plain/customer-stock-notification-verify.php';
		$this->placeholders   = array(
			'{product_name}' => '',
			'{site_title}'   => '',
		);

		add_action( 'woocommerce_email_stock_notification_verify_notification', array( $this, 'trigger' ), 10, 1 );

		// Call parent constructor.
		parent::__construct();
	}

	/**
	 * Get email subject.
	 *
	 * @return string
	 */
	public function get_default_subject() {
		return __( 'Join the "{product_name}" waitlist.', 'woocommerce' );
	}

	/**
	 * Get email heading.
	 *
	 * @return string
	 */
	public function get_default_heading() {
		return __( 'Confirm sign-up', 'woocommerce' );
	}

	/**
	 * Get default email content.
	 *
	 * @return string
	 */
	public function get_default_intro_content() {
		return __( 'Please follow the link below to complete the sign-up process and join the "{product_name}" waitlist.', 'woocommerce' );
	}

	/**
	 * Default content to show below main email content.
	 *
	 * @return string
	 */
	public function get_default_additional_content() {
		return __( 'Thanks for shopping with us.', 'woocommerce' );
	}

	/**
	 * Get email content.
	 *
	 * @return string
	 */
	public function get_intro_content() {
		/**
		 * Allows modifying the email introduction content.
		 *
		 * @since  10.2.0
		 *
		 * @return string
		 */
		return apply_filters( 'woocommerce_email_stock_notification_intro_content', $this->format_string( $this->get_option_or_transient( 'intro_content', $this->get_default_intro_content() ) ), $this->object, $this );
	}

	/**
	 * Get content html.
	 *
	 * @return string
	 */
	public function get_content_html() {
		return wc_get_template_html(
			$this->template_html,
			array_merge(
				$this->get_additional_template_args(),
				array(
					'notification'       => $this->object,
					'product'            => $this->object->get_product(),
					'email_heading'      => $this->get_heading(),
					'intro_content'      => $this->get_intro_content(),
					'additional_content' => $this->get_additional_content(),
					'plain_text'         => false,
					'email'              => $this,
				),
			),
		);
	}

	/**
	 * Get content plain.
	 *
	 * @return string
	 */
	public function get_content_plain() {
		return wc_get_template_html(
			$this->template_plain,
			array_merge(
				$this->get_additional_template_args(),
				array(
					'notification'       => $this->object,
					'product'            => $this->object->get_product(),
					'email_heading'      => $this->get_heading(),
					'intro_content'      => $this->get_intro_content(),
					'additional_content' => $this->get_additional_content(),
					'plain_text'         => true,
					'email'              => $this,
				),
			),
		);
	}

	/**
	 * Get template args.
	 *
	 * @return array
	 */
	private function get_additional_template_args(): array {

		$notification = $this->object;
		$product      = $notification->get_product();

		/**
		 * Filter the button text.
		 *
		 * @since 10.2.0
		 *
		 * @param string $button_text The button text.
		 * @param Notification $notification The notification object.
		 * @param WC_Product $product The product object.
		 */
		$verification_button_text  = apply_filters(
			'woocommerce_email_stock_notification_verify_button_text',
			_x( 'Confirm', 'Stock Notification confirm notification', 'woocommerce' ),
			$notification,
			$product
		);
		$verification_key          = $notification->get_verification_key( true );
		$expiration_threshold      = Config::get_verification_expiration_time_threshold();
		$expiration_threshold_text = sprintf(
			/* translators: %s is the time duration in minutes */
			_n( '%s minute', '%s minutes', $expiration_threshold / 60, 'woocommerce' ),
			floor( $expiration_threshold / 60 )
		);

		return array(
			'verification_button_text'          => $verification_button_text,
			'verification_expiration_threshold' => $expiration_threshold_text,
			'verification_link'                 => add_query_arg(
				array(
					'email_link_action_key' => $verification_key,
					'notification_id'       => $notification->get_id(),
				),
				get_option( 'siteurl' )
			),
		);
	}

	/**
	 * Trigger the sending of this email.
	 *
	 * @param Notification|int $notification The notification object or ID.
	 */
	public function trigger( $notification ) {
		$this->setup_locale();

		if ( is_numeric( $notification ) ) {
			$notification = Factory::get_notification( $notification );
		}

		if ( ! $notification instanceof Notification ) {
			return;
		}

		$product = $notification->get_product();
		if ( ! $product || ! is_a( $product, 'WC_Product' ) ) {
			return;
		}

		$this->maybe_setup_notification_locale( $notification );
		$this->prepare_email( $notification );

		if ( $this->is_enabled() && $this->get_recipient() ) {

			$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );

		}

		$this->maybe_restore_notification_locale( $notification );
		$this->restore_locale();
	}

	/**
	 * Prepares the email based on the notification data.
	 *
	 * @param Notification $notification Notification.
	 * @return void
	 */
	public function prepare_email( Notification $notification ): void {
		$this->object                         = $notification;
		$this->recipient                      = $notification->get_user_email();
		$product                              = $notification->get_product();
		$this->placeholders['{product_name}'] = preg_replace( $this->plain_search, $this->plain_replace, $product->get_name() );
		$this->placeholders['{site_title}']   = preg_replace( $this->plain_search, $this->plain_replace, $this->get_blogname() );
	}


	/**
	 * Setup notification locale if necessary based on notification meta.
	 *
	 * @param Notification $notification Notification object.
	 */
	private function maybe_setup_notification_locale( $notification ) {
		$customer_locale = $notification->get_meta( '_customer_locale' );
		if ( ! empty( $customer_locale ) ) {
			switch_to_locale( $customer_locale );
		}
	}

	/**
	 * Restore locale if previously switched.
	 *
	 * @param Notification $notification Notification object.
	 */
	private function maybe_restore_notification_locale( $notification ) {
		$customer_locale = $notification->get_meta( '_customer_locale' );
		if ( ! empty( $customer_locale ) ) {
			restore_previous_locale();
		}
	}

	/**
	 * Initialize Settings Form Fields.
	 *
	 * @return void
	 */
	public function init_form_fields() {

		parent::init_form_fields();

		if ( ! is_array( $this->form_fields ) ) {
			return;
		}

		/* translators: %s: list of placeholders */
		$placeholder_text = sprintf( __( 'Available placeholders: %s', 'woocommerce' ), '<code>' . esc_html( implode( '</code>, <code>', array_keys( $this->placeholders ) ) ) . '</code>' );

		$intro_content_field = array(
			'title'       => __( 'Email content', 'woocommerce' ),
			'description' => __( 'Text to appear below the main e-mail header.', 'woocommerce' ) . ' ' . $placeholder_text,
			'css'         => 'width: 400px; height: 75px;',
			'placeholder' => $this->get_default_intro_content(),
			'type'        => 'textarea',
			'desc_tip'    => true,
		);

		// Find `heading` key.
		$inject_index = array_search( 'heading', array_keys( $this->form_fields ), true );
		if ( $inject_index ) {
			++$inject_index;
		} else {
			$inject_index = 0;
		}

		// Inject.
		$this->form_fields = array_slice( $this->form_fields, 0, $inject_index, true ) + array( 'intro_content' => $intro_content_field ) + array_slice( $this->form_fields, $inject_index, count( $this->form_fields ) - $inject_index, true );
	}
}
PK     [1]&    3  StockNotifications/Emails/EmailActionController.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\Emails;

use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationCancellationSource;
use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
use Automattic\WooCommerce\Internal\StockNotifications\Factory;
use Automattic\WooCommerce\Internal\StockNotifications\Notification;

/**
 * Class EmailActionController
 *
 * Handles email actions such as verification and unsubscribe.
 *
 * @package Automattic\WooCommerce\Internal\StockNotifications\Emails
 */
class EmailActionController {
	/**
	 * EmailActionController constructor.
	 *
	 * Initializes the controller by adding actions to process verification and unsubscribe actions from requests.
	 */
	public function __construct() {
		add_action( 'template_redirect', array( $this, 'maybe_process_email_action' ) );
	}

	/**
	 * This method checks if the request contains indicators to process an action from an email link.
	 */
	public function maybe_process_email_action(): void {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( ! isset( $_GET['notification_id'] ) || ! isset( $_GET['email_link_action_key'] ) ) {
			return;
		}
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$notification_id = absint( wp_unslash( $_GET['notification_id'] ) );
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$action_key = sanitize_text_field( wp_unslash( $_GET['email_link_action_key'] ) );

		$this->validate_and_maybe_process_request( $notification_id, $action_key );
	}

	/**
	 * Checks request parameters and processes the notification based on the action key.
	 *
	 * @param int    $notification_id The ID of the notification to process.
	 * @param string $email_link_action_key The action key from the email link.
	 * @return void
	 */
	public function validate_and_maybe_process_request( int $notification_id, string $email_link_action_key ): void {
		if ( empty( $email_link_action_key ) || empty( $notification_id ) ) {
			return;
		}

		$notification = $this->get_notification_to_be_processed( $notification_id );

		if ( ! $notification ) {
			return;
		}

		$action_key = $notification->get_meta( 'email_link_action_key' );
		if ( strpos( $action_key, ':' ) !== false ) {
			$this->process_verification_action( $notification, $email_link_action_key );
		} else {
			$this->process_unsubscribe_action( $notification, $email_link_action_key );
		}
	}

	/**
	 * If the verification key matches, it updates the notification status to active.
	 *
	 * @param Notification $notification The notification to process.
	 * @param string       $action_key The action key to verify.
	 * @return void
	 */
	private function process_verification_action( Notification $notification, string $action_key ): void {
		if ( $notification->check_verification_key( $action_key ) ) {
			$notification->set_status( NotificationStatus::ACTIVE );
			$notification->set_date_confirmed( time() );
			$notification->save();

			// We need session for notices to work.
			if ( ! WC()->session->has_session() ) {
				// Generate a random customer ID.
				WC()->session->set_customer_session_cookie( true );
			}

			$product = wc_get_product( $notification->get_product_id() );

			/* translators: %s is product name */
			$notice_text = sprintf( esc_html__( 'Successfully verified stock notifications for "%s".', 'woocommerce' ), $product->get_name() );
			wc_add_notice( $notice_text );
			/**
			 * `woocommerce_customer_stock_notification_verified_redirect_url` filter.
			 *
			 * @since 10.2.0
			 *
			 * @param  string  $url
			 * @return string
			 */
			$url = apply_filters( 'woocommerce_customer_stock_notification_verified_redirect_url', get_permalink( wc_get_page_id( 'shop' ) ) );
			wp_safe_redirect( $url );
		}
	}

	/**
	 * If the unsubscribe key matches, it updates the notification status to cancelled.
	 *
	 * @param Notification $notification The Notification to process.
	 * @param string       $action_key The action key to verify.
	 * @return void
	 */
	private function process_unsubscribe_action( Notification $notification, string $action_key ): void {
		if ( $notification->check_unsubscribe_key( $action_key ) ) {
			$notification->set_status( NotificationStatus::CANCELLED );
			$notification->set_cancellation_source( NotificationCancellationSource::USER );
			$notification->set_date_cancelled( time() );
			$notification->save();

			// We need session for notices to work.
			if ( ! WC()->session->has_session() ) {
				// Generate a random customer ID.
				WC()->session->set_customer_session_cookie( true );
			}

			$product = wc_get_product( $notification->get_product_id() );

			/* translators: %2$s product name, %1$s user email */
			$notice_text = sprintf( esc_html__( 'Successfully unsubscribed %1$s. You will not receive a notification when "%2$s" becomes available.', 'woocommerce' ), $notification->get_user_email(), $product->get_name() );
			wc_add_notice( $notice_text );
			/**
			 * `woocommerce_customer_stock_notification_unsubscribe_redirect_url` filter.
			 *
			 * @since 10.2.0
			 *
			 * @param  string  $url
			 * @return string
			 */
			$url = apply_filters( 'woocommerce_customer_stock_notification_unsubscribe_redirect_url', get_permalink( wc_get_page_id( 'shop' ) ) );
			wp_safe_redirect( $url );
		}
	}

	/**
	 * Retrieves the notification to be processed based on the provided notification ID and action key.
	 *
	 * @param int $notification_id The ID of the notification to process.
	 * @return Notification|false The notification object if found and has an action key, null otherwise.
	 */
	private function get_notification_to_be_processed( int $notification_id ): ?Notification {
		$notification = Factory::get_notification( (int) $notification_id );

		if ( ! $notification ) {
			return false;
		}

		if ( empty( $notification->get_meta( 'email_link_action_key' ) ) ) {
			return false;
		}

		return $notification;
	}
}
PK     [1]@u    6  StockNotifications/Emails/EmailTemplatesController.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\Emails;

use Automattic\WooCommerce\Internal\StockNotifications\Notification;

/**
 * Email templates controller.
 */
class EmailTemplatesController {

	/**
	 * Initialize the class.
	 *
	 * @internal
	 *
	 * @return void
	 */
	final public function init() {
		add_action( 'init', array( $this, 'register_template_hooks' ) );
	}

	/**
	 * Add template hooks.
	 *
	 * @internal
	 */
	public function register_template_hooks() {
		add_action( 'woocommerce_email_stock_notification_product', array( $this, 'email_product_image' ), 10, 3 );
		add_action( 'woocommerce_email_stock_notification_product', array( $this, 'email_product_title' ), 20, 3 );
		add_action( 'woocommerce_email_stock_notification_product', array( $this, 'email_product_attributes' ), 30, 3 );
		add_action( 'woocommerce_email_stock_notification_product', array( $this, 'email_product_price' ), 40, 3 );
	}

	/**
	 * Email product image.
	 *
	 * @param WC_Product   $product The product object.
	 * @param Notification $notification The notification object.
	 * @param bool         $plain_text Whether the email is plain text.
	 */
	public function email_product_image( $product, $notification, $plain_text = false ) {
		if ( $plain_text ) {
			return;
		}

		$image     = wp_get_attachment_image_src( $product->get_image_id(), 'woocommerce_thumbnail' );
		$image_src = is_array( $image ) && isset( $image[0] ) ? $image[0] : '';

		ob_start();
		if ( $image_src ) { ?>
				<div id="notification__product__image">
					<img src="<?php echo esc_attr( $image_src ); ?>" alt="<?php echo esc_attr( $product->get_title() ); ?>" width="220"/>
				</div>
			<?php
		}
		$html = ob_get_clean();
		echo wp_kses_post( $html );
	}

	/**
	 * Email product title.
	 *
	 * @param WC_Product   $product The product object.
	 * @param Notification $notification The notification object.
	 * @param bool         $plain_text Whether the email is plain text.
	 */
	public function email_product_title( $product, $notification, $plain_text = false ) {
		if ( $plain_text ) {
			return;
		}

		ob_start();
		?>
		<div id="notification__product__title"><?php echo esc_html( $product->get_name() ); ?></div>
		<?php
		$html = ob_get_clean();
		echo wp_kses_post( $html );
	}

	/**
	 * Email product attributes.
	 *
	 * @param WC_Product   $product The product object.
	 * @param Notification $notification The notification object.
	 * @param bool         $plain_text Whether the email is plain text.
	 */
	public function email_product_attributes( $product, $notification, $plain_text = false ) {
		if ( $plain_text ) {
			return;
		}

		$formatted_variation_list = $notification->get_product_formatted_variation_list( false );
		if ( empty( $formatted_variation_list ) ) {
			return;
		}

		// Convert list to HTML table for better rendering.
		$formatted_variation_list = strtr(
			$formatted_variation_list,
			array(
				'<dl' => '<table',
				'<dd' => '<tr><th',
				'<dt' => '<tr><td',
				'dl>' => 'table>',
				'dd>' => 'th></tr>',
				'dt>' => 'td></tr>',
			)
		);

		ob_start();
		?>
			<div id="notification__product__attributes"><?php echo wp_kses_post( $formatted_variation_list ); ?></div>
		<?php
		$html = ob_get_clean();
		echo wp_kses_post( $html );
	}

	/**
	 * Email product price.
	 *
	 * @param WC_Product   $product The product object.
	 * @param Notification $notification The notification object.
	 * @param bool         $plain_text Whether the email is plain text.
	 */
	public function email_product_price( $product, $notification, $plain_text = false ) {
		if ( $plain_text ) {
			return;
		}

		ob_start();
		?>
		<div id="notification__product__price"><?php echo wp_kses_post( $product->get_price_html() ); ?></div>
		<?php
		$html = ob_get_clean();
		echo wp_kses_post( $html );
	}
}
PK     [1]6    D  StockNotifications/Emails/CustomerStockNotificationVerifiedEmail.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\Emails;

use Automattic\WooCommerce\Internal\StockNotifications\Notification;
use Automattic\WooCommerce\Internal\StockNotifications\Factory;
use WC_Email;

/**
 * Back in stock notification email class.
 */
class CustomerStockNotificationVerifiedEmail extends WC_Email {

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->id             = 'customer_stock_notification_verified';
		$this->customer_email = true;

		$this->title       = __( 'Back in stock sign-up confirmation', 'woocommerce' );
		$this->description = __( 'Email sent to customers after completing the sign-up process successfully.', 'woocommerce' );

		$this->template_html  = 'emails/customer-stock-notification-verified.php';
		$this->template_plain = 'emails/plain/customer-stock-notification-verified.php';
		$this->placeholders   = array(
			'{product_name}' => '',
			'{site_title}'   => '',
		);

		add_action( 'woocommerce_email_stock_notification_verified_notification', array( $this, 'trigger' ), 10, 1 );

		// Call parent constructor.
		parent::__construct();
	}

	/**
	 * Get email subject.
	 *
	 * @return string
	 */
	public function get_default_subject() {
		return __( 'You have joined the "{product_name}" waitlist.', 'woocommerce' );
	}

	/**
	 * Get email heading.
	 *
	 * @return string
	 */
	public function get_default_heading() {
		return __( 'Sign-up successful', 'woocommerce' );
	}

	/**
	 * Get default email content.
	 *
	 * @return string
	 */
	public function get_default_intro_content() {
		return __( 'Thanks for joining the waitlist! You will hear from us again when "{product_name}" is back in stock.', 'woocommerce' );
	}

	/**
	 * Default content to show below main email content.
	 *
	 * @return string
	 */
	public function get_default_additional_content() {
		return __( 'Thanks for shopping with us.', 'woocommerce' );
	}

	/**
	 * Get email content.
	 *
	 * @return string
	 */
	public function get_intro_content() {
		/**
		 * Allows modifying the email introduction content.
		 *
		 * @since  10.2.0
		 *
		 * @return string
		 */
		return apply_filters( 'woocommerce_email_stock_notification_intro_content', $this->format_string( $this->get_option_or_transient( 'intro_content', $this->get_default_intro_content() ) ), $this->object, $this );
	}

	/**
	 * Get content html.
	 *
	 * @return string
	 */
	public function get_content_html() {
		return wc_get_template_html(
			$this->template_html,
			array_merge(
				$this->get_additional_template_args(),
				array(
					'notification'       => $this->object,
					'product'            => $this->object->get_product(),
					'email_heading'      => $this->get_heading(),
					'intro_content'      => $this->get_intro_content(),
					'additional_content' => $this->get_additional_content(),
					'plain_text'         => false,
					'email'              => $this,
				),
			),
		);
	}

	/**
	 * Get content plain.
	 *
	 * @return string
	 */
	public function get_content_plain() {
		return wc_get_template_html(
			$this->template_plain,
			array_merge(
				$this->get_additional_template_args(),
				array(
					'notification'       => $this->object,
					'product'            => $this->object->get_product(),
					'email_heading'      => $this->get_heading(),
					'intro_content'      => $this->get_intro_content(),
					'additional_content' => $this->get_additional_content(),
					'plain_text'         => true,
					'email'              => $this,
				),
			),
		);
	}

	/**
	 * Get template args.
	 *
	 * @return array
	 */
	private function get_additional_template_args(): array {
		$notification    = $this->object;
		$unsubscribe_key = $notification->get_unsubscribe_key( true );
		$user            = get_user_by( 'email', $notification->get_user_email() );
		$is_guest        = ! is_a( $user, 'WP_User' );

		return array(
			'is_guest'         => $is_guest,
			'unsubscribe_link' => add_query_arg(
				array(
					'email_link_action_key' => $unsubscribe_key,
					'notification_id'       => $notification->get_id(),
				),
				get_option( 'siteurl' )
			),
		);
	}

	/**
	 * Trigger the sending of this email.
	 *
	 * @param Notification|int $notification The notification object or ID.
	 */
	public function trigger( $notification ) {
		$this->setup_locale();

		if ( is_numeric( $notification ) ) {
			$notification = Factory::get_notification( $notification );
		}

		if ( ! $notification instanceof Notification ) {
			return;
		}

		$product = $notification->get_product();
		if ( ! $product || ! is_a( $product, 'WC_Product' ) ) {
			return;
		}

		$this->maybe_setup_notification_locale( $notification );
		$this->prepare_email( $notification );

		if ( $this->is_enabled() && $this->get_recipient() ) {

			$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );

		}

		$this->maybe_restore_notification_locale( $notification );
		$this->restore_locale();
	}

	/**
	 * Prepares the email based on the notification data.
	 *
	 * @param Notification $notification Notification.
	 * @return void
	 */
	public function prepare_email( Notification $notification ): void {
		$this->object                         = $notification;
		$this->recipient                      = $notification->get_user_email();
		$product                              = $notification->get_product();
		$this->placeholders['{product_name}'] = preg_replace( $this->plain_search, $this->plain_replace, $product->get_name() );
		$this->placeholders['{site_title}']   = preg_replace( $this->plain_search, $this->plain_replace, $this->get_blogname() );
	}


	/**
	 * Setup notification locale if necessary based on notification meta.
	 *
	 * @param Notification $notification Notification object.
	 */
	private function maybe_setup_notification_locale( $notification ) {
		$customer_locale = $notification->get_meta( '_customer_locale' );
		if ( ! empty( $customer_locale ) ) {
			switch_to_locale( $customer_locale );
		}
	}

	/**
	 * Restore locale if previously switched.
	 *
	 * @param Notification $notification Notification object.
	 */
	private function maybe_restore_notification_locale( $notification ) {
		$customer_locale = $notification->get_meta( '_customer_locale' );
		if ( ! empty( $customer_locale ) ) {
			restore_previous_locale();
		}
	}

	/**
	 * Initialize Settings Form Fields.
	 *
	 * @return void
	 */
	public function init_form_fields() {

		parent::init_form_fields();

		if ( ! is_array( $this->form_fields ) ) {
			return;
		}

		/* translators: %s: list of placeholders */
		$placeholder_text = sprintf( __( 'Available placeholders: %s', 'woocommerce' ), '<code>' . esc_html( implode( '</code>, <code>', array_keys( $this->placeholders ) ) ) . '</code>' );

		$intro_content_field = array(
			'title'       => __( 'Email content', 'woocommerce' ),
			'description' => __( 'Text to appear below the main e-mail header.', 'woocommerce' ) . ' ' . $placeholder_text,
			'css'         => 'width: 400px; height: 75px;',
			'placeholder' => $this->get_default_intro_content(),
			'type'        => 'textarea',
			'desc_tip'    => true,
		);

		// Find `heading` key.
		$inject_index = array_search( 'heading', array_keys( $this->form_fields ), true );
		if ( $inject_index ) {
			++$inject_index;
		} else {
			$inject_index = 0;
		}

		// Inject.
		$this->form_fields = array_slice( $this->form_fields, 0, $inject_index, true ) + array( 'intro_content' => $intro_content_field ) + array_slice( $this->form_fields, $inject_index, count( $this->form_fields ) - $inject_index, true );
	}
}
PK     [1]i:!  :!  <  StockNotifications/Emails/CustomerStockNotificationEmail.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\Emails;

use Automattic\WooCommerce\Internal\StockNotifications\Notification;
use Automattic\WooCommerce\Internal\StockNotifications\Factory;
use WC_Email;

/**
 * Back in stock notification email class.
 */
class CustomerStockNotificationEmail extends WC_Email {

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->id             = 'customer_stock_notification';
		$this->customer_email = true;

		$this->title       = __( 'Back in stock notification', 'woocommerce' );
		$this->description = __( 'Email sent to signed-up customers when a product is back in stock.', 'woocommerce' );

		$this->template_html  = 'emails/customer-stock-notification.php';
		$this->template_plain = 'emails/plain/customer-stock-notification.php';
		$this->placeholders   = array(
			'{product_name}' => '',
			'{site_title}'   => '',
		);

		// Call parent constructor.
		parent::__construct();
	}

	/**
	 * Get email subject.
	 *
	 * @return string
	 */
	public function get_default_subject() {
		return __( '"{product_name}" is back in stock!', 'woocommerce' );
	}

	/**
	 * Get email heading.
	 *
	 * @return string
	 */
	public function get_default_heading() {
		return __( 'It\'s back in stock!', 'woocommerce' );
	}

	/**
	 * Get default email content.
	 *
	 * @return string
	 */
	public function get_default_intro_content() {
		return __( 'Great news: "{product_name}" is now available for purchase.', 'woocommerce' );
	}

	/**
	 * Default content to show below main email content.
	 *
	 * @return string
	 */
	public function get_default_additional_content() {
		return __( 'Thanks for shopping with us.', 'woocommerce' );
	}

	/**
	 * Get email content.
	 *
	 * @return string
	 */
	public function get_intro_content() {
		/**
		 * Allows modifying the email introduction content.
		 *
		 * @since  10.2.0
		 *
		 * @return string
		 */
		return apply_filters( 'woocommerce_email_stock_notification_intro_content', $this->format_string( $this->get_option_or_transient( 'intro_content', $this->get_default_intro_content() ) ), $this->object, $this );
	}

	/**
	 * Get content html.
	 *
	 * @return string
	 */
	public function get_content_html() {
		return wc_get_template_html(
			$this->template_html,
			array_merge(
				$this->get_additional_template_args(),
				array(
					'notification'       => $this->object,
					'product'            => $this->object->get_product(),
					'email_heading'      => $this->get_heading(),
					'intro_content'      => $this->get_intro_content(),
					'additional_content' => $this->get_additional_content(),
					'plain_text'         => false,
					'email'              => $this,
				),
			),
		);
	}

	/**
	 * Get content plain.
	 *
	 * @return string
	 */
	public function get_content_plain() {
		return wc_get_template_html(
			$this->template_plain,
			array_merge(
				$this->get_additional_template_args(),
				array(
					'notification'       => $this->object,
					'product'            => $this->object->get_product(),
					'email_heading'      => $this->get_heading(),
					'intro_content'      => $this->get_intro_content(),
					'additional_content' => $this->get_additional_content(),
					'plain_text'         => true,
					'email'              => $this,
				),
			),
		);
	}

	/**
	 * Get template args.
	 *
	 * @return array
	 */
	private function get_additional_template_args(): array {

		$notification = $this->object;
		$product      = $notification->get_product();

		/**
		 * Filter the button text.
		 *
		 * @since 10.2.0
		 *
		 * @param string $button_text The button text.
		 * @param Notification $notification The notification object.
		 * @param WC_Product $product The product object.
		 */
		$button_text = apply_filters( 'woocommerce_email_stock_notification_button_text', _x( 'Shop Now', 'Email notification', 'woocommerce' ), $notification, $product );

		$query_args = array(
			'utm_source' => 'back-in-stock-notifications',
			'utm_medium' => 'email',
		);

		/**
		 * Filter the button href.
		 *
		 * @since 10.2.0
		 *
		 * @param string $button_href The button href.
		 * @param Notification $notification The notification object.
		 * @param WC_Product $product The product object.
		 */
		$button_link = apply_filters(
			'woocommerce_email_stock_notification_button_link',
			add_query_arg(
				$query_args,
				$notification->get_product_permalink()
			),
			$notification,
			$product
		);

		$unsubscribe_key = $notification->get_unsubscribe_key( true );
		$user            = get_user_by( 'email', $notification->get_user_email() );
		$is_guest        = ! is_a( $user, 'WP_User' );

		return array(
			'button_text'      => $button_text,
			'button_link'      => $button_link,
			'unsubscribe_link' => add_query_arg(
				array(
					'email_link_action_key' => $unsubscribe_key,
					'notification_id'       => $notification->get_id(),
				),
				get_option( 'siteurl' )
			),
			'is_guest'         => $is_guest,
		);
	}

	/**
	 * Trigger the sending of this email.
	 *
	 * @param Notification|int $notification The notification object or ID.
	 */
	public function trigger( $notification ) {
		$this->setup_locale();

		if ( is_numeric( $notification ) ) {
			$notification = Factory::get_notification( $notification );
		}

		if ( ! $notification instanceof Notification ) {
			return;
		}

		$product = $notification->get_product();
		if ( ! $product || ! is_a( $product, 'WC_Product' ) ) {
			return;
		}

		$this->maybe_setup_notification_locale( $notification );
		$this->prepare_email( $notification );

		if ( $this->is_enabled() && $this->get_recipient() ) {

			$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );

		}

		$this->maybe_restore_notification_locale( $notification );
		$this->restore_locale();
	}

	/**
	 * Prepares the email based on the notification data.
	 *
	 * @param Notification $notification Notification.
	 * @return void
	 */
	public function prepare_email( Notification $notification ): void {
		$this->object                         = $notification;
		$this->recipient                      = $notification->get_user_email();
		$product                              = $notification->get_product();
		$this->placeholders['{product_name}'] = preg_replace( $this->plain_search, $this->plain_replace, $product->get_name() );
		$this->placeholders['{site_title}']   = preg_replace( $this->plain_search, $this->plain_replace, $this->get_blogname() );
	}


	/**
	 * Setup notification locale if necessary based on notification meta.
	 *
	 * @param Notification $notification Notification object.
	 */
	private function maybe_setup_notification_locale( $notification ) {
		$customer_locale = $notification->get_meta( '_customer_locale' );
		if ( ! empty( $customer_locale ) ) {
			switch_to_locale( $customer_locale );
		}
	}

	/**
	 * Restore locale if previously switched.
	 *
	 * @param Notification $notification Notification object.
	 */
	private function maybe_restore_notification_locale( $notification ) {
		$customer_locale = $notification->get_meta( '_customer_locale' );
		if ( ! empty( $customer_locale ) ) {
			restore_previous_locale();
		}
	}

	/**
	 * Initialize Settings Form Fields.
	 *
	 * @return void
	 */
	public function init_form_fields() {

		parent::init_form_fields();

		if ( ! is_array( $this->form_fields ) ) {
			return;
		}

		/* translators: %s: list of placeholders */
		$placeholder_text = sprintf( __( 'Available placeholders: %s', 'woocommerce' ), '<code>' . esc_html( implode( '</code>, <code>', array_keys( $this->placeholders ) ) ) . '</code>' );

		$intro_content_field = array(
			'title'       => __( 'Email content', 'woocommerce' ),
			'description' => __( 'Text to appear below the main e-mail header.', 'woocommerce' ) . ' ' . $placeholder_text,
			'css'         => 'width: 400px; height: 75px;',
			'placeholder' => $this->get_default_intro_content(),
			'type'        => 'textarea',
			'desc_tip'    => true,
		);

		// Find `heading` key.
		$inject_index = array_search( 'heading', array_keys( $this->form_fields ), true );
		if ( $inject_index ) {
			++$inject_index;
		} else {
			$inject_index = 0;
		}

		// Inject.
		$this->form_fields = array_slice( $this->form_fields, 0, $inject_index, true ) + array( 'intro_content' => $intro_content_field ) + array_slice( $this->form_fields, $inject_index, count( $this->form_fields ) - $inject_index, true );
	}
}
PK     [1]S   S   *  StockNotifications/Emails/EmailManager.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\Emails;

use Automattic\WooCommerce\Internal\StockNotifications\Notification;
use Automattic\WooCommerce\Internal\StockNotifications\Factory;
use Automattic\WooCommerce\Internal\StockNotifications\Emails\CustomerStockNotificationEmail;
use Automattic\WooCommerce\Internal\StockNotifications\Emails\CustomerStockNotificationVerifyEmail;
use Automattic\WooCommerce\Internal\StockNotifications\Emails\CustomerStockNotificationVerifiedEmail;
use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailTemplatesController;
/**
 * Emails manager.
 */
class EmailManager {

	/**
	 * List of all core email IDs.
	 *
	 * @var array
	 */
	public static $email_ids = array(
		'customer_stock_notification',
		'customer_stock_notification_verify',
		'customer_stock_notification_verified',
	);

	/**
	 * Initialize the emails.
	 *
	 * @internal
	 *
	 * @return void
	 */
	final public function init() {

		// Setup email hooks & handlers.
		add_filter( 'woocommerce_email_classes', array( $this, 'email_classes' ) );

		// Add "transactional" emails.
		add_action( 'woocommerce_email_actions', array( $this, 'add_transactional_emails' ) );

		// Setup styles.
		add_filter( 'woocommerce_email_styles', array( $this, 'add_stylesheets' ), 10, 2 );

		// Preview.
		add_filter( 'woocommerce_prepare_email_for_preview', array( $this, 'prepare_email_for_preview' ) );
		add_filter( 'woocommerce_email_preview_email_content_setting_ids', array( $this, 'add_intro_content_to_preview_settings' ), 10, 2 );

		// Restore customer's context while rendering the emails.
		add_action( 'woocommerce_email_stock_notification_product', array( $this, 'maybe_restore_customer_tax_location_data' ), 9 );

		// Register email templates.
		$container = wc_get_container();
		$container->get( EmailTemplatesController::class );
	}

	/**
	 * Registers custom emails classes.
	 *
	 * @param array $emails Array of email classes.
	 * @return array
	 */
	public function email_classes( $emails ) {
		$emails['WC_Email_Customer_Stock_Notification']          = new CustomerStockNotificationEmail();
		$emails['WC_Email_Customer_Stock_Notification_Verify']   = new CustomerStockNotificationVerifyEmail();
		$emails['WC_Email_Customer_Stock_Notification_Verified'] = new CustomerStockNotificationVerifiedEmail();

		return $emails;
	}

	/**
	 * Adds transactional emails.
	 *
	 * Stock notifications are sent via a custom AS job.
	 * Additionally, two transactional emails are dispatched during the signup and verification processes,
	 * which need to be included in the actions array to support deferred email functionality.
	 *
	 * @hook woocommerce_defer_transactional_emails
	 *
	 * @param array $actions The list of actions.
	 * @return array
	 */
	public function add_transactional_emails( $actions ) {
		if ( ! is_array( $actions ) ) {
			return $actions;
		}

		$actions[] = 'woocommerce_customer_stock_notification_verify';
		$actions[] = 'woocommerce_customer_stock_notification_verified';

		return $actions;
	}

	/**
	 * Restore customer tax location data from notification's metadata
	 * to display product prices in emails using the customer's tax location, if applicable.
	 *
	 * @param  Notification $notification The notification object.
	 * @return void
	 */
	public function maybe_restore_customer_tax_location_data( $notification ) {

		// No need if stores displaying price excluding tax.
		if ( 'incl' !== get_option( 'woocommerce_tax_display_shop' ) ) {
			return;
		}

		// Check if for some reason (e.g., 3PD), a WC_Customer is already assigned into the BG process's context.
		if ( ! empty( WC()->customer ) ) {
			return;
		}

		// Get the recorded customer data, if any.
		$location = $notification->get_meta( '_customer_location_data' );
		if ( empty( $location ) || ! is_array( $location ) || 4 !== count( $location ) ) {
			return;
		}

		// Restore the tax location.
		add_filter(
			'woocommerce_get_tax_location',
			function () use ( $location ) {
				return $location;
			}
		);
	}

	/**
	 * Prints CSS in the emails.
	 *
	 * @param  string   $css The CSS to print.
	 * @param  WC_Email $email (Optional) The email object.
	 * @return string
	 */
	public function add_stylesheets( $css, $email = null ) {

		/**
		 * `woocommerce_email_stock_notification_emails_to_style` filter.
		 *
		 * @since  10.2.0
		 *
		 * @return array
		 */
		if ( ( is_null( $email ) || ! in_array( $email->id, (array) apply_filters( 'woocommerce_email_stock_notification_emails_to_style', self::$email_ids ), true ) ) ) {
			return $css;
		}

		// General text.
		$text = get_option( 'woocommerce_email_text_color' );

		// Primary color.
		$base = get_option( 'woocommerce_email_base_color' );

		/**
		 * `woocommerce_email_stock_notification_base_text_color` filter.
		 *
		 * @since  10.2.0
		 *
		 * @return string
		 */
		$base_text = (string) apply_filters( 'woocommerce_email_stock_notification_base_text_color', wc_light_or_dark( $base, '#202020', '#ffffff' ), $email );

		ob_start();
		?>
		#header_wrapper h1 {
			line-height: 1em !important;
		}
		#notification__container {
			color: <?php echo esc_attr( $text ); ?> !important;
			padding: 20px 20px;
			text-align: center;
			font-family: "Helvetica Neue", Helvetica, Roboto, Arial, sans-serif;
			width: 100%;
		}
		#notification__into_content {
			margin-bottom: 48px;
			color: <?php echo esc_attr( $text ); ?> !important;
		}
		#notification__product__image {
			text-align: center;
			margin-bottom: 20px;
			width: 100%;
		}
		#notification__product__image img {
			margin-right: 0;
			width: 220px;
		}
		#notification__product__title {
			font-size: 16px;
			font-weight: bold;
			line-height: 130%;
			margin-bottom: 5px;
			color: <?php echo esc_attr( $text ); ?> !important;
		}
		#notification__product__attributes table {
			width: 100%;
			padding: 0;
			margin: 0;
			color: <?php echo esc_attr( $text ); ?> !important;
		}
		#notification__product__attributes th,
		#notification__product__attributes td {
			color: <?php echo esc_attr( $text ); ?> !important;
			padding: 4px !important;
			text-align: center;
		}
		#notification__product__price {
			margin-bottom: 20px;
			color: <?php echo esc_attr( $text ); ?> !important;
		}
		#notification__action_button {
			text-decoration: none;
			display: inline-block;
			background: <?php echo esc_attr( $base ); ?>;
			color: <?php echo esc_attr( $base_text ); ?> !important;
			border: 10px solid <?php echo esc_attr( $base ); ?>;
		}
		#notification__verification_expiration {
			font-size: 0.8em;
			margin-top: 20px;
			color: <?php echo esc_attr( $text ); ?>;
		}
		#notification__footer {
			text-align: center;
			margin-top: 20px;
			color: <?php echo esc_attr( $text ); ?>;
		}
		#notification__unsubscribe_link {
			color: <?php echo esc_attr( $text ); ?>;
		}
		#notification__product__price .screen-reader-text {
			display: none;
		}
		<?php
		$css .= ob_get_clean();

		return $css;
	}

	/**
	 * Register intro_content email fields to be watched by WooCommerce's live email preview.
	 *
	 * @param array  $setting_ids The email content setting IDs.
	 * @param string $email_id The email ID.
	 * @return array
	 */
	public function add_intro_content_to_preview_settings( $setting_ids, $email_id ) {

		if ( in_array( $email_id, self::$email_ids, true ) ) {
			$setting_ids[] = "woocommerce_{$email_id}_intro_content";
		}

		return $setting_ids;
	}

	/**
	 * Prepares the email for preview.
	 *
	 * @param \WC_Email $email The email object being previewed.
	 * @return \WC_Email
	 */
	public function prepare_email_for_preview( $email ) {
		if ( ! in_array( $email->id, self::$email_ids, true ) ) {
			return $email;
		}

		$notification = Factory::create_dummy_notification();
		$email->prepare_email( $notification );

		return $email;
	}

	/**
	 * Send a stock notification email.
	 *
	 * @param Notification $notification The notification object.
	 * @return void
	 */
	public function send_stock_notification_email( Notification $notification ) {
		$emails = WC()->mailer()->get_emails();
		if ( isset( $emails['WC_Email_Customer_Stock_Notification'] ) ) {
			$emails['WC_Email_Customer_Stock_Notification']->trigger( $notification );
		}
	}
}
PK     [1] 
  
  ,  StockNotifications/Privacy/PrivacyEraser.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\Privacy;

use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationCancellationSource;
use Automattic\WooCommerce\Internal\StockNotifications\Factory;
use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
use Automattic\WooCommerce\Internal\StockNotifications\NotificationQuery;

/**
 * Privacy eraser for WooCommerce Customer Stock Notifications.
 *
 * This class handles the erasure of customer stock notification data for users
 * who request their personal data to be erased.
 */
class PrivacyEraser extends \WC_Abstract_Privacy {

	/**
	 * Constructor.
	 */
	public function __construct() {
		parent::__construct();

		add_action( 'init', array( $this, 'register_erasers_exporters' ) );
	}

	/**
	 * Register the eraser for stock notifications.
	 */
	public function register_erasers_exporters() {
		$this->add_eraser(
			'woocommerce-customer-stock-notifications',
			__( 'WooCommerce Customer Stock Notifications', 'woocommerce' ),
			array( $this, 'erase_notification_data' )
		);
	}

	/**
	 * Erase customer stock notification data for a given email address.
	 *
	 * This method anonymizes the user email and sets the status of the notifications to 'cancelled'.
	 *
	 * @param string $email_address The email address to erase data for.
	 *
	 * @return array Response containing the status of the operation and messages.
	 */
	public static function erase_notification_data( string $email_address ): array {
		$response = array(
			'items_removed'  => false,
			'items_retained' => false,
			'messages'       => array(),
			'done'           => true,
		);

		$notifications = NotificationQuery::get_notifications(
			array(
				'user_email' => $email_address,
			)
		);

		foreach ( $notifications as $notification_id ) {
			$notification    = Factory::get_notification( $notification_id );
			$anonymous_email = wp_privacy_anonymize_data( 'email', $email_address );
			$notification->set_user_email( $anonymous_email );
			$notification->set_user_id( 0 );
			$notification->set_status( NotificationStatus::CANCELLED );
			$notification->set_cancellation_source( NotificationCancellationSource::USER );
			$notification->set_date_cancelled( current_time( 'mysql' ) );
			$notification->update_meta_data( '_anonymized', 'yes' );
			$notification->update_meta_data( 'email_link_action_key', '' );
			$notification->save();
			$response['messages'][] = sprintf(
			/* translators: %d the numeric product ID */
				__( 'Removed back-in-stock notification for product id: %d', 'woocommerce' ),
				$notification->get_product_id()
			);
			$response['items_removed'] = true;
		}

		return $response;
	}
}
PK     [1]yҳ    ,  StockNotifications/Admin/MenusController.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\Admin;

/**
 * Menus controller for Customer Stock Notifications.
 */
class MenusController {

	/**
	 * Notifications page.
	 *
	 * @var NotificationsPage
	 */
	private $notifications_page;

	/**
	 * Init.
	 *
	 * @internal
	 *
	 * @param NotificationsPage $notifications_page Notifications page.
	 * @return void
	 */
	final public function init( NotificationsPage $notifications_page ): void {
		$this->notifications_page = $notifications_page;
	}

	/**
	 * Constructor.
	 */
	public function __construct() {

		add_action( 'admin_menu', array( $this, 'add_menu' ), 10 );
		add_filter( 'woocommerce_screen_ids', array( $this, 'add_screen_ids' ) );
		add_filter( 'set-screen-option', array( $this, 'set_screen_option' ), 10, 3 );
	}

	/**
	 * Add Stock Notifications menu item.
	 *
	 * @return bool|void
	 */
	public function add_menu() {

		if ( ! current_user_can( 'manage_woocommerce' ) ) {
			return false;
		}

		$dashboard_page = add_submenu_page(
			'woocommerce',
			__( 'Stock Notifications', 'woocommerce' ),
			__( 'Notifications', 'woocommerce' ),
			'manage_woocommerce',
			'wc-customer-stock-notifications',
			array( $this, 'notifications_page' )
		);

		add_action( "load-$dashboard_page", array( $this, 'add_screen_options' ) );
	}

	/**
	 * Add screen options support.
	 *
	 * @return void
	 */
	public function add_screen_options(): void {
		$screen = get_current_screen();

		if ( ! $screen ) {
			return;
		}

		add_screen_option(
			'per_page',
			array(
				'label'   => __( 'Notifications per page', 'woocommerce' ),
				'default' => 10,
				'option'  => 'stock_notifications_per_page',
			)
		);
	}

	/**
	 * Save screen options.
	 *
	 * @param int    $status The status of the screen option.
	 * @param string $option The option name.
	 * @param int    $value The value of the screen option.
	 *
	 * @return int
	 */
	public function set_screen_option( $status, $option, $value ): int {
		if ( 'stock_notifications_per_page' === $option ) {
			return (int) $value;
		}
		return $status;
	}

	/**
	 * Displays the Notifications list table.
	 */
	public function notifications_page() {

		$action = isset( $_GET['notification_action'] ) ? sanitize_text_field( wp_unslash( $_GET['notification_action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended

		if ( ! in_array( $action, array( 'create', 'edit' ), true ) ) {
			$action = '';
		}

		switch ( $action ) {
			case 'create':
				$this->notifications_page->create();
				break;
			case 'edit':
				$this->notifications_page->edit();
				break;
			default:
				$this->notifications_page->output();
				break;
		}
	}

	/**
	 * Add screen id to WooCommerce.
	 *
	 * @param array $screen_ids List of screen IDs.
	 * @return array
	 */
	public static function add_screen_ids( $screen_ids ): array {
		$screen_ids[] = 'woocommerce_page_wc-customer-stock-notifications';
		return $screen_ids;
	}
}
PK     [1]8F
  
  .  StockNotifications/Admin/NotificationsPage.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\Admin;

use Automattic\WooCommerce\Internal\StockNotifications\Admin\ListTable;
use Automattic\WooCommerce\Internal\StockNotifications\Admin\NotificationCreatePage;
use Automattic\WooCommerce\Internal\StockNotifications\Admin\NotificationEditPage;

/**
 * Notifications admin page for Customer Stock Notifications.
 */
class NotificationsPage {

	/**
	 * Page URL.
	 *
	 * @const PAGE_URL
	 */
	const PAGE_URL = 'admin.php?page=wc-customer-stock-notifications';

	/**
	 * Notices option name.
	 */
	const ADMIN_NOTICE_OPTION_NAME = 'wc_customer_stock_notifications_admin_notice';

	/**
	 * Render page.
	 */
	public function output() {
		$table = wc_get_container()->get( ListTable::class );
		$table->process_actions();
		$this->output_admin_notice();
		$table->prepare_items();
		include __DIR__ . '/Templates/html-admin-notifications.php';
	}

	/**
	 * Create notification.
	 */
	public function create() {
		$create_page = new NotificationCreatePage();
		$create_page->output();
		$this->output_admin_notice();
	}

	/**
	 * Edit notification.
	 */
	public function edit() {
		$edit_page = new NotificationEditPage();
		$edit_page->output();
		$this->output_admin_notice();
	}

	/**
	 * Add a notice to the admin notices.
	 *
	 * @param string $message The notice message.
	 * @param string $type The notice type (optional).
	 * @return void
	 */
	public static function add_notice( $message, $type = 'info' ) {
		if ( empty( $message ) ) {
			return;
		}

		$notice_data = get_option( self::ADMIN_NOTICE_OPTION_NAME );
		if ( false !== $notice_data ) {
			return;
		}

		if ( ! in_array( $type, array( 'error', 'warning', 'success', 'info' ), true ) ) {
			$type = 'info';
		}

		$notice_data = array(
			'message' => $message,
			'type'    => $type,
		);

		update_option( self::ADMIN_NOTICE_OPTION_NAME, $notice_data );
	}

	/**
	 * Display admin notices.
	 *
	 * @return void
	 */
	public function output_admin_notice(): void {
		if ( ! function_exists( 'wp_admin_notice' ) ) {
			return;
		}

		$notice_data = get_option( self::ADMIN_NOTICE_OPTION_NAME );
		if ( false === $notice_data ) {
			return;
		}

		// Check if invalid data.
		if ( empty( $notice_data ) || ! is_array( $notice_data ) || empty( $notice_data['message'] ) ) {
			delete_option( self::ADMIN_NOTICE_OPTION_NAME );
			return;
		}

		$type = in_array( $notice_data['type'], array( 'error', 'warning', 'success', 'info' ), true )
			? $notice_data['type']
			: 'info';

		\wp_admin_notice(
			$notice_data['message'],
			array(
				'type'        => $type,
				'id'          => self::ADMIN_NOTICE_OPTION_NAME,
				'dismissible' => false,
			)
		);

		delete_option( self::ADMIN_NOTICE_OPTION_NAME );
	}
}
PK     [1]JQ+N$  N$  /  StockNotifications/Admin/SettingsController.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\Admin;

use Automattic\WooCommerce\Internal\StockNotifications\Config;

/**
 * Settings controller for Customer Stock Notifications.
 */
class SettingsController {

	/**
	 * Constructor.
	 */
	public function __construct() {

		// Add a 'Customer stock notifications' section to Products settings.
		add_filter( 'woocommerce_get_sections_products', array( $this, 'add_customer_stock_notifications_section' ), 100, 1 );

		// Add the Customer Stock Notifications settings.
		add_filter( 'woocommerce_get_settings_products', array( $this, 'add_customer_stock_notifications_settings' ), 100, 2 );

		// Display admin notices about incompatible settings combinations.
		add_action( 'admin_notices', array( $this, 'output_admin_notices' ) );

		// Display and save product-level stock notifications option.
		add_action( 'woocommerce_product_options_stock_status', array( $this, 'add_disable_stock_notifications_checkbox' ), 20 );
		add_action( 'woocommerce_admin_process_product_object', array( $this, 'process_product_object' ) );
	}

	/**
	 * Add a 'Customer stock notifications' section to Products settings.
	 *
	 * @param array $sections Products settings sections.
	 * @return array New Products settings sections.
	 */
	public function add_customer_stock_notifications_section( $sections ) {
		if ( ! is_array( $sections ) ) {
			return $sections;
		}

		$section_title = __( 'Customer stock notifications', 'woocommerce' );

		// Add 'Customer stock notifications' section to the Products tab, after Inventory.
		$inventory_index = array_search( 'inventory', array_keys( $sections ), true );
		if ( false !== $inventory_index ) {
			$sections = array_slice( $sections, 0, $inventory_index + 1, true ) +
				array( 'customer_stock_notifications' => $section_title ) +
				array_slice( $sections, $inventory_index + 1, null, true );
		} else {
			$sections['customer_stock_notifications'] = $section_title;
		}

		return $sections;
	}

	/**
	 * Add the Customer Stock Notifications settings.
	 *
	 * @param array  $settings Original settings.
	 * @param string $section_id Settings section identifier.
	 * @return array New settings.
	 */
	public function add_customer_stock_notifications_settings( $settings, $section_id ) {

		if ( ! is_array( $settings ) ) {
			return $settings;
		}

		if ( 'customer_stock_notifications' !== $section_id ) {
			return $settings;
		}

		/**
		 * Filter the Customer Stock Notifications settings.
		 *
		 * @since 10.2.0
		 *
		 * @param array $default_customer_stock_notifications_settings The default Customer Stock Notifications settings.
		 */
		$stock_notification_settings = apply_filters(
			'woocommerce_customer_stock_notifications_settings',
			array(

				array(
					'title' => __( 'Customer stock notifications', 'woocommerce' ),
					'type'  => 'title',
					'desc'  => '',
					'id'    => 'product_customer_stock_notifications_options',
				),

				array(
					'title'   => __( 'Allow sign-ups', 'woocommerce' ),
					'desc'    => __( 'Let customers sign up to be notified when products in your store are restocked.', 'woocommerce' ),
					'id'      => 'woocommerce_customer_stock_notifications_allow_signups',
					'default' => 'no',
					'type'    => 'checkbox',
				),

				array(
					'title'   => __( 'Require double opt-in to sign up', 'woocommerce' ),
					'desc'    => __( 'To complete the sign-up process, customers must follow a verification link sent to their e-mail after submitting the sign-up form.', 'woocommerce' ),
					'id'      => 'woocommerce_customer_stock_notifications_require_double_opt_in',
					'default' => 'no',
					'type'    => 'checkbox',
				),

				array(
					'title'   => __( 'Delete unverified notification sign-ups after (in days)', 'woocommerce' ),
					'desc'    => __( 'Controls how long the plugin will store unverified notification sign-ups in the database. Enter zero, or leave this field empty if you would like to store expired sign-up requests indefinitey.', 'woocommerce' ),
					'id'      => 'woocommerce_customer_stock_notifications_unverified_deletions_days_threshold',
					'default' => Config::get_unverified_deletion_days_threshold(),
					'type'    => 'number',
				),

				array(
					'title'           => __( 'Guest sign-up', 'woocommerce' ),
					'desc'            => __( 'Customers must be logged in to sign up for stock notifications.', 'woocommerce' ),
					'id'              => 'woocommerce_customer_stock_notifications_require_account',
					'default'         => 'no',
					'type'            => 'checkbox',
					'desc_tip'        => __( 'When enabled, guests will be redirected to a login page to complete the sign-up process.', 'woocommerce' ),
					'checkboxgroup'   => 'start',
					'hide_if_checked' => 'option',
				),

				array(
					'desc'            => __( 'Create an account when guests sign up for stock notifications.', 'woocommerce' ),
					'id'              => 'woocommerce_customer_stock_notifications_create_account_on_signup',
					'default'         => 'no',
					'type'            => 'checkbox',
					'checkboxgroup'   => 'end',
					'hide_if_checked' => 'yes',
					'autoload'        => true,
				),

				array(
					'type' => 'sectionend',
					'id'   => 'product_customer_stock_notifications_options',
				),
			)
		);

		$settings = array_merge( $settings, $stock_notification_settings );

		return $settings;
	}

	/**
	 * Display admin notices about incompatible settings combinations.
	 *
	 * @return void
	 */
	public function output_admin_notices() {
		// Only show notices on the Customer Stock Notifications settings page.
		$screen = get_current_screen();
		if ( ! $screen || 'woocommerce_page_wc-settings' !== $screen->id || ! isset( $_GET['section'] ) || 'customer_stock_notifications' !== $_GET['section'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			return;
		}

		if ( 'no' === get_option( 'woocommerce_registration_generate_password', 'no' ) && 'yes' === get_option( 'woocommerce_customer_stock_notifications_create_account_on_signup', 'no' ) ) {
			wp_admin_notice(
				sprintf(
					/* translators: %s settings page link */
					__( 'WooCommerce is currently <a href="%s">configured</a> to create new accounts without generating passwords automatically. Guests who sign up to receive stock notifications will need to reset their password before they can log into their new account.', 'woocommerce' ),
					esc_url( admin_url( 'admin.php?page=wc-settings&tab=account' ) )
				),
				array(
					'id'          => 'message',
					'type'        => 'warning',
					'dismissible' => false,
				)
			);
		}

		if ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) && Config::allows_signups() ) {
			wp_admin_notice(
				sprintf(
					/* translators: %s settings page link */
					__( 'WooCommerce is currently <a href="%s">configured</a> to hide out-of-stock products from your catalog. Customers will not be able sign up for back-in-stock notifications while this option is enabled.', 'woocommerce' ),
					esc_url( admin_url( 'admin.php?page=wc-settings&tab=products&section=inventory' ) )
				),
				array(
					'id'          => 'message',
					'type'        => 'warning',
					'dismissible' => false,
				)
			);
		}
	}

	/**
	 * Setting to allow admins disabling bis on product level.
	 *
	 * @return void
	 */
	public function add_disable_stock_notifications_checkbox() {

		if ( ! Config::allows_signups() ) {
			return;
		}

		global $product_object;
		if ( ! is_a( $product_object, 'WC_Product' ) ) {
			return;
		}

		$enable_signups = 'no' !== $product_object->get_meta( Config::get_product_signups_meta_key() ) ? 'yes' : 'no';

		wp_nonce_field( 'woocommerce-customer-stock-notifications-edit-product', 'customer_stock_notifications_edit_product_security' );
		woocommerce_wp_checkbox(
			array(
				'id'            => Config::get_product_signups_meta_key(),
				'label'         => __( 'Stock notifications', 'woocommerce' ),
				'value'         => $enable_signups,
				'wrapper_class' => implode(
					' ',
					array_map(
						function ( $type ) {
							return 'show_if_' . $type;
						},
						Config::get_supported_product_types()
					)
				),
				'description'   => __( 'Let customers sign up to be notified when this product is restocked', 'woocommerce' ),
			)
		);
	}

	/**
	 * Save product settings meta.
	 *
	 * @param  WC_Product $product The product object.
	 * @return void
	 */
	public static function process_product_object( $product ) {

		if ( ! Config::allows_signups() ) {
			return;
		}

		if ( ! is_a( $product, 'WC_Product' ) ) {
			return;
		}

		if ( ! $product->is_type( Config::get_supported_product_types() ) ) {
			return;
		}

		$posted_is_enabled = isset( $_POST[ Config::get_product_signups_meta_key() ] );
		$current_value     = $product->get_meta( Config::get_product_signups_meta_key() );
		if ( ( $posted_is_enabled && 'no' === $current_value ) || ( ! $posted_is_enabled && 'yes' === $current_value ) ) {
			check_admin_referer( 'woocommerce-customer-stock-notifications-edit-product', 'customer_stock_notifications_edit_product_security' );

			$product->update_meta_data( Config::get_product_signups_meta_key(), $posted_is_enabled ? 'yes' : 'no' );
		}
	}
}
PK     [1]5b    1  StockNotifications/Admin/NotificationEditPage.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\Admin;

use Automattic\WooCommerce\Internal\StockNotifications\Notification;
use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
use Automattic\WooCommerce\Internal\StockNotifications\Admin\NotificationsPage;
use Automattic\WooCommerce\Internal\StockNotifications\Factory;
use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailManager;
use Automattic\WooCommerce\Internal\StockNotifications\Admin\ListTable;
use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationCancellationSource;

/**
 * Notification create page for Customer Stock Notifications.
 */
class NotificationEditPage {

	/**
	 * Render page.
	 */
	public function output() {
		$table           = new ListTable();
		$notification_id = isset( $_GET['notification_id'] ) ? absint( wp_unslash( $_GET['notification_id'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( $notification_id ) {
			$notification = Factory::get_notification( $notification_id );
		}

		if ( ! $notification instanceof Notification ) {
			$notice_message = __( 'Notification not found.', 'woocommerce' );
			NotificationsPage::add_notice( $notice_message, 'error' );
			wp_safe_redirect( admin_url( NotificationsPage::PAGE_URL ) );
			exit;
		}

		$this->process_edit_form( $notification );
		$table->process_delete_action();

		$signed_up_customers = $table->data_store->query(
			array(
				'product_id' => $notification->get_product_id(),
				'return'     => 'count',
			)
		);

		include __DIR__ . '/Templates/html-admin-notification-edit.php';
	}

	/**
	 * Update notification.
	 *
	 * @param Notification $notification The notification object.
	 * @return void
	 */
	public function process_edit_form( Notification $notification ) {

		if ( empty( $_POST ) || empty( $_POST['wc_customer_stock_notification_action'] ) ) {
			return;
		}

		check_admin_referer( 'woocommerce-customer-stock-notification-edit', 'customer_stock_notification_edit_security' );

		$action = wc_clean( wp_unslash( $_POST['wc_customer_stock_notification_action'] ) );
		switch ( $action ) {
			case 'activate_notification':
				$notification->set_status( NotificationStatus::ACTIVE );
				$result = $notification->save();
				if ( is_wp_error( $result ) ) {
					$notice_message = $result->get_error_message();
					NotificationsPage::add_notice( $notice_message, 'error' );
				} else {
					$notice_message = __( 'Notification updated.', 'woocommerce' );
					NotificationsPage::add_notice( $notice_message, 'success' );
				}
				break;
			case 'cancel_notification':
				$notification->set_status( NotificationStatus::CANCELLED );
				$notification->set_date_cancelled( time() );
				$notification->set_date_notified( NotificationCancellationSource::ADMIN );
				$result = $notification->save();
				if ( is_wp_error( $result ) ) {
					$notice_message = $result->get_error_message();
					NotificationsPage::add_notice( $notice_message, 'error' );
				} else {
					$notice_message = __( 'Notification updated.', 'woocommerce' );
					NotificationsPage::add_notice( $notice_message, 'success' );
				}
				break;
			case 'send_notification':
				$product = $notification->get_product();

				if ( ! $product || ! $product->is_in_stock() ) {
					$notice_message = __( 'Failed to send notification. Please make sure that the listed product is available.', 'woocommerce' );
					NotificationsPage::add_notice( $notice_message, 'error' );
				} else {
					$email_manager = new EmailManager();
					$email_manager->send_stock_notification_email( $notification );
					$notification->set_status( NotificationStatus::SENT );
					$notification->set_date_notified( time() );
					$notification->save();
					// translators: %s user email.
					$notice_message = sprintf( __( 'Notification sent to "%s".', 'woocommerce' ), $notification->get_user_email() );
					NotificationsPage::add_notice( $notice_message, 'success' );
				}
				break;
			case 'send_verification_email':
				// translators: %s user email.
				$notice_message = sprintf( __( 'Verification email sent to "%s".', 'woocommerce' ), $notification->get_user_email() );
				NotificationsPage::add_notice( $notice_message, 'success' );
				break;
		}

		// Construct edit url.
		$edit_url = add_query_arg(
			array(
				'notification_action' => 'edit',
				'notification_id'     => $notification->get_id(),
			),
			NotificationsPage::PAGE_URL
		);

		wp_safe_redirect( $edit_url );
		exit;
	}
}
PK     [1]1&MM  M  )  StockNotifications/Admin/AdminManager.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\Admin;

use Automattic\WooCommerce\Internal\StockNotifications\Admin\MenusController;
use Automattic\WooCommerce\Internal\StockNotifications\Admin\SettingsController;
use Automattic\Jetpack\Constants;

/**
 * Admin controller for Customer Stock Notifications.
 */
class AdminManager {

	/**
	 * Initialize admin components.
	 *
	 * @internal
	 *
	 * @return void
	 */
	final public function __construct() {

		// Enqueue scripts.
		add_action( 'admin_enqueue_scripts', array( $this, 'admin_resources' ), 11 );

		$container = wc_get_container();
		$container->get( MenusController::class );
		$container->get( SettingsController::class );
	}

	/**
	 * Admin scripts.
	 *
	 * @return void
	 */
	public static function admin_resources() {

		$screen    = get_current_screen();
		$screen_id = $screen ? $screen->id : '';
		$suffix    = Constants::is_true( 'SCRIPT_DEBUG' ) ? '' : '.min';
		$version   = Constants::get_constant( 'WC_VERSION' );

		wp_register_script( 'wc-admin-customer-stock-notifications', WC()->plugin_url() . '/assets/js/admin/wc-customer-stock-notifications' . $suffix . '.js', array( 'jquery' ), $version, true );

		$params = array(
			'i18n_wc_delete_notification_warning'       => __( 'Delete this notification permanently?', 'woocommerce' ),
			'i18n_wc_bulk_delete_notifications_warning' => __( 'Delete the selected notifications permanently?', 'woocommerce' ),
		);

		/*
		 * Enqueue specific styles & scripts.
		 */
		if (
			! in_array(
				$screen_id,
				array( 'woocommerce_page_wc-customer-stock-notifications', 'woocommerce_page_wc-settings' ),
				true
			)
		) {
			return;
		}
		//phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( 'woocommerce_page_wc-settings' === $screen_id && isset( $_GET['section'] ) && 'customer_stock_notifications' !== $_GET['section'] ) {
			return;
		}

		wp_enqueue_script( 'wc-admin-customer-stock-notifications' );
		wp_localize_script( 'wc-admin-customer-stock-notifications', 'wc_admin_customer_stock_notifications_params', $params );
	}
}
PK     [1]ޣSZ  Z  3  StockNotifications/Admin/NotificationCreatePage.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\Admin;

use Automattic\WooCommerce\Internal\StockNotifications\Notification;
use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
use Automattic\WooCommerce\Internal\StockNotifications\Admin\NotificationsPage;

/**
 * Notification create page for Customer Stock Notifications.
 */
class NotificationCreatePage {

	/**
	 * Render page.
	 */
	public function output() {
		$this->process_create_form();
		include __DIR__ . '/Templates/html-admin-notification-create.php';
	}

	/**
	 * Create and save notification.
	 */
	public function process_create_form() {
		if ( empty( $_POST ) ) {
			return;
		}

		check_admin_referer( 'woocommerce-customer-stock-notification-create', 'customer_stock_notification_create_security' );

		if ( ! isset( $_POST['save'] ) ) {
			return;
		}

		if ( ! isset( $_POST['product_id'] ) || empty( $_POST['product_id'] ) ) {
			NotificationsPage::add_notice( __( 'Please select a product.', 'woocommerce' ), 'error' );
			return;
		}

		if ( empty( $_POST['user_id'] ) && empty( $_POST['user_email'] ) ) {
			NotificationsPage::add_notice( __( 'Please select a customer.', 'woocommerce' ), 'error' );
			return;
		}

		// Posted data.
		$posted_data               = array();
		$posted_data['product_id'] = absint( wp_unslash( $_POST['product_id'] ) );

		if ( isset( $_POST['user_id'] ) && ! empty( $_POST['user_id'] ) ) {

			$posted_data['user_id'] = absint( wp_unslash( $_POST['user_id'] ) );
			if ( 0 === $posted_data['user_id'] ) {
				NotificationsPage::add_notice( __( 'Please select a customer.', 'woocommerce' ), 'error' );
				return;
			}

			$user                      = get_user_by( 'id', $posted_data['user_id'] );
			$posted_data['user_email'] = is_a( $user, 'WP_User' ) ? $user->user_email : '';

		} elseif ( isset( $_POST['user_email'] ) && ! empty( $_POST['user_email'] ) ) {

			$posted_data['user_email'] = sanitize_text_field( wp_unslash( $_POST['user_email'] ) );
			if ( ! filter_var( $posted_data['user_email'], FILTER_VALIDATE_EMAIL ) ) {
				NotificationsPage::add_notice( __( 'Please enter a valid email address.', 'woocommerce' ), 'error' );
				return;
			}

			$user                   = get_user_by( 'email', $posted_data['user_email'] );
			$posted_data['user_id'] = is_a( $user, 'WP_User' ) ? $user->ID : 0;
		}

		// Check if a notification already exists for the same product and customer.
		$notification_ids = \WC_Data_Store::load( 'stock_notification' )->query( $posted_data );
		if ( count( $notification_ids ) > 0 ) {
			$notice_message = sprintf(
				// translators: %s: notification edit url.
				__(
					'A <a href="%s">notification</a> for the same product and customer already exists in your database.',
					'woocommerce'
				),
				admin_url( NotificationsPage::PAGE_URL . '&notification_action=edit&notification_id=' . $notification_ids[0] )
			);
			NotificationsPage::add_notice( $notice_message, 'error' );
			return;
		}

		// Save notification.
		$notification = new Notification();
		$notification->set_status( NotificationStatus::ACTIVE );
		$notification->set_product_id( $posted_data['product_id'] );
		$notification->set_user_id( $posted_data['user_id'] );
		$notification->set_user_email( $posted_data['user_email'] );
		$result = $notification->save();

		if ( is_wp_error( $result ) ) {
			$notice_message = $result->get_error_message();
			NotificationsPage::add_notice( $notice_message, 'error' );
			return;
		} else {

			$notice_message = __( 'Notification created.', 'woocommerce' );
			NotificationsPage::add_notice( $notice_message, 'success' );

			// Construct edit url.
			$edit_url = add_query_arg(
				array(
					'notification_action' => 'edit',
					'notification_id'     => $notification->get_id(),
				),
				NotificationsPage::PAGE_URL
			);

			wp_safe_redirect( $edit_url );
			exit;
		}
	}
}
PK     [1]9Ҍg  g  &  StockNotifications/Admin/ListTable.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\Admin;

use Automattic\WooCommerce\Internal\DataStores\StockNotifications\StockNotificationsDataStore;
use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
use Automattic\WooCommerce\Internal\StockNotifications\Notification;
use Automattic\WooCommerce\Internal\StockNotifications\Factory;
use Automattic\WooCommerce\Internal\StockNotifications\Admin\NotificationsPage;
use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EligibilityService;

/**
 * Notifications list table for Customer Stock Notifications.
 */
class ListTable extends \WP_List_Table {

	/**
	 * Total view records.
	 *
	 * @var int
	 */
	public $total_items = 0;

	/**
	 * Total active records.
	 *
	 * @var int
	 */
	public $total_active_items = 0;

	/**
	 * Total pending records.
	 *
	 * @var int
	 */
	public $total_pending_items = 0;

	/**
	 * Total cancelled records.
	 *
	 * @var int
	 */
	public $total_cancelled_items = 0;

	/**
	 * Total sent records.
	 *
	 * @var int
	 */
	public $total_sent_items = 0;

	/**
	 * Has stock notifications.
	 *
	 * @var bool
	 */
	public $has_stock_notifications = false;

	/**
	 * Data store.
	 *
	 * @var StockNotificationsDataStore
	 */
	public $data_store;

	/**
	 * Eligibility service.
	 *
	 * @var EligibilityService
	 */
	public $eligibility_service;

	/**
	 * Init.
	 *
	 * @internal
	 *
	 * @param EligibilityService $eligibility_service Eligibility service.
	 */
	final public function init( EligibilityService $eligibility_service ) {
		$this->eligibility_service = $eligibility_service;
	}

	/**
	 * Constructor.
	 *
	 * @return void
	 */
	public function __construct() {

		$this->data_store              = \WC_Data_Store::load( 'stock_notification' );
		$this->has_stock_notifications = $this->data_store->query( array( 'return' => 'count' ) ) > 0;

		parent::__construct(
			array(
				'singular' => 'woocommerce_stock_notification',
				'plural'   => 'woocommerce_stock_notifications',
			)
		);
	}

	/**
	 * Handles the checkbox column output.
	 *
	 * @param Notification $notification The notification object.
	 * @return void
	 */
	public function column_cb( $notification ) {
		?><label class="screen-reader-text" for="cb-select-<?php echo absint( $notification->get_id() ); ?>">
		<?php
			/* translators: %s: Notification code */
			printf( esc_html__( 'Select %s', 'woocommerce' ), esc_html( $notification->get_id() ) );
		?>
		</label>
		<input id="cb-select-<?php echo absint( $notification->get_id() ); ?>" type="checkbox" name="notification[]" value="<?php echo absint( $notification->get_id() ); ?>" />
		<?php
	}

	/**
	 * Handles the title column output.
	 *
	 * @param Notification $notification The notification object.
	 * @return void
	 */
	public function column_id( $notification ) {
		$actions = array(
			'edit'   => sprintf( '<a href="' . admin_url( NotificationsPage::PAGE_URL . '&notification_action=edit&notification_id=%d' ) . '">%s</a>', $notification->get_id(), __( 'Edit', 'woocommerce' ) ),
			'delete' => sprintf( '<a href="' . wp_nonce_url( admin_url( NotificationsPage::PAGE_URL . '&notification_action=delete&notification_id=%d' ), 'delete_customer_stock_notification' ) . '">%s</a>', $notification->get_id(), __( 'Delete', 'woocommerce' ) ),
		);

		$title = $notification->get_id();

		printf(
			'<a class="row-title" href="%s" aria-label="%s">#%s</a>%s',
			esc_url( admin_url( NotificationsPage::PAGE_URL . '&notification_action=edit&notification_id=' . $notification->get_id() ) ),
			/* translators: %s: Notification code */
			sprintf( esc_attr__( '&#8220;%s&#8221; (Edit)', 'woocommerce' ), esc_attr( $title ) ),
			esc_html( $title ),
			wp_kses_post( $this->row_actions( $actions ) )
		);
	}

	/**
	 * Handles the status column output.
	 *
	 * @param Notification $notification The notification object.
	 * @return void
	 */
	public function column_status( $notification ) {

		if ( $notification->get_status() === NotificationStatus::PENDING ) {
			$status = 'cancelled';
			$label  = _x( 'Pending', 'stock notification status', 'woocommerce' );
		} elseif ( $notification->get_status() === NotificationStatus::CANCELLED ) {
			$status = 'cancelled';
			$label  = _x( 'Cancelled', 'stock notification status', 'woocommerce' );
		} elseif ( $notification->get_status() === NotificationStatus::SENT ) {
			$status = 'cancelled';
			$label  = _x( 'Sent', 'stock notification status', 'woocommerce' );
		} else {
			$status = 'completed';
			$label  = _x( 'Active', 'stock notification status', 'woocommerce' );
		}

		printf( '<mark class="order-status %s"><span>%s</span></mark>', esc_attr( sanitize_html_class( 'status-' . $status ) ), esc_html( $label ) );
	}

	/**
	 * Handles the redeemed user column output.
	 *
	 * @param Notification $notification The notification object.
	 * @return void
	 */
	public function column_user( $notification ) {
		if ( $notification->get_user_id() ) {
			$user = get_user_by( 'id', $notification->get_user_id() );
		}

		if ( isset( $user ) && $user ) {
			printf( '<a href="%s" target="_blank">%s</a>', esc_url( get_edit_user_link( $user->ID ) ), esc_html( $user->display_name ) );
		} else {
			echo esc_html( $notification->get_user_email() );
		}
	}

	/**
	 * Handles the product column output.
	 *
	 * @param Notification $notification The notification object.
	 * @return void
	 */
	public function column_product( $notification ) {
		$product = $notification->get_product();

		if ( ! is_a( $product, 'WC_Product' ) ) {
			echo '&mdash;';
			return;
		}

		$name                     = $product->get_name();
		$formatted_variation_list = $this->get_product_formatted_variation_list( true );

		if ( $formatted_variation_list ) {
			/* translators: product name, identifier */
			$name .= '<span class="description">' . $formatted_variation_list . '</span>';
		}

		echo wp_kses_post(
			sprintf(
				'<a target="_blank" href="' . admin_url( 'post.php?post=%d&action=edit' ) . '">%s</a>',
				$product->get_parent_id() ? absint( $product->get_parent_id() ) : absint( $product->get_id() ),
				$name
			)
		);
	}

	/**
	 * Handles the product SKU output.
	 *
	 * @param Notification $notification The notification object.
	 * @return void
	 */
	public function column_sku( $notification ) {
		$product = $notification->get_product();
		$sku     = false;

		if ( is_a( $product, 'WC_Product' ) ) {
			$sku = $product->get_sku();
		}

		if ( $sku ) {
			echo wp_kses_post( $sku );
		} else {
			echo '&mdash;';
		}
	}

	/**
	 * Handles the notification date column output.
	 *
	 * @param Notification $notification The notification object.
	 * @return void
	 */
	public function column_date_created_gmt( $notification ) {
		$date_created = $notification->get_date_created();

		if ( ! $date_created ) {
			$t_time = __( '&mdash;', 'woocommerce' );
			$h_time = $t_time;
		} else {
			$date_created = $date_created->getTimestamp();
			$t_time       = date_i18n( _x( 'Y/m/d g:i:s a', 'list table date hover format', 'woocommerce' ), $date_created );
			$h_time       = date_i18n( wc_date_format(), $date_created );
		}

		echo '<span title="' . esc_attr( $t_time ) . '">' . esc_html( $h_time ) . '</span>';
	}

	/**
	 * Message to be displayed when there are no items.
	 *
	 * @return void
	 */
	public function no_items() {
		?>
		<p class="main">
			<?php esc_html_e( 'No Notifications found', 'woocommerce' ); ?>
		</p>
		<?php
	}

	/**
	 * Get a list of columns. The format is:
	 * 'internal-name' => 'Title'
	 */
	public function get_columns() {

		$columns                     = array();
		$columns['cb']               = '<input type="checkbox" />';
		$columns['id']               = _x( 'Notification', 'column_name', 'woocommerce' );
		$columns['status']           = _x( 'Status', 'column_name', 'woocommerce' );
		$columns['user']             = _x( 'User/Email', 'column_name', 'woocommerce' );
		$columns['product']          = _x( 'Product', 'column_name', 'woocommerce' );
		$columns['sku']              = _x( 'SKU', 'column_name', 'woocommerce' );
		$columns['date_created_gmt'] = _x( 'Signed Up', 'column_name', 'woocommerce' );

		return $columns;
	}

	/**
	 * Return sortable columns.
	 *
	 * @return array
	 */
	public function get_sortable_columns() {
		$sortable_columns = array(
			'id'      => array( 'id', true ),
			'product' => array( 'product_id', true ),
		);

		return $sortable_columns;
	}

	/**
	 * Returns bulk actions.
	 *
	 * @return array
	 */
	protected function get_bulk_actions() {
		$actions           = array();
		$actions['enable'] = __( 'Activate', 'woocommerce' );
		$actions['cancel'] = __( 'Cancel', 'woocommerce' );
		$actions['delete'] = __( 'Delete permanently', 'woocommerce' );
		return $actions;
	}

	/**
	 * Query the DB and attach items.
	 *
	 * @return void
	 */
	public function prepare_items() {
		$per_page = (int) get_user_meta( get_current_user_id(), 'stock_notifications_per_page', true );
		$per_page = $per_page > 0 ? $per_page : 10;

		// Table columns.
		$columns               = $this->get_columns();
		$hidden                = array();
		$sortable              = $this->get_sortable_columns();
		$this->_column_headers = array( $columns, $hidden, $sortable );

		// Setup params.
		$paged   = isset( $_REQUEST['paged'] ) ? max( 0, (int) wp_unslash( $_REQUEST['paged'] ) - 1 ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$orderby = ( isset( $_REQUEST['orderby'] ) && in_array( wp_unslash( $_REQUEST['orderby'] ), array_keys( $this->get_sortable_columns() ), true ) ) ? wc_clean( wp_unslash( $_REQUEST['orderby'] ) ) : 'id'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$order   = ( isset( $_REQUEST['order'] ) && in_array( wp_unslash( $_REQUEST['order'] ), array( 'asc', 'desc' ), true ) ) ? wc_clean( wp_unslash( $_REQUEST['order'] ) ) : 'desc'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended

		// Query args.
		$query_args = array(
			'order_by' => array( $orderby => $order ),
			'limit'    => $per_page,
			'offset'   => $paged * $per_page,
		);

		// Search.
		if ( isset( $_REQUEST['s'] ) && ! empty( $_REQUEST['s'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$query_args['user_email'] = wc_clean( wp_unslash( $_REQUEST['s'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		}

		// Views.
		if ( ! empty( $_REQUEST['status'] ) && 'active_customer_stock_notifications' === $_REQUEST['status'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$query_args['status'] = NotificationStatus::ACTIVE;
		} elseif ( ! empty( $_REQUEST['status'] ) && 'sent_customer_stock_notifications' === $_REQUEST['status'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$query_args['status'] = NotificationStatus::SENT;
		} elseif ( ! empty( $_REQUEST['status'] ) && 'cancelled_customer_stock_notifications' === $_REQUEST['status'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$query_args['status'] = NotificationStatus::CANCELLED;
		} elseif ( ! empty( $_REQUEST['status'] ) && 'pending_customer_stock_notifications' === $_REQUEST['status'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$query_args['status'] = NotificationStatus::PENDING;
		}

		// Filters.
		if ( ! empty( $_GET['m'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$filter = absint( wp_unslash( $_GET['m'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$month  = substr( (string) $filter, 4, 6 );
			$year   = substr( (string) $filter, 0, 4 ); // This will break at year 10.000 AC :).

			$start_timestamp          = mktime( 0, 0, 0, (int) $month, 1, (int) $year );
			$query_args['start_date'] = gmdate( 'Y-m-d H:i:s', $start_timestamp );

			$end_timestamp          = mktime( 0, 0, 0, (int) $month + 1, 1, (int) $year );
			$query_args['end_date'] = gmdate( 'Y-m-d H:i:s', $end_timestamp );
		}

		if ( ! empty( $_GET['customer_stock_notifications_product_filter'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$filter  = absint( wp_unslash( $_GET['customer_stock_notifications_product_filter'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$product = wc_get_product( $filter );
			if ( $product instanceof \WC_Product ) {
				$target_ids               = $this->eligibility_service->get_target_product_ids( $product );
				$query_args['product_id'] = $target_ids;
			} else {
				NotificationsPage::add_notice( __( 'Invalid product selected.', 'woocommerce' ), 'error' );
			}
		}

		if ( ! empty( $_GET['customer_stock_notifications_customer_filter'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$filter                = absint( wp_unslash( $_GET['customer_stock_notifications_customer_filter'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$query_args['user_id'] = array( $filter );
		}

		$query_args['return'] = 'objects';
		$this->items          = $this->data_store->query( $query_args );

		// Count total items.
		$query_args['return'] = 'count';
		unset( $query_args['limit'] );
		unset( $query_args['offset'] );
		$this->total_items = $this->data_store->query( $query_args );

		// Count active.
		$query_args['status']     = NotificationStatus::ACTIVE;
		$this->total_active_items = $this->data_store->query( $query_args );

		// Count sent.
		$query_args['status']   = NotificationStatus::SENT;
		$this->total_sent_items = $this->data_store->query( $query_args );

		// Count cancelled.
		$query_args['status']        = NotificationStatus::CANCELLED;
		$this->total_cancelled_items = $this->data_store->query( $query_args );

		// Count pending.
		$query_args['status']      = NotificationStatus::PENDING;
		$this->total_pending_items = $this->data_store->query( $query_args );

		// Configure pagination.
		$this->set_pagination_args(
			array(
				'total_items' => $this->total_items, // Total items defined above.
				'per_page'    => $per_page, // Per page constant defined at top of method.
				'total_pages' => ceil( $this->total_items / $per_page ), // Calculate pages count.
			)
		);
	}

	/**
	 * Display table extra nav.
	 *
	 * @param string $which top|bottom.
	 * @return void
	 */
	public function extra_tablenav( $which ) {
		if ( 'top' === $which && ! is_singular() ) {
			?>
			<div class="alignleft actions">
				<?php
				$this->render_filters();
				submit_button( __( 'Filter', 'woocommerce' ), '', 'filter_action', false, array( 'id' => 'post-query-submit' ) );
				?>
			</div>
			<?php
		}
	}

	/**
	 * Display table filters.
	 *
	 * @return void
	 */
	protected function render_filters() {
		$this->display_months_dropdown();
		$this->display_customer_dropdown();
		$this->display_product_dropdown();
	}

	/**
	 * Display product filter.
	 *
	 * @return void
	 */
	protected function display_product_dropdown() {
		$product_string = '';
		$product_id     = '';

		if ( ! empty( $_GET['customer_stock_notifications_product_filter'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$product_id = wc_clean( wp_unslash( $_GET['customer_stock_notifications_product_filter'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$product    = wc_get_product( absint( $product_id ) );

			if ( $product ) {
				$product_string = sprintf(
					/* translators: 1: product title 2: product ID */
					esc_html__( '%1$s (#%2$s)', 'woocommerce' ),
					$product->get_parent_id() ? $product->get_name() : $product->get_title(),
					absint( $product->get_id() )
				);
			}
		}
		?>
		<select class="wc-product-search" name="customer_stock_notifications_product_filter" data-placeholder="<?php esc_attr_e( 'Select product&hellip;', 'woocommerce' ); ?>" data-allow_clear="true" id="customer_stock_notifications_product_filter">
			<?php if ( $product_string && $product_id ) { ?>
				<option value="<?php echo esc_attr( $product_id ); ?>" selected="selected"><?php echo wp_kses_post( htmlspecialchars( $product_string, ENT_COMPAT ) ); ?></option>
			<?php } ?>
		</select>
		<?php
	}

	/**
	 * Display customer filter.
	 *
	 * @return void
	 */
	protected function display_customer_dropdown() {
		$user_string = '';
		$user_id     = '';

		if ( ! empty( $_GET['customer_stock_notifications_customer_filter'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$user_id = wc_clean( wp_unslash( $_GET['customer_stock_notifications_customer_filter'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$user    = get_user_by( 'id', absint( $user_id ) );

			if ( $user ) {
				$user_string = sprintf(
					/* translators: 1: user display name 2: user ID 3: user email */
					esc_html__( '%1$s (#%2$s &ndash; %3$s)', 'woocommerce' ),
					$user->display_name,
					absint( $user->ID ),
					$user->user_email
				);
			}
		}
		?>
		<select class="wc-customer-search" name="customer_stock_notifications_customer_filter" data-placeholder="<?php esc_attr_e( 'Select customer&hellip;', 'woocommerce' ); ?>" data-allow_clear="true" id="customer_stock_notifications_customer_filter">
			<?php if ( $user_string && $user_id ) { ?>
				<option value="<?php echo esc_attr( $user_id ); ?>" selected="selected"><?php echo wp_kses_post( htmlspecialchars( $user_string, ENT_COMPAT ) ); ?></option>
			<?php } ?>
		</select>
		<?php
	}

	/**
	 * Items of the `subsubsub` status menu.
	 *
	 * @return array
	 */
	protected function get_views() {
		$status_links = array();

		// All view.
		$class          = ! empty( $_REQUEST['status'] ) && 'all_customer_stock_notifications' === $_REQUEST['status'] ? 'current' : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$all_inner_html = sprintf(
			/* translators: %s: Notifications count */
			_nx(
				'All <span class="count">(%s)</span>',
				'All <span class="count">(%s)</span>',
				$this->total_items,
				'notifications_status',
				'woocommerce'
			),
			number_format_i18n( $this->total_items )
		);

		$status_links['all'] = $this->get_link( array( 'status' => 'all_customer_stock_notifications' ), $all_inner_html, $class );

		// Active view.
		$class             = ! empty( $_REQUEST['status'] ) && 'active_customer_stock_notifications' === $_REQUEST['status'] ? 'current' : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$active_inner_html = sprintf(
			/* translators: %s: Notifications count */
			_nx(
				'Active <span class="count">(%s)</span>',
				'Active <span class="count">(%s)</span>',
				$this->total_active_items,
				'notifications_status',
				'woocommerce'
			),
			number_format_i18n( $this->total_active_items )
		);

		$status_links['active'] = $this->get_link( array( 'status' => 'active_customer_stock_notifications' ), $active_inner_html, $class );

		// Sent view.
		$class           = ! empty( $_REQUEST['status'] ) && 'sent_customer_stock_notifications' === $_REQUEST['status'] ? 'current' : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$sent_inner_html = sprintf(
			/* translators: %s: Notifications count */
			_nx(
				'Sent <span class="count">(%s)</span>',
				'Sent <span class="count">(%s)</span>',
				$this->total_sent_items,
				'notifications_status',
				'woocommerce'
			),
			number_format_i18n( $this->total_sent_items )
		);

		$status_links['sent'] = $this->get_link( array( 'status' => 'sent_customer_stock_notifications' ), $sent_inner_html, $class );

		// Cancelled view.
		$class                = ! empty( $_REQUEST['status'] ) && 'cancelled_customer_stock_notifications' === $_REQUEST['status'] ? 'current' : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$cancelled_inner_html = sprintf(
			/* translators: %s: Notifications count */
			_nx(
				'Cancelled <span class="count">(%s)</span>',
				'Cancelled <span class="count">(%s)</span>',
				$this->total_cancelled_items,
				'notifications_status',
				'woocommerce'
			),
			number_format_i18n( $this->total_cancelled_items )
		);

		$status_links['cancelled'] = $this->get_link( array( 'status' => 'cancelled_customer_stock_notifications' ), $cancelled_inner_html, $class );

		// Pending view.
		$class              = ! empty( $_REQUEST['status'] ) && 'pending_customer_stock_notifications' === $_REQUEST['status'] ? 'current' : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$pending_inner_html = sprintf(
			/* translators: %s: Notifications count */
			_nx(
				'Pending <span class="count">(%s)</span>',
				'Pending <span class="count">(%s)</span>',
				$this->total_pending_items,
				'notifications_status',
				'woocommerce'
			),
			number_format_i18n( $this->total_pending_items )
		);

		$status_links['pending'] = $this->get_link( array( 'status' => 'pending_customer_stock_notifications' ), $pending_inner_html, $class );

		return $status_links;
	}

	/**
	 * Construct a link string from args.
	 *
	 * @param array  $args Arguments for the link.
	 * @param string $label Link label.
	 * @param string $css_class CSS class.
	 * @return string
	 */
	protected function get_link( $args, $label, $css_class = '' ) {
		$url = add_query_arg( $args );

		$class_html   = '';
		$aria_current = '';
		if ( ! empty( $css_class ) ) {
			$class_html = sprintf(
				' class="%s"',
				esc_attr( $css_class )
			);

			if ( 'current' === $css_class ) {
				$aria_current = ' aria-current="page"';
			}
		}

		return sprintf(
			'<a href="%s"%s%s>%s</a>',
			esc_url( $url ),
			$class_html,
			$aria_current,
			$label
		);
	}

	/**
	 * Display dates dropdown filter.
	 *
	 * @return void
	 */
	protected function display_months_dropdown() {
		global $wp_locale;

		$months = $this->data_store->get_distinct_dates();

		if ( ! is_array( $months ) ) {
			return;
		}

		$month_count = count( $months );

		if ( $month_count < 1 ) {
			return;
		}

		$m = isset( $_GET['m'] ) ? (int) wp_unslash( $_GET['m'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		?>
		<label for="filter-by-date" class="screen-reader-text"><?php esc_html_e( 'Filter by date', 'woocommerce' ); ?></label>
		<select name="m" id="filter-by-date">
			<option<?php selected( $m, 0 ); ?> value="0"><?php esc_html_e( 'All dates', 'woocommerce' ); ?></option>
			<?php
			foreach ( $months as $arc_row ) {
				if ( 0 === (int) $arc_row->year || 0 === (int) $arc_row->month ) {
					continue;
				}

				$month = zeroise( $arc_row->month, 2 );
				$year  = $arc_row->year;

				printf(
					"<option %s value='%s'>%s</option>\n",
					selected( $m, $year . $month, false ),
					esc_attr( $arc_row->year . $month ),
					/* translators: %1$s: month %2$s: year */
					sprintf( esc_html__( '%1$s %2$d', 'woocommerce' ), esc_html( $wp_locale->get_month( $month ) ), esc_html( $year ) )
				);
			}
			?>
		</select>
		<?php
	}

	/**
	 * Process actions.
	 */
	public function process_actions(): void {
		$this->process_delete_action();
		$this->process_bulk_action();
	}

	/**
	 * Process delete action.
	 *
	 * @return void
	 */
	public function process_delete_action(): void {

		$action = isset( $_GET['notification_action'] ) ? wc_clean( wp_unslash( $_GET['notification_action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended

		if ( 'delete' !== $action ) {
			return;
		}

		$notification_id = isset( $_GET['notification_id'] ) ? absint( $_GET['notification_id'] ) : 0;

		if ( ! $notification_id ) {
			return;
		}

		check_admin_referer( 'delete_customer_stock_notification' );

		try {

			$notification = Factory::get_notification( $notification_id );
			$this->data_store->delete( $notification );

			$notice_message = __( 'Notification deleted.', 'woocommerce' );
			NotificationsPage::add_notice( $notice_message, 'success' );

		} catch ( \Exception $e ) {

			$notice_message = __( 'Notification not found.', 'woocommerce' );
			NotificationsPage::add_notice( $notice_message, 'error' );
		}

		wp_safe_redirect( admin_url( NotificationsPage::PAGE_URL ) );
		exit();
	}

	/**
	 * Process bulk actions.
	 *
	 * @return void
	 */
	private function process_bulk_action() {
		if ( ! $this->current_action() ) {
			return;
		}

		check_admin_referer( 'bulk-' . $this->_args['plural'] );

		$notifications = isset( $_GET['notification'] ) && is_array( $_GET['notification'] ) ? array_map( 'absint', $_GET['notification'] ) : array();

		if ( empty( $notifications ) ) {
			return;
		}

		$redirect_url = NotificationsPage::PAGE_URL;

		if ( 'enable' === $this->current_action() ) {
			foreach ( $notifications as $id ) {

				$notification = Factory::get_notification( $id );
				$notification->set_status( NotificationStatus::ACTIVE );
				$this->data_store->update( $notification );

			}
			$notice_message = sprintf(
				/* translators: %s: Notifications count */
				_nx(
					'%s notification updated.',
					'%s notifications updated.',
					count( $notifications ),
					'notifications_status',
					'woocommerce'
				),
				count( $notifications )
			);

			NotificationsPage::add_notice( $notice_message, 'success' );

		} elseif ( 'cancel' === $this->current_action() ) {
			foreach ( $notifications as $id ) {
				$notification = Factory::get_notification( $id );
				$notification->set_status( NotificationStatus::CANCELLED );
				$this->data_store->update( $notification );
			}

			$notice_message = sprintf(
				/* translators: %s: Notifications count */
				_nx(
					'%s notification updated.',
					'%s notifications updated.',
					count( $notifications ),
					'notifications_status',
					'woocommerce'
				),
				count( $notifications )
			);

			NotificationsPage::add_notice( $notice_message, 'success' );

		} elseif ( 'delete' === $this->current_action() ) {
			foreach ( $notifications as $id ) {
				$notification = Factory::get_notification( $id );
				$this->data_store->delete( $notification );
			}

			$notice_message = sprintf(
				/* translators: %s: Notifications count */
				_nx(
					'%s notification deleted.',
					'%s notifications deleted.',
					count( $notifications ),
					'notifications_status',
					'woocommerce'
				),
				count( $notifications )
			);

			NotificationsPage::add_notice( $notice_message, 'success' );
		}

		wp_safe_redirect( $redirect_url );
		exit();
	}
}
PK     [1]i1  1  >  StockNotifications/Admin/Templates/html-product-data-admin.phpnu         <?php
/**
 * Admin View: Stock Notifications selected product
 *
 * @since    10.2.0
 */

declare( strict_types = 1 );

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

$image              = wp_get_attachment_image_src( $product->get_image_id(), 'woocommerce_thumbnail' );
$image_src          = is_array( $image ) && isset( $image[0] ) ? $image[0] : '';
$stock_availability = $product->get_availability();
$identifier         = '#' . $product->get_id();
if ( ! empty( $product->get_sku() ) ) {
	$identifier = $product->get_sku();
}
?>

<img src="<?php echo esc_attr( $image_src ? $image_src : wc_placeholder_img_src() ); ?>" alt="<?php echo esc_attr( $product->get_name() ); ?>">

<div class="product-details">

	<p class="product-details__title">
		<?php echo esc_html( $product->get_name() ); ?>
		<span>
			<?php printf( '(%s)', esc_html( $identifier ) ); ?>
		</span>
		<a target="_blank" href="<?php echo esc_url( admin_url( sprintf( 'post.php?post=%d&action=edit', $product->get_parent_id() ? $product->get_parent_id() : $product->get_id() ) ) ); ?>"><span class="dashicons dashicons-external"></span></a>
	</p>

	<span class="product-details__price">
		<?php echo wp_kses_post( $product->get_price_html( 'edit' ) ); ?>
	</span>

	<span class="product-details__stock-status <?php echo esc_attr( $stock_availability['class'] ); ?>">
		<?php
		if ( empty( $stock_availability['availability'] ) && 'in-stock' === $stock_availability['class'] ) {
			echo esc_html__( 'In stock', 'woocommerce' );
		} else {
			echo esc_html( $stock_availability['availability'] );
		}
		?>
	</span>

</div>
PK     [1]Z2q  q  E  StockNotifications/Admin/Templates/html-admin-notification-create.phpnu         <?php
/**
 * Admin View: Notification create
 *
 * @since 10.2.0
 */

declare( strict_types = 1 );

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use Automattic\WooCommerce\Internal\StockNotifications\Admin\NotificationsPage;
?>
<div class="wrap woocommerce-customer-stock-notifications">

	<h1 class="wp-heading-inline"><?php esc_html_e( 'Add Notification', 'woocommerce' ); ?></h1>
	<a href="<?php echo esc_url( NotificationsPage::PAGE_URL ); ?>" class="page-title-action"><?php esc_html_e( 'View All', 'woocommerce' ); ?></a>

	<hr class="wp-header-end">

	<form method="POST" id="edit-notification-form">
	<?php wp_nonce_field( 'woocommerce-customer-stock-notification-create', 'customer_stock_notification_create_security' ); ?>

	<div id="poststuff">
		<div id="post-body" class="columns-2">

			<!-- SIDEBAR -->
			<div id="postbox-container-1" class="postbox-container">

				<div id="woocommerce-order-actions" class="postbox">

					<h2 class="hndle ui-sortable-handle"><span><?php esc_html_e( 'Notification actions', 'woocommerce' ); ?></span></h2>

					<div class="inside">
						<ul class="order_actions submitbox">

							<li class="wide" id="actions">
								<select name="wc_customer_stock_notification_action" disabled="disabled">
									<option value=""><?php esc_html_e( 'Choose an action...', 'woocommerce' ); ?></option>
								</select>
								<button class="button wc-reload" disabled="disabled"><span><?php esc_html_e( 'Apply', 'woocommerce' ); ?></span></button>
							</li>

							<li class="wide">
								<button type="submit" class="button save_order button-primary" name="save" value="<?php esc_attr_e( 'Create', 'woocommerce' ); ?>"><?php esc_html_e( 'Create', 'woocommerce' ); ?></button>
							</li>

						</ul>
					</div>

				</div><!-- .postbox -->

			</div><!-- #container1 -->

			<!-- MAIN -->
			<div id="postbox-container-2" class="postbox-container">

				<div id="notification-data" class="postbox notification-data notification-data--create">
					<div class="notification-data__row notification-data__row--columns">

						<div class="notification-data__header-column">

							<h2 class="notification-data__header">
								<?php esc_html_e( 'Notification details', 'woocommerce' ); ?>
							</h2>

						</div>

					</div><!-- #row -->

					<div class="notification-data__row notification-data__row--columns">

						<div class="notification-data__form-field">
							<label><?php esc_html_e( 'Customer', 'woocommerce' ); ?></label>
							<?php
							$user_string = '';
							$user_id     = 0;

							// phpcs:disable WordPress.Security.NonceVerification.Recommended
							if ( ! empty( $_REQUEST['user_id'] ) ) {

								$user_id = absint( wp_unslash( $_REQUEST['user_id'] ) );
								if ( $user_id > 0 ) {
									$user = get_user_by( 'id', absint( $user_id ) );
									if ( $user ) {
										$user_string = sprintf(
											/* translators: 1: user display name 2: user ID 3: user email */
											esc_html__( '%1$s (#%2$s &ndash; %3$s)', 'woocommerce' ),
											$user->display_name,
											absint( $user->ID ),
											$user->user_email
										);
									}
								}
							}

							$email = isset( $_REQUEST['user_email'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['user_email'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
							?>
							<select class="wc-customer-search" name="user_id" data-placeholder="<?php esc_attr_e( 'Search for a customer&hellip;', 'woocommerce' ); ?>" data-allow_clear="true">
								<?php if ( $user_string && $user_id ) { ?>
									<option value="<?php echo esc_attr( $user_id ); ?>" selected="selected"><?php echo wp_kses_post( htmlspecialchars( $user_string, ENT_COMPAT ) ); ?><option>
								<?php } ?>
							</select>
							<div class="divider"></div>
							<span class="or_relation_label"><?php esc_html_e( '&mdash;&nbsp;or&nbsp;&mdash;', 'woocommerce' ); ?></span>
							<input type="email" class="or_relation_label__input" placeholder="<?php esc_html_e( 'Enter customer e-mail&hellip;', 'woocommerce' ); ?>" name="user_email" value="<?php echo esc_attr( $email ); ?>"/>

							<div class="wp-clearfix"></div>
						</div>

						<div class="notification-data__form-field">

							<label><?php esc_html_e( 'Product', 'woocommerce' ); ?></label>
							<?php
							$product_string = '';
							$product_id     = 0;

							// phpcs:disable WordPress.Security.NonceVerification.Recommended
							if ( ! empty( $_REQUEST['product_id'] ) ) {

								$product_id = absint( wp_unslash( $_REQUEST['product_id'] ) );
								if ( $product_id > 0 ) {
									$product = wc_get_product( $product_id );
									if ( is_a( $product, 'WC_Product' ) ) {
										$product_string = sprintf(
											/* translators: 1: product title 2: product ID */
											esc_html__( '%1$s (#%2$s)', 'woocommerce' ),
											$product->get_parent_id() ? $product->get_name() : $product->get_title(),
											absint( $product->get_id() )
										);
									}
								}
							}
							// phpcs:enable WordPress.Security.NonceVerification.Recommended
							$excluded_product_types = array_diff( array_keys( wc_get_product_types() ), array( 'simple', 'variable' ) );
							?>
							<select class="wc-product-search" name="product_id" data-action="woocommerce_json_search_products_and_variations" data-exclude_type="<?php echo esc_attr( implode( ',', $excluded_product_types ) ); ?>" data-display_stock="true"data-placeholder="<?php esc_attr_e( 'Select product&hellip;', 'woocommerce' ); ?>" data-allow_clear="true">
								<?php if ( $product_string && $product_id ) { ?>
									<option value="<?php echo esc_attr( $product_id ); ?>" selected="selected"><?php echo wp_kses_post( htmlspecialchars( $product_string, ENT_COMPAT ) ); ?><option>
								<?php } ?>
							</select>
						</div>

					</div><!-- #row -->

				</div><!-- .postbox -->

			</div><!-- #container2 -->

		</div><!-- #post-body -->
	</div>

	</form>

</div>PK     [1]7$%(  %(  C  StockNotifications/Admin/Templates/html-admin-notification-edit.phpnu         <?php
/**
 * Admin View: Notification create
 *
 * @since 10.2.0
 */

declare( strict_types = 1 );

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use Automattic\WooCommerce\Internal\StockNotifications\Admin\NotificationsPage;
use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
?>
<div class="wrap woocommerce woocommerce-customer-stock-notifications">

	<h1 class="wp-heading-inline"><?php esc_html_e( 'Edit Notification', 'woocommerce' ); ?></h1>
	<a href="<?php echo esc_url( NotificationsPage::PAGE_URL ); ?>" class="page-title-action"><?php esc_html_e( 'View All', 'woocommerce' ); ?></a>
	<a href="<?php echo esc_url( add_query_arg( array( 'notification_action' => 'create' ), NotificationsPage::PAGE_URL ) ); ?>" class="page-title-action"><?php esc_html_e( 'Add New', 'woocommerce' ); ?></a>

	<hr class="wp-header-end">

	<form method="POST" id="edit-notification-form">
	<?php wp_nonce_field( 'woocommerce-customer-stock-notification-edit', 'customer_stock_notification_edit_security' ); ?>

	<div id="poststuff">
		<div id="post-body" class="columns-2">

			<!-- SIDEBAR -->
			<div id="postbox-container-1" class="postbox-container">

				<div id="woocommerce-order-actions" class="postbox">

					<h2 class="hndle ui-sortable-handle"><span><?php esc_html_e( 'Notification actions', 'woocommerce' ); ?></span></h2>

					<div class="inside">
						<ul class="order_actions submitbox">

							<li class="wide" id="actions">
								<select name="wc_customer_stock_notification_action">
									<option value=""><?php esc_html_e( 'Choose an action...', 'woocommerce' ); ?></option>
									<?php if ( $notification->get_status() === NotificationStatus::ACTIVE ) : ?>
										<option value="send_notification"><?php esc_html_e( 'Send', 'woocommerce' ); ?></option>
										<option value="cancel_notification"><?php esc_html_e( 'Cancel', 'woocommerce' ); ?></option>
									<?php elseif ( $notification->get_status() === NotificationStatus::PENDING ) : ?>
										<option value="send_verification_email"><?php esc_html_e( 'Resend verification email', 'woocommerce' ); ?></option>
										<option value="activate_notification"><?php esc_html_e( 'Activate', 'woocommerce' ); ?></option>
									<?php elseif ( $notification->get_status() === NotificationStatus::CANCELLED ) : ?>
										<option value="activate_notification"><?php esc_html_e( 'Activate', 'woocommerce' ); ?></option>
									<?php elseif ( $notification->get_status() === NotificationStatus::SENT ) : ?>
										<option value="activate_notification"><?php esc_html_e( 'Activate', 'woocommerce' ); ?></option>
										<option value="cancel_notification"><?php esc_html_e( 'Cancel', 'woocommerce' ); ?></option>
									<?php endif; ?>
								</select>
								<button class="button wc-reload"><span><?php esc_html_e( 'Apply', 'woocommerce' ); ?></span></button>
							</li>

							<li class="wide">
								<div id="delete-action">
									<a class="submitdelete deletion" href="<?php echo esc_url( wp_nonce_url( admin_url( sprintf( NotificationsPage::PAGE_URL . '&notification_action=delete&notification_id=%d', $notification->get_id() ) ), 'delete_customer_stock_notification' ) ); ?>"><?php esc_html_e( 'Delete permanently', 'woocommerce' ); ?></a>
								</div>

								<button type="submit" class="button save_order button-primary" name="save" value="<?php esc_attr_e( 'Update', 'woocommerce' ); ?>"><?php esc_html_e( 'Update', 'woocommerce' ); ?></button>
							</li>

						</ul>
					</div>

				</div><!-- .postbox -->

			</div><!-- #container1 -->

			<!-- MAIN -->
			<div id="postbox-container-2" class="postbox-container">

				<div id="notification-data" class="postbox notification-data">

					<div class="notification-data__row notification-data__row--columns">

						<div class="notification-data__header-column">

							<h2 class="notification-data__header">
								<?php
								/* translators: %s: Notification ID */
								echo esc_html( sprintf( __( 'Notification #%d details', 'woocommerce' ), $notification->get_id() ) );
								?>
							</h2>

						</div>

						<div class="notification-data__status-column">
							<?php
							if ( $notification->get_status() === NotificationStatus::PENDING ) {
								$notification_status = 'cancelled';
								$label               = _x( 'Pending', 'stock notification status', 'woocommerce' );
							} elseif ( $notification->get_status() === NotificationStatus::CANCELLED ) {
								$notification_status = 'cancelled';
								$label               = _x( 'Cancelled', 'stock notification status', 'woocommerce' );
							} elseif ( $notification->get_status() === NotificationStatus::SENT ) {
								$notification_status = 'cancelled';
								$label               = _x( 'Sent', 'stock notification status', 'woocommerce' );
							} else {
								$notification_status = 'completed';
								$label               = _x( 'Active', 'stock notification status', 'woocommerce' );
							}

							printf( '<mark class="order-status %s"><span>%s</span></mark>', esc_attr( sanitize_html_class( 'status-' . $notification_status ) ), esc_html( $label ) );

							?>
						</div>

					</div><!-- #row -->

					<div class="notification-data__row notification-data__row--columns">

						<div class="notification-data__form-field">
							<label><?php esc_html_e( 'Customer', 'woocommerce' ); ?></label>
							<?php
							$user_string = '&mdash;';
							$user_id     = $notification->get_user_id();
							$user        = $user_id ? get_user_by( 'id', $user_id ) : null;
							if ( is_a( $user, 'WP_User' ) ) {
								$user_string = $user->display_name;
							} elseif ( filter_var( $notification->get_user_email(), FILTER_VALIDATE_EMAIL ) ) {
								$user_string = $notification->get_user_email();
							}
							?>
							<p class="notification-data__customer-data"><?php echo esc_html( $user_string ); ?></p>

							<div class="form-field__actions">
								<?php if ( isset( $user ) && is_a( $user, 'WP_User' ) ) { ?>
									<a href="<?php echo esc_url( get_edit_user_link( $user->ID ) ); ?>"><?php esc_html_e( 'View profile &rarr;', 'woocommerce' ); ?></a>
								<?php } ?>
								<a href="<?php echo esc_url( admin_url( NotificationsPage::PAGE_URL . '&s=' . rawurlencode( $notification->get_user_email() ) ) ); ?>"><?php esc_html_e( 'View notifications &rarr;', 'woocommerce' ); ?></a>
							</div>
						</div>

						<div class="notification-data__form-field">

							<label><?php esc_html_e( 'Product', 'woocommerce' ); ?></label>

							<div class="notification-data__product-data">
								<?php
								$product = $notification->get_product();
								if ( is_a( $product, 'WC_Product' ) ) {
									include __DIR__ . '/html-product-data-admin.php';
								} else {
									?>
									<small><?php esc_html_e( 'Product not found.', 'woocommerce' ); ?></small>
									<?php
								}
								?>
							</div>

						</div>

					</div><!-- #row -->

					<div class="notification-data__meta">
						<div class="notification-data__row notification-data__row--columns">

							<div class="notification-data__meta-column">
								<div class="notification-data__meta-data">
									<label><?php esc_html_e( 'Waiting', 'woocommerce' ); ?></label>
									<span>
										<?php
										if ( ! $notification->get_date_created() || $notification->get_status() !== 'active' ) {
											$t_time    = __( '&mdash;', 'woocommerce' );
											$h_time    = $t_time;
											$time_diff = 0;
										} else {
											$date_created_timestamp = $notification->get_date_created()->getTimestamp();
											$t_time                 = date_i18n( _x( 'Y/m/d g:i:s a', 'list table date hover format', 'woocommerce' ), $date_created_timestamp );
											$time_diff              = time() - $date_created_timestamp;

											if ( $time_diff > 0 && $time_diff < DAY_IN_SECONDS ) {
												/* translators: %s: human time diff */
												$h_time = wp_kses_post( human_time_diff( $date_created_timestamp ) );
											} else {
												$h_time = date_i18n( wc_date_format(), $date_created_timestamp );
											}
										}
										?>
										<span title="<?php echo esc_attr( $t_time ); ?>"><?php echo esc_html( $h_time ); ?></span>
									</span>
								</div>
								<div class="notification-data__meta-data">
									<label><?php esc_html_e( 'Signed up', 'woocommerce' ); ?></label>
									<?php
									$date_created = $notification->get_date_created();

									if ( ! $date_created ) {
										$t_time = __( '&mdash;', 'woocommerce' );
										$h_time = $t_time;
									} else {
										$date_created = $date_created->getTimestamp();
										$t_time       = date_i18n( _x( 'Y/m/d g:i:s a', 'list table date hover format', 'woocommerce' ), $date_created );
										$h_time       = date_i18n( wc_date_format(), $date_created );
									}
									?>
									<span title="<?php echo esc_attr( $t_time ); ?>"><?php echo esc_html( $h_time ); ?></span>
								</div>
							</div><!-- .column -->

							<div class="notification-data__meta-column">
								<div class="notification-data__meta-data">
									<label><?php esc_html_e( 'Signed-up customers', 'woocommerce' ); ?></label>
									<span>
										<?php
										echo absint( $signed_up_customers );

										if ( $signed_up_customers > 0 ) {
											?>
											<a href="<?php echo esc_attr( add_query_arg( array( 'customer_stock_notifications_product_filter' => $notification->get_product_id() ), NotificationsPage::PAGE_URL ) ); ?>"><?php esc_html_e( 'View notifications &rarr;', 'woocommerce' ); ?></a>
										<?php } ?>
									</span>
								</div>
								<?php
								$attributes = $notification->get_product_formatted_variation_list( true );
								if ( ! empty( $attributes ) ) {
									?>
									<div class="notification-data__meta-data">
										<label><?php esc_html_e( 'Attributes', 'woocommerce' ); ?></label>
										<span>
											<?php echo wp_kses_post( $attributes ); ?>
										</span>
									</div>
								<?php } ?>

							</div><!-- .column -->

						</div>
					</div>

				</div><!-- .postbox -->

			</div><!-- #container2 -->

		</div><!-- #post-body -->
	</div>

	</form>

</div>
PK     [1]Αv  v  ?  StockNotifications/Admin/Templates/html-admin-notifications.phpnu         <?php
/**
 * Admin View: Stock Notifications list
 *
 * @since    10.2.0
 */

declare( strict_types = 1 );

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use Automattic\WooCommerce\Internal\StockNotifications\Admin\NotificationsPage;
?>
<div class="wrap woocommerce-customer-stock-notifications">

	<h1 class="wp-heading-inline"><?php esc_html_e( 'Stock Notifications', 'woocommerce' ); ?></h1>
	<a href="<?php echo esc_url( add_query_arg( array( 'notification_action' => 'create' ), NotificationsPage::PAGE_URL ) ); ?>" class="page-title-action"><?php esc_html_e( 'Add New', 'woocommerce' ); ?></a>

	<hr class="wp-header-end">
	<?php
	if ( $table->has_stock_notifications ) {
		$table->views();
		?>

		<form id="customer-stock-notifications-table" class="customer-stock-notifications-select2" method="GET">
			<p class="search-box">
				<label for="post-search-input" class="screen-reader-text"><?php esc_html_e( 'Search Notifications', 'woocommerce' ); ?>:</label>
				<input type="search" placeholder="<?php echo esc_attr__( 'Search by user e-mail', 'woocommerce' ); ?>" value="<?php echo isset( $_REQUEST['s'] ) ? esc_attr( wc_clean( wp_unslash( $_REQUEST['s'] ) ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended ?>" name="s" id="customer-stock-notifications-search-input">
				<input type="submit" value="<?php echo esc_attr__( 'Search', 'woocommerce' ); ?>" class="button" id="search-submit" name="">
			</p>
			<input type="hidden" name="page" value="<?php echo isset( $_REQUEST['page'] ) ? esc_attr( wc_clean( wp_unslash( $_REQUEST['page'] ) ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended ?>"/>
			<?php $table->display(); ?>
		</form>

	<?php } else { ?>

		<div class="woocommerce-BlankState">
			<h2 class="woocommerce-BlankState-message">
				<?php esc_html_e( 'No customers have signed up to receive stock notifications from you just yet.', 'woocommerce' ); ?>
			</h2>
			<a class="woocommerce-BlankState-cta button-primary button" target="_blank" href="https://woocommerce.com/document/back-in-stock-notifications"><?php esc_html_e( 'Learn more', 'woocommerce' ); ?></a>
		</div>

	<?php } ?>
</div>
PK     [1]=F    /  StockNotifications/Enums/NotificationStatus.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\Enums;

/**
 * Enum class for all the notification statuses.
 */
final class NotificationStatus {

	/**
	 * Status: 'pending'.
	 * Initial state when Double Opt-In (DOI) is active, awaiting user email verification.
	 * Not eligible for "back in stock" notifications until confirmed.
	 *
	 * @var string
	 */
	public const PENDING = 'pending';

	/**
	 * Status: 'active'.
	 * User's subscription is confirmed and they are waiting for a "back in stock" alert.
	 * This is the default for new subscriptions if DOI is disabled, or after DOI confirmation.
	 * Notifications in this state are processed when the product is available.
	 *
	 * @var string
	 */
	public const ACTIVE = 'active';

	/**
	 * Status: 'sent'.
	 * The "back in stock" notification email has been successfully dispatched.
	 * Typically a final state for that notification event.
	 *
	 * @var string
	 */
	public const SENT = 'sent';

	/**
	 * Status: 'cancelled'.
	 * The notification is no longer active and will not be sent.
	 * The reason for cancellation should be in the `cancellation_source` field.
	 *
	 * @var string
	 */
	public const CANCELLED = 'cancelled';

	/**
	 * Get all available notification statuses.
	 *
	 * @return array<string> Notification statuses.
	 */
	public static function get_valid_statuses(): array {
		return array(
			self::PENDING,
			self::ACTIVE,
			self::SENT,
			self::CANCELLED,
		);
	}
}
PK     [1]Ͱ    ;  StockNotifications/Enums/NotificationCancellationSource.phpnu         <?php

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\StockNotifications\Enums;

/**
 * Notification cancellation source enum.
 */
final class NotificationCancellationSource {

	/**
	 * Admin cancellation source.
	 *
	 * @var string
	 */
	public const ADMIN = 'admin';

	/**
	 * User cancellation source.
	 *
	 * @var string
	 */
	public const USER = 'user';

	/**
	 * System cancellation source.
	 *
	 * @var string
	 */
	public const SYSTEM = 'system';

	/**
	 * Get valid cancellation sources.
	 *
	 * @return string[]
	 */
	public static function get_valid_cancellation_sources(): array {
		return array(
			self::ADMIN,
			self::USER,
			self::SYSTEM,
		);
	}
}
PK     [1]"¸    ,  StockNotifications/AsyncTasks/JobManager.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\AsyncTasks;

use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationCancellationSource;
use Automattic\WooCommerce\Internal\StockNotifications\Factory;
use Automattic\WooCommerce\Internal\StockNotifications\Config;
use Automattic\WooCommerce\Internal\StockNotifications\Notification;
use Automattic\WooCommerce\Internal\StockNotifications\NotificationQuery;
use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailManager;
use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EligibilityService;
use WC_Product;
use Exception;

/**
 * The manager for async tasks.
 */
class JobManager {

	/**
	 * The job hook for sending stock notifications.
	 */
	public const AS_JOB_SEND_STOCK_NOTIFICATIONS = 'wc_send_stock_notifications_batch';

	/**
	 * The job group for stock notifications.
	 */
	public const AS_JOB_GROUP = 'wc-stock-notifications';

	/**
	 * The logger instance.
	 *
	 * @var \WC_Logger_Interface
	 */
	private $logger;

	/**
	 * The queue instance.
	 *
	 * @var \WC_Queue_Interface
	 */
	private $queue;

	/**
	 * Constructor.
	 *
	 * @return void
	 */
	public function __construct() {
		$this->logger = \wc_get_logger();
		$this->queue  = \WC()->queue();
	}

	/**
	 * Schedule a job.
	 *
	 * @param int $product_id The product ID.
	 * @return bool True if the job was scheduled, false otherwise.
	 */
	public function schedule_initial_job_for_product( int $product_id ): bool {
		$args = array( 'product_id' => $product_id );

		try {

			if ( $this->queue->get_next( self::AS_JOB_SEND_STOCK_NOTIFICATIONS, $args, self::AS_JOB_GROUP ) ) {
				return false;
			}

			/**
			 * Filter: woocommerce_customer_stock_notifications_first_batch_delay
			 *
			 * @since 10.2.0
			 *
			 * Schedule the first batch with a delay to prevent overwhelming the system.
			 *
			 * @param int   $delay       Delay time in seconds before first batch.
			 * @param int   $product_id  Product ID being scheduled.
			 */
			$delay = (int) apply_filters( 'woocommerce_customer_stock_notifications_first_batch_delay', MINUTE_IN_SECONDS, $product_id );
			$delay = max( 0, $delay );

			$action_id = $this->queue->schedule_single(
				time() + $delay,
				self::AS_JOB_SEND_STOCK_NOTIFICATIONS,
				$args,
				self::AS_JOB_GROUP
			);

			if ( ! $action_id ) {
				return false;
			}

			$this->logger->info(
				sprintf( 'Scheduled stock notification for product %d', $product_id ),
				array( 'source' => 'wc-customer-stock-notifications' )
			);

			return true;
		} catch ( Exception $e ) {
			$this->logger->error(
				sprintf( 'Failed to schedule stock notification for product %d: %s', $product_id, $e->getMessage() ),
				array( 'source' => 'wc-customer-stock-notifications' )
			);

			return false;
		}
	}

	/**
	 * Schedule the next batch for a product.
	 *
	 * @param int $product_id The product ID.
	 * @return bool
	 */
	public function schedule_next_batch_for_product( int $product_id ): bool {

		$args = array( 'product_id' => $product_id );

		if ( $this->queue->get_next( self::AS_JOB_SEND_STOCK_NOTIFICATIONS, $args, self::AS_JOB_GROUP ) ) {
			return false;
		}

		/**
		 * Filter: woocommerce_customer_stock_notifications_next_batch_delay
		 *
		 * @since 10.2.0
		 *
		 * @param int   $delay       Delay time in seconds before next batch.
		 * @param int   $product_id  Product ID being scheduled.
		 */
		$delay = (int) apply_filters( 'woocommerce_customer_stock_notifications_next_batch_delay', 0, $product_id );
		$delay = max( 0, $delay );

		if ( 0 === $delay ) {
			$action_id = $this->queue->add(
				self::AS_JOB_SEND_STOCK_NOTIFICATIONS,
				$args,
				self::AS_JOB_GROUP
			);
		} else {
			$action_id = $this->queue->schedule_single(
				time() + $delay,
				self::AS_JOB_SEND_STOCK_NOTIFICATIONS,
				$args,
				self::AS_JOB_GROUP
			);
		}

		return ! empty( $action_id );
	}
}
PK     [1]    3  StockNotifications/AsyncTasks/CycleStateService.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\AsyncTasks;

use Automattic\WooCommerce\Internal\StockNotifications\AsyncTasks\JobManager;

/**
 * The service for managing a product's send cycle state.
 */
class CycleStateService {

	/**
	 * State option prefix.
	 */
	public const STATE_OPTION_PREFIX = 'wc_stock_notifications_cycle_state_';

	/**
	 * The logger instance.
	 *
	 * @var \WC_Logger_Interface
	 */
	private $logger;

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->logger = \wc_get_logger();
	}

	/**
	 * Parse the cycle state for a product.
	 *
	 * @param int $product_id The product ID.
	 * @return array
	 * @throws \Exception If the cycle state is invalid.
	 */
	public function get_or_initialize_cycle_state( int $product_id ): array {

		if ( $product_id <= 0 ) {
			throw new \Exception( 'Product ID is required.' );
		}

		$default_state = array(
			'cycle_start_time' => time(),
			'product_ids'      => array( $product_id ),
			'total_count'      => 0,
			'skipped_count'    => 0,
			'sent_count'       => 0,
			'failed_count'     => 0,
			'duration'         => 0,
		);

		$cycle_state = $this->get_raw_cycle_state( $product_id );
		if ( empty( $cycle_state ) ) {
			return $default_state;
		}

		if ( array_diff_key( $default_state, $cycle_state ) || empty( $cycle_state['cycle_start_time'] ) || ! is_numeric( $cycle_state['cycle_start_time'] ) ) {
			throw new \Exception( 'Invalid cycle state.' );
		}

		$cycle_state = wp_parse_args( $cycle_state, $default_state );

		return $cycle_state;
	}

	/**
	 * Get the raw cycle state.
	 *
	 * @param int $product_id The product ID.
	 * @return array
	 */
	private function get_raw_cycle_state( int $product_id ): array {
		$cycle_state = get_option( $this->get_option_name( $product_id ), false );
		if ( ! is_array( $cycle_state ) ) {
			return array();
		}

		return $cycle_state;
	}

	/**
	 * Complete the cycle.
	 *
	 * @param int|string $product_id The product ID.
	 * @param array      $cycle_state The cycle state.
	 * @return void
	 */
	public function complete_cycle( int $product_id, array $cycle_state ): void {

		$cycle_state['duration'] = time() - $cycle_state['cycle_start_time'];

		$this->logger->info(
			sprintf( 'Completed cycle for product %d. Sent: %d, Skipped: %d, Failed: %d, Duration: %d seconds. Total notifications processed: %d', $product_id, $cycle_state['sent_count'], $cycle_state['skipped_count'], $cycle_state['failed_count'], $cycle_state['duration'], $cycle_state['total_count'] ),
			array( 'source' => 'wc-customer-stock-notifications' )
		);

		$this->save_cycle_state( $product_id, array() );
	}

	/**
	 * Save the cycle state.
	 *
	 * @param int   $product_id The product ID.
	 * @param array $cycle_state The cycle state.
	 * @return bool Whether the state was saved.
	 */
	public function save_cycle_state( int $product_id, array $cycle_state ): bool {
		if ( $product_id <= 0 ) {
			return false;
		}

		$current_cycle_state = $this->get_raw_cycle_state( $product_id );
		if ( $current_cycle_state === $cycle_state ) {
			return false;
		}

		if ( empty( $cycle_state ) ) {
			$result = delete_option( $this->get_option_name( $product_id ) );
		} else {
			$result = update_option( $this->get_option_name( $product_id ), $cycle_state, false );
		}

		if ( ! $result ) {
			$this->logger->error( sprintf( 'Failed to save cycle state for product %d. Cycle state: %s', $product_id, wc_print_r( $cycle_state, true ) ), array( 'source' => 'wc-customer-stock-notifications' ) );
		}

		return $result;
	}

	/**
	 * Get the option name.
	 *
	 * @param int $product_id The product ID.
	 * @return string
	 */
	private function get_option_name( int $product_id ): string {
		if ( $product_id <= 0 ) {
			return '';
		}

		return self::STATE_OPTION_PREFIX . $product_id;
	}
}
PK     [1]?^f    8  StockNotifications/AsyncTasks/NotificationsProcessor.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\AsyncTasks;

use Automattic\WooCommerce\Internal\StockNotifications\AsyncTasks\JobManager;
use Automattic\WooCommerce\Internal\StockNotifications\AsyncTasks\CycleStateService;
use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EligibilityService;
use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailManager;
use Automattic\WooCommerce\Internal\StockNotifications\Factory;
use Automattic\WooCommerce\Internal\StockNotifications\Notification;
use Automattic\WooCommerce\Internal\StockNotifications\NotificationQuery;
use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationCancellationSource;
use WC_Product;

/**
 * The async processor for sending stock notifications in bulk.
 */
class NotificationsProcessor {

	/**
	 * The email manager.
	 *
	 * @var EmailManager
	 */
	private EmailManager $email_manager;

	/**
	 * The logger.
	 *
	 * @var Logger
	 */
	private $logger;

	/**
	 * The eligibility service.
	 *
	 * @var EligibilityService
	 */
	private EligibilityService $eligibility_service;

	/**
	 * The job manager.
	 *
	 * @var JobManager
	 */
	private JobManager $job_manager;

	/**
	 * The cycle state service.
	 *
	 * @var CycleStateService
	 */
	private CycleStateService $cycle_state_service;

	/**
	 * The batch size for processing notifications.
	 */
	protected const BATCH_SIZE = 50;

	/**
	 * Initialize the controller.
	 *
	 * @internal
	 *
	 * @param EligibilityService $eligibility_service The eligibility service.
	 * @param JobManager         $job_manager The job manager.
	 * @param CycleStateService  $cycle_state_service The cycle state service.
	 * @param EmailManager       $email_manager The email manager.
	 * @return void
	 */
	final public function init(
		EligibilityService $eligibility_service,
		JobManager $job_manager,
		CycleStateService $cycle_state_service,
		EmailManager $email_manager
	): void {
		$this->eligibility_service = $eligibility_service;
		$this->job_manager         = $job_manager;
		$this->cycle_state_service = $cycle_state_service;
		$this->email_manager       = $email_manager;
	}

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->logger = \wc_get_logger();
		add_action( JobManager::AS_JOB_SEND_STOCK_NOTIFICATIONS, array( $this, 'process_batch' ) );
	}

	/**
	 * Get the batch size for processing notifications.
	 *
	 * @return int
	 */
	private function get_batch_size(): int {
		/**
		 * Filter: woocommerce_customer_stock_notifications_batch_size
		 *
		 * @since 10.2.0
		 *
		 * Allow customization of batch size for processing notifications.
		 *
		 * @param int $batch_size Default batch size.
		 * @return int
		 */
		return (int) apply_filters( 'woocommerce_customer_stock_notifications_batch_size', self::BATCH_SIZE );
	}

	/**
	 * Parse the product ID from the arguments.
	 *
	 * @param int $product_id The product ID.
	 * @return int
	 * @throws \Exception If the product is not found.
	 */
	private function parse_args( $product_id ): int {
		if ( empty( $product_id ) || ! is_numeric( $product_id ) ) {
			throw new \Exception( 'Invalid arguments.' );
		}

		$product_id = (int) $product_id;
		if ( $product_id <= 0 ) {
			throw new \Exception( 'Product ID is required.' );
		}

		return $product_id;
	}

	/**
	 * Parse the product.
	 *
	 * @param int $product_id The product ID.
	 * @return \WC_Product
	 * @throws \Exception If the product is not valid for notifications.
	 */
	private function parse_product( int $product_id ): WC_Product {

		$product = wc_get_product( $product_id );
		if ( ! $product instanceof WC_Product ) {
			throw new \Exception( sprintf( 'Product %d not found.', absint( $product_id ) ) );
		}

		if ( ! $this->eligibility_service->is_product_eligible( $product ) ) {
			throw new \Exception( sprintf( 'Product %d is not eligible for notifications.', $product->get_id() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
		}

		if ( ! $this->eligibility_service->is_stock_status_eligible( $product->get_stock_status() ) ) {
			throw new \Exception( sprintf( 'Product %d stock status is not eligible for notifications (i.e. not in stock).', $product->get_id() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
		}

		return $product;
	}

	/**
	 * Process a batch of notifications.
	 *
	 * @param int $product_id The product ID from AS job args.
	 * @return void
	 */
	public function process_batch( $product_id ) {
		// Sanity checks.
		try {
			$product_id  = $this->parse_args( $product_id );
			$cycle_state = $this->cycle_state_service->get_or_initialize_cycle_state( $product_id );
			$product     = $this->parse_product( $product_id );
		} catch ( \Throwable $e ) {
			$product_id = (int) $product_id ?? 0;
			$this->logger->error(
				sprintf( 'Background process for product %s terminated. Reason: %s', $product_id, $e->getMessage() ),
				array(
					'source'     => 'wc-customer-stock-notifications',
					'product_id' => $product_id,
					'exception'  => get_class( $e ),
				)
			);

			// Clean up the cycle state.
			if ( isset( $cycle_state ) ) {
				$this->cycle_state_service->complete_cycle( $product_id, $cycle_state );
			}

			return;
		}

		$cycle_state['product_ids'] = $this->eligibility_service->get_target_product_ids( $product );

		// Get notifications.
		$notifications = NotificationQuery::get_notifications(
			array(
				'status'             => NotificationStatus::ACTIVE,
				'product_id'         => $cycle_state['product_ids'],
				'last_attempt_limit' => (int) $cycle_state['cycle_start_time'],
				'return'             => 'ids',
				'limit'              => $this->get_batch_size(),
				'orderby'            => 'id',
				'order'              => 'ASC',
			)
		);

		if ( empty( $notifications ) ) {
			$this->cycle_state_service->complete_cycle( $product_id, $cycle_state );
			return;
		}

		foreach ( $notifications as $notification_id ) {
			$notification = Factory::get_notification( $notification_id );
			if ( ! $notification instanceof Notification ) {
				$this->logger->error(
					sprintf( 'Failed to get notification ID: %d', $notification_id ),
					array( 'source' => 'wc-customer-stock-notifications' )
				);
				continue;
			}

			$notification->set_date_last_attempt( time() );
			++$cycle_state['total_count'];

			if ( $this->eligibility_service->should_skip_notification( $notification, $product ) ) {
				++$cycle_state['skipped_count'];
				$notification->save();
				continue;
			}

			$is_sent = true;
			try {
				$this->email_manager->send_stock_notification_email( $notification );
			} catch ( \Throwable $e ) {
				$is_sent = false;
			}

			if ( $is_sent ) {
				$notification->set_date_notified( time() );
				$notification->set_status( NotificationStatus::SENT );
				++$cycle_state['sent_count'];
			} else {
				$notification->set_status( NotificationStatus::CANCELLED );
				$notification->set_cancellation_source( NotificationCancellationSource::SYSTEM );
				++$cycle_state['failed_count'];
			}

			// Always save the notification to reflect last attempt time.
			$notification->save();
		}

		if ( count( $notifications ) === $this->get_batch_size() ) {
			$this->cycle_state_service->save_cycle_state( $product_id, $cycle_state );
			$this->job_manager->schedule_next_batch_for_product( $product_id );
			return;
		}

		$this->cycle_state_service->complete_cycle( $product_id, $cycle_state );
	}
}
PK     [1]ڻB4  4  #  StockNotifications/Notification.phpnu         <?php
/**
 * StockNotification class file.
 */

declare( strict_types = 1);

namespace Automattic\WooCommerce\Internal\StockNotifications;

use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationCancellationSource;
use Automattic\WooCommerce\Internal\StockNotifications\Utilities\HasherHelper;

defined( 'ABSPATH' ) || exit;

/**
 * Notification data class.
 */
class Notification extends \WC_Data {
	/**
	 * This is the name of this object type.
	 *
	 * @var string
	 */
	protected $object_type = 'stock_notification';

	/**
	 * Product. Runtime property.
	 *
	 * @var \WC_Product
	 */
	public $product;

	/**
	 * Default data.
	 *
	 * @var array
	 */
	protected $data = array(
		'status'              => NotificationStatus::PENDING,
		'product_id'          => 0,
		'user_id'             => 0,
		'user_email'          => '',
		'date_created'        => null,
		'date_confirmed'      => null,
		'date_modified'       => null,
		'date_notified'       => null,
		'date_last_attempt'   => null,
		'date_cancelled'      => null,
		'cancellation_source' => null,
	);

	/**
	 * Constructor.
	 *
	 * @param int|object|array $read ID to load from the DB (optional) or already queried data.
	 */
	public function __construct( $read = 0 ) {
		parent::__construct( $read );
		if ( is_numeric( $read ) && $read > 0 ) {
			$this->set_id( $read );
		} elseif ( $read instanceof self ) {
			$this->set_id( $read->get_id() );
		} elseif ( ! empty( $read->ID ) ) {
			$this->set_id( absint( $read->ID ) );
		} elseif ( is_array( $read ) && ! empty( $read['id'] ) ) {
			$this->set_props( $read );
			$this->set_object_read( true );
		} else {
			$this->set_object_read( true );
		}

		$this->data_store = \WC_Data_Store::load( 'stock_notification' );
		if ( $this->get_id() > 0 ) {
			$this->data_store->read( $this );
		}
	}

	/**
	 * Get the product ID.
	 *
	 * @param string $context Context.
	 * @return int
	 */
	public function get_product_id( $context = 'view' ) {
		return $this->get_prop( 'product_id', $context );
	}

	/**
	 * Get the user ID.
	 *
	 * @param string $context Context.
	 * @return int
	 */
	public function get_user_id( $context = 'view' ) {
		return $this->get_prop( 'user_id', $context );
	}

	/**
	 * Get the user email.
	 *
	 * @param string $context Context.
	 * @return string
	 */
	public function get_user_email( $context = 'view' ) {
		return $this->get_prop( 'user_email', $context );
	}

	/**
	 * Get the status.
	 *
	 * @param string $context Context.
	 * @return string
	 */
	public function get_status( $context = 'view' ) {
		return $this->get_prop( 'status', $context );
	}

	/**
	 * Get the date created.
	 *
	 * @param string $context Context.
	 * @return \WC_DateTime|null Datetime object if the date is set or null if there is no date.
	 */
	public function get_date_created( $context = 'view' ) {
		return $this->get_prop( 'date_created', $context );
	}

	/**
	 * Get the date modified.
	 *
	 * @param string $context Context.
	 * @return \WC_DateTime|null Datetime object if the date is set or null if there is no date.
	 */
	public function get_date_modified( $context = 'view' ) {
		return $this->get_prop( 'date_modified', $context );
	}

	/**
	 * Get the date confirmed.
	 *
	 * @param string $context Context.
	 * @return \WC_DateTime|null Datetime object if the date is set or null if there is no date.
	 */
	public function get_date_confirmed( $context = 'view' ) {
		return $this->get_prop( 'date_confirmed', $context );
	}

	/**
	 * Get the date last attempt.
	 *
	 * @param string $context Context.
	 * @return \WC_DateTime|null Datetime object if the date is set or null if there is no date.
	 */
	public function get_date_last_attempt( $context = 'view' ) {
		return $this->get_prop( 'date_last_attempt', $context );
	}

	/**
	 * Get the date notified.
	 *
	 * @param string $context Context.
	 * @return \WC_DateTime|null Datetime object if the date is set or null if there is no date.
	 */
	public function get_date_notified( $context = 'view' ) {
		return $this->get_prop( 'date_notified', $context );
	}

	/**
	 * Get the date cancelled.
	 *
	 * @param string $context Context.
	 * @return \WC_DateTime|null Datetime object if the date is set or null if there is no date.
	 */
	public function get_date_cancelled( $context = 'view' ) {
		return $this->get_prop( 'date_cancelled', $context );
	}

	/**
	 * Get the cancellation source.
	 *
	 * @param string $context Context.
	 * @return string|null The cancellation source or null if there is no source.
	 */
	public function get_cancellation_source( $context = 'view' ) {
		return $this->get_prop( 'cancellation_source', $context );
	}

	/**
	 * Get the product.
	 *
	 * @return \WC_Product|false
	 */
	public function get_product() {
		if ( ! empty( $this->product ) ) {
			return $this->product;
		}

		$product = wc_get_product( $this->get_prop( 'product_id' ) );
		if ( ! $product ) {
			return false;
		}

		$this->product = $product;
		return $product;
	}

	/*
	|--------------------------------------------------------------------------
	| Setters
	|--------------------------------------------------------------------------
	*/

	/**
	 * Set the product ID.
	 *
	 * @param int $product_id Product ID.
	 */
	public function set_product_id( int $product_id ) {

		// Reset runtime cache if the product ID has changed.
		if ( is_a( $this->product, 'WC_Product' ) && $product_id !== $this->product->get_id() ) {
			$this->product = null;
		}
		$this->set_prop( 'product_id', $product_id );
	}

	/**
	 * Set the user ID.
	 *
	 * @param int $user_id User ID.
	 */
	public function set_user_id( int $user_id ) {
		$this->set_prop( 'user_id', $user_id );
	}

	/**
	 * Set the user email.
	 *
	 * @param string $user_email User email.
	 */
	public function set_user_email( string $user_email ) {
		$this->set_prop( 'user_email', $user_email );
	}

	/**
	 * Set the status.
	 *
	 * @param string $status Status.
	 */
	public function set_status( string $status ) {

		if ( ! in_array( $status, NotificationStatus::get_valid_statuses(), true ) ) {
			// Default to pending.
			$status = NotificationStatus::PENDING;
		}

		$this->set_prop( 'status', $status );
	}

	/**
	 * Set the date created.
	 *
	 * @param string|int $date_created Date created.
	 */
	public function set_date_created( $date_created ) {
		$this->set_date_prop( 'date_created', $date_created );
	}

	/**
	 * Set the date modified.
	 *
	 * @param string|int $date_modified Date modified.
	 */
	public function set_date_modified( $date_modified ) {
		$this->set_date_prop( 'date_modified', $date_modified );
	}

	/**
	 * Set the date confirmed.
	 *
	 * @param string|int $date_confirmed Date confirmed.
	 */
	public function set_date_confirmed( $date_confirmed ) {
		$this->set_date_prop( 'date_confirmed', $date_confirmed );
	}

	/**
	 * Set the date last attempt.
	 *
	 * @param string|int $date_last_attempt Date last attempt.
	 */
	public function set_date_last_attempt( $date_last_attempt ) {
		$this->set_date_prop( 'date_last_attempt', $date_last_attempt );
	}

	/**
	 * Set the date notified.
	 *
	 * @param string|int $date_notified Date notified.
	 */
	public function set_date_notified( $date_notified ) {
		$this->set_date_prop( 'date_notified', $date_notified );
	}

	/**
	 * Set the date cancelled.
	 *
	 * @param string|int $date_cancelled Date cancelled.
	 */
	public function set_date_cancelled( $date_cancelled ) {
		$this->set_date_prop( 'date_cancelled', $date_cancelled );
	}

	/**
	 * Set the cancellation source.
	 *
	 * @param string|null $cancellation_source Cancellation source. Can be null.
	 */
	public function set_cancellation_source( ?string $cancellation_source ) {
		if ( $cancellation_source && ! in_array( $cancellation_source, NotificationCancellationSource::get_valid_cancellation_sources(), true ) ) {
			// Default to user.
			$cancellation_source = NotificationCancellationSource::USER;
		}

		$this->set_prop( 'cancellation_source', $cancellation_source );
	}

	/*
	|--------------------------------------------------------------------------
	| Other Methods
	|--------------------------------------------------------------------------
	*/

	/**
	 * Validate the data.
	 *
	 * @throws \WC_Data_Exception If the data is invalid.
	 */
	protected function validate_props() {
		if ( empty( $this->get_prop( 'product_id' ) ) ) {
			$this->error( 'stock_notification_product_id_required', __( 'Product ID is required.', 'woocommerce' ) );
		}

		if ( empty( $this->get_prop( 'user_id' ) ) && empty( $this->get_prop( 'user_email' ) ) ) {
			$this->error( 'stock_notification_user_id_or_user_email_required', __( 'User ID or User Email is required.', 'woocommerce' ) );
		}

		if ( ! empty( $this->get_prop( 'user_email' ) ) && ! filter_var( $this->get_prop( 'user_email' ), FILTER_VALIDATE_EMAIL ) ) {
			$this->error( 'stock_notification_user_email_invalid', __( 'User Email is invalid.', 'woocommerce' ) );
		}
	}

	/**
	 * Save the notification.
	 *
	 * @return int|\WP_Error The notification ID or a WP_Error if the save failed.
	 */
	public function save() {
		if ( ! $this->data_store ) {
			return $this->get_id();
		}

		try {
			$this->validate_props();
		} catch ( \WC_Data_Exception $e ) {
			return new \WP_Error( 'stock_notification_validation_error', $e->getMessage() );
		}

		if ( $this->get_id() ) {
			$this->data_store->update( $this );
		} else {
			$this->data_store->create( $this );
		}

		return $this->get_id();
	}

	/**
	 * Retrieves the formatted attributes of the product based on the notification's posted attributes.
	 *
	 * Wrapper of the `wc_get_formatted_variation` function.
	 *
	 * @param bool $flat Flatten the list.
	 * @return string
	 */
	public function get_product_formatted_variation_list( bool $flat = false ) {

		$product = $this->get_product();
		if ( ! $product || ! $product->is_type( array( 'variation' ) ) ) {
			return '';
		}

		// Replace list with custom data.
		$attributes = $this->get_meta( 'posted_attributes' );
		if ( ! $attributes ) {
			$attributes = $product->get_attributes();
		}

		if ( empty( $attributes ) ) {
			return '';
		}

		$attrs = array();
		foreach ( $attributes as $key => $value ) {

			if ( 0 === strpos( $key, 'attribute_pa_' ) ) {
				$attrs[ str_replace( 'attribute_', '', $key ) ] = $value;
			} else {
				// By pass converting global product attributes.
				$attrs[ wc_attribute_label( str_replace( 'attribute_', '', $key ), $product ) ] = $value;
			}
		}

		$formatted_variation_list = wc_get_formatted_variation( $attrs, $flat, true, true );

		return $formatted_variation_list;
	}

	/**
	 * Get product link.
	 *
	 * @return string
	 */
	public function get_product_permalink() {

		$product = $this->get_product();
		if ( ! $product ) {
			return '';
		}

		if ( $product->is_type( 'variation' ) && ! empty( $this->get_meta( 'posted_attributes' ) ) ) {
			return $product->get_permalink( array( 'item_meta_array' => $this->get_meta( 'posted_attributes' ) ) );
		} else {
			return $product->get_permalink();
		}
	}

	/**
	 * Get product name.
	 *
	 * @return string
	 */
	public function get_product_name() {
		$product = $this->get_product();
		if ( ! $product ) {
			return '';
		}

		return $product->get_parent_id() ? $product->get_name() : $product->get_title();
	}

	/**
	 * Check if the given key is a valid verification key.
	 *
	 * This method checks if the key is valid by verifying the hash and checking the expiration time.
	 *
	 * @param string $key The key to check.
	 * @return bool True if the key is valid, false otherwise.
	 */
	public function check_verification_key( string $key ): bool {
		$action_key = $this->get_meta( 'email_link_action_key' );

		if ( ! str_contains( $action_key, ':' ) ) {
			return false;
		}

		list( $timestamp, $hash ) = explode( ':', $action_key, 2 );

		$threshold = Config::get_verification_expiration_time_threshold();
		if ( time() - (int) $timestamp > $threshold ) {
			return false;
		}

		return HasherHelper::wp_verify_fast_hash( $key, $hash );
	}

	/**
	 * Maybe setup verification data for the notification.
	 *
	 * This is used to ensure that the notification has valid verification data.
	 *
	 * @param bool $persist If true, save the changes to the database.
	 * @return string The generated verification key.
	 */
	public function get_verification_key( bool $persist ): string {
		$key = wp_generate_password( 20, false );
		$this->update_meta_data( 'email_link_action_key', time() . ':' . HasherHelper::wp_fast_hash( $key ) );

		if ( $persist ) {
			$this->save();
		}

		return $key;
	}

	/**
	 * Check if the given key is a valid unsubscribe key.
	 *
	 * @param string $key The key to check.
	 * @return bool True if the key is valid, false otherwise.
	 */
	public function check_unsubscribe_key( string $key ): bool {
		return HasherHelper::wp_verify_fast_hash( $key, $this->get_meta( 'email_link_action_key' ) );
	}

	/**
	 * Maybe setup verification data for the notification.
	 *
	 * This is used to ensure that the notification has valid verification data.
	 *
	 * @param bool $persist If true, save the changes to the database.
	 * @return string The generated unsubscribe key.
	 */
	public function get_unsubscribe_key( bool $persist ): string {
		$key  = wp_generate_password( 20, false );
		$hash = HasherHelper::wp_fast_hash( $key );
		$this->update_meta_data( 'email_link_action_key', $hash );

		if ( $persist ) {
			$this->save();
		}

		return $key;
	}
}
PK     [1]YC    (  StockNotifications/NotificationQuery.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\StockNotifications;

/**
 * Notification query class.
 */
class NotificationQuery {

	/**
	 * Get notifications.
	 *
	 * @param array $args The arguments to pass to the query.
	 * @return array The notifications.
	 */
	public static function get_notifications( array $args ): array {
		return \WC_Data_Store::load( 'stock_notification' )->query( $args );
	}

	/**
	 * Check if a product has active notifications.
	 *
	 * @param array<int> $product_ids The product IDs to check.
	 * @return bool True if the product has active notifications, false otherwise.
	 */
	public static function product_has_active_notifications( array $product_ids ): bool {
		return \WC_Data_Store::load( 'stock_notification' )->product_has_active_notifications( $product_ids );
	}

	/**
	 * Check if a notification exists by email.
	 *
	 * @param int    $product_id The product ID.
	 * @param string $email The email address.
	 * @return bool True if the notification exists, false otherwise.
	 */
	public static function notification_exists_by_email( int $product_id, string $email ): bool {
		return \WC_Data_Store::load( 'stock_notification' )->notification_exists_by_email( $product_id, $email );
	}

	/**
	 * Get a notification by user ID.
	 *
	 * @param int $product_id The product ID.
	 * @param int $user_id The user ID.
	 * @return bool True if the notification exists, false otherwise.
	 */
	public static function notification_exists_by_user_id( int $product_id, int $user_id ): bool {
		return \WC_Data_Store::load( 'stock_notification' )->notification_exists_by_user_id( $product_id, $user_id );
	}
}
PK     [1]ar      StockNotifications/Factory.phpnu         <?php
/**
 * Notification Factory
 */

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications;

use Automattic\WooCommerce\Internal\StockNotifications\Notification;

defined( 'ABSPATH' ) || exit;

/**
 * Notification factory class
 */
class Factory {

	/**
	 * Get the notification object.
	 *
	 * @param  int $notification_id Notification ID to get.
	 * @return Notification|bool
	 */
	public static function get_notification( int $notification_id ) {

		if ( ! $notification_id ) {
			return false;
		}

		try {
			$notification = new Notification( $notification_id );
			return $notification;
		} catch ( \Exception $e ) {
			\wc_caught_exception( $e, __FUNCTION__, array( $notification_id ) );
			return false;
		}
	}

	/**
	 * Create a dummy notification for preview/testing purposes.
	 *
	 * @return Notification
	 */
	public static function create_dummy_notification(): Notification {
		$notification = new Notification();

		// Create a dummy product.
		$product = new \WC_Product();
		$product->set_name( __( 'Dummy Product', 'woocommerce' ) );
		$product->set_price( 25 );
		$product->set_image_id( get_option( 'woocommerce_placeholder_image', 0 ) );

		// Set required notification data.
		$notification->set_product_id( $product->get_id() );
		$notification->set_user_email( 'preview@example.com' );

		// Store the dummy product in the notification object for preview.
		$notification->product = $product;

		return $notification;
	}
}
PK     [1]=qf    6  StockNotifications/Frontend/ProductPageIntegration.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\Frontend;

use Automattic\WooCommerce\Internal\StockNotifications\Config;
use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EligibilityService;
use Automattic\WooCommerce\Internal\StockNotifications\Frontend\SignupService;
use Automattic\WooCommerce\Internal\StockNotifications\Notification;
use WC_Product;

/**
 * Class for integrating with the product page.
 */
class ProductPageIntegration {

	/**
	 * Runtime cache for preventing double rendering.
	 *
	 * @var array<int, bool>
	 */
	private array $rendered = array();

	/**
	 * The eligibility service instance.
	 *
	 * @var EligibilityService
	 */
	private EligibilityService $eligibility_service;

	/**
	 * The signup service instance.
	 *
	 * @var SignupService
	 */
	private SignupService $signup_service;

	/**
	 * Init.
	 *
	 * @internal
	 *
	 * @param EligibilityService $eligibility_service The eligibility service instance.
	 * @param SignupService      $signup_service The signup service instance.
	 */
	final public function init( EligibilityService $eligibility_service, SignupService $signup_service ): void {
		$this->eligibility_service = $eligibility_service;
		$this->signup_service      = $signup_service;
	}

	/**
	 * Constructor.
	 */
	public function __construct() {
		add_action( 'woocommerce_simple_add_to_cart', array( $this, 'maybe_render_form' ), 30 );
		add_action( 'woocommerce_after_add_to_cart_form', array( $this, 'maybe_render_form' ), 30 );
	}

	/**
	 * Handle BIS form.
	 *
	 * @return void
	 */
	public function maybe_render_form() {

		if ( ! Config::allows_signups() ) {
			return;
		}

		global $product;
		if ( ! is_product() || ! is_a( $product, 'WC_Product' ) ) {
			return;
		}

		if ( isset( $this->rendered[ $product->get_id() ] ) ) {
			return;
		}

		$this->rendered[ $product->get_id() ] = true;

		$is_variable = $product->is_type( 'variable' );
		// Check if the product is in stock.
		// Hint: This is negative logic. If the product is eligible for notifications, skip rendering.
		// We avoid checking for variable products here because we want to render the form for out of stock variations.
		if ( ! $is_variable && $this->eligibility_service->is_stock_status_eligible( $product->get_stock_status() ) ) {
			return;
		}

		if ( ! $this->eligibility_service->is_product_eligible( $product ) ) {
			return;
		}

		if ( ! $this->eligibility_service->product_allows_signups( $product ) ) {
			return;
		}

		// Enqueue the script.
		wp_enqueue_script( 'wc-back-in-stock-form' );

		$this->render_form( $product );
	}

	/**
	 * Render the form.
	 *
	 * @param WC_Product $product Product object.
	 * @return void
	 */
	private function render_form( WC_Product $product ): void {

		// Check if requires account.
		if ( Config::requires_account() && ! is_user_logged_in() ) {
			$this->display_account_required( $product );
			return;
		}

		// Check if already signed up.
		if ( $this->is_personalization_enabled() && is_user_logged_in() ) {
			$user         = \get_user_by( 'id', \get_current_user_id() );
			$notification = $this->signup_service->is_already_signed_up( $product->get_id(), $user->ID, $user->user_email );
			if ( $notification instanceof Notification ) {
				$this->display_already_signed_up( $product, $notification );
				return;
			}
		}

		$this->display_form( $product );
	}

	/**
	 * Display the account required message.
	 *
	 * @param WC_Product $product Product object.
	 * @return void
	 */
	public function display_account_required( WC_Product $product ): void {

		/**
		 * Filter the account required message HTML.
		 *
		 * @since 10.2.0
		 *
		 * @param string|null $pre The message.
		 * @param WC_Product  $product Product object.
		 * @return string|null The message.
		 */
		$pre = apply_filters( 'woocommerce_customer_stock_notifications_account_required_message_html', null, $product );
		if ( ! is_null( $pre ) ) {
			echo wp_kses_post( $pre );
			return;
		}

		$text = __( 'Please {login_link} to sign up for stock notifications.', 'woocommerce' );
		$text = str_replace( '{login_link}', '<a href="' . wc_get_account_endpoint_url( 'my-account' ) . '">' . _x( 'log in', 'back in stock form', 'woocommerce' ) . '</a>', $text );
		wc_print_notice( $text, 'notice' );
	}

	/**
	 * Display the already signed up message.
	 *
	 * @param WC_Product   $product Product object.
	 * @param Notification $notification Notification object.
	 * @return void
	 */
	public function display_already_signed_up( WC_Product $product, Notification $notification ): void {

		/**
		 * Filter the already signed up message HTML.
		 *
		 * @since 10.2.0
		 *
		 * @param string|null  $pre The message.
		 * @param WC_Product   $product Product object.
		 * @param Notification $notification Notification object.
		 * @return string|null The message.
		 */
		$pre = apply_filters( 'woocommerce_customer_stock_notifications_already_signed_up_message_html', null, $product, $notification );
		if ( ! is_null( $pre ) ) {
			echo wp_kses_post( $pre );
			return;
		}

		$text = __( 'You have already joined the waitlist! Click {manage_account_link} to manage your notifications.', 'woocommerce' );
		$text = str_replace( '{manage_account_link}', '<a href="' . wc_get_account_endpoint_url( 'stock-notifications' ) . '">' . _x( 'here', 'back in stock form', 'woocommerce' ) . '</a>', $text );
		wc_print_notice( $text, 'notice' );
	}

	/**
	 * Display the form.
	 *
	 * @param WC_Product $product Product object.
	 * @return void
	 */
	public function display_form( WC_Product $product ): void {

		$button_class = implode(
			' ',
			array_filter(
				array(
					'button',
					\wc_wp_theme_get_element_class_name( 'button' ),
					'wc_bis_form__button',
				)
			)
		);

		// When a variable has no purchasable variations, allow for signups on the parent product.
		$is_visible = ! $product->is_type( 'variable' ) || ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) && ! $product->has_purchasable_variations() );

		wc_get_template(
			'single-product/back-in-stock-form.php',
			array(
				'product_id'       => $product->get_parent_id() ? $product->get_parent_id() : $product->get_id(),
				'show_checkbox'    => ! is_user_logged_in() && Config::creates_account_on_signup() && ! Config::requires_account(),
				'show_email_field' => ! is_user_logged_in() && ! Config::requires_account(),
				'button_class'     => $button_class,
				'is_visible'       => $is_visible,
			)
		);
	}

	/**
	 * Whether personalization is enabled.
	 *
	 * Personalization includes checking if the user is already signed up and displaying the 'already signed up' message.
	 *
	 * @return bool True if personalization is enabled, false otherwise.
	 */
	public static function is_personalization_enabled(): bool {

		/**
		 * Filter whether personalization is enabled while rendering the form.
		 *
		 * @since 10.2.0
		 *
		 * @param bool $enabled Whether personalization is enabled.
		 * @return bool
		 */
		return (bool) apply_filters( 'woocommerce_customer_stock_notifications_personalization_enabled', false );
	}
}
PK     [1]a^Y    =  StockNotifications/Frontend/NotificationManagementService.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\Frontend;

use Automattic\WooCommerce\Internal\StockNotifications\Notification;

/**
 * Notification management service.
 */
class NotificationManagementService {

	/**
	 * Get resend verification email URL.
	 *
	 * @param Notification $notification The notification.
	 * @return string The resend verification email URL.
	 */
	public function get_resend_verification_email_url( Notification $notification ): string {
		$url = add_query_arg(
			array(
				'wc_bis_resend_notification' => $notification->get_id(),
			),
			$notification->get_product_permalink()
		);

		return wp_nonce_url(
			$url,
			'wc_bis_resend_verification_email_nonce'
		);
	}
}
PK     [1]TX'    ,  StockNotifications/Frontend/SignupResult.phpnu         <?php
declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\StockNotifications\Frontend;

use Automattic\WooCommerce\Internal\StockNotifications\Notification;

/**
 * A class for representing the result of a signup.
 *
 * @internal
 */
class SignupResult {

	/**
	 * The signup code.
	 *
	 * @var string
	 */
	private string $code;

	/**
	 * The notification.
	 *
	 * @var Notification|null
	 */
	private ?Notification $notification;

	/**
	 * Constructor.
	 *
	 * @param string            $code The signup code.
	 * @param Notification|null $notification The notification.
	 */
	public function __construct( string $code, ?Notification $notification = null ) {
		$this->code         = $code;
		$this->notification = $notification;
	}

	/**
	 * Get the signup code.
	 *
	 * @return string
	 */
	public function get_code(): string {
		return $this->code;
	}

	/**
	 * Get the notification.
	 *
	 * @return Notification|null
	 */
	public function get_notification(): ?Notification {
		return $this->notification;
	}
}
PK     [1]6[  [  2  StockNotifications/Frontend/FormHandlerService.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\Frontend;

use Automattic\WooCommerce\Internal\StockNotifications\Config;

/**
 * Class for handling the form submission.
 */
class FormHandlerService {

	/**
	 * The signup service.
	 *
	 * @var SignupService
	 */
	private SignupService $signup_service;

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

	/**
	 * Initialize the service.
	 *
	 * @internal
	 *
	 * @param SignupService $signup_service The signup service.
	 */
	final public function init( SignupService $signup_service ) {
		$this->signup_service = $signup_service;
		$this->logger         = \wc_get_logger();
	}

	/**
	 * Constructor.
	 */
	public function __construct() {
		add_action( 'template_redirect', array( $this, 'handle_signup' ) );
	}

	/**
	 * Handle the form submit event.
	 */
	public function handle_signup() {

		// Sanity checks.
		if ( ! Config::allows_signups() ) {
			return;
		}

		if ( ! isset( $_POST['wc_bis_register'] ) ) { // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended
			return;
		}

		try {

			if ( self::requires_nonce_check() ) {
				if ( ! isset( $_POST['wc_bis_nonce'] ) || ! wp_verify_nonce( wp_unslash( $_POST['wc_bis_nonce'] ), 'wc_bis_signup' ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
					wc_add_notice( $this->signup_service->get_error_message( SignupService::ERROR_INVALID_REQUEST ), 'error' );
					return;
				}
			}

			$data = $this->signup_service->parse( $_POST );
			if ( \is_wp_error( $data ) ) {
				wc_add_notice( $this->signup_service->get_error_message( $data->get_error_code() ), 'error' );
				return;
			}

			$result = $this->signup_service->signup(
				$data['product_id'],
				$data['user_id'],
				$data['user_email'],
				$data['posted_attributes'] ?? array()
			);

			if ( \is_wp_error( $result ) ) {
				wc_add_notice( $this->signup_service->get_error_message( $result->get_error_code() ), 'error' );
				return;
			}

			wc_add_notice( $this->signup_service->get_signup_user_message( $result->get_code(), $result->get_notification() ), 'success' );
		} catch ( \Throwable $e ) {
			wc_add_notice( $this->signup_service->get_error_message( SignupService::ERROR_FAILED ), 'error' );
			$this->logger->error( $e->getMessage(), array( 'source' => 'stock-notifications-signup-errors' ) );
			return;
		}
	}

	/**
	 * Whether the form requires a nonce check.
	 *
	 * Note: Nonce checks may be disabled for guest signups to support HTML caching.
	 *
	 * @return bool True if the form requires a nonce check, false otherwise.
	 */
	public static function requires_nonce_check(): bool {

		$requires_account = ProductPageIntegration::is_personalization_enabled() && ( Config::requires_account() || \is_user_logged_in() );

		/**
		 * Filter to require nonce check.
		 *
		 * @since 10.2.0
		 *
		 * @param bool $requires_nonce_check Whether to require nonce check.
		 * @return bool
		 */
		return (bool) apply_filters( 'woocommerce_customer_stock_notifications_requires_nonce_check', $requires_account );
	}
}
PK     [1]]_G  _G  -  StockNotifications/Frontend/SignupService.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\Frontend;

use Automattic\WooCommerce\Internal\StockNotifications\Config;
use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
use Automattic\WooCommerce\Internal\StockNotifications\Factory;
use Automattic\WooCommerce\Internal\StockNotifications\Notification;
use Automattic\WooCommerce\Internal\StockNotifications\NotificationQuery;
use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EligibilityService;

/**
 * A class for handling the business logic of the signup process.
 *
 * @internal
 */
class SignupService {

	// phpcs:disable
	public const SIGNUP_ALREADY_JOINED                        = 'already_joined';
	public const SIGNUP_ALREADY_JOINED_DOUBLE_OPT_IN          = 'already_joined_double_opt_in';
	public const SIGNUP_SUCCESS                               = 'success';
	public const SIGNUP_SUCCESS_ACCOUNT_CREATED               = 'success_account_created';
	public const SIGNUP_SUCCESS_ACCOUNT_CREATED_DOUBLE_OPT_IN = 'success_account_created_double_opt_in';
	public const SIGNUP_SUCCESS_DOUBLE_OPT_IN                 = 'success_double_opt_in';

	public const ERROR_FAILED           = 'failed_to_signup';
	public const ERROR_INVALID_REQUEST  = 'invalid_request';
	public const ERROR_INVALID_PRODUCT  = 'invalid_product';
	public const ERROR_REQUIRES_ACCOUNT = 'requires_account';
	public const ERROR_RATE_LIMITED     = 'rate_limited';
	public const ERROR_INVALID_USER     = 'invalid_user';
	public const ERROR_INVALID_EMAIL    = 'invalid_email';
	public const ERROR_INVALID_OPT_IN   = 'invalid_opt_in';
	// phpcs:enable

	/**
	 * Eligibility service.
	 *
	 * @var EligibilityService
	 */
	private EligibilityService $eligibility_service;

	/**
	 * Notification management service.
	 *
	 * @var NotificationManagementService
	 */
	private NotificationManagementService $notification_management_service;

	/**
	 * Init the service.
	 *
	 * @internal
	 *
	 * @param EligibilityService            $eligibility_service The eligibility service.
	 * @param NotificationManagementService $notification_management_service The notification management service.
	 */
	final public function init( EligibilityService $eligibility_service, NotificationManagementService $notification_management_service ) {
		$this->eligibility_service             = $eligibility_service;
		$this->notification_management_service = $notification_management_service;
	}

	/**
	 * Signup.
	 *
	 * @param int    $product_id The product ID.
	 * @param int    $user_id The user ID.
	 * @param string $user_email The user email.
	 * @param array  $posted_attributes The posted attributes (Optional).
	 * @return SignupResult|\WP_Error The signup result.
	 */
	public function signup( int $product_id, int $user_id, string $user_email, array $posted_attributes = array() ) {

		// Sanity checks.
		if ( ! Config::allows_signups() ) {
			return new \WP_Error( self::ERROR_FAILED );
		}

		if ( empty( $user_email ) && empty( $user_id ) ) {
			return new \WP_Error( self::ERROR_INVALID_REQUEST );
		}

		$product = wc_get_product( $product_id );
		if ( ! $product ) {
			return new \WP_Error( self::ERROR_INVALID_PRODUCT );
		}

		if ( ! $this->eligibility_service->is_product_eligible( $product ) ) {
			return new \WP_Error( self::ERROR_INVALID_PRODUCT );
		}

		if ( $this->eligibility_service->is_stock_status_eligible( $product->get_stock_status() ) ) {
			return new \WP_Error( self::ERROR_INVALID_REQUEST );
		}

		if ( ! $this->eligibility_service->product_allows_signups( $product ) ) {
			return new \WP_Error( self::ERROR_INVALID_PRODUCT );
		}

		$notification = $this->is_already_signed_up( $product_id, $user_id, $user_email, $posted_attributes );
		if ( $notification instanceof Notification ) {
			if ( NotificationStatus::ACTIVE === $notification->get_status() ) {
				return new SignupResult( self::SIGNUP_ALREADY_JOINED, $notification );
			}

			if ( NotificationStatus::PENDING === $notification->get_status() ) {
				if ( Config::requires_double_opt_in() ) {
					return new SignupResult( self::SIGNUP_ALREADY_JOINED_DOUBLE_OPT_IN, $notification );
				}

				// If the notification is pending and double opt-in is not required, skip and activate the notification.
				$notification->set_status( NotificationStatus::ACTIVE );
				$notification->save();

				/**
				 * Action: woocommerce_customer_stock_notifications_signup
				 *
				 * @since 10.2.0
				 *
				 * @param Notification $notification The notification.
				 */
				do_action( 'woocommerce_customer_stock_notifications_signup', $notification );
				return new SignupResult( self::SIGNUP_SUCCESS, $notification );
			}
		}

		$account_created = null;
		if ( empty( $user_id ) && Config::creates_account_on_signup() ) {
			$account_created = $this->create_customer( $user_email );
			$user_id         = $account_created ? $account_created : $user_id;
		}

		$notification = new Notification();
		$notification->set_status( NotificationStatus::ACTIVE );
		$notification->set_product_id( $product_id );
		$notification->set_user_id( $user_id );
		$notification->set_user_email( $user_email );

		if ( ! empty( $posted_attributes ) ) {
			$notification->update_meta_data( 'posted_attributes', $posted_attributes );
		}

		if ( Config::requires_double_opt_in() ) {
			$notification->set_status( NotificationStatus::PENDING );
		}

		$saved = $notification->save();
		if ( ! $saved ) {
			return new \WP_Error( self::ERROR_FAILED );
		}

		/**
		 * Action: woocommerce_customer_stock_notifications_signup
		 *
		 * @since 10.2.0
		 *
		 * @param Notification $notification The notification.
		 */
		do_action( 'woocommerce_customer_stock_notifications_signup', $notification );

		$signup_code = self::SIGNUP_SUCCESS;
		if ( Config::requires_double_opt_in() ) {
			$signup_code = $account_created
				? self::SIGNUP_SUCCESS_ACCOUNT_CREATED_DOUBLE_OPT_IN
				: self::SIGNUP_SUCCESS_DOUBLE_OPT_IN;
		} elseif ( $account_created ) {
			$signup_code = self::SIGNUP_SUCCESS_ACCOUNT_CREATED;
		}
		return new SignupResult( $signup_code, $notification );
	}

	/**
	 * Get the active notification for the request data.
	 *
	 * @param int    $product_id The product ID.
	 * @param int    $user_id The user ID.
	 * @param string $user_email The user email.
	 * @param array  $posted_attributes The posted attributes (Optional).
	 * @return Notification|null The notification, or null if it doesn't exist.
	 */
	public function is_already_signed_up( int $product_id, int $user_id, string $user_email, array $posted_attributes = array() ) {

		if ( empty( $product_id ) ) {
			return null;
		}

		if ( empty( $user_id ) && empty( $user_email ) ) {
			return null;
		}

		$found = false;
		if ( ! empty( $user_id ) ) {
			$found = NotificationQuery::notification_exists_by_user_id( $product_id, $user_id );
		} else {
			$found = NotificationQuery::notification_exists_by_email( $product_id, $user_email );
		}

		if ( ! $found ) {
			return null;
		}

		$query_args = array( 'product_id' => $product_id );
		if ( ! empty( $user_id ) ) {
			$query_args['user_id'] = $user_id;
		} else {
			$query_args['user_email'] = $user_email;
		}

		$query_args['return'] = 'ids';
		$query_args['limit']  = 1;
		if ( ! empty( $posted_attributes ) ) {
			// Hint: We need to compare the posted attributes with the stored attributes to handle variations with "any" attributes.
			$query_args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
				array(
					'key'     => 'posted_attributes',
					'value'   => maybe_serialize( $posted_attributes ),
					'compare' => '=',
				),
			);
		}

		$ids = NotificationQuery::get_notifications( $query_args );
		if ( empty( $ids ) || ! is_numeric( $ids[0] ) ) {
			return null;
		}

		$notification = Factory::get_notification( $ids[0] );
		if ( ! $notification ) {
			return null;
		}

		return $notification;
	}

	/**
	 * Create a new customer.
	 *
	 * @param string $user_email The user email.
	 * @return int|null The user ID if the customer was created, null otherwise.
	 */
	private function create_customer( string $user_email ) {

		if ( empty( $user_email ) || ! is_email( $user_email ) ) {
			return null;
		}

		try {
			$username = wc_create_new_customer_username( $user_email );
			$username = sanitize_user( $username );
			if ( empty( $username ) || ! validate_username( $username ) ) {
				return null;
			}

			$password = 'yes' === get_option( 'woocommerce_registration_generate_password' ) ? '' : wp_generate_password();
			$user_id  = wc_create_new_customer( $user_email, $username, $password );
			if ( is_a( $user_id, 'WP_Error' ) ) {
				return null;
			}
		} catch ( \Throwable $e ) {
			return null;
		}

		return $user_id;
	}

	/**
	 * Parse the request data from a given source.
	 *
	 * @param array $source The source data, e.g. $_POST or $_REQUEST.
	 * @return array|\WP_Error {
	 *  The parsed request data, or a WP_Error if the request data is invalid.
	 *
	 *  @type int    $product_id The product ID.
	 *  @type int    $user_id The user ID.
	 *  @type string $user_email The user email.
	 *  @type array  $posted_attributes The posted attributes (Optional).
	 * }
	 */
	public function parse( array $source ) {

		$parsed_data = $this->parse_user_data( $source );
		if ( \is_wp_error( $parsed_data ) ) {
			return $parsed_data;
		}

		$product = $this->parse_product( $source );
		if ( \is_wp_error( $product ) ) {
			return $product;
		}

		$parsed_data['product_id'] = $product->get_id();
		if ( $product instanceof \WC_Product_Variation ) {
			$posted_attributes = $this->parse_posted_attributes( $source, $product );

			if ( ! empty( $posted_attributes ) ) {
				$parsed_data['posted_attributes'] = $posted_attributes;
			}
		}

		return $parsed_data;
	}

	/**
	 * Parse the user data from the source data.
	 *
	 * @param array $source The source data, e.g. $_POST or $_REQUEST.
	 * @return array|\WP_Error The parsed user data, or a WP_Error if the user data is invalid.
	 */
	private function parse_user_data( array $source ) {
		$data = array();

		$is_logged_in = \is_user_logged_in();
		if ( ! $is_logged_in && Config::requires_account() ) {
			return new \WP_Error( self::ERROR_REQUIRES_ACCOUNT );
		}

		// Check for valid privacy terms.
		if ( ! $is_logged_in && Config::creates_account_on_signup() && ! Config::requires_account() ) {
			$opt_in = isset( $source['wc_bis_opt_in'] ) ? wc_clean( wp_unslash( $source['wc_bis_opt_in'] ) ) : false;
			if ( 'on' !== $opt_in ) {
				return new \WP_Error( self::ERROR_INVALID_OPT_IN );
			}
		}

		if ( ! $is_logged_in ) {
			$email = isset( $source['wc_bis_email'] ) ? sanitize_email( wp_unslash( $source['wc_bis_email'] ) ) : false;
			if ( ! $email ) {
				return new \WP_Error( self::ERROR_INVALID_EMAIL );
			}

			if ( ! is_email( $email ) ) {
				return new \WP_Error( self::ERROR_INVALID_EMAIL );
			}

			$data['user_id']    = 0;
			$data['user_email'] = $email;

			// Check if user exists with this email.
			$user = get_user_by( 'email', $email );
			if ( $user ) {
				$data['user_id'] = $user->ID;
			}
		} else {
			$user = wp_get_current_user();
			if ( ! $user ) {
				return new \WP_Error( self::ERROR_INVALID_USER );
			}

			$data['user_id']    = $user->ID;
			$data['user_email'] = $user->user_email;
		}

		return $data;
	}

	/**
	 * Parse the product from the source data.
	 *
	 * @param array $source The source data, e.g. $_POST or $_REQUEST.
	 * @return \WC_Product|\WP_Error The product, or a WP_Error if the product is invalid.
	 */
	private function parse_product( array $source ) {
		$product_id = isset( $source['wc_bis_product_id'] ) ? absint( wp_unslash( $source['wc_bis_product_id'] ) ) : false;
		if ( ! $product_id ) {
			return new \WP_Error( self::ERROR_INVALID_PRODUCT );
		}

		$product = wc_get_product( $product_id );
		if ( ! $product instanceof \WC_Product ) {
			return new \WP_Error( self::ERROR_INVALID_PRODUCT );
		}

		if ( ! $this->eligibility_service->is_product_eligible( $product ) ) {
			return new \WP_Error( self::ERROR_INVALID_PRODUCT );
		}

		if ( ! $this->eligibility_service->product_allows_signups( $product ) ) {
			return new \WP_Error( self::ERROR_INVALID_PRODUCT );
		}

		return $product;
	}

	/**
	 * Parse variation attributes from source data.
	 *
	 * This method extracts attributes that are defined as 'any' in the variation and need to be
	 * explicitly specified during signup. These attributes cannot be retrieved directly from the variation
	 * since they are not fixed values.
	 *
	 * For example, if a t-shirt variation has 'any' size but a specific color, we need to capture
	 * the chosen size from the form submission while the color comes from the variation itself.
	 *
	 * @see \WC_Cart::add_to_cart() for similar attribute parsing logic.
	 *
	 * @param array       $source The source data, e.g. $_POST or $_REQUEST.
	 * @param \WC_Product $variation The variation.
	 * @return array The posted attributes.
	 */
	private function parse_posted_attributes( array $source, \WC_Product $variation ): array {

		if ( ! $variation instanceof \WC_Product_Variation ) {
			return array();
		}

		$product = wc_get_product( $variation->get_parent_id() );
		if ( ! $product ) {
			return array();
		}

		$posted_attributes = array();
		foreach ( $product->get_attributes() as $attribute ) {
			if ( ! $attribute['is_variation'] ) {
				continue;
			}

			$attribute_key = 'attribute_' . sanitize_title( $attribute['name'] );
			if ( isset( $source[ $attribute_key ] ) ) {
				if ( $attribute['is_taxonomy'] ) {
					$value = sanitize_title( wp_unslash( $source[ $attribute_key ] ) );
				} else {
					$value = html_entity_decode( wc_clean( wp_unslash( $source[ $attribute_key ] ) ), ENT_QUOTES, get_bloginfo( 'charset' ) );
				}

				// Don't include if it's empty.
				if ( ! empty( $value ) || '0' === $value ) {
					$posted_attributes[ $attribute_key ] = $value;
				}
			}
		}

		$variation_attributes = $variation->get_variation_attributes();
		// Filter out 'any' variations, which are empty.
		$variation_attributes = array_filter( $variation_attributes );
		$diff                 = array_diff( $posted_attributes, $variation_attributes );

		// Return the posted attributes only if a variation with `any` attribute is detected.
		return ! empty( $diff ) ? $diff : array();
	}

	/**
	 * Get the error message for the error code.
	 *
	 * @param string $error_code The error code.
	 * @return string The error message.
	 */
	public function get_error_message( string $error_code ): string {
		switch ( $error_code ) {
			case self::ERROR_INVALID_PRODUCT:
				return wp_kses_post( __( 'Invalid product.', 'woocommerce' ) );
			case self::ERROR_INVALID_USER:
				return wp_kses_post( __( 'Invalid user.', 'woocommerce' ) );
			case self::ERROR_INVALID_EMAIL:
				return wp_kses_post( __( 'Invalid email address.', 'woocommerce' ) );
			case self::ERROR_INVALID_OPT_IN:
				return wp_kses_post( __( 'To proceed, please consent to the creation of a new account with your e-mail.', 'woocommerce' ) );
			case self::ERROR_RATE_LIMITED:
				return wp_kses_post( __( 'You have already signed up too many times. Please try again later.', 'woocommerce' ) );
			default:
				return wp_kses_post( __( 'Failed to sign up. Please try again.', 'woocommerce' ) );
		}
	}

	/**
	 * Get the signup user message for the signup code.
	 *
	 * @param string       $signup_code The signup code.
	 * @param Notification $notification The notification.
	 * @return string The signup user message.
	 */
	public function get_signup_user_message( string $signup_code, Notification $notification ): string {
		$message           = '';
		$has_action_button = false;
		switch ( $signup_code ) {

			case self::SIGNUP_SUCCESS:
				/* translators: Product name */
				$message = sprintf( esc_html__( 'You have successfully signed up! You will be notified when "%s" is back in stock.', 'woocommerce' ), $notification->get_product_name() );
				break;

			case self::SIGNUP_SUCCESS_DOUBLE_OPT_IN:
				$message = esc_html__( 'Thanks for signing up! Please complete the sign-up process by following the verification link sent to your e-mail.', 'woocommerce' );
				break;

			case self::SIGNUP_SUCCESS_ACCOUNT_CREATED:
				/* translators: Product name */
				$message = sprintf( esc_html__( 'You have successfully signed up and will be notified when "%s" is back in stock! Note that a new account has been created for you; please check your e-mail for details.', 'woocommerce' ), $notification->get_product_name() );
				break;

			case self::SIGNUP_SUCCESS_ACCOUNT_CREATED_DOUBLE_OPT_IN:
				$message = esc_html__( 'Thanks for signing up! An account has been created for you. Please complete the sign-up process by following the verification link sent to your e-mail.', 'woocommerce' );
				break;

			case self::SIGNUP_ALREADY_JOINED:
				$message = esc_html__( 'You have already joined this waitlist.', 'woocommerce' );
				break;

			case self::SIGNUP_ALREADY_JOINED_DOUBLE_OPT_IN:
				$notice_text     = esc_html__( 'You have already joined this waitlist. Please complete the sign-up process by following the verification link sent to your e-mail.', 'woocommerce' );
				$url             = $this->notification_management_service->get_resend_verification_email_url( $notification );
				$button_class    = wc_wp_theme_get_element_class_name( 'button' );
				$wp_button_class = $button_class ? ' ' . $button_class : '';
				$message         = sprintf(
					'<a href="%s" class="button wc-forward%s">%s</a> %s',
					$url,
					$wp_button_class,
					esc_html_x( 'Resend verification', 'notice action', 'woocommerce' ),
					$notice_text
				);

				$has_action_button = true;
				break;
			default:
				$message = '';
				break;
		}

		if ( is_user_logged_in() && ! $has_action_button ) {
			$button_class    = \wc_wp_theme_get_element_class_name( 'button' );
			$wp_button_class = $button_class ? ' ' . $button_class : '';
			$message         = sprintf( '<a href="%s" class="button wc-forward%s">%s</a> %s', \wc_get_account_endpoint_url( 'stock-notifications' ), $wp_button_class, esc_html_x( 'Manage notifications', 'notice action', 'woocommerce' ), $message );
		}

		return $message;
	}
}
PK     [1]CR	  	  )  StockNotifications/StockNotifications.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications;

use Automattic\WooCommerce\Internal\DataStores\StockNotifications\StockNotificationsDataStore;
use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailActionController;
use Automattic\WooCommerce\Internal\StockNotifications\StockSyncController;
use Automattic\WooCommerce\Internal\StockNotifications\Privacy\PrivacyEraser;
use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailManager;
use Automattic\WooCommerce\Internal\StockNotifications\AsyncTasks\NotificationsProcessor;
use Automattic\WooCommerce\Internal\StockNotifications\Admin\AdminManager;
use Automattic\WooCommerce\Internal\StockNotifications\Frontend\ProductPageIntegration;
use Automattic\WooCommerce\Internal\StockNotifications\Frontend\FormHandlerService;

/**
 * The controller for the stock notifications.
 */
class StockNotifications {

	/**
	 * Initialize the controller.
	 */
	public function __construct() {
		add_action( 'plugins_loaded', array( $this, 'init_hooks' ) );
		add_action( 'woocommerce_installed', array( $this, 'on_install_or_update' ) );
	}

	/**
	 * Handle the WooCommerce installation event.
	 *
	 * This method is called when WooCommerce is installed or updated.
	 * It initializes the data retention controller to set up necessary tasks.
	 */
	public function on_install_or_update() {
		wc_get_container()->get( DataRetentionController::class )->on_woo_install_or_update();
	}

	/**
	 * Register hooks and services.
	 *
	 * @internal
	 */
	public function init_hooks() {
		add_filter( 'woocommerce_data_stores', array( $this, 'register_data_stores' ) );

		$container = wc_get_container();
		$container->get( EmailManager::class );
		$container->get( StockSyncController::class );
		$container->get( NotificationsProcessor::class );
		$container->get( PrivacyEraser::class );
		$container->get( DataRetentionController::class );
		$container->get( EmailActionController::class );

		$container->get( ProductPageIntegration::class );
		$container->get( FormHandlerService::class );

		if ( is_admin() ) {
			$container->get( AdminManager::class );
		}
	}

	/**
	 * Register the data stores.
	 *
	 * @param array $data_stores Data stores.
	 * @return array
	 */
	public function register_data_stores( $data_stores ) {
		if ( ! is_array( $data_stores ) ) {
			return $data_stores;
		}

		$data_stores['stock_notification'] = wc_get_container()->get( StockNotificationsDataStore::class );
		return $data_stores;
	}
}
PK     [1]i/-b  b  .  StockNotifications/DataRetentionController.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications;

use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;

/**
 * Controller for managing data retention of customer stock notifications.
 *
 * This controller handles the scheduling and execution of tasks related to
 * deleting overdue notifications based on a configured time threshold.
 */
class DataRetentionController {
	public const DAILY_TASK_HOOK = 'customer_stock_notifications_daily';

	/**
	 * Constructor to set up hooks for managing data retention tasks.
	 */
	public function __construct() {
		add_action( self::DAILY_TASK_HOOK, array( $this, 'do_wc_customer_stock_notifications_daily' ) );
		add_action( 'update_option_woocommerce_customer_stock_notifications_unverified_deletions_days_threshold', array( $this, 'schedule_or_unschedule_daily_task' ), 10, 2 );
		add_action( 'add_option_woocommerce_customer_stock_notifications_unverified_deletions_days_threshold', array( $this, 'schedule_or_unschedule_daily_task' ), 10, 2 );
		register_deactivation_hook( WC_PLUGIN_FILE, array( $this, 'clear_daily_task' ) );
	}

	/**
	 * Tasks to run when WooCommerce is installed or updated.
	 *
	 * @return void
	 */
	public function on_woo_install_or_update(): void {
		$this->schedule_or_unschedule_daily_task( null, Config::get_unverified_deletion_days_threshold() );
	}

	/**
	 * Responds to changes in the option for deleting unverified notifications.
	 * If the new value is numeric and greater than zero, it schedules a daily task.
	 * If the new value is not numeric or is empty, it clears the scheduled tasks.
	 *
	 * @param mixed $unused The old option value or option name (not used in this function).
	 * @param mixed $new_option_value The new value of the option.
	 * @return void
	 */
	public function schedule_or_unschedule_daily_task( $unused, $new_option_value ): void {
		if ( ! is_numeric( $new_option_value ) || empty( $new_option_value ) ) {
			$this->clear_daily_task();
			return;
		}

		if ( ! wp_next_scheduled( self::DAILY_TASK_HOOK ) ) {
			wp_schedule_event( time() + 10, 'daily', self::DAILY_TASK_HOOK );
		}
	}

	/**
	 * Unschedule the daily task when the plugin is deactivated, or the option is set to zero.
	 */
	public function clear_daily_task() {
		wp_clear_scheduled_hook( self::DAILY_TASK_HOOK );
	}

	/**
	 * Deletes overdue notifications based on the configured time threshold.
	 * It retrieves notifications that are pending and past the threshold,
	 * then deletes them.
	 *
	 * @return void
	 */
	public function do_wc_customer_stock_notifications_daily() {
		$time_threshold = Config::get_unverified_deletion_days_threshold();

		if ( 0 === $time_threshold ) {
			return;
		}
		$overdue_threshold = time() - $time_threshold * DAY_IN_SECONDS;

		$overdue_notifications = NotificationQuery::get_notifications(
			array(
				'status'   => NotificationStatus::PENDING,
				'end_date' => gmdate( 'Y-m-d H:i:s', $overdue_threshold ),
			)
		);

		foreach ( $overdue_notifications as $notification_id ) {
			$notification = Factory::get_notification( $notification_id );
			$notification->delete();
		}
	}
}
PK     [1]?Z`  `  3  StockNotifications/Utilities/EligibilityService.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\Utilities;

use Automattic\WooCommerce\Internal\StockNotifications\Notification;
use Automattic\WooCommerce\Internal\StockNotifications\Config;
use Automattic\WooCommerce\Internal\StockNotifications\NotificationQuery;
use Automattic\WooCommerce\Internal\StockNotifications\Utilities\StockManagementHelper;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Enums\ProductStatus;
use WC_Product;

/**
 * EligibilityService class file.
 */
class EligibilityService {

	/**
	 * The spam threshold for notifications.
	 *
	 * @var int
	 */
	public const SPAM_THRESHOLD = 60 * 60 * 24; // 24 hours.

	/**
	 * The stock management helper instance.
	 *
	 * @var StockManagementHelper
	 */
	private StockManagementHelper $stock_management_helper;

	/**
	 * Init.
	 *
	 * @internal
	 *
	 * @param StockManagementHelper $stock_management_helper The stock management helper instance.
	 */
	final public function init( StockManagementHelper $stock_management_helper ): void {
		$this->stock_management_helper = $stock_management_helper;
	}

	/**
	 * Validate product type and other basic criteria for notifications.
	 *
	 * @param WC_Product|null $product The product to check.
	 * @return bool True if the product is eligible for notifications, false otherwise.
	 */
	public function is_product_eligible( ?WC_Product $product ): bool {
		if ( ! $product instanceof WC_Product ) {
			return false;
		}

		if ( ! $product->is_type( Config::get_supported_product_types() ) ) {
			return false;
		}

		// Check for invalid product statuses.
		if ( in_array( $product->get_status(), array( ProductStatus::TRASH, ProductStatus::AUTO_DRAFT, ProductStatus::PENDING, ProductStatus::FUTURE ), true ) ) {
			return false;
		}

		/**
		 * Filter: woocommerce_customer_stock_notifications_product_is_valid
		 * Allows custom validation for whether a product is generally eligible for notifications.
		 *
		 * @since 10.2.0
		 *
		 * @param bool $is_valid True if the product is valid for notifications, false otherwise.
		 * @param WC_Product $product The product to check.
		 * @return bool True if the product is valid for notifications, false otherwise.
		 */
		return (bool) apply_filters( 'woocommerce_customer_stock_notifications_product_is_valid', true, $product );
	}

	/**
	 * Check if a product allows signups.
	 *
	 * @param WC_Product $product The product to check.
	 * @return bool True if the product allows signups, false otherwise.
	 */
	public function product_allows_signups( WC_Product $product ): bool {
		if ( $product->is_type( ProductType::VARIATION ) ) {
			$parent_product = wc_get_product( $product->get_parent_id() );
			if ( ! $parent_product instanceof WC_Product ) {
				return false;
			}

			return $this->product_allows_signups( $parent_product );
		}

		return 'no' !== $product->get_meta( Config::get_product_signups_meta_key() );
	}

	/**
	 * Check if a stock status is eligible for notifications.
	 *
	 * @param string $stock_status The stock status to check.
	 * @return bool True if the stock status is eligible for notifications, false otherwise.
	 */
	public function is_stock_status_eligible( string $stock_status ): bool {
		return in_array( $stock_status, Config::get_eligible_stock_statuses(), true );
	}

	/**
	 * Check if a product (or its relevant variations) has any active notifications.
	 *
	 * @param WC_Product $product The product to check.
	 * @return bool True if the product has active notifications, false otherwise.
	 */
	public function has_active_notifications( WC_Product $product ): bool {
		$lookup_ids = $this->get_target_product_ids( $product );

		if ( empty( $lookup_ids ) ) {
			return false;
		}

		return NotificationQuery::product_has_active_notifications( $lookup_ids );
	}

	/**
	 * Get the product IDs that need to be checked for stock notifications.
	 *
	 * For simple products, this returns just the product ID. For variable products,
	 * it returns both the parent product ID and the IDs of all variations whose stock
	 * is managed by the parent product.
	 *
	 * This is used in two key scenarios:
	 * 1. Checking if a product has any active notifications
	 * 2. Determining which notifications need to be sent during a stock broadcast
	 *
	 * @since 10.2.0
	 *
	 * @param WC_Product $product The product to check.
	 * @return array<int> Array of product IDs to check for notifications.
	 */
	public function get_target_product_ids( WC_Product $product ): array {
		$lookup_ids = array( $product->get_id() );
		if ( $product->is_type( ProductType::VARIABLE ) ) {
			$children_ids = $this->stock_management_helper->get_managed_variations( $product );
			$lookup_ids   = array_merge( $lookup_ids, $children_ids );
		}

		return $lookup_ids;
	}

	/**
	 * Check if a notification is eligible for sending.
	 *
	 * @param Notification $notification The notification to check.
	 * @param WC_Product   $product The product to check.
	 * @return bool True if the notification is eligible for sending, false otherwise.
	 */
	public function should_skip_notification( Notification $notification, WC_Product $product ): bool {
		$is_throttled         = $this->is_notification_throttled( $notification );
		$is_product_published = in_array( $product->get_status(), Config::get_supported_product_statuses(), true );
		$should_skip          = $is_throttled || ! $is_product_published;

		// Bypass for privileged users.
		if ( $should_skip ) {
			$user_id = $notification->get_user_id();
			if ( $user_id ) {
				$user = get_user_by( 'id', $user_id );
				if ( $user && ( user_can( $user, 'manage_woocommerce' ) || user_can( $user, 'manage_options' ) ) ) {
					$should_skip = false;
				}
			}
		}

		/**
		 * Filter: woocommerce_customer_stock_notification_should_skip_sending
		 *
		 * @since 10.2.0
		 *
		 * Prevent or manage sending a specific notification.
		 *
		 * @param bool $should_skip Whether to skip sending.
		 * @param int  $notification_id The notification ID.
		 * @return bool
		 */
		return (bool) apply_filters( 'woocommerce_customer_stock_notification_should_skip_sending', $should_skip, $notification->get_id() );
	}

	/**
	 * Check if notification is throttled.
	 *
	 * @param Notification $notification The notification object.
	 * @return bool
	 */
	private function is_notification_throttled( Notification $notification ): bool {

		/**
		 * Filter: woocommerce_customer_stock_notification_throttle_threshold
		 *
		 * @since 10.2.0
		 *
		 * @param int $threshold Throttle time in seconds should pass from the last notification delivery time.
		 */
		$threshold = (int) apply_filters( 'woocommerce_customer_stock_notification_throttle_threshold', self::SPAM_THRESHOLD );
		if ( $threshold <= 0 ) {
			return false;
		}

		$last_notified = $notification->get_date_notified();
		$is_throttled  = $last_notified instanceof \WC_DateTime && $last_notified->getTimestamp() > ( time() - $threshold );

		return $is_throttled;
	}
}
PK     [1])v    -  StockNotifications/Utilities/HasherHelper.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\Utilities;

/**
 * Helper class for hashing.
 *
 * Hint: This is a copy of the hashing functions introduced in WordPress 6.8.
 * Once WooCommerce Core requires WordPress 6.8, we can remove/replace this class.
 *
 * @internal
 */
class HasherHelper {

	/**
	 * Hash a string.
	 *
	 * @param string $key The string to hash.
	 * @return string The hashed string.
	 */
	public static function wp_fast_hash( string $key ): string {
		if ( function_exists( 'wp_fast_hash' ) ) {
			return wp_fast_hash( $key );
		}

		$hashed = sodium_crypto_generichash( $key, 'wp_fast_hash_6.8+', 30 );
		return '$generic$' . sodium_bin2base64( $hashed, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING );
	}

	/**
	 * Verify a string.
	 *
	 * @param string $key The string to verify.
	 * @param string $hash The hash to verify.
	 * @return bool Whether the string matches the hash.
	 */
	public static function wp_verify_fast_hash( string $key, string $hash ): bool {
		if ( function_exists( 'wp_verify_fast_hash' ) ) {
			return wp_verify_fast_hash( $key, $hash );
		}

		if ( ! str_starts_with( $hash, '$generic$' ) ) {
			return false;
		}

		return hash_equals( $hash, self::wp_fast_hash( $key ) );
	}
}
PK     [1]W    6  StockNotifications/Utilities/StockManagementHelper.phpnu         <?php
declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\StockNotifications\Utilities;

use Automattic\WooCommerce\Enums\ProductType;
use WC_Product;

defined( 'ABSPATH' ) || exit;

/**
 * Utility class for stock management related queries.
 */
class StockManagementHelper {

	/**
	 * Runtime cache for managed variations.
	 *
	 * @var array<int, array<int>>
	 */
	private array $managed_variations = array();

	/**
	 * Get a list of variations that inherit stock management from the parent.
	 *
	 * If the product is a variable product, we need sync the children that don't manage stock.
	 *
	 * @param WC_Product $product The product to check.
	 * @return array<int> Array of variation IDs that inherit stock management from the parent.
	 */
	public function get_managed_variations( WC_Product $product ): array {
		if ( ! $product->is_type( ProductType::VARIABLE ) ) {
			return array();
		}

		$product_id = $product->get_id();
		if ( isset( $this->managed_variations[ $product_id ] ) ) {
			return $this->managed_variations[ $product_id ];
		}

		$children = $product->get_children();
		if ( empty( $children ) ) {
			return array();
		}

		global $wpdb;

		$format           = array_fill( 0, count( $children ), '%d' );
		$query_in         = '(' . implode( ',', $format ) . ')';
		$managed_children = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT post_id FROM $wpdb->postmeta WHERE meta_key = '_manage_stock' AND meta_value != 'yes' AND post_id IN {$query_in}", $children ) ); // @codingStandardsIgnoreLine.

		$this->managed_variations[ $product_id ] = array_map( 'intval', $managed_children );

		return $this->managed_variations[ $product_id ];
	}
}
PK     [1]F)6  6    EmailEditor/Integration.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\EmailEditor;

use Automattic\WooCommerce\EmailEditor\Email_Editor_Container;
use Automattic\WooCommerce\EmailEditor\Engine\Dependency_Check;
use Automattic\WooCommerce\Internal\Admin\EmailPreview\EmailPreview;
use Automattic\WooCommerce\Internal\EmailEditor\EmailPatterns\PatternsController;
use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\TemplatesController;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmails;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\TemplateApiController;
use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger;
use WP_Post;

defined( 'ABSPATH' ) || exit;

/**
 * Integration class for the Email Editor functionality.
 */
class Integration {
	const EMAIL_POST_TYPE = 'woo_email';

	/**
	 * The email editor page renderer instance.
	 *
	 * @var PageRenderer
	 */
	private PageRenderer $editor_page_renderer;

	/**
	 * The dependency check instance.
	 *
	 * @var Dependency_Check
	 */
	private Dependency_Check $dependency_check;

	/**
	 * The template API controller instance.
	 *
	 * @var TemplateApiController
	 */
	private TemplateApiController $template_api_controller;

	/**
	 * The email data API controller instance.
	 *
	 * @var EmailApiController
	 */
	private EmailApiController $email_api_controller;

	/**
	 * Constructor.
	 */
	public function __construct() {
		$editor_container       = Email_Editor_Container::container();
		$this->dependency_check = $editor_container->get( Dependency_Check::class );
	}

	/**
	 * Initialize the integration.
	 *
	 * @internal
	 */
	final public function init(): void {
		if ( ! $this->dependency_check->are_dependencies_met() ) {
			// If dependencies are not met, do not initialize the email editor integration.
			return;
		}

		add_action( 'woocommerce_init', array( $this, 'initialize' ) );
	}

	/**
	 * Initialize the integration.
	 */
	public function initialize() {
		$this->init_logger();
		$this->init_hooks();
		$this->extend_post_api();
		$this->extend_template_post_api();
		$this->register_hooks();
	}

	/**
	 * Initialize the logger.
	 */
	public function init_logger() {
		$editor_container = Email_Editor_Container::container();
		$logger           = $editor_container->get( Email_Editor_Logger::class );

		// Register the WooCommerce logger with the email editor package.
		$logger->set_logger( new Logger( wc_get_logger() ) );
	}

	/**
	 * Initialize hooks for required classes.
	 */
	public function init_hooks() {
		$container = wc_get_container();
		$container->get( PatternsController::class );
		$container->get( TemplatesController::class );
		$container->get( PersonalizationTagManager::class );
		$container->get( BlockEmailRenderer::class );
		$container->get( WCTransactionalEmails::class );
		$this->editor_page_renderer    = $container->get( PageRenderer::class );
		$this->template_api_controller = $container->get( TemplateApiController::class );
		$this->email_api_controller    = $container->get( EmailApiController::class );
	}

	/**
	 * Register hooks for the integration.
	 */
	public function register_hooks() {
		add_filter( 'woocommerce_email_editor_post_types', array( $this, 'add_email_post_type' ) );
		add_filter( 'woocommerce_is_email_editor_page', array( $this, 'is_editor_page' ), 10, 1 );
		add_filter( 'replace_editor', array( $this, 'replace_editor' ), 10, 2 );
		add_action( 'before_delete_post', array( $this, 'delete_email_template_associated_with_email_editor_post' ), 10, 2 );
		add_filter( 'woocommerce_email_editor_send_preview_email_rendered_data', array( $this, 'update_send_preview_email_rendered_data' ), 10, 2 );
		add_filter( 'woocommerce_email_editor_send_preview_email_personalizer_context', array( $this, 'update_send_preview_email_personalizer_context' ) );
		add_filter( 'woocommerce_email_editor_preview_post_template_html', array( $this, 'update_preview_post_template_html_data' ), 100, 1 );
	}

	/**
	 * Add WooCommerce email post type to the list of supported post types.
	 *
	 * @param array $post_types List of post types.
	 * @return array Modified list of post types.
	 */
	public function add_email_post_type( array $post_types ): array {
		$post_types[] = array(
			'name' => self::EMAIL_POST_TYPE,
			'args' => array(
				'labels'          => array(
					'name'          => __( 'Emails', 'woocommerce' ),
					'singular_name' => __( 'Email', 'woocommerce' ),
					'add_new_item'  => __( 'Add Email', 'woocommerce' ),
					'edit_item'     => __( 'Edit Email', 'woocommerce' ),
					'new_item'      => __( 'New Email', 'woocommerce' ),
					'view_item'     => __( 'View Email', 'woocommerce' ),
					'search_items'  => __( 'Search Emails', 'woocommerce' ),
				),
				'rewrite'         => array( 'slug' => self::EMAIL_POST_TYPE ),
				'supports'        => array(
					'title',
					'editor' => array(
						'default-mode' => 'template-locked',
					),
					'excerpt',
				),
				'capability_type' => self::EMAIL_POST_TYPE,
				'capabilities'    => array(
					'edit_post'          => 'manage_woocommerce',
					'read_post'          => 'manage_woocommerce',
					'delete_post'        => 'manage_woocommerce',
					'edit_posts'         => 'manage_woocommerce',
					'edit_others_posts'  => 'manage_woocommerce',
					'delete_posts'       => 'manage_woocommerce',
					'publish_posts'      => 'manage_woocommerce',
					'read_private_posts' => 'manage_woocommerce',
					'create_posts'       => 'manage_woocommerce',
				),
				'map_meta_cap'    => false,
			),
		);
		return $post_types;
	}

	/**
	 * Check if current page is email editor page.
	 *
	 * @param bool $is_editor_page Current editor page status.
	 * @return bool Whether current page is email editor page.
	 */
	public function is_editor_page( bool $is_editor_page ): bool {
		if ( $is_editor_page ) {
			return $is_editor_page;
		}

		// We need to check early if we are on the email editor page. The check runs early so we can't use current_screen() here.
		if ( is_admin() && isset( $_GET['post'] ) && isset( $_GET['action'] ) && 'edit' === $_GET['action'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- We are not verifying the nonce here because we are not using the nonce in the function and the data is okay in this context (WP-admin errors out gracefully).
			$post = get_post( (int) $_GET['post'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- We are not verifying the nonce here because we are not using the nonce in the function and the data is okay in this context (WP-admin errors out gracefully).
			return $post && self::EMAIL_POST_TYPE === $post->post_type;
		}

		return false;
	}

	/**
	 * Replace the default editor with our custom email editor.
	 *
	 * @param bool    $replace Whether to replace the editor.
	 * @param WP_Post $post    Post object.
	 * @return bool Whether the editor was replaced.
	 */
	public function replace_editor( $replace, $post ) {
		$current_screen = get_current_screen();
		if ( self::EMAIL_POST_TYPE === $post->post_type && $current_screen ) {
			$this->editor_page_renderer->render();
			return true;
		}
		return $replace;
	}

	/**
	 * Delete the email template associated with the email editor post when the post is permanently deleted.
	 *
	 * @param int     $post_id The post ID.
	 * @param WP_Post $post    The post object.
	 */
	public function delete_email_template_associated_with_email_editor_post( $post_id, $post ) {
		if ( self::EMAIL_POST_TYPE !== $post->post_type ) {
			return;
		}

		$post_manager = WCTransactionalEmailPostsManager::get_instance();

		$email_type = $post_manager->get_email_type_from_post_id( $post_id, true );

		if ( empty( $email_type ) ) {
			return;
		}

		$post_manager->delete_email_template( $email_type );
	}

	/**
	 * Extend the post API for the wp_template post type to add and save the woocommerce_data field.
	 */
	public function extend_template_post_api(): void {
		register_rest_field(
			'wp_template',
			'woocommerce_data',
			array(
				'get_callback'    => array( $this->template_api_controller, 'get_template_data' ),
				'update_callback' => array( $this->template_api_controller, 'save_template_data' ),
				'schema'          => $this->template_api_controller->get_template_data_schema(),
			)
		);
	}

	/**
	 * Filter email preview data to replace placeholders with actual content.
	 *
	 * This method retrieves the appropriate email type based on the request,
	 * generates the email content using the WooContentProcessor, and replaces
	 * the placeholder in the preview HTML.
	 *
	 * @param string $data       The preview data.
	 * @param string $email_type The email type identifier (e.g., 'customer_processing_order').
	 * @param int    $post_id    The post ID.
	 * @return string The updated preview data with placeholders replaced.
	 */
	private function update_email_preview_data( $data, string $email_type, $post_id = 0 ) {
		$type_param = EmailPreview::DEFAULT_EMAIL_TYPE;

		if ( ! empty( $post_id ) ) {
			$type_param = WCTransactionalEmailPostsManager::get_instance()->get_email_type_class_name_from_post_id( $post_id );
		} elseif ( ! empty( $email_type ) ) {
			$type_param = WCTransactionalEmailPostsManager::get_instance()->get_email_type_class_name_from_email_id( $email_type );
		}

		$email_preview = wc_get_container()->get( EmailPreview::class );

		try {
			$message = $email_preview->generate_placeholder_content( $type_param );
		} catch ( \InvalidArgumentException $e ) {
			// If the provided type was invalid, fall back to the default.
			try {
				$message = $email_preview->generate_placeholder_content( EmailPreview::DEFAULT_EMAIL_TYPE );
			} catch ( \Throwable $e ) {
				return $data;
			}
		} catch ( \Throwable $e ) {
			return $data;
		}

		return str_replace( BlockEmailRenderer::WOO_EMAIL_CONTENT_PLACEHOLDER, $message, $data );
	}

	/**
	 * Filter email preview data used when sending a preview email.
	 *
	 * @param string  $data The preview data.
	 * @param WP_Post $post The post object.
	 * @return string The updated preview data with placeholders replaced.
	 */
	public function update_send_preview_email_rendered_data( $data, $post ) {
		$email_type = '';
		$post_body  = file_get_contents( 'php://input' );

		if ( $post_body ) {
			$decoded_body = json_decode( $post_body );

			if ( json_last_error() === JSON_ERROR_NONE && isset( $decoded_body->postId ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
				$post_id = absint( $decoded_body->postId ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase

				$email_type = WCTransactionalEmailPostsManager::get_instance()->get_email_type_from_post_id( $post_id );
				if ( ! empty( $email_type ) ) {
					return $this->update_email_preview_data( $data, $email_type );
				}
			}
		} elseif ( ! empty( $post ) && $post instanceof \WP_Post ) {
			$email_type = WCTransactionalEmailPostsManager::get_instance()->get_email_type_from_post_id( $post->ID );
			if ( ! empty( $email_type ) ) {
				return $this->update_email_preview_data( $data, $email_type, $post->ID );
			}
		}
		return $data;
	}

	/**
	 * Update the personalizer context for the send preview email.
	 *
	 * @param array $context The personalizer context.
	 * @return array The updated personalizer context.
	 */
	public function update_send_preview_email_personalizer_context( $context ) {
		$post_manager  = WCTransactionalEmailPostsManager::get_instance();
		$email_id      = $post_manager->get_email_type_from_post_id( get_the_ID() );
		$email_type    = $email_id ? $post_manager->get_email_type_class_name_from_email_id( $email_id ) : EmailPreview::DEFAULT_EMAIL_TYPE;
		$email_preview = wc_get_container()->get( EmailPreview::class );

		try {
			$email_preview->set_email_type( $email_type );
		} catch ( \InvalidArgumentException $e ) {
			// If the email type is invalid, return the context data as is.
			return $context;
		}

		$email            = $email_preview->get_email();
		$email->recipient = $context['recipient_email'] ?? '';
		$personalizer     = wc_get_container()->get( TransactionalEmailPersonalizer::class );

		return $personalizer->prepare_context_data( $context, $email );
	}

	/**
	 * Filter email preview data used when previewing the email in new tab.
	 *
	 * @param string $data The preview HTML string.
	 * @return string The updated preview HTML with placeholders replaced.
	 */
	public function update_preview_post_template_html_data( $data ) {
		// return early if the data does not contain the placeholder meaning it's already been processed.
		if ( ! str_contains( (string) $data, BlockEmailRenderer::WOO_EMAIL_CONTENT_PLACEHOLDER ) ) {
			return $data;
		}

		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		// Nonce verification is disabled here because the preview action doesn't modify data,
		// and the check caused issues with the 'Preview in new tab' feature due to context changes.
		$type_param = isset( $_GET['woo_email'] ) ? sanitize_text_field( wp_unslash( $_GET['woo_email'] ) ) : '';

		// check for post id (preview id) in the request.
		$post_id = isset( $_REQUEST['preview_id'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['preview_id'] ) ) : '';

		// phpcs:enable
		return $this->update_email_preview_data( $data, $type_param, $post_id );
	}

	/**
	 * Extend the post API for the woo_email post type to add and save the woocommerce_data field.
	 */
	public function extend_post_api(): void {
		register_rest_field(
			self::EMAIL_POST_TYPE,
			'woocommerce_data',
			array(
				'get_callback'    => array( $this->email_api_controller, 'get_email_data' ),
				'update_callback' => array( $this->email_api_controller, 'save_email_data' ),
				'schema'          => $this->email_api_controller->get_email_data_schema(),
			)
		);
	}
}
PK     [1]cf    "  EmailEditor/BlockEmailRenderer.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\EmailEditor;

use Automattic\WooCommerce\EmailEditor\Email_Editor_Container;
use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\Renderer as EmailRenderer;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;

/**
 * Class responsible for rendering block-based emails.
 */
class BlockEmailRenderer {
	const WOO_EMAIL_CONTENT_PLACEHOLDER = '##WOO_CONTENT##';

	/**
	 * Service for rendering block emails
	 *
	 * @var EmailRenderer
	 */
	private $renderer;

	/**
	 * Service for personalization of emails
	 * It replaces personalization tags with actual values
	 *
	 * @var Personalizer
	 */
	private $personalizer;

	/**
	 * Service for extracting WooCommerce content from WC_Email object.
	 *
	 * @var WooContentProcessor
	 */
	private $woo_content_processor;

	/**
	 * WooCommerce Email Template Manager instance.
	 *
	 * @var WCTransactionalEmailPostsManager
	 */
	private $template_manager;

	/**
	 * Constructor.
	 */
	public function __construct() {
		$editor_container       = Email_Editor_Container::container();
		$this->renderer         = $editor_container->get( EmailRenderer::class );
		$this->personalizer     = $editor_container->get( Personalizer::class );
		$this->template_manager = WCTransactionalEmailPostsManager::get_instance();
	}

	/**
	 * Initialize the renderer.
	 *
	 * @param WooContentProcessor $woo_content_processor Service for extracting WooCommerce content from WC_Email object.
	 * @internal
	 */
	final public function init( WooContentProcessor $woo_content_processor ): void {
		$this->woo_content_processor = $woo_content_processor;
		add_action( 'woocommerce_email_blocks_renderer_initialized', array( $this, 'register_block_renderers' ) );
	}

	/**
	 * Maybe render block-based email content.
	 *
	 * @param \WC_Email $wc_email WooCommerce email.
	 * @return string|null Modified email content
	 */
	public function maybe_render_block_email( \WC_Email $wc_email ): ?string {
		$email_post = $this->get_email_post_by_wc_email( $wc_email );
		if ( ! $email_post ) {
			return null;
		}

		$woo_content = $this->woo_content_processor->get_woo_content( $wc_email );
		return $this->render_block_email( $email_post, $woo_content, $wc_email );
	}

	/**
	 * Maybe render block-based email content.
	 *
	 * @param \WP_Post  $email_post Email post.
	 * @param string    $woo_content WooCommerce email content.
	 * @param \WC_Email $wc_email WooCommerce email.
	 * @return string Modified email content
	 */
	private function render_block_email( \WP_Post $email_post, string $woo_content, \WC_Email $wc_email ): ?string {
		try {
			// Set email context before rendering so blocks can access it.
			$filter_callback = function ( $context = array() ) use ( $wc_email ) {
				return array_merge( $context, $this->build_email_context( $wc_email ) );
			};
			add_filter( 'woocommerce_email_editor_rendering_email_context', $filter_callback, 10, 1 );

			$subject             = $wc_email->get_subject(); // We will get subject from $email_post after we add it to the editor.
			$preheader           = $wc_email->get_preheader();
			$rendered_email_data = $this->renderer->render( $email_post, $subject, $preheader, 'en' );
			$personalized_email  = $this->personalizer->personalize_content( $rendered_email_data['html'] );
			$rendered_email      = str_replace( self::WOO_EMAIL_CONTENT_PLACEHOLDER, $woo_content, $personalized_email );

			// Remove the filter after rendering to prevent context leakage.
			remove_filter( 'woocommerce_email_editor_rendering_email_context', $filter_callback );

			add_filter( 'woocommerce_email_styles', array( $this->woo_content_processor, 'prepare_css' ), 10, 2 );
			return $rendered_email;
		} catch ( \Exception $e ) {
			wc_caught_exception( $e, __METHOD__, array( $email_post, $woo_content, $wc_email ) );
			// Remove the filter in case of exception.
			if ( isset( $filter_callback ) ) {
				remove_filter( 'woocommerce_email_editor_rendering_email_context', $filter_callback );
			}
			return null;
		}
	}

	/**
	 * Get the email post for a given WC_Email.
	 *
	 * @param \WC_Email $email WooCommerce email.
	 * @return \WP_Post|null
	 */
	private function get_email_post_by_wc_email( \WC_Email $email ): ?\WP_Post {
		return $this->template_manager->get_email_post( $email->id );
	}

	/**
	 * Build email context from WC_Email object.
	 *
	 * Extracts relevant context data from the WC_Email object that can be used
	 * by blocks during rendering, such as user ID, email address, order information, etc.
	 *
	 * Blocks that need cart product information can derive it from the user_id or email
	 * using CartCheckoutUtils::get_cart_product_ids_for_user().
	 *
	 * @param \WC_Email $wc_email WooCommerce email object.
	 * @return array Email context data.
	 */
	private function build_email_context( \WC_Email $wc_email ): array {
		$recipient_raw = $wc_email->get_recipient();
		$emails        = array_values( array_filter( array_map( 'sanitize_email', array_map( 'trim', explode( ',', $recipient_raw ) ) ) ) );
		$context       = array(
			'recipient_email' => $emails[0] ?? null,
		);

		// Extract order-related context if the email object is an order.
		if ( isset( $wc_email->object ) && $wc_email->object instanceof \WC_Order ) {
			$order              = $wc_email->object;
			$context['user_id'] = $order->get_customer_id();
		}

		return $context;
	}
}
PK     [1]},G    8  EmailEditor/PersonalizationTags/CustomerTagsProvider.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\EmailEditor\PersonalizationTags;

use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tag;
use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
use Automattic\WooCommerce\Internal\EmailEditor\Integration;

/**
 * Provider for customer-related personalization tags.
 *
 * @internal
 */
class CustomerTagsProvider extends AbstractTagProvider {
	/**
	 * Register customer tags with the registry.
	 *
	 * @param Personalization_Tags_Registry $registry The personalization tags registry.
	 * @return void
	 */
	public function register_tags( Personalization_Tags_Registry $registry ): void {
		$registry->register(
			new Personalization_Tag(
				__( 'Customer Email', 'woocommerce' ),
				'woocommerce/customer-email',
				__( 'Customer', 'woocommerce' ),
				function ( array $context ): string {
					if ( isset( $context['order'] ) ) {
						return $context['order']->get_billing_email() ?? '';
					}
					return $context['recipient_email'] ?? '';
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Customer First Name', 'woocommerce' ),
				'woocommerce/customer-first-name',
				__( 'Customer', 'woocommerce' ),
				function ( array $context ): string {
					if ( isset( $context['order'] ) ) {
						return $context['order']->get_billing_first_name() ?? '';
					} elseif ( isset( $context['wp_user'] ) ) {
						return $context['wp_user']->first_name ?? '';
					}
					return '';
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Customer Last Name', 'woocommerce' ),
				'woocommerce/customer-last-name',
				__( 'Customer', 'woocommerce' ),
				function ( array $context ): string {
					if ( isset( $context['order'] ) ) {
						return $context['order']->get_billing_last_name() ?? '';
					} elseif ( isset( $context['wp_user'] ) ) {
						return $context['wp_user']->last_name ?? '';
					}
					return '';
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Customer Full Name', 'woocommerce' ),
				'woocommerce/customer-full-name',
				__( 'Customer', 'woocommerce' ),
				function ( array $context ): string {
					if ( isset( $context['order'] ) ) {
						return $context['order']->get_formatted_billing_full_name() ?? '';
					} elseif ( isset( $context['wp_user'] ) ) {
						$first_name = $context['wp_user']->first_name ?? '';
						$last_name  = $context['wp_user']->last_name ?? '';
						return trim( "$first_name $last_name" );
					}
					return '';
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Customer Username', 'woocommerce' ),
				'woocommerce/customer-username',
				__( 'Customer', 'woocommerce' ),
				function ( array $context ): string {
					if ( isset( $context['wp_user'] ) ) {
						return stripslashes( $context['wp_user']->user_login ?? '' );
					}
					return '';
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Customer Country', 'woocommerce' ),
				'woocommerce/customer-country',
				__( 'Customer', 'woocommerce' ),
				function ( array $context ): string {
					if ( isset( $context['order'] ) ) {
						$country_code = $context['order']->get_billing_country();
						return WC()->countries->countries[ $country_code ] ?? $country_code ?? '';
					}
					return '';
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);
	}
}
PK     [1]f1NZ'  '  4  EmailEditor/PersonalizationTags/SiteTagsProvider.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\EmailEditor\PersonalizationTags;

use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tag;
use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
use Automattic\WooCommerce\Internal\EmailEditor\Integration;
use Automattic\WooCommerce\Internal\Orders\PointOfSaleOrderUtil;
use Automattic\WooCommerce\Internal\Settings\PointOfSaleDefaultSettings;

/**
 * Provider for site-related personalization tags.
 *
 * @internal
 */
class SiteTagsProvider extends AbstractTagProvider {
	/**
	 * Register site tags with the registry.
	 *
	 * @param Personalization_Tags_Registry $registry The personalization tags registry.
	 * @return void
	 */
	public function register_tags( Personalization_Tags_Registry $registry ): void {
		$registry->register(
			new Personalization_Tag(
				__( 'Site Title', 'woocommerce' ),
				'woocommerce/site-title',
				__( 'Site', 'woocommerce' ),
				function ( array $context ): string {
					if ( isset( $context['order'] ) && PointOfSaleOrderUtil::is_pos_order( $context['order'] ) ) {
						$store_name = get_option( 'woocommerce_pos_store_name' );
						return htmlspecialchars_decode( empty( $store_name ) ? PointOfSaleDefaultSettings::get_default_store_name() : $store_name, ENT_QUOTES );
					}
					return htmlspecialchars_decode( get_bloginfo( 'name' ) );
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Homepage URL', 'woocommerce' ),
				'woocommerce/site-homepage-url',
				__( 'Site', 'woocommerce' ),
				function (): string {
					return get_bloginfo( 'url' );
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);
	}
}
PK     [1]1TKa!  a!  5  EmailEditor/PersonalizationTags/OrderTagsProvider.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\EmailEditor\PersonalizationTags;

use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tag;
use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
use Automattic\WooCommerce\Internal\EmailEditor\Integration;

/**
 * Provider for order-related personalization tags.
 *
 * @internal
 */
class OrderTagsProvider extends AbstractTagProvider {
	/**
	 * Register order tags with the registry.
	 *
	 * @param Personalization_Tags_Registry $registry The personalization tags registry.
	 * @return void
	 */
	public function register_tags( Personalization_Tags_Registry $registry ): void {
		$registry->register(
			new Personalization_Tag(
				__( 'Order Number', 'woocommerce' ),
				'woocommerce/order-number',
				__( 'Order', 'woocommerce' ),
				function ( array $context ): string {
					if ( ! isset( $context['order'] ) ) {
						return '';
					}
					return $context['order']->get_order_number() ?? '';
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Order Date', 'woocommerce' ),
				'woocommerce/order-date',
				__( 'Order', 'woocommerce' ),
				function ( array $context, array $parameters = array() ): string {
					if ( ! isset( $context['order'] ) ) {
						return '';
					}
					$format       = isset( $parameters['format'] ) && is_string( $parameters['format'] ) ? $parameters['format'] : wc_date_format();
					$date_created = $context['order']->get_date_created();
					if ( ! $date_created ) {
						return '';
					}
					return wc_format_datetime( $date_created, $format );
				},
				array(
					'format' => wc_date_format(),
				),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Order Items', 'woocommerce' ),
				'woocommerce/order-items',
				__( 'Order', 'woocommerce' ),
				function ( array $context ): string {
					if ( ! isset( $context['order'] ) ) {
						return '';
					}
					$items = array();
					foreach ( $context['order']->get_items() as $item ) {
						$items[] = $item->get_name();
					}
					return implode( ', ', $items );
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Order Subtotal', 'woocommerce' ),
				'woocommerce/order-subtotal',
				__( 'Order', 'woocommerce' ),
				function ( array $context ): string {
					if ( ! isset( $context['order'] ) ) {
						return '';
					}
					return (string) $context['order']->get_subtotal() ?? '';
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Order Tax', 'woocommerce' ),
				'woocommerce/order-tax',
				__( 'Order', 'woocommerce' ),
				function ( array $context ): string {
					if ( ! isset( $context['order'] ) ) {
						return '';
					}
					return (string) $context['order']->get_total_tax() ?? '';
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Order Discount', 'woocommerce' ),
				'woocommerce/order-discount',
				__( 'Order', 'woocommerce' ),
				function ( array $context ): string {
					if ( ! isset( $context['order'] ) ) {
						return '';
					}
					return wc_price( $context['order']->get_discount_total(), array( 'currency' => $context['order']->get_currency() ) );
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Order Shipping', 'woocommerce' ),
				'woocommerce/order-shipping',
				__( 'Order', 'woocommerce' ),
				function ( array $context ): string {
					if ( ! isset( $context['order'] ) ) {
						return '';
					}
					return wc_price( $context['order']->get_shipping_total(), array( 'currency' => $context['order']->get_currency() ) );
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Order Total', 'woocommerce' ),
				'woocommerce/order-total',
				__( 'Order', 'woocommerce' ),
				function ( array $context ): string {
					if ( ! isset( $context['order'] ) ) {
						return '';
					}
					return (string) $context['order']->get_total() ?? '';
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Payment Method', 'woocommerce' ),
				'woocommerce/order-payment-method',
				__( 'Order', 'woocommerce' ),
				function ( array $context ): string {
					if ( ! isset( $context['order'] ) ) {
						return '';
					}
					return $context['order']->get_payment_method_title() ?? '';
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Payment URL', 'woocommerce' ),
				'woocommerce/order-payment-url',
				__( 'Order', 'woocommerce' ),
				function ( array $context ): string {
					if ( ! isset( $context['order'] ) ) {
						return '';
					}
					return $context['order']->get_checkout_payment_url() ?? '';
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Order Transaction ID', 'woocommerce' ),
				'woocommerce/order-transaction-id',
				__( 'Order', 'woocommerce' ),
				function ( array $context ): string {
					if ( ! isset( $context['order'] ) ) {
						return '';
					}
					return $context['order']->get_transaction_id() ?? '';
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Order Shipping Method', 'woocommerce' ),
				'woocommerce/order-shipping-method',
				__( 'Order', 'woocommerce' ),
				function ( array $context ): string {
					if ( ! isset( $context['order'] ) ) {
						return '';
					}
					return $context['order']->get_shipping_method() ?? '';
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Order Shipping Address', 'woocommerce' ),
				'woocommerce/order-shipping-address',
				__( 'Order', 'woocommerce' ),
				function ( array $context ): string {
					if ( ! isset( $context['order'] ) ) {
						return '';
					}
					return $context['order']->get_formatted_shipping_address() ?? '';
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Order Billing Address', 'woocommerce' ),
				'woocommerce/order-billing-address',
				__( 'Order', 'woocommerce' ),
				function ( array $context ): string {
					if ( ! isset( $context['order'] ) ) {
						return '';
					}
					return $context['order']->get_formatted_billing_address() ?? '';
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Order View URL', 'woocommerce' ),
				'woocommerce/order-view-url',
				__( 'Order', 'woocommerce' ),
				function ( array $context ): string {
					if ( ! isset( $context['order'] ) ) {
						return '';
					}
					return $context['order']->get_view_order_url() ?? '';
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Order Admin URL', 'woocommerce' ),
				'woocommerce/order-admin-url',
				__( 'Order', 'woocommerce' ),
				function ( array $context ): string {
					if ( ! isset( $context['order'] ) ) {
						return '';
					}
					return $context['order']->get_edit_order_url() ?? '';
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Order Custom Field', 'woocommerce' ),
				'woocommerce/order-custom-field',
				__( 'Order', 'woocommerce' ),
				function ( array $context, array $parameters = array() ): string {
					if ( ! isset( $context['order'] ) || ! isset( $parameters['key'] ) ) {
						return '';
					}
					$field_key = sanitize_text_field( $parameters['key'] );
					return $context['order']->get_meta( $field_key ) ?? '';
				},
				array(
					'key' => '',
				),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);
	}
}
PK     [1]ʺSM  M  5  EmailEditor/PersonalizationTags/StoreTagsProvider.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\EmailEditor\PersonalizationTags;

use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tag;
use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
use Automattic\WooCommerce\Internal\EmailEditor\Integration;

/**
 * Provider for store-related personalization tags.
 *
 * @internal
 */
class StoreTagsProvider extends AbstractTagProvider {
	/**
	 * Register store tags with the registry.
	 *
	 * @param Personalization_Tags_Registry $registry The personalization tags registry.
	 * @return void
	 */
	public function register_tags( Personalization_Tags_Registry $registry ): void {
		$registry->register(
			new Personalization_Tag(
				__( 'Store Email', 'woocommerce' ),
				'woocommerce/store-email',
				__( 'Store', 'woocommerce' ),
				function ( array $context ): string {
					if ( isset( $context['wc_email'], $context['wc_email']->get_from_address ) ) {
						return $context['wc_email']->get_from_address();
					}
					return get_option( 'admin_email' );
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Store URL', 'woocommerce' ),
				'woocommerce/store-url',
				__( 'Store', 'woocommerce' ),
				function (): string {
					return esc_attr( wc_get_page_permalink( 'shop' ) );
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Store Name', 'woocommerce' ),
				'woocommerce/store-name',
				__( 'Store', 'woocommerce' ),
				function ( array $context ): string {
					if ( isset( $context['wc_email'] ) && ! empty( $context['wc_email']->get_from_name() ) ) {
						return $context['wc_email']->get_from_name();
					}

					return wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Store Address', 'woocommerce' ),
				'woocommerce/store-address',
				__( 'Store', 'woocommerce' ),
				function (): string {
					return WC()->mailer->get_store_address() ?? '';
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'My Account URL', 'woocommerce' ),
				'woocommerce/my-account-url',
				__( 'Store', 'woocommerce' ),
				function (): string {
					return esc_attr( wc_get_page_permalink( 'myaccount' ) );
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);

		$registry->register(
			new Personalization_Tag(
				__( 'Admin Order Note', 'woocommerce' ),
				'woocommerce/admin-order-note',
				__( 'Store', 'woocommerce' ),
				function ( array $context ): string {
					if ( isset( $context['wc_email'], $context['wc_email']->customer_note ) ) {
						return wptexturize( $context['wc_email']->customer_note );
					}
					return '';
				},
				array(),
				null,
				array( Integration::EMAIL_POST_TYPE ),
			)
		);
	}
}
PK     [1],G99  9  7  EmailEditor/PersonalizationTags/AbstractTagProvider.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\EmailEditor\PersonalizationTags;

use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;

/**
 * Abstract class for personalization tag providers.
 *
 * @internal
 */
abstract class AbstractTagProvider {
	/**
	 * Register tags with the registry.
	 *
	 * @param Personalization_Tags_Registry $registry The personalization tags registry.
	 * @return void
	 */
	abstract public function register_tags( Personalization_Tags_Registry $registry ): void;
}
PK     [1]sn8"  8"  "  EmailEditor/EmailApiController.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\EmailEditor;

use Automattic\WooCommerce\EmailEditor\Validator\Builder;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
use WC_Email;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * API Controller for managing WooCommerce email templates via extending the post type API.
 *
 * @internal
 */
class EmailApiController {

	/**
	 * The WooCommerce transactional email post manager.
	 *
	 * @var WCTransactionalEmailPostsManager|null
	 */
	private ?WCTransactionalEmailPostsManager $post_manager;

	/**
	 * Initialize the controller.
	 *
	 * @internal
	 */
	final public function init(): void {
		$this->post_manager = WCTransactionalEmailPostsManager::get_instance();
	}

	/**
	 * Returns the data from wp_options table for the given post.
	 *
	 * @param array $post_data - Post data.
	 * @return array - The email data.
	 */
	public function get_email_data( $post_data ): array {
		$email_type = $this->post_manager->get_email_type_from_post_id( $post_data['id'] );
		$email      = $this->get_email_by_type( $email_type ?? '' );

		// When the email type is not found, it means that the email type is not supported.
		if ( ! $email ) {
			return array(
				'subject'         => null,
				'subject_full'    => null,
				'subject_partial' => null,
				'preheader'       => null,
				'default_subject' => null,
				'email_type'      => null,
				'recipient'       => null,
				'cc'              => null,
				'bcc'             => null,
			);
		}

		$form_fields = $email->get_form_fields();
		$enabled     = $email->get_option( 'enabled' );
		return array(
			'enabled'         => is_null( $enabled ) ? $email->is_enabled() : 'yes' === $enabled,
			'is_manual'       => $email->is_manual(),
			'subject'         => $email->get_option( 'subject' ),
			'subject_full'    => $email->get_option( 'subject_full' ), // For customer_refunded_order email type because it has two different subjects.
			'subject_partial' => $email->get_option( 'subject_partial' ),
			'preheader'       => $email->get_option( 'preheader' ),
			'default_subject' => $email->get_default_subject(),
			'email_type'      => $email_type,
			// Recipient is possible to set only for the specific type of emails. When the field `recipient` is set in the form fields, it means that the email type has a recipient field.
			'recipient'       => array_key_exists( 'recipient', $form_fields ) ? $email->get_option( 'recipient', get_option( 'admin_email' ) ) : null,
			'cc'              => $email->get_option( 'cc' ),
			'bcc'             => $email->get_option( 'bcc' ),
		);
	}

	/**
	 * Update WooCommerce specific option data by post name.
	 *
	 * @param array    $data - Data that are stored in the wp_options table.
	 * @param \WP_Post $post - WP_Post object.
	 * @return \WP_Error|null Returns WP_Error if email validation fails, null otherwise.
	 */
	public function save_email_data( array $data, \WP_Post $post ): ?\WP_Error {
		$error = $this->validate_email_data( $data );
		if ( is_wp_error( $error ) ) {
			return new \WP_Error( 'invalid_email_data', implode( ' ', $error->get_error_messages() ), array( 'status' => 400 ) );
		}

		if ( ! array_key_exists( 'subject', $data ) && ! array_key_exists( 'preheader', $data ) ) {
			return null;
		}
		$email_type = $this->post_manager->get_email_type_from_post_id( $post->ID );
		$email      = $this->get_email_by_type( $email_type ?? '' );

		if ( ! $email ) {
			return null; // not saving of type wc_email. Allow process to continue.
		}

		// Handle customer_refunded_order email type because it has two different subjects.
		if ( 'customer_refunded_order' === $email_type ) {
			if ( array_key_exists( 'subject_full', $data ) ) {
				$email->update_option( 'subject_full', $data['subject_full'] );
			}
			if ( array_key_exists( 'subject_partial', $data ) ) {
				$email->update_option( 'subject_partial', $data['subject_partial'] );
			}
		} elseif ( array_key_exists( 'subject', $data ) ) {
			$email->update_option( 'subject', $data['subject'] );
		}

		if ( array_key_exists( 'preheader', $data ) ) {
			$email->update_option( 'preheader', $data['preheader'] );
		}

		if ( array_key_exists( 'enabled', $data ) ) {
			$email->update_option( 'enabled', $data['enabled'] ? 'yes' : 'no' );
		}
		if ( array_key_exists( 'recipient', $data ) ) {
			$email->update_option( 'recipient', $data['recipient'] );
		}
		if ( array_key_exists( 'cc', $data ) ) {
			$email->update_option( 'cc', $data['cc'] );
		}
		if ( array_key_exists( 'bcc', $data ) ) {
			$email->update_option( 'bcc', $data['bcc'] );
		}

		return null;
	}

	/**
	 * Validate the email data.
	 *
	 * @param array $data - The email data.
	 * @return \WP_Error|null Returns WP_Error if email validation fails, null otherwise.
	 */
	private function validate_email_data( array $data ) {
		$error = new \WP_Error();

		// Validate 'recipient' email(s) field.
		$invalid_recipients = $this->filter_invalid_email_addresses( $data['recipient'] ?? '' );
		if ( ! empty( $invalid_recipients ) ) {
			$error_message = sprintf(
				// translators: %s will be replaced by comma-separated email addresses. For example, "invalidemail1@example.com,invalidemail2@example.com".
				__( 'One or more Recipient email addresses are invalid: “%s”. Please enter valid email addresses separated by commas.', 'woocommerce' ),
				implode( ',', $invalid_recipients )
			);
			$error->add( 'invalid_recipient_email_address', $error_message );
		}

		// Validate 'cc' email(s) field.
		$invalid_cc = $this->filter_invalid_email_addresses( $data['cc'] ?? '' );
		if ( ! empty( $invalid_cc ) ) {
			$error_message = sprintf(
				// translators: %s will be replaced by comma-separated email addresses. For example, "invalidemail1@example.com,invalidemail2@example.com".
				__( 'One or more CC email addresses are invalid: “%s”. Please enter valid email addresses separated by commas.', 'woocommerce' ),
				implode( ',', $invalid_cc )
			);
			$error->add( 'invalid_cc_email_address', $error_message );
		}

		// Validate 'bcc' email(s) field.
		$invalid_bcc = $this->filter_invalid_email_addresses( $data['bcc'] ?? '' );
		if ( ! empty( $invalid_bcc ) ) {
			$error_message = sprintf(
				// translators: %s will be replaced by comma-separated email addresses. For example, "invalidemail1@example.com,invalidemail2@example.com".
				__( 'One or more BCC email addresses are invalid: “%s”. Please enter valid email addresses separated by commas.', 'woocommerce' ),
				implode( ',', $invalid_bcc )
			);
			$error->add( 'invalid_bcc_email_address', $error_message );
		}

		if ( $error->has_errors() ) {
			return $error;
		}

		return null;
	}

	/**
	 * Filter in invalid email addresses from a comma-separated string.
	 *
	 * @param string $comma_separated_email_addresses - A comma-separated string of email addresses.
	 * @return array - An array of invalid email addresses.
	 */
	private function filter_invalid_email_addresses( $comma_separated_email_addresses ) {
		$invalid_email_addresses = array();

		if ( empty( trim( $comma_separated_email_addresses ) ) ) {
			return $invalid_email_addresses;
		}

		foreach ( explode( ',', $comma_separated_email_addresses ) as $email_address ) {
			if ( ! filter_var( trim( $email_address ), FILTER_VALIDATE_EMAIL ) ) {
				$invalid_email_addresses[] = trim( $email_address );
			}
		}

		return $invalid_email_addresses;
	}

	/**
	 * Get the schema for the WooCommerce email post data.
	 *
	 * @return array
	 */
	public function get_email_data_schema(): array {
		return Builder::object(
			array(
				'subject'         => Builder::string()->nullable(),
				'subject_full'    => Builder::string()->nullable(), // For customer_refunded_order email type because it has two different subjects.
				'subject_partial' => Builder::string()->nullable(),
				'preheader'       => Builder::string()->nullable(),
				'default_subject' => Builder::string()->nullable(),
				'email_type'      => Builder::string()->nullable(),
				'recipient'       => Builder::string()->nullable(),
				'cc'              => Builder::string()->nullable(),
				'bcc'             => Builder::string()->nullable(),
			)
		)->to_array();
	}

	/**
	 * Get all WooCommerce emails.
	 *
	 * @return \WC_Email[]
	 */
	protected function get_emails(): array {
		return WC()->mailer()->get_emails();
	}

	/**
	 * Get the email object by ID.
	 *
	 * @param string $id - The email ID.
	 * @return \WC_Email|null - The email object or null if not found.
	 */
	private function get_email_by_type( ?string $id ): ?WC_Email {
		foreach ( $this->get_emails() as $email ) {
			if ( $email->id === $id ) {
				return $email;
			}
		}
		return null;
	}
}
PK     [1]w5jaF	  F	  4  EmailEditor/EmailTemplates/TemplateApiController.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates;

use Automattic\WooCommerce\EmailEditor\Validator\Builder;

defined( 'ABSPATH' ) || exit;

/**
 * API Controller for managing WooCommerce email templates via extending the post type API.
 *
 * @internal
 */
class TemplateApiController {
	/**
	 * Returns the sender settings for the given template.
	 *
	 * @param array $template_data - WP_Block_Template data.
	 * @return array
	 */
	public function get_template_data( $template_data ): array {
		$template_slug = $template_data['slug'] ?? null;
		if ( WooEmailTemplate::TEMPLATE_SLUG !== $template_slug ) {
			return array();
		}

		return array(
			'sender_settings' => array(
				'from_name'    => get_option( 'woocommerce_email_from_name', get_bloginfo( 'name', 'display' ) ),
				'from_address' => get_option( 'woocommerce_email_from_address' ),
			),
		);
	}

	/**
	 * Update WooCommerce specific data we store with Template.
	 *
	 * @param array              $data - WP_Block_Template data.
	 * @param \WP_Block_Template $template_post - WP_Block_Template object.
	 * @return \WP_Error|null Returns WP_Error if email validation fails, null otherwise.
	 */
	public function save_template_data( array $data, \WP_Block_Template $template_post ): ?\WP_Error {
		if ( WooEmailTemplate::TEMPLATE_SLUG === $template_post->slug && isset( $data['sender_settings'] ) ) {
			$new_from_name = $data['sender_settings']['from_name'] ?? null;

			if ( null !== $new_from_name ) {
				update_option( 'woocommerce_email_from_name', $new_from_name );
			}

			$new_from_address = $data['sender_settings']['from_address'] ?? null;
			if ( null === $new_from_address || ! filter_var( $new_from_address, FILTER_VALIDATE_EMAIL ) ) {
				return new \WP_Error( 'invalid_email_address', __( 'Invalid email address provided for sender settings', 'woocommerce' ), array( 'status' => 400 ) );
			}

			update_option( 'woocommerce_email_from_address', $new_from_address );
		}

		return null;
	}

	/**
	 * Get the schema for the template data.
	 *
	 * @return array
	 */
	public function get_template_data_schema(): array {
		return Builder::object(
			array(
				'sender_settings' => Builder::object(
					array(
						'preheader'   => Builder::string(),
						'preview_url' => Builder::string(),
					)
				),
			)
		)->to_array();
	}
}
PK     [1]+(  (  /  EmailEditor/EmailTemplates/WooEmailTemplate.phpnu         <?php declare(strict_types = 1);

namespace Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates;

/**
 * Basic template for WooCommerce transactional emails used in the email editor.
 */
class WooEmailTemplate {
	/**
	 * The template slug.
	 */
	public const TEMPLATE_SLUG = 'wooemailtemplate';

	/**
	 * Get the template slug.
	 *
	 * @return string Template identifier.
	 */
	public function get_slug(): string {
		return self::TEMPLATE_SLUG;
	}

	/**
	 * Get the template title.
	 *
	 * @return string Localized template title.
	 */
	public function get_title(): string {
		return __( 'Woo Email Template', 'woocommerce' );
	}

	/**
	 * Get the template description.
	 *
	 * @return string Localized template description.
	 */
	public function get_description(): string {
		return __( 'Basic template for WooCommerce transactional emails used in the email editor', 'woocommerce' );
	}

	/**
	 * Get the template content.
	 *
	 * @return string HTML content for the template.
	 */
	public function get_content(): string {
		return '
<!-- wp:group {"style":{"spacing":{"padding":{"top":"var:preset|spacing|10","bottom":"var:preset|spacing|10","left":"var:preset|spacing|20","right":"var:preset|spacing|20"}}},"layout":{"type":"constrained"}} -->
<div class="wp-block-group" style="padding-top:var(--wp--preset--spacing--10);padding-right:var(--wp--preset--spacing--20);padding-bottom:var(--wp--preset--spacing--10);padding-left:var(--wp--preset--spacing--20)">
' . $this->get_site_logo_or_title() . '

<!-- wp:group {"layout":{"type":"constrained"}} -->
<div class="wp-block-group">
<!-- wp:post-content {"lock":{"move":true,"remove":true},"layout":{"type":"default"}} /-->
</div>
<!-- /wp:group -->

<!-- wp:group {"style":{"spacing":{"padding":{"right":"var:preset|spacing|20","left":"var:preset|spacing|20","top":"var:preset|spacing|10","bottom":"var:preset|spacing|10"}}}} -->
<div class="wp-block-group" style="padding-top:var(--wp--preset--spacing--10);padding-right:var(--wp--preset--spacing--20);padding-bottom:var(--wp--preset--spacing--10);padding-left:var(--wp--preset--spacing--20)"><!-- wp:paragraph {"align":"center","fontSize":"small","style":{"border":{"top":{"color":"var:preset|color|cyan-bluish-gray","width":"1px","style":"solid"},"right":[],"bottom":[],"left":[]},"spacing":{"padding":{"top":"var:preset|spacing|20","bottom":"var:preset|spacing|20"}},"color":{"text":"#787c82"},"elements":{"link":{"color":{"text":"#787c82"}}}}} -->
<p class="has-text-align-center has-text-color has-link-color has-small-font-size" style="border-top-color:var(--wp--preset--color--cyan-bluish-gray);border-top-style:solid;border-top-width:1px;color:#787c82;padding-top:var(--wp--preset--spacing--20);padding-bottom:var(--wp--preset--spacing--20)">You received this email because you shopped at <!--[woocommerce/site-title]--></p>
<!-- /wp:paragraph --></div>
<!-- /wp:group -->
</div>
<!-- /wp:group -->
		';
	}

	/**
	 * Get the site logo or title.
	 *
	 * This is used to display the site logo or title in the email template.
	 *
	 * @return string HTML content for the site logo or title.
	 */
	private function get_site_logo_or_title(): string {
		$custom_logo = get_custom_logo();

		if ( ! empty( $custom_logo ) ) {
			// Use Site logo if available.
			return '<!-- wp:site-logo {"width":130,"isLink":false,"align":"center","style":{"spacing":{"padding":{"top":"var:preset|spacing|10","bottom":"var:preset|spacing|10"}}}} /-->';
		}

		return '<!-- wp:site-title {"level":2,"textAlign":"center","style":{"spacing":{"padding":{"top":"var:preset|spacing|10","bottom":"var:preset|spacing|10"}}}} /-->';
	}
}
PK     [1]8    2  EmailEditor/EmailTemplates/TemplatesController.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates;

use Automattic\WooCommerce\EmailEditor\Engine\Templates\Template;
use Automattic\WooCommerce\EmailEditor\Engine\Templates\Templates_Registry;
use Automattic\WooCommerce\Internal\EmailEditor\Integration;

defined( 'ABSPATH' ) || exit;

/**
 * Controller for managing WooCommerce email templates.
 *
 * @internal
 */
class TemplatesController {

	/**
	 * Prefix used for template identification.
	 *
	 * @var string
	 */
	private string $template_prefix = 'woocommerce';

	/**
	 * Initialize the controller by registering hooks.
	 *
	 * @internal
	 * @return void
	 */
	final public function init(): void {
		add_filter( 'woocommerce_email_editor_register_templates', array( $this, 'register_templates' ) );
		// Priority 100 ensures this runs last to remove email templates from the Site Editor.
		add_filter( 'get_block_templates', array( $this, 'filter_email_templates' ), 100, 1 );
	}

	/**
	 * Filters out email templates from the block templates list in the Site Editor.
	 *
	 * This function is necessary to prevent email templates from appearing in the Site Editor's
	 * template list. Email templates are stored in the database with the same post type as site
	 * templates, which causes them to be included in the Site Editor by default. By filtering
	 * them out, we ensure that only relevant site templates are displayed, improving the user
	 * experience and maintaining the intended separation between email and site templates.
	 *
	 * @param array $templates The list of block templates.
	 * @return array The filtered list of block templates.
	 */
	public function filter_email_templates( $templates ) {
		// Skip filtering if we're in a REST API request to avoid affecting API endpoints.
		if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
			return $templates;
		}

		if ( ! is_admin() || ! function_exists( 'get_current_screen' ) ) {
			return $templates;
		}

		$current_screen = get_current_screen();
		if ( $current_screen && 'site-editor' === $current_screen->id ) {
			$templates = array_filter(
				$templates,
				function ( $template ) {
					return WooEmailTemplate::TEMPLATE_SLUG !== $template->slug;
				}
			);
		}

		return $templates;
	}

	/**
	 * Register WooCommerce email templates with the template registry.
	 *
	 * @param Templates_Registry $templates_registry The template registry instance.
	 * @return Templates_Registry
	 */
	public function register_templates( Templates_Registry $templates_registry ) {
		$templates   = array();
		$templates[] = new WooEmailTemplate();

		foreach ( $templates as $template ) {
			$the_template = new Template(
				$this->template_prefix,
				$template->get_slug(),
				$template->get_title(),
				$template->get_description(),
				$template->get_content(),
				array( Integration::EMAIL_POST_TYPE )
			);
			$templates_registry->register( $the_template );
		}

		return $templates_registry;
	}
}
PK     [1]8l=[R  R  #  EmailEditor/WooContentProcessor.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\EmailEditor;

use Automattic\WooCommerce\EmailEditor\Email_Css_Inliner;
use Automattic\WooCommerce\EmailEditor\Email_Editor_Container;
use Automattic\WooCommerce\EmailEditor\Engine\Theme_Controller;

/**
 * Class responsible for extracting the main content from a WC_Email object.
 */
class WooContentProcessor {

	/**
	 * Email theme controller
	 * We use it to get email CSS.
	 *
	 * @var Theme_Controller
	 */
	private $theme_controller;

	/**
	 * CSS inliner
	 *
	 * @var Email_Css_Inliner
	 */
	private $css_inliner;

	/**
	 * Constructor
	 */
	public function __construct() {
		$this->theme_controller = Email_Editor_Container::container()->get( Theme_Controller::class );
		$this->css_inliner      = new Email_Css_Inliner();
	}

	/**
	 * Get the WooCommerce content excluding headers and footers.
	 *
	 * @param \WC_Email $wc_email WooCommerce email.
	 * @return string
	 */
	public function get_woo_content( \WC_Email $wc_email ): string {
		$woo_content          = $this->capture_woo_content( $wc_email );
		$woo_content_with_css = $this->inline_css( $woo_content );
		return $this->get_html_body_content( $woo_content_with_css );
	}

	/**
	 * Filter CSS for the email.
	 * The CSS was from email editor was already inlined.
	 * The method hookes to woocommerce_email_styles and removes CSS rules that we don't want to apply to the email.
	 *
	 * @param string $css CSS.
	 * @return string
	 */
	public function prepare_css( string $css ): string {
		remove_filter( 'woocommerce_email_styles', array( $this, 'prepare_css' ) );
		// Remove color and font-family declarations from WooCommerce CSS.
		$css = preg_replace( '/color\s*:\s*[^;]+;/', '', $css );
		$css = preg_replace( '/font-family\s*:\s*[^;]+;/', '', $css );
		return $css;
	}

	/**
	 * Get the content of the body tag from the HTML.
	 *
	 * @param string $html HTML.
	 * @return string
	 */
	private function get_html_body_content( string $html ): string {
		// Extract content between <body> and </body> tags using regex.
		if ( preg_match( '/<body[^>]*>(.*?)<\/body>/is', $html, $matches ) ) {
			return $matches[1];
		}
		return $html;
	}

	/**
	 * Inline the CSS from the email theme and user email settings.
	 *
	 * @param string $woo_content WooCommerce content.
	 * @return string
	 */
	private function inline_css( string $woo_content ): string {
		if ( empty( $woo_content ) ) {
			return '';
		}
		$css = $this->theme_controller->get_stylesheet_for_rendering();
		return $this->css_inliner->from_html( $woo_content )->inline_css( $css )->render();
	}

	/**
	 * Capture the WooCommerce content excluding headers and footers.
	 *
	 * @param \WC_Email $wc_email WooCommerce email.
	 * @return string
	 */
	private function capture_woo_content( \WC_Email $wc_email ): string {
		return $wc_email->get_block_editor_email_template_content();
	}
}
PK     [1]^U<    )  EmailEditor/PersonalizationTagManager.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\EmailEditor;

use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
use Automattic\WooCommerce\Internal\EmailEditor\PersonalizationTags\CustomerTagsProvider;
use Automattic\WooCommerce\Internal\EmailEditor\PersonalizationTags\OrderTagsProvider;
use Automattic\WooCommerce\Internal\EmailEditor\PersonalizationTags\SiteTagsProvider;
use Automattic\WooCommerce\Internal\EmailEditor\PersonalizationTags\StoreTagsProvider;

defined( 'ABSPATH' ) || exit;

/**
 * Manages personalization tags for WooCommerce emails.
 *
 * @internal
 */
class PersonalizationTagManager {

	/**
	 * The customer related tags provider.
	 *
	 * @var CustomerTagsProvider
	 */
	private $customer_tags_provider;

	/**
	 * The order related tags provider.
	 *
	 * @var OrderTagsProvider
	 */
	private $order_tags_provider;

	/**
	 * The site related tags provider.
	 *
	 * @var SiteTagsProvider
	 */
	private $site_tags_provider;

	/**
	 * The store related tags provider.
	 *
	 * @var StoreTagsProvider
	 */
	private $store_tags_provider;

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->customer_tags_provider = new CustomerTagsProvider();
		$this->order_tags_provider    = new OrderTagsProvider();
		$this->site_tags_provider     = new SiteTagsProvider();
		$this->store_tags_provider    = new StoreTagsProvider();
	}

	/**
	 * Initialize the personalization tag manager.
	 *
	 * @internal
	 * @return void
	 */
	final public function init(): void {
		add_filter( 'woocommerce_email_editor_register_personalization_tags', array( $this, 'register_personalization_tags' ) );
	}

	/**
	 * Register WooCommerce personalization tags with the registry.
	 *
	 * @param Personalization_Tags_Registry $registry The personalization tags registry.
	 * @return Personalization_Tags_Registry
	 */
	public function register_personalization_tags( Personalization_Tags_Registry $registry ) {
		$this->customer_tags_provider->register_tags( $registry );
		$this->order_tags_provider->register_tags( $registry );
		$this->site_tags_provider->register_tags( $registry );
		$this->store_tags_provider->register_tags( $registry );

		return $registry;
	}
}
PK     [1] "  "  H  EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails;

use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\EmailEditor\Integration;
use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\WooEmailTemplate;
use Automattic\WooCommerce\Utilities\StringUtil;

/**
 * Class WCTransactionalEmailPostsGenerator
 *
 * Handles the generation of WooCommerce transactional email templates.
 * This class is responsible for initializing and managing default email templates,
 * as well as generating new templates when required.
 *
 * @package Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
 */
class WCTransactionalEmailPostsGenerator {
	/**
	 * WooCommerce Email Template Manager instance.
	 *
	 * @var WCTransactionalEmailPostsManager
	 */
	private $template_manager;

	/**
	 * Default templates.
	 *
	 * @var array<string, \WC_Email>
	 */
	private $default_templates = array();

	/**
	 * Transient name.
	 *
	 * @var string
	 */
	private $transient_name = 'wc_email_editor_initial_templates_generated';

	/**
	 * Constructor.
	 *
	 * Initializes the WCTransactionalEmailPostsGenerator by setting up the template manager.
	 */
	public function __construct() {
		$this->template_manager = WCTransactionalEmailPostsManager::get_instance();
	}

	/**
	 * Initialize the email template generator.
	 *
	 * This function initializes the email template generator by loading the default templates
	 * and generating initial email templates if needed.
	 *
	 * @internal
	 */
	public function initialize() {
		if ( Constants::get_constant( 'WC_VERSION' ) === get_transient( $this->transient_name ) ) {
			// if templates are already generated, we don't need to run this function again.
			return true;
		}

		$this->init_default_transactional_emails();
		$this->generate_initial_email_templates();
	}

	/**
	 * Initialize the default WooCommerce Transactional Emails.
	 *
	 * This function initializes the default templates for the core transactional emails.
	 * It fetches all the emails from WooCommerce and filters them to include only the core transactional emails.
	 */
	public function init_default_transactional_emails() {
		if ( ! empty( $this->default_templates ) ) {
			// If the default templates are already initialized, we don't need to run this function again.
			return;
		}

		$core_transactional_emails = WCTransactionalEmails::get_transactional_emails();

		$wc_emails = \WC_Emails::instance();
		/**
		 * WooCommerce Transactional Emails instance.
		 *
		 * @var \WC_Email[]
		 */
		$email_types = $wc_emails->get_emails();

		// Filter the emails to include only the core transactional emails.
		$email_types = array_filter(
			$email_types,
			function ( $email ) use ( $core_transactional_emails ) {
				return in_array( $email->id, $core_transactional_emails, true );
			}
		);

		$this->default_templates = array_reduce(
			$email_types,
			function ( $acc, $email ) {
				$acc[ $email->id ] = $email;
				return $acc;
			},
			array()
		);
	}

	/**
	 * Get the email template for the given email.
	 *
	 * Looks for the initial email block content in plugins/woocommerce/templates/emails/block.
	 *
	 * @param \WC_Email $email The email object.
	 * @return string The email template.
	 */
	public function get_email_template( $email ) {
		$template_name = ! empty( $email->template_block ) ? $email->template_block : str_replace( 'plain', 'block', $email->template_plain );

		try {
			$template_html = wc_get_template_html(
				$template_name,
				array(),
				'',
				$email->template_base ?? ''
			);
		} catch ( \Exception $e ) {
			// wc_get_template_html() uses ob_start(), so we need to clean the output buffer if an exception is thrown.
			if ( ob_get_level() > 0 ) {
				ob_end_clean();
			}
			$template_html = '';
		}

		// wc_get_template_html does not throw an error when the template is not found.
		// We need to check if the template is not found by checking the template_html content.
		$has_template_error =
			StringUtil::contains( $template_html, 'No such file or directory', false ) ||
			StringUtil::contains( $template_html, 'Failed to open stream', false ) ||
			StringUtil::contains( $template_html, 'Warning: include', false );

		if ( is_wp_error( $template_html ) || empty( $template_html ) || $has_template_error ) {
			$default_template_name = 'emails/block/default-block-content.php';
			$template_html         = wc_get_template_html(
				$default_template_name,
				array()
			);
		}

		return $template_html;
	}

	/**
	 * Generate initial email templates.
	 *
	 * This function generates the initial email templates for the core transactional emails.
	 * It checks if the templates are already generated and if not, it generates them.
	 *
	 * @return bool True if the templates are generated, false otherwise.
	 */
	public function generate_initial_email_templates() {
		$core_transactional_emails = WCTransactionalEmails::get_transactional_emails();

		$templates_to_generate = array();
		foreach ( $core_transactional_emails as $email_type ) {
			if ( empty( $this->template_manager->get_email_template_post_id( $email_type ) ) ) {
				$templates_to_generate[] = $email_type;
			}
		}

		if ( empty( $templates_to_generate ) ) {
			return;
		}

		$result = $this->generate_email_templates( $templates_to_generate );

		if ( is_wp_error( $result ) ) {
			return false;
		}

		set_transient( $this->transient_name, Constants::get_constant( 'WC_VERSION' ), WEEK_IN_SECONDS );

		// Flush rewrite rules to ensure the new templates are loaded.
		flush_rewrite_rules();

		return true;
	}

	/**
	 * Generate email template if it doesn't exist.
	 *
	 * This function generates an email template if it doesn't exist.
	 *
	 * @param string $email_type The email type.
	 * @return int The post ID of the generated template.
	 * @throws \Exception When post creation fails.
	 */
	public function generate_email_template_if_not_exists( $email_type ) {
		$email_data = $this->default_templates[ $email_type ];

		if ( $this->template_manager->get_email_template_post_id( $email_type ) || empty( $email_data ) ) {
			return $this->template_manager->get_email_template_post_id( $email_type );
		}

		return $this->generate_single_template( $email_type, $email_data );
	}

	/**
	 * Generate email templates.
	 *
	 * This function generates the email templates for the given email types.
	 *
	 * @param array $templates_to_generate The email types to generate.
	 */
	public function generate_email_templates( $templates_to_generate ) {
		global $wpdb;

		$core_emails = array_filter(
			$this->default_templates,
			function ( $email_id ) use ( $templates_to_generate ) {
				return in_array( $email_id, $templates_to_generate, true );
			},
			ARRAY_FILTER_USE_KEY
		);

		if ( empty( $core_emails ) ) {
			return false;
		}

		// Start transaction.
		$wpdb->query( 'START TRANSACTION' );

		try {
			foreach ( $core_emails as $email_type => $email_data ) {
				$this->generate_single_template( $email_type, $email_data );
			}

			$wpdb->query( 'COMMIT' );
			return true;

		} catch ( \Exception $e ) {
			$wpdb->query( 'ROLLBACK' );
			return new \WP_Error( 'email_generation_failed', $e->getMessage() );
		}
	}

	/**
	 * Generate a single email template.
	 *
	 * This function generates a single email template post and sets its postmeta association.
	 *
	 * @param string    $email_type    The email type.
	 * @param \WC_Email $email_data The transactional email data.
	 * @return int The post ID of the generated template.
	 * @throws \Exception When post creation fails.
	 */
	private function generate_single_template( $email_type, $email_data ) {
		$post_data = array(
			'post_type'    => Integration::EMAIL_POST_TYPE,
			'post_status'  => 'publish',
			'post_name'    => $email_type,
			'post_title'   => $email_data->get_title(),
			'post_excerpt' => $email_data->get_description(),
			'post_content' => $this->get_email_template( $email_data ),
			'meta_input'   => array(
				'_wp_page_template' => ( new WooEmailTemplate() )->get_slug(),
			),
		);

		/**
		 * Filter the email content post data before creating the post.
		 *
		 * Allows third-party integrators to modify the post data (title, content, meta, etc.)
		 * before the email content post is created.
		 *
		 * @since 10.5.0
		 * @param array     $post_data  The post data array to be used for wp_insert_post().
		 * @param string    $email_type The email type identifier (e.g., 'customer_processing_order').
		 * @param \WC_Email $email_data The WooCommerce email object.
		 */
		$post_data = apply_filters( 'woocommerce_email_content_post_data', $post_data, $email_type, $email_data );

		$post_id = wp_insert_post( $post_data, true );

		if ( is_wp_error( $post_id ) ) {
			throw new \Exception( esc_html( $post_id->get_error_message() ) );
		}

		$this->template_manager->save_email_template_post_id( $email_type, $post_id );

		return $post_id;
	}
}
PK     [1](  (  F  EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsManager.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails;

/**
 * Class responsible for managing WooCommerce email editor post templates.
 */
class WCTransactionalEmailPostsManager {
	const WC_OPTION_NAME = 'woocommerce_email_templates_%_post_id';

	/**
	 * Cache group for email template lookups.
	 *
	 * @var string
	 */
	const CACHE_GROUP = 'wc_block_email_templates';

	/**
	 * Cache expiration time in seconds (1 week).
	 *
	 * @var int
	 */
	const CACHE_EXPIRATION = WEEK_IN_SECONDS;

	/**
	 * Singleton instance of the class.
	 *
	 * @var WCTransactionalEmailPostsManager|null
	 */
	private static $instance = null;

	/**
	 * In-memory cache for post_id to email_type lookups within the same request.
	 *
	 * @var array<int|string, string|null>
	 */
	private $post_id_to_email_type_cache = array();

	/**
	 * In-memory cache for email class name (e.g. 'WC_Email_Customer_New_Account') lookups within the same request.
	 *
	 * @var array<string, string|null>
	 */
	private $email_class_name_cache = array();

	/**
	 * Gets the singleton instance of the class.
	 *
	 * @return WCTransactionalEmailPostsManager Instance of the class.
	 */
	public static function get_instance() {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Retrieves the email post by its type.
	 *
	 * Type here refers to the email type, e.g. 'customer_new_account' from the WC_Email->id property.
	 *
	 * @param string $email_type The type of email to retrieve.
	 * @return \WP_Post|null The email post if found, null otherwise.
	 */
	public function get_email_post( $email_type ) {
		$post_id = $this->get_email_template_post_id( $email_type );

		if ( ! $post_id ) {
			return null;
		}

		$post = get_post( $post_id );

		if ( ! $post instanceof \WP_Post ) {
			return null;
		}

		return $post;
	}

	/**
	 * Retrieves the WooCommerce email type from the options table when post ID is provided.
	 *
	 * Uses multi-level caching:
	 * 1. In-memory cache for the same request
	 * 2. WordPress object cache for cross-request caching
	 * 3. Database query if cache is not available.
	 *
	 * @param int|string $post_id The post ID.
	 * @param bool       $skip_cache Whether to skip the cache. Defaults to false.
	 * @return string|null The WooCommerce email type if found, null otherwise.
	 */
	public function get_email_type_from_post_id( $post_id, $skip_cache = false ) {
		// Early return if post_id is invalid.
		if ( empty( $post_id ) ) {
			return null;
		}

		$post_id   = (int) $post_id;
		$cache_key = $this->get_cache_key_for_post_id( $post_id );

		if ( ! $skip_cache ) {
			// Check in-memory cache first (fastest).
			if ( array_key_exists( $post_id, $this->post_id_to_email_type_cache ) ) {
				return $this->post_id_to_email_type_cache[ $post_id ];
			}

			// Check WordPress object cache.
			$email_type = wp_cache_get( $cache_key, self::CACHE_GROUP );

			if ( ! empty( $email_type ) ) {
				$this->post_id_to_email_type_cache[ $post_id ] = $email_type;
				return $email_type;
			}
		}

		// Cache miss - perform database query.
		global $wpdb;

		$option_name = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s AND option_value = %s LIMIT 1",
				self::WC_OPTION_NAME,
				$post_id
			)
		);

		if ( empty( $option_name ) ) {
			return null;
		}

		$email_type = $this->get_email_type_from_option_name( $option_name );

		// Store in both caches.
		$this->post_id_to_email_type_cache[ $post_id ] = $email_type;
		wp_cache_set( $cache_key, $email_type, self::CACHE_GROUP, self::CACHE_EXPIRATION );

		return $email_type;
	}

	/**
	 * Checks if an email template exists for the given type.
	 *
	 * Type here refers to the email type, e.g. 'customer_new_account' from the WC_Email->id property.
	 *
	 * @param string $email_type The type of email to check.
	 * @return bool True if the template exists, false otherwise.
	 */
	public function template_exists( $email_type ) {
		return null !== $this->get_email_post( $email_type );
	}

	/**
	 * Saves the post ID for a specific email template type.
	 *
	 * @param string $email_type The type of email template e.g. 'customer_new_account' from the WC_Email->id property.
	 * @param int    $post_id    The post ID to save.
	 */
	public function save_email_template_post_id( $email_type, $post_id ) {
		$option_name = $this->get_option_name( $email_type );

		$previous_id = get_option( $option_name );

		update_option( $option_name, $post_id );

		// Invalidate caches for the previous mapping (if any).
		if ( ! empty( $previous_id ) ) {
			$this->invalidate_cache_for_template( (int) $previous_id, 'post_id' );
		}

		// Invalidate cache for the new post_id.
		$this->invalidate_cache_for_template( $email_type, 'email_type' );

		// Update in-memory caches with the new values.
		$this->post_id_to_email_type_cache[ $post_id ] = $email_type;
		wp_cache_set( $this->get_cache_key_for_post_id( $post_id ), $email_type, self::CACHE_GROUP, self::CACHE_EXPIRATION );
	}

	/**
	 * Gets the post ID for a specific email template type.
	 *
	 * Uses multi-level caching for improved performance.
	 *
	 * @param string $email_type The type of email template e.g. 'customer_new_account' from the WC_Email->id property.
	 * @return int|false The post ID if found, false otherwise.
	 */
	public function get_email_template_post_id( $email_type ) {
		// Check in-memory cache first.
		$post_id_from_cache = array_search( $email_type, $this->post_id_to_email_type_cache, true );
		if ( false !== $post_id_from_cache ) {
			return $post_id_from_cache;
		}

		$option_name = $this->get_option_name( $email_type );
		$post_id     = get_option( $option_name );

		if ( ! empty( $post_id ) ) {
			$post_id = (int) $post_id;

			// Store in in-memory cache.
			$this->post_id_to_email_type_cache[ $post_id ] = $email_type;
		}

		return $post_id;
	}

	/**
	 * Deletes the post ID for a specific email template type.
	 *
	 * @param string $email_type The type of email template e.g. 'customer_new_account' from the WC_Email->id property.
	 */
	public function delete_email_template( $email_type ) {
		$option_name = $this->get_option_name( $email_type );
		$post_id     = get_option( $option_name );

		if ( ! $post_id ) {
			return;
		}

		delete_option( $option_name );

		// Invalidate cache.
		$this->invalidate_cache_for_template( $post_id, 'post_id' );
	}

	/**
	 * Invalidates cache entries for a specific post ID or email type.
	 *
	 * @param int|string $value The value to invalidate cache for.
	 * @param string     $type The type of value to invalidate cache for. Can be 'post_id' or 'email_type'.
	 * @return void
	 */
	private function invalidate_cache_for_template( $value, $type = 'post_id' ) {
		$post_id_array = array();
		if ( 'post_id' === $type ) {
			$post_id_array[] = (int) $value;
		} elseif ( 'email_type' === $type ) {
			// Get all the post IDs that map to the email type.
			$post_id_array = array_merge( $post_id_array, array_unique( array_keys( $this->post_id_to_email_type_cache, $value, true ) ) );
		}

		foreach ( $post_id_array as $post_id ) {
			unset( $this->post_id_to_email_type_cache[ $post_id ] );

			// Delete from WordPress object cache.
			$cache_key = $this->get_cache_key_for_post_id( $post_id );
			wp_cache_delete( $cache_key, self::CACHE_GROUP );
		}
	}

	/**
	 * Clears all in-memory caches.
	 *
	 * Useful for testing and debugging. Note that this only clears in-memory caches,
	 * not the WordPress object cache entries (which will expire naturally).
	 */
	public function clear_caches() {
		$this->post_id_to_email_type_cache = array();
		$this->email_class_name_cache      = array();
	}

	/**
	 * Gets the cache key for a specific post ID.
	 *
	 * @param int $post_id The post ID.
	 * @return string The cache key e.g. 'post_id_to_email_type_123'.
	 */
	public function get_cache_key_for_post_id( $post_id ) {
		return 'post_id_to_email_type_' . $post_id;
	}

	/**
	 * Gets the option name for a specific email type.
	 *
	 * @param string $email_type The type of email template e.g. 'customer_new_account' from the WC_Email->id property.
	 * @return string The option name e.g. 'woocommerce_email_templates_customer_new_account_post_id'
	 */
	private function get_option_name( $email_type ) {
		return str_replace( '%', $email_type, self::WC_OPTION_NAME );
	}

	/**
	 * Gets the email type from the option name.
	 *
	 * @param string $option_name The option name e.g. 'woocommerce_email_templates_customer_new_account_post_id'.
	 * @return string The email type e.g. 'customer_new_account'
	 */
	private function get_email_type_from_option_name( $option_name ) {
		return str_replace(
			array(
				'woocommerce_email_templates_',
				'_post_id',
			),
			'',
			$option_name
		);
	}

	/**
	 * Gets the email type class name, e.g. 'WC_Email_Customer_New_Account' from the email ID (e.g. 'customer_new_account' from the WC_Email->id property).
	 *
	 * Uses in-memory caching to avoid repeated iterations through all registered emails.
	 *
	 * @param string $email_id The email ID.
	 * @return string|null The email type class name.
	 */
	public function get_email_type_class_name_from_email_id( $email_id ) {
		// Early return if email_id is invalid.
		if ( empty( $email_id ) ) {
			return null;
		}

		// Check in-memory cache first.
		if ( isset( $this->email_class_name_cache[ $email_id ] ) ) {
			return $this->email_class_name_cache[ $email_id ];
		}

		/**
		 * Get all the emails registered in WooCommerce.
		 *
		 * @var \WC_Email[]
		 */
		$emails = WC()->mailer()->get_emails();

		// Build the cache for all emails at once to avoid repeated iterations.
		foreach ( $emails as $email ) {
			$this->email_class_name_cache[ $email->id ] = get_class( $email );
		}

		// Return the requested email class name if it exists.
		return $this->email_class_name_cache[ $email_id ] ?? null;
	}

	/**
	 * Gets the email type class name, e.g. 'WC_Email_Customer_New_Account' from the post ID.
	 *
	 * @param int $post_id The post ID.
	 * @return string|null The email type class name.
	 */
	public function get_email_type_class_name_from_post_id( $post_id ) {
		// Early return if post_id is invalid.
		if ( empty( $post_id ) ) {
			return null;
		}

		return $this->get_email_type_class_name_from_email_id( $this->get_email_type_from_post_id( $post_id ) );
	}
}
PK     [1]bG    ;  EmailEditor/WCTransactionalEmails/WCTransactionalEmails.phpnu         <?php
declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails;

use Automattic\WooCommerce\Utilities\FeaturesUtil;

/**
 * Class WCTransactionalEmails
 *
 * Handles the initialization and management of WooCommerce transactional emails.
 *
 * @package Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
 */
class WCTransactionalEmails {

	/**
	 * Array of core transactional email types.
	 *
	 * @var array
	 */
	public static $core_transactional_emails = array(
		'cancelled_order',
		'customer_cancelled_order',
		'customer_completed_order',
		'customer_failed_order',
		'customer_invoice',
		'customer_new_account',
		'customer_note',
		'customer_on_hold_order',
		'customer_processing_order',
		'customer_refunded_order',
		'customer_reset_password',
		'failed_order',
		'new_order',
	);

	/**
	 * Email template generator instance.
	 *
	 * @var WCTransactionalEmailPostsGenerator
	 */
	private $email_template_generator;

	/**
	 * Constructor.
	 *
	 * Initializes the WCTransactionalEmailPostsGenerator by setting up the template generator.
	 */
	public function __construct() {
		$this->email_template_generator = new WCTransactionalEmailPostsGenerator();
	}

	/**
	 * Initialize the class.
	 *
	 * @internal
	 */
	final public function init() {
		add_action( 'current_screen', array( $this, 'init_email_templates' ), 50 );
	}

	/**
	 * Get the Core WooCommerce transactional emails for the block editor.
	 *
	 * @return array
	 */
	public static function get_transactional_emails() {
		$emails = self::$core_transactional_emails;

		if ( FeaturesUtil::feature_is_enabled( 'point_of_sale' ) ) {
			$emails[] = 'customer_pos_completed_order';
			$emails[] = 'customer_pos_refunded_order';
		}

		if ( FeaturesUtil::feature_is_enabled( 'fulfillments' ) ) {
			$fulfillment_emails = array(
				'customer_fulfillment_created',
				'customer_fulfillment_updated',
				'customer_fulfillment_deleted',
			);
			$emails             = array_merge( $emails, $fulfillment_emails );
		}

		/**
		 * Filter the transactional emails for the block editor.
		 *
		 * @param array $transactional_emails The transactional emails.
		 * @return array
		 * @since 9.9.0
		 */
		return apply_filters( 'woocommerce_transactional_emails_for_block_editor', $emails );
	}

	/**
	 * Initialize email templates on WooCommerce admin pages.
	 */
	public function init_email_templates() {
		if ( ! function_exists( 'wc_get_screen_ids' ) ) {
			return;
		}

		$screen = get_current_screen();

		$wc_screen_ids = array_merge(
			wc_get_screen_ids(),
			array(
				'woocommerce_page_wc-admin',
				'edit-woo_email',
			)
		);

		if ( ! $screen || ! in_array( $screen->id, $wc_screen_ids, true ) ) {
			return;
		}

		// run only on WooCommerce admin pages.
		$this->email_template_generator->initialize();
	}
}
PK     [1]&cEl
  l
  4  EmailEditor/EmailPatterns/WooEmailContentPattern.phpnu         <?php declare(strict_types = 1);

namespace Automattic\WooCommerce\Internal\EmailEditor\EmailPatterns;

use Automattic\WooCommerce\EmailEditor\Engine\Patterns\Abstract_Pattern;
use Automattic\WooCommerce\Internal\EmailEditor\Integration;

/**
 * Pattern class for WooCommerce email content.
 *
 * Provides a default content pattern that can be used in WooCommerce email templates.
 */
class WooEmailContentPattern extends Abstract_Pattern {
	/**
	 * Pattern name identifier.
	 *
	 * @var string
	 */
	public $name = 'woo-email-content-pattern';

	/**
	 * Allowed block types for this pattern.
	 *
	 * @var array
	 */
	public $block_types = array();

	/**
	 * Template types where this pattern can be used.
	 *
	 * @var array
	 */
	public $template_types = array( 'email-template' );    // Required.

	/**
	 * Categories this pattern belongs to.
	 *
	 * @var array
	 */
	public $categories = array( 'email-contents' );        // Optional.

	/**
	 * Pattern namespace.
	 *
	 * @var string
	 */
	public $namespace = 'woocommerce';      // Required.

	/**
	 * List of supported post types.
	 *
	 * @var string[]
	 */
	protected $post_types = array( Integration::EMAIL_POST_TYPE );
	/**
	 * Get the pattern content.
	 *
	 * @return string HTML content for the pattern.
	 */
	public function get_content(): string {
		return '<!-- wp:group {"style":{"spacing":{"padding":{"right":"var:preset|spacing|20","left":"var:preset|spacing|20"}}},"layout":{"type":"constrained"}} -->
<div class="wp-block-group" style="padding-right:var(--wp--preset--spacing--20);padding-left:var(--wp--preset--spacing--20)"><!-- wp:heading -->
<h2 class="wp-block-heading">Woo Email Content</h2>
<!-- /wp:heading -->

<!-- wp:paragraph -->
<p>Here comes content composed of supported core blocks and Woo transactional email block(s).</p>
<!-- /wp:paragraph -->

<!-- wp:woocommerce/email-content {"lock":{"move":false,"remove":true}} -->
<div class="wp-block-woocommerce-email-content">##WOO_CONTENT##</div>
<!-- /wp:woocommerce/email-content -->

<!-- wp:buttons {"layout":{"justifyContent":"center"}} -->
<div class="wp-block-buttons"><!-- wp:button {"style":{"color":{"background":"#873eff"}}} -->
<div class="wp-block-button"><a class="wp-block-button__link has-background wp-element-button" style="background-color:#873eff">Shop now</a></div>
<!-- /wp:button --></div>
<!-- /wp:buttons --></div>
<!-- /wp:group -->';
	}

	/**
	 * Get the pattern title.
	 *
	 * @return string Localized pattern title.
	 */
	public function get_title(): string {
		/* translators: Name of a content pattern used as starting content of an email */
		return __( 'Woo Email Content Pattern', 'woocommerce' );
	}
}
PK     [1]Bm    0  EmailEditor/EmailPatterns/PatternsController.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\EmailEditor\EmailPatterns;

defined( 'ABSPATH' ) || exit;

/**
 * Controller class for registering block patterns used in the email editor.
 */
class PatternsController {

	/**
	 * Initialize the controller.
	 *
	 * @internal
	 */
	final public function init(): void {
		$this->register_patterns();
	}

	/**
	 * Register all email editor block patterns.
	 */
	public function register_patterns(): void {
		$patterns   = array();
		$patterns[] = new WooEmailContentPattern();
		foreach ( $patterns as $pattern ) {
			register_block_pattern( $pattern->get_namespace() . '/' . $pattern->get_name(), $pattern->get_properties() );
		}
	}
}
PK     [1]Yx  x    EmailEditor/PageRenderer.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\EmailEditor;

use Automattic\WooCommerce\EmailEditor\Engine\Assets_Manager;
use Automattic\WooCommerce\EmailEditor\Engine\Templates\Template;
use Automattic\WooCommerce\EmailEditor\Engine\Templates\Templates_Registry;
use Automattic\WooCommerce\EmailEditor\Email_Editor_Container;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;

defined( 'ABSPATH' ) || exit;

/**
 * Class responsible for rendering the email editor page.
 */
class PageRenderer {
	/**
	 * Template registry instance.
	 *
	 * @var Templates_Registry
	 */
	private Templates_Registry $template_registry;

	/**
	 * Assets manager instance.
	 *
	 * @var Assets_Manager
	 */
	private Assets_Manager $assets_manager;

	/**
	 * Constructor.
	 */
	public function __construct() {
		$editor_container        = Email_Editor_Container::container();
		$this->template_registry = $editor_container->get( Templates_Registry::class );

		$assets_manager = $editor_container->get( Assets_Manager::class );
		$assets_manager->set_assets_path( WC_ABSPATH . WC_ADMIN_DIST_JS_FOLDER . 'email-editor/' );
		$assets_manager->set_assets_url( WC()->plugin_url() . '/' . WC_ADMIN_DIST_JS_FOLDER . 'email-editor/' );
		$this->assets_manager = $assets_manager;
	}

	/**
	 * Render the email editor page.
	 */
	public function render() {
		$post_id     = isset( $_GET['post'] ) ? intval( $_GET['post'] ) : 0;  // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- We are not verifying the nonce here because we are not using the nonce in the function and the data is okay in this context (WP-admin errors out gracefully).
		$template_id = isset( $_GET['template'] ) ? sanitize_text_field( wp_unslash( $_GET['template'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- We are not verifying the nonce here because we are not using the nonce in the function and the data is okay in this context (WP-admin errors out gracefully).
		$post_type   = $template_id ? 'wp_template' : Integration::EMAIL_POST_TYPE;
		$post_id     = $template_id ? $template_id : $post_id;

		$edited_item = $this->get_edited_item( $post_id, $post_type );

		if ( ! $edited_item ) {
			return;
		}

		add_filter( 'woocommerce_email_editor_script_localization_data', array( $this, 'update_localized_data' ) );
		// Load the email editor integration script.
		// The JS file is located in plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/index.ts.
		WCAdminAssets::register_script( 'wp-admin-scripts', 'email-editor-integration', true );
		WCAdminAssets::register_style( 'email-editor-integration', 'style', true );

		$this->assets_manager->load_editor_assets( $edited_item, 'wc-admin-email-editor-integration' );
		$this->assets_manager->render_email_editor_html();

		remove_filter(
			'woocommerce_email_editor_script_localization_data',
			array( $this, 'update_localized_data' ),
			10
		);
	}

	/**
	 * Update localized script data.
	 *
	 * @param array $localized_data Original localized data.
	 * @return array
	 */
	public function update_localized_data( array $localized_data ): array {
		// Fetch all email types from WooCommerce including those added by other plugins.
		$wc_emails   = \WC_Emails::instance();
		$email_types = $wc_emails->get_emails();
		$email_types = array_values(
			array_map(
				function ( $email ) {
					return array(
						'value' => $email->id,
						'label' => $email->title,
						'id'    => get_class( $email ),
					);
				},
				$email_types
			)
		);

		$localized_data['email_types'] = $email_types;
		// Modify email editor settings.
		$localized_data['editor_settings']['isFullScreenForced']     = true;
		$localized_data['editor_settings']['displaySendEmailButton'] = false;

		return $localized_data;
	}

	/**
	 * Check if the post can be edited in the email editor.
	 *
	 * @param int|string $id   The post ID.
	 * @param string     $type The post type.
	 * @return \WP_Post|\WP_Block_Template|null Edited item or null if the item is not found.
	 */
	private function get_edited_item( $id, string $type ) {
		// When we pass template we need to verify that the template is registered in the email template registry.
		if ( 'wp_template' === $type ) {
			$wp_template = get_block_template( $id );
			if ( ! $wp_template ) {
				return null;
			}
			$email_template = $this->template_registry->get_by_slug( $wp_template->slug );
			return $email_template instanceof Template ? $wp_template : null;
		}

		// For post we need to verify that the post is of the email type.
		$post = get_post( $id );
		if ( $post instanceof \WP_Post && $type === $post->post_type ) {
			return $post;
		}

		return null;
	}
}
PK     [1]N6      EmailEditor/Package.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\EmailEditor;

defined( 'ABSPATH' ) || exit;

/**
 * This class is used to initialize the email editor package.
 *
 * It is a wrapper around the Automattic\WooCommerce\EmailEditor\Package class and
 * ensures that the email editor package is only initialized if the block editor feature flag is enabled.
 */
class Package {
	/**
	 * Version.
	 *
	 * @var string
	 */
	const VERSION = \Automattic\WooCommerce\EmailEditor\Package::VERSION;

	/**
	 * Package active.
	 *
	 * @var bool
	 */
	private static $package_active = false;

	/**
	 * Init the package.
	 *
	 * @internal
	 */
	final public static function init() {
		self::$package_active = get_option( 'woocommerce_feature_block_email_editor_enabled', 'no' ) === 'yes'; // init is called pretty early. Cant use FeaturesUtil.

		// we only want to initialize the package if the block editor feature flag is enabled.
		if ( ! self::$package_active ) {
			return;
		}

		self::initialize();
		\Automattic\WooCommerce\EmailEditor\Package::init();
	}

	/**
	 * Return the version of the package.
	 *
	 * @return string
	 */
	public static function get_version() {
		return \Automattic\WooCommerce\EmailEditor\Package::get_version();
	}

	/**
	 * Return the path to the package.
	 *
	 * @return string
	 */
	public static function get_path() {
		return \Automattic\WooCommerce\EmailEditor\Package::get_path();
	}

	/**
	 * Initialize the email editor integration by fetching the class from the container.
	 *
	 * @return void
	 */
	public static function initialize() {
		$container = wc_get_container();
		$container->get( Integration::class );
	}
}
PK     [1]"3    .  EmailEditor/TransactionalEmailPersonalizer.phpnu         <?php
/**
 * Class for handling transactional email personalization.
 *
 * @package Automattic\WooCommerce\Internal\EmailEditor
 */

declare(strict_types = 1);

namespace Automattic\WooCommerce\Internal\EmailEditor;

use Automattic\WooCommerce\EmailEditor\Email_Editor_Container;
use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;

/**
 * Class TransactionalEmailPersonalizer that internally uses the Personalizer class.
 * The inheritance is not used here because Personalizer needs to pass Personalization_Tags_Registry and
 * the combination of two different dependency injection containers is not possible.
 */
class TransactionalEmailPersonalizer {
	/**
	 * Personalizer instance for handling email content personalization.
	 *
	 * @var Personalizer
	 */
	private Personalizer $personalizer;

	/**
	 * Constructor.
	 */
	public function __construct() {
		$editor_container   = Email_Editor_Container::container();
		$this->personalizer = $editor_container->get( Personalizer::class );
	}

	/**
	 * Personalize transactional email content with specific handling.
	 *
	 * @param string    $content The content to personalize.
	 * @param \WC_Email $email The WooCommerce email object.
	 * @return string The personalized content.
	 */
	public function personalize_transactional_content( string $content, \WC_Email $email ): string {
		$this->configure_context_by_email( $email );
		return $this->personalizer->personalize_content( $content );
	}

	/**
	 * Configure personalization context based on WooCommerce email object.
	 *
	 * @param \WC_Email $email The WooCommerce email object.
	 * @return void
	 */
	public function configure_context_by_email( \WC_Email $email ): void {
		$prepared_context = $this->prepare_context_data( $this->personalizer->get_context(), $email );
		$this->personalizer->set_context( $prepared_context );
	}

	/**
	 * Prepare context data for email personalization.
	 * Adds new order specific context data.
	 *
	 * @param array     $previous_context Previous version of context data.
	 * @param \WC_Email $email The WooCommerce email object.
	 * @return array Context data for personalization
	 */
	public function prepare_context_data( array $previous_context, \WC_Email $email ): array {
		$context = $previous_context;

		/**
		 * Filters the context data for email personalization.
		 *
		 * @since 10.5.0
		 * @param array     $context Previous version of context data.
		 * @param \WC_Email $email The WooCommerce email object.
		 * @return array Context data for personalization
		 */
		$context = apply_filters( 'woocommerce_email_editor_integration_personalizer_context_data', $context, $email );

		if ( ! is_array( $context ) ) {
			$context = $previous_context;
		}

		$context['recipient_email'] = $email->get_recipient();
		$context['order']           = $email->object instanceof \WC_Order ? $email->object : null;
		// For emails of type new_user or reset_password we want to set user directly from the object.
		if ( $email->object instanceof \WP_User ) {
			$context['wp_user'] = $email->object;
		} elseif ( $email->object instanceof \WC_Order ) {
			$context['wp_user'] = $email->object->get_user();
		} else {
			$context['wp_user'] = null;
		}
		$context['wc_email'] = $email;

		return $context;
	}
}
PK     [1]^Pi  i    EmailEditor/Logger.phpnu         <?php
/**
 * This file is part of the WooCommerce package.
 *
 * @package Automattic\WooCommerce\Internal\EmailEditor
 */

declare(strict_types = 1);

namespace Automattic\WooCommerce\Internal\EmailEditor;

use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger_Interface;
use WC_Log_Levels;

/**
 * WooCommerce logger adapter for the email editor.
 *
 * This class adapts the WooCommerce logger to work with the email editor logging interface.
 */
class Logger implements Email_Editor_Logger_Interface {
	/**
	 * The WooCommerce logger instance.
	 *
	 * @var \WC_Logger_Interface
	 */
	private \WC_Logger_Interface $wc_logger;

	/**
	 * Constructor.
	 *
	 * @param \WC_Logger_Interface $wc_logger The WooCommerce logger instance.
	 */
	public function __construct( \WC_Logger_Interface $wc_logger ) {
		$this->wc_logger = $wc_logger;
	}

	/**
	 * Checks if the log level should be handled.
	 *
	 * @param string $level The log level.
	 * @return bool Whether the log level should be handled.
	 */
	private function should_handle( string $level ): bool {
		/**
		 * Controls the logging threshold for the email editor.
		 *
		 * @param string $threshold The log level threshold.
		 *
		 * @since 10.2.0
		 */
		$logging_threshold = apply_filters( 'woocommerce_email_editor_logging_threshold', WC_Log_Levels::WARNING );

		return WC_Log_Levels::get_level_severity( $logging_threshold ) <= WC_Log_Levels::get_level_severity( $level );
	}

	/**
	 * Adds emergency level log message.
	 *
	 * @param string $message The log message.
	 * @param array  $context The log context.
	 * @return void
	 */
	public function emergency( string $message, array $context = array() ): void {
		$this->log( WC_Log_Levels::EMERGENCY, $message, $context );
	}

	/**
	 * Adds alert level log message.
	 *
	 * @param string $message The log message.
	 * @param array  $context The log context.
	 * @return void
	 */
	public function alert( string $message, array $context = array() ): void {
		$this->log( WC_Log_Levels::ALERT, $message, $context );
	}

	/**
	 * Adds critical level log message.
	 *
	 * @param string $message The log message.
	 * @param array  $context The log context.
	 * @return void
	 */
	public function critical( string $message, array $context = array() ): void {
		$this->log( WC_Log_Levels::CRITICAL, $message, $context );
	}

	/**
	 * Adds error level log message.
	 *
	 * @param string $message The log message.
	 * @param array  $context The log context.
	 * @return void
	 */
	public function error( string $message, array $context = array() ): void {
		$this->log( WC_Log_Levels::ERROR, $message, $context );
	}

	/**
	 * Adds warning level log message.
	 *
	 * @param string $message The log message.
	 * @param array  $context The log context.
	 * @return void
	 */
	public function warning( string $message, array $context = array() ): void {
		$this->log( WC_Log_Levels::WARNING, $message, $context );
	}

	/**
	 * Adds notice level log message.
	 *
	 * @param string $message The log message.
	 * @param array  $context The log context.
	 * @return void
	 */
	public function notice( string $message, array $context = array() ): void {
		$this->log( WC_Log_Levels::NOTICE, $message, $context );
	}

	/**
	 * Adds info level log message.
	 *
	 * @param string $message The log message.
	 * @param array  $context The log context.
	 * @return void
	 */
	public function info( string $message, array $context = array() ): void {
		$this->log( WC_Log_Levels::INFO, $message, $context );
	}

	/**
	 * Adds debug level log message.
	 *
	 * @param string $message The log message.
	 * @param array  $context The log context.
	 * @return void
	 */
	public function debug( string $message, array $context = array() ): void {
		$this->log( WC_Log_Levels::DEBUG, $message, $context );
	}

	/**
	 * Logs with an arbitrary level.
	 *
	 * @param string $level   The log level.
	 * @param string $message The log message.
	 * @param array  $context The log context.
	 * @return void
	 */
	public function log( string $level, string $message, array $context = array() ): void {
		if ( $this->should_handle( $level ) ) {
			$this->wc_logger->log( $level, $message, $context );
		}
	}
}
PK     [1]9B  B  +  ReceiptRendering/ReceiptRenderingEngine.phpnu         <?php

namespace Automattic\WooCommerce\Internal\ReceiptRendering;

use Automattic\WooCommerce\Internal\Orders\PaymentInfo;
use Automattic\WooCommerce\Internal\TransientFiles\TransientFilesEngine;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Exception;
use WC_Abstract_Order;

/**
 * This class generates printable order receipts as transient files (see src/Internal/TransientFiles).
 * The template for the receipt is Templates/order-receipt.php, it uses the variables returned as array keys
 * 'get_order_data'.
 *
 * When a receipt is generated for an order with 'generate_receipt' the receipt file name is stored as order meta
 * (see RECEIPT_FILE_NAME_META_KEY) for later retrieval with 'get_existing_receipt'. Beware! The files pointed
 * by such meta keys could have expired and thus no longer exist. 'get_existing_receipt' will appropriately return null
 * if the meta entry exists but the file doesn't.
 */
class ReceiptRenderingEngine {

	private const FONT_SIZE = 12;

	private const LINE_HEIGHT = self::FONT_SIZE * 1.5;

	private const ICON_HEIGHT = self::LINE_HEIGHT;

	private const ICON_WIDTH = self::ICON_HEIGHT * ( 4 / 3 );

	private const MARGIN = 16;

	private const TITLE_FONT_SIZE = 24;

	private const FOOTER_FONT_SIZE = 10;

	/**
	 * This array must contain all the names of the files in the CardIcons directory (without extension),
	 * except 'unknown'.
	 */
	private const KNOWN_CARD_TYPES = array( 'amex', 'diners', 'discover', 'interac', 'jcb', 'mastercard', 'visa' );

	/**
	 * Order meta key that stores the file name of the last generated receipt.
	 */
	public const RECEIPT_FILE_NAME_META_KEY = '_receipt_file_name';

	/**
	 * The instance of TransientFilesEngine to use.
	 *
	 * @var TransientFilesEngine
	 */
	private $transient_files_engine;

	/**
	 * The instance of LegacyProxy to use.
	 *
	 * @var LegacyProxy
	 */
	private $legacy_proxy;

	/**
	 * Initializes the class.
	 *
	 * @param TransientFilesEngine $transient_files_engine The instance of TransientFilesEngine to use.
	 * @param LegacyProxy          $legacy_proxy The instance of LegacyProxy to use.
	 * @internal
	 */
	final public function init( TransientFilesEngine $transient_files_engine, LegacyProxy $legacy_proxy ) {
		$this->transient_files_engine = $transient_files_engine;
		$this->legacy_proxy           = $legacy_proxy;
	}

	/**
	 * Get the (transient) file name of the receipt for an order, creating a new file if necessary.
	 *
	 * If $force_new is false, and a receipt file for the order already exists (as pointed by order meta key
	 * RECEIPT_FILE_NAME_META_KEY), then the name of the already existing receipt file is returned.
	 *
	 * If $force_new is true, OR if it's false but no receipt file for the order exists (no order meta with key
	 * RECEIPT_FILE_NAME_META_KEY exists, OR it exists but the file it points to doesn't), then a new receipt
	 * transient file is created with the supplied expiration date (defaulting to "tomorrow"), and the new file name
	 * is stored as order meta with the key RECEIPT_FILE_NAME_META_KEY.
	 *
	 * @param int|WC_Abstract_Order $order The order object or order id to get the receipt for.
	 * @param string|int|null       $expiration_date GMT expiration date formatted as yyyy-mm-dd, or as a timestamp, or null for "tomorrow".
	 * @param bool                  $force_new If true, creates a new receipt file even if one already exists for the order.
	 * @return string|null The file name of the new or already existing receipt file, null if an order id is passed and the order doesn't exist.
	 * @throws InvalidArgumentException Invalid expiration date (wrongly formatted, or it's a date in the past).
	 * @throws Exception The directory to store the file doesn't exist and can't be created.
	 */
	public function generate_receipt( $order, $expiration_date = null, bool $force_new = false ): ?string {
		if ( ! $order instanceof WC_Abstract_Order ) {
			$order = wc_get_order( $order );
			if ( false === $order ) {
				return null;
			}
		}

		if ( ! $force_new ) {
			$existing_receipt_filename = $this->get_existing_receipt( $order );
			if ( ! is_null( $existing_receipt_filename ) ) {
				return $existing_receipt_filename;
			}
		}

		$expiration_date ??=
			$this->legacy_proxy->call_function(
				'gmdate',
				'Y-m-d',
				$this->legacy_proxy->call_function(
					'strtotime',
					'+1 days'
				)
			);

		/**
		 * Filter to customize the set of data that is used to render the receipt.
		 * The formatted line items aren't included, use the woocommerce_printable_order_receipt_formatted_line_item
		 * filter to customize those.
		 *
		 * See the value returned by the 'get_order_data' and 'get_woo_pay_data' methods for a reference of
		 * the structure of the data.
		 *
		 * See the template file, Templates/order-receipt.php, for reference on how the data is used.
		 *
		 * @param array $data The original set of data.
		 * @param WC_Abstract_Order $order The order for which the receipt is being generated.
		 * @returns array The updated set of data.
		 *
		 * @since 9.0.0
		 */
		$data = apply_filters( 'woocommerce_printable_order_receipt_data', $this->get_order_data( $order ), $order );

		$formatted_line_items = array();
		$row_index            = 0;
		foreach ( $data['line_items'] as $line_item_data ) {
			$quantity_data          = isset( $line_item_data['quantity'] ) ? " × {$line_item_data['quantity']}" : '';
			$line_item_display_data = array(
				'inner_html'    => "<td>{$line_item_data['title']}$quantity_data</td><td>{$line_item_data['amount']}</td>",
				'tr_attributes' => array(),
				'row_index'     => $row_index++,
			);

			/**
			 * Filter to customize the HTML that gets rendered for each order line item in the receipt.
			 *
			 * $line_item_display_data will be passed (and must be returned) with the following keys:
			 *
			 * - inner_html: the HTML text that will go inside a <tr> element, note that
			 *               wp_kses_post will be applied to this text before actual rendering.
			 * - tr_attributes: attributes (e.g. 'class', 'data', 'style') that will be applied to the <tr> element,
			 *                  as an associative array of attribute name => value.
			 * - row_index: a number that starts at 0 and increases by one for each processed line item.
			 *
			 * $line_item_data will contain the following keys:
			 *
			 * - type: One of 'product', 'subtotal', 'discount', 'fee', 'shipping_total', 'taxes_total', 'amount_paid'
			 * - title
			 * - amount (formatted with wc_price)
			 * - item (only when type is 'product'), and instance of WC_Order_Item
			 * - quantity (only when type is 'product')
			 *
			 * @param string $line_item_display_data Data to use to generate the HTML table row to be rendered for the line item.
			 * @param array $line_item_data The relevant data for the line item for which the HTML table row is being generated.
			 * @param WC_Abstract_Order $order The order for which the receipt is being generated.
			 * @return string The actual data to use to generate the HTML for the line item.
			 *
			 * @since 9.0.0
			 */
			$line_item_display_data = apply_filters( 'woocommerce_printable_order_receipt_line_item_display_data', $line_item_display_data, $line_item_data, $order );
			$attributes             = '';
			foreach ( $line_item_display_data['tr_attributes'] as $attribute_name => $attribute_value ) {
				$attribute_value = esc_attr( $attribute_value );
				$attributes     .= " $attribute_name=\"$attribute_value\"";
			}
			$formatted_line_items[] = wp_kses_post( "<tr$attributes>{$line_item_display_data['inner_html']}</tr>" );
		}
		$data['formatted_line_items'] = $formatted_line_items;

		ob_start();
		$css = include __DIR__ . '/Templates/order-receipt-css.php';
		$css = ob_get_contents();
		ob_end_clean();

		/**
		 * Filter to customize the CSS styles used to render the receipt.
		 *
		 * See Templates/order-receipt.php for guidance on the existing HTMl elements and their ids.
		 * See Templates/order-receipt-css.php for the original CSS styles.
		 *
		 * @param string $css The original CSS styles to use.
		 * @param WC_Abstract_Order $order The order for which the receipt is being generated.
		 * @return string The actual CSS styles that will be used.
		 *
		 * @since 9.0.0
		 */
		$data['css'] = apply_filters( 'woocommerce_printable_order_receipt_css', $css, $order );

		$default_template_path = __DIR__ . '/Templates/order-receipt.php';

		/**
		 * Filter the order receipt template path.
		 *
		 * @since 9.2.0
		 * @hook wc_get_template
		 * @param  string $template      The template path.
		 * @param  string $template_name The template name.
		 * @param  array  $args          The available data for the template.
		 * @param string  $template_path The template path.
		 * @param string  $default_path  The default template path.
		 */
		$template_path = apply_filters(
			'wc_get_template',
			$default_template_path,
			'ReceiptRendering/order-receipt.php',
			$data,
			$default_template_path,
			$default_template_path
		);

		if ( ! file_exists( $template_path ) ) {
			$template_path = $default_template_path;
		}

		ob_start();
		include $template_path;
		$rendered_template = ob_get_contents();
		ob_end_clean();

		$file_name = $this->transient_files_engine->create_transient_file( $rendered_template, $expiration_date );

		$order->update_meta_data( self::RECEIPT_FILE_NAME_META_KEY, $file_name );
		$order->save_meta_data();

		return $file_name;
	}

	/**
	 * Get the file name of an existing receipt file for an order.
	 *
	 * A receipt is considered to be available for the order if there's an order meta entry with key
	 * RECEIPT_FILE_NAME_META_KEY AND the transient file it points to exists AND it has not expired.
	 *
	 * @param WC_Abstract_Order $order The order object or order id to get the receipt for.
	 * @return string|null The receipt file name, or null if no receipt is currently available for the order.
	 * @throws Exception Thrown if a wrong file path is passed.
	 */
	public function get_existing_receipt( $order ): ?string {
		if ( ! $order instanceof WC_Abstract_Order ) {
			$order = wc_get_order( $order );
			if ( false === $order ) {
				return null;
			}
		}

		$existing_receipt_filename = $order->get_meta( self::RECEIPT_FILE_NAME_META_KEY, true );

		if ( '' === $existing_receipt_filename ) {
			return null;
		}

		$file_path = $this->transient_files_engine->get_transient_file_path( $existing_receipt_filename );
		if ( is_null( $file_path ) ) {
			return null;
		}

		return $this->transient_files_engine->file_has_expired( $file_path ) ? null : $existing_receipt_filename;
	}

	/**
	 * Get the order data that the receipt template will use.
	 *
	 * @param WC_Abstract_Order $order The order to get the data from.
	 * @return array The order data as an associative array.
	 */
	private function get_order_data( WC_Abstract_Order $order ): array {
		$store_name = get_bloginfo( 'name' );
		if ( $store_name ) {
			/* translators: %s = store name */
			$receipt_title = sprintf( __( 'Receipt from %s', 'woocommerce' ), $store_name );
		} else {
			$receipt_title = __( 'Receipt', 'woocommerce' );
		}

		$order_id = $order->get_id();
		if ( $order_id ) {
			/* translators: %d = order id */
			$summary_title = sprintf( __( 'Summary: Order #%d', 'woocommerce' ), $order->get_id() );
		} else {
			$summary_title = __( 'Summary', 'woocommerce' );
		}

		$get_price_args = array( 'currency' => $order->get_currency() );

		$line_items_info = array();
		$line_items      = $order->get_items( 'line_item' );
		foreach ( $line_items as $line_item ) {
			$line_item_product = $line_item->get_product();
			if ( false === $line_item_product ) {
				$line_item_title = $line_item->get_name();
			} else {
				$line_item_title =
					( $line_item_product instanceof \WC_Product_Variation ) ?
						( wc_get_product( $line_item_product->get_parent_id() )->get_name() ) . '. ' . $line_item_product->get_attribute_summary() :
						$line_item_product->get_name();
			}
			$line_items_info[] = array(
				'type'     => 'product',
				'item'     => $line_item,
				'title'    => wp_kses( $line_item_title, array() ),
				'quantity' => $line_item->get_quantity(),
				'amount'   => wc_price( $line_item->get_subtotal(), $get_price_args ),
			);
		}

		$line_items_info[] = array(
			'type'   => 'subtotal',
			'title'  => __( 'Subtotal', 'woocommerce' ),
			'amount' => wc_price( $order->get_subtotal(), $get_price_args ),
		);

		$coupon_names = ArrayUtil::select( $order->get_coupons(), 'get_name', ArrayUtil::SELECT_BY_OBJECT_METHOD );
		if ( ! empty( $coupon_names ) ) {
			$line_items_info[] = array(
				'type'   => 'discount',
				/* translators: %s = comma-separated list of coupon codes */
				'title'  => sprintf( __( 'Discount (%s)', 'woocommerce' ), join( ', ', $coupon_names ) ),
				'amount' => wc_price( -$order->get_total_discount(), $get_price_args ),
			);
		}

		foreach ( $order->get_fees() as $fee ) {
			$name              = $fee->get_name();
			$line_items_info[] = array(
				'type'   => 'fee',
				'title'  => '' === $name ? __( 'Fee', 'woocommerce' ) : $name,
				'amount' => wc_price( $fee->get_total(), $get_price_args ),
			);
		}

		$shipping_total = (float) $order->get_shipping_total();
		if ( $shipping_total ) {
			$line_items_info[] = array(
				'type'   => 'shipping_total',
				'title'  => __( 'Shipping', 'woocommerce' ),
				'amount' => wc_price( $order->get_shipping_total(), $get_price_args ),
			);
		}

		$total_taxes = 0;
		foreach ( $order->get_taxes() as $tax ) {
			$total_taxes += (float) $tax->get_tax_total() + (float) $tax->get_shipping_tax_total();
		}

		if ( $total_taxes ) {
			$line_items_info[] = array(
				'type'   => 'taxes_total',
				'title'  => __( 'Taxes', 'woocommerce' ),
				'amount' => wc_price( $total_taxes, $get_price_args ),
			);
		}

		$is_order_failed = $order->has_status( 'failed' );

		$line_items_info[] = array(
			'type'   => 'amount_paid',
			'title'  => $is_order_failed ? __( 'Amount', 'woocommerce' ) : __( 'Amount Paid', 'woocommerce' ),
			'amount' => wc_price( $order->get_total(), $get_price_args ),
		);

		$payment_info = $this->get_woo_pay_data( $order );

		return array(
			'order'                     => $order,
			'constants'                 => array(
				'font_size'        => self::FONT_SIZE,
				'margin'           => self::MARGIN,
				'title_font_size'  => self::TITLE_FONT_SIZE,
				'footer_font_size' => self::FOOTER_FONT_SIZE,
				'line_height'      => self::LINE_HEIGHT,
				'icon_height'      => self::ICON_HEIGHT,
				'icon_width'       => self::ICON_WIDTH,
			),
			'texts'                     => array(
				'receipt_title'                => $receipt_title,
				'amount_paid_section_title'    => $is_order_failed ? __( 'Order Total', 'woocommerce' ) : __( 'Amount Paid', 'woocommerce' ),
				'date_paid_section_title'      => $is_order_failed ? __( 'Order Date', 'woocommerce' ) : __( 'Date Paid', 'woocommerce' ),
				'payment_method_section_title' => __( 'Payment method', 'woocommerce' ),
				'payment_status_section_title' => __( 'Payment status', 'woocommerce' ),
				'payment_status'               => $is_order_failed ? __( 'Failed', 'woocommerce' ) : __( 'Success', 'woocommerce' ),
				'summary_section_title'        => $summary_title,
				'order_notes_section_title'    => __( 'Notes', 'woocommerce' ),
				'app_name'                     => __( 'Application Name', 'woocommerce' ),
				'aid'                          => __( 'AID', 'woocommerce' ),
				'account_type'                 => __( 'Account Type', 'woocommerce' ),
			),
			'formatted_amount'          => wc_price( $order->get_total(), $get_price_args ),
			'formatted_date'            => wc_format_datetime( $order->get_date_paid() ?? $order->get_date_created() ),
			'line_items'                => $line_items_info,
			'payment_method'            => $order->get_payment_method_title(),
			'show_payment_method_title' => empty( $payment_info['card_last4'] ) && empty( $payment_info['brand'] ),
			'notes'                     => array_map( 'get_comment_text', $order->get_customer_order_notes() ),
			'payment_info'              => $payment_info,
		);
	}

	/**
	 * Get the order data related to WooCommerce Payments.
	 *
	 * It will return null if any of these is true:
	 *
	 * - Payment method is not "woocommerce_payments".
	 * - WooCommerce Payments is not installed.
	 * - No intent id is stored for the order.
	 * - Retrieving the payment information from Stripe API (providing the intent id) fails.
	 * - The received data set doesn't contain the expected information.
	 *
	 * @param WC_Abstract_Order $order The order to get the data from.
	 * @return array|null An array of payment information for the order, or null if not available.
	 */
	private function get_woo_pay_data( WC_Abstract_Order $order ): ?array {
		$card_info = PaymentInfo::get_card_info( $order );

		if ( empty( $card_info ) ) {
			return null;
		}

		// Backcompat for custom templates.
		$card_info['card_icon']  = $card_info['icon'];
		$card_info['card_last4'] = $card_info['last4'];

		return $card_info;
	}
}
PK     [1]70ݟ    3  ReceiptRendering/ReceiptRenderingRestController.phpnu         <?php

namespace Automattic\WooCommerce\Internal\ReceiptRendering;

use Automattic\WooCommerce\Internal\TransientFiles\TransientFilesEngine;
use \WP_REST_Server;
use \WP_REST_Request;
use \WP_Error;
use Automattic\WooCommerce\Internal\RestApiControllerBase;

/**
 * Controller for the REST endpoints associated to the receipt rendering engine.
 * The endpoints require the read_shop_order capability for the order at hand.
 */
class ReceiptRenderingRestController extends RestApiControllerBase {

	/**
	 * Get the WooCommerce REST API namespace for the class.
	 *
	 * @return string
	 */
	protected function get_rest_api_namespace(): string {
		return 'order-receipts';
	}

	/**
	 * Register the REST API endpoints handled by this controller.
	 */
	public function register_routes() {
		register_rest_route(
			$this->route_namespace,
			'/orders/(?P<id>[\d]+)/receipt',
			array(
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'create_order_receipt' ),
					'permission_callback' => fn( $request ) => $this->check_permission( $request, 'read_shop_order', $request->get_param( 'id' ) ),
					'args'                => $this->get_args_for_create_order_receipt(),
					'schema'              => $this->get_schema_for_get_and_post_order_receipt(),
				),
			)
		);

		register_rest_route(
			$this->route_namespace,
			'/orders/(?P<id>[\d]+)/receipt',
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'get_order_receipt' ),
					'permission_callback' => fn( $request ) => $this->check_permission( $request, 'read_shop_order', $request->get_param( 'id' ) ),
					'args'                => $this->get_args_for_get_order_receipt(),
					'schema'              => $this->get_schema_for_get_and_post_order_receipt(),
				),
			)
		);

	}

	/**
	 * Handle the GET /orders/id/receipt:
	 *
	 * Return the data for a receipt if it exists, or a 404 error if it doesn't.
	 *
	 * @param WP_REST_Request $request The received request.
	 * @return array|WP_Error
	 */
	public function get_order_receipt( WP_REST_Request $request ) {
		$order_id = $request->get_param( 'id' );
		$filename = wc_get_container()->get( ReceiptRenderingEngine::class )->get_existing_receipt( $order_id );

		return is_null( $filename ) ?
			new WP_Error( 'woocommerce_rest_not_found', __( 'Receipt not found', 'woocommerce' ), array( 'status' => 404 ) ) :
			$this->get_response_for_file( $filename );
	}

	/**
	 * Handle the POST /orders/id/receipt:
	 *
	 * Return the data for a receipt if it exists, or create a new receipt and return its data otherwise.
	 *
	 * Optional query string arguments:
	 *
	 * expiration_date: formatted as yyyy-mm-dd.
	 * expiration_days: a number, 0 is today, 1 is tomorrow, etc.
	 * force_new: defaults to false, if true, create a new receipt even if one already exists for the order.
	 *
	 * If neither expiration_date nor expiration_days are supplied, the default is expiration_days = 1.
	 *
	 * @param WP_REST_Request $request The received request.
	 * @return array|WP_Error Request response or an error.
	 */
	public function create_order_receipt( WP_REST_Request $request ) {
		$expiration_date =
			$request->get_param( 'expiration_date' ) ??
			gmdate( 'Y-m-d', strtotime( "+{$request->get_param('expiration_days')} days" ) );

		$order_id = $request->get_param( 'id' );

		$filename = wc_get_container()->get( ReceiptRenderingEngine::class )->generate_receipt( $order_id, $expiration_date, $request->get_param( 'force_new' ) );

		return is_null( $filename ) ?
			new WP_Error( 'woocommerce_rest_not_found', __( 'Order not found', 'woocommerce' ), array( 'status' => 404 ) ) :
			$this->get_response_for_file( $filename );
	}

	/**
	 * Formats the response for both the GET and POST endpoints.
	 *
	 * @param string $filename The filename to return the information for.
	 * @return array The data for the actual response to be returned.
	 */
	private function get_response_for_file( string $filename ): array {
		$expiration_date = TransientFilesEngine::get_expiration_date( $filename );
		$public_url      = wc_get_container()->get( TransientFilesEngine::class )->get_public_url( $filename );

		return array(
			'receipt_url'     => $public_url,
			'expiration_date' => $expiration_date,
		);
	}

	/**
	 * Get the accepted arguments for the GET request.
	 *
	 * @return array[] The accepted arguments for the GET request.
	 */
	private function get_args_for_get_order_receipt(): array {
		return array(
			'id' => array(
				'description' => __( 'Unique identifier of the order.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
		);
	}

	/**
	 * Get the schema for both the GET and the POST requests.
	 *
	 * @return array[]
	 */
	private function get_schema_for_get_and_post_order_receipt(): array {
		$schema               = $this->get_base_schema();
		$schema['properties'] = array(
			'receipt_url'     => array(
				'description' => __( 'Public url of the receipt.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
			'expiration_date' => array(
				'description' => __( 'Expiration date of the receipt, formatted as yyyy-mm-dd.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
		);

		return $schema;
	}

	/**
	 * Get the accepted arguments for the POST request.
	 *
	 * @return array[]
	 */
	private function get_args_for_create_order_receipt(): array {
		return array(
			'id'              => array(
				'description' => __( 'Unique identifier of the order.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
			'expiration_date' => array(
				'description' => __( 'Expiration date formatted as yyyy-mm-dd.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'default'     => null,
			),
			'expiration_days' => array(
				'description' => __( 'Number of days to be added to the current date to get the expiration date.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'default'     => 1,
			),
			'force_new'       => array(
				'description' => __( 'True to force the creation of a new receipt even if one already exists and has not expired yet.', 'woocommerce' ),
				'type'        => 'boolean',
				'required'    => false,
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'default'     => false,
			),
		);
	}
}
PK     [1].5    0  ReceiptRendering/Templates/order-receipt-css.phpnu         <?php /* phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped */ ?>
html { font-family: "Helvetica Neue", sans-serif; font-size: <?php echo $data['constants']['font_size']; ?>pt; }
header { margin-top: <?php echo $data['constants']['margin']; ?>; }
h1 { font-size: <?php echo $data['constants']['title_font_size']; ?>pt; font-weight: 500; text-align: center; }
h3 { color: #707070; margin:0; }
table {
	background-color:#F5F5F5;
	width:100%;
	color: #707070;
	margin: <?php echo $data['constants']['margin'] / 2; ?>pt 0;
	padding: <?php echo $data['constants']['margin'] / 2; ?>pt;
}
table td:last-child { width: 30%; text-align: right; }
table tr:last-child { color: #000000; font-weight: bold; }
footer {
	font-size: <?php echo $data['constants']['footer_font_size']; ?>pt;
	border-top: 1px solid #707070;
	margin-top: <?php echo $data['constants']['margin']; ?>pt;
	padding-top: <?php echo $data['constants']['margin']; ?>pt;
}
p { line-height: <?php echo $data['constants']['line_height']; ?>pt; margin: 0 0 <?php echo $data['constants']['margin'] / 2; ?> 0; }
<?php if ( isset( $data['payment_info'] ) ) { ?>
.card-icon {
	width: <?php echo $data['constants']['icon_width']; ?>pt;
	height: <?php echo $data['constants']['icon_height']; ?>pt;
	vertical-align: top;
	background-repeat: no-repeat;
	background-position-y: center;
	display: inline-block;
	background-image: url("data:image/svg+xml;base64,<?php echo $data['payment_info']['card_icon']; ?>");
}
<?php } ?>
PK     [1]a
  
  ,  ReceiptRendering/Templates/order-receipt.phpnu         <?php /* phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped */ ?>
<html>
<head>
	<meta http-equiv="Content-Type" content="<?php bloginfo( 'html_type' ); ?>; charset=<?php echo get_option( 'blog_charset' ); ?>" />
	<style>
<?php echo $data['css']; ?>
	</style>
</head>

<body>
<header>
	<h1 id="receipt_title"><?php echo $data['texts']['receipt_title']; ?></h1>
	<h3 id="amount_paid_section_title"><?php echo strtoupper( $data['texts']['amount_paid_section_title'] ); ?></h3>
	<p>
		<?php echo $data['formatted_amount']; ?>
	</p>
	<h3 id="date_paid_section_title"><?php echo strtoupper( $data['texts']['date_paid_section_title'] ); ?></h3>
	<p>
		<?php echo $data['formatted_date']; ?>
	</p>

	<h3 id="payment_status_section_title"><?php echo strtoupper( $data['texts']['payment_status_section_title'] ); ?></h3>
	<p><?php echo $data['texts']['payment_status']; ?></p>

	<?php if ( isset( $data['payment_method'] ) ) { ?>
		<h3 id="payment_method_section_title"><?php echo strtoupper( $data['texts']['payment_method_section_title'] ); ?></h3>
		<p>
			<?php if ( $data['show_payment_method_title'] ) { ?>
				<span><?php echo $data['payment_method']; ?></span>
			<?php } else { ?>
				<span class="card-icon"></span>
				<?php if ( $data['payment_info']['card_last4'] ) { ?>
					- <?php echo $data['payment_info']['card_last4']; ?>
				<?php } ?>
			<?php } ?>
		</p>
	<?php } ?>
</header>

<h3 id="summary_section_title"><?php echo strtoupper( $data['texts']['summary_section_title'] ); ?></h3>
<table id="line_items">
	<?php
	foreach ( $data['formatted_line_items'] as $formatted_line_item ) {
		echo $formatted_line_item;
	}
	?>
</table>

<?php if ( ! empty( $data['notes'] ) ) { ?>
	<h3 id="order_notes_section_title"><?php echo strtoupper( $data['texts']['order_notes_section_title'] ); ?></h3>
	<?php foreach ( $data['notes'] as $note ) { ?>
		<p><?php echo $note; ?></p>
		<?php
	}
}

if (
	! empty( $data['payment_info']['app_name'] )
	|| ! empty( $data['payment_info']['aid'] )
	|| ! empty( $data['payment_info']['account_type'] )
) {
	?>
	<footer>
		<p id="payment_info">
			<?php
			if ( $data['payment_info']['app_name'] ) {
				echo $data['texts']['app_name'] . ': ' . $data['payment_info']['app_name'] . '<br/>';
			}
			if ( $data['payment_info']['aid'] ) {
				echo $data['texts']['aid'] . ': ' . $data['payment_info']['aid'] . '<br/>';
			}
			if ( $data['payment_info']['account_type'] ) {
				echo $data['texts']['account_type'] . ': ' . $data['payment_info']['account_type'];
			}
			?>
		</p>
	</footer>
<?php } ?>

</body>
</html>
PK     [1]K
  
  I  Features/ProductBlockEditor/ProductTemplates/DownloadableProductTrait.phpnu         <?php
/**
 * DownloadableProductTrait
 */

namespace Automattic\WooCommerce\Internal\Features\ProductBlockEditor\ProductTemplates;

use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates\GroupInterface;

/**
 * Downloadable Product Trait.
 */
trait DownloadableProductTrait {
	/**
	 * Adds downloadable blocks to the given parent block.
	 *
	 * @param GroupInterface $parent_block The parent block.
	 */
	private function add_downloadable_product_blocks( $parent_block ) {
		// Downloads section.
		$product_downloads_section_group = $parent_block->add_section(
			array(
				'id'             => 'product-downloads-section-group',
				'order'          => 50,
				'attributes'     => array(
					'blockGap' => 'unit-40',
				),
				'hideConditions' => array(
					array(
						'expression' => 'postType === "product" && editedProduct.type !== "simple"',
					),
				),
			)
		);

		$product_downloads_section_group->add_block(
			array(
				'id'         => 'product-downloadable',
				'blockName'  => 'woocommerce/product-toggle-field',
				'order'      => 10,
				'attributes' => array(
					'property'      => 'downloadable',
					'label'         => __( 'Include downloads', 'woocommerce' ),
					'checkedHelp'   => __( 'Add any files you\'d like to make available for the customer to download after purchasing, such as instructions or warranty info.', 'woocommerce' ),
					'uncheckedHelp' => __( 'Add any files you\'d like to make available for the customer to download after purchasing, such as instructions or warranty info.', 'woocommerce' ),
				),
			)
		);

		$product_downloads_section_group->add_subsection(
			array(
				'id'             => 'product-downloads-section',
				'order'          => 20,
				'attributes'     => array(
					'title'       => __( 'Downloads', 'woocommerce' ),
					'description' => sprintf(
						/* translators: %1$s: Downloads settings link opening tag. %2$s: Downloads settings link closing tag. */
						__( 'Add any files you\'d like to make available for the customer to download after purchasing, such as instructions or warranty info. Store-wide updates can be managed in your %1$sproduct settings%2$s.', 'woocommerce' ),
						'<a href="' . admin_url( 'admin.php?page=wc-settings&tab=products&section=downloadable' ) . '" target="_blank" rel="noreferrer">',
						'</a>'
					),
				),
				'hideConditions' => array(
					array(
						'expression' => 'editedProduct.downloadable !== true',
					),
				),
			)
		)->add_block(
			array(
				'id'        => 'product-downloads',
				'blockName' => 'woocommerce/product-downloads-field',
				'order'     => 10,
			)
		);
	}
}
PK     [1]а    F  Features/ProductBlockEditor/ProductTemplates/SimpleProductTemplate.phpnu         <?php
/**
 * SimpleProductTemplate
 */

namespace Automattic\WooCommerce\Internal\Features\ProductBlockEditor\ProductTemplates;

use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates\ProductFormTemplateInterface;
use Automattic\WooCommerce\Enums\CatalogVisibility;
use Automattic\WooCommerce\Enums\ProductStockStatus;
use Automattic\WooCommerce\Enums\ProductTaxStatus;
use WC_Tax;

/**
 * Simple Product Template.
 */
class SimpleProductTemplate extends AbstractProductFormTemplate implements ProductFormTemplateInterface {
	use DownloadableProductTrait;

	/**
	 * The context name used to identify the editor.
	 */
	const GROUP_IDS = array(
		'GENERAL'         => 'general',
		'ORGANIZATION'    => 'organization',
		'INVENTORY'       => 'inventory',
		'SHIPPING'        => 'shipping',
		'VARIATIONS'      => 'variations',
		'LINKED_PRODUCTS' => 'linked-products',
	);

	/**
	 * SimpleProductTemplate constructor.
	 */
	public function __construct() {
		$this->add_group_blocks();
		$this->add_general_group_blocks();
		$this->add_organization_group_blocks();
		$this->add_inventory_group_blocks();
		$this->add_shipping_group_blocks();
		$this->add_variation_group_blocks();
		$this->add_linked_products_group_blocks();
	}

	/**
	 * Get the template ID.
	 */
	public function get_id(): string {
		return 'simple-product';
	}

	/**
	 * Get the template title.
	 */
	public function get_title(): string {
		return __( 'Simple Product Template', 'woocommerce' );
	}

	/**
	 * Get the template description.
	 */
	public function get_description(): string {
		return __( 'Template for the simple product form', 'woocommerce' );
	}

	/**
	 * Adds the group blocks to the template.
	 */
	private function add_group_blocks() {
		$this->add_group(
			array(
				'id'         => $this::GROUP_IDS['GENERAL'],
				'order'      => 10,
				'attributes' => array(
					'title' => __( 'General', 'woocommerce' ),
				),
			)
		);

		// Variations tab.
		$variations_hide_conditions   = array();
		$variations_hide_conditions[] = array(
			'expression' => 'editedProduct.type === "grouped"',
		);
		$variations_hide_conditions[] = array(
			'expression' => 'editedProduct.type === "external"',
		);

		$this->add_group(
			array(
				'id'             => $this::GROUP_IDS['VARIATIONS'],
				'order'          => 20,
				'attributes'     => array(
					'title' => __( 'Variations', 'woocommerce' ),
				),
				'hideConditions' => $variations_hide_conditions,
			)
		);

		$this->add_group(
			array(
				'id'         => $this::GROUP_IDS['ORGANIZATION'],
				'order'      => 30,
				'attributes' => array(
					'title' => __( 'Organization', 'woocommerce' ),
				),
			)
		);
		$this->add_group(
			array(
				'id'         => $this::GROUP_IDS['INVENTORY'],
				'order'      => 50,
				'attributes' => array(
					'title' => __( 'Inventory', 'woocommerce' ),
				),
			)
		);
		$shipping_hide_conditions   = array();
		$shipping_hide_conditions[] = array(
			'expression' => 'editedProduct.type === "grouped"',
		);
		$shipping_hide_conditions[] = array(
			'expression' => 'editedProduct.type === "external"',
		);

		$this->add_group(
			array(
				'id'             => $this::GROUP_IDS['SHIPPING'],
				'order'          => 60,
				'attributes'     => array(
					'title' => __( 'Shipping', 'woocommerce' ),
				),
				'hideConditions' => $shipping_hide_conditions,
			)
		);

		// Linked Products tab.
		$this->add_group(
			array(
				'id'         => $this::GROUP_IDS['LINKED_PRODUCTS'],
				'order'      => 70,
				'attributes' => array(
					'title' => __( 'Linked products', 'woocommerce' ),
				),
			)
		);
	}

	/**
	 * Adds the general group blocks to the template.
	 */
	private function add_general_group_blocks() {
		$is_calc_taxes_enabled = wc_tax_enabled();
		$general_group         = $this->get_group_by_id( $this::GROUP_IDS['GENERAL'] );
		$general_group->add_block(
			array(
				'id'         => 'product_variation_notice_general_tab',
				'blockName'  => 'woocommerce/product-has-variations-notice',
				'order'      => 10,
				'attributes' => array(
					'content'    => __( 'This product has options, such as size or color. You can manage each variation\'s images, downloads, and other details individually.', 'woocommerce' ),
					'buttonText' => __( 'Go to Variations', 'woocommerce' ),
					'type'       => 'info',
				),
			)
		);
		// Basic Details Section.
		$basic_details = $general_group->add_section(
			array(
				'id'         => 'basic-details',
				'order'      => 10,
				'attributes' => array(
					'title'       => __( 'Basic details', 'woocommerce' ),
					'description' => __( 'This info will be displayed on the product page, category pages, social media, and search results.', 'woocommerce' ),
				),
			)
		);
		$basic_details->add_block(
			array(
				'id'        => 'product-details-section-description',
				'blockName' => 'woocommerce/product-details-section-description',
				'order'     => 10,
			)
		);
		$basic_details->add_block(
			array(
				'id'         => 'product-name',
				'blockName'  => 'woocommerce/product-name-field',
				'order'      => 10,
				'attributes' => array(
					'name'      => 'Product name',
					'autoFocus' => true,
					'metadata'  => array(
						'bindings' => array(
							'value' => array(
								'source' => 'woocommerce/entity-product',
								'args'   => array(
									'prop' => 'name',
								),
							),
						),
					),
				),
			)
		);

		// Product Pricing columns.
		$pricing_columns  = $basic_details->add_block(
			array(
				'id'        => 'product-pricing-group-pricing-columns',
				'blockName' => 'core/columns',
				'order'     => 10,
			)
		);
		$pricing_column_1 = $pricing_columns->add_block(
			array(
				'id'         => 'product-pricing-group-pricing-column-1',
				'blockName'  => 'core/column',
				'order'      => 10,
				'attributes' => array(
					'templateLock' => 'all',
				),
			)
		);
		$pricing_column_1->add_block(
			array(
				'id'                => 'product-pricing-regular-price',
				'blockName'         => 'woocommerce/product-regular-price-field',
				'order'             => 10,
				'attributes'        => array(
					'name'  => 'regular_price',
					'label' => __( 'Regular price', 'woocommerce' ),
					'help'  => $is_calc_taxes_enabled ? null : sprintf(
					/* translators: %1$s: store settings link opening tag. %2$s: store settings link closing tag.*/
						__( 'Per your %1$sstore settings%2$s, taxes are not enabled.', 'woocommerce' ),
						'<a href="' . admin_url( 'admin.php?page=wc-settings&tab=general' ) . '" target="_blank" rel="noreferrer">',
						'</a>'
					),
				),
				'disableConditions' => array(
					array(
						'expression' => 'editedProduct.type === "variable"',
					),
					array(
						'expression' => 'editedProduct.type === "grouped"',
					),
				),
			)
		);
		$pricing_column_2 = $pricing_columns->add_block(
			array(
				'id'         => 'product-pricing-group-pricing-column-2',
				'blockName'  => 'core/column',
				'order'      => 20,
				'attributes' => array(
					'templateLock' => 'all',
				),
			)
		);
		$pricing_column_2->add_block(
			array(
				'id'                => 'product-pricing-sale-price',
				'blockName'         => 'woocommerce/product-sale-price-field',
				'order'             => 10,
				'attributes'        => array(
					'label' => __( 'Sale price', 'woocommerce' ),
				),
				'disableConditions' => array(
					array(
						'expression' => 'editedProduct.type === "variable"',
					),
					array(
						'expression' => 'editedProduct.type === "grouped"',
					),
				),
			)
		);
		$basic_details->add_block(
			array(
				'id'        => 'product-pricing-schedule-sale-fields',
				'blockName' => 'woocommerce/product-schedule-sale-fields',
				'order'     => 20,
			)
		);

		if ( $is_calc_taxes_enabled ) {
			$basic_details->add_block(
				array(
					'id'         => 'product-sale-tax',
					'blockName'  => 'woocommerce/product-radio-field',
					'order'      => 30,
					'attributes' => array(
						'title'    => __( 'Charge sales tax on', 'woocommerce' ),
						'property' => 'tax_status',
						'options'  => array(
							array(
								'label' => __( 'Product and shipping', 'woocommerce' ),
								'value' => ProductTaxStatus::TAXABLE,
							),
							array(
								'label' => __( 'Only shipping', 'woocommerce' ),
								'value' => 'shipping',
							),
							array(
								'label' => __( "Don't charge tax", 'woocommerce' ),
								'value' => 'none',
							),
						),
					),
				)
			);
			$pricing_advanced_block = $basic_details->add_block(
				array(
					'id'         => 'product-pricing-advanced',
					'blockName'  => 'woocommerce/product-collapsible',
					'order'      => 40,
					'attributes' => array(
						'toggleText'       => __( 'Advanced', 'woocommerce' ),
						'initialCollapsed' => true,
						'persistRender'    => true,
					),
				)
			);
			$pricing_advanced_block->add_block(
				array(
					'id'         => 'product-tax-class',
					'blockName'  => 'woocommerce/product-select-field',
					'order'      => 10,
					'attributes' => array(
						'label'    => __( 'Tax class', 'woocommerce' ),
						'help'     => sprintf(
						/* translators: %1$s: Learn more link opening tag. %2$s: Learn more link closing tag.*/
							__( 'Apply a tax rate if this product qualifies for tax reduction or exemption. %1$sLearn more%2$s', 'woocommerce' ),
							'<a href="https://woocommerce.com/document/setting-up-taxes-in-woocommerce/#shipping-tax-class" target="_blank" rel="noreferrer">',
							'</a>'
						),
						'property' => 'tax_class',
						'options'  => self::get_tax_classes(),
					),
				)
			);
		}

		$basic_details->add_block(
			array(
				'id'         => 'product-summary',
				'blockName'  => 'woocommerce/product-text-area-field',
				'order'      => 50,
				'attributes' => array(
					'label'    => __( 'Summary', 'woocommerce' ),
					'help'     => __(
						"Summarize this product in 1-2 short sentences. We'll show it at the top of the page.",
						'woocommerce'
					),
					'property' => 'short_description',
					'lock'     => array(
						'move' => true,
					),
				),
			)
		);

		// Description section.
		$description_section = $general_group->add_section(
			array(
				'id'         => 'product-description-section',
				'order'      => 20,
				'attributes' => array(
					'title'       => __( 'Description', 'woocommerce' ),
					'description' => __( 'What makes this product unique? What are its most important features? Enrich the product page by adding rich content using blocks.', 'woocommerce' ),
				),
			)
		);

		$description_field_block = $description_section->add_block(
			array(
				'id'        => 'product-description',
				'blockName' => 'woocommerce/product-description-field',
				'order'     => 10,
			)
		);

		$description_field_block->add_block(
			array(
				'id'         => 'product-description__content',
				'blockName'  => 'woocommerce/product-summary-field',
				'order'      => 10,
				'attributes' => array(
					'helpText' => null,
					'label'    => null,
					'property' => 'description',
					'lock'     => array(
						'move' => true,
					),
				),
			)
		);

		// External/Affiliate section.
		$buy_button_section = $general_group->add_section(
			array(
				'id'             => 'product-buy-button-section',
				'order'          => 30,
				'attributes'     => array(
					'title'       => __( 'Buy button', 'woocommerce' ),
					'description' => __( 'Add a link and choose a label for the button linked to a product sold elsewhere.', 'woocommerce' ),
				),
				'hideConditions' => array(
					array(
						'expression' => 'editedProduct.type !== "external"',
					),
				),
			)
		);

		$buy_button_section->add_block(
			array(
				'id'         => 'product-external-url',
				'blockName'  => 'woocommerce/product-text-field',
				'order'      => 10,
				'attributes' => array(
					'property'    => 'external_url',
					'label'       => __( 'Link to the external product', 'woocommerce' ),
					'placeholder' => __( 'Enter the external URL to the product', 'woocommerce' ),
					'suffix'      => true,
					'type'        => array(
						'value'   => 'url',
						'message' => __( 'Link to the external product is an invalid URL.', 'woocommerce' ),
					),
				),
			)
		);

		$button_text_columns = $buy_button_section->add_block(
			array(
				'id'        => 'product-button-text-columns',
				'blockName' => 'core/columns',
				'order'     => 20,
			)
		);

		$button_text_columns->add_block(
			array(
				'id'        => 'product-button-text-column1',
				'blockName' => 'core/column',
				'order'     => 10,
			)
		)->add_block(
			array(
				'id'         => 'product-button-text',
				'blockName'  => 'woocommerce/product-text-field',
				'order'      => 10,
				'attributes' => array(
					'property' => 'button_text',
					'label'    => __( 'Buy button text', 'woocommerce' ),
				),
			)
		);

		$button_text_columns->add_block(
			array(
				'id'        => 'product-button-text-column2',
				'blockName' => 'core/column',
				'order'     => 20,
			)
		);

		// Product list section.
		$product_list_section = $general_group->add_section(
			array(
				'id'             => 'product-list-section',
				'order'          => 35,
				'attributes'     => array(
					'title'       => __( 'Products in this group', 'woocommerce' ),
					'description' => __( 'Make a collection of related products, enabling customers to purchase multiple items together.', 'woocommerce' ),
				),
				'hideConditions' => array(
					array(
						'expression' => 'editedProduct.type !== "grouped"',
					),
				),
			)
		);

		$product_list_section->add_block(
			array(
				'id'         => 'product-list',
				'blockName'  => 'woocommerce/product-list-field',
				'order'      => 10,
				'attributes' => array(
					'property' => 'grouped_products',
				),
			)
		);

		// Images section.
		$images_section = $general_group->add_section(
			array(
				'id'         => 'product-images-section',
				'order'      => 40,
				'attributes' => array(
					'title'       => __( 'Images', 'woocommerce' ),
					'description' => sprintf(
					/* translators: %1$s: Images guide link opening tag. %2$s: Images guide link closing tag. */
						__( 'Drag images, upload new ones or select files from your library. For best results, use JPEG files that are 1000 by 1000 pixels or larger. %1$sHow to prepare images?%2$s', 'woocommerce' ),
						'<a href="https://woocommerce.com/posts/how-to-take-professional-product-photos-top-tips" target="_blank" rel="noreferrer">',
						'</a>'
					),
				),
			)
		);
		$images_section->add_block(
			array(
				'id'         => 'product-images',
				'blockName'  => 'woocommerce/product-images-field',
				'order'      => 10,
				'attributes' => array(
					'images'   => array(),
					'property' => 'images',
				),
			)
		);

		// Downloads section.
		$this->add_downloadable_product_blocks( $general_group );
	}

	/**
	 * Adds the organization group blocks to the template.
	 */
	private function add_organization_group_blocks() {
		$organization_group = $this->get_group_by_id( $this::GROUP_IDS['ORGANIZATION'] );
		// Product Catalog Section.
		$product_catalog_section = $organization_group->add_section(
			array(
				'id'         => 'product-catalog-section',
				'order'      => 10,
				'attributes' => array(
					'title'       => __( 'Product catalog', 'woocommerce' ),
					'description' => __( 'Help customers find this product by assigning it to categories, adding extra details, and managing its visibility in your store and other channels.', 'woocommerce' ),
				),
			)
		);
		$product_catalog_section->add_block(
			array(
				'id'         => 'product-categories',
				'blockName'  => 'woocommerce/product-taxonomy-field',
				'order'      => 10,
				'attributes' => array(
					'slug'               => 'product_cat',
					'property'           => 'categories',
					'label'              => __( 'Categories', 'woocommerce' ),
					'createTitle'        => __( 'Create new category', 'woocommerce' ),
					'dialogNameHelpText' => __( 'Shown to customers on the product page.', 'woocommerce' ),
					'parentTaxonomyText' => __( 'Parent category', 'woocommerce' ),
					'placeholder'        => __( 'Search or create categories…', 'woocommerce' ),
				),
			)
		);
		$product_catalog_section->add_block(
			array(
				'id'         => 'product-tags',
				'blockName'  => 'woocommerce/product-tag-field',
				'attributes' => array(
					'name' => 'tags',
				),
			)
		);
		$product_catalog_section->add_block(
			array(
				'id'         => 'product-catalog-search-visibility',
				'blockName'  => 'woocommerce/product-catalog-visibility-field',
				'order'      => 20,
				'attributes' => array(
					'label'      => __( 'Hide in product catalog', 'woocommerce' ),
					'visibility' => CatalogVisibility::SEARCH,
				),
			)
		);
		$product_catalog_section->add_block(
			array(
				'id'         => 'product-catalog-catalog-visibility',
				'blockName'  => 'woocommerce/product-catalog-visibility-field',
				'order'      => 30,
				'attributes' => array(
					'label'      => __( 'Hide from search results', 'woocommerce' ),
					'visibility' => CatalogVisibility::CATALOG,
				),
			)
		);
		$product_catalog_section->add_block(
			array(
				'id'         => 'product-enable-product-reviews',
				'blockName'  => 'woocommerce/product-checkbox-field',
				'order'      => 40,
				'attributes' => array(
					'label'    => __( 'Enable product reviews', 'woocommerce' ),
					'property' => 'reviews_allowed',
				),
			)
		);
		$product_catalog_section->add_block(
			array(
				'id'         => 'product-post-password',
				'blockName'  => 'woocommerce/product-password-field',
				'order'      => 50,
				'attributes' => array(
					'label' => __( 'Require a password', 'woocommerce' ),
				),
			)
		);
		// Attributes section.
		$product_attributes_section = $organization_group->add_section(
			array(
				'id'         => 'product-attributes-section',
				'order'      => 20,
				'attributes' => array(
					'title'       => __( 'Attributes', 'woocommerce' ),
					'description' => __( 'Use global attributes to allow shoppers to filter and search for this product. Use custom attributes to provide detailed product information.', 'woocommerce' ),
					'blockGap'    => 'unit-40',
				),
			)
		);
		$product_attributes_section->add_block(
			array(
				'id'        => 'product-attributes',
				'blockName' => 'woocommerce/product-attributes-field',
				'order'     => 10,
			)
		);

		if ( Features::is_enabled( 'product-custom-fields' ) ) {
			$organization_group->add_section(
				array(
					'id'    => 'product-custom-fields-wrapper-section',
					'order' => 30,
				)
			)->add_block(
				array(
					'id'         => 'product-custom-fields-toggle',
					'blockName'  => 'woocommerce/product-custom-fields-toggle-field',
					'order'      => 10,
					'attributes' => array(
						'label' => __( 'Show custom fields', 'woocommerce' ),
					),
				)
			)->add_block(
				array(
					'id'         => 'product-custom-fields-section',
					'blockName'  => 'woocommerce/product-section',
					'order'      => 10,
					'attributes' => array(
						'blockGap'    => 'unit-30',
						'title'       => __( 'Custom fields', 'woocommerce' ),
						'description' => sprintf(
							/* translators: %1$s: Custom fields guide link opening tag. %2$s: Custom fields guide link closing tag. */
							__( 'Custom fields can be used in a variety of ways, such as sharing more detailed product information, showing more input fields, or for internal inventory organization. %1$sRead more about custom fields%2$s', 'woocommerce' ),
							'<a href="https://woocommerce.com/document/custom-product-fields/" target="_blank" rel="noreferrer">',
							'</a>'
						),
					),
				)
			)->add_block(
				array(
					'id'        => 'product-custom-fields',
					'blockName' => 'woocommerce/product-custom-fields',
					'order'     => 10,
				)
			);
		}
	}

	/**
	 * Get the tax classes as select options.
	 *
	 * @param string $post_type The post type.
	 * @return array Array of options.
	 */
	public static function get_tax_classes( $post_type = 'product' ) {
		$tax_classes = array();

		if ( 'product_variation' === $post_type ) {
			$tax_classes[] = array(
				'label' => __( 'Same as main product', 'woocommerce' ),
				'value' => 'parent',
			);
		}

		// Add standard class.
		$tax_classes[] = array(
			'label' => __( 'Standard rate', 'woocommerce' ),
			'value' => '',
		);

		$classes = WC_Tax::get_tax_rate_classes();

		foreach ( $classes as $tax_class ) {
			$tax_classes[] = array(
				'label' => $tax_class->name,
				'value' => $tax_class->slug,
			);
		}

		return $tax_classes;
	}

	/**
	 * Adds the inventory group blocks to the template.
	 */
	private function add_inventory_group_blocks() {
		$inventory_group = $this->get_group_by_id( $this::GROUP_IDS['INVENTORY'] );
		$inventory_group->add_block(
			array(
				'id'         => 'product_variation_notice_inventory_tab',
				'blockName'  => 'woocommerce/product-has-variations-notice',
				'order'      => 10,
				'attributes' => array(
					'content'    => __( 'This product has options, such as size or color. You can now manage each variation\'s inventory and other details individually.', 'woocommerce' ),
					'buttonText' => __( 'Go to Variations', 'woocommerce' ),
					'type'       => 'info',
				),
			)
		);
		// Product Inventory Section.
		$product_inventory_section       = $inventory_group->add_section(
			array(
				'id'         => 'product-inventory-section',
				'order'      => 20,
				'attributes' => array(
					'title'       => __( 'Inventory', 'woocommerce' ),
					'description' => sprintf(
					/* translators: %1$s: Inventory settings link opening tag. %2$s: Inventory settings link closing tag.*/
						__( 'Set up and manage inventory for this product, including status and available quantity. %1$sManage store inventory settings%2$s', 'woocommerce' ),
						'<a href="' . admin_url( 'admin.php?page=wc-settings&tab=products&section=inventory' ) . '" target="_blank" rel="noreferrer">',
						'</a>'
					),
					'blockGap'    => 'unit-40',
				),
			)
		);
		$product_inventory_inner_section = $product_inventory_section->add_subsection(
			array(
				'id'    => 'product-inventory-inner-section',
				'order' => 10,
			)
		);
		$inventory_columns               = $product_inventory_inner_section->add_block(
			array(
				'id'        => 'product-inventory-inner-columns',
				'blockName' => 'core/columns',
			)
		);
		$inventory_columns->add_block(
			array(
				'id'        => 'product-inventory-inner-column1',
				'blockName' => 'core/column',
			)
		)->add_block(
			array(
				'id'                => 'product-sku-field',
				'blockName'         => 'woocommerce/product-sku-field',
				'order'             => 10,
				'disableConditions' => array(
					array(
						'expression' => 'editedProduct.type === "variable"',
					),
				),
			)
		);
		$inventory_columns->add_block(
			array(
				'id'        => 'product-inventory-inner-column2',
				'blockName' => 'core/column',
			)
		)->add_block(
			array(
				'id'                => 'product-unique-id-field',
				'blockName'         => 'woocommerce/product-text-field',
				'order'             => 20,
				'attributes'        => array(
					'property' => 'global_unique_id',
					// translators: %1$s GTIN %2$s UPC %3$s EAN %4$s ISBN.
					'label'    => sprintf( __( '%1$s, %2$s, %3$s, or %4$s', 'woocommerce' ), '<abbr title="' . esc_attr__( 'Global Trade Item Number', 'woocommerce' ) . '">' . esc_html__( 'GTIN', 'woocommerce' ) . '</abbr>', '<abbr title="' . esc_attr__( 'Universal Product Code', 'woocommerce' ) . '">' . esc_html__( 'UPC', 'woocommerce' ) . '</abbr>', '<abbr title="' . esc_attr__( 'European Article Number', 'woocommerce' ) . '">' . esc_html__( 'EAN', 'woocommerce' ) . '</abbr>', '<abbr title="' . esc_attr__( 'International Standard Book Number', 'woocommerce' ) . '">' . esc_html__( 'ISBN', 'woocommerce' ) . '</abbr>' ),
					'tooltip'  => __( 'Enter a barcode or any other identifier unique to this product. It can help you list this product on other channels or marketplaces.', 'woocommerce' ),
					'pattern'  => array(
						'value'   => '[0-9\-]*',
						'message' => __( 'Please enter only numbers and hyphens (-).', 'woocommerce' ),
					),
				),
				'disableConditions' => array(
					array(
						'expression' => 'editedProduct.type === "variable"',
					),
				),
			)
		);

		$manage_stock = 'yes' === get_option( 'woocommerce_manage_stock' );
		$product_inventory_inner_section->add_block(
			array(
				'id'                => 'product-track-stock',
				'blockName'         => 'woocommerce/product-toggle-field',
				'order'             => 20,
				'attributes'        => array(
					'label'        => __( 'Track inventory', 'woocommerce' ),
					'property'     => 'manage_stock',
					'disabled'     => ! $manage_stock,
					'disabledCopy' => ! $manage_stock ? sprintf(
						/* translators: %1$s: Learn more link opening tag. %2$s: Learn more link closing tag.*/
						__( 'Per your %1$sstore settings%2$s, inventory management is <strong>disabled</strong>.', 'woocommerce' ),
						'<a href="' . admin_url( 'admin.php?page=wc-settings&tab=products&section=inventory' ) . '" target="_blank" rel="noreferrer">',
						'</a>'
					) : null,
				),
				'hideConditions'    => array(
					array(
						'expression' => 'editedProduct.type === "external" || editedProduct.type === "grouped"',
					),
				),
				'disableConditions' => array(
					array(
						'expression' => 'editedProduct.type === "variable"',
					),
				),
			)
		);
		$product_inventory_quantity_hide_conditions   = array(
			array(
				'expression' => 'editedProduct.manage_stock === false',
			),
		);
		$product_inventory_quantity_hide_conditions[] = array(
			'expression' => 'editedProduct.type === "grouped"',
		);
		$product_inventory_inner_section->add_block(
			array(
				'id'             => 'product-inventory-quantity',
				'blockName'      => 'woocommerce/product-inventory-quantity-field',
				'order'          => 30,
				'hideConditions' => $product_inventory_quantity_hide_conditions,
			)
		);
		$product_stock_status_hide_conditions   = array(
			array(
				'expression' => 'editedProduct.manage_stock === true',
			),
		);
		$product_stock_status_hide_conditions[] = array(
			'expression' => 'editedProduct.type === "grouped"',
		);
		$product_inventory_section->add_block(
			array(
				'id'                => 'product-stock-status',
				'blockName'         => 'woocommerce/product-radio-field',
				'order'             => 10,
				'attributes'        => array(
					'title'    => __( 'Stock status', 'woocommerce' ),
					'property' => 'stock_status',
					'options'  => array(
						array(
							'label' => __( 'In stock', 'woocommerce' ),
							'value' => ProductStockStatus::IN_STOCK,
						),
						array(
							'label' => __( 'Out of stock', 'woocommerce' ),
							'value' => ProductStockStatus::OUT_OF_STOCK,
						),
						array(
							'label' => __( 'On backorder', 'woocommerce' ),
							'value' => ProductStockStatus::ON_BACKORDER,
						),
					),
				),
				'hideConditions'    => $product_stock_status_hide_conditions,
				'disableConditions' => array(
					array(
						'expression' => 'editedProduct.type === "variable"',
					),
				),
			)
		);

		$product_inventory_section->add_block(
			array(
				'id'         => 'product-purchase-note',
				'blockName'  => 'woocommerce/product-text-area-field',
				'order'      => 20,
				'attributes' => array(
					'property'    => 'purchase_note',
					'label'       => __( 'Post-purchase note', 'woocommerce' ),
					'placeholder' => __( 'Enter an optional note attached to the order confirmation message sent to the shopper.', 'woocommerce' ),
					'lock'        => array(
						'move' => true,
					),
				),
			)
		);

		$product_inventory_advanced         = $product_inventory_section->add_block(
			array(
				'id'             => 'product-inventory-advanced',
				'blockName'      => 'woocommerce/product-collapsible',
				'order'          => 30,
				'attributes'     => array(
					'toggleText'       => __( 'Advanced', 'woocommerce' ),
					'initialCollapsed' => true,
					'persistRender'    => true,
				),
				'hideConditions' => array(
					array(
						'expression' => 'editedProduct.type === "grouped"',
					),
				),
			)
		);
		$product_inventory_advanced_wrapper = $product_inventory_advanced->add_block(
			array(
				'blockName'  => 'woocommerce/product-section',
				'order'      => 10,
				'attributes' => array(
					'blockGap' => 'unit-40',
				),
			)
		);
		$product_inventory_advanced_wrapper->add_block(
			array(
				'id'             => 'product-out-of-stock',
				'blockName'      => 'woocommerce/product-radio-field',
				'order'          => 10,
				'attributes'     => array(
					'title'    => __( 'When out of stock', 'woocommerce' ),
					'property' => 'backorders',
					'options'  => array(
						array(
							'label' => __( 'Allow purchases', 'woocommerce' ),
							'value' => 'yes',
						),
						array(
							'label' => __(
								'Allow purchases, but notify customers',
								'woocommerce'
							),
							'value' => 'notify',
						),
						array(
							'label' => __( "Don't allow purchases", 'woocommerce' ),
							'value' => 'no',
						),
					),
				),
				'hideConditions' => array(
					array(
						'expression' => 'editedProduct.manage_stock === false',
					),
				),
			)
		);
		$product_inventory_advanced_wrapper->add_block(
			array(
				'id'             => 'product-inventory-email',
				'blockName'      => 'woocommerce/product-inventory-email-field',
				'order'          => 20,
				'hideConditions' => array(
					array(
						'expression' => 'editedProduct.manage_stock === false',
					),
				),
			)
		);

		$product_inventory_advanced_wrapper->add_block(
			array(
				'id'         => 'product-limit-purchase',
				'blockName'  => 'woocommerce/product-checkbox-field',
				'order'      => 20,
				'attributes' => array(
					'title'    => __(
						'Restrictions',
						'woocommerce'
					),
					'label'    => __(
						'Limit purchases to 1 item per order',
						'woocommerce'
					),
					'property' => 'sold_individually',
					'tooltip'  => __(
						'When checked, customers will be able to purchase only 1 item in a single order. This is particularly useful for items that have limited quantity, like art or handmade goods.',
						'woocommerce'
					),
				),
			)
		);
	}

	/**
	 * Adds the shipping group blocks to the template.
	 */
	private function add_shipping_group_blocks() {
		$shipping_group = $this->get_group_by_id( $this::GROUP_IDS['SHIPPING'] );
		$shipping_group->add_block(
			array(
				'id'         => 'product_variation_notice_shipping_tab',
				'blockName'  => 'woocommerce/product-has-variations-notice',
				'order'      => 10,
				'attributes' => array(
					'content'    => __( 'This product has options, such as size or color. You can now manage each variation\'s shipping settings and other details individually.', 'woocommerce' ),
					'buttonText' => __( 'Go to Variations', 'woocommerce' ),
					'type'       => 'info',
				),
			)
		);
		// Virtual section.
		$shipping_group->add_section(
			array(
				'id'             => 'product-virtual-section',
				'order'          => 10,
				'hideConditions' => array(
					array(
						'expression' => 'editedProduct.type !== "simple"',
					),
				),
			)
		)->add_block(
			array(
				'id'         => 'product-virtual',
				'blockName'  => 'woocommerce/product-toggle-field',
				'order'      => 10,
				'attributes' => array(
					'property'       => 'virtual',
					'checkedValue'   => false,
					'uncheckedValue' => true,
					'label'          => __( 'This product requires shipping or pickup', 'woocommerce' ),
					'uncheckedHelp'  => __( 'This product will not trigger your customer\'s shipping calculator in cart or at checkout. This product also won\'t require your customers to enter their shipping details at checkout. <a href="https://woocommerce.com/document/managing-products/#adding-a-virtual-product" target="_blank" rel="noreferrer">Read more about virtual products</a>.', 'woocommerce' ),
				),
			)
		);
		// Product Shipping Section.
		$product_fee_and_dimensions_section = $shipping_group->add_section(
			array(
				'id'         => 'product-fee-and-dimensions-section',
				'order'      => 20,
				'attributes' => array(
					'title'       => __( 'Fees & dimensions', 'woocommerce' ),
					'description' => sprintf(
					/* translators: %1$s: How to get started? link opening tag. %2$s: How to get started? link closing tag.*/
						__( 'Set up shipping costs and enter dimensions used for accurate rate calculations. %1$sHow to get started?%2$s', 'woocommerce' ),
						'<a href="https://woocommerce.com/posts/how-to-calculate-shipping-costs-for-your-woocommerce-store/" target="_blank" rel="noreferrer">',
						'</a>'
					),
				),
			)
		);
		$product_fee_and_dimensions_section->add_block(
			array(
				'id'                => 'product-shipping-class',
				'blockName'         => 'woocommerce/product-shipping-class-field',
				'order'             => 10,
				'disableConditions' => array(
					array(
						'expression' => 'editedProduct.type === "variable"',
					),
				),
			)
		);
		$product_fee_and_dimensions_section->add_block(
			array(
				'id'                => 'product-shipping-dimensions',
				'blockName'         => 'woocommerce/product-shipping-dimensions-fields',
				'order'             => 20,
				'disableConditions' => array(
					array(
						'expression' => 'editedProduct.type === "variable"',
					),
				),
			)
		);
	}

	/**
	 * Adds the variation group blocks to the template.
	 */
	private function add_variation_group_blocks() {
		$variation_group = $this->get_group_by_id( $this::GROUP_IDS['VARIATIONS'] );
		if ( ! $variation_group ) {
			return;
		}

		$variation_group->add_section(
			array(
				'id'         => 'product-variation-options-section',
				'order'      => 10,
				'attributes' => array(
					'title'       => __( 'Variation options', 'woocommerce' ),
					'description' => __( 'Add and manage attributes used for product options, such as size and color.', 'woocommerce' ),
				),
			)
		)->add_block(
			array(
				'id'        => 'product-variation-options',
				'blockName' => 'woocommerce/product-variations-options-field',
				'order'     => 10,
			)
		);

		$variation_group->add_section(
			array(
				'id'         => 'product-variation-section',
				'order'      => 20,
				'attributes' => array(
					'title'       => __( 'Variations', 'woocommerce' ),
					'description' => __( 'Manage individual product combinations created from options.', 'woocommerce' ),
				),
			)
		)->add_block(
			array(
				'id'        => 'product-variation-items',
				'blockName' => 'woocommerce/product-variation-items-field',
				'order'     => 10,
			)
		);
	}

	/**
	 * Adds the linked products group blocks to the template.
	 */
	private function add_linked_products_group_blocks() {
		$linked_products_group = $this->get_group_by_id( $this::GROUP_IDS['LINKED_PRODUCTS'] );
		if ( ! isset( $linked_products_group ) ) {
			return;
		}

		$linked_products_group->add_section(
			array(
				'id'         => 'product-linked-upsells-section',
				'order'      => 10,
				'attributes' => array(
					'title'       => __( 'Upsells', 'woocommerce' ),
					'description' => sprintf(
						/* translators: %1$s: "Learn more about linked products" link opening tag. %2$s: "Learn more about linked products" link closing tag. */
						__( 'Upsells are typically products that are extra profitable or better quality or more expensive. Experiment with combinations to boost sales. %1$sLearn more about linked products%2$s', 'woocommerce' ),
						'<br /><a href="https://woocommerce.com/document/related-products-up-sells-and-cross-sells/" target="_blank" rel="noreferrer">',
						'</a>'
					),
				),
			)
		)->add_block(
			array(
				'id'         => 'product-linked-upsells',
				'blockName'  => 'woocommerce/product-linked-list-field',
				'order'      => 10,
				'attributes' => array(
					'property'   => 'upsell_ids',
					'emptyState' => array(
						'image'         => 'ShoppingBags',
						'tip'           => __(
							'Tip: Upsells are products that are extra profitable or better quality or more expensive. Experiment with combinations to boost sales.',
							'woocommerce'
						),
						'isDismissible' => true,
					),
				),
			)
		);

		$linked_products_group->add_section(
			array(
				'id'             => 'product-linked-cross-sells-section',
				'order'          => 20,
				'attributes'     => array(
					'title'       => __( 'Cross-sells', 'woocommerce' ),
					'description' => sprintf(
						/* translators: %1$s: "Learn more about linked products" link opening tag. %2$s: "Learn more about linked products" link closing tag. */
						__( 'By suggesting complementary products in the cart using cross-sells, you can significantly increase the average order value. %1$sLearn more about linked products%2$s', 'woocommerce' ),
						'<br /><a href="https://woocommerce.com/document/related-products-up-sells-and-cross-sells/" target="_blank" rel="noreferrer">',
						'</a>'
					),
				),
				'hideConditions' => array(
					array(
						'expression' => 'editedProduct.type === "external" || editedProduct.type === "grouped"',
					),
				),
			)
		)->add_block(
			array(
				'id'         => 'product-linked-cross-sells',
				'blockName'  => 'woocommerce/product-linked-list-field',
				'order'      => 10,
				'attributes' => array(
					'property'   => 'cross_sell_ids',
					'emptyState' => array(
						'image'         => 'CashRegister',
						'tip'           => __(
							'Tip: By suggesting complementary products in the cart using cross-sells, you can significantly increase the average order value.',
							'woocommerce'
						),
						'isDismissible' => true,
					),
				),
			)
		);
	}
}
PK     [1]    ;  Features/ProductBlockEditor/ProductTemplates/Subsection.phpnu         <?php
/**
 * WooCommerce Subsection Block class.
 */

namespace Automattic\WooCommerce\Internal\Features\ProductBlockEditor\ProductTemplates;

use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\ContainerInterface;
use Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates\SubsectionInterface;

/**
 * Class for Subsection block.
 */
class Subsection extends ProductBlock implements SubsectionInterface {
	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
	/**
	 * Subsection Block constructor.
	 *
	 * @param array                   $config The block configuration.
	 * @param BlockTemplateInterface  $root_template The block template that this block belongs to.
	 * @param ContainerInterface|null $parent The parent block container.
	 *
	 * @throws \ValueError If the block configuration is invalid.
	 * @throws \ValueError If the parent block container does not belong to the same template as the block.
	 * @throws \InvalidArgumentException If blockName key and value are passed into block configuration.
	 */
	public function __construct( array $config, BlockTemplateInterface &$root_template, ?ContainerInterface &$parent = null ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.parentFound
		if ( ! empty( $config['blockName'] ) ) {
			throw new \InvalidArgumentException( 'Unexpected key "blockName", this defaults to "woocommerce/product-subsection".' );
		}
		parent::__construct( array_merge( array( 'blockName' => 'woocommerce/product-subsection' ), $config ), $root_template, $parent );
	}
	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
}
PK     [1]QM͈	  	  8  Features/ProductBlockEditor/ProductTemplates/Section.phpnu         <?php
/**
 * WooCommerce Section Block class.
 */

namespace Automattic\WooCommerce\Internal\Features\ProductBlockEditor\ProductTemplates;

use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\ContainerInterface;
use Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates\SectionInterface;
use Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates\SubsectionInterface;

/**
 * Class for Section block.
 */
class Section extends ProductBlock implements SectionInterface {
	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
	/**
	 * Section Block constructor.
	 *
	 * @param array                   $config The block configuration.
	 * @param BlockTemplateInterface  $root_template The block template that this block belongs to.
	 * @param ContainerInterface|null $parent The parent block container.
	 *
	 * @throws \ValueError If the block configuration is invalid.
	 * @throws \ValueError If the parent block container does not belong to the same template as the block.
	 * @throws \InvalidArgumentException If blockName key and value are passed into block configuration.
	 */
	public function __construct( array $config, BlockTemplateInterface &$root_template, ?ContainerInterface &$parent = null ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.parentFound
		if ( ! empty( $config['blockName'] ) ) {
			throw new \InvalidArgumentException( 'Unexpected key "blockName", this defaults to "woocommerce/product-section".' );
		}
		parent::__construct( array_merge( array( 'blockName' => 'woocommerce/product-section' ), $config ), $root_template, $parent );
	}
	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

	/**
	 * Add a sub-section block type to this template.
	 *
	 * @param array $block_config The block data.
	 */
	public function add_subsection( array $block_config ): SubsectionInterface {
		$block = new Subsection( $block_config, $this->get_root_template(), $this );
		return $this->add_inner_block( $block );
	}

	/**
	 * Add a sub-section block type to this template.
	 *
	 * @deprecated 8.6.0
	 *
	 * @param array $block_config The block data.
	 */
	public function add_section( array $block_config ): SubsectionInterface {
		wc_deprecated_function( 'add_section', '8.6.0', 'add_subsection' );
		return $this->add_subsection( $block_config );
	}
}
PK     [1]Vؚ    L  Features/ProductBlockEditor/ProductTemplates/AbstractProductFormTemplate.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Features\ProductBlockEditor\ProductTemplates;

use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates\GroupInterface;
use Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates\ProductFormTemplateInterface;
use Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates\SectionInterface;
use Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates\SubsectionInterface;
use Automattic\WooCommerce\Internal\Admin\BlockTemplates\AbstractBlockTemplate;

/**
 * Block template class.
 */
abstract class AbstractProductFormTemplate extends AbstractBlockTemplate implements ProductFormTemplateInterface {
	/**
	 * Get the template area.
	 */
	public function get_area(): string {
		return 'product-form';
	}

	/**
	 * Get a group block by ID.
	 *
	 * @param string $group_id The group block ID.
	 * @throws \UnexpectedValueException If block is not of type GroupInterface.
	 */
	public function get_group_by_id( string $group_id ): ?GroupInterface {
		$group = $this->get_block( $group_id );
		if ( $group && ! $group instanceof GroupInterface ) {
			throw new \UnexpectedValueException( 'Block with specified ID is not a group.' );
		}
		return $group;
	}

	/**
	 * Get a section block by ID.
	 *
	 * @param string $section_id The section block ID.
	 * @throws \UnexpectedValueException If block is not of type SectionInterface.
	 */
	public function get_section_by_id( string $section_id ): ?SectionInterface {
		$section = $this->get_block( $section_id );
		if ( $section && ! $section instanceof SectionInterface ) {
			throw new \UnexpectedValueException( 'Block with specified ID is not a section.' );
		}
		return $section;
	}

	/**
	 * Get a subsection block by ID.
	 *
	 * @param string $subsection_id The subsection block ID.
	 * @throws \UnexpectedValueException If block is not of type SubsectionInterface.
	 */
	public function get_subsection_by_id( string $subsection_id ): ?SubsectionInterface {
		$subsection = $this->get_block( $subsection_id );
		if ( $subsection && ! $subsection instanceof SubsectionInterface ) {
			throw new \UnexpectedValueException( 'Block with specified ID is not a subsection.' );
		}
		return $subsection;
	}

	/**
	 * Get a block by ID.
	 *
	 * @param string $block_id The block block ID.
	 */
	public function get_block_by_id( string $block_id ): ?BlockInterface {
		return $this->get_block( $block_id );
	}

	/**
	 * Add a custom block type to this template.
	 *
	 * @param array $block_config The block data.
	 */
	public function add_group( array $block_config ): GroupInterface {
		$block = new Group( $block_config, $this->get_root_template(), $this );
		return $this->add_inner_block( $block );
	}
}
PK     [1]P;V  V  =  Features/ProductBlockEditor/ProductTemplates/ProductBlock.phpnu         <?php
/**
 * WooCommerce Product Block class.
 */

namespace Automattic\WooCommerce\Internal\Features\ProductBlockEditor\ProductTemplates;

use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\ContainerInterface;
use Automattic\WooCommerce\Internal\Admin\BlockTemplates\AbstractBlock;
use Automattic\WooCommerce\Internal\Admin\BlockTemplates\BlockContainerTrait;

/**
 * Class for Product block.
 */
class ProductBlock extends AbstractBlock implements ContainerInterface {
	use BlockContainerTrait;
	/**
	 * Adds block to the section block.
	 *
	 * @param array $block_config The block data.
	 */
	public function &add_block( array $block_config ): BlockInterface {
		$block = new ProductBlock( $block_config, $this->get_root_template(), $this );
		return $this->add_inner_block( $block );
	}
}
PK     [1]MeA  A  I  Features/ProductBlockEditor/ProductTemplates/ProductVariationTemplate.phpnu         <?php
/**
 * ProductVariationTemplate
 */

namespace Automattic\WooCommerce\Internal\Features\ProductBlockEditor\ProductTemplates;

use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates\ProductFormTemplateInterface;
use Automattic\WooCommerce\Enums\ProductStockStatus;

/**
 * Product Variation Template.
 */
class ProductVariationTemplate extends AbstractProductFormTemplate implements ProductFormTemplateInterface {
	use DownloadableProductTrait;

	/**
	 * The context name used to identify the editor.
	 */
	const GROUP_IDS = array(
		'GENERAL'   => 'general',
		'PRICING'   => 'pricing',
		'INVENTORY' => 'inventory',
		'SHIPPING'  => 'shipping',
	);

	/**
	 * The option name used check whether the single variation notice has been dismissed.
	 */
	const SINGLE_VARIATION_NOTICE_DISMISSED_OPTION = 'woocommerce_single_variation_notice_dismissed';

	/**
	 * ProductVariationTemplate constructor.
	 */
	public function __construct() {
		$this->add_group_blocks();
		$this->add_general_group_blocks();
		$this->add_inventory_group_blocks();
		$this->add_shipping_group_blocks();
	}

	/**
	 * Get the template ID.
	 */
	public function get_id(): string {
		return 'product-variation';
	}

	/**
	 * Get the template title.
	 */
	public function get_title(): string {
		return __( 'Product Variation Template', 'woocommerce' );
	}

	/**
	 * Get the template description.
	 */
	public function get_description(): string {
		return __( 'Template for the product variation form', 'woocommerce' );
	}

	/**
	 * Adds the group blocks to the template.
	 */
	protected function add_group_blocks() {
		$this->add_group(
			array(
				'id'         => $this::GROUP_IDS['GENERAL'],
				'order'      => 10,
				'attributes' => array(
					'title' => __( 'General', 'woocommerce' ),
				),
			)
		);
		$this->add_group(
			array(
				'id'         => $this::GROUP_IDS['INVENTORY'],
				'order'      => 30,
				'attributes' => array(
					'title' => __( 'Inventory', 'woocommerce' ),
				),
			)
		);
		$this->add_group(
			array(
				'id'         => $this::GROUP_IDS['SHIPPING'],
				'order'      => 40,
				'attributes' => array(
					'title' => __( 'Shipping', 'woocommerce' ),
				),
			)
		);
	}

	/**
	 * Adds the general group blocks to the template.
	 */
	protected function add_general_group_blocks() {
		$is_calc_taxes_enabled = wc_tax_enabled();

		$general_group = $this->get_group_by_id( $this::GROUP_IDS['GENERAL'] );
		$general_group->add_block(
			array(
				'id'         => 'general-single-variation-notice',
				'blockName'  => 'woocommerce/product-single-variation-notice',
				'order'      => 10,
				'attributes' => array(
					'content'       => __( '<strong>You’re editing details specific to this variation.</strong> Some information, like description and images, will be inherited from the main product, <noticeLink><parentProductName/></noticeLink>.', 'woocommerce' ),
					'type'          => 'info',
					'isDismissible' => true,
					'name'          => $this::SINGLE_VARIATION_NOTICE_DISMISSED_OPTION,
				),
			)
		);
		// Basic Details Section.
		$basic_details = $general_group->add_section(
			array(
				'id'         => 'product-variation-details-section',
				'order'      => 10,
				'attributes' => array(
					'title'       => __( 'Variation details', 'woocommerce' ),
					'description' => __( 'This info will be displayed on the product page, category pages, social media, and search results.', 'woocommerce' ),
				),
			)
		);

		// Product Pricing columns.
		$pricing_columns  = $basic_details->add_block(
			array(
				'id'        => 'product-pricing-group-pricing-columns',
				'blockName' => 'core/columns',
				'order'     => 10,
			)
		);
		$pricing_column_1 = $pricing_columns->add_block(
			array(
				'id'         => 'product-pricing-group-pricing-column-1',
				'blockName'  => 'core/column',
				'order'      => 10,
				'attributes' => array(
					'templateLock' => 'all',
				),
			)
		);
		$pricing_column_1->add_block(
			array(
				'id'         => 'product-pricing-regular-price',
				'blockName'  => 'woocommerce/product-regular-price-field',
				'order'      => 10,
				'attributes' => array(
					'name'       => 'regular_price',
					'label'      => __( 'Regular price', 'woocommerce' ),
					'isRequired' => true,
					'help'       => $is_calc_taxes_enabled ? null : sprintf(
					/* translators: %1$s: store settings link opening tag. %2$s: store settings link closing tag.*/
						__( 'Per your %1$sstore settings%2$s, taxes are not enabled.', 'woocommerce' ),
						'<a href="' . admin_url( 'admin.php?page=wc-settings&tab=general' ) . '" target="_blank" rel="noreferrer">',
						'</a>'
					),
				),
			)
		);
		$pricing_column_2 = $pricing_columns->add_block(
			array(
				'id'         => 'product-pricing-group-pricing-column-2',
				'blockName'  => 'core/column',
				'order'      => 20,
				'attributes' => array(
					'templateLock' => 'all',
				),
			)
		);
		$pricing_column_2->add_block(
			array(
				'id'         => 'product-pricing-sale-price',
				'blockName'  => 'woocommerce/product-sale-price-field',
				'order'      => 10,
				'attributes' => array(
					'label' => __( 'Sale price', 'woocommerce' ),
				),
			)
		);
		$basic_details->add_block(
			array(
				'id'        => 'product-pricing-schedule-sale-fields',
				'blockName' => 'woocommerce/product-schedule-sale-fields',
				'order'     => 20,
			)
		);

		if ( $is_calc_taxes_enabled ) {
			$basic_details->add_block(
				array(
					'id'         => 'product-tax-class',
					'blockName'  => 'woocommerce/product-select-field',
					'order'      => 40,
					'attributes' => array(
						'label'    => __( 'Tax class', 'woocommerce' ),
						'help'     => sprintf(
						/* translators: %1$s: Learn more link opening tag. %2$s: Learn more link closing tag.*/
							__( 'Apply a tax rate if this product qualifies for tax reduction or exemption. %1$sLearn more%2$s', 'woocommerce' ),
							'<a href="https://woocommerce.com/document/setting-up-taxes-in-woocommerce/#shipping-tax-class" target="_blank" rel="noreferrer">',
							'</a>'
						),
						'property' => 'tax_class',
						'options'  => SimpleProductTemplate::get_tax_classes( 'product_variation' ),
					),
				)
			);
		}

		$basic_details->add_block(
			array(
				'id'         => 'product-variation-note',
				'blockName'  => 'woocommerce/product-text-area-field',
				'order'      => 20,
				'attributes' => array(
					'property' => 'description',
					'label'    => __( 'Note', 'woocommerce' ),
					'help'     => 'Enter an optional note displayed on the product page when customers select this variation.',
					'lock'     => array(
						'move' => true,
					),
				),
			)
		);
		$basic_details->add_block(
			array(
				'id'         => 'product-variation-visibility',
				'blockName'  => 'woocommerce/product-checkbox-field',
				'order'      => 30,
				'attributes' => array(
					'property'       => 'status',
					'label'          => __( 'Hide in product catalog', 'woocommerce' ),
					'checkedValue'   => 'private',
					'uncheckedValue' => 'publish',
				),
			)
		);

		// Images section.
		$images_section = $general_group->add_section(
			array(
				'id'         => 'product-variation-images-section',
				'order'      => 30,
				'attributes' => array(
					'title'       => __( 'Image', 'woocommerce' ),
					'description' => sprintf(
					/* translators: %1$s: Images guide link opening tag. %2$s: Images guide link closing tag. */
						__( 'Drag images, upload new ones or select files from your library. For best results, use JPEG files that are 1000 by 1000 pixels or larger. %1$sHow to prepare images?%2$s', 'woocommerce' ),
						'<a href="https://woocommerce.com/posts/how-to-take-professional-product-photos-top-tips" target="_blank" rel="noreferrer">',
						'</a>'
					),
				),
			)
		);
		$images_section->add_block(
			array(
				'id'         => 'product-variation-image',
				'blockName'  => 'woocommerce/product-images-field',
				'order'      => 10,
				'attributes' => array(
					'property' => 'image',
					'multiple' => false,
				),
			)
		);

		// Downloads section.
		$this->add_downloadable_product_blocks( $general_group );
	}

	/**
	 * Adds the inventory group blocks to the template.
	 */
	protected function add_inventory_group_blocks() {
		$inventory_group = $this->get_group_by_id( $this::GROUP_IDS['INVENTORY'] );
		$inventory_group->add_block(
			array(
				'id'         => 'inventory-single-variation-notice',
				'blockName'  => 'woocommerce/product-single-variation-notice',
				'order'      => 10,
				'attributes' => array(
					'content'       => __( '<strong>You’re editing details specific to this variation.</strong> Some information, like description and images, will be inherited from the main product, <noticeLink><parentProductName/></noticeLink>.', 'woocommerce' ),
					'type'          => 'info',
					'isDismissible' => true,
					'name'          => $this::SINGLE_VARIATION_NOTICE_DISMISSED_OPTION,
				),
			)
		);
		// Product Inventory Section.
		$product_inventory_section       = $inventory_group->add_section(
			array(
				'id'         => 'product-variation-inventory-section',
				'order'      => 20,
				'attributes' => array(
					'title'       => __( 'Inventory', 'woocommerce' ),
					'description' => sprintf(
					/* translators: %1$s: Inventory settings link opening tag. %2$s: Inventory settings link closing tag.*/
						__( 'Set up and manage inventory for this product, including status and available quantity. %1$sManage store inventory settings%2$s', 'woocommerce' ),
						'<a href="' . admin_url( 'admin.php?page=wc-settings&tab=products&section=inventory' ) . '" target="_blank" rel="noreferrer">',
						'</a>'
					),
					'blockGap'    => 'unit-40',
				),
			)
		);
		$product_inventory_inner_section = $product_inventory_section->add_subsection(
			array(
				'id'    => 'product-variation-inventory-inner-section',
				'order' => 10,
			)
		);
		$inventory_columns               = $product_inventory_inner_section->add_block(
			array(
				'id'        => 'product-inventory-inner-columns',
				'blockName' => 'core/columns',
			)
		);
		$inventory_columns->add_block(
			array(
				'id'        => 'product-inventory-inner-column1',
				'blockName' => 'core/column',
			)
		)->add_block(
			array(
				'id'        => 'product-variation-sku-field',
				'blockName' => 'woocommerce/product-sku-field',
				'order'     => 10,
			)
		);
		$inventory_columns->add_block(
			array(
				'id'        => 'product-inventory-inner-column2',
				'blockName' => 'core/column',
			)
		)->add_block(
			array(
				'id'         => 'product-unique-id-field',
				'blockName'  => 'woocommerce/product-text-field',
				'order'      => 20,
				'attributes' => array(
					'property' => 'global_unique_id',
					// translators: %1$s GTIN %2$s UPC %3$s EAN %4$s ISBN.
					'label'    => sprintf( __( '%1$s, %2$s, %3$s, or %4$s', 'woocommerce' ), '<abbr title="' . esc_attr__( 'Global Trade Item Number', 'woocommerce' ) . '">' . esc_html__( 'GTIN', 'woocommerce' ) . '</abbr>', '<abbr title="' . esc_attr__( 'Universal Product Code', 'woocommerce' ) . '">' . esc_html__( 'UPC', 'woocommerce' ) . '</abbr>', '<abbr title="' . esc_attr__( 'European Article Number', 'woocommerce' ) . '">' . esc_html__( 'EAN', 'woocommerce' ) . '</abbr>', '<abbr title="' . esc_attr__( 'International Standard Book Number', 'woocommerce' ) . '">' . esc_html__( 'ISBN', 'woocommerce' ) . '</abbr>' ),
					'tooltip'  => __( 'Enter a barcode or any other identifier unique to this product. It can help you list this product on other channels or marketplaces.', 'woocommerce' ),
					'pattern'  => array(
						'value'   => '[0-9\-]*',
						'message' => __( 'Please enter only numbers and hyphens (-).', 'woocommerce' ),
					),
				),
			)
		);
		$product_inventory_inner_section->add_block(
			array(
				'id'         => 'product-variation-track-stock',
				'blockName'  => 'woocommerce/product-toggle-field',
				'order'      => 20,
				'attributes' => array(
					'label'        => __( 'Track inventory', 'woocommerce' ),
					'property'     => 'manage_stock',
					'disabled'     => 'yes' !== get_option( 'woocommerce_manage_stock' ),
					'disabledCopy' => sprintf(
						/* translators: %1$s: Learn more link opening tag. %2$s: Learn more link closing tag.*/
						__( 'Per your %1$sstore settings%2$s, inventory management is <strong>disabled</strong>.', 'woocommerce' ),
						'<a href="' . admin_url( 'admin.php?page=wc-settings&tab=products&section=inventory' ) . '" target="_blank" rel="noreferrer">',
						'</a>'
					),
				),
			)
		);
		$product_inventory_inner_section->add_block(
			array(
				'id'             => 'product-variation-inventory-quantity',
				'blockName'      => 'woocommerce/product-inventory-quantity-field',
				'order'          => 10,
				'hideConditions' => array(
					array(
						'expression' => 'editedProduct.manage_stock === false',
					),
				),
			)
		);
		$product_inventory_section->add_block(
			array(
				'id'             => 'product-variation-stock-status',
				'blockName'      => 'woocommerce/product-radio-field',
				'order'          => 10,
				'attributes'     => array(
					'title'    => __( 'Stock status', 'woocommerce' ),
					'property' => 'stock_status',
					'options'  => array(
						array(
							'label' => __( 'In stock', 'woocommerce' ),
							'value' => ProductStockStatus::IN_STOCK,
						),
						array(
							'label' => __( 'Out of stock', 'woocommerce' ),
							'value' => ProductStockStatus::OUT_OF_STOCK,
						),
						array(
							'label' => __( 'On backorder', 'woocommerce' ),
							'value' => ProductStockStatus::ON_BACKORDER,
						),
					),
				),
				'hideConditions' => array(
					array(
						'expression' => 'editedProduct.manage_stock === true',
					),
				),
			)
		);
	}

	/**
	 * Adds the shipping group blocks to the template.
	 */
	protected function add_shipping_group_blocks() {
		$shipping_group = $this->get_group_by_id( $this::GROUP_IDS['SHIPPING'] );
		$shipping_group->add_block(
			array(
				'id'         => 'shipping-single-variation-notice',
				'blockName'  => 'woocommerce/product-single-variation-notice',
				'order'      => 10,
				'attributes' => array(
					'content'       => __( '<strong>You’re editing details specific to this variation.</strong> Some information, like description and images, will be inherited from the main product, <noticeLink><parentProductName/></noticeLink>.', 'woocommerce' ),
					'type'          => 'info',
					'isDismissible' => true,
					'name'          => $this::SINGLE_VARIATION_NOTICE_DISMISSED_OPTION,
				),
			)
		);
		// Virtual section.
		$shipping_group->add_section(
			array(
				'id'    => 'product-variation-virtual-section',
				'order' => 20,
			)
		)->add_block(
			array(
				'id'         => 'product-variation-virtual',
				'blockName'  => 'woocommerce/product-toggle-field',
				'order'      => 10,
				'attributes' => array(
					'property'       => 'virtual',
					'checkedValue'   => false,
					'uncheckedValue' => true,
					'label'          => __( 'This variation requires shipping or pickup', 'woocommerce' ),
					'uncheckedHelp'  => __( 'This variation will not trigger your customer\'s shipping calculator in cart or at checkout. This product also won\'t require your customers to enter their shipping details at checkout. <a href="https://woocommerce.com/document/managing-products/#adding-a-virtual-product" target="_blank" rel="noreferrer">Read more about virtual products</a>.', 'woocommerce' ),
				),
			)
		);
		// Product Shipping Section.
		$product_fee_and_dimensions_section = $shipping_group->add_section(
			array(
				'id'         => 'product-variation-fee-and-dimensions-section',
				'order'      => 30,
				'attributes' => array(
					'title'       => __( 'Fees & dimensions', 'woocommerce' ),
					'description' => sprintf(
					/* translators: %1$s: How to get started? link opening tag. %2$s: How to get started? link closing tag.*/
						__( 'Set up shipping costs and enter dimensions used for accurate rate calculations. %1$sHow to get started?%2$s', 'woocommerce' ),
						'<a href="https://woocommerce.com/posts/how-to-calculate-shipping-costs-for-your-woocommerce-store/" target="_blank" rel="noreferrer">',
						'</a>'
					),
				),
			)
		);
		$product_fee_and_dimensions_section->add_block(
			array(
				'id'        => 'product-variation-shipping-class',
				'blockName' => 'woocommerce/product-shipping-class-field',
				'order'     => 10,
			)
		);
		$product_fee_and_dimensions_section->add_block(
			array(
				'id'        => 'product-variation-shipping-dimensions',
				'blockName' => 'woocommerce/product-shipping-dimensions-fields',
				'order'     => 20,
			)
		);
	}
}
PK     [1]xB	  	  6  Features/ProductBlockEditor/ProductTemplates/Group.phpnu         <?php
/**
 * WooCommerce Product Group Block class.
 */

namespace Automattic\WooCommerce\Internal\Features\ProductBlockEditor\ProductTemplates;

use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\ContainerInterface;
use Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates\GroupInterface;
use Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates\SectionInterface;
use Automattic\WooCommerce\Internal\Admin\BlockTemplates\BlockContainerTrait;

/**
 * Class for Group block.
 */
class Group extends ProductBlock implements GroupInterface {
	use BlockContainerTrait;
	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
	/**
	 * Group Block constructor.
	 *
	 * @param array                   $config The block configuration.
	 * @param BlockTemplateInterface  $root_template The block template that this block belongs to.
	 * @param ContainerInterface|null $parent The parent block container.
	 *
	 * @throws \ValueError If the block configuration is invalid.
	 * @throws \ValueError If the parent block container does not belong to the same template as the block.
	 * @throws \InvalidArgumentException If blockName key and value are passed into block configuration.
	 */
	public function __construct( array $config, BlockTemplateInterface &$root_template, ?ContainerInterface &$parent = null ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.parentFound
		if ( ! empty( $config['blockName'] ) ) {
			throw new \InvalidArgumentException( 'Unexpected key "blockName", this defaults to "woocommerce/product-tab".' );
		}
		if ( $config['id'] && ( empty( $config['attributes'] ) || empty( $config['attributes']['id'] ) ) ) {
			$config['attributes']       = empty( $config['attributes'] ) ? array() : $config['attributes'];
			$config['attributes']['id'] = $config['id'];
		}
		parent::__construct( array_merge( array( 'blockName' => 'woocommerce/product-tab' ), $config ), $root_template, $parent );
	}
	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

	/**
	 * Add a section block type to this template.
	 *
	 * @param array $block_config The block data.
	 */
	public function add_section( array $block_config ): SectionInterface {
		$block = new Section( $block_config, $this->get_root_template(), $this );
		return $this->add_inner_block( $block );
	}
}
PK     [1][P P   Features/FeaturesController.phpnu         <?php
/**
 * FeaturesController class file
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Features;

use Automattic\WooCommerce\Internal\Admin\EmailPreview\EmailPreview;
use WC_Tracks;
use WC_Site_Tracking;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Admin\Analytics;
use Automattic\WooCommerce\Internal\Caches\ProductCacheController;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;
use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Automattic\WooCommerce\Utilities\PluginUtil;
use Automattic\WooCommerce\Enums\FeaturePluginCompatibility;

defined( 'ABSPATH' ) || exit;

/**
 * Class to define the WooCommerce features that can be enabled and disabled by admin users,
 * provides also a mechanism for WooCommerce plugins to declare that they are compatible
 * (or incompatible) with a given feature.
 *
 * Note: the 'woocommerce_register_feature_definitions' hook allows registering new features
 * externally. This hook is deprecated, features should be registered from within get_feature_definitions.
 * However, in case you use it for testing purposes, keep in mind that the hook is fired from inside 'init';
 * therefore, features that need to be queried, enabled, or disabled before 'init' (e.g. during WP CLI initialization)
 * can't be registered using the hook.
 */
class FeaturesController {

	public const FEATURE_ENABLED_CHANGED_ACTION = 'woocommerce_feature_enabled_changed';

	public const PLUGINS_COMPATIBLE_BY_DEFAULT_OPTION = 'woocommerce_plugins_are_compatible_with_features_by_default';

	/**
	 * The existing feature definitions.
	 *
	 * @var array[]
	 */
	private $features = array();

	/**
	 * The registered compatibility info for WooCommerce plugins, with plugin names as keys.
	 *
	 * @var array
	 */
	private $compatibility_info_by_plugin = array();

	/**
	 * The registered compatibility info for WooCommerce plugins, with feature ids as keys.
	 *
	 * @var array
	 */
	private $compatibility_info_by_feature = array();

	/**
	 * Pending compatibility declarations. Format is [feature_id, plugin_file, positive_compatibility].
	 *
	 * @var array
	 */
	private $pending_declarations = array();

	/**
	 * The LegacyProxy instance to use.
	 *
	 * @var LegacyProxy
	 */
	private $proxy;

	/**
	 * The PluginUtil instance to use.
	 *
	 * @var PluginUtil
	 */
	private $plugin_util;

	/**
	 * Flag indicating that features will be enableable from the settings page
	 * even when they are incompatible with active plugins.
	 *
	 * @var bool
	 */
	private $force_allow_enabling_features = false;

	/**
	 * Flag indicating that plugins will be activable from the plugins page
	 * even when they are incompatible with enabled features.
	 *
	 * @var bool
	 */
	private $force_allow_enabling_plugins = false;

	/**
	 * List of plugins excluded from feature compatibility warnings in UI.
	 *
	 * @var string[]
	 */
	private $plugins_excluded_from_compatibility_ui;

	/**
	 * Flag indicating if additional features have been registered already
	 * via woocommerce_register_feature_definitions action.
	 *
	 * @var bool
	 */
	private bool $registered_additional_features_via_action = false;

	/**
	 * Flag indicating if additional features have been registered already
	 * via calls to other classes.
	 *
	 * @var bool
	 */
	private bool $registered_additional_features_via_class_calls = false;

	/**
	 * Flag indicating if we are currently delaying plugin normalization.
	 *
	 * @var bool
	 */
	private bool $lazy = true;

	/**
	 * Creates a new instance of the class.
	 */
	public function __construct() {
		// In principle, register_additional_features is triggered manually from within class-woocommerce
		// right before before_woocommerce_init is fired (this is needed for the features to be visible
		// to plugins executing declare_compatibility).
		// However we add additional checks/hookings here to support unit tests and possible overlooked/future
		// DI container/class instantiation nuances.
		if ( ! $this->registered_additional_features_via_action ) {
			if ( did_action( 'before_woocommerce_init' ) ) {
				// Needed for unit tests, where 'before_woocommerce_init' will have been fired already at this point.
				$this->register_additional_features();
			} else {
				// This needs to have a higher $priority than the 'before_woocommerce_init' hooked by plugins that declare compatibility.
				add_filter( 'before_woocommerce_init', array( $this, 'register_additional_features' ), -9999, 0 );
			}
		}

		if ( did_action( 'init' ) ) {
			// Needed for unit tests, where 'init' will have been fired already at this point.
			$this->start_listening_for_option_changes();
		} else {
			add_filter( 'init', array( $this, 'start_listening_for_option_changes' ), 10, 0 );
		}

		add_filter( 'woocommerce_get_sections_advanced', array( $this, 'add_features_section' ), 10, 1 );
		add_filter( 'woocommerce_get_settings_advanced', array( $this, 'add_feature_settings' ), 10, 2 );
		add_filter( 'deactivated_plugin', array( $this, 'handle_plugin_deactivation' ), 10, 1 );
		add_filter( 'all_plugins', array( $this, 'filter_plugins_list' ), 10, 1 );
		add_action( 'admin_notices', array( $this, 'display_notices_in_plugins_page' ), 10, 0 );
		add_action( 'load-plugins.php', array( $this, 'maybe_invalidate_cached_plugin_data' ) );
		add_action( 'after_plugin_row', array( $this, 'handle_plugin_list_rows' ), 10, 2 );
		add_action( 'current_screen', array( $this, 'enqueue_script_to_fix_plugin_list_html' ), 10, 1 );
		add_filter( 'views_plugins', array( $this, 'handle_plugins_page_views_list' ), 10, 1 );
		add_filter( 'woocommerce_admin_shared_settings', array( $this, 'set_change_feature_enable_nonce' ), 20, 1 );
		add_action( 'admin_init', array( $this, 'change_feature_enable_from_query_params' ), 20, 0 );
		add_action( self::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'display_email_improvements_feedback_notice' ), 10, 2 );
	}

	/**
	 * Register a feature.
	 *
	 * This used to be called during the `woocommerce_register_feature_definitions` action hook,
	 * now it's called directly from get_feature_definitions as needed.
	 *
	 * @param string $slug The ID slug of the feature.
	 * @param string $name The name of the feature that will appear on the Features screen and elsewhere.
	 * @param array  $args {
	 *     Properties that make up the feature definition. Each of these properties can also be set as a
	 *     callback function, as long as that function returns the specified type.
	 *
	 *     @type string  $default_plugin_compatibility The default plugin compatibility for the feature: either 'compatible' or 'incompatible'. Required.
	 *     @type array[] $additional_settings          An array of definitions for additional settings controls related to
	 *                                                 the feature that will display on the Features screen. See the Settings API
	 *                                                 for the schema of these props.
	 *     @type string  $description                  A brief description of the feature, used as an input label if the feature
	 *                                                 setting is a checkbox.
	 *     @type bool    $disabled                     True to disable the setting field for this feature on the Features screen,
	 *                                                 so it can't be changed.
	 *     @type bool    $disable_ui                   Set to true to hide the setting field for this feature on the
	 *                                                 Features screen. Defaults to false.
	 *     @type bool    $enabled_by_default           Set to true to have this feature by opt-out instead of opt-in.
	 *                                                 Defaults to false.
	 *     @type bool    $is_experimental              Set to true to display this feature under the "Experimental" heading on
	 *                                                 the Features screen. Features set to experimental are also omitted from
	 *                                                 the features list in some cases. Defaults to true.
	 *     @type bool    $skip_compatibility_checks    Set to true if the feature should not produce warnings about incompatible plugins.
	 *                                                 Defaults to false.
	 *     @type string  $learn_more_url               The URL to the learn more page for the feature.
	 *     @type string  $option_key                   The key name for the option that enables/disables the feature.
	 *     @type int     $order                        The order that the feature will appear in the list on the Features screen.
	 *                                                 Higher number = higher in the list. Defaults to 10.
	 *     @type array   $setting                      The properties used by the Settings API to render the setting control on
	 *                                                 the Features screen. See the Settings API for the schema of these props.
	 *     @type string  $deprecated_since             The WooCommerce version since which this feature is deprecated.
	 *                                                 When set, feature_is_enabled() will force feature value to the deprecated_value
	 *                                                 instead of reading from the database.
	 *     @type bool    $deprecated_value             The value to return for deprecated features when feature_is_enabled()
	 *                                                 is called. Defaults to false.
	 * }
	 *
	 * @return void
	 */
	public function add_feature_definition( $slug, $name, array $args = array() ) {
		$defaults = array(
			'disable_ui'                   => false,
			'enabled_by_default'           => false,
			'is_experimental'              => true,
			'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
			'skip_compatibility_checks'    => false,
			'name'                         => $name,
			'order'                        => 10,
			'learn_more_url'               => '',
		);

		if ( empty( $args['default_plugin_compatibility'] ) ) {
			wc_doing_it_wrong(
				__FUNCTION__,
				sprintf(
					'Assuming positive compatibility by default will be deprecated in the future. Please set \'default_plugin_compatibility\' for feature "%s".',
					esc_html( $slug )
				),
				'10.3.0'
			);
		}

		$args = wp_parse_args( $args, $defaults );

		// Sanitize 'default_plugin_compatibility'.
		if ( ! in_array( $args['default_plugin_compatibility'], FeaturePluginCompatibility::VALID_REGISTRATION_VALUES, true ) ) {
			$args['default_plugin_compatibility'] = wc_string_to_bool( $args['default_plugin_compatibility'] ) ? FeaturePluginCompatibility::COMPATIBLE : FeaturePluginCompatibility::INCOMPATIBLE;
		}

		// Support 'is_legacy' flag for backwards compatibility.
		if ( ! empty( $args['is_legacy'] ) ) {
			$args['skip_compatibility_checks'] = true;
		}

		$this->features[ $slug ] = $args;
	}

	/**
	 * Generate and cache the feature definitions.
	 *
	 * @return array[]
	 */
	private function get_feature_definitions() {
		if ( empty( $this->features ) ) {
			$this->init_feature_definitions();
		}

		if ( ! $this->registered_additional_features_via_class_calls ) {
			// This needs to be set to true *before* additional feature definition calls are made,
			// to prevent infinite loops in case one of these calls ends up calling here again.
			$this->registered_additional_features_via_class_calls = true;

			// Additional feature definitions.
			// These used to be tied to the now deprecated woocommerce_register_feature_definitions action,
			// and aren't processed in init_feature_definitions to avoid circular calls in the dependency injection container.
			$container = wc_get_container();
			$container->get( CustomOrdersTableController::class )->add_feature_definition( $this );
			$container->get( CostOfGoodsSoldController::class )->add_feature_definition( $this );

			$this->init_compatibility_info_by_feature();
		}

		return $this->features;
	}

	/**
	 * Initialize the hardcoded feature definitions array.
	 * This doesn't include:
	 * - Features that get initialized via the (deprecated) woocommerce_register_feature_definitions.
	 * - Features whose definition comes from another class. These are initialized directly in get_feature_definitions
	 *   to avoid circular calls in the dependency injection container.
	 */
	private function init_feature_definitions(): void {
		$alpha_feature_testing_is_enabled = Constants::is_true( 'WOOCOMMERCE_ENABLE_ALPHA_FEATURE_TESTING' );
		$tracking_enabled                 = WC_Site_Tracking::is_tracking_enabled();

		$legacy_features = array(
			'analytics'                          => array(
				'name'                         => __( 'Analytics', 'woocommerce' ),
				'description'                  => __( 'Enable WooCommerce Analytics', 'woocommerce' ),
				'option_key'                   => Analytics::TOGGLE_OPTION_NAME,
				'is_experimental'              => false,
				'enabled_by_default'           => true,
				'disable_ui'                   => false,
				'skip_compatibility_checks'    => true,
				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
			),
			'product_block_editor'               => array(
				'name'                         => __( 'New product editor', 'woocommerce' ),
				'description'                  => __( 'Try the new product editor (Beta)', 'woocommerce' ),
				'is_experimental'              => true,
				'disable_ui'                   => false,
				'skip_compatibility_checks'    => true,
				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
			),
			'cart_checkout_blocks'               => array(
				'name'                         => __( 'Cart & Checkout Blocks', 'woocommerce' ),
				'description'                  => __( 'Optimize for faster checkout', 'woocommerce' ),
				'is_experimental'              => false,
				'disable_ui'                   => true,
				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
			),
			'rate_limit_checkout'                => array(
				'name'                         => __( 'Rate limit Checkout', 'woocommerce' ),
				'description'                  => sprintf(
					// translators: %s is the URL to the rate limiting documentation.
					__( 'Enables rate limiting for Checkout place order and Store API /checkout endpoint. To further control this, refer to <a href="%s" target="_blank">rate limiting documentation</a>.', 'woocommerce' ),
					'https://developer.woocommerce.com/docs/apis/store-api/rate-limiting/'
				),
				'is_experimental'              => false,
				'disable_ui'                   => false,
				'enabled_by_default'           => false,
				'skip_compatibility_checks'    => true,
				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
			),
			'marketplace'                        => array(
				'name'                         => __( 'Marketplace', 'woocommerce' ),
				'description'                  => __(
					'New, faster way to find extensions and themes for your WooCommerce store',
					'woocommerce'
				),
				'is_experimental'              => false,
				'enabled_by_default'           => true,
				'disable_ui'                   => true,
				'skip_compatibility_checks'    => true,
				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
				'deprecated_since'             => '10.5.0',
				'deprecated_value'             => true,
			),
			// Marked as a legacy feature to avoid compatibility checks, which aren't really relevant to this feature.
			// https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959.
			'order_attribution'                  => array(
				'name'                         => __( 'Order Attribution', 'woocommerce' ),
				'description'                  => __(
					'Enable this feature to track and credit channels and campaigns that contribute to orders on your site',
					'woocommerce'
				),
				'enabled_by_default'           => true,
				'disable_ui'                   => false,
				'skip_compatibility_checks'    => true,
				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
				'is_experimental'              => false,
			),
			'site_visibility_badge'              => array(
				'name'                         => __( 'Site visibility badge', 'woocommerce' ),
				'description'                  => __(
					'Enable the site visibility badge in the WordPress admin bar',
					'woocommerce'
				),
				'enabled_by_default'           => true,
				'disable_ui'                   => false,
				'skip_compatibility_checks'    => true,
				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
				'is_experimental'              => false,
				'disabled'                     => false,
			),
			'hpos_fts_indexes'                   => array(
				'name'                         => __( 'HPOS Full text search indexes', 'woocommerce' ),
				'description'                  => __(
					'Create and use full text search indexes for orders. This feature only works with high-performance order storage.',
					'woocommerce'
				),
				'is_experimental'              => true,
				'enabled_by_default'           => false,
				'skip_compatibility_checks'    => true,
				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
				'option_key'                   => CustomOrdersTableController::HPOS_FTS_INDEX_OPTION,
			),
			'hpos_datastore_caching'             => array(
				'name'                         => __( 'HPOS Data Caching', 'woocommerce' ),
				'description'                  => __(
					'Enable order data caching in the datastore. This feature only works with high-performance order storage and is recommended for stores using object caching.',
					'woocommerce'
				),
				'is_experimental'              => false,
				'enabled_by_default'           => false,
				'skip_compatibility_checks'    => true,
				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
				'disable_ui'                   => false,
				'option_key'                   => CustomOrdersTableController::HPOS_DATASTORE_CACHING_ENABLED_OPTION,
			),
			'remote_logging'                     => array(
				'name'                         => __( 'Remote Logging', 'woocommerce' ),
				'description'                  => sprintf(
					/* translators: %1$s: opening link tag, %2$s: closing link tag */
					__( 'Allow WooCommerce to send error logs and non-sensitive diagnostic data to help improve WooCommerce. This feature requires %1$susage tracking%2$s to be enabled.', 'woocommerce' ),
					'<a href="' . admin_url( 'admin.php?page=wc-settings&tab=advanced&section=woocommerce_com' ) . '">',
					'</a>'
				),
				'enabled_by_default'           => true,
				'disable_ui'                   => false,

				/*
				 * This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController),
				 * but we wish to handle compatibility checking in a similar fashion to legacy features. The
				 * rational for setting legacy to true is therefore similar to that of the 'order_attribution'
				 * feature.
				 *
				 * @see https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959
				 */
				'skip_compatibility_checks'    => true,
				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
				'is_experimental'              => false,
				'setting'                      => array(
					'disabled' => function () use ( $tracking_enabled ) {
						return ! $tracking_enabled;
					},
					'desc_tip' => function () use ( $tracking_enabled ) {
						if ( ! $tracking_enabled ) {
							return __( '⚠ Usage tracking must be enabled to use remote logging.', 'woocommerce' );
						}

						return '';
					},
				),
			),
			'email_improvements'                 => array(
				'name'                         => __( 'Email improvements', 'woocommerce' ),
				'description'                  => __(
					'Enable modern email design for transactional emails',
					'woocommerce'
				),

				/*
				 * This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController),
				 * but as this feature doesn't affect all extensions, and the rollout is fairly short,
				 * we'll skip the compatibility check by marking this as legacy. This is a workaround until
				 * we can implement a more sophisticated compatibility checking system.
				 *
				 * @see https://github.com/woocommerce/woocommerce/issues/39147
				 * @see https://github.com/woocommerce/woocommerce/issues/55540
				 */
				'skip_compatibility_checks'    => true,
				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
				'is_experimental'              => false,
			),
			'blueprint'                          => array(
				'name'                         => __( 'Blueprint (beta)', 'woocommerce' ),
				'description'                  => __(
					'Enable blueprint to import and export settings in bulk',
					'woocommerce'
				),
				'enabled_by_default'           => true,
				'disable_ui'                   => false,

				/*
				* This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController),
				* but we wish to handle compatibility checking in a similar fashion to legacy features. The
				* rational for setting legacy to true is therefore similar to that of the 'order_attribution'
				* feature.
				*
				* @see https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959
				*/
				'skip_compatibility_checks'    => true,
				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
				'is_experimental'              => false,
			),
			'block_email_editor'                 => array(
				'name'                         => __( 'Block Email Editor (alpha)', 'woocommerce' ),
				'description'                  => __(
					'Enable the block-based email editor for transactional emails.',
					'woocommerce'
				),
				'learn_more_url'               => 'https://github.com/woocommerce/woocommerce/discussions/52897#discussioncomment-11630256',

				/*
				* This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController),
				* but we wish to handle compatibility checking in a similar fashion to legacy features. The
				* rational for setting legacy to true is therefore similar to that of the 'order_attribution'
				* feature.
				*
				* @see https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959
				*/
				'skip_compatibility_checks'    => true,
				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
				'enabled_by_default'           => false,
			),
			'point_of_sale'                      => array(
				'name'                         => __( 'Point of Sale', 'woocommerce' ),
				'description'                  => __(
					'Enable Point of Sale functionality in the WooCommerce mobile apps.',
					'woocommerce'
				),
				'enabled_by_default'           => true,
				'disable_ui'                   => false,

				/*
				* This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController),
				* but we wish to handle compatibility checking in a similar fashion to legacy features. The
				* rational for setting legacy to true is therefore similar to that of the 'order_attribution'
				* feature.
				*
				* @see https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959
				*/
				'skip_compatibility_checks'    => true,
				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
				'is_experimental'              => true,
			),
			'fulfillments'                       => array(
				'name'                         => __( 'Order Fulfillments', 'woocommerce' ),
				'description'                  => __(
					'Enable the Order Fulfillments feature to manage order fulfillment and shipping.',
					'woocommerce'
				),
				'enabled_by_default'           => false,
				'disable_ui'                   => true,
				'is_experimental'              => false,
				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
			),
			'mcp_integration'                    => array(
				'name'                         => __( 'WooCommerce MCP', 'woocommerce' ),
				'description'                  => $this->get_mcp_integration_description(),
				'enabled_by_default'           => false,
				'disable_ui'                   => false,
				'is_experimental'              => true,
				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
				'is_legacy'                    => false,
			),
			'destroy-empty-sessions'             => array(
				'name'                         => __( 'Clear Customer Sessions When Empty', 'woocommerce' ),
				'description'                  => __(
					'[Performance] Removes session cookies for non-logged in customers when session data is empty, improving page caching performance. May cause compatibility issues with extensions that depend on the session cookie without using session data.',
					'woocommerce'
				),
				'enabled_by_default'           => false,
				'is_experimental'              => true,
				'disable_ui'                   => false,
				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
			),
			'agentic_checkout'                   => array(
				'name'                         => __( 'Agentic Checkout API', 'woocommerce' ),
				'description'                  => __(
					'Enable the Agentic Checkout API for AI-powered checkout experiences (e.g., ChatGPT). This adds REST API endpoints that allow AI agents to create and manage checkout sessions.',
					'woocommerce'
				),
				'enabled_by_default'           => false,
				'is_experimental'              => true,
				'disable_ui'                   => true,
				'skip_compatibility_checks'    => true,
				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
			),
			PushNotifications::FEATURE_NAME      => array(
				'name'                         => __( 'Push Notifications', 'woocommerce' ),
				'description'                  => __(
					'Enable push notifications for the WooCommerce mobile apps to receive order notifications and store updates.',
					'woocommerce'
				),
				'enabled_by_default'           => false,
				'is_experimental'              => true,
				'disable_ui'                   => true,
				'skip_compatibility_checks'    => false,
				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
			),
			'rest_api_caching'                   => array(
				'name'                         => __( 'REST API Caching', 'woocommerce' ),
				'description'                  => sprintf(
					/* translators: %1$s and %2$s are opening and closing <a> tags */
					__( 'Enable backend caching and cache control headers for REST API responses via the <code>RestApiCache</code> trait. ⚙️ %1$sConfiguration%2$s', 'woocommerce' ),
					'<a href="' . admin_url( 'admin.php?page=wc-settings&tab=advanced&section=rest_api_caching' ) . '">',
					'</a>'
				),
				'enabled_by_default'           => false,
				'is_experimental'              => true,
				'disable_ui'                   => false,
				'skip_compatibility_checks'    => true,
				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
			),
			ProductCacheController::FEATURE_NAME => array(
				'name'                         => __( 'Cache Product Objects', 'woocommerce' ),
				'description'                  => __(
					'[Performance] Speeds up your store by caching product objects during each request, preventing duplicate product loads. Can improve page load times on product-heavy pages.',
					'woocommerce'
				),
				'default_plugin_compatibility' => FeaturePluginCompatibility::INCOMPATIBLE,
				'enabled_by_default'           => false,
				'is_experimental'              => true,
				'disable_ui'                   => false,
			),
			'fraud_protection'                   => array(
				'name'                         => __( 'Fraud protection', 'woocommerce' ),
				'description'                  => __(
					'Enable fraud protection features for your store.',
					'woocommerce'
				),
				'enabled_by_default'           => false,
				'disable_ui'                   => true,
				'is_experimental'              => true,
				'skip_compatibility_checks'    => true,
				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
			),
		);

		if ( ! $tracking_enabled ) {
			// Uncheck the remote logging feature when usage tracking is disabled.
			$legacy_features['remote_logging']['setting']['value'] = 'no';
		}

		foreach ( $legacy_features as $slug => $definition ) {
			$this->add_feature_definition( $slug, $definition['name'], $definition );
		}

		$this->init_compatibility_info_by_feature();
	}

	/**
	 * Initialize the compatibility_info_by_feature property after all the features have been added.
	 */
	private function init_compatibility_info_by_feature() {
		foreach ( array_keys( $this->features ) as $feature_id ) {
			if ( ! isset( $this->compatibility_info_by_feature[ $feature_id ] ) ) {
				$this->compatibility_info_by_feature[ $feature_id ] = array(
					FeaturePluginCompatibility::COMPATIBLE => array(),
					FeaturePluginCompatibility::INCOMPATIBLE => array(),
				);
			}
		}
	}

	/**
	 * Generate the description for the MCP integration feature.
	 *
	 * @return string The feature description with conditional permalink warning and documentation link.
	 */
	private function get_mcp_integration_description() {
		$base_description = __( 'Enable WooCommerce MCP (Model Context Protocol) for AI-powered store operations. AI-generated results and actions can be unpredictable - please review before executing in your store.', 'woocommerce' );

		// Check permalink structure requirement.
		$permalink_structure = get_option( 'permalink_structure' );
		if ( empty( $permalink_structure ) ) {
			$permalinks_url    = admin_url( 'options-permalink.php' );
			$permalink_warning = sprintf(
				'<br><br><strong>%s:</strong> %s <a href="%s">%s</a>',
				__( 'Configuration Required', 'woocommerce' ),
				__( 'WordPress permalinks must be set to anything other than "Plain" for MCP to work.', 'woocommerce' ),
				$permalinks_url,
				__( 'Configure Permalinks', 'woocommerce' )
			);
			// Add documentation link to permalink warning.
			$documentation_link = sprintf(
				' <a href="%s" target="_blank">%s</a>',
				'https://github.com/woocommerce/woocommerce/blob/trunk/docs/features/mcp/README.md',
				__( 'Learn more', 'woocommerce' )
			);
			return $base_description . $permalink_warning . $documentation_link;
		}

		// Add documentation link.
		$documentation_link = sprintf(
			' <a href="%s" target="_blank">%s</a>',
			'https://github.com/woocommerce/woocommerce/blob/trunk/docs/features/mcp/README.md',
			__( 'Learn more', 'woocommerce' )
		);

		return $base_description . $documentation_link;
	}

	/**
	 * Function to trigger the (now deprecated) 'woocommerce_register_feature_definitions' hook.
	 *
	 * This function must execute immediately before the 'before_woocommerce_init'
	 * action is fired, so that feature compatibility declarations happening
	 * in that action find all the features properly declared already.
	 *
	 * @internal
	 */
	public function register_additional_features() {
		if ( $this->registered_additional_features_via_action ) {
			return;
		}

		if ( empty( $this->features ) ) {
			$this->init_feature_definitions();
		}

		/**
		 * The action for registering features.
		 *
		 * @since 8.3.0
		 *
		 * @param FeaturesController $features_controller The instance of FeaturesController.
		 *
		 * @deprecated 9.9.0 Features should be defined directly in get_feature_definitions.
		 */
		do_action( 'woocommerce_register_feature_definitions', $this );

		$this->init_compatibility_info_by_feature();

		$this->registered_additional_features_via_action = true;
	}

	/**
	 * Initialize the class instance.
	 *
	 * @internal
	 *
	 * @param LegacyProxy $proxy The instance of LegacyProxy to use.
	 * @param PluginUtil  $plugin_util The instance of PluginUtil to use.
	 */
	final public function init( LegacyProxy $proxy, PluginUtil $plugin_util ) {
		$this->proxy       = $proxy;
		$this->plugin_util = $plugin_util;

		$this->plugins_excluded_from_compatibility_ui = $plugin_util->get_plugins_excluded_from_compatibility_ui();
	}

	/**
	 * Get all the existing WooCommerce features.
	 *
	 * Returns an associative array where keys are unique feature ids
	 * and values are arrays with these keys:
	 *
	 * - name (string)
	 * - description (string)
	 * - is_experimental (bool)
	 * - is_enabled (bool) (only if $include_enabled_info is passed as true)
	 *
	 * @param bool $include_experimental Include also experimental/work in progress features in the list.
	 * @param bool $include_enabled_info True to include the 'is_enabled' field in the returned features info.
	 * @returns array An array of information about existing features.
	 */
	public function get_features( bool $include_experimental = false, bool $include_enabled_info = false ): array {
		$features = $this->get_feature_definitions();

		if ( ! $include_experimental ) {
			$features = array_filter(
				$features,
				function ( $feature ) {
					return ! $feature['is_experimental'];
				}
			);
		}

		if ( $include_enabled_info ) {
			foreach ( array_keys( $features ) as $feature_id ) {
				$is_enabled = false;
				// For deprecated features, use the deprecated_value directly without triggering the deprecation notice.
				// The deprecation notice should only fire for external code checking feature status, not for internal listing.
				if ( ! empty( $features[ $feature_id ]['deprecated_since'] ) ) {
					$is_enabled = (bool) ( $features[ $feature_id ]['deprecated_value'] ?? false );
				} else {
					$is_enabled = $this->feature_is_enabled( $feature_id );
				}
				$features[ $feature_id ]['is_enabled'] = $is_enabled;
			}
		}

		// We're deprecating the product block editor feature in favor of a v3 coming out.
		// We want to hide this setting in the UI for users that don't have it enabled.
		// If users have it enabled, we won't hide it until they explicitly disable it.
		if ( isset( $features['product_block_editor'] )
			&& ! $this->feature_is_enabled( 'product_block_editor' ) ) {
			$features['product_block_editor']['disable_ui'] = true;
		}

		return $features;
	}

	/**
	 * Get the default plugin compatibility for a given feature.
	 *
	 * @param string $feature_id Feature id to check.
	 * @return string Either 'compatible' or 'incompatible'.
	 * @throws \InvalidArgumentException If the feature doesn't exist.
	 */
	public function get_default_plugin_compatibility( string $feature_id ): string {
		$feature = $this->get_feature_definition( $feature_id );
		if ( null === $feature ) {
			throw new \InvalidArgumentException( esc_html( "The WooCommerce feature '$feature_id' doesn't exist" ) );
		}

		$default_plugin_compatibility = $feature['default_plugin_compatibility'] ?? FeaturePluginCompatibility::COMPATIBLE;

		// Filter below is only fired for backwards compatibility with (now removed) get_plugins_are_incompatible_by_default().
		/**
		 * Filter to determine if plugins that don't declare compatibility nor incompatibility with a given feature
		 * are to be considered incompatible with that feature.
		 *
		 * @param bool $incompatible_by_default Default value, true if plugins are to be considered incompatible by default with the feature.
		 * @param string $feature_id The feature to check.
		 *
		 * @since 9.2.0
		 */
		$incompatible_by_default = (bool) apply_filters( 'woocommerce_plugins_are_incompatible_with_feature_by_default', FeaturePluginCompatibility::INCOMPATIBLE === $default_plugin_compatibility, $feature_id );

		return $incompatible_by_default ? FeaturePluginCompatibility::INCOMPATIBLE : FeaturePluginCompatibility::COMPATIBLE;
	}

	/**
	 * Get the definition array for a specific feature.
	 *
	 * @param string $feature_id Unique feature id.
	 * @return array|null The feature definition array, or null if the feature doesn't exist.
	 *
	 * @since 10.5.0
	 */
	public function get_feature_definition( string $feature_id ): ?array {
		return $this->get_feature_definitions()[ $feature_id ] ?? null;
	}

	/**
	 * Check if a given feature is currently enabled.
	 *
	 * Note: This method does not log deprecation notices for deprecated features.
	 * Deprecation logging is handled by FeaturesUtil::feature_is_enabled() which is the public API.
	 *
	 * @param  string $feature_id Unique feature id.
	 * @return bool True if the feature is enabled, false if not or if the feature doesn't exist.
	 */
	public function feature_is_enabled( string $feature_id ): bool {
		$feature = $this->get_feature_definition( $feature_id );

		if ( null === $feature ) {
			return false;
		}

		// Handle deprecated features - return the backwards-compatible value.
		if ( ! empty( $feature['deprecated_since'] ) ) {
			return (bool) ( $feature['deprecated_value'] ?? false );
		}

		if ( $this->is_preview_email_improvements_enabled( $feature_id ) ) {
			return true;
		}

		$default_value = $this->feature_is_enabled_by_default( $feature_id ) ? 'yes' : 'no';
		$value         = 'yes' === get_option( $this->feature_enable_option_name( $feature_id ), $default_value );
		return $value;
	}

	/**
	 * Check if a given feature is enabled by default.
	 *
	 * @param string $feature_id Unique feature id.
	 * @return boolean TRUE if the feature is enabled by default, FALSE otherwise.
	 */
	private function feature_is_enabled_by_default( string $feature_id ): bool {
		$features = $this->get_feature_definitions();

		return ! empty( $features[ $feature_id ]['enabled_by_default'] );
	}

	/**
	 * Change the enabled/disabled status of a feature.
	 *
	 * @param string $feature_id Unique feature id.
	 * @param bool   $enable True to enable the feature, false to disable it.
	 * @return bool True on success, false if feature doesn't exist or the new value is the same as the old value.
	 */
	public function change_feature_enable( string $feature_id, bool $enable ): bool {
		if ( ! $this->feature_exists( $feature_id ) ) {
			return false;
		}

		return update_option( $this->feature_enable_option_name( $feature_id ), $enable ? 'yes' : 'no', 'on' );
	}

	/**
	 * Declare (in)compatibility with a given feature for a given plugin.
	 *
	 * This method MUST be executed from inside a handler for the 'before_woocommerce_init' hook.
	 *
	 * The plugin name is expected to be in the form 'directory/file.php' and be one of the keys
	 * of the array returned by 'get_plugins', but this won't be checked. Plugins are expected to use
	 * FeaturesUtil::declare_compatibility instead, passing the full plugin file path instead of the plugin name.
	 *
	 * @param string $feature_id Unique feature id.
	 * @param string $plugin_file Plugin file path, either full or in the form 'directory/file.php'.
	 * @param bool   $positive_compatibility True if the plugin declares being compatible with the feature, false if it declares being incompatible.
	 * @return bool True on success, false on error (feature doesn't exist or not inside the required hook).
	 * @throws \Exception A plugin attempted to declare itself as compatible and incompatible with a given feature at the same time.
	 */
	public function declare_compatibility( string $feature_id, string $plugin_file, bool $positive_compatibility = true ): bool {
		if ( ! $this->proxy->call_function( 'doing_action', 'before_woocommerce_init' ) ) {
			$class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__;
			/* translators: 1: class::method 2: before_woocommerce_init */
			$this->proxy->call_function( 'wc_doing_it_wrong', $class_and_method, sprintf( __( '%1$s should be called inside the %2$s action.', 'woocommerce' ), $class_and_method, 'before_woocommerce_init' ), '7.0' );
			return false;
		}
		if ( ! $this->feature_exists( $feature_id ) ) {
			return false;
		}

		if ( $this->lazy ) {
			// Lazy mode: Queue to be normalized later.
			$this->pending_declarations[] = array( $feature_id, $plugin_file, $positive_compatibility );
			return true;
		}

		// Late call: Normalize and register immediately.
		return $this->register_compatibility_internal( $feature_id, $plugin_file, $positive_compatibility );
	}

	/**
	 * Registers compatibility information internally for a given feature and plugin file.
	 *
	 * This method normalizes the plugin file path to a plugin ID, handles validation and logging for invalid plugins,
	 * and registers the compatibility data if valid.
	 * It updates the internal compatibility arrays, checks for conflicts (e.g., a plugin declaring both
	 * compatible and incompatible with the same feature), and throws an exception if a conflict is detected.
	 * Duplicate declarations (same compatibility type) are ignored.
	 *
	 * This is an internal helper method and should not be called directly.
	 *
	 * @internal For usage by WooCommerce core only. Backwards compatibility not guaranteed.
	 * @since 10.1.0
	 *
	 * @param string $feature_id             Unique feature ID.
	 * @param string $plugin_file            Raw plugin file path (full or 'directory/file.php').
	 * @param bool   $positive_compatibility True if declaring compatibility, false if declaring incompatibility.
	 * @return bool True on successful registration, false if the feature does not exist.
	 * @throws \Exception If the plugin attempts to declare both compatibility and incompatibility for the same feature.
	 */
	private function register_compatibility_internal( string $feature_id, string $plugin_file, bool $positive_compatibility ): bool {
		if ( ! $this->feature_exists( $feature_id ) ) {
			return false;
		}

		// Normalize and validate plugin file.
		$plugin_id = $this->plugin_util->get_wp_plugin_id( $plugin_file );
		if ( ! $plugin_id ) {
			$logger = $this->proxy->call_function( 'wc_get_logger' );
			$logger->error( "FeaturesController: Invalid plugin file '{$plugin_file}' for feature '{$feature_id}'." );
			return false;
		}

		// Register compatibility by plugin.
		ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin, $plugin_id );

		$key          = $positive_compatibility ? FeaturePluginCompatibility::COMPATIBLE : FeaturePluginCompatibility::INCOMPATIBLE;
		$opposite_key = $positive_compatibility ? FeaturePluginCompatibility::INCOMPATIBLE : FeaturePluginCompatibility::COMPATIBLE;
		ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_id ], $key );
		ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_id ], $opposite_key );

		if ( in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_id ][ $opposite_key ], true ) ) {
			throw new \Exception( esc_html( "Plugin $plugin_id is trying to declare itself as $key with the '$feature_id' feature, but it already declared itself as $opposite_key" ) );
		}

		if ( ! in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_id ][ $key ], true ) ) {
			$this->compatibility_info_by_plugin[ $plugin_id ][ $key ][] = $feature_id;
		}

		// Register compatibility by feature.
		$key = $positive_compatibility ? FeaturePluginCompatibility::COMPATIBLE : FeaturePluginCompatibility::INCOMPATIBLE;

		if ( ! in_array( $plugin_id, $this->compatibility_info_by_feature[ $feature_id ][ $key ], true ) ) {
			$this->compatibility_info_by_feature[ $feature_id ][ $key ][] = $plugin_id;
		}

		return true;
	}

	/**
	 * Processes any pending compatibility declarations by normalizing plugin file paths
	 * and registering them internally.
	 *
	 * This method is called lazily when compatibility information is queried (via
	 * get_compatible_features_for_plugin() or get_compatible_plugins_for_feature()).
	 * It resolves plugin IDs using PluginUtil and logs errors for unrecognized plugins.
	 * Pending declarations are cleared after processing to avoid redundant work.
	 *
	 * @internal For usage by WooCommerce core only. Backwards compatibility not guaranteed.
	 * @since 10.1.0
	 * @return void
	 */
	private function process_pending_declarations(): void {
		if ( empty( $this->pending_declarations ) ) {
			return;
		}

		foreach ( $this->pending_declarations as $declaration ) {
			list( $feature_id, $plugin_file, $positive_compatibility ) = $declaration;

			// Register internally.
			$this->register_compatibility_internal( $feature_id, $plugin_file, $positive_compatibility );
		}

		$this->pending_declarations = array();
		$this->lazy                 = false;
	}

	/**
	 * Check whether a feature exists with a given id.
	 *
	 * @param string $feature_id The feature id to check.
	 * @return bool True if the feature exists.
	 */
	private function feature_exists( string $feature_id ): bool {
		$features = $this->get_feature_definitions();

		return isset( $features[ $feature_id ] );
	}

	/**
	 * Get the ids of the features that a certain plugin has declared compatibility for.
	 *
	 * This method can't be called before the 'woocommerce_init' hook is fired.
	 *
	 * @param string $plugin_name           Plugin name, in the form 'directory/file.php'.
	 * @param bool   $enabled_features_only True to return only names of enabled plugins.
	 * @param bool   $resolve_uncertain     True to resolve the uncertain features to compatible or incompatible.
	 * @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of feature ids.
	 */
	public function get_compatible_features_for_plugin( string $plugin_name, bool $enabled_features_only = false, bool $resolve_uncertain = false ): array {
		$this->process_pending_declarations();
		$this->verify_did_woocommerce_init( __FUNCTION__ );

		$features = $this->get_feature_definitions();

		if ( $enabled_features_only ) {
			$features = array_filter(
				$features,
				array( $this, 'feature_is_enabled' ),
				ARRAY_FILTER_USE_KEY
			);
		}

		if ( ! isset( $this->compatibility_info_by_plugin[ $plugin_name ] ) ) {
			return array(
				FeaturePluginCompatibility::COMPATIBLE   => array(),
				FeaturePluginCompatibility::INCOMPATIBLE => array(),
				FeaturePluginCompatibility::UNCERTAIN    => array_keys( $features ),
			);
		}

		$info = $this->compatibility_info_by_plugin[ $plugin_name ];
		$info[ FeaturePluginCompatibility::COMPATIBLE ]   = array_values( array_intersect( array_keys( $features ), $info[ FeaturePluginCompatibility::COMPATIBLE ] ) );
		$info[ FeaturePluginCompatibility::INCOMPATIBLE ] = array_values( array_intersect( array_keys( $features ), $info[ FeaturePluginCompatibility::INCOMPATIBLE ] ) );
		$info[ FeaturePluginCompatibility::UNCERTAIN ]    = array_values( array_diff( array_keys( $features ), $info[ FeaturePluginCompatibility::COMPATIBLE ], $info[ FeaturePluginCompatibility::INCOMPATIBLE ] ) );

		if ( $resolve_uncertain ) {
			foreach ( $info[ FeaturePluginCompatibility::UNCERTAIN ] as $feature_id ) {
				$key            = $this->get_default_plugin_compatibility( $feature_id );
				$info[ $key ][] = $feature_id;
			}

			$info[ FeaturePluginCompatibility::UNCERTAIN ] = array();
		}

		return $info;
	}

	/**
	 * Get the names of the plugins that have been declared compatible or incompatible with a given feature.
	 *
	 * @param string $feature_id Feature id.
	 * @param bool   $active_only True to return only active plugins.
	 * @param bool   $resolve_uncertain True to resolve the uncertain plugins to compatible or incompatible.
	 * @return array An array having a 'compatible', an 'incompatible' and an 'uncertain' key, each holding an array of plugin names.
	 */
	public function get_compatible_plugins_for_feature( string $feature_id, bool $active_only = false, bool $resolve_uncertain = false ): array {
		$this->process_pending_declarations();
		$this->verify_did_woocommerce_init( __FUNCTION__ );

		$woo_aware_plugins = $this->plugin_util->get_woocommerce_aware_plugins( $active_only );
		if ( ! $this->feature_exists( $feature_id ) ) {
			return array(
				FeaturePluginCompatibility::COMPATIBLE   => array(),
				FeaturePluginCompatibility::INCOMPATIBLE => array(),
				FeaturePluginCompatibility::UNCERTAIN    => $woo_aware_plugins,
			);
		}

		$info = $this->compatibility_info_by_feature[ $feature_id ];
		ArrayUtil::ensure_key_is_array( $info, FeaturePluginCompatibility::UNCERTAIN );

		// Resolve uncertain plugin compatibility?
		$uncertain_plugins = array_values( array_diff( $woo_aware_plugins, $info[ FeaturePluginCompatibility::COMPATIBLE ], $info[ FeaturePluginCompatibility::INCOMPATIBLE ] ) );
		$key               = $resolve_uncertain ? $this->get_default_plugin_compatibility( $feature_id ) : FeaturePluginCompatibility::UNCERTAIN;
		$info[ $key ]      = array_merge( $info[ $key ], $uncertain_plugins );

		return $info;
	}

	/**
	 * Check if the 'woocommerce_init' has run or is running, do a 'wc_doing_it_wrong' if not.
	 *
	 * @param string|null $function_name Name of the invoking method, if not null, 'wc_doing_it_wrong' will be invoked if 'woocommerce_init' has not run and is not running.
	 *
	 * @return bool True if 'woocommerce_init' has run or is running, false otherwise.
	 */
	private function verify_did_woocommerce_init( ?string $function_name = null ): bool {
		if ( ! $this->proxy->call_function( 'did_action', 'woocommerce_init' ) &&
			! $this->proxy->call_function( 'doing_action', 'woocommerce_init' ) ) {
			if ( ! is_null( $function_name ) ) {
				$class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . $function_name;
				/* translators: 1: class::method 2: plugins_loaded */
				$this->proxy->call_function( 'wc_doing_it_wrong', $class_and_method, sprintf( __( '%1$s should not be called before the %2$s action.', 'woocommerce' ), $class_and_method, 'woocommerce_init' ), '7.0' );
			}
			return false;
		}

		return true;
	}

	/**
	 * Get the name of the option that enables/disables a given feature.
	 *
	 * Note that it doesn't check if the feature actually exists. Instead it
	 * defaults to "woocommerce_feature_{$feature_id}_enabled" if a different
	 * name isn't specified in the feature registration.
	 *
	 * @param  string $feature_id The id of the feature.
	 * @return string The option that enables or disables the feature.
	 */
	public function feature_enable_option_name( string $feature_id ): string {
		$features = $this->get_feature_definitions();

		if ( ! empty( $features[ $feature_id ]['option_key'] ) ) {
			return $features[ $feature_id ]['option_key'];
		}

		return "woocommerce_feature_{$feature_id}_enabled";
	}

	/**
	 * Check if the compatibility checks should be skipped for a given feature.
	 *
	 * @since 10.3.0
	 *
	 * @param string $feature_id The feature id to check.
	 * @return bool TRUE if the compatibility checks should be skipped.
	 */
	public function should_skip_compatibility_checks( string $feature_id ): bool {
		$features = $this->get_feature_definitions();

		return ! empty( $features[ $feature_id ]['skip_compatibility_checks'] );
	}

	/**
	 * Sets a flag indicating that it's allowed to enable features for which incompatible plugins are active
	 * from the WooCommerce feature settings page.
	 */
	public function allow_enabling_features_with_incompatible_plugins(): void {
		$this->force_allow_enabling_features = true;
	}

	/**
	 * Sets a flag indicating that it's allowed to activate plugins for which incompatible features are enabled
	 * from the WordPress plugins page.
	 */
	public function allow_activating_plugins_with_incompatible_features(): void {
		$this->force_allow_enabling_plugins = true;
	}

	/**
	 * Adds our callbacks for the `updated_option` and `added_option` filter hooks.
	 *
	 * We delay adding these hooks until `init`, because both callbacks need to load our list of feature definitions,
	 * and building that list requires translating various strings (which should not be done earlier than `init`).
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function start_listening_for_option_changes(): void {
		add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 );
		add_filter( 'added_option', array( $this, 'process_added_option' ), 999, 3 );
	}

	/**
	 * Handler for the 'added_option' hook.
	 *
	 * It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled.
	 *
	 * @param string $option The option that has been created.
	 * @param mixed  $value The value of the option.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function process_added_option( string $option, $value ) {
		$this->process_updated_option( $option, false, $value );
	}

	/**
	 * Handler for the 'updated_option' hook.
	 *
	 * It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled.
	 *
	 * @param string $option    The option that has been modified.
	 * @param mixed  $old_value The old value of the option.
	 * @param mixed  $value     The new value of the option.
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function process_updated_option( string $option, $old_value, $value ) {
		$matches                   = array();
		$is_default_key            = preg_match( '/^woocommerce_feature_([a-zA-Z0-9_]+)_enabled$/', $option, $matches );
		$features_with_custom_keys = array_filter(
			$this->get_feature_definitions(),
			function ( $feature ) {
				return ! empty( $feature['option_key'] );
			}
		);
		$custom_keys               = wp_list_pluck( $features_with_custom_keys, 'option_key' );

		if ( ! $is_default_key && ! in_array( $option, $custom_keys, true ) ) {
			return;
		}

		if ( $value === $old_value ) {
			return;
		}

		$feature_id = '';
		if ( $is_default_key ) {
			$feature_id = $matches[1];
		} elseif ( in_array( $option, $custom_keys, true ) ) {
			$feature_id = array_search( $option, $custom_keys, true );
		}

		if ( ! $feature_id ) {
			return;
		}

		WC_Tracks::record_event(
			self::FEATURE_ENABLED_CHANGED_ACTION,
			array(
				'feature_id' => $feature_id,
				'enabled'    => $value,
			)
		);

		/**
		 * Action triggered when a feature is enabled or disabled (the value of the corresponding setting option is changed).
		 *
		 * @param string $feature_id The id of the feature.
		 * @param bool $enabled True if the feature has been enabled, false if it has been disabled.
		 *
		 * @since 7.0.0
		 */
		do_action( self::FEATURE_ENABLED_CHANGED_ACTION, $feature_id, 'yes' === $value );
	}

	/**
	 * Handler for the 'woocommerce_get_sections_advanced' hook,
	 * it adds the "Features" section to the advanced settings page.
	 *
	 * @param array $sections The original sections array.
	 * @return array The updated sections array.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function add_features_section( $sections ) {
		if ( ! isset( $sections['features'] ) ) {
			$sections['features'] = __( 'Features', 'woocommerce' );
		}
		return $sections;
	}

	/**
	 * Handler for the 'woocommerce_get_settings_advanced' hook,
	 * it adds the settings UI for all the existing features.
	 *
	 * Note that the settings added via the 'woocommerce_settings_features' hook will be
	 * displayed in the non-experimental features section.
	 *
	 * @param array  $settings The existing settings for the corresponding settings section.
	 * @param string $current_section The section to get the settings for.
	 * @return array The updated settings array.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function add_feature_settings( $settings, $current_section ): array {
		if ( 'features' !== $current_section ) {
			return $settings;
		}

		$feature_settings = array(
			array(
				'title' => __( 'Features', 'woocommerce' ),
				'type'  => 'title',
				'desc'  => __( 'Start using new features that are being progressively rolled out to improve the store management experience.', 'woocommerce' ),
				'id'    => 'features_options',
			),
		);

		$features = $this->get_features( true );

		$feature_ids = array_keys( $features );
		usort(
			$feature_ids,
			function ( $feature_id_a, $feature_id_b ) use ( $features ) {
				return ( $features[ $feature_id_b ]['order'] ?? 0 ) <=> ( $features[ $feature_id_a ]['order'] ?? 0 );
			}
		);
		$experimental_feature_ids = array_filter(
			$feature_ids,
			function ( $feature_id ) use ( $features ) {
				return $features[ $feature_id ]['is_experimental'] ?? false;
			}
		);
		$mature_feature_ids       = array_diff( $feature_ids, $experimental_feature_ids );
		$feature_ids              = array_merge( $mature_feature_ids, array( 'mature_features_end' ), $experimental_feature_ids );

		foreach ( $feature_ids as $id ) {
			if ( 'mature_features_end' === $id ) {
				// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
				/**
				 * Filter allowing to add additional settings to the WooCommerce Advanced - Features settings page.
				 *
				 * @param bool $disabled False.
				 */
				$feature_settings = apply_filters( 'woocommerce_settings_features', $feature_settings );
				// phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment

				if ( ! empty( $experimental_feature_ids ) ) {
					$feature_settings[] = array(
						'type' => 'sectionend',
						'id'   => 'features_options',
					);

					$feature_settings[] = array(
						'title' => __( 'Experimental features', 'woocommerce' ),
						'type'  => 'title',
						'desc'  => __( 'These features are either experimental or incomplete, enable them at your own risk!', 'woocommerce' ),
						'id'    => 'experimental_features_options',
					);
				}
				continue;
			}

			if ( 'new_navigation' === $id && 'yes' !== get_option( $this->feature_enable_option_name( $id ), 'no' ) ) {
				continue;
			}

			if ( isset( $features[ $id ]['disable_ui'] ) && $features[ $id ]['disable_ui'] ) {
				continue;
			}

			$feature_settings[] = $this->get_setting_for_feature( $id, $features[ $id ] );

			$additional_settings = $features[ $id ]['additional_settings'] ?? array();
			if ( count( $additional_settings ) > 0 ) {
				$feature_settings = array_merge( $feature_settings, $additional_settings );
			}
		}

		$feature_settings[] = array(
			'type' => 'sectionend',
			'id'   => empty( $experimental_feature_ids ) ? 'features_options' : 'experimental_features_options',
		);

		if ( $this->verify_did_woocommerce_init() ) {
			// Allow feature setting properties to be determined dynamically just before being rendered.
			$feature_settings = array_map(
				function ( $feature_setting ) {
					foreach ( $feature_setting as $prop => $value ) {
						if ( is_callable( $value ) ) {
							$feature_setting[ $prop ] = call_user_func( $value );
						}
					}

					return $feature_setting;
				},
				$feature_settings
			);
		}

		return $feature_settings;
	}

	/**
	 * Get the parameters to display the setting enable/disable UI for a given feature.
	 *
	 * @param string $feature_id The feature id.
	 * @param array  $feature The feature parameters, as returned by get_features.
	 * @return array The parameters to add to the settings array.
	 */
	private function get_setting_for_feature( string $feature_id, array $feature ): array {
		$description        = $feature['description'] ?? '';
		$disabled           = false;
		$desc_tip           = '';
		$tooltip            = $feature['tooltip'] ?? '';
		$type               = $feature['type'] ?? 'checkbox';
		$setting_definition = $feature['setting'] ?? array();

		// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
		/**
		 * Filter allowing WooCommerce Admin to be disabled.
		 *
		 * @param bool $disabled False.
		 */
		$admin_features_disabled = apply_filters( 'woocommerce_admin_disabled', false );
		// phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment

		if ( ( 'analytics' === $feature_id || 'new_navigation' === $feature_id ) && $admin_features_disabled ) {
			$disabled = true;
			$desc_tip = __( 'WooCommerce Admin has been disabled', 'woocommerce' );
		} elseif ( 'new_navigation' === $feature_id ) {
			$update_text = sprintf(
				// translators: 1: line break tag.
				__(
					'%1$s This navigation will soon become unavailable while we make necessary improvements.
									If you turn it off now, you will not be able to turn it back on.',
					'woocommerce'
				),
				'<br/>'
			);

			$needs_update = version_compare( get_bloginfo( 'version' ), '5.6', '<' );
			if ( $needs_update && current_user_can( 'update_core' ) && current_user_can( 'update_php' ) ) {
				$update_text = sprintf(
					// translators: 1: line break tag, 2: open link to WordPress update link, 3: close link tag.
					__( '%1$s %2$sUpdate WordPress to enable the new navigation%3$s', 'woocommerce' ),
					'<br/>',
					'<a href="' . self_admin_url( 'update-core.php' ) . '" target="_blank">',
					'</a>'
				);
				$disabled = true;
			}

			if ( ! empty( $update_text ) ) {
				$description .= $update_text;
			}
		}

		if ( ! $this->should_skip_compatibility_checks( $feature_id ) && ! $disabled && $this->verify_did_woocommerce_init() ) {
			$plugin_info_for_feature = $this->get_compatible_plugins_for_feature( $feature_id, true );
			$desc_tip                = $this->plugin_util->generate_incompatible_plugin_feature_warning( $feature_id, $plugin_info_for_feature );
		}

		/**
		 * Filter to customize the description tip that appears under the description of each feature in the features settings page.
		 *
		 * @since 7.1.0
		 *
		 * @param string $desc_tip The original description tip.
		 * @param string $feature_id The id of the feature for which the description tip is being customized.
		 * @param bool $disabled True if the UI currently prevents changing the enable/disable status of the feature.
		 * @return string The new description tip to use.
		 */
		$desc_tip = apply_filters( 'woocommerce_feature_description_tip', $desc_tip, $feature_id, $disabled );

		$feature_setting_defaults = array(
			'title'    => $feature['name'],
			'desc'     => $description,
			'type'     => $type,
			'id'       => $this->feature_enable_option_name( $feature_id ),
			'disabled' => $disabled && ! $this->force_allow_enabling_features,
			'desc_tip' => $desc_tip,
			'tooltip'  => $tooltip,
			'default'  => $this->feature_is_enabled_by_default( $feature_id ) ? 'yes' : 'no',
		);

		$feature_setting = wp_parse_args( $setting_definition, $feature_setting_defaults );

		if ( ! empty( $feature['learn_more_url'] ) ) {
			$feature_setting['desc'] .= sprintf(
				'<span class="learn-more-link"><a href="%s" target="_blank">%s</a></span>',
				esc_attr( $feature['learn_more_url'] ),
				esc_html__( 'Learn more', 'woocommerce' )
			);
		}

		/**
		 * Allows to modify feature setting that will be used to render in the feature page.
		 *
		 * @param array $feature_setting The feature setting. Describes the feature:
		 *      - title: The title of the feature.
		 *      - desc: The description of the feature. Will be displayed under the title.
		 *      - type: The type of the feature. Could be any of supported settings types from `WC_Admin_Settings::output_fields`, but if it's anything other than checkbox or radio, it will need custom handling.
		 *      - id: The id of the feature. Will be used as the name of the setting.
		 *      - disabled: Whether the feature is disabled or not.
		 *      - desc_tip: The description tip of the feature. Will be displayed as a tooltip next to the description.
		 *      - tooltip: The tooltip of the feature. Will be displayed as a tooltip next to the name.
		 *      - default: The default value of the feature.
		 * @param string $feature_id The id of the feature.
		 * @since 8.0.0
		 */
		return apply_filters( 'woocommerce_feature_setting', $feature_setting, $feature_id );
	}

	/**
	 * Handle the plugin deactivation hook.
	 *
	 * @param string $plugin_name Name of the plugin that has been deactivated.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function handle_plugin_deactivation( $plugin_name ): void {
		unset( $this->compatibility_info_by_plugin[ $plugin_name ] );

		foreach ( array_keys( $this->compatibility_info_by_feature ) as $feature ) {
			$compatibles = $this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::COMPATIBLE ];
			$this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::COMPATIBLE ] = array_diff( $compatibles, array( $plugin_name ) );

			$incompatibles = $this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::INCOMPATIBLE ];
			$this->compatibility_info_by_feature[ $feature ][ FeaturePluginCompatibility::INCOMPATIBLE ] = array_diff( $incompatibles, array( $plugin_name ) );
		}
	}

	/**
	 * Handler for the all_plugins filter.
	 *
	 * Returns the list of plugins incompatible with a given plugin
	 * if we are in the plugins page and the query string of the current request
	 * looks like '?plugin_status=incompatible_with_feature&feature_id=<feature id>'.
	 *
	 * @param array $plugin_list The original list of plugins.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function filter_plugins_list( $plugin_list ): array {
		if ( ! $this->verify_did_woocommerce_init() ) {
			return $plugin_list;
		}

		// phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
		if ( ! function_exists( 'get_current_screen' ) ||
			( get_current_screen() && 'plugins' !== get_current_screen()->id ) ||
			'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) {
			return $plugin_list;
		}

		$feature_id = $_GET['feature_id'] ?? 'all';
		if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) {
			return $plugin_list;
		}

		return $this->get_incompatible_plugins( $feature_id, $plugin_list );
	}

	/**
	 * Returns the list of plugins incompatible with a given feature.
	 *
	 * @param string $feature_id ID of the feature. Can also be `all` to denote all features.
	 * @param array  $plugin_list       List of plugins to filter.
	 *
	 * @return array List of plugins incompatible with the given feature.
	 */
	public function get_incompatible_plugins( $feature_id, $plugin_list ) {
		$incompatibles         = array();
		$plugin_list           = array_diff_key( $plugin_list, array_flip( $this->plugins_excluded_from_compatibility_ui ) );
		$feature_ids           = 'all' === $feature_id ? array_keys( $this->get_feature_definitions() ) : array( $feature_id );
		$only_enabled_features = 'all' === $feature_id;

		// phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput
		foreach ( array_keys( $plugin_list ) as $plugin_name ) {
			if ( ! $this->plugin_util->is_woocommerce_aware_plugin( $plugin_name ) || ! $this->proxy->call_function( 'is_plugin_active', $plugin_name ) ) {
				continue;
			}

			$compatibility_info = $this->get_compatible_features_for_plugin( $plugin_name );
			foreach ( $feature_ids as $feature_id ) {
				$features_considered_incompatible = array_filter(
					$this->plugin_util->get_items_considered_incompatible( $feature_id, $compatibility_info ),
					$only_enabled_features ?
						fn( $id ) => $this->feature_is_enabled( $id ) && ! $this->should_skip_compatibility_checks( $id ) :
						fn( $id ) => ! $this->should_skip_compatibility_checks( $id )
				);
				if ( in_array( $feature_id, $features_considered_incompatible, true ) ) {
					$incompatibles[] = $plugin_name;
				}
			}
		}

		return array_intersect_key( $plugin_list, array_flip( $incompatibles ) );
	}

	/**
	 * Handler for the admin_notices action.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function display_notices_in_plugins_page(): void {
		if ( ! $this->verify_did_woocommerce_init() ) {
			return;
		}

		$feature_filter_description_shown = $this->maybe_display_current_feature_filter_description();
		if ( ! $feature_filter_description_shown ) {
			$this->maybe_display_feature_incompatibility_warning();
		}
	}

	/**
	 * Shows a warning when there are any incompatibility between active plugins and enabled features.
	 * The warning is shown in on any admin screen except the plugins screen itself, since
	 * there's already a "You are viewing plugins that are incompatible" notice.
	 */
	private function maybe_display_feature_incompatibility_warning(): void {
		if ( ! current_user_can( 'activate_plugins' ) ) {
			return;
		}

		$incompatible_plugins = false;
		$relevant_plugins     = array_diff( $this->plugin_util->get_woocommerce_aware_plugins( true ), $this->plugins_excluded_from_compatibility_ui );

		foreach ( $relevant_plugins as $plugin ) {
			$compatibility_info = $this->get_compatible_features_for_plugin( $plugin, true );

			$incompatibles = array_filter( $compatibility_info[ FeaturePluginCompatibility::INCOMPATIBLE ], fn( $id ) => ! $this->should_skip_compatibility_checks( $id ) );
			if ( ! empty( $incompatibles ) ) {
				$incompatible_plugins = true;
				break;
			}

			$uncertains = array_filter( $compatibility_info[ FeaturePluginCompatibility::UNCERTAIN ], fn( $id ) => ! $this->should_skip_compatibility_checks( $id ) );
			foreach ( $uncertains as $feature_id ) {
				if ( FeaturePluginCompatibility::COMPATIBLE !== $this->get_default_plugin_compatibility( $feature_id ) ) {
					$incompatible_plugins = true;
					break;
				}
			}

			if ( $incompatible_plugins ) {
				break;
			}
		}

		if ( ! $incompatible_plugins ) {
			return;
		}

		$message = str_replace(
			'<a>',
			'<a href="' . esc_url( add_query_arg( array( 'plugin_status' => 'incompatible_with_feature' ), admin_url( 'plugins.php' ) ) ) . '">',
			__( 'WooCommerce has detected that some of your active plugins are incompatible with currently enabled WooCommerce features. Please <a>review the details</a>.', 'woocommerce' )
		);

		// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
		?>
		<div class="notice notice-error">
		<p><?php echo $message; ?></p>
		</div>
		<?php
		// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
	}

	/**
	 * Shows a "You are viewing the plugins that are incompatible with the X feature"
	 * if we are in the plugins page and the query string of the current request
	 * looks like '?plugin_status=incompatible_with_feature&feature_id=<feature id>'.
	 */
	private function maybe_display_current_feature_filter_description(): bool {
		if ( 'plugins' !== get_current_screen()->id ) {
			return false;
		}

		// phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
		$plugin_status = $_GET['plugin_status'] ?? '';
		$feature_id    = $_GET['feature_id'] ?? '';
		// phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput

		if ( 'incompatible_with_feature' !== $plugin_status ) {
			return false;
		}

		$feature_id = ( '' === $feature_id ) ? 'all' : $feature_id;

		if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) {
			return false;
		}

		$features          = $this->get_feature_definitions();
		$plugins_page_url  = admin_url( 'plugins.php' );
		$features_page_url = $this->get_features_page_url();

		$message =
			'all' === $feature_id
			? __( 'You are viewing active plugins that are incompatible with currently enabled WooCommerce features.', 'woocommerce' )
			: sprintf(
				/* translators: %s is a feature name. */
				__( "You are viewing the active plugins that are incompatible with the '%s' feature.", 'woocommerce' ),
				$features[ $feature_id ]['name']
			);

		$message .= '<br />';
		$message .= sprintf(
			__( "<a href='%1\$s'>View all plugins</a> - <a href='%2\$s'>Manage WooCommerce features</a>", 'woocommerce' ),
			$plugins_page_url,
			$features_page_url
		);

		// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
		?>
		<div class="notice notice-info">
			<p><?php echo $message; ?></p>
		</div>
		<?php
		// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped

		return true;
	}

	/**
	 * If the 'incompatible with features' plugin list is being rendered, invalidate existing cached plugin data.
	 *
	 * This heads off a problem in which WordPress's `get_plugins()` function may be called much earlier in the request
	 * (by third party code, for example), the results of which are cached, and before WooCommerce can modify the list
	 * to inject useful information of its own.
	 *
	 * @see https://github.com/woocommerce/woocommerce/issues/37343
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function maybe_invalidate_cached_plugin_data(): void {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		if ( ( $_GET['plugin_status'] ?? '' ) === 'incompatible_with_feature' ) {
			wp_cache_delete( 'plugins', 'plugins' );
		}
	}

	/**
	 * Handler for the 'after_plugin_row' action.
	 * Displays a "This plugin is incompatible with X features" notice if necessary.
	 *
	 * @param string $plugin_file The id of the plugin for which a row has been rendered in the plugins page.
	 * @param array  $plugin_data Plugin data, as returned by 'get_plugins'.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function handle_plugin_list_rows( $plugin_file, $plugin_data ) {
		global $wp_list_table;

		if ( in_array( $plugin_file, $this->plugins_excluded_from_compatibility_ui, true ) ) {
			return;
		}

		if ( 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) { // phpcs:ignore WordPress.Security.NonceVerification
			return;
		}

		if ( is_null( $wp_list_table ) || ! $this->plugin_util->is_woocommerce_aware_plugin( $plugin_data ) ) {
			return;
		}

		if ( ! $this->proxy->call_function( 'is_plugin_active', $plugin_file ) ) {
			return;
		}

		$features                   = $this->get_feature_definitions();
		$feature_compatibility_info = $this->get_compatible_features_for_plugin( $plugin_file, true, true );
		$incompatible_features      = $feature_compatibility_info[ FeaturePluginCompatibility::INCOMPATIBLE ];
		$incompatible_features      = array_values(
			array_filter(
				$incompatible_features,
				function ( $feature_id ) {
					return ! $this->should_skip_compatibility_checks( $feature_id );
				}
			)
		);

		$incompatible_features_count = count( $incompatible_features );
		if ( $incompatible_features_count > 0 ) {
			$columns_count      = $wp_list_table->get_column_count();
			$is_active          = true; // For now we are showing active plugins in the "Incompatible with..." view.
			$is_active_class    = $is_active ? 'active' : 'inactive';
			$is_active_td_style = $is_active ? " style='border-left: 4px solid #72aee6;'" : '';

			if ( 1 === $incompatible_features_count ) {
				$message = sprintf(
					/* translators: %s = printable plugin name */
					__( "⚠ This plugin is incompatible with the enabled WooCommerce feature '%s', it shouldn't be activated.", 'woocommerce' ),
					$features[ $incompatible_features[0] ]['name']
				);
			} elseif ( 2 === $incompatible_features_count ) {
				/* translators: %1\$s, %2\$s = printable plugin names */
				$message = sprintf(
					__( "⚠ This plugin is incompatible with the enabled WooCommerce features '%1\$s' and '%2\$s', it shouldn't be activated.", 'woocommerce' ),
					$features[ $incompatible_features[0] ]['name'],
					$features[ $incompatible_features[1] ]['name']
				);
			} else {
				/* translators: %1\$s, %2\$s = printable plugin names, %3\$d = plugins count */
				$message = sprintf(
					__( "⚠ This plugin is incompatible with the enabled WooCommerce features '%1\$s', '%2\$s' and %3\$d more, it shouldn't be activated.", 'woocommerce' ),
					$features[ $incompatible_features[0] ]['name'],
					$features[ $incompatible_features[1] ]['name'],
					$incompatible_features_count - 2
				);
			}
			$features_page_url       = $this->get_features_page_url();
			$manage_features_message = __( 'Manage WooCommerce features', 'woocommerce' );

			// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
			?>
			<tr class='plugin-update-tr update <?php echo $is_active_class; ?>' data-plugin='<?php echo $plugin_file; ?>' data-plugin-row-type='feature-incomp-warn'>
				<td colspan='<?php echo $columns_count; ?>' class='plugin-update'<?php echo $is_active_td_style; ?>>
					<div class='notice inline notice-warning notice-alt'>
						<p>
							<?php echo $message; ?>
							<a href="<?php echo $features_page_url; ?>"><?php echo $manage_features_message; ?></a>
						</p>
					</div>
				</td>
			</tr>
			<?php
			// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
		}
	}

	/**
	 * Get the URL of the features settings page.
	 *
	 * @return string
	 */
	public function get_features_page_url(): string {
		return admin_url( 'admin.php?page=wc-settings&tab=advanced&section=features' );
	}

	/**
	 * Fix for the HTML of the plugins list when there are feature-plugin incompatibility warnings.
	 *
	 * WordPress renders the plugin information rows in the plugins page in <tr> elements as follows:
	 *
	 * - If the plugin needs update, the <tr> will have an "update" class. This will prevent the lower
	 *   border line to be drawn. Later an additional <tr> with an "update available" warning will be rendered,
	 *   it will have a "plugin-update-tr" class which will draw the missing lower border line.
	 * - Otherwise, the <tr> will be already drawn with the lower border line.
	 *
	 * This is a problem for our rendering of the "plugin is incompatible with X features" warning:
	 *
	 * - If the plugin info <tr> has "update", our <tr> will render nicely right after it; but then
	 *   our own "plugin-update-tr" class will draw an additional line before the "needs update" warning.
	 * - If not, the plugin info <tr> will render its lower border line right before our compatibility info <tr>.
	 *
	 * This small script fixes this by adding the "update" class to the plugin info <tr> if it doesn't have it
	 * (so no extra line before our <tr>), or removing 'plugin-update-tr' from our <tr> otherwise
	 * (and then some extra manual tweaking of margins is needed).
	 *
	 * @param string $current_screen The current screen object.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function enqueue_script_to_fix_plugin_list_html( $current_screen ): void {
		if ( 'plugins' !== $current_screen->id ) {
			return;
		}

		$handle = 'wc-features-fix-plugin-list-html';
		wp_register_script( $handle, '', array(), WC_VERSION, array( 'in_footer' => true ) );
		wp_enqueue_script( $handle );
		wp_add_inline_script(
			$handle,
			"
            const warningRows = document.querySelectorAll('tr[data-plugin-row-type=\"feature-incomp-warn\"]');
            for(const warningRow of warningRows) {
                const pluginName = warningRow.getAttribute('data-plugin');
                const pluginInfoRow = document.querySelector('tr.active[data-plugin=\"' + pluginName + '\"]:not(.plugin-update-tr), tr.inactive[data-plugin=\"' + pluginName + '\"]:not(.plugin-update-tr)');
                if(!pluginInfoRow) {
                    continue;
                }
                if(pluginInfoRow.classList.contains('update')) {
                    warningRow.classList.remove('plugin-update-tr');
                    warningRow.querySelector('.notice').style.margin = '5px 10px 15px 30px';
                }
                else {
                    pluginInfoRow.classList.add('update');
                }
            }
            "
		);
	}

	/**
	 * Handler for the 'views_plugins' hook that shows the links to the different views in the plugins page.
	 * If we come from a "Manage incompatible plugins" in the features page we'll show just two views:
	 * "All" (so that it's easy to go back to a known state) and "Incompatible with X".
	 * We'll skip the rest of the views since the counts are wrong anyway, as we are modifying
	 * the plugins list via the 'all_plugins' filter.
	 *
	 * @param array $views An array of view ids => view links.
	 * @return string[] The actual views array to use.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function handle_plugins_page_views_list( $views ): array {
		// phpcs:disable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput
		if ( 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) {
			return $views;
		}

		$feature_id = $_GET['feature_id'] ?? 'all';
		if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) {
			return $views;
		}
		// phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput

		$all_items = get_plugins();
		$features  = $this->get_feature_definitions();

		$incompatible_plugins_count = count( $this->filter_plugins_list( $all_items ) );
		$incompatible_text          =
			'all' === $feature_id
			? __( 'Incompatible with WooCommerce features', 'woocommerce' )
			/* translators: %s = name of a WooCommerce feature */
			: sprintf( __( "Incompatible with '%s'", 'woocommerce' ), $features[ $feature_id ]['name'] );
		$incompatible_link = "<a href='plugins.php?plugin_status=incompatible_with_feature&feature_id={$feature_id}' class='current' aria-current='page'>{$incompatible_text} <span class='count'>({$incompatible_plugins_count})</span></a>";

		$all_plugins_count = count( $all_items );
		$all_text          = __( 'All', 'woocommerce' );
		$all_link          = "<a href='plugins.php?plugin_status=all'>{$all_text} <span class='count'>({$all_plugins_count})</span></a>";

		return array(
			'all'                       => $all_link,
			'incompatible_with_feature' => $incompatible_link,
		);
	}

	/**
	 * Set the feature nonce to be sent from client side.
	 *
	 * @param array $settings Component settings.
	 *
	 * @return array
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function set_change_feature_enable_nonce( $settings ) {
		$settings['_feature_nonce'] = wp_create_nonce( 'change_feature_enable' );
		return $settings;
	}

	/**
	 * Changes the feature given it's id, a toggle value and nonce as a query param.
	 *
	 * `/wp-admin/post.php?product_block_editor=1&_feature_nonce=1234`, 1 for on
	 * `/wp-admin/post.php?product_block_editor=0&_feature_nonce=1234`, 0 for off
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function change_feature_enable_from_query_params(): void {
		if ( ! current_user_can( 'manage_woocommerce' ) ) {
			return;
		}

		$is_feature_nonce_invalid = ( ! isset( $_GET['_feature_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_feature_nonce'] ) ), 'change_feature_enable' ) );

		$query_params_to_remove = array( '_feature_nonce' );

		foreach ( array_keys( $this->get_feature_definitions() ) as $feature_id ) {
			if ( isset( $_GET[ $feature_id ] ) && is_numeric( $_GET[ $feature_id ] ) ) {
				$value = absint( $_GET[ $feature_id ] );

				if ( $is_feature_nonce_invalid ) {
					wp_die( esc_html__( 'Action failed. Please refresh the page and retry.', 'woocommerce' ) );
					return;
				}

				if ( 1 === $value ) {
					$this->change_feature_enable( $feature_id, true );
				} elseif ( 0 === $value ) {
					$this->change_feature_enable( $feature_id, false );
				}
				$query_params_to_remove[] = $feature_id;
			}
		}
		if ( count( $query_params_to_remove ) > 1 && isset( $_SERVER['REQUEST_URI'] ) ) {
			// phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
			wp_safe_redirect( remove_query_arg( $query_params_to_remove, $_SERVER['REQUEST_URI'] ) );
		}
	}

	/**
	 * Display the email improvements feedback notice to render CES modal in.
	 *
	 * @param string $feature_id The feature id.
	 * @param bool   $is_enabled Whether the feature is enabled.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function display_email_improvements_feedback_notice( $feature_id, $is_enabled ): void {
		if ( 'email_improvements' === $feature_id && ! $is_enabled ) {
			set_transient( 'wc_settings_email_improvements_reverted', 'yes', 15 );
			add_action(
				'admin_notices',
				function () {
					echo '<div id="wc_settings_features_email_feedback_slotfill"></div>';
				}
			);
		}
	}

	/**
	 * Check if the email improvements feature is enabled in preview mode in Settings > Emails.
	 * This is used to force the email improvements feature without affecting shoppers.
	 *
	 * @param string $feature_id The feature id.
	 * @return bool Whether the email improvements feature is enabled in preview mode.
	 */
	private function is_preview_email_improvements_enabled( string $feature_id ): bool {
		if ( 'email_improvements' !== $feature_id ) {
			return false;
		}
		/**
		 * This filter is documented in templates/emails/email-styles.php
		 *
		 * @since 9.9.0
		 * @param bool $is_email_preview Whether the email is being previewed.
		 */
		$is_email_preview = apply_filters( 'woocommerce_is_email_preview', false );
		if ( $is_email_preview ) {
			return get_transient( EmailPreview::TRANSIENT_PREVIEW_EMAIL_IMPROVEMENTS ) === 'yes';
		}
		return false;
	}
}
PK     [1]p"  "  )  DependencyManagement/RuntimeContainer.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\DependencyManagement;

use Automattic\WooCommerce\Blocks\Package as BlocksPackage;
use Automattic\WooCommerce\StoreApi\StoreApi;
use Automattic\WooCommerce\Utilities\StringUtil;

/**
 * Dependency injection container used at runtime.
 *
 * This is a simple container that doesn't implement explicit class registration.
 * Instead, all the classes in the Automattic\WooCommerce namespace can be resolved
 * and are considered as implicitly registered as single-instance classes
 * (so each class will be instantiated only once and the instance will be cached).
 */
class RuntimeContainer {
	/**
	 * The root namespace of all WooCommerce classes in the `src` directory.
	 *
	 * @var string
	 */
	const WOOCOMMERCE_NAMESPACE = 'Automattic\\WooCommerce\\';

	/**
	 * Cache of classes already resolved.
	 *
	 * @var array
	 */
	protected array $resolved_cache;

	/**
	 * A copy of the initial resolved classes cache passed to the constructor.
	 *
	 * @var array
	 */
	protected array $initial_resolved_cache;

	/**
	 * Initializes a new instance of the class.
	 *
	 * @param array $initial_resolved_cache Dictionary of class name => instance, to be used as the starting point for the resolved classes cache.
	 */
	public function __construct( array $initial_resolved_cache ) {
		$this->initial_resolved_cache = $initial_resolved_cache;
		$this->resolved_cache         = $initial_resolved_cache;
	}

	/**
	 * Get an instance of a class.
	 *
	 * ContainerException will be thrown in these cases:
	 *
	 * - $class_name is outside the WooCommerce root namespace (and wasn't included in the initial resolve cache).
	 * - The class referred by $class_name doesn't exist.
	 * - Recursive resolution condition found.
	 * - Reflection exception thrown when instantiating or initializing the class.
	 *
	 * A "recursive resolution condition" happens when class A depends on class B and at the same time class B depends on class A, directly or indirectly;
	 * without proper handling this would lead to an infinite loop.
	 *
	 * Note that this method throwing ContainerException implies that code fixes are needed, it's not an error condition that's recoverable at runtime.
	 *
	 * @template T of object
	 * @param string $class_name Class name.
	 * @phpstan-param class-string<T> $class_name
	 *
	 * @return T Object instance.
	 * @throws ContainerException Error when resolving the class to an object instance.
	 * @throws \Exception Exception thrown in the constructor or in the 'init' method of one of the resolved classes.
	 */
	public function get( string $class_name ) {
		$class_name    = trim( $class_name, '\\' );
		$resolve_chain = array();
		// @phpstan-ignore return.type (get_core uses reflection to instantiate the correct class type at runtime)
		return $this->get_core( $class_name, $resolve_chain );
	}

	/**
	 * Core function to get an instance of a class.
	 *
	 * @param string $class_name The class name.
	 * @param array  $resolve_chain Classes already resolved in this resolution chain. Passed between recursive calls to the method in order to detect a recursive resolution condition.
	 * @return object The resolved object.
	 * @throws ContainerException Error when resolving the class to an object instance.
	 */
	protected function get_core( string $class_name, array &$resolve_chain ) {
		if ( isset( $this->resolved_cache[ $class_name ] ) ) {
			return $this->resolved_cache[ $class_name ];
		}

		// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped

		if ( in_array( $class_name, $resolve_chain, true ) ) {
			throw new ContainerException( "Recursive resolution of class '$class_name'. Resolution chain: " . implode( ', ', $resolve_chain ) );
		}

		if ( ! $this->is_class_allowed( $class_name ) ) {
			throw new ContainerException( "Attempt to get an instance of class '$class_name', which is not in the " . self::WOOCOMMERCE_NAMESPACE . ' namespace. Did you forget to add a namespace import?' );
		}

		if ( ! class_exists( $class_name ) ) {
			throw new ContainerException( "Attempt to get an instance of class '$class_name', which doesn't exist." );
		}

		// Account for the containers used by the Store API and Blocks.
		if ( StringUtil::starts_with( $class_name, 'Automattic\WooCommerce\StoreApi\\' ) ) {
			return StoreApi::container()->get( $class_name );
		}
		if ( StringUtil::starts_with( $class_name, 'Automattic\WooCommerce\Blocks\\' ) ) {
			return BlocksPackage::container()->get( $class_name );
		}

		$resolve_chain[] = $class_name;

		try {
			$instance = $this->instantiate_class_using_reflection( $class_name, $resolve_chain );
		} catch ( \ReflectionException $e ) {
			throw new ContainerException( "Reflection error when resolving '$class_name': (" . get_class( $e ) . ") {$e->getMessage()}", 0, $e );
		}

		// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped

		$this->resolved_cache[ $class_name ] = $instance;

		return $instance;
	}

	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
	/**
	 * Get an instance of a class using reflection.
	 * This method recursively calls 'get_core' (which in turn calls this method) for each of the arguments
	 * in the 'init' method of the resolved class (if the method is public and non-static).
	 *
	 * @param string $class_name The name of the class to resolve.
	 * @param array  $resolve_chain Classes already resolved in this resolution chain. Passed between recursive calls to the method in order to detect a recursive resolution condition.
	 * @return object The resolved object.
	 *
	 * @throws ContainerException The 'init' method has invalid arguments.
	 * @throws \ReflectionException Something went wrong when using reflection to get information about the class to resolve.
	 */
	private function instantiate_class_using_reflection( string $class_name, array &$resolve_chain ): object {
		$ref_class = new \ReflectionClass( $class_name );

		// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped

		$constructor = $ref_class->getConstructor();
		if ( ! is_null( $constructor ) ) {
			if ( ! $constructor->isPublic() ) {
				throw new ContainerException( "Error resolving '$class_name': the class doesn't have a public constructor." );
			}
			$constructor_arguments = $constructor->getParameters();
			foreach ( $constructor_arguments as $argument ) {
				if ( ! $argument->isOptional() ) {
					throw new ContainerException( "Error resolving '$class_name': the class constructor has non-optional arguments." );
				}
			}
		}

		$instance = $ref_class->newInstance();
		if ( ! $ref_class->hasMethod( 'init' ) ) {
			return $instance;
		}

		$init_method = $ref_class->getMethod( 'init' );
		if ( ! $init_method->isPublic() || $init_method->isStatic() ) {
			return $instance;
		}

		$init_args          = $init_method->getParameters();
		$init_arg_instances = array_map(
			function ( \ReflectionParameter $arg ) use ( $class_name, &$resolve_chain ) {
				$arg_type = $arg->getType();
				if ( ! ( $arg_type instanceof \ReflectionNamedType ) ) {
					throw new ContainerException( "Error resolving '$class_name': argument '\${$arg->getName()}' doesn't have a type declaration." );
				}
				if ( $arg_type->isBuiltin() ) {
					throw new ContainerException( "Error resolving '$class_name': argument '\${$arg->getName()}' is not of a class type." );
				}
				if ( $arg->isPassedByReference() ) {
					throw new ContainerException( "Error resolving '$class_name': argument '\${$arg->getName()}' is passed by reference." );
				}
				return $this->get_core( $arg_type->getName(), $resolve_chain );
			},
			$init_args
		);

		// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped

		$init_method->invoke( $instance, ...$init_arg_instances );

		return $instance;
	}
	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

	/**
	 * Tells if the 'get' method can be used to resolve a given class.
	 *
	 * Note that 'get' can throw an exception even if this method returns true,
	 * for example for classes that are in the correct namespace but don't have a public constructor.
	 *
	 * @param string $class_name The class name.
	 * @return bool True if the class with the supplied name can be resolved with 'get'.
	 */
	public function has( string $class_name ): bool {
		$class_name = trim( $class_name, '\\' );
		return $this->is_class_allowed( $class_name ) || isset( $this->resolved_cache[ $class_name ] );
	}

	/**
	 * Checks to see whether a class is allowed to be registered.
	 *
	 * @param string $class_name The class to check.
	 *
	 * @return bool True if the class is allowed to be registered, false otherwise.
	 */
	protected function is_class_allowed( string $class_name ): bool {
		return StringUtil::starts_with( $class_name, self::WOOCOMMERCE_NAMESPACE, false );
	}
}
PK     [1]w    +  DependencyManagement/ContainerException.phpnu         <?php
/**
 * ContainerException class file.
 */

namespace Automattic\WooCommerce\Internal\DependencyManagement;

/**
 * Class ContainerException.
 * Used to signal error conditions related to the dependency injection container.
 */
class ContainerException extends \Exception {
	/**
	 * Create a new instance of the class.
	 *
	 * @param null            $message The exception message to throw.
	 * @param int             $code The error code.
	 * @param \Exception|null $previous The previous throwable used for exception chaining.
	 */
	public function __construct( $message = null, $code = 0, ?\Exception $previous = null ) {
		parent::__construct( $message, $code, $previous );
	}
}
PK     [1]}V'^  ^    Jetpack/JetpackConnection.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Jetpack;

use Automattic\Jetpack\Connection\Manager;
use Automattic\WooCommerce\Admin\Features\Features;
use WP_Error;

/**
 * Jetpack Connection wrapper class.
 *
 * @since 8.3.0
 */
class JetpackConnection {
	/**
	 * Jetpack connection manager.
	 *
	 * @var Manager
	 */
	private static $manager;

	/**
	 * Get the Jetpack connection manager.
	 *
	 * @return Manager
	 */
	public static function get_manager() {
		if ( ! self::$manager instanceof Manager ) {
			self::$manager = new Manager( 'woocommerce' );
		}

		return self::$manager;
	}

	/**
	 * Get the authorization URL for the Jetpack connection.
	 *
	 * @param mixed  $redirect_url Redirect URL.
	 * @param string $from         From parameter.
	 *
	 * @return array {
	 *     Authorization data.
	 *
	 *     @type bool   $success      Whether authorization URL generation succeeded.
	 *     @type array  $errors       Array of error messages if any.
	 *     @type string $color_scheme User's admin color scheme.
	 *     @type string $url          The authorization URL.
	 * }
	 */
	public static function get_authorization_url( $redirect_url, $from = '' ) {
		$manager = self::get_manager();
		$errors  = new WP_Error();

		// Register the site to wp.com.
		if ( ! $manager->is_connected() ) {
			$result = $manager->try_registration();
			if ( is_wp_error( $result ) ) {
				$errors->add( $result->get_error_code(), $result->get_error_message() );
			}
		}

		$calypso_env = defined( 'WOOCOMMERCE_CALYPSO_ENVIRONMENT' ) && in_array( WOOCOMMERCE_CALYPSO_ENVIRONMENT, array( 'development', 'wpcalypso', 'horizon', 'stage' ), true ) ? WOOCOMMERCE_CALYPSO_ENVIRONMENT : 'production';

		$authorization_url = $manager->get_authorization_url( null, $redirect_url );
		$authorization_url = add_query_arg( 'locale', self::get_wpcom_locale(), $authorization_url );

		if ( Features::is_enabled( 'use-wp-horizon' ) ) {
			$calypso_env = 'horizon';
		}

		$color_scheme = get_user_option( 'admin_color', get_current_user_id() );
		if ( ! $color_scheme ) {
			// The default Core color schema is 'fresh'.
			$color_scheme = 'fresh';
		}

		return array(
			'success'      => ! $errors->has_errors(),
			'errors'       => $errors->get_error_messages(),
			'color_scheme' => $color_scheme,
			'url'          => add_query_arg(
				array(
					'from'        => $from,
					'calypso_env' => $calypso_env,
				),
				$authorization_url,
			),
		);
	}

	/**
	 * Return a locale string for wpcom.
	 *
	 * @return string
	 */
	private static function get_wpcom_locale() {
		// List of locales that should be used with region code.
		$locale_to_lang = array(
			'bre'   => 'br',
			'de_AT' => 'de-at',
			'de_CH' => 'de-ch',
			'de'    => 'de_formal',
			'el'    => 'el-po',
			'en_GB' => 'en-gb',
			'es_CL' => 'es-cl',
			'es_MX' => 'es-mx',
			'fr_BE' => 'fr-be',
			'fr_CA' => 'fr-ca',
			'nl_BE' => 'nl-be',
			'nl'    => 'nl_formal',
			'pt_BR' => 'pt-br',
			'sr'    => 'sr_latin',
			'zh_CN' => 'zh-cn',
			'zh_HK' => 'zh-hk',
			'zh_SG' => 'zh-sg',
			'zh_TW' => 'zh-tw',
		);

		$system_locale = get_locale();
		if ( isset( $locale_to_lang[ $system_locale ] ) ) {
			// Return the locale with region code if it's in the list.
			return $locale_to_lang[ $system_locale ];
		}

		// If the locale is not in the list, return the language code only.
		return explode( '_', $system_locale )[0];
	}
}
PK     [1]MQH    -  RestApi/Routes/V4/AbstractCollectionQuery.phpnu         <?php
/**
 * AbstractCollectionQuery class.
 *
 * @package WooCommerce\RestApi
 * @internal This file is for internal use only and should not be used by external code.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4;

defined( 'ABSPATH' ) || exit;

use WP_REST_Request;
use WC_Order;

/**
 * AbstractCollectionQuery class.
 *
 * @internal This class is for internal use only and should not be used by external code.
 */
abstract class AbstractCollectionQuery {
	/**
	 * Operator constants for easy access.
	 */
	const OPERATOR_IS                    = 'is';
	const OPERATOR_IS_NOT                = 'isNot';
	const OPERATOR_LESS_THAN             = 'lessThan';
	const OPERATOR_GREATER_THAN          = 'greaterThan';
	const OPERATOR_LESS_THAN_OR_EQUAL    = 'lessThanOrEqual';
	const OPERATOR_GREATER_THAN_OR_EQUAL = 'greaterThanOrEqual';
	const OPERATOR_BETWEEN               = 'between';

	/**
	 * Array of operators for validation.
	 */
	const OPERATORS = array(
		self::OPERATOR_IS,
		self::OPERATOR_IS_NOT,
		self::OPERATOR_LESS_THAN,
		self::OPERATOR_GREATER_THAN,
		self::OPERATOR_LESS_THAN_OR_EQUAL,
		self::OPERATOR_GREATER_THAN_OR_EQUAL,
		self::OPERATOR_BETWEEN,
	);

	/**
	 * Get query schema for collection.
	 *
	 * @return array
	 */
	abstract public function get_query_schema(): array;

	/**
	 * Prepares query args.
	 *
	 * @param WP_REST_Request $request The request object.
	 * @return array
	 */
	abstract public function get_query_args( WP_REST_Request $request ): array;

	/**
	 * Get results of the query.
	 *
	 * @param array           $query_args The query arguments.
	 * @param WP_REST_Request $request The request object.
	 * @return array
	 */
	abstract public function get_query_results( array $query_args, WP_REST_Request $request ): array;
}
PK     [1];  ;  =  RestApi/Routes/V4/ShippingZoneMethod/ShippingMethodSchema.phpnu         <?php
/**
 * Shipping Method Schema.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\ShippingZoneMethod;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractSchema;
use WP_REST_Request;

/**
 * Shipping Method Schema class.
 */
class ShippingMethodSchema extends AbstractSchema {

	/**
	 * The schema identifier.
	 *
	 * @var string
	 */
	const IDENTIFIER = 'shipping_method';

	/**
	 * Return all properties for the item schema
	 *
	 * @return array
	 */
	public function get_item_schema_properties(): array {
		return array(
			'instance_id' => array(
				'description' => __( 'Shipping method instance ID.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
			'zone_id'     => array(
				'description' => __( 'Shipping zone ID.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'required'    => true,
			),
			'enabled'     => array(
				'description' => __( 'Whether the shipping method is enabled.', 'woocommerce' ),
				'type'        => 'boolean',
				'context'     => array( 'view', 'edit' ),
				'required'    => true,
			),
			'order'       => array(
				'description'       => __( 'Shipping method sort order.', 'woocommerce' ),
				'type'              => 'integer',
				'context'           => array( 'view', 'edit' ),
				'sanitize_callback' => 'absint',
			),
			'method_id'   => array(
				'description' => __( 'Shipping method ID.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => array( 'view', 'edit' ),
				'required'    => true,
			),
			'settings'    => array(
				'description'          => __( 'Shipping method settings including title and configuration.', 'woocommerce' ),
				'type'                 => 'object',
				'context'              => array( 'view', 'edit' ),
				'required'             => true,
				'properties'           => array(
					'title' => array(
						'description' => __( 'Shipping method title.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => array( 'view', 'edit' ),
						'required'    => true,
					),
				),
				'additionalProperties' => true,
			),
		);
	}

	/**
	 * Get the item response for a shipping method.
	 *
	 * @param object          $method Shipping method instance.
	 * @param WP_REST_Request $request Request object.
	 * @param array           $include_fields Fields to include in the response.
	 * @return array The item response.
	 */
	public function get_item_response( $method, WP_REST_Request $request, array $include_fields = array() ): array {
		if ( isset( $request['zone_id'] ) ) {
			$zone_id = (int) $request['zone_id'];
		} else {
			$data_store = \WC_Data_Store::load( 'shipping-zone' );
			$zone_id    = $data_store->get_zone_id_by_instance_id( $method->instance_id );
		}

		return array(
			'instance_id' => (int) $method->instance_id,
			'zone_id'     => (int) $zone_id,
			'enabled'     => wc_string_to_bool( $method->enabled ),
			'order'       => (int) $method->method_order,
			'method_id'   => $method->id,
			'settings'    => $this->get_method_settings( $method ),
		);
	}

	/**
	 * Get shipping method settings with title included.
	 *
	 * @param object $method Shipping method instance.
	 * @return array Method settings including title.
	 */
	protected function get_method_settings( $method ): array {
		$settings = array();

		// Get the method title (moved from root to settings per Ismael's feedback).
		$settings['title'] = $method->get_title();

		// Get common method settings.
		$common_fields = array( 'cost', 'min_amount', 'requires', 'class_cost', 'no_class_cost', 'tax_status' );

		foreach ( $common_fields as $field ) {
			if ( isset( $method->$field ) ) {
				$settings[ $field ] = $method->$field;
			}
		}

		// Return all available settings for maximum flexibility.
		if ( isset( $method->instance_settings ) && is_array( $method->instance_settings ) ) {
			$settings = array_merge( $settings, $method->instance_settings );
		}

		return $settings;
	}
}
PK     [1]q-  -  3  RestApi/Routes/V4/ShippingZoneMethod/Controller.phpnu         <?php
/**
 * Shipping Zone Methods Controller.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\ShippingZoneMethod;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractController;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\ShippingZoneMethod\ShippingZoneMethodService;
use WC_Shipping_Zones;
use WC_Shipping_Zone;
use WP_Http;
use WP_REST_Request;
use WP_REST_Server;
use WP_Error;

/**
 * Shipping Zone Methods Controller class.
 */
class Controller extends AbstractController {

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'shipping-zone-method';

	/**
	 * Shipping method schema instance.
	 *
	 * @var ShippingMethodSchema
	 */
	protected $method_schema;

	/**
	 * Shipping service instance.
	 *
	 * @var ShippingZoneMethodService
	 */
	protected $shipping_method_service;

	/**
	 * Custom error constants for shipping-specific errors.
	 */
	const INVALID_ZONE_ID     = 'invalid_zone_id';
	const INVALID_METHOD_TYPE = 'invalid_method_type';
	const ZONE_MISMATCH       = 'zone_mismatch';

	/**
	 * Initialize the controller with schema dependency injection.
	 *
	 * @internal
	 * @param ShippingMethodSchema      $method_schema            Schema for shipping methods.
	 * @param ShippingZoneMethodService $shipping_method_service Service for shipping method operations.
	 */
	final public function init( ShippingMethodSchema $method_schema, ShippingZoneMethodService $shipping_method_service ) {
		$this->method_schema           = $method_schema;
		$this->shipping_method_service = $shipping_method_service;
	}

	/**
	 * Register the routes for shipping zone methods.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'create_item' ),
				'permission_callback' => array( $this, 'check_permissions' ),
				'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[\d]+)',
			array(
				'schema' => array( $this, 'get_public_item_schema' ),
				'args'   => array(
					'id' => array(
						'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
						'type'        => 'integer',
					),
				),
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_item' ),
					'permission_callback' => array( $this, 'check_permissions' ),
				),
				array(
					'methods'             => WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'update_item' ),
					'permission_callback' => array( $this, 'check_permissions' ),
					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
				),
				array(
					'methods'             => WP_REST_Server::DELETABLE,
					'callback'            => array( $this, 'delete_item' ),
					'permission_callback' => array( $this, 'check_permissions' ),
					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::DELETABLE ),
				),
			)
		);
	}

	/**
	 * Check if a given request has permission to manage shipping methods.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return true|WP_Error True if the request has permission, WP_Error otherwise.
	 */
	public function check_permissions( $request ) {
		if ( ! wc_shipping_enabled() ) {
			return new WP_Error(
				'rest_shipping_disabled',
				__( 'Shipping is disabled.', 'woocommerce' ),
				array( 'status' => WP_Http::SERVICE_UNAVAILABLE )
			);
		}

		$method = $request->get_method();

		if ( 'GET' === $method ) {
			$context = 'read';
		} elseif ( 'DELETE' === $method ) {
			$context = 'delete';
		} else {
			$context = 'edit';
		}

		if ( ! wc_rest_check_manager_permissions( 'settings', $context ) ) {
			return $this->get_authentication_error_by_method( $method );
		}

		return true;
	}

	/**
	 * Get shipping zone method by ID.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_item( $request ) {
		$instance_id = (int) $request['id'];

		$method = WC_Shipping_Zones::get_shipping_method( $instance_id );

		if ( ! $method ) {
			return $this->get_route_error_by_code( self::INVALID_ID );
		}

		return rest_ensure_response( $this->prepare_item_for_response( $method, $request ) );
	}

	/**
	 * Create a shipping method.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error Response object or WP_Error.
	 */
	public function create_item( $request ) {
		$zone = $this->validate_zone( $request['zone_id'] );
		if ( is_wp_error( $zone ) ) {
			return $zone;
		}

		$method_validation = $this->validate_method_type( $request['method_id'] );
		if ( is_wp_error( $method_validation ) ) {
			return $method_validation;
		}

		$instance_id = $zone->add_shipping_method( $request['method_id'] );

		if ( ! $instance_id ) {
			return $this->get_route_error_by_code( self::CANNOT_CREATE );
		}

		$method = WC_Shipping_Zones::get_shipping_method( $instance_id );
		if ( ! $method ) {
			return $this->get_route_error_by_code( self::CANNOT_CREATE );
		}

		$result = $this->shipping_method_service->update_shipping_zone_method( $method, $instance_id, $request->get_params(), $zone->get_id() );
		if ( is_wp_error( $result ) ) {
			// Rollback: delete the method instance to prevent orphaned records.
			$zone->delete_shipping_method( $instance_id );
			return $result;
		}

		$request['zone_id'] = $zone->get_id();
		$response           = $this->prepare_item_for_response( $method, $request );
		$response->set_status( 201 );
		return $response;
	}

	/**
	 * Update a shipping method.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error Response object or WP_Error.
	 */
	public function update_item( $request ) {
		$instance_id = (int) $request['id'];

		$method = WC_Shipping_Zones::get_shipping_method( $instance_id );
		if ( ! $method ) {
			return $this->get_route_error_by_code( self::INVALID_ID );
		}

		$zone = $this->validate_zone_by_method_instance( $instance_id );
		if ( is_wp_error( $zone ) ) {
			return $zone;
		}

		if ( isset( $request['enabled'] ) || isset( $request['settings'] ) || isset( $request['order'] ) ) {
			$result = $this->shipping_method_service->update_shipping_zone_method( $method, $instance_id, $request->get_params(), $zone->get_id() );
			if ( is_wp_error( $result ) ) {
				return $result;
			}
		}

		$request['zone_id'] = $zone->get_id();
		return $this->prepare_item_for_response( $method, $request );
	}

	/**
	 * Delete shipping zone method by ID.
	 *
	 * Note: In v2/v3, this endpoint required a `force` parameter, but since shipping zone methods
	 * do not support trashing, it would either delete (force=true) or return a 501 error (force=false).
	 * We removed the `force` parameter in v4 as it serves no purpose when soft delete is not supported.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function delete_item( $request ) {
		$instance_id = (int) $request['id'];

		// Get the method before deletion to return in response.
		$method = WC_Shipping_Zones::get_shipping_method( $instance_id );
		if ( ! $method ) {
			return $this->get_route_error_by_code( self::INVALID_ID );
		}

		$zone = $this->validate_zone_by_method_instance( $instance_id );
		if ( is_wp_error( $zone ) ) {
			return $zone;
		}

		// Prepare response before deletion.
		$request->set_param( 'context', 'view' );
		$request['zone_id'] = $zone->get_id();
		$response           = $this->prepare_item_for_response( $method, $request );

		// Perform the deletion.
		$result = $zone->delete_shipping_method( $instance_id );

		if ( ! $result ) {
			return $this->get_route_error_by_code( self::INVALID_ID );
		}

		/**
		 * Fires after a shipping zone method is deleted via the REST API.
		 *
		 * @since 10.5.0
		 *
		 * @param WC_Shipping_Method $method   The shipping zone method being deleted.
		 * @param WC_Shipping_Zone   $zone     The shipping zone the method belonged to.
		 * @param WP_REST_Response   $response The response data.
		 * @param WP_REST_Request    $request  The request sent to the API.
		 */
		do_action( 'woocommerce_rest_delete_shipping_zone_method', $method, $zone, $response, $request );

		return $response;
	}

	/**
	 * Get the schema for shipping methods.
	 *
	 * @return array
	 */
	protected function get_schema(): array {
		return $this->method_schema->get_item_schema();
	}

	/**
	 * Get the item response for a shipping method.
	 *
	 * @param mixed           $zone    Shipping method data.
	 * @param WP_REST_Request $request Request object.
	 * @return array
	 */
	protected function get_item_response( $zone, WP_REST_Request $request ): array {
		return $this->method_schema->get_item_response( $zone, $request, $this->get_fields_for_response( $request ) );
	}

	/**
	 * Get route error by code, including custom shipping method errors.
	 *
	 * @param string $error_code Error code.
	 * @return WP_Error
	 */
	protected function get_route_error_by_code( string $error_code ): WP_Error {
		$custom_errors = array(
			self::INVALID_ZONE_ID     => array(
				'message' => __( 'Invalid shipping zone ID.', 'woocommerce' ),
				'status'  => WP_Http::NOT_FOUND,
			),
			self::INVALID_METHOD_TYPE => array(
				'message' => __( 'Invalid shipping method type.', 'woocommerce' ),
				'status'  => WP_Http::BAD_REQUEST,
			),
			self::ZONE_MISMATCH       => array(
				'message' => __( 'Shipping method does not belong to the specified zone.', 'woocommerce' ),
				'status'  => WP_Http::BAD_REQUEST,
			),
		);

		if ( isset( $custom_errors[ $error_code ] ) ) {
			return $this->get_route_error_response(
				$this->get_error_prefix() . $error_code,
				$custom_errors[ $error_code ]['message'],
				$custom_errors[ $error_code ]['status']
			);
		}

		return parent::get_route_error_by_code( $error_code );
	}

	/**
	 * Validate that a shipping zone exists.
	 *
	 * @param int $zone_id Zone ID.
	 * @return WC_Shipping_Zone|WP_Error Zone object or error.
	 */
	protected function validate_zone( $zone_id ) {
		$zone = WC_Shipping_Zones::get_zone( $zone_id );

		if ( ! $zone || ( 0 !== $zone->get_id() && ! $zone->get_zone_name() ) ) {
			return $this->get_route_error_by_code( self::INVALID_ZONE_ID );
		}

		return $zone;
	}

	/**
	 * Validate that a shipping method type is valid.
	 *
	 * @param string $method_id Shipping method ID.
	 * @return true|WP_Error True if valid, error otherwise.
	 */
	protected function validate_method_type( $method_id ) {
		$available_methods = WC()->shipping()->get_shipping_methods();

		if ( ! isset( $available_methods[ $method_id ] ) ) {
			return $this->get_route_error_by_code( self::INVALID_METHOD_TYPE );
		}

		return true;
	}

	/**
	 * Get zone by method instance ID.
	 *
	 * @param int $instance_id Method instance ID.
	 * @return WC_Shipping_Zone|WP_Error Zone object or error.
	 */
	protected function validate_zone_by_method_instance( $instance_id ) {
		$zone = WC_Shipping_Zones::get_zone_by( 'instance_id', $instance_id );

		if ( ! $zone ) {
			return $this->get_route_error_by_code( self::INVALID_ID );
		}

		return $zone;
	}
}
PK     [1]:    B  RestApi/Routes/V4/ShippingZoneMethod/ShippingZoneMethodService.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\ShippingZoneMethod;

use WP_Error;
use WC_Cache_Helper;
use WC_Shipping_Method;

/**
 * A service class to manage shipping zones methods.
 */
class ShippingZoneMethodService {

	/**
	 * Update settings of a shipping method.
	 *
	 * Validates and saves shipping method settings. Settings vary by method type
	 * (e.g., flat_rate has 'cost', free_shipping has 'requires' and 'min_amount').
	 *
	 * @param WC_Shipping_Method $method   Shipping method instance to update.
	 * @param array              $settings Settings to update as key-value pairs (e.g., ['title' => 'Express', 'cost' => '10']).
	 *                                     Available settings depend on the specific shipping method type.
	 * @return WC_Shipping_Method|\WP_Error Updated method object on success, WP_Error on validation failure.
	 */
	public function update_shipping_method_settings( $method, $settings ) {
		if ( ! is_array( $settings ) ) {
			return new \WP_Error(
				'woocommerce_rest_shipping_method_invalid_settings',
				__( 'Settings must be an array.', 'woocommerce' ),
				array( 'status' => 400 )
			);
		}

		$method->init_instance_settings();
		$instance_settings = $method->instance_settings;

		/**
		 * Transform setting keys to WooCommerce's expected format.
		 *
		 * WC_Settings_API::get_field_value() expects prefixed keys (e.g., 'woocommerce_flat_rate_1_title').
		 * Transform clean keys ('title') to prefixed keys before validation.
		 */
		$post_data = array();
		foreach ( $settings as $key => $value ) {
			$field_key               = $method->get_field_key( $key );
			$post_data[ $field_key ] = $value;
		}

		$form_fields = $method->get_instance_form_fields();
		foreach ( $settings as $key => $value ) {
			if ( isset( $form_fields[ $key ] ) ) {
				try {
					$instance_settings[ $key ] = $method->get_field_value( $key, $form_fields[ $key ], $post_data );
				} catch ( \Exception $e ) {
					return new \WP_Error(
						'woocommerce_rest_shipping_method_invalid_setting',
						$e->getMessage(),
						array( 'status' => 400 )
					);
				}
			}
		}

		/**
		 * Filter the instance settings values before saving.
		 *
		 * @since 9.4.0
		 * @param array              $instance_settings Instance settings.
		 * @param WC_Shipping_Method $method            Shipping method instance.
		 */
		$filtered_settings = apply_filters( 'woocommerce_shipping_' . $method->id . '_instance_settings_values', $instance_settings, $method );
		$result            = update_option( $method->get_instance_option_key(), $filtered_settings );

		if ( $result ) {
			$method->instance_settings = $instance_settings;
		}

		return $method;
	}

	/**
	 * Update a shipping method's properties.
	 *
	 * Updates settings, enabled status, and/or sort order for a shipping method instance.
	 *
	 * @since 9.4.0
	 *
	 * @param WC_Shipping_Method $method      Shipping method instance to update.
	 * @param int                $instance_id Method instance ID from the database.
	 * @param array              $data        {
	 *     Method properties to update. All parameters are optional.
	 *
	 *     @type array $settings Settings to update (key-value pairs). See update_shipping_method_settings().
	 *     @type bool  $enabled  Whether the shipping method is enabled.
	 *     @type int   $order    Sort order for displaying methods.
	 * }
	 * @param int|null           $zone_id    Zone ID. Optional, but required for firing the status toggle hook.
	 * @return WC_Shipping_Method|\WP_Error Updated method object on success, WP_Error on failure.
	 */
	public function update_shipping_zone_method( $method, $instance_id, $data, $zone_id = null ) {
		global $wpdb;

		$data = wp_parse_args(
			$data,
			array(
				'settings' => null,
				'enabled'  => null,
				'order'    => null,
			)
		);

		$updates         = array();
		$formats         = array();
		$enabled_changed = false;

		if ( ! is_null( $data['settings'] ) ) {
			$result = $this->update_shipping_method_settings( $method, $data['settings'] );
			if ( is_wp_error( $result ) ) {
				return $result;
			}
		}

		if ( ! is_null( $data['enabled'] ) ) {
			$updates['is_enabled'] = wc_string_to_bool( $data['enabled'] ) ? 1 : 0;
			$formats[]             = '%d';
			$method->enabled       = wc_string_to_bool( $data['enabled'] ) ? 'yes' : 'no';
			$enabled_changed       = true;
		}

		if ( ! is_null( $data['order'] ) ) {
			$updates['method_order'] = absint( $data['order'] );
			$formats[]               = '%d';
			$method->method_order    = absint( $data['order'] );
		}

		if ( empty( $updates ) ) {
			return $method;
		}

		$result = $wpdb->update(
			"{$wpdb->prefix}woocommerce_shipping_zone_methods",
			$updates,
			array( 'instance_id' => $instance_id ),
			$formats,
			array( '%d' )
		);

		if ( false === $result ) {
			return new WP_Error(
				'update_failed',
				__( 'Could not update shipping method.', 'woocommerce' )
			);
		}

		if ( $enabled_changed && null !== $zone_id ) {
			/**
			 * Fires when a shipping method's enabled status is toggled.
			 *
			 * @since 3.0.0
			 * @param int    $instance_id Instance ID of the shipping method.
			 * @param string $method_id   Shipping method ID (e.g., 'flat_rate').
			 * @param int    $zone_id     Zone ID.
			 * @param bool   $is_enabled  Whether the method is enabled.
			 */
			do_action(
				'woocommerce_shipping_zone_method_status_toggled',
				$instance_id,
				$method->id,
				$zone_id,
				(bool) $updates['is_enabled']
			);
		}

		WC_Cache_Helper::get_transient_version( 'shipping', true );
		return $method;
	}
}
PK     [1]u3 3 )  RestApi/Routes/V4/Products/Controller.phpnu         <?php
/**
 * REST API Products controller
 *
 * Handles requests to the /products endpoint.
 *
 * @package WooCommerce\RestApi
 * @since   2.6.0
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Products;

use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductStockStatus;
use Automattic\WooCommerce\Enums\ProductTaxStatus;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Enums\CatalogVisibility;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareRestControllerTrait;
use Automattic\WooCommerce\Utilities\I18nUtil;
use WC_REST_Products_V2_Controller;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use WC_Admin_Duplicate_Product;
use WC_REST_CRUD_Controller;
use WC_Product_Factory;


defined( 'ABSPATH' ) || exit;

/**
 * REST API Products controller class.
 *
 * @extends WC_REST_Products_V2_Controller
 */
class Controller extends WC_REST_Products_V2_Controller {

	use CogsAwareRestControllerTrait;

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc/v4';

	/**
	 * The value of the 'search_sku' argument if present.
	 *
	 * See prepare_objects_query()
	 *
	 * @var string
	 */
	private $search_sku_arg_value = '';

	/**
	 * If the 'search_name_or_sku' argument is present this will be set
	 * to an array of the (space-separated) tokens that form the argument value.
	 *
	 * @var array|null
	 */
	private $search_name_or_sku_tokens = null;

	/**
	 * If the 'search_fields' argument is present with 'search' this will be set
	 * to an array containing the fields to search and tokenized search terms.
	 *
	 * @var array|null
	 */
	private $search_fields_tokens = null;

	/**
	 * Suggested product ids.
	 *
	 * @var array
	 */
	private $suggested_products_ids = array();

	/**
	 * Product statuses to exclude from the query.
	 *
	 * @var array
	 */
	private $exclude_status = array();

	/**
	 * Stores attachment IDs processed during the current request for potential cleanup.
	 *
	 * @var array
	 */
	private $processed_attachment_ids_for_request = array();

	/**
	 * Register the routes for products.
	 */
	public function register_routes() {
		parent::register_routes();

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/suggested-products',
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => $this->with_cache(
						array( $this, 'get_suggested_products' ),
						array( 'endpoint_id' => 'get_suggested_products' )
					),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => $this->get_suggested_products_collection_params(),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[\d]+)/duplicate',
			array(
				'args'   => array(
					'id' => array(
						'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
						'type'        => 'integer',
					),
				),
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'duplicate_product' ),
					'permission_callback' => array( $this, 'create_item_permissions_check' ),
					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}

	/**
	 * Override the get_item permissions so that published products which are
	 * not password-protected are available to users without the
	 * 'read_private_posts' capability but can edit posts.
	 * This is required for the Product block in the editor, see:
	 * https://github.com/woocommerce/woocommerce/pull/61470
	 *
	 * @param WP_REST_Request $request Request data.
	 * @return bool|WP_Error
	 */
	public function get_item_permissions_check( $request ) {
		$object = $this->get_object( (int) $request['id'] );

		if ( $object && 0 !== $object->get_id() ) {
			if ( 'product' !== $object->post_type && 'product_variation' !== $object->post_type ) {
				return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot view this resource.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
			}

			$object_id        = $object->get_id();
			$post_type_object = get_post_type_object( $object->post_type );
			$permission       = false;

			if ( $post_type_object instanceof \WP_Post_Type ) {
				// These are the default permissions inherited from
				// `WC_REST_Products_V2_Controller`.
				$permission = current_user_can( $post_type_object->cap->read_private_posts, $object_id );

				// We add an special case when the post is published, not
				// password-protected and the user has post edit capabilities.
				if ( ! $permission && 'publish' === $object->get_status() && ! post_password_required( $object_id ) ) {
					$permission = current_user_can( 'edit_posts' ) && current_user_can( $post_type_object->cap->read, $object_id );
				}
			}

			/**
			* Filter the permission to view a product.
			*
			* @since 10.4.0
			* @param bool $permission The permission to view a product.
			* @param string $cap The capability to check.
			* @param int $object_id The ID of the product.
			* @param string $post_type The post type of the product.
			*/
			$permission = apply_filters( 'woocommerce_rest_check_permissions', $permission, 'read', $object_id, $object->post_type );

			if ( ! $permission ) {
				return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot view this resource.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
			}
		}

		return true;
	}

	/**
	 * Duplicate a product and returns the duplicated product.
	 * The product status is set to "draft" and the name includes a "(copy)" at the end by default.
	 *
	 * @param WP_REST_Request $request Request data.
	 * @return WP_REST_Response|WP_Error
	 */
	public function duplicate_product( $request ) {
		$product_id = $request->get_param( 'id' );
		$product    = wc_get_product( $product_id );

		if ( ! $product ) {
			return new WP_Error( 'woocommerce_rest_product_invalid_id', __( 'Invalid product ID.', 'woocommerce' ), array( 'status' => 404 ) );
		}

		// Creating product object from request data in preparation for copying.
		$updated_product    = $this->prepare_object_for_database( $request );
		$duplicated_product = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $updated_product );

		if ( is_wp_error( $duplicated_product ) ) {
			return new WP_Error( 'woocommerce_rest_product_duplicate_error', $duplicated_product->get_error_message(), array( 'status' => 400 ) );
		}

		$response_data = $duplicated_product->get_data();

		return new WP_REST_Response( $response_data, 200 );
	}

	/**
	 * Get the images for a product or product variation.
	 *
	 * @param WC_Product|WC_Product_Variation $product Product instance.
	 * @return array
	 */
	protected function get_images( $product ) {
		$images         = array();
		$attachment_ids = array();

		// Add featured image.
		if ( $product->get_image_id() ) {
			$attachment_ids[] = $product->get_image_id();
		}

		// Add gallery images.
		$attachment_ids = array_merge( $attachment_ids, $product->get_gallery_image_ids() );

		// Build image data.
		foreach ( $attachment_ids as $attachment_id ) {
			$attachment_post = get_post( $attachment_id );
			if ( is_null( $attachment_post ) ) {
				continue;
			}

			$attachment = wp_get_attachment_image_src( $attachment_id, 'full' );

			if ( ! is_array( $attachment ) ) {
				continue;
			}
			$thumbnail = wp_get_attachment_image_src( $attachment_id, 'woocommerce_thumbnail' );

			$images[] = array(
				'id'                => (int) $attachment_id,
				'date_created'      => wc_rest_prepare_date_response( $attachment_post->post_date, false ),
				'date_created_gmt'  => wc_rest_prepare_date_response( strtotime( $attachment_post->post_date_gmt ) ),
				'date_modified'     => wc_rest_prepare_date_response( $attachment_post->post_modified, false ),
				'date_modified_gmt' => wc_rest_prepare_date_response( strtotime( $attachment_post->post_modified_gmt ) ),
				'src'               => current( $attachment ),
				'name'              => get_the_title( $attachment_id ),
				'alt'               => get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ),
				'srcset'            => (string) wp_get_attachment_image_srcset( $attachment_id, 'full' ),
				'sizes'             => (string) wp_get_attachment_image_sizes( $attachment_id, 'full' ),
				'thumbnail'         => current( $thumbnail ),
			);
		}

		return $images;
	}

	/**
	 * Make extra product orderby features supported by WooCommerce available to the WC API.
	 * This includes 'price', 'popularity', and 'rating'.
	 *
	 * @param WP_REST_Request $request Request data.
	 * @return array
	 */
	protected function prepare_objects_query( $request ) {
		$args = WC_REST_CRUD_Controller::prepare_objects_query( $request );

		// Set post_status.
		$args['post_status'] = $request['status'];

		// Filter by a list of product statuses.
		if ( ! empty( $request['include_status'] ) ) {
			$args['post_status'] = $request['include_status'];
		}

		if ( ! empty( $request['exclude_status'] ) ) {
			$this->exclude_status = $request['exclude_status'];
		} else {
			$this->exclude_status = array();
		}

		// Filter downloadable products.
		if ( isset( $request['downloadable'] ) ) {
			$args['meta_query'] = $this->add_meta_query( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
				$args,
				array(
					'key'   => '_downloadable',
					'value' => wc_bool_to_string( $request['downloadable'] ),
				)
			);
		}

		// Filter virtual products.
		if ( isset( $request['virtual'] ) ) {
			$args['meta_query'] = $this->add_meta_query( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
				$args,
				array(
					'key'   => '_virtual',
					'value' => wc_bool_to_string( $request['virtual'] ),
				)
			);
		}

		// Taxonomy query to filter products by type, category,
		// tag, shipping class, and attribute.
		$tax_query = array();

		// Map between taxonomy name and arg's key.
		$taxonomies = array(
			'product_cat'            => 'category',
			'product_tag'            => 'tag',
			'product_shipping_class' => 'shipping_class',
		);

		// Set tax_query for each passed arg.
		foreach ( $taxonomies as $taxonomy => $key ) {
			if ( ! empty( $request[ $key ] ) ) {
				$tax_query[] = array(
					'taxonomy' => $taxonomy,
					'field'    => 'term_id',
					'terms'    => $request[ $key ],
				);
			}
		}

		if ( ! empty( $request['exclude_category'] ) ) {
			$tax_query[] = array(
				'taxonomy' => 'product_cat',
				'field'    => 'term_id',
				'terms'    => $request['exclude_category'],
				'operator' => 'NOT IN',
			);
		}

		// Filter product type by slug.
		$terms = array();
		if ( ! empty( $request['include_types'] ) ) {
			$terms = $request['include_types'];
		} elseif ( ! empty( $request['type'] ) ) {
			$terms[] = $request['type'];
		}

		if ( ! empty( $terms ) ) {
			$tax_query[] = array(
				'taxonomy' => 'product_type',
				'field'    => 'slug',
				'terms'    => $terms,
			);
		}

		// Add exclude types filter.
		if ( ! empty( $request['exclude_types'] ) ) {
			$tax_query[] = array(
				'taxonomy' => 'product_type',
				'field'    => 'slug',
				'terms'    => $request['exclude_types'],
				'operator' => 'NOT IN',
			);
		}

		// Filter by attribute and term.
		if ( ! empty( $request['attribute'] ) && ! empty( $request['attribute_term'] ) ) {
			if ( in_array( $request['attribute'], wc_get_attribute_taxonomy_names(), true ) ) {
				$tax_query[] = array(
					'taxonomy' => $request['attribute'],
					'field'    => 'term_id',
					'terms'    => $request['attribute_term'],
				);
			}
		}

		// Build tax_query if taxonomies are set.
		if ( ! empty( $tax_query ) ) {
			if ( ! empty( $args['tax_query'] ) ) {
				$args['tax_query'] = array_merge( $tax_query, $args['tax_query'] ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
			} else {
				$args['tax_query'] = $tax_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
			}
		}

		// Filter featured.
		if ( is_bool( $request['featured'] ) ) {
			$args['tax_query'][] = array(
				'taxonomy' => 'product_visibility',
				'field'    => 'name',
				'terms'    => 'featured',
				'operator' => true === $request['featured'] ? 'IN' : 'NOT IN',
			);
		}

		// Search parameter precedence: search_fields > search_name_or_sku > search_sku > sku.
		$search_fields = $request['search_fields'] ?? array();
		$search_arg    = trim( $request['search'] ?? '' );

		if ( $search_fields && $search_arg ) {
			$tokens = array_filter( array_map( 'trim', explode( ' ', $search_arg ) ) );

			$this->search_fields_tokens = array(
				'fields' => $search_fields,
				'tokens' => $tokens,
			);

			unset( $request['search'], $request['search_sku'], $request['sku'], $request['search_name_or_sku'], $args['s'] );
		}

		$search_name_or_sku_arg = $request['search_name_or_sku'] ?? '';

		if ( '' !== $search_name_or_sku_arg ) {
			// Do a tokenized search for name or SKU. Supersedes the 'search', 'search_sku' and 'sku' arguments.
			$tokens                          = array_filter( array_map( 'trim', explode( ' ', $search_name_or_sku_arg ) ) );
			$this->search_name_or_sku_tokens = $tokens;

			unset( $request['search'] );
			unset( $args['s'] );
			unset( $request['search_sku'] );
			unset( $request['sku'] );
		} elseif ( wc_product_sku_enabled() ) {
			// Do a partial match for a sku. Supersedes the 'sku' argument, that does exact matching.
			if ( ! empty( $request['search_sku'] ) ) {
				// Store this for use in the query clause filters.
				$this->search_sku_arg_value = $request['search_sku'];

				unset( $request['sku'] );
			}

			// Filter by sku.
			if ( ! empty( $request['sku'] ) ) {
				$skus = explode( ',', $request['sku'] );
				// Include the current string as a SKU too.
				if ( 1 < count( $skus ) ) {
					$skus[] = $request['sku'];
				}

				$args['meta_query'] = $this->add_meta_query( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
					$args,
					array(
						'key'     => '_sku',
						'value'   => $skus,
						'compare' => 'IN',
					)
				);
			}
		}

		if ( ! empty( $request['global_unique_id'] ) ) {
			$global_unique_ids  = array_map( 'trim', explode( ',', $request['global_unique_id'] ) );
			$args['meta_query'] = $this->add_meta_query( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
				$args,
				array(
					'key'     => '_global_unique_id',
					'value'   => $global_unique_ids,
					'compare' => 'IN',
				)
			);
		}

		// Filter by tax class.
		if ( ! empty( $request['tax_class'] ) ) {
			$args['meta_query'] = $this->add_meta_query( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
				$args,
				array(
					'key'   => '_tax_class',
					'value' => 'standard' !== $request['tax_class'] ? $request['tax_class'] : '',
				)
			);
		}

		// Price filter.
		if ( ! empty( $request['min_price'] ) || ! empty( $request['max_price'] ) ) {
			$args['meta_query'] = $this->add_meta_query( $args, wc_get_min_max_price_meta_query( $request ) );  // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
		}

		// Filter product by stock_status.
		if ( ! empty( $request['stock_status'] ) ) {
			$args['meta_query'] = $this->add_meta_query( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
				$args,
				array(
					'key'   => '_stock_status',
					'value' => $request['stock_status'],
				)
			);
		}

		// Filter by on sale products.
		if ( is_bool( $request['on_sale'] ) ) {
			$on_sale_key = $request['on_sale'] ? 'post__in' : 'post__not_in';
			$on_sale_ids = wc_get_product_ids_on_sale();

			// Use 0 when there's no on sale products to avoid return all products.
			$on_sale_ids = empty( $on_sale_ids ) ? array( 0 ) : $on_sale_ids;

			$args[ $on_sale_key ] += $on_sale_ids;
		}

		// Force the post_type argument, since it's not a user input variable.
		if ( ! empty( $request['sku'] ) || ! empty( $request['search_sku'] ) || $this->search_name_or_sku_tokens || $this->search_fields_tokens ) {
			$args['post_type'] = array( 'product', 'product_variation' );
		} else {
			$args['post_type'] = $this->post_type;
		}

		$ordering_args   = WC()->query->get_catalog_ordering_args( $args['orderby'], $args['order'] );
		$args['orderby'] = $ordering_args['orderby'];
		$args['order']   = $ordering_args['order'];
		if ( $ordering_args['meta_key'] ) {
			$args['meta_key'] = $ordering_args['meta_key']; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
		}

		/*
		 * When the suggested products ids is not empty,
		 * filter the query to return only the suggested products,
		 * overwriting the post__in parameter.
		 */
		if ( ! empty( $this->suggested_products_ids ) ) {
			$args['post__in'] = $this->suggested_products_ids;
		}

		// Force the post_type argument, since it's not a user input variable.
		if ( ! empty( $request['global_unique_id'] ) ) {
			$args['post_type'] = array( 'product', 'product_variation' );
		}

		return $args;
	}

	/**
	 * Get objects.
	 *
	 * @param array $query_args Query args.
	 * @return array
	 */
	protected function get_objects( $query_args ) {
		$add_search_criteria = $this->search_sku_arg_value || $this->search_name_or_sku_tokens || $this->search_fields_tokens;

		// Add filters for search criteria in product postmeta via the lookup table.
		if ( $add_search_criteria ) {
			add_filter( 'posts_join', array( $this, 'add_search_criteria_to_wp_query_join' ) );
			add_filter( 'posts_where', array( $this, 'add_search_criteria_to_wp_query_where' ) );
		}

		// Add filters for excluding product statuses.
		if ( ! empty( $this->exclude_status ) ) {
			add_filter( 'posts_where', array( $this, 'exclude_product_statuses' ) );
		}

		$result = parent::get_objects( $query_args );

		// Remove filters for search criteria in product postmeta via the lookup table.
		if ( $add_search_criteria ) {
			remove_filter( 'posts_join', array( $this, 'add_search_criteria_to_wp_query_join' ) );
			remove_filter( 'posts_where', array( $this, 'add_search_criteria_to_wp_query_where' ) );

			$this->search_sku_arg_value      = '';
			$this->search_name_or_sku_tokens = null;
			$this->search_fields_tokens      = null;
		}

		// Remove filters for excluding product statuses.
		if ( ! empty( $this->exclude_status ) ) {
			remove_filter( 'posts_where', array( $this, 'exclude_product_statuses' ) );

			$this->exclude_status = array();
		}

		return $result;
	}

	/**
	 * Join `wc_product_meta_lookup` table when SKU search query is present.
	 *
	 * @param string $join Join clause used to search posts.
	 * @return string
	 */
	public function add_search_criteria_to_wp_query_join( $join ) {
		// Check if already joined to avoid duplicate joins.
		if ( strstr( $join, 'wc_product_meta_lookup' ) ) {
			return $join;
		}

		// Only join if we need meta table search.
		if ( ! $this->search_fields_tokens &&
			! $this->search_sku_arg_value &&
			! ( $this->search_name_or_sku_tokens && wc_product_sku_enabled() ) ) {
			return $join;
		}

		global $wpdb;

		$join .= " LEFT JOIN $wpdb->wc_product_meta_lookup wc_product_meta_lookup
						ON $wpdb->posts.ID = wc_product_meta_lookup.product_id ";

		return $join;
	}

	/**
	 * Add a where clause for matching the SKU field.
	 *
	 * @param string $where Where clause used to search posts.
	 * @return string
	 */
	public function add_search_criteria_to_wp_query_where( $where ) {
		global $wpdb;

		if ( $this->search_fields_tokens ) {
			$where .= $this->build_dynamic_search_clauses(
				$this->search_fields_tokens['tokens'],
				$this->search_fields_tokens['fields']
			);
		} elseif ( $this->search_name_or_sku_tokens ) {
			$searchable_fields = wc_product_sku_enabled() ? array( 'name', 'sku' ) : array( 'name' );
			$where            .= $this->build_dynamic_search_clauses(
				$this->search_name_or_sku_tokens,
				$searchable_fields
			);
		} elseif ( ! empty( $this->search_sku_arg_value ) ) {
			$like_search = '%' . $wpdb->esc_like( $this->search_sku_arg_value ) . '%';
			$where      .= ' AND ' . $wpdb->prepare( '(wc_product_meta_lookup.sku LIKE %s)', $like_search );
		}
		return $where;
	}

	/**
	 * Build search clauses for dynamic product search.
	 *
	 * @param array $tokens Search tokens.
	 * @param array $fields Fields to search in.
	 * @return string
	 */
	private function build_dynamic_search_clauses( $tokens, $fields ) {
		global $wpdb;

		if ( empty( $fields ) || empty( $tokens ) ) {
			return '';
		}

		$column_map = array(
			'name'              => "{$wpdb->posts}.post_title",
			'sku'               => 'wc_product_meta_lookup.sku',
			'global_unique_id'  => 'wc_product_meta_lookup.global_unique_id',
			'description'       => "{$wpdb->posts}.post_content",
			'short_description' => "{$wpdb->posts}.post_excerpt",
		);

		$field_clauses = array();

		foreach ( $tokens as $token ) {
			$like_search         = '%' . $wpdb->esc_like( $token ) . '%';
			$field_token_clauses = array();

			foreach ( $fields as $field ) {
				if ( ! isset( $column_map[ $field ] ) ) {
					continue;
				}

				$db_column             = $column_map[ $field ];
				$field_token_clauses[] = $wpdb->prepare( "({$db_column} LIKE %s)", $like_search ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			}

			if ( $field_token_clauses ) {
				$field_clauses[] = '(' . implode( ' OR ', $field_token_clauses ) . ')';
			}
		}

		return $field_clauses ? ' AND (' . implode( ' AND ', $field_clauses ) . ')' : '';
	}

	/**
	 * Exclude product statuses from the query.
	 *
	 * @param string $where Where clause used to search posts.
	 * @return string
	 */
	public function exclude_product_statuses( $where ) {
		if ( ! empty( $this->exclude_status ) && is_array( $this->exclude_status ) ) {
			global $wpdb;

			$not_in = array();
			foreach ( $this->exclude_status as $status_to_exclude ) {
				$not_in[] = $wpdb->prepare( '%s', $status_to_exclude );
			}

			$not_in = join( ', ', $not_in );
			return $where . " AND $wpdb->posts.post_status NOT IN ( $not_in )";
		}

		return $where;
	}

	/**
	 * Set product images.
	 *
	 * @throws WC_REST_Exception REST API exceptions.
	 * @param WC_Product $product Product instance.
	 * @param array      $images  Images data.
	 * @return WC_Product
	 */
	protected function set_product_images( $product, $images ) {
		$images = is_array( $images ) ? array_filter( $images ) : array();

		if ( ! empty( $images ) ) {
			$gallery = array();

			foreach ( $images as $index => $image ) {
				$attachment_id = isset( $image['id'] ) ? absint( $image['id'] ) : 0;
				// The request can contain an attachment ID, if it doesn't, it's a new upload.
				$is_new_upload = false;

				if ( 0 === $attachment_id && isset( $image['src'] ) ) {
					$upload = wc_rest_upload_image_from_url( esc_url_raw( $image['src'] ) );

					if ( is_wp_error( $upload ) ) {
						/**
						 * Filter to check if it should suppress the image upload error, false by default.
						 */
						if ( ! apply_filters( 'woocommerce_rest_suppress_image_upload_error', false, $upload, $product->get_id(), $images ) ) { // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment
							throw new WC_REST_Exception( 'woocommerce_product_image_upload_error', $upload->get_error_message(), 400 ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
						} else {
							continue;
						}
					}

					$attachment_id = wc_rest_set_uploaded_image_as_attachment( $upload, $product->get_id() );
					$is_new_upload = true;
				}

				if ( ! wp_attachment_is_image( $attachment_id ) ) {
					/* translators: %s: image ID */
					throw new WC_REST_Exception( 'woocommerce_product_invalid_image_id', sprintf( __( '#%s is an invalid image ID.', 'woocommerce' ), $attachment_id ), 400 ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
				}

				if ( $is_new_upload && $attachment_id > 0 ) {
					// Tracking this for rollback purposes.
					$this->processed_attachment_ids_for_request[] = $attachment_id;
				}

				$featured_image = $product->get_image_id();

				if ( 0 === $index ) {
					$product->set_image_id( $attachment_id );
					wc_product_attach_featured_image( $attachment_id, $product, false );
				} else {
					$gallery[] = $attachment_id;
				}

				// Set the image alt if present.
				if ( ! empty( $image['alt'] ) ) {
					update_post_meta( $attachment_id, '_wp_attachment_image_alt', wc_clean( $image['alt'] ) );
				}

				// Set the image name if present.
				if ( ! empty( $image['name'] ) ) {
					wp_update_post(
						array(
							'ID'         => $attachment_id,
							'post_title' => $image['name'],
						)
					);
				}
			}

			$product->set_gallery_image_ids( $gallery );
		} else {
			$product->set_image_id( '' );
			$product->set_gallery_image_ids( array() );
		}

		return $product;
	}

	/**
	 * Prepare a single product for create or update.
	 *
	 * @param  WP_REST_Request $request Request object.
	 * @param  bool            $creating If is creating a new object.
	 * @return WP_Error|WC_Data
	 */
	protected function prepare_object_for_database( $request, $creating = false ) {
		$id = isset( $request['id'] ) ? absint( $request['id'] ) : 0;

		// Type is the most important part here because we need to be using the correct class and methods.
		if ( isset( $request['type'] ) ) {
			$classname = WC_Product_Factory::get_classname_from_product_type( $request['type'] );

			if ( ! class_exists( $classname ) ) {
				$classname = 'WC_Product_Simple';
			}

			$product = new $classname( $id );
		} elseif ( isset( $request['id'] ) ) {
			$product = wc_get_product( $id );
		} else {
			$product = new WC_Product_Simple();
		}

		if ( ProductType::VARIATION === $product->get_type() ) {
			return new WP_Error(
				"woocommerce_rest_invalid_{$this->post_type}_id",
				__( 'To manipulate product variations you should use the /products/&lt;product_id&gt;/variations/&lt;id&gt; endpoint.', 'woocommerce' ),
				array(
					'status' => 404,
				)
			);
		}

		// Post title.
		if ( isset( $request['name'] ) ) {
			$product->set_name( wp_filter_post_kses( $request['name'] ) );
		}

		// Post content.
		if ( isset( $request['description'] ) ) {
			$product->set_description( wp_filter_post_kses( $request['description'] ) );
		}

		// Post excerpt.
		if ( isset( $request['short_description'] ) ) {
			$product->set_short_description( wp_filter_post_kses( $request['short_description'] ) );
		}

		// Post status.
		if ( isset( $request['status'] ) ) {
			$product->set_status( get_post_status_object( $request['status'] ) ? $request['status'] : ProductStatus::DRAFT );
		}

		// Post slug.
		if ( isset( $request['slug'] ) ) {
			$product->set_slug( $request['slug'] );
		}

		// Menu order.
		if ( isset( $request['menu_order'] ) ) {
			$product->set_menu_order( $request['menu_order'] );
		}

		// Comment status.
		if ( isset( $request['reviews_allowed'] ) ) {
			$product->set_reviews_allowed( $request['reviews_allowed'] );
		}

		// Post password.
		if ( isset( $request['post_password'] ) ) {
			$product->set_post_password( $request['post_password'] );
		}

		// Virtual.
		if ( isset( $request['virtual'] ) ) {
			$product->set_virtual( $request['virtual'] );
		}

		// Tax status.
		if ( isset( $request['tax_status'] ) ) {
			$product->set_tax_status( $request['tax_status'] );
		}

		// Tax Class.
		if ( isset( $request['tax_class'] ) ) {
			$product->set_tax_class( $request['tax_class'] );
		}

		// Catalog Visibility.
		if ( isset( $request['catalog_visibility'] ) ) {
			$product->set_catalog_visibility( $request['catalog_visibility'] );
		}

		// Purchase Note.
		if ( isset( $request['purchase_note'] ) ) {
			$product->set_purchase_note( wp_kses_post( wp_unslash( $request['purchase_note'] ) ) );
		}

		// Featured Product.
		if ( isset( $request['featured'] ) ) {
			$product->set_featured( $request['featured'] );
		}

		// Shipping data.
		$product = $this->save_product_shipping_data( $product, $request );

		// SKU.
		if ( isset( $request['sku'] ) ) {
			$product->set_sku( wc_clean( $request['sku'] ) );
		}

		// Unique ID.
		if ( isset( $request['global_unique_id'] ) ) {
			$product->set_global_unique_id( wc_clean( $request['global_unique_id'] ) );
		}

		// Attributes.
		if ( isset( $request['attributes'] ) ) {
			$attributes = array();

			foreach ( $request['attributes'] as $attribute ) {
				$attribute_id   = 0;
				$attribute_name = '';

				// Check ID for global attributes or name for product attributes.
				if ( ! empty( $attribute['id'] ) ) {
					$attribute_id   = absint( $attribute['id'] );
					$attribute_name = wc_attribute_taxonomy_name_by_id( $attribute_id );
				} elseif ( ! empty( $attribute['name'] ) ) {
					$attribute_name = wc_clean( $attribute['name'] );
				}

				if ( ! $attribute_id && ! $attribute_name ) {
					continue;
				}

				if ( $attribute_id ) {

					if ( isset( $attribute['options'] ) ) {
						$options = $attribute['options'];

						if ( ! is_array( $attribute['options'] ) ) {
							// Text based attributes - Posted values are term names.
							$options = explode( WC_DELIMITER, $options );
						}

						$values = array_map( 'wc_sanitize_term_text_based', $options );
						$values = array_filter( $values, 'strlen' );
					} else {
						$values = array();
					}

					if ( ! empty( $values ) ) {
						// Add attribute to array, but don't set values.
						$attribute_object = new WC_Product_Attribute();
						$attribute_object->set_id( $attribute_id );
						$attribute_object->set_name( $attribute_name );
						$attribute_object->set_options( $values );
						$attribute_object->set_position( isset( $attribute['position'] ) ? (string) absint( $attribute['position'] ) : '0' );
						$attribute_object->set_visible( ( isset( $attribute['visible'] ) && $attribute['visible'] ) ? 1 : 0 );
						$attribute_object->set_variation( ( isset( $attribute['variation'] ) && $attribute['variation'] ) ? 1 : 0 );
						$attributes[] = $attribute_object;
					}
				} elseif ( isset( $attribute['options'] ) ) {
					// Custom attribute - Add attribute to array and set the values.
					if ( is_array( $attribute['options'] ) ) {
						$values = $attribute['options'];
					} else {
						$values = explode( WC_DELIMITER, $attribute['options'] );
					}
					$attribute_object = new WC_Product_Attribute();
					$attribute_object->set_name( $attribute_name );
					$attribute_object->set_options( $values );
					$attribute_object->set_position( isset( $attribute['position'] ) ? (string) absint( $attribute['position'] ) : '0' );
					$attribute_object->set_visible( ( isset( $attribute['visible'] ) && $attribute['visible'] ) ? 1 : 0 );
					$attribute_object->set_variation( ( isset( $attribute['variation'] ) && $attribute['variation'] ) ? 1 : 0 );
					$attributes[] = $attribute_object;
				}
			}
			$product->set_attributes( $attributes );
		}

		// Sales and prices.
		if ( in_array( $product->get_type(), array( ProductType::VARIABLE, ProductType::GROUPED ), true ) ) {
			$product->set_regular_price( '' );
			$product->set_sale_price( '' );
			$product->set_date_on_sale_to( '' );
			$product->set_date_on_sale_from( '' );
			$product->set_price( '' );
		} else {
			// Regular Price.
			if ( isset( $request['regular_price'] ) ) {
				$product->set_regular_price( $request['regular_price'] );
			}

			// Sale Price.
			if ( isset( $request['sale_price'] ) ) {
				$product->set_sale_price( $request['sale_price'] );
			}

			if ( isset( $request['date_on_sale_from'] ) ) {
				$product->set_date_on_sale_from( $request['date_on_sale_from'] );
			}

			if ( isset( $request['date_on_sale_from_gmt'] ) ) {
				$product->set_date_on_sale_from( $request['date_on_sale_from_gmt'] ? strtotime( $request['date_on_sale_from_gmt'] ) : null );
			}

			if ( isset( $request['date_on_sale_to'] ) ) {
				$product->set_date_on_sale_to( $request['date_on_sale_to'] );
			}

			if ( isset( $request['date_on_sale_to_gmt'] ) ) {
				$product->set_date_on_sale_to( $request['date_on_sale_to_gmt'] ? strtotime( $request['date_on_sale_to_gmt'] ) : null );
			}
		}

		// Product parent ID.
		if ( isset( $request['parent_id'] ) ) {
			$product->set_parent_id( $request['parent_id'] );
		}

		// Sold individually.
		if ( isset( $request['sold_individually'] ) ) {
			$product->set_sold_individually( $request['sold_individually'] );
		}

		// Stock status; stock_status has priority over in_stock.
		if ( isset( $request['stock_status'] ) ) {
			$stock_status = $request['stock_status'];
		} else {
			$stock_status = $product->get_stock_status();
		}

		// Stock data.
		if ( 'yes' === get_option( 'woocommerce_manage_stock' ) ) {
			// Manage stock.
			if ( isset( $request['manage_stock'] ) ) {
				$product->set_manage_stock( $request['manage_stock'] );
			}

			// Backorders.
			if ( isset( $request['backorders'] ) ) {
				$product->set_backorders( $request['backorders'] );
			}

			if ( $product->is_type( ProductType::GROUPED ) ) {
				$product->set_manage_stock( 'no' );
				$product->set_backorders( 'no' );
				$product->set_stock_quantity( '' );
				$product->set_stock_status( $stock_status );
			} elseif ( $product->is_type( ProductType::EXTERNAL ) ) {
				$product->set_manage_stock( 'no' );
				$product->set_backorders( 'no' );
				$product->set_stock_quantity( '' );
				$product->set_stock_status( ProductStockStatus::IN_STOCK );
			} elseif ( $product->get_manage_stock() ) {
				// Stock status is always determined by children so sync later.
				if ( ! $product->is_type( ProductType::VARIABLE ) ) {
					$product->set_stock_status( $stock_status );
				}

				// Stock quantity.
				if ( isset( $request['stock_quantity'] ) ) {
					$product->set_stock_quantity( wc_stock_amount( $request['stock_quantity'] ) );
				} elseif ( isset( $request['inventory_delta'] ) ) {
					$stock_quantity  = wc_stock_amount( $product->get_stock_quantity() );
					$stock_quantity += wc_stock_amount( $request['inventory_delta'] );
					$product->set_stock_quantity( wc_stock_amount( $stock_quantity ) );
				}

				// Low stock amount.
				// isset() returns false for value null, thus we need to check whether the value has been sent by the request.
				if ( array_key_exists( 'low_stock_amount', $request->get_params() ) ) {
					if ( null === $request['low_stock_amount'] ) {
						$product->set_low_stock_amount( '' );
					} else {
						$product->set_low_stock_amount( wc_stock_amount( $request['low_stock_amount'] ) );
					}
				}
			} else {
				// Don't manage stock.
				$product->set_manage_stock( 'no' );
				$product->set_stock_quantity( '' );
				$product->set_stock_status( $stock_status );
				$product->set_low_stock_amount( '' );
			}
		} elseif ( ! $product->is_type( ProductType::VARIABLE ) ) {
			$product->set_stock_status( $stock_status );
		}

		// Upsells.
		if ( isset( $request['upsell_ids'] ) ) {
			$upsells = array();
			$ids     = $request['upsell_ids'];

			if ( ! empty( $ids ) ) {
				foreach ( $ids as $id ) {
					if ( $id && $id > 0 ) {
						$upsells[] = $id;
					}
				}
			}

			$product->set_upsell_ids( $upsells );
		}

		// Cross sells.
		if ( isset( $request['cross_sell_ids'] ) ) {
			$crosssells = array();
			$ids        = $request['cross_sell_ids'];

			if ( ! empty( $ids ) ) {
				foreach ( $ids as $id ) {
					if ( $id && $id > 0 ) {
						$crosssells[] = $id;
					}
				}
			}

			$product->set_cross_sell_ids( $crosssells );
		}

		// Product categories.
		if ( isset( $request['categories'] ) && is_array( $request['categories'] ) ) {
			$product = $this->save_taxonomy_terms( $product, $request['categories'] );
		}

		// Product tags.
		if ( isset( $request['tags'] ) && is_array( $request['tags'] ) ) {
			$new_tags = array();

			foreach ( $request['tags'] as $tag ) {
				if ( ! isset( $tag['name'] ) ) {
					$new_tags[] = $tag;
					continue;
				}

				if ( ! term_exists( $tag['name'], 'product_tag' ) ) {
					// Create the tag if it doesn't exist.
					$term = wp_insert_term( $tag['name'], 'product_tag' );

					if ( ! is_wp_error( $term ) ) {
						$new_tags[] = array(
							'id' => $term['term_id'],
						);

						continue;
					}
				} else {
					// Tag exists, assume user wants to set the product with this tag.
					$new_tags[] = array(
						'id' => get_term_by( 'name', $tag['name'], 'product_tag' )->term_id,
					);
				}
			}

			$product = $this->save_taxonomy_terms( $product, $new_tags, 'tag' );
		}

		// Downloadable.
		if ( isset( $request['downloadable'] ) ) {
			$product->set_downloadable( $request['downloadable'] );
		}

		// Downloadable options.
		if ( $product->get_downloadable() ) {

			// Downloadable files.
			if ( isset( $request['downloads'] ) && is_array( $request['downloads'] ) ) {
				$product = $this->save_downloadable_files( $product, $request['downloads'] );
			}

			// Download limit.
			if ( isset( $request['download_limit'] ) ) {
				$product->set_download_limit( $request['download_limit'] );
			}

			// Download expiry.
			if ( isset( $request['download_expiry'] ) ) {
				$product->set_download_expiry( $request['download_expiry'] );
			}
		}

		// Product url and button text for external products.
		if ( $product->is_type( ProductType::EXTERNAL ) ) {
			if ( isset( $request['external_url'] ) ) {
				$product->set_product_url( $request['external_url'] );
			}

			if ( isset( $request['button_text'] ) ) {
				$product->set_button_text( $request['button_text'] );
			}
		}

		// Save default attributes for variable products.
		if ( $product->is_type( ProductType::VARIABLE ) ) {
			$product = $this->save_default_attributes( $product, $request );
		}

		// Set children for a grouped product.
		if ( $product->is_type( ProductType::GROUPED ) && isset( $request['grouped_products'] ) ) {
			$product->set_children( $request['grouped_products'] );
		}

		// Check for featured/gallery images, upload it and set it.
		if ( isset( $request['images'] ) ) {
			$product = $this->set_product_images( $product, $request['images'] );
		}

		// Allow set meta_data.
		if ( is_array( $request['meta_data'] ) ) {
			foreach ( $request['meta_data'] as $meta ) {
				$product->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
			}
		}

		if ( ! empty( $request['date_created'] ) ) {
			$date = rest_parse_date( $request['date_created'] );

			if ( $date ) {
				$product->set_date_created( $date );
			}
		}

		if ( ! empty( $request['date_created_gmt'] ) ) {
			$date = rest_parse_date( $request['date_created_gmt'], true );

			if ( $date ) {
				$product->set_date_created( $date );
			}
		}

		if ( $this->cogs_is_enabled() ) {
			$this->set_cogs_info_in_product_object( $request, $product );
		}

		/**
		 * Filters an object before it is inserted via the REST API.
		 *
		 * The dynamic portion of the hook name, `$this->post_type`,
		 * refers to the object type slug.
		 *
		 * @param WC_Data         $product  Object object.
		 * @param WP_REST_Request $request  Request object.
		 * @param bool            $creating If is creating a new object.
		 */
		return apply_filters( "woocommerce_rest_pre_insert_{$this->post_type}_object", $product, $request, $creating ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment
	}

	/**
	 * Get the Product's schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$weight_unit_label    = I18nUtil::get_weight_unit_label( get_option( 'woocommerce_weight_unit', 'kg' ) );
		$dimension_unit_label = I18nUtil::get_dimensions_unit_label( get_option( 'woocommerce_dimension_unit', 'cm' ) );
		$schema               = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => $this->post_type,
			'type'       => 'object',
			'properties' => array(
				'id'                    => array(
					'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'name'                  => array(
					'description' => __( 'Product name.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
				'slug'                  => array(
					'description' => __( 'Product slug.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
				'permalink'             => array(
					'description' => __( 'Product URL.', 'woocommerce' ),
					'type'        => 'string',
					'format'      => 'uri',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'date_created'          => array(
					'description' => __( "The date the product was created, in the site's timezone.", 'woocommerce' ),
					'type'        => 'date-time',
					'context'     => array( 'view', 'edit' ),
				),
				'date_created_gmt'      => array(
					'description' => __( 'The date the product was created, as GMT.', 'woocommerce' ),
					'type'        => 'date-time',
					'context'     => array( 'view', 'edit' ),
				),
				'date_modified'         => array(
					'description' => __( "The date the product was last modified, in the site's timezone.", 'woocommerce' ),
					'type'        => 'date-time',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'date_modified_gmt'     => array(
					'description' => __( 'The date the product was last modified, as GMT.', 'woocommerce' ),
					'type'        => 'date-time',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'type'                  => array(
					'description' => __( 'Product type.', 'woocommerce' ),
					'type'        => 'string',
					'default'     => ProductType::SIMPLE,
					'enum'        => array_keys( wc_get_product_types() ),
					'context'     => array( 'view', 'edit' ),
				),
				'status'                => array(
					'description' => __( 'Product status (post status).', 'woocommerce' ),
					'type'        => 'string',
					'default'     => ProductStatus::PUBLISH,
					'enum'        => array_merge( array_keys( get_post_statuses() ), array( ProductStatus::FUTURE, ProductStatus::AUTO_DRAFT, ProductStatus::TRASH ) ),
					'context'     => array( 'view', 'edit' ),
				),
				'featured'              => array(
					'description' => __( 'Featured product.', 'woocommerce' ),
					'type'        => 'boolean',
					'default'     => false,
					'context'     => array( 'view', 'edit' ),
				),
				'catalog_visibility'    => array(
					'description' => __( 'Catalog visibility.', 'woocommerce' ),
					'type'        => 'string',
					'default'     => CatalogVisibility::VISIBLE,
					'enum'        => array( CatalogVisibility::VISIBLE, CatalogVisibility::CATALOG, CatalogVisibility::SEARCH, CatalogVisibility::HIDDEN ),
					'context'     => array( 'view', 'edit' ),
				),
				'description'           => array(
					'description' => __( 'Product description.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
				'short_description'     => array(
					'description' => __( 'Product short description.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
				'sku'                   => array(
					'description' => __( 'Stock Keeping Unit.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
				'global_unique_id'      => array(
					'description' => __( 'GTIN, UPC, EAN or ISBN.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
				'price'                 => array(
					'description' => __( 'Current product price.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'regular_price'         => array(
					'description' => __( 'Product regular price.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
				'sale_price'            => array(
					'description' => __( 'Product sale price.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
				'date_on_sale_from'     => array(
					'description' => __( "Start date of sale price, in the site's timezone.", 'woocommerce' ),
					'type'        => 'date-time',
					'context'     => array( 'view', 'edit' ),
				),
				'date_on_sale_from_gmt' => array(
					'description' => __( 'Start date of sale price, as GMT.', 'woocommerce' ),
					'type'        => 'date-time',
					'context'     => array( 'view', 'edit' ),
				),
				'date_on_sale_to'       => array(
					'description' => __( "End date of sale price, in the site's timezone.", 'woocommerce' ),
					'type'        => 'date-time',
					'context'     => array( 'view', 'edit' ),
				),
				'date_on_sale_to_gmt'   => array(
					'description' => __( "End date of sale price, in the site's timezone.", 'woocommerce' ),
					'type'        => 'date-time',
					'context'     => array( 'view', 'edit' ),
				),
				'price_html'            => array(
					'description' => __( 'Price formatted in HTML.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'on_sale'               => array(
					'description' => __( 'Shows if the product is on sale.', 'woocommerce' ),
					'type'        => 'boolean',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'purchasable'           => array(
					'description' => __( 'Shows if the product can be bought.', 'woocommerce' ),
					'type'        => 'boolean',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'total_sales'           => array(
					'description' => __( 'Amount of sales.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'virtual'               => array(
					'description' => __( 'If the product is virtual.', 'woocommerce' ),
					'type'        => 'boolean',
					'default'     => false,
					'context'     => array( 'view', 'edit' ),
				),
				'downloadable'          => array(
					'description' => __( 'If the product is downloadable.', 'woocommerce' ),
					'type'        => 'boolean',
					'default'     => false,
					'context'     => array( 'view', 'edit' ),
				),
				'downloads'             => array(
					'description' => __( 'List of downloadable files.', 'woocommerce' ),
					'type'        => 'array',
					'context'     => array( 'view', 'edit' ),
					'items'       => array(
						'type'       => 'object',
						'properties' => array(
							'id'   => array(
								'description' => __( 'File ID.', 'woocommerce' ),
								'type'        => 'string',
								'context'     => array( 'view', 'edit' ),
							),
							'name' => array(
								'description' => __( 'File name.', 'woocommerce' ),
								'type'        => 'string',
								'context'     => array( 'view', 'edit' ),
							),
							'file' => array(
								'description' => __( 'File URL.', 'woocommerce' ),
								'type'        => 'string',
								'context'     => array( 'view', 'edit' ),
							),
						),
					),
				),
				'download_limit'        => array(
					'description' => __( 'Number of times downloadable files can be downloaded after purchase.', 'woocommerce' ),
					'type'        => 'integer',
					'default'     => -1,
					'context'     => array( 'view', 'edit' ),
				),
				'download_expiry'       => array(
					'description' => __( 'Number of days until access to downloadable files expires.', 'woocommerce' ),
					'type'        => 'integer',
					'default'     => -1,
					'context'     => array( 'view', 'edit' ),
				),
				'external_url'          => array(
					'description' => __( 'Product external URL. Only for external products.', 'woocommerce' ),
					'type'        => 'string',
					'format'      => 'uri',
					'context'     => array( 'view', 'edit' ),
				),
				'button_text'           => array(
					'description' => __( 'Product external button text. Only for external products.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
				'tax_status'            => array(
					'description' => __( 'Tax status.', 'woocommerce' ),
					'type'        => 'string',
					'default'     => ProductTaxStatus::TAXABLE,
					'enum'        => array( ProductTaxStatus::TAXABLE, ProductTaxStatus::SHIPPING, ProductTaxStatus::NONE ),
					'context'     => array( 'view', 'edit' ),
				),
				'tax_class'             => array(
					'description' => __( 'Tax class.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
				'manage_stock'          => array(
					'description' => __( 'Stock management at product level.', 'woocommerce' ),
					'type'        => 'boolean',
					'default'     => false,
					'context'     => array( 'view', 'edit' ),
				),
				'stock_quantity'        => array(
					'description' => __( 'Stock quantity.', 'woocommerce' ),
					'type'        => wc_is_stock_amount_integer() ? 'integer' : 'number',
					'context'     => array( 'view', 'edit' ),
				),
				'stock_status'          => array(
					'description' => __( 'Controls the stock status of the product.', 'woocommerce' ),
					'type'        => 'string',
					'default'     => ProductStockStatus::IN_STOCK,
					'enum'        => array_keys( wc_get_product_stock_status_options() ),
					'context'     => array( 'view', 'edit' ),
				),
				'backorders'            => array(
					'description' => __( 'If managing stock, this controls if backorders are allowed.', 'woocommerce' ),
					'type'        => 'string',
					'default'     => 'no',
					'enum'        => array( 'no', 'notify', 'yes' ),
					'context'     => array( 'view', 'edit' ),
				),
				'backorders_allowed'    => array(
					'description' => __( 'Shows if backorders are allowed.', 'woocommerce' ),
					'type'        => 'boolean',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'backordered'           => array(
					'description' => __( 'Shows if the product is on backordered.', 'woocommerce' ),
					'type'        => 'boolean',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'low_stock_amount'      => array(
					'description' => __( 'Low Stock amount for the product.', 'woocommerce' ),
					'type'        => array( 'integer', 'null' ),
					'context'     => array( 'view', 'edit' ),
				),
				'sold_individually'     => array(
					'description' => __( 'Allow one item to be bought in a single order.', 'woocommerce' ),
					'type'        => 'boolean',
					'default'     => false,
					'context'     => array( 'view', 'edit' ),
				),
				'weight'                => array(
					/* translators: %s: weight unit */
					'description' => sprintf( __( 'Product weight (%s).', 'woocommerce' ), $weight_unit_label ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
				'dimensions'            => array(
					'description' => __( 'Product dimensions.', 'woocommerce' ),
					'type'        => 'object',
					'context'     => array( 'view', 'edit' ),
					'properties'  => array(
						'length' => array(
							/* translators: %s: dimension unit */
							'description' => sprintf( __( 'Product length (%s).', 'woocommerce' ), $dimension_unit_label ),
							'type'        => 'string',
							'context'     => array( 'view', 'edit' ),
						),
						'width'  => array(
							/* translators: %s: dimension unit */
							'description' => sprintf( __( 'Product width (%s).', 'woocommerce' ), $dimension_unit_label ),
							'type'        => 'string',
							'context'     => array( 'view', 'edit' ),
						),
						'height' => array(
							/* translators: %s: dimension unit */
							'description' => sprintf( __( 'Product height (%s).', 'woocommerce' ), $dimension_unit_label ),
							'type'        => 'string',
							'context'     => array( 'view', 'edit' ),
						),
					),
				),
				'shipping_required'     => array(
					'description' => __( 'Shows if the product need to be shipped.', 'woocommerce' ),
					'type'        => 'boolean',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'shipping_taxable'      => array(
					'description' => __( 'Shows whether or not the product shipping is taxable.', 'woocommerce' ),
					'type'        => 'boolean',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'shipping_class'        => array(
					'description' => __( 'Shipping class slug.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
				'shipping_class_id'     => array(
					'description' => __( 'Shipping class ID.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'reviews_allowed'       => array(
					'description' => __( 'Allow reviews.', 'woocommerce' ),
					'type'        => 'boolean',
					'default'     => true,
					'context'     => array( 'view', 'edit' ),
				),
				'post_password'         => array(
					'description' => __( 'Post password.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
				'average_rating'        => array(
					'description' => __( 'Reviews average rating.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'rating_count'          => array(
					'description' => __( 'Amount of reviews that the product have.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'related_ids'           => array(
					'description' => __( 'List of related products IDs.', 'woocommerce' ),
					'type'        => 'array',
					'items'       => array(
						'type' => 'integer',
					),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'upsell_ids'            => array(
					'description' => __( 'List of up-sell products IDs.', 'woocommerce' ),
					'type'        => 'array',
					'items'       => array(
						'type' => 'integer',
					),
					'context'     => array( 'view', 'edit' ),
				),
				'cross_sell_ids'        => array(
					'description' => __( 'List of cross-sell products IDs.', 'woocommerce' ),
					'type'        => 'array',
					'items'       => array(
						'type' => 'integer',
					),
					'context'     => array( 'view', 'edit' ),
				),
				'parent_id'             => array(
					'description' => __( 'Product parent ID.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
				),
				'purchase_note'         => array(
					'description' => __( 'Optional note to send the customer after purchase.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
				'categories'            => array(
					'description' => __( 'List of categories.', 'woocommerce' ),
					'type'        => 'array',
					'context'     => array( 'view', 'edit' ),
					'items'       => array(
						'type'       => 'object',
						'properties' => array(
							'id'   => array(
								'description' => __( 'Category ID.', 'woocommerce' ),
								'type'        => 'integer',
								'context'     => array( 'view', 'edit' ),
							),
							'name' => array(
								'description' => __( 'Category name.', 'woocommerce' ),
								'type'        => 'string',
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
							'slug' => array(
								'description' => __( 'Category slug.', 'woocommerce' ),
								'type'        => 'string',
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
						),
					),
				),
				'brands'                => array(
					'description' => __( 'List of brands.', 'woocommerce' ),
					'type'        => 'array',
					'context'     => array( 'view', 'edit' ),
					'items'       => array(
						'type'       => 'object',
						'properties' => array(
							'id'   => array(
								'description' => __( 'Brand ID.', 'woocommerce' ),
								'type'        => 'integer',
								'context'     => array( 'view', 'edit' ),
							),
							'name' => array(
								'description' => __( 'Brand name.', 'woocommerce' ),
								'type'        => 'string',
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
							'slug' => array(
								'description' => __( 'Brand slug.', 'woocommerce' ),
								'type'        => 'string',
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
						),
					),
				),
				'tags'                  => array(
					'description' => __( 'List of tags.', 'woocommerce' ),
					'type'        => 'array',
					'context'     => array( 'view', 'edit' ),
					'items'       => array(
						'type'       => 'object',
						'properties' => array(
							'id'   => array(
								'description' => __( 'Tag ID.', 'woocommerce' ),
								'type'        => 'integer',
								'context'     => array( 'view', 'edit' ),
							),
							'name' => array(
								'description' => __( 'Tag name.', 'woocommerce' ),
								'type'        => 'string',
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
							'slug' => array(
								'description' => __( 'Tag slug.', 'woocommerce' ),
								'type'        => 'string',
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
						),
					),
				),
				'images'                => array(
					'description' => __( 'List of images.', 'woocommerce' ),
					'type'        => 'array',
					'context'     => array( 'view', 'edit' ),
					'items'       => array(
						'type'       => 'object',
						'properties' => array(
							'id'                => array(
								'description' => __( 'Image ID.', 'woocommerce' ),
								'type'        => 'integer',
								'context'     => array( 'view', 'edit' ),
							),
							'date_created'      => array(
								'description' => __( "The date the image was created, in the site's timezone.", 'woocommerce' ),
								'type'        => 'date-time',
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
							'date_created_gmt'  => array(
								'description' => __( 'The date the image was created, as GMT.', 'woocommerce' ),
								'type'        => 'date-time',
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
							'date_modified'     => array(
								'description' => __( "The date the image was last modified, in the site's timezone.", 'woocommerce' ),
								'type'        => 'date-time',
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
							'date_modified_gmt' => array(
								'description' => __( 'The date the image was last modified, as GMT.', 'woocommerce' ),
								'type'        => 'date-time',
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
							'src'               => array(
								'description' => __( 'Image URL.', 'woocommerce' ),
								'type'        => 'string',
								'format'      => 'uri',
								'context'     => array( 'view', 'edit' ),
							),
							'name'              => array(
								'description' => __( 'Image name.', 'woocommerce' ),
								'type'        => 'string',
								'context'     => array( 'view', 'edit' ),
							),
							'alt'               => array(
								'description' => __( 'Image alternative text.', 'woocommerce' ),
								'type'        => 'string',
								'context'     => array( 'view', 'edit' ),
							),
						),
					),
				),
				'has_options'           => array(
					'description' => __( 'Shows if the product needs to be configured before it can be bought.', 'woocommerce' ),
					'type'        => 'boolean',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'attributes'            => array(
					'description' => __( 'List of attributes.', 'woocommerce' ),
					'type'        => 'array',
					'context'     => array( 'view', 'edit' ),
					'items'       => array(
						'type'       => 'object',
						'properties' => array(
							'id'        => array(
								'description' => __( 'Attribute ID.', 'woocommerce' ),
								'type'        => 'integer',
								'context'     => array( 'view', 'edit' ),
							),
							'name'      => array(
								'description' => __( 'Attribute name.', 'woocommerce' ),
								'type'        => 'string',
								'context'     => array( 'view', 'edit' ),
							),
							'position'  => array(
								'description' => __( 'Attribute position.', 'woocommerce' ),
								'type'        => 'integer',
								'context'     => array( 'view', 'edit' ),
							),
							'visible'   => array(
								'description' => __( "Define if the attribute is visible on the \"Additional information\" tab in the product's page.", 'woocommerce' ),
								'type'        => 'boolean',
								'default'     => false,
								'context'     => array( 'view', 'edit' ),
							),
							'variation' => array(
								'description' => __( 'Define if the attribute can be used as variation.', 'woocommerce' ),
								'type'        => 'boolean',
								'default'     => false,
								'context'     => array( 'view', 'edit' ),
							),
							'options'   => array(
								'description' => __( 'List of available term names of the attribute.', 'woocommerce' ),
								'type'        => 'array',
								'items'       => array(
									'type' => 'string',
								),
								'context'     => array( 'view', 'edit' ),
							),
						),
					),
				),
				'default_attributes'    => array(
					'description' => __( 'Defaults variation attributes.', 'woocommerce' ),
					'type'        => 'array',
					'context'     => array( 'view', 'edit' ),
					'items'       => array(
						'type'       => 'object',
						'properties' => array(
							'id'     => array(
								'description' => __( 'Attribute ID.', 'woocommerce' ),
								'type'        => 'integer',
								'context'     => array( 'view', 'edit' ),
							),
							'name'   => array(
								'description' => __( 'Attribute name.', 'woocommerce' ),
								'type'        => 'string',
								'context'     => array( 'view', 'edit' ),
							),
							'option' => array(
								'description' => __( 'Selected attribute term name.', 'woocommerce' ),
								'type'        => 'string',
								'context'     => array( 'view', 'edit' ),
							),
						),
					),
				),
				'variations'            => array(
					'description' => __( 'List of variations IDs.', 'woocommerce' ),
					'type'        => 'array',
					'context'     => array( 'view', 'edit' ),
					'items'       => array(
						'type' => 'integer',
					),
					'readonly'    => true,
				),
				'grouped_products'      => array(
					'description' => __( 'List of grouped products ID.', 'woocommerce' ),
					'type'        => 'array',
					'items'       => array(
						'type' => 'integer',
					),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'menu_order'            => array(
					'description' => __( 'Menu order, used to custom sort products.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
				),
				'meta_data'             => array(
					'description' => __( 'Meta data.', 'woocommerce' ),
					'type'        => 'array',
					'context'     => array( 'view', 'edit' ),
					'items'       => array(
						'type'       => 'object',
						'properties' => array(
							'id'    => array(
								'description' => __( 'Meta ID.', 'woocommerce' ),
								'type'        => 'integer',
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
							'key'   => array(
								'description' => __( 'Meta key.', 'woocommerce' ),
								'type'        => 'string',
								'context'     => array( 'view', 'edit' ),
							),
							'value' => array(
								'description' => __( 'Meta value.', 'woocommerce' ),
								'type'        => 'mixed',
								'context'     => array( 'view', 'edit' ),
							),
						),
					),
				),
			),
		);

		$post_type_obj = get_post_type_object( $this->post_type );
		if ( is_post_type_viewable( $post_type_obj ) && $post_type_obj->public ) {
			$schema['properties']['permalink_template'] = array(
				'description' => __( 'Permalink template for the product.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => array( 'edit' ),
				'readonly'    => true,
			);

			$schema['properties']['generated_slug'] = array(
				'description' => __( 'Slug automatically generated from the product name.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => array( 'edit' ),
				'readonly'    => true,
			);
		}

		if ( $this->cogs_is_enabled() ) {
			$schema = $this->add_cogs_related_product_schema( $schema, false );
		}

		// New properties added for v4.

		$schema['properties']['min_price'] = array(
			'description' => __( 'Product minimum price.', 'woocommerce' ),
			'type'        => 'string',
			'context'     => array( 'view', 'edit' ),
		);

		$schema['properties']['max_price'] = array(
			'description' => __( 'Product maximum price.', 'woocommerce' ),
			'type'        => 'string',
			'context'     => array( 'view', 'edit' ),
		);

		$schema['properties']['add_to_cart'] = array(
			'description' => __( 'Add to cart details.', 'woocommerce' ),
			'type'        => 'object',
			'context'     => array( 'view', 'edit' ),
			'properties'  => array(
				'url'         => array(
					'description' => __( 'Add to cart URL.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
				'description' => array(
					'description' => __( 'Add to cart description.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
				'text'        => array(
					'description' => __( 'Add to cart text.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
				'single_text' => array(
					'description' => __( 'Add to cart single text.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
			),
			'readonly'    => true,
		);

			return $this->add_additional_fields_schema( $schema );
	}

	/**
	 * Add new options for 'orderby' to the collection params.
	 *
	 * @return array
	 */
	public function get_collection_params() {
		$params                    = parent::get_collection_params();
		$params['orderby']['enum'] = array_merge( $params['orderby']['enum'], array( 'price', 'popularity', 'rating' ) );

		unset( $params['in_stock'] );
		$params['stock_status'] = array(
			'description'       => __( 'Limit result set to products with specified stock status.', 'woocommerce' ),
			'type'              => 'string',
			'enum'              => array_keys( wc_get_product_stock_status_options() ),
			'sanitize_callback' => 'sanitize_text_field',
			'validate_callback' => 'rest_validate_request_arg',
		);

		$params['search_sku'] = array(
			'description'       => __( "Limit results to those with a SKU that partial matches a string. This argument takes precedence over 'sku'.", 'woocommerce' ),
			'type'              => 'string',
			'sanitize_callback' => 'sanitize_text_field',
			'validate_callback' => 'rest_validate_request_arg',
		);

		$params['search_name_or_sku'] = array(
			'description'       => __( "Limit results to those with a name or SKU that partial matches a string. This argument takes precedence over 'search', 'sku' and 'search_sku'.", 'woocommerce' ),
			'type'              => 'string',
			'sanitize_callback' => 'sanitize_text_field',
			'validate_callback' => 'rest_validate_request_arg',
		);

		$search_fields_enum = array( 'name', 'global_unique_id', 'description', 'short_description' );
		if ( wc_product_sku_enabled() ) {
			$search_fields_enum[] = 'sku';
		}

		$params['search_fields'] = array(
			'description'       => __( 'Limit search to specific fields when used with search parameter. Available fields: name, sku, global_unique_id, description, short_description. This argument takes precedence over all other search parameters.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'string',
				'enum' => $search_fields_enum,
			),
			'default'           => array(),
			'sanitize_callback' => 'wp_parse_slug_list',
			'validate_callback' => 'rest_validate_request_arg',
		);

		$params['include_status'] = array(
			'description'       => __( 'Limit result set to products with any of the statuses.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'string',
				'enum' => array_merge( array( 'any', ProductStatus::FUTURE, ProductStatus::TRASH ), array_keys( get_post_statuses() ) ),
			),
			'sanitize_callback' => 'wp_parse_list',
			'validate_callback' => 'rest_validate_request_arg',
		);

		$params['exclude_status'] = array(
			'description'       => __( 'Exclude products with any of the statuses from result set.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'string',
				'enum' => array_merge( array( ProductStatus::FUTURE, ProductStatus::TRASH ), array_keys( get_post_statuses() ) ),
			),
			'sanitize_callback' => 'wp_parse_list',
			'validate_callback' => 'rest_validate_request_arg',
		);

		$params['include_types'] = array(
			'description'       => __( 'Limit result set to products with any of the types.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'string',
				'enum' => array_keys( wc_get_product_types() ),
			),
			'sanitize_callback' => 'wp_parse_list',
			'validate_callback' => 'rest_validate_request_arg',
		);

		$params['exclude_types'] = array(
			'description'       => __( 'Exclude products with any of the types from result set.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'string',
				'enum' => array_keys( wc_get_product_types() ),
			),
			'sanitize_callback' => 'wp_parse_list',
			'validate_callback' => 'rest_validate_request_arg',
		);

		$params['exclude_category'] = array(
			'description'       => __( 'Exclude products that belong to specific product category IDs.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
		);

		$params['downloadable'] = array(
			'description'       => __( 'Limit result set to downloadable products.', 'woocommerce' ),
			'type'              => 'boolean',
			'sanitize_callback' => 'rest_sanitize_boolean',
			'validate_callback' => 'rest_validate_request_arg',
		);

		$params['virtual'] = array(
			'description'       => __( 'Limit result set to virtual products.', 'woocommerce' ),
			'type'              => 'boolean',
			'sanitize_callback' => 'rest_sanitize_boolean',
			'validate_callback' => 'rest_validate_request_arg',
		);

		return $params;
	}

	/**
	 * Add new options for the suggested-products endpoint.
	 *
	 * @return array
	 */
	public function get_suggested_products_collection_params() {
		$params = parent::get_collection_params();

		$params['categories'] = array(
			'description'       => __( 'Limit result set to specific product categorie ids.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
		);

		$params['tags'] = array(
			'description'       => __( 'Limit result set to specific product tag ids.', 'woocommerce' ),
			'type'              => 'array',
			'items'             => array(
				'type' => 'integer',
			),
			'default'           => array(),
			'validate_callback' => 'rest_validate_request_arg',
			'sanitize_callback' => 'wp_parse_id_list',
		);

		$params['limit'] = array(
			'description'       => __( 'Limit result set to specific amount of suggested products.', 'woocommerce' ),
			'type'              => 'integer',
			'default'           => 5,
			'validate_callback' => 'rest_validate_request_arg',
			'sanitize_callback' => 'absint',
		);

		return $params;
	}

	/**
	 * Get the downloads for a product.
	 *
	 * @param WC_Product $product Product instance.
	 *
	 * @return array
	 */
	protected function get_downloads( $product ) {
		$downloads = array();

		$context = isset( $this->request ) && isset( $this->request['context'] ) ? $this->request['context'] : 'view';

		if ( $product->is_downloadable() || 'edit' === $context ) {
			foreach ( $product->get_downloads() as $file_id => $file ) {
				$downloads[] = array(
					'id'   => $file_id, // MD5 hash.
					'name' => $file['name'],
					'file' => $file['file'],
				);
			}
		}

		return $downloads;
	}

	/**
	 * Get product data.
	 *
	 * @param WC_Product $product Product instance.
	 * @param string     $context Request context. Options: 'view' and 'edit'.
	 *
	 * @return array
	 */
	protected function get_product_data( $product, $context = 'view' ) {
		$data = parent::get_product_data( ...func_get_args() );

		if ( isset( $this->request ) ) {
			$fields = $this->get_fields_for_response( $this->request );

			// Add stock_status if needed.
			if ( in_array( 'stock_status', $fields, true ) ) {
				$data['stock_status'] = $product->get_stock_status( $context );
			}

			// Add has_options if needed.
			if ( in_array( 'has_options', $fields, true ) ) {
				$data['has_options'] = $product->has_options( $context );
			}

			if ( in_array( 'post_password', $fields, true ) ) {
				$data['post_password'] = $product->get_post_password( $context );
			}

			if ( in_array( 'global_unique_id', $fields, true ) ) {
				$data['global_unique_id'] = $product->get_global_unique_id( $context );
			}

			if ( in_array( 'min_price', $fields, true ) ) {
				$data['min_price'] = method_exists( $product, 'get_min_price' ) ? $product->get_min_price() : '';
			}

			if ( in_array( 'max_price', $fields, true ) ) {
				$data['max_price'] = method_exists( $product, 'get_max_price' ) ? $product->get_max_price() : '';
			}

			$post_type_obj = get_post_type_object( $this->post_type );
			if ( is_post_type_viewable( $post_type_obj ) && $post_type_obj->public ) {
				$permalink_template_requested = in_array( 'permalink_template', $fields, true );
				$generated_slug_requested     = in_array( 'generated_slug', $fields, true );

				if ( $permalink_template_requested || $generated_slug_requested ) {
					if ( ! function_exists( 'get_sample_permalink' ) ) {
						require_once ABSPATH . 'wp-admin/includes/post.php';
					}

					$sample_permalink = get_sample_permalink( $product->get_id(), $product->get_name(), '' );

					// Add permalink_template if needed.
					if ( $permalink_template_requested ) {
						$data['permalink_template'] = $sample_permalink[0];
					}

					// Add generated_slug if needed.
					if ( $generated_slug_requested ) {
						$data['generated_slug'] = $sample_permalink[1];
					}
				}
			}
		}

		return $data;
	}

	/**
	 * Get the suggested products.
	 *
	 * @param WP_REST_Request $request Request object.
	 * @return object
	 */
	public function get_suggested_products( $request ) {
		$categories  = $request->get_param( 'categories' );
		$tags        = $request->get_param( 'tags' );
		$exclude_ids = $request->get_param( 'exclude' );
		$limit       = $request->get_param( 'limit' ) ? $request->get_param( 'limit' ) : 5;

		$data_store = \WC_Data_Store::load( 'product' );
		// @phpstan-ignore-next-line method.notFound
		$this->suggested_products_ids = $data_store->get_related_products(
			$categories,
			$tags,
			$exclude_ids,
			$limit,
			null // No need to pass the product ID.
		);

		// When no suggested products are found, return an empty array.
		if ( empty( $this->suggested_products_ids ) ) {
			return array();
		}

		// Ensure to respect the limit, since the data store may return more than the limit.
		$this->suggested_products_ids = array_slice( $this->suggested_products_ids, 0, $limit );

		return parent::get_items( $request );
	}

	/**
	 * Core function to prepare a single product output for response
	 * (doesn't fire hooks, ensure_response, or add links).
	 *
	 * @param WC_Data         $object_data Object data.
	 * @param WP_REST_Request $request Request object.
	 * @param string          $context Request context.
	 * @return array Product data to be included in the response.
	 */
	protected function prepare_object_for_response_core( $object_data, $request, $context ): array {
		$data = parent::prepare_object_for_response_core( $object_data, $request, $context );

		if ( $this->cogs_is_enabled() ) {
			$this->add_cogs_info_to_returned_product_data( $data, $object_data );
		}

		$data['add_to_cart'] = array(
			'url'         => $object_data->add_to_cart_url(),
			'description' => $object_data->add_to_cart_description(),
			'text'        => $object_data->add_to_cart_text(),
			'single_text' => $object_data->single_add_to_cart_text(),
		);
		return $data;
	}

	/**
	 * Create a single item.
	 * Handles cleanup of orphaned images if product creation fails.
	 *
	 * @param WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
	 */
	public function create_item( $request ) {
		$this->processed_attachment_ids_for_request = array();

		$response = parent::create_item( $request );

		if ( is_wp_error( $response ) ) {
			if ( ! empty( $this->processed_attachment_ids_for_request ) ) {
				// Handle deletion of orphaned images.
				foreach ( $this->processed_attachment_ids_for_request as $attachment_id ) {
					wp_delete_attachment( (int) $attachment_id, true );
				}
			}
		}

		$this->processed_attachment_ids_for_request = array();

		return $response;
	}
}
PK     [1]XG  G  7  RestApi/Routes/V4/OrderNotes/Schema/OrderNoteSchema.phpnu         <?php
/**
 * OrderNoteSchema class.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\OrderNotes\Schema;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractSchema;
use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
use WP_REST_Request;

/**
 * OrderNoteSchema class.
 */
class OrderNoteSchema extends AbstractSchema {
	/**
	 * The schema item identifier.
	 *
	 * @var string
	 */
	const IDENTIFIER = 'order_note';

	/**
	 * Return all properties for the item schema.
	 *
	 * Note that context determines under which context data should be visible. For example, edit would be the context
	 * used when getting records with the intent of editing them. embed context allows the data to be visible when the
	 * item is being embedded in another response.
	 *
	 * @return array
	 */
	public function get_item_schema_properties(): array {
		$schema = array(
			'id'               => array(
				'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'order_id'         => array(
				'description' => __( 'Order ID the note belongs to.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'author'           => array(
				'description' => __( 'Order note author.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'date_created'     => array(
				'description' => __( "The date the order note was created, in the site's timezone.", 'woocommerce' ),
				'type'        => 'string',
				'format'      => 'date-time',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'date_created_gmt' => array(
				'description' => __( 'The date the order note was created, as GMT.', 'woocommerce' ),
				'type'        => 'string',
				'format'      => 'date-time',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'note'             => array(
				'description' => __( 'Order note content.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'required'    => true,
			),
			'title'            => array(
				'description' => __( 'The title of the order note group.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'group'            => array(
				'description' => __( 'The group of order note.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'is_customer_note' => array(
				'description' => __( 'If true, the note will be shown to customers. If false, the note will be for admin reference only.', 'woocommerce' ),
				'type'        => 'boolean',
				'default'     => false,
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
		);

		return $schema;
	}

	/**
	 * Get the item response.
	 *
	 * @param WP_Comment      $note Order note object.
	 * @param WP_REST_Request $request Request object.
	 * @param array           $include_fields Fields to include in the response.
	 * @return array The item response.
	 */
	public function get_item_response( $note, WP_REST_Request $request, array $include_fields = array() ): array {
		$group            = get_comment_meta( $note->comment_ID, 'note_group', true );
		$title            = get_comment_meta( $note->comment_ID, 'note_title', true );
		$is_customer_note = wc_string_to_bool( get_comment_meta( $note->comment_ID, 'is_customer_note', true ) );

		if ( $group && ! $title ) {
			$title = OrderNoteGroup::get_default_group_title( $group );
		}

		return array(
			'id'               => (int) $note->comment_ID,
			'order_id'         => (int) $note->comment_post_ID,
			'author'           => $note->comment_author,
			'date_created'     => wc_rest_prepare_date_response( $note->comment_date ),
			'date_created_gmt' => wc_rest_prepare_date_response( $note->comment_date_gmt ),
			'note'             => $note->comment_content,
			'title'            => $title,
			'group'            => $group,
			'is_customer_note' => $is_customer_note,
		);
	}
}
PK     [1]Y1-  1-  +  RestApi/Routes/V4/OrderNotes/Controller.phpnu         <?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
 * REST API Order Notes controller
 *
 * Handles route registration, permissions, CRUD operations, and schema definition.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\OrderNotes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractController;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\OrderNotes\Schema\OrderNoteSchema;
use WP_Http;
use WP_Error;
use WP_Comment;
use WC_Order;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

/**
 * OrdersNotes Controller.
 */
class Controller extends AbstractController {
	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'order-notes';

	/**
	 * Schema class for this route.
	 *
	 * @var OrderNoteSchema
	 */
	protected $item_schema;

	/**
	 * Query utils class.
	 *
	 * @var QueryUtils
	 */
	protected $query_utils;

	/**
	 * Initialize the controller.
	 *
	 * @param OrderNoteSchema $item_schema Order schema class.
	 * @param CollectionQuery $query_utils Query utils class.
	 * @internal
	 */
	final public function init( OrderNoteSchema $item_schema, CollectionQuery $query_utils ) {
		$this->item_schema      = $item_schema;
		$this->collection_query = $query_utils;
	}

	/**
	 * Get the schema for the current resource. This use consumed by the AbstractController to generate the item schema
	 * after running various hooks on the response.
	 */
	protected function get_schema(): array {
		return $this->item_schema->get_item_schema();
	}

	/**
	 * Get the collection args schema.
	 *
	 * @return array
	 */
	protected function get_query_schema(): array {
		return $this->collection_query->get_query_schema();
	}

	/**
	 * Register the routes for orders.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				'schema' => array( $this, 'get_public_item_schema' ),
				'args'   => array(
					'order_id' => array(
						'description'       => __( 'The order ID that notes belong to.', 'woocommerce' ),
						'type'              => 'integer',
						'validate_callback' => function ( $value ) {
							return $this->is_valid_order_id( $value );
						},
						'required'          => true,
					),
				),
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => $this->get_collection_params(),
				),
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'create_item' ),
					'permission_callback' => array( $this, 'create_item_permissions_check' ),
					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
				),
			)
		);
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[\d]+)',
			array(
				'schema' => array( $this, 'get_public_item_schema' ),
				'args'   => array(
					'id' => array(
						'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
						'type'        => 'integer',
					),
				),
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_item' ),
					'permission_callback' => array( $this, 'get_item_permissions_check' ),
					'args'                => array(
						'context' => $this->get_context_param( array( 'default' => 'view' ) ),
					),
				),
				array(
					'methods'             => WP_REST_Server::DELETABLE,
					'callback'            => array( $this, 'delete_item' ),
					'permission_callback' => array( $this, 'delete_item_permissions_check' ),
				),
			)
		);
	}

	/**
	 * Prepare links for the request.
	 *
	 * @param mixed            $item WordPress representation of the item.
	 * @param WP_REST_Request  $request Request object.
	 * @param WP_REST_Response $response Response object.
	 * @return array
	 */
	protected function prepare_links( $item, WP_REST_Request $request, WP_REST_Response $response ): array {
		return array(
			'self'       => array(
				'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, (int) $item->comment_ID ) ),
			),
			'collection' => array(
				'href' => add_query_arg(
					array( 'order_id' => (int) $item->comment_post_ID ),
					rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) )
				),
			),
		);
	}

	/**
	 * Prepare a single order note item for response.
	 *
	 * @param WP_Comment      $note Note object.
	 * @param WP_REST_Request $request Request object.
	 * @return array
	 */
	protected function get_item_response( $note, WP_REST_Request $request ): array {
		return $this->item_schema->get_item_response( $note, $request );
	}

	/**
	 * Check if a given request has access to read an item.
	 *
	 * @param  WP_REST_Request $request The request object.
	 * @return WP_Error|boolean
	 */
	public function get_item_permissions_check( $request ) {
		$order = $this->get_order_by_note_id( (int) $request['id'] );

		if ( ! $order ) {
			return $this->get_route_error_by_code( self::INVALID_ID );
		}

		if ( ! wc_rest_check_post_permissions( 'shop_order', 'read', $order->get_id() ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );
		}
		return true;
	}

	/**
	 * Check if a given request has access to read items.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function get_items_permissions_check( $request ) {
		if ( ! wc_rest_check_post_permissions( 'shop_order', 'read', (int) $request['order_id'] ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );
		}
		return true;
	}

	/**
	 * Check if a given request has access to create an item.
	 *
	 * @param  WP_REST_Request $request The request object.
	 * @return WP_Error|boolean
	 */
	public function create_item_permissions_check( $request ) {
		if ( ! wc_rest_check_post_permissions( 'shop_order', 'create', (int) $request['order_id'] ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );
		}
		return true;
	}

	/**
	 * Check if a given request has access to delete an item.
	 *
	 * @param  WP_REST_Request $request The request object.
	 * @return bool|WP_Error
	 */
	public function delete_item_permissions_check( $request ) {
		$order = $this->get_order_by_note_id( (int) $request['id'] );

		if ( ! $order ) {
			return $this->get_route_error_by_code( self::INVALID_ID );
		}

		if ( ! wc_rest_check_post_permissions( 'shop_order', 'delete', $order->get_id() ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );
		}
		return true;
	}

	/**
	 * Get a single item.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response
	 */
	public function get_item( $request ) {
		$note = $this->get_note_by_id( (int) $request['id'] );

		if ( ! $note ) {
			return $this->get_route_error_by_code( self::INVALID_ID );
		}

		return $this->prepare_item_for_response( $note, $request );
	}

	/**
	 * Get collection of orders.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response
	 */
	public function get_items( $request ) {
		$order = $this->get_order_by_id( (int) $request['order_id'] );

		if ( ! $order ) {
			return $this->get_route_error_by_code( self::INVALID_ID );
		}

		$query_args = $this->collection_query->get_query_args( $request );
		$results    = $this->collection_query->get_query_results( $query_args, $request );
		$items      = array();

		foreach ( $results as $result ) {
			$items[] = $this->prepare_response_for_collection( $this->prepare_item_for_response( $result, $request ) );
		}

		return rest_ensure_response( $items );
	}

	/**
	 * Create a single item.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response
	 */
	public function create_item( $request ) {
		if ( ! empty( $request['id'] ) ) {
			return $this->get_route_error_by_code( self::RESOURCE_EXISTS );
		}

		$order   = $this->get_order_by_id( (int) $request['order_id'] );
		$note_id = $order ? $order->add_order_note( $request['note'], $request['is_customer_note'], true ) : null;

		if ( ! $note_id ) {
			return $this->get_route_error_by_code( self::CANNOT_CREATE );
		}

		$note = get_comment( $note_id );
		$this->update_additional_fields_for_object( $note, $request );

		/**
		 * Fires after a single object is created via the REST API.
		 *
		 * @param WP_Comment         $note    Inserted object.
		 * @param WP_REST_Request $request   Request object.
		 * @since 10.2.0
		 */
		do_action( $this->get_hook_prefix() . 'created', $note, $request );

		$request->set_param( 'context', 'edit' );
		$response = $this->prepare_item_for_response( $note, $request );
		$response->set_status( WP_Http::CREATED );
		$response->header( 'Location', rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $note_id ) ) );

		return $response;
	}

	/**
	 * Delete a single item.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function delete_item( $request ) {
		$note = $this->get_note_by_id( (int) $request['id'] );

		if ( empty( $note ) ) {
			return $this->get_route_error_by_code( self::INVALID_ID );
		}

		$request->set_param( 'context', 'edit' );
		$response = $this->prepare_item_for_response( $note, $request );

		$result = wc_delete_order_note( (int) $note->comment_ID );

		if ( ! $result ) {
			return $this->get_route_error_by_code( self::CANNOT_DELETE );
		}

		/**
		 * Fires after a single object is deleted or trashed via the REST API.
		 *
		 * @param WP_Comment         $note   The deleted or trashed object.
		 * @param WP_REST_Response $response The response data.
		 * @param WP_REST_Request  $request  The request sent to the API.
		 * @since 10.2.0
		 */
		do_action( $this->get_hook_prefix() . 'deleted', $note, $response, $request );

		return $response;
	}

	/**
	 * Check if an order is valid.
	 *
	 * @param mixed $order_id The order ID.
	 * @return bool True if the order is valid, false otherwise.
	 */
	protected function is_valid_order_id( $order_id ): bool {
		$order = $this->get_order_by_id( (int) $order_id );
		return $order && $order instanceof WC_Order;
	}

	/**
	 * Get an order by ID.
	 *
	 * @param int $order_id The order ID.
	 * @return WC_Order|null
	 */
	protected function get_order_by_id( int $order_id ) {
		if ( ! $order_id ) {
			return null;
		}
		$order = wc_get_order( $order_id );
		return $order && 'shop_order' === $order->get_type() ? $order : null;
	}
	/**
	 * Get the parent order of a note.
	 *
	 * @param int|WP_Comment $note_id The note ID or note object.
	 * @return WC_Order|null
	 */
	protected function get_order_by_note_id( $note_id ) {
		$note = $note_id instanceof WP_Comment ? $note_id : $this->get_note_by_id( (int) $note_id );
		if ( ! $note ) {
			return null;
		}
		return $this->get_order_by_id( (int) $note->comment_post_ID );
	}

	/**
	 * Get a note by ID.
	 *
	 * @param int $note_id The note ID.
	 * @return WP_Comment|null
	 */
	protected function get_note_by_id( int $note_id ) {
		if ( ! $note_id ) {
			return null;
		}
		$note = get_comment( $note_id );
		return $note && 'order_note' === $note->comment_type ? $note : null;
	}
}
PK     [1]Y	  	  0  RestApi/Routes/V4/OrderNotes/CollectionQuery.phpnu         <?php
/**
 * CollectionQuery class.
 *
 * @package WooCommerce\RestApi
 * @internal This file is for internal use only and should not be used by external code.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\OrderNotes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractCollectionQuery;
use WP_REST_Request;
use WC_Order;

/**
 * CollectionQuery class.
 *
 * @internal This class is for internal use only and should not be used by external code.
 */
final class CollectionQuery extends AbstractCollectionQuery {
	/**
	 * Get query schema.
	 *
	 * @return array
	 */
	public function get_query_schema(): array {
		return array(
			'note_type' => array(
				'default'           => 'all',
				'description'       => __( 'Limit result to customer notes or private notes.', 'woocommerce' ),
				'type'              => 'string',
				'enum'              => array( 'all', 'customer', 'private' ),
				'sanitize_callback' => 'sanitize_key',
				'validate_callback' => 'rest_validate_request_arg',
			),
		);
	}

	/**
	 * Prepares query args.
	 *
	 * @param WP_REST_Request $request The request object.
	 * @return array
	 */
	public function get_query_args( WP_REST_Request $request ): array {
		$args = array(
			'post_id' => $request['order_id'] ?? 0,
			'status'  => 'approve',
			'type'    => 'order_note',
		);

		// Allow filter by order note type.
		if ( 'customer' === $request['note_type'] ) {
			$args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
				array(
					'key'     => 'is_customer_note',
					'value'   => 1,
					'compare' => '=',
				),
			);
		} elseif ( 'private' === $request['note_type'] ) {
			$args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
				array(
					'key'     => 'is_customer_note',
					'compare' => 'NOT EXISTS',
				),
			);
		}

		return $args;
	}

	/**
	 * Get results of the query.
	 *
	 * @param array           $query_args The query arguments.
	 * @param WP_REST_Request $request The request object.
	 * @return array
	 */
	public function get_query_results( array $query_args, WP_REST_Request $request ): array {
		remove_filter( 'comments_clauses', array( 'WC_Comments', 'exclude_order_comments' ), 10, 1 );
		$results = get_comments( $query_args );
		add_filter( 'comments_clauses', array( 'WC_Comments', 'exclude_order_comments' ), 10, 1 );

		return (array) $results;
	}
}
PK     [1]P4C|  |  -  RestApi/Routes/V4/Orders/ActionController.phpnu         <?php
/**
 * ActionController class.
 *
 * @package WooCommerce\RestApi
 * @internal This file is for internal use only and should not be used by external code.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders;

defined( 'ABSPATH' ) || exit;

use WC_REST_Exception;
use WP_REST_Request;
use WP_Error;
use WC_Order;
use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;

/**
 * ActionController class.
 *
 * Actions that can be performed on orders.
 *
 * @internal This class is for internal use only and should not be used by external code.
 */
class ActionController {

	/**
	 * Get endpoint args for the actions.
	 *
	 * @return array
	 */
	public function get_endpoint_args_for_actions(): array {
		return array(
			'payment_complete'           => array(
				'description' => __( 'Marks the order as paid. Updates the order status and reduces line item stock if necessary.', 'woocommerce' ),
				'type'        => 'boolean',
				'default'     => false,
			),
			'reset_download_permissions' => array(
				'description' => __( 'Resets any download permissions linked to the order.', 'woocommerce' ),
				'type'        => 'boolean',
				'default'     => false,
			),
		);
	}

	/**
	 * Run the actions for the order.
	 *
	 * @throws WC_REST_Exception If an error occurs.
	 * @param WC_Order        $order The order object.
	 * @param WP_REST_Request $request The request object.
	 * @return void
	 */
	public function run_actions( WC_Order $order, WP_REST_Request $request ) {
		$valid_actions = array_keys( $this->get_endpoint_args_for_actions() );

		foreach ( $valid_actions as $action ) {
			$callback = 'action_' . $action;
			$param    = $request->get_param( $action );
			if ( null !== $param && is_callable( array( $this, $callback ) ) ) {
				$result = call_user_func( array( $this, $callback ), $param, $order, $request );

				if ( is_wp_error( $result ) ) {
					throw new WC_REST_Exception( 'woocommerce_rest_invalid_action', esc_html( $result->get_error_message() ) );
				}
			}
		}
	}

	/**
	 * Regenerate the download permissions for the order.
	 *
	 * @param bool            $action_value The action value.
	 * @param WC_Order        $order The order object.
	 * @param WP_REST_Request $request The request object.
	 * @return bool
	 */
	private function action_reset_download_permissions( $action_value, WC_Order $order, WP_REST_Request $request ) {
		if ( ! $action_value ) {
			return false;
		}

		$data_store = \WC_Data_Store::load( 'customer-download' );

		if ( $data_store ) {
			$data_store->delete_by_order_id( $order->get_id() );
		}

		wc_downloadable_product_permissions( $order->get_id(), true );

		$user_agent = esc_html( $request->get_header( 'User-Agent' ) );
		$order->add_order_note(
			esc_html__( 'Download permissions were reset manually.', 'woocommerce' ),
			false,
			true,
			array(
				'user_agent' => $user_agent ? $user_agent : 'REST API',
				'note_title' => __( 'Download permissions', 'woocommerce' ),
				'note_group' => OrderNoteGroup::ORDER_UPDATE,
			)
		);

		return true;
	}

	/**
	 * Mark the order as paid.
	 *
	 * @param bool            $action_value The action value.
	 * @param WC_Order        $order The order object.
	 * @param WP_REST_Request $request The request object.
	 * @return true|WP_Error
	 */
	private function action_payment_complete( $action_value, WC_Order $order, WP_REST_Request $request ) {
		if ( $action_value ) {
			$result = $order->payment_complete( $request['transaction_id'] ?? '' );

			if ( ! $result ) {
				return new WP_Error( 'woocommerce_rest_payment_complete_failed', __( 'Could not mark the order as paid.', 'woocommerce' ) );
			}
		}
		return true;
	}
}
PK     [1]5aB  aB  '  RestApi/Routes/V4/Orders/Controller.phpnu         <?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
 * REST API Orders controller
 *
 * Handles route registration, permissions, CRUD operations, and schema definition.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractController;
use Automattic\WooCommerce\StoreApi\Utilities\Pagination;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema\OrderSchema;
use WP_Http;
use WP_Error;
use WC_Order;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

/**
 * Orders Controller.
 */
class Controller extends AbstractController {
	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'orders';

	/**
	 * Post type used for orders.
	 *
	 * @var string
	 */
	protected $post_type = 'shop_order';

	/**
	 * Schema class for this route.
	 *
	 * @var OrderSchema
	 */
	protected $item_schema;

	/**
	 * Query utils class.
	 *
	 * @var QueryUtils
	 */
	protected $query_utils;

	/**
	 * Update utils class.
	 *
	 * @var UpdateUtils
	 */
	protected $update_utils;

	/**
	 * Action controller class.
	 *
	 * @var ActionController
	 */
	protected $action_controller;

	/**
	 * Initialize the controller.
	 *
	 * @param OrderSchema      $item_schema Order schema class.
	 * @param CollectionQuery  $query_utils Query utils class.
	 * @param UpdateUtils      $update_utils Update utils class.
	 * @param ActionController $action_controller Action controller class.
	 * @internal
	 */
	final public function init( OrderSchema $item_schema, CollectionQuery $query_utils, UpdateUtils $update_utils, ActionController $action_controller ) {
		$this->item_schema       = $item_schema;
		$this->collection_query  = $query_utils;
		$this->update_utils      = $update_utils;
		$this->action_controller = $action_controller;
	}

	/**
	 * Get the schema for the current resource. This use consumed by the AbstractController to generate the item schema
	 * after running various hooks on the response.
	 */
	protected function get_schema(): array {
		return $this->item_schema->get_item_schema();
	}

	/**
	 * Get the collection args schema.
	 *
	 * @return array
	 */
	protected function get_query_schema(): array {
		return $this->collection_query->get_query_schema();
	}

	/**
	 * List of args for endpoints. These may alter how data is returned or formatted. Extended by routes.
	 *
	 * @return array
	 */
	protected function get_endpoint_args(): array {
		return array(
			'num_decimals' => array(
				'default'           => wc_get_price_decimals(),
				'description'       => __( 'Number of decimal points to use in each resource.', 'woocommerce' ),
				'type'              => 'integer',
				'sanitize_callback' => 'absint',
				'validate_callback' => 'rest_validate_request_arg',
			),
		);
	}

	/**
	 * Register the routes for orders.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				'schema' => array( $this, 'get_public_item_schema' ),
				'args'   => $this->get_endpoint_args(),
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => $this->get_collection_params(),
				),
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'create_item' ),
					'permission_callback' => array( $this, 'create_item_permissions_check' ),
					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
				),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[\d]+)',
			array(
				'schema' => array( $this, 'get_public_item_schema' ),
				'args'   => array_merge(
					$this->get_endpoint_args(),
					array(
						'id' => array(
							'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
							'type'        => 'integer',
						),
					),
				),
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_item' ),
					'permission_callback' => array( $this, 'get_item_permissions_check' ),
					'args'                => array(
						'context' => $this->get_context_param( array( 'default' => 'view' ) ),
					),
				),
				array(
					'methods'             => WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'update_item' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
					'args'                => array_merge(
						$this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
						$this->action_controller->get_endpoint_args_for_actions(),
					),
				),
				array(
					'methods'             => WP_REST_Server::DELETABLE,
					'callback'            => array( $this, 'delete_item' ),
					'permission_callback' => array( $this, 'delete_item_permissions_check' ),
					'args'                => array(
						'force' => array(
							'default'     => false,
							'type'        => 'boolean',
							'description' => __( 'Whether to bypass trash and force deletion.', 'woocommerce' ),
						),
					),
				),
			)
		);
	}

	/**
	 * Prepare links for the request.
	 *
	 * @param mixed            $item WordPress representation of the item.
	 * @param WP_REST_Request  $request Request object.
	 * @param WP_REST_Response $response Response object.
	 * @return array
	 */
	protected function prepare_links( $item, WP_REST_Request $request, WP_REST_Response $response ): array {
		$links = array(
			'self'            => array(
				'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $item->get_id() ) ),
			),
			'collection'      => array(
				'href' => rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ),
			),
			'email-templates' => array(
				'href'       => rest_url( sprintf( '/wc/v3/%s/%d/actions/email_templates', $this->rest_base, $item->get_id() ) ),
				'embeddable' => true,
			),
			'order-notes'     => array(
				'href'       => add_query_arg(
					array( 'order_id' => (int) $item->get_id() ),
					rest_url( sprintf( '/%s/order-notes', $this->namespace ) )
				),
				'embeddable' => true,
			),
			'refunds'         => array(
				'href'       => add_query_arg(
					array( 'order_id' => (int) $item->get_id() ),
					rest_url( sprintf( '/%s/refunds', $this->namespace ) )
				),
				'embeddable' => true,
			),
		);

		if ( $item->get_payment_method() ) {
			$links['payment_gateway'] = array(
				'href'       => rest_url( sprintf( '/%s/settings/payment-gateways/%s', $this->namespace, rawurlencode( $item->get_payment_method() ) ) ),
				'embeddable' => true,
			);
		}

		if ( $item->get_customer_id() ) {
			$links['customer'] = array(
				'href' => rest_url( sprintf( '/%s/customers/%d', $this->namespace, $item->get_customer_id() ) ),
			);
		}

		if ( $item->get_parent_id() ) {
			$links['up'] = array(
				'href' => rest_url( sprintf( '/%s/orders/%d', $this->namespace, $item->get_parent_id() ) ),
			);
		}

		return $links;
	}

	/**
	 * Prepare a single order object for response.
	 *
	 * @param WC_Order        $order Order object.
	 * @param WP_REST_Request $request Request object.
	 * @return array
	 */
	protected function get_item_response( $order, WP_REST_Request $request ): array {
		return $this->item_schema->get_item_response( $order, $request, $this->get_fields_for_response( $request ) );
	}

	/**
	 * Get a single item.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response
	 */
	public function get_item( $request ) {
		$order = wc_get_order( (int) $request['id'] );

		if ( ! $this->is_valid_order_for_request( $order ) ) {
			return $this->get_route_error_by_code( self::INVALID_ID );
		}

		return $this->prepare_item_for_response( $order, $request );
	}

	/**
	 * Get collection of orders.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response
	 */
	public function get_items( $request ) {
		/**
		 * Filter collection query args before executing the query.
		 *
		 * @param array           $query_args Query arguments for WC_Order_Query.
		 * @param WP_REST_Request $request    The REST request object.
		 * @param Controller      $controller The controller instance.
		 * @since 10.4.0
		 */
		$query_args = (array) apply_filters(
			$this->get_hook_prefix() . 'collection_query_args',
			$this->collection_query->get_query_args( $request ),
			$request,
			$this
		);
		$query_args = wp_parse_args(
			$query_args,
			array(
				'post_type' => $this->post_type,
			)
		);
		$results    = $this->collection_query->get_query_results( $query_args, $request );
		$items      = array();

		foreach ( $results['results'] as $result ) {
			$items[] = $this->prepare_response_for_collection( $this->prepare_item_for_response( $result, $request ) );
		}

		$pagination_util = new Pagination();
		$response        = $pagination_util->add_headers( rest_ensure_response( $items ), $request, $results['total'], $results['pages'] );

		return $response;
	}

	/**
	 * Create a single item.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response
	 */
	public function create_item( $request ) {
		if ( ! empty( $request['id'] ) ) {
			/* translators: %s: post type */
			return $this->get_route_error_by_code( self::RESOURCE_EXISTS );
		}

		try {
			$order = new WC_Order();
			$order->set_created_via( ! empty( $request['created_via'] ) ? sanitize_text_field( wp_unslash( $request['created_via'] ) ) : 'rest-api' );
			$order->set_prices_include_tax( 'yes' === get_option( 'woocommerce_prices_include_tax' ) );

			$this->update_utils->update_order_from_request( $order, $request );
			$this->update_additional_fields_for_object( $order, $request );

			/**
			 * Fires after a single object is created via the REST API.
			 *
			 * @param WC_Order         $order    Inserted object.
			 * @param WP_REST_Request $request   Request object.
			 * @since 10.2.0
			 */
			do_action( $this->get_hook_prefix() . 'created', $order, $request );

			$request->set_param( 'context', 'edit' );
			$response = $this->prepare_item_for_response( $order, $request );
			$response->set_status( WP_Http::CREATED );
			$response->header( 'Location', rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $order->get_id() ) ) );

			return $response;
		} catch ( \WC_Data_Exception $e ) {
			$data = $e->getErrorData();

			if ( $order && $order instanceof WC_Order && $order->get_id() ) {
				try {
					$order->set_status( 'checkout-draft' );
					$order->save();
					// phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
				} catch ( \Exception $_ ) {
					// We don't want a failure in changing the order status
					// to throw on itself, but we don't have anything meaningful
					// to do with this failure either.
				}
				$data['new_draft_order_id'] = $order->get_id();
			}

			return new WP_Error( $e->getErrorCode(), $e->getMessage(), $data );
		} catch ( \WC_REST_Exception $e ) {
			if ( $order && $order instanceof WC_Order && $order->get_id() ) {
				$order->delete( true );
			}
			return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
		}
	}

	/**
	 * Update a single item.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response
	 */
	public function update_item( $request ) {
		$order = wc_get_order( (int) $request['id'] );

		if ( ! $this->is_valid_order_for_request( $order ) ) {
			return $this->get_route_error_by_code( self::INVALID_ID );
		}

		try {
			$this->update_utils->update_order_from_request( $order, $request );
			$this->update_additional_fields_for_object( $order, $request );
			$this->action_controller->run_actions( $order, $request );

			/**
			 * Fires after a single object is updated via the REST API.
			 *
			 * @param WC_Data         $order    Inserted object.
			 * @param WP_REST_Request $request   Request object.
			 * @param boolean         $creating  True when creating object, false when updating.
			 * @since 10.2.0
			 */
			do_action( $this->get_hook_prefix() . 'updated', $order, $request );

			$request->set_param( 'context', 'edit' );
			return $this->prepare_item_for_response( $order, $request );
		} catch ( \WC_Data_Exception $e ) {
			return new WP_Error( $e->getErrorCode(), $e->getMessage(), $e->getErrorData() );
		} catch ( \WC_REST_Exception $e ) {
			return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
		}
	}

	/**
	 * Delete a single item.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function delete_item( $request ) {
		$order = wc_get_order( (int) $request['id'] );

		if ( ! $this->is_valid_order_for_request( $order ) ) {
			return $this->get_route_error_by_code( self::INVALID_ID );
		}

		$request->set_param( 'context', 'edit' );
		$force = (bool) $request['force'];

		if ( $force ) {
			$result   = $order->delete( true );
			$response = new WP_REST_Response( null, 204 );
		} else {
			$response = $this->prepare_item_for_response( $order, $request );

			/**
			 * Filter whether an object is trashable.
			 *
			 * @param boolean $supports_trash Whether the object type support trashing.
			 * @param WC_Order $order         The object being considered for trashing support.
			 * @since 10.2.0
			 */
			$supports_trash = apply_filters( $this->get_hook_prefix() . 'object_trashable', EMPTY_TRASH_DAYS > 0, $order );

			if ( ! $supports_trash ) {
				return $this->get_route_error_by_code( self::TRASH_NOT_SUPPORTED );
			}

			if ( 'trash' === $order->get_status() ) {
				return $this->get_route_error_by_code( self::CANNOT_TRASH );
			}

			$order->delete();
			$result = 'trash' === $order->get_status();
		}

		if ( ! $result ) {
			return $this->get_route_error_by_code( self::CANNOT_DELETE );
		}

		/**
		 * Fires after a single object is deleted or trashed via the REST API.
		 *
		 * @param WC_Order         $order   The deleted or trashed object.
		 * @param WP_REST_Response $response The response data.
		 * @param WP_REST_Request  $request  The request sent to the API.
		 * @since 10.2.0
		 */
		do_action( $this->get_hook_prefix() . 'deleted', $order, $response, $request );

		return $response;
	}

	/**
	 * Check if an order is valid.
	 *
	 * @param WC_Order $order The order object.
	 * @return bool True if the order is valid, false otherwise.
	 */
	protected function is_valid_order_for_request( $order ): bool {
		return $order instanceof WC_Order && $order->get_id() !== 0 && 'shop_order_refund' !== $order->get_type();
	}

	/**
	 * Check if a given request has access to read items.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function get_items_permissions_check( $request ) {
		if ( ! wc_rest_check_post_permissions( $this->post_type, 'read' ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );
		}
		return true;
	}

	/**
	 * Check if a given request has access to read an item.
	 *
	 * @param  WP_REST_Request $request The request object.
	 * @return WP_Error|boolean
	 */
	public function get_item_permissions_check( $request ) {
		if ( ! wc_rest_check_post_permissions( $this->post_type, 'read', $request['id'] ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );
		}
		return true;
	}

	/**
	 * Check if a given request has access to create an item.
	 *
	 * @param  WP_REST_Request $request The request object.
	 * @return WP_Error|boolean
	 */
	public function create_item_permissions_check( $request ) {
		if ( ! wc_rest_check_post_permissions( $this->post_type, 'create' ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );
		}
		return true;
	}

	/**
	 * Check if a given request has access to update an item.
	 *
	 * @param  WP_REST_Request $request The request object.
	 * @return WP_Error|boolean
	 */
	public function update_item_permissions_check( $request ) {
		if ( ! wc_rest_check_post_permissions( $this->post_type, 'edit', $request['id'] ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );
		}
		return true;
	}

	/**
	 * Check if a given request has access to delete an item.
	 *
	 * @param  WP_REST_Request $request The request object.
	 * @return bool|WP_Error
	 */
	public function delete_item_permissions_check( $request ) {
		if ( ! wc_rest_check_post_permissions( $this->post_type, 'delete', $request['id'] ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );
		}
		return true;
	}
}
PK     [1]og)0  )0  ,  RestApi/Routes/V4/Orders/CollectionQuery.phpnu         <?php
/**
 * CollectionQuery class.
 *
 * @package WooCommerce\RestApi
 * @internal This file is for internal use only and should not be used by external code.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders;

defined( 'ABSPATH' ) || exit;

use WP_REST_Request;
use WP_Http;
use WP_Error;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractCollectionQuery;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Utilities\OrderUtil;
use WC_Order_Query;
use Automattic\WooCommerce\Internal\Fulfillments\FulfillmentUtils;

/**
 * CollectionQuery class.
 *
 * @internal This class is for internal use only and should not be used by external code.
 */
class CollectionQuery extends AbstractCollectionQuery {
	/**
	 * Get query schema.
	 *
	 * @return array
	 */
	public function get_query_schema(): array {
		return array(
			'page'               => array(
				'description'       => __( 'Current page of the collection.', 'woocommerce' ),
				'type'              => 'integer',
				'default'           => 1,
				'sanitize_callback' => 'absint',
				'validate_callback' => 'rest_validate_request_arg',
				'minimum'           => 1,
			),
			'per_page'           => array(
				'description'       => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ),
				'type'              => 'integer',
				'default'           => 10,
				'minimum'           => 1,
				'maximum'           => 100,
				'sanitize_callback' => 'absint',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'order'              => array(
				'description'       => __( 'Order sort attribute ascending or descending.', 'woocommerce' ),
				'type'              => 'string',
				'default'           => 'desc',
				'enum'              => array( 'asc', 'desc' ),
				'validate_callback' => 'rest_validate_request_arg',
			),
			'orderby'            => array(
				'description'       => __( 'Sort collection by object attribute.', 'woocommerce' ),
				'type'              => 'string',
				'default'           => 'date',
				'enum'              => array(
					'date',
					'id',
					'include',
					'title',
					'slug',
					'modified',
					'total',
				),
				'validate_callback' => 'rest_validate_request_arg',
			),
			'created_via'        => array(
				'description'       => __( 'Limit result set to orders created via specific sources (e.g. checkout, admin).', 'woocommerce' ),
				'type'              => 'array',
				'items'             => array(
					'type' => 'string',
				),
				'validate_callback' => 'rest_validate_request_arg',
				'sanitize_callback' => 'wp_parse_list',
			),
			'customer'           => array(
				'description'       => __( 'Limit result set to orders assigned a specific customer.', 'woocommerce' ),
				'type'              => array( 'string', 'integer' ),
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'product'            => array(
				'description'       => __( 'Limit result set to orders assigned a specific product.', 'woocommerce' ),
				'type'              => 'integer',
				'sanitize_callback' => 'absint',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'status'             => array(
				'default'           => 'any',
				'description'       => __( 'Limit result set to orders which have specific statuses.', 'woocommerce' ),
				'type'              => 'array',
				'items'             => array(
					'type' => 'string',
					'enum' => array_map( OrderUtil::class . '::remove_status_prefix', array_merge( array( 'any', OrderStatus::TRASH ), array_keys( wc_get_order_statuses() ) ) ),
				),
				'validate_callback' => 'rest_validate_request_arg',
			),
			'search'             => array(
				'description'       => __( 'Limit results to those matching a string.', 'woocommerce' ),
				'type'              => 'string',
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'after'              => array(
				'description'       => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce' ),
				'type'              => 'string',
				'format'            => 'date-time',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'before'             => array(
				'description'       => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce' ),
				'type'              => 'string',
				'format'            => 'date-time',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'modified_after'     => array(
				'description'       => __( 'Limit response to resources modified after a given ISO8601 compliant date.', 'woocommerce' ),
				'type'              => 'string',
				'format'            => 'date-time',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'modified_before'    => array(
				'description'       => __( 'Limit response to resources modified before a given ISO8601 compliant date.', 'woocommerce' ),
				'type'              => 'string',
				'format'            => 'date-time',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'dates_are_gmt'      => array(
				'description'       => __( 'Whether to consider GMT post dates when limiting response by published or modified date.', 'woocommerce' ),
				'type'              => 'boolean',
				'default'           => false,
				'validate_callback' => 'rest_validate_request_arg',
			),
			'total'              => array(
				'description'       => __( 'Limit result set to orders with specific total amounts. For between operators, list two values.', 'woocommerce' ),
				'type'              => array( 'string', 'array' ),
				'items'             => array(
					'type' => 'string',
				),
				'sanitize_callback' => 'wp_parse_list',
			),
			'total_operator'     => array(
				'description'       => __( 'The comparison operator to use for total filtering.', 'woocommerce' ),
				'type'              => 'string',
				'enum'              => self::OPERATORS,
				'default'           => self::OPERATOR_IS,
				'validate_callback' => function ( $param, $request, $key ) {
					$valid = rest_validate_request_arg( $param, $request, $key );

					if ( true === $valid && self::OPERATOR_BETWEEN === $param ) {
						$total_field = wp_parse_list( $request->get_param( 'total' ) );

						if ( ! is_array( $total_field ) || count( $total_field ) !== 2 ) {
							return new WP_Error( 'rest_invalid_param', __( 'Total value must be an array with exactly 2 numbers for between operators.', 'woocommerce' ), array( 'status' => WP_Http::BAD_REQUEST ) );
						}
					}

					return $valid;
				},
			),
			'fulfillment_status' => array(
				'description'       => __( 'Limit result set to orders with specific fulfillment statuses.', 'woocommerce' ),
				'type'              => 'array',
				'items'             => array(
					'type' => 'string',
					'enum' => array_keys( FulfillmentUtils::get_order_fulfillment_statuses() ),
				),
				'sanitize_callback' => 'wp_parse_list',
				'validate_callback' => 'rest_validate_request_arg',
			),
		);
	}

	/**
	 * Prepares query args.
	 *
	 * @param WP_REST_Request $request The request object.
	 * @return array
	 */
	public function get_query_args( WP_REST_Request $request ): array {
		$args = array(
			'order'          => $request['order'],
			'orderby'        => $request['orderby'],
			'page'           => $request['page'],
			'posts_per_page' => $request['per_page'],
			's'              => $request['search'],
			'created_via'    => $request['created_via'],
			'status'         => $request['status'],
			'customer'       => $request['customer'],
		);

		if ( 'date' === $args['orderby'] ) {
			$args['orderby'] = 'date ID';
		}

		$date_query = array();
		$use_gmt    = $request['dates_are_gmt'];

		if ( isset( $request['before'] ) ) {
			$date_query[] = array(
				'column' => $use_gmt ? 'post_date_gmt' : 'post_date',
				'before' => $request['before'],
			);
		}

		if ( isset( $request['after'] ) ) {
			$date_query[] = array(
				'column' => $use_gmt ? 'post_date_gmt' : 'post_date',
				'after'  => $request['after'],
			);
		}

		if ( isset( $request['modified_before'] ) ) {
			$date_query[] = array(
				'column' => $use_gmt ? 'post_modified_gmt' : 'post_modified',
				'before' => $request['modified_before'],
			);
		}

		if ( isset( $request['modified_after'] ) ) {
			$date_query[] = array(
				'column' => $use_gmt ? 'post_modified_gmt' : 'post_modified',
				'after'  => $request['modified_after'],
			);
		}

		if ( ! empty( $date_query ) ) {
			$date_query['relation'] = 'AND';
			$args['date_query']     = $date_query;
		}

		// Search by product.
		if ( ! empty( $request['product'] ) ) {
			global $wpdb;

			$order_ids = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT order_id FROM %i WHERE order_item_id IN ( SELECT order_item_id FROM %i WHERE meta_key = '_product_id' AND meta_value = %d ) AND order_item_type = 'line_item'",
					$wpdb->prefix . 'woocommerce_order_items',
					$wpdb->prefix . 'woocommerce_order_itemmeta',
					$request['product']
				)
			);

			// Force WP_Query to return an empty array of IDs (0) if no matches are found. This forces no results.
			if ( empty( $order_ids ) ) {
				$order_ids = array( 0 );
			} else {
				$include_ids      = $args['post__in'] ?? array();
				$order_ids        = ! empty( $include_ids ) ? array_intersect( $order_ids, $include_ids ) : $order_ids;
				$args['post__in'] = array_merge( $order_ids, array( 0 ) );
			}
		}

		// Search.
		if ( ! OrderUtil::custom_orders_table_usage_is_enabled() && ! empty( $args['s'] ) ) {
			$order_ids = wc_order_search( $args['s'] );

			if ( ! empty( $order_ids ) ) {
				unset( $args['s'] );

				$include_ids      = $args['post__in'] ?? array();
				$order_ids        = ! empty( $include_ids ) ? array_intersect( $order_ids, $include_ids ) : $order_ids;
				$args['post__in'] = array_merge( $order_ids, array( 0 ) );
			}
		}

		// Total filtering.
		if ( isset( $request['total'] ) ) {
			// WC_Order-Query uses `total` as the key. DataStores handle the operators.
			$total_param    = (array) $request['total']; // List of total values supports single and between.
			$total_value    = $total_param[0] ?? 0;
			$total_operator = '=';

			// Map rest api operators to the operators `WC_Order_Query` expects. These are the ones defined in the enum.
			switch ( $request['total_operator'] ?? self::OPERATOR_IS ) {
				case self::OPERATOR_IS_NOT:
					$total_operator = '!=';
					break;
				case self::OPERATOR_LESS_THAN:
					$total_operator = '<';
					break;
				case self::OPERATOR_GREATER_THAN:
					$total_operator = '>';
					break;
				case self::OPERATOR_LESS_THAN_OR_EQUAL:
					$total_operator = '<=';
					break;
				case self::OPERATOR_GREATER_THAN_OR_EQUAL:
					$total_operator = '>=';
					break;
				case self::OPERATOR_BETWEEN:
					$total_operator = 'BETWEEN';
					$total_value    = array( $total_param[0] ?? 0, $total_param[1] ?? 0 );
					break;
			}

			$args['total'] = array(
				'value'    => $total_value,
				'operator' => $total_operator,
			);
		}

		// Order fulfillment status filtering.
		if ( isset( $request['fulfillment_status'] ) ) {
			$request['fulfillment_status'] = is_array( $request['fulfillment_status'] ) ? $request['fulfillment_status'] : array( $request['fulfillment_status'] );
			$fulfillment_status            = array();

			foreach ( $request['fulfillment_status'] as $status ) {
				if ( FulfillmentUtils::is_valid_order_fulfillment_status( $status ) ) {
					$fulfillment_status[] = $status;
				}
			}

			$args['fulfillment_status'] = $fulfillment_status;
		}

		return $args;
	}

	/**
	 * Get results of the query.
	 *
	 * @param array           $query_args The query arguments from prepare_query().
	 * @param WP_REST_Request $request The request object.
	 * @return array
	 */
	public function get_query_results( array $query_args, WP_REST_Request $request ): array {
		$query   = new WC_Order_Query(
			array_merge(
				$query_args,
				array(
					'paginate' => true,
				)
			)
		);
		$results = $query->get_orders();

		return array(
			'results' => $results->orders,
			'total'   => $results->total,
			'pages'   => $results->max_num_pages,
		);
	}
}
PK     [1]T.E  .E  (  RestApi/Routes/V4/Orders/UpdateUtils.phpnu         <?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
 * Handles order data updates from the request.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema\OrderSchema;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Automattic\WooCommerce\Utilities\StringUtil;
use Automattic\WooCommerce\Internal\Utilities\Users;
use WC_REST_Exception;
use WC_Order;
use WP_REST_Request;
use WP_Http;
use WC_Order_Item_Product;
use WC_Order_Item_Shipping;
use WC_Order_Item_Fee;
use WC_Order_Item_Coupon;

/**
 * UpdateUtils class.
 */
class UpdateUtils {
	use CogsAwareTrait;

	/**
	 * The order schema.
	 *
	 * @var OrderSchema
	 */
	private $order_schema;

	/**
	 * Initialize the update utils.
	 *
	 * @internal
	 * @param OrderSchema $order_schema The order schema.
	 */
	final public function init( OrderSchema $order_schema ) {
		$this->order_schema = $order_schema;
	}

	/**
	 * Update an order from the request.
	 *
	 * @throws WC_REST_Exception When fails to set any item, \WC_Data_Exception When fails to set any item.
	 * @param WC_Order        $order Order object.
	 * @param WP_REST_Request $request Request object.
	 * @return void
	 */
	public function update_order_from_request( WC_Order $order, WP_REST_Request $request ) {
		// Get data that can be edited from schema.
		$ignore_keys = array( 'created_via', 'status', 'customer_id' );
		$data_keys   = array_diff( array_keys( $this->order_schema->get_writable_item_schema_properties() ), $ignore_keys );

		// Make sure gateways are loaded so hooks from gateways fire on save/create.
		WC()->payment_gateways();

		// Handle all writable props.
		foreach ( $data_keys as $key ) {
			$value = $request[ $key ];

			if ( is_null( $value ) ) {
				continue;
			}

			if ( 'billing' === $key || 'shipping' === $key ) {
				$this->update_address( $order, $key, (array) $value );
			} elseif ( 'coupon_lines' === $key ) {
				$this->update_line_items( $order, (array) $value, 'coupon' );
			} elseif ( 'line_items' === $key ) {
				$this->update_line_items( $order, (array) $value, 'line_item' );
			} elseif ( 'shipping_lines' === $key ) {
				$this->update_line_items( $order, (array) $value, 'shipping' );
			} elseif ( 'fee_lines' === $key ) {
				$this->update_line_items( $order, (array) $value, 'fee' );
			} elseif ( 'meta_data' === $key ) {
				$this->update_meta_data( $order, (array) $value );
			} elseif ( is_callable( array( $order, "set_{$key}" ) ) ) {
				$order->{"set_{$key}"}( $value );
			}
		}

		if ( ! is_null( $request['customer_id'] ) && 0 !== $request['customer_id'] ) {
			// The customer must exist, and in a multisite context must be visible to the current user.
			if ( is_wp_error( Users::get_user_in_current_site( $request['customer_id'] ) ) ) {
				throw new WC_REST_Exception( 'woocommerce_rest_invalid_customer_id', esc_html__( 'Customer ID is invalid.', 'woocommerce' ), (int) WP_Http::BAD_REQUEST );
			}

			// Make sure customer is part of blog.
			if ( is_multisite() && ! is_user_member_of_blog( $request['customer_id'] ) ) {
				add_user_to_blog( get_current_blog_id(), $request['customer_id'], 'customer' );
			}

			$order->set_customer_id( (int) $request['customer_id'] );
		}

		// Save before calculating totals to ensure all line items are up to date.
		$order->save();

		// If items have changed, recalculate order totals.
		if ( isset( $request['billing'] ) || isset( $request['shipping'] ) || isset( $request['line_items'] ) || isset( $request['shipping_lines'] ) || isset( $request['fee_lines'] ) ) {
			$order->calculate_totals( true );
		}

		if ( isset( $request['coupon_lines'] ) ) {
			$order->recalculate_coupons();
		}

		if ( ! empty( $request['status'] ) ) {
			$order->set_status( $request['status'], '', true );
			$order->save();
		}
	}


	/**
	 * Update address.
	 *
	 * @param WC_Order $order  Order data.
	 * @param string   $type   Type of address; 'billing' or 'shipping'.
	 * @param array    $request_data Posted data.
	 */
	protected function update_address( WC_Order $order, string $type, array $request_data ) {
		foreach ( $request_data as $key => $value ) {
			if ( is_callable( array( $order, "set_{$type}_{$key}" ) ) ) {
				$order->{"set_{$type}_{$key}"}( $value );
			}
		}
	}

	/**
	 * Update meta data.
	 *
	 * @param WC_Order $order  Order data.
	 * @param array    $meta_data Posted data.
	 */
	protected function update_meta_data( WC_Order $order, array $meta_data ) {
		foreach ( $meta_data as $meta ) {
			$order->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
		}
	}

	/**
	 * Update line items from an array of line item data for an order. Non-posted line items are removed.
	 *
	 * @throws WC_REST_Exception If line items type is invalid.
	 * @param WC_Order $order The order to update the line items for.
	 * @param array    $line_items The line items to update.
	 * @param string   $line_items_type The type of line items to update.
	 */
	protected function update_line_items( WC_Order $order, array $line_items, string $line_items_type = 'line_item' ) {
		if ( ! in_array( $line_items_type, array( 'line_item', 'shipping', 'fee', 'coupon' ), true ) ) {
			throw new WC_REST_Exception( 'woocommerce_rest_invalid_line_items_type', esc_html__( 'Invalid line items type.', 'woocommerce' ), 400 );
		}

		// Get existing items from the order. Any items that are not in the $line_items array will be removed.
		$existing_items     = $order->get_items( $line_items_type );
		$processed_item_ids = array();
		foreach ( $line_items as $line_item_data ) {
			if ( ! is_array( $line_item_data ) ) {
				continue;
			}
			if ( $this->item_is_null_or_zero( $line_item_data ) ) {
				if ( $line_item_data['id'] ) {
					$this->remove_item_from_order( $order, $line_items_type, (int) $line_item_data['id'] );
				}
				continue;
			}
			$processed_item_ids[] = $this->update_line_item( $order, $line_items_type, $line_item_data );
		}

		// Remove any pre-existing items that were not posted.
		foreach ( $existing_items as $existing_item ) {
			if ( ! in_array( $existing_item->get_id(), $processed_item_ids, true ) ) {
				$this->remove_item_from_order( $order, $line_items_type, $existing_item->get_id() );
			}
		}
	}

	/**
	 * Wrapper method to create/update order items.
	 * When updating, the item ID provided is checked to ensure it is associated with the order.
	 *
	 * @throws WC_REST_Exception If item ID is not associated with order.
	 * @param WC_Order $order order object.
	 * @param string   $line_items_type The item type.
	 * @param array    $line_item_data item provided in the request body.
	 * @return int The ID of the updated or created item.
	 */
	protected function update_line_item( WC_Order $order, string $line_items_type, array $line_item_data ) {
		global $wpdb;

		$action = empty( $line_item_data['id'] ) ? 'create' : 'update';
		$method = 'prepare_' . $line_items_type . '_data';
		$item   = null;

		// Verify provided line item ID is associated with order.
		if ( 'update' === $action ) {
			$item = $order->get_item( absint( $line_item_data['id'] ), false );

			if ( ! $item ) {
				throw new WC_REST_Exception( 'woocommerce_rest_invalid_item_id', esc_html__( 'Order item ID provided is not associated with order.', 'woocommerce' ), 400 );
			}
		}

		// Prepare item data.
		$item = $this->$method( $line_item_data, $action, $item );

		/**
		 * Allow extensions be notified before the item is saved.
		 *
		 * @param WC_Order_Item $item The item object.
		 * @param array         $request_data The item data.
		 *
		 * @since 4.5.0.
		 */
		do_action( 'woocommerce_rest_set_order_item', $item, $line_item_data );

		// If creating the order, add the item to it.
		if ( 'create' === $action ) {
			$order->add_item( $item );
		} else {
			$item->save();
		}

		// Maybe update product stock quantity.
		if ( 'line_item' === $line_items_type && in_array( $order->get_status(), array( OrderStatus::PROCESSING, OrderStatus::COMPLETED, OrderStatus::ON_HOLD ), true ) ) {
			require_once WC_ABSPATH . 'includes/admin/wc-admin-functions.php';
			$changed_stock = wc_maybe_adjust_line_item_product_stock( $item );
			if ( $changed_stock && ! is_wp_error( $changed_stock ) ) {
				$order->add_order_note(
					sprintf(
						// translators: %s item name.
						__( 'Adjusted stock: %s.', 'woocommerce' ),
						sprintf(
							'%1$s (%2$s&rarr;%3$s)',
							$item->get_name(),
							$changed_stock['from'],
							$changed_stock['to']
						)
					),
					false,
					true,
					array(
						'note_group' => OrderNoteGroup::PRODUCT_STOCK,
					)
				);
			}
		}

		return $item->get_id();
	}

	/**
	 * Helper method to check if the resource ID associated with the provided item is null.
	 * Items can be deleted by setting the resource ID to null.
	 *
	 * @param array $item Item provided in the request body.
	 * @return bool True if the item resource ID is null, false otherwise.
	 */
	protected function item_is_null_or_zero( $item ) {
		$keys = array( 'product_id', 'method_id', 'method_title', 'name', 'code' );

		foreach ( $keys as $key ) {
			if ( array_key_exists( $key, $item ) && is_null( $item[ $key ] ) ) {
				return true;
			}
		}

		if ( array_key_exists( 'quantity', $item ) && 0 === $item['quantity'] ) {
			return true;
		}

		return false;
	}

	/**
	 * Wrapper method to remove order items.
	 * When updating, the item ID provided is checked to ensure it is associated with the order.
	 *
	 * @param WC_Order $order     The order to remove the item from.
	 * @param string   $line_items_type The item type.
	 * @param int      $item_id   The ID of the item to remove.
	 *
	 * @return void
	 * @throws WC_REST_Exception If item ID is not associated with order.
	 */
	protected function remove_item_from_order( WC_Order $order, string $line_items_type, int $item_id ): void {
		$item = $order->get_item( $item_id );

		if ( ! $item ) {
			throw new WC_REST_Exception(
				'woocommerce_rest_invalid_item_id',
				esc_html__( 'Order item ID provided is not associated with order.', 'woocommerce' ),
				400
			);
		}

		if ( 'line_item' === $line_items_type ) {
			require_once WC_ABSPATH . 'includes/admin/wc-admin-functions.php';
			wc_maybe_adjust_line_item_product_stock( $item, 0 );
		}

		/**
		 * Allow extensions be notified before the item is removed.
		 *
		 * @param WC_Order_Item $item The item object.
		 *
		 * @since 9.3.0.
		 */
		do_action( 'woocommerce_rest_remove_order_item', $item );

		$order->remove_item( $item_id );
	}

	/**
	 * Gets the product ID from the SKU or posted ID.
	 *
	 * @throws WC_REST_Exception When SKU or ID is not valid.
	 * @param array  $request_data Request data.
	 * @param string $action 'create' to add line item or 'update' to update it.
	 * @return int
	 */
	protected function get_product_id_from_line_item( $request_data, $action = 'create' ) {
		if ( ! empty( $request_data['sku'] ) ) {
			$product_id = (int) wc_get_product_id_by_sku( $request_data['sku'] );
		} elseif ( ! empty( $request_data['product_id'] ) && empty( $request_data['variation_id'] ) ) {
			$product_id = (int) $request_data['product_id'];
		} elseif ( ! empty( $request_data['variation_id'] ) ) {
			$product_id = (int) $request_data['variation_id'];
		} elseif ( 'update' === $action ) {
			$product_id = 0;
		} else {
			throw new WC_REST_Exception( 'woocommerce_rest_required_product_reference', esc_html__( 'Product ID or SKU is required.', 'woocommerce' ), 400 );
		}
		return $product_id;
	}

	/**
	 * Create or update a line item, overridden to add COGS data as needed.
	 *
	 * @param array  $request_data Line item data.
	 * @param string $action 'create' to add line item or 'update' to update it.
	 * @param object $item Passed when updating an item. Null during creation.
	 * @return WC_Order_Item_Product
	 * @throws WC_REST_Exception Invalid data, server error.
	 */
	protected function prepare_line_item_data( $request_data, $action = 'create', $item = null ) {
		$item    = is_null( $item ) ? new WC_Order_Item_Product( ! empty( $request_data['id'] ) ? $request_data['id'] : '' ) : $item;
		$product = wc_get_product( $this->get_product_id_from_line_item( $request_data, $action ) );

		if ( $product && $product !== $item->get_product() ) {
			$item->set_product( $product );

			if ( 'create' === $action ) {
				$quantity = isset( $request_data['quantity'] ) ? $request_data['quantity'] : 1;
				$total    = wc_get_price_excluding_tax( $product, array( 'qty' => $quantity ) );
				$item->set_total( $total );
				$item->set_subtotal( $total );
			}
		}

		$this->maybe_set_item_props( $item, array( 'name', 'quantity', 'total', 'subtotal', 'tax_class' ), $request_data );
		$this->maybe_set_item_meta_data( $item, $request_data );

		if ( ! $item->has_cogs() || ! $this->cogs_is_enabled() ) {
			return $item;
		}

		$cogs_value = $request_data['cost_of_goods_sold']['total_value'] ?? null;
		if ( ! is_null( $cogs_value ) ) {
			$item->set_cogs_value( (float) $cogs_value );
		}

		return $item;
	}

	/**
	 * Create or update an order shipping method.
	 *
	 * @param array  $request_data $shipping Item data.
	 * @param string $action 'create' to add shipping or 'update' to update it.
	 * @param object $item Passed when updating an item. Null during creation.
	 * @return WC_Order_Item_Shipping
	 * @throws WC_REST_Exception Invalid data, server error.
	 */
	protected function prepare_shipping_data( $request_data, $action = 'create', $item = null ) {
		$item = is_null( $item ) ? new WC_Order_Item_Shipping( ! empty( $request_data['id'] ) ? $request_data['id'] : '' ) : $item;

		if ( 'create' === $action && empty( $request_data['method_id'] ) ) {
			throw new WC_REST_Exception( 'woocommerce_rest_invalid_shipping_item', esc_html__( 'Shipping method ID is required.', 'woocommerce' ), 400 );
		}

		$this->maybe_set_item_props( $item, array( 'method_id', 'method_title', 'total', 'instance_id' ), $request_data );
		$this->maybe_set_item_meta_data( $item, $request_data );

		return $item;
	}

	/**
	 * Create or update an order fee.
	 *
	 * @param array  $request_data Item data.
	 * @param string $action 'create' to add fee or 'update' to update it.
	 * @param object $item Passed when updating an item. Null during creation.
	 * @return WC_Order_Item_Fee
	 * @throws WC_REST_Exception Invalid data, server error.
	 */
	protected function prepare_fee_data( $request_data, $action = 'create', $item = null ) {
		$item = is_null( $item ) ? new WC_Order_Item_Fee( ! empty( $request_data['id'] ) ? $request_data['id'] : '' ) : $item;

		if ( 'create' === $action && empty( $request_data['name'] ) ) {
			throw new WC_REST_Exception( 'woocommerce_rest_invalid_fee_item', esc_html__( 'Fee name is required.', 'woocommerce' ), 400 );
		}

		$this->maybe_set_item_props( $item, array( 'name', 'tax_class', 'tax_status', 'total' ), $request_data );
		$this->maybe_set_item_meta_data( $item, $request_data );

		return $item;
	}

	/**
	 * Create or update an order coupon.
	 *
	 * @param array  $request_data Item data.
	 * @param string $action 'create' to add coupon or 'update' to update it.
	 * @param object $item Passed when updating an item. Null during creation.
	 * @return WC_Order_Item_Coupon
	 * @throws WC_REST_Exception Invalid data, server error.
	 */
	protected function prepare_coupon_data( $request_data, $action = 'create', $item = null ) {
		$item = is_null( $item ) ? new WC_Order_Item_Coupon( ! empty( $request_data['id'] ) ? $request_data['id'] : '' ) : $item;

		if ( 'create' === $action ) {
			$coupon_code = ArrayUtil::get_value_or_default( $request_data, 'code' );
			if ( StringUtil::is_null_or_whitespace( $coupon_code ) ) {
				throw new WC_REST_Exception( 'woocommerce_rest_invalid_coupon_coupon', esc_html__( 'Coupon code is required.', 'woocommerce' ), 400 );
			}
		}

		$this->maybe_set_item_props( $item, array( 'code', 'discount' ), $request_data );
		$this->maybe_set_item_meta_data( $item, $request_data );

		return $item;
	}

	/**
	 * Maybe set an item prop if the value was posted.
	 *
	 * @param WC_Order_Item $item   Order item.
	 * @param string        $prop   Order property.
	 * @param array         $request_data Request data.
	 */
	protected function maybe_set_item_prop( $item, $prop, $request_data ) {
		if ( isset( $request_data[ $prop ] ) && is_callable( array( $item, "set_$prop" ) ) ) {
			$item->{"set_$prop"}( $request_data[ $prop ] );
		}
	}

	/**
	 * Maybe set item props if the values were posted.
	 *
	 * @param WC_Order_Item $item   Order item data.
	 * @param string[]      $props  Properties.
	 * @param array         $request_data Request data.
	 */
	protected function maybe_set_item_props( $item, $props, $request_data ) {
		foreach ( $props as $prop ) {
			$this->maybe_set_item_prop( $item, $prop, $request_data );
		}
	}

	/**
	 * Maybe set item meta if posted.
	 *
	 * @param WC_Order_Item $item   Order item data.
	 * @param array         $request_data Request data.
	 */
	protected function maybe_set_item_meta_data( $item, $request_data ) {
		if ( ! empty( $request_data['meta_data'] ) && is_array( $request_data['meta_data'] ) ) {
			foreach ( $request_data['meta_data'] as $meta ) {
				if ( isset( $meta['key'] ) ) {
					$value = isset( $meta['value'] ) ? $meta['value'] : null;
					$item->update_meta_data( $meta['key'], $value, isset( $meta['id'] ) ? $meta['id'] : '' );
				}
			}
		}
	}
}
PK     [1]e  e  :  RestApi/Routes/V4/Orders/Schema/AbstractLineItemSchema.phpnu         <?php
/**
 * AbstractLineItemSchema class.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractSchema;
use WC_Order_Item;
use WP_REST_Request;

/**
 * AbstractLineItemSchema class.
 */
abstract class AbstractLineItemSchema extends AbstractSchema {
	/**
	 * Get the meta data schema shared by all line item schemas.
	 *
	 * @return array
	 */
	protected function get_meta_data_schema(): array {
		return array(
			'description' => __( 'Meta data.', 'woocommerce' ),
			'type'        => 'array',
			'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			'items'       => array(
				'type'       => 'object',
				'properties' => array(
					'id'            => array(
						'description' => __( 'Meta ID.', 'woocommerce' ),
						'type'        => 'integer',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
						'readonly'    => true,
					),
					'key'           => array(
						'description' => __( 'Meta key.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
					'value'         => array(
						'description' => __( 'Meta value.', 'woocommerce' ),
						'type'        => array( 'null', 'object', 'string', 'number', 'boolean', 'integer', 'array' ),
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
					'display_key'   => array(
						'description' => __( 'Meta key for UI display.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
					'display_value' => array(
						'description' => __( 'Meta value for UI display.', 'woocommerce' ),
						'type'        => array( 'null', 'object', 'string', 'number', 'boolean', 'integer', 'array' ),
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
				),
			),
		);
	}

	/**
	 * Prepare the meta data for the order item.
	 *
	 * @param WC_Order_Item $order_item Order item instance.
	 * @return array
	 */
	protected function prepare_meta_data( $order_item ) {
		$formatted_meta_data = $order_item->get_all_formatted_meta_data( null );
		$return              = array();

		foreach ( $formatted_meta_data as $meta_id => $meta ) {
			$return[] = array(
				'id'            => $meta_id,
				'key'           => $meta->key,
				'value'         => $meta->value,
				'display_key'   => wc_clean( $meta->display_key ),
				'display_value' => wc_clean( $meta->display_value ),
			);
		}

		return $return;
	}

	/**
	 * Get the taxes schema shared by line item schemas.
	 *
	 * @return array
	 */
	protected function get_taxes_schema(): array {
		return array(
			'description' => __( 'Line taxes.', 'woocommerce' ),
			'type'        => 'array',
			'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			'readonly'    => true,
			'items'       => array(
				'type'       => 'object',
				'properties' => array(
					'id'       => array(
						'description' => __( 'Tax rate ID.', 'woocommerce' ),
						'type'        => 'integer',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
						'readonly'    => true,
					),
					'total'    => array(
						'description' => __( 'Tax total.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
						'readonly'    => true,
					),
					'subtotal' => array(
						'description' => __( 'Tax subtotal.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
						'readonly'    => true,
					),
				),
			),
		);
	}

	/**
	 * Prepare the taxes for the order item.
	 *
	 * @param WC_Order_Item_Product|WC_Order_Item_Fee $order_item Order item instance.
	 * @param WP_REST_Request                         $request Request object.
	 * @return array
	 */
	protected function prepare_taxes( $order_item, WP_REST_Request $request ) {
		$taxes  = $order_item->get_taxes();
		$dp     = is_null( $request['num_decimals'] ) ? wc_get_price_decimals() : absint( $request['num_decimals'] );
		$return = array();

		if ( $taxes && ! empty( $taxes['total'] ) ) {
			foreach ( $taxes['total'] as $tax_rate_id => $tax ) {
				$return[] = array(
					'id'       => $tax_rate_id,
					'total'    => wc_format_decimal( $tax, $dp ),
					'subtotal' => wc_format_decimal( $taxes['subtotal'][ $tax_rate_id ] ?? $tax, $dp ),
				);
			}
		}

		return $return;
	}
}
PK     [1]>Cn  n  /  RestApi/Routes/V4/Orders/Schema/OrderSchema.phpnu         <?php
/**
 * OrderSchema class.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractSchema;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
use Automattic\WooCommerce\Utilities\OrderUtil;
use WC_Order;
use WP_REST_Request;
use Automattic\WooCommerce\Internal\Fulfillments\FulfillmentUtils;

/**
 * OrderSchema class.
 */
class OrderSchema extends AbstractSchema {
	use CogsAwareTrait;

	/**
	 * The schema item identifier.
	 *
	 * @var string
	 */
	const IDENTIFIER = 'order';

	/**
	 * The order item schema.
	 *
	 * @var OrderItemSchema
	 */
	private $order_item_schema;

	/**
	 * The order coupon schema.
	 *
	 * @var OrderCouponSchema
	 */
	private $order_coupon_schema;

	/**
	 * The order fee schema.
	 *
	 * @var OrderFeeSchema
	 */
	private $order_fee_schema;

	/**
	 * The order tax schema.
	 *
	 * @var OrderTaxSchema
	 */
	private $order_tax_schema;

	/**
	 * The order shipping schema.
	 *
	 * @var OrderShippingSchema
	 */
	private $order_shipping_schema;

	/**
	 * Initialize the schema.
	 *
	 * @internal
	 * @param OrderItemSchema     $order_item_schema The order item schema.
	 * @param OrderCouponSchema   $order_coupon_schema The order coupon schema.
	 * @param OrderFeeSchema      $order_fee_schema The order fee schema.
	 * @param OrderTaxSchema      $order_tax_schema The order tax schema.
	 * @param OrderShippingSchema $order_shipping_schema The order shipping schema.
	 */
	final public function init( OrderItemSchema $order_item_schema, OrderCouponSchema $order_coupon_schema, OrderFeeSchema $order_fee_schema, OrderTaxSchema $order_tax_schema, OrderShippingSchema $order_shipping_schema ) {
		$this->order_item_schema     = $order_item_schema;
		$this->order_coupon_schema   = $order_coupon_schema;
		$this->order_fee_schema      = $order_fee_schema;
		$this->order_tax_schema      = $order_tax_schema;
		$this->order_shipping_schema = $order_shipping_schema;
	}

	/**
	 * Return all properties for the item schema.
	 *
	 * Note that context determines under which context data should be visible. For example, edit would be the context
	 * used when getting records with the intent of editing them. embed context allows the data to be visible when the
	 * item is being embedded in another response.
	 *
	 * @return array
	 */
	public function get_item_schema_properties(): array {
		$schema = array(
			'id'                   => array(
				'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'parent_id'            => array(
				'description' => __( 'Parent order ID.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'number'               => array(
				'description' => __( 'Order number.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'order_key'            => array(
				'description' => __( 'Order key.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'created_via'          => array(
				'description' => __( 'Shows where the order was created.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'version'              => array(
				'description' => __( 'Version of WooCommerce which last updated the order.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'status'               => array(
				'description' => __( 'Order status.', 'woocommerce' ),
				'type'        => 'string',
				'default'     => OrderStatus::PENDING,
				'enum'        => array_map( OrderUtil::class . '::remove_status_prefix', array_merge( array( OrderStatus::AUTO_DRAFT ), array_keys( wc_get_order_statuses() ) ) ),
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'currency'             => array(
				'description' => __( 'Currency the order was created with, in ISO format.', 'woocommerce' ),
				'type'        => 'string',
				'default'     => get_woocommerce_currency(),
				'enum'        => array_keys( get_woocommerce_currencies() ),
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'currency_symbol'      => array(
				'description' => __( 'Currency symbol for the currency which can be used to format returned prices.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'date_created'         => array(
				'description' => __( "The date the order was created, in the site's timezone.", 'woocommerce' ),
				'type'        => 'string',
				'format'      => 'date-time',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'date_created_gmt'     => array(
				'description' => __( 'The date the order was created, as GMT.', 'woocommerce' ),
				'type'        => 'string',
				'format'      => 'date-time',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'date_modified'        => array(
				'description' => __( "The date the order was last modified, in the site's timezone.", 'woocommerce' ),
				'type'        => 'string',
				'format'      => 'date-time',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'date_modified_gmt'    => array(
				'description' => __( 'The date the order was last modified, as GMT.', 'woocommerce' ),
				'type'        => 'string',
				'format'      => 'date-time',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'discount_total'       => array(
				'description' => __( 'Total discount amount for the order.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'discount_tax'         => array(
				'description' => __( 'Total discount tax amount for the order.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'shipping_total'       => array(
				'description' => __( 'Total shipping amount for the order.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'shipping_tax'         => array(
				'description' => __( 'Total shipping tax amount for the order.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'cart_tax'             => array(
				'description' => __( 'Sum of line item taxes only.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'total'                => array(
				'description' => __( 'Grand total.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'total_tax'            => array(
				'description' => __( 'Sum of all taxes.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'refund_total'         => array(
				'description' => __( 'Total refund amount for the order.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'refund_tax'           => array(
				'description' => __( 'Total refund tax amount for the order.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'prices_include_tax'   => array(
				'description' => __( 'True the prices included tax during checkout.', 'woocommerce' ),
				'type'        => 'boolean',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'customer_id'          => array(
				'description' => __( 'User ID who owns the order. 0 for guests.', 'woocommerce' ),
				'type'        => 'integer',
				'default'     => 0,
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'customer_ip_address'  => array(
				'description' => __( "Customer's IP address.", 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'customer_user_agent'  => array(
				'description' => __( 'User agent of the customer.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'customer_note'        => array(
				'description' => __( 'Note left by customer during checkout.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'billing'              => array(
				'description' => __( 'Billing address.', 'woocommerce' ),
				'type'        => 'object',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'properties'  => array(
					'first_name' => array(
						'description' => __( 'First name.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
					'last_name'  => array(
						'description' => __( 'Last name.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
					'company'    => array(
						'description' => __( 'Company name.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
					'address_1'  => array(
						'description' => __( 'Address line 1', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
					'address_2'  => array(
						'description' => __( 'Address line 2', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
					'city'       => array(
						'description' => __( 'City name.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
					'state'      => array(
						'description' => __( 'ISO code or name of the state, province or district.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
					'postcode'   => array(
						'description' => __( 'Postal code.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
					'country'    => array(
						'description' => __( 'Country code in ISO 3166-1 alpha-2 format.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
					'email'      => array(
						'description' => __( 'Email address.', 'woocommerce' ),
						'type'        => array( 'string', 'null' ),
						'format'      => 'email',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
					'phone'      => array(
						'description' => __( 'Phone number.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
				),
			),
			'shipping'             => array(
				'description' => __( 'Shipping address.', 'woocommerce' ),
				'type'        => 'object',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'properties'  => array(
					'first_name' => array(
						'description' => __( 'First name.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
					'last_name'  => array(
						'description' => __( 'Last name.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
					'company'    => array(
						'description' => __( 'Company name.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
					'address_1'  => array(
						'description' => __( 'Address line 1', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
					'address_2'  => array(
						'description' => __( 'Address line 2', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
					'city'       => array(
						'description' => __( 'City name.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
					'state'      => array(
						'description' => __( 'ISO code or name of the state, province or district.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
					'postcode'   => array(
						'description' => __( 'Postal code.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
					'country'    => array(
						'description' => __( 'Country code in ISO 3166-1 alpha-2 format.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
					'phone'      => array(
						'description' => __( 'Phone number.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
					),
				),
			),
			'payment_method'       => array(
				'description' => __( 'Payment method ID.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'payment_method_title' => array(
				'description' => __( 'Payment method title.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'arg_options' => array(
					'sanitize_callback' => 'sanitize_text_field',
				),
			),
			'transaction_id'       => array(
				'description' => __( 'Unique transaction ID.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'date_paid'            => array(
				'description' => __( "The date the order was paid, in the site's timezone.", 'woocommerce' ),
				'type'        => 'string',
				'format'      => 'date-time',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'date_paid_gmt'        => array(
				'description' => __( 'The date the order was paid, as GMT.', 'woocommerce' ),
				'type'        => 'string',
				'format'      => 'date-time',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'date_completed'       => array(
				'description' => __( "The date the order was completed, in the site's timezone.", 'woocommerce' ),
				'type'        => 'string',
				'format'      => 'date-time',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'date_completed_gmt'   => array(
				'description' => __( 'The date the order was completed, as GMT.', 'woocommerce' ),
				'type'        => 'string',
				'format'      => 'date-time',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'cart_hash'            => array(
				'description' => __( 'MD5 hash of cart items to ensure orders are not modified.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'meta_data'            => array(
				'description' => __( 'Meta data.', 'woocommerce' ),
				'type'        => 'array',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'items'       => array(
					'type'       => 'object',
					'properties' => array(
						'id'    => array(
							'description' => __( 'Meta ID.', 'woocommerce' ),
							'type'        => 'integer',
							'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
							'readonly'    => true,
						),
						'key'   => array(
							'description' => __( 'Meta key.', 'woocommerce' ),
							'type'        => 'string',
							'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
						),
						'value' => array(
							'description' => __( 'Meta value.', 'woocommerce' ),
							'type'        => array( 'null', 'object', 'string', 'number', 'boolean', 'integer', 'array' ),
							'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
						),
					),
				),
			),
			'line_items'           => array(
				'description' => __( 'A list of line items (products) within this order.', 'woocommerce' ),
				'type'        => 'array',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'items'       => array(
					'type'       => 'object',
					'properties' => $this->order_item_schema->get_item_schema_properties(),
				),
			),
			'tax_lines'            => array(
				'description' => __( 'Tax lines data.', 'woocommerce' ),
				'type'        => 'array',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
				'items'       => array(
					'type'       => 'object',
					'properties' => $this->order_tax_schema->get_item_schema_properties(),
				),
			),
			'shipping_lines'       => array(
				'description' => __( 'Shipping lines data.', 'woocommerce' ),
				'type'        => 'array',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'items'       => array(
					'type'       => 'object',
					'properties' => $this->order_shipping_schema->get_item_schema_properties(),
				),
			),
			'fee_lines'            => array(
				'description' => __( 'Fee lines data.', 'woocommerce' ),
				'type'        => 'array',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'items'       => array(
					'type'       => 'object',
					'properties' => $this->order_fee_schema->get_item_schema_properties(),
				),
			),
			'coupon_lines'         => array(
				'description' => __( 'Coupons line data.', 'woocommerce' ),
				'type'        => 'array',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'items'       => array(
					'type'       => 'object',
					'properties' => $this->order_coupon_schema->get_item_schema_properties(),
				),
			),
			'payment_url'          => array(
				'description' => __( 'Order payment URL.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'is_editable'          => array(
				'description' => __( 'Whether an order can be edited.', 'woocommerce' ),
				'type'        => 'boolean',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'needs_payment'        => array(
				'description' => __( 'Whether an order needs payment, based on status and order total.', 'woocommerce' ),
				'type'        => 'boolean',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'needs_processing'     => array(
				'description' => __( 'Whether an order needs processing before it can be completed.', 'woocommerce' ),
				'type'        => 'boolean',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'fulfillment_status'   => array(
				'description' => __( 'The fulfillment status of the order.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
		);

		if ( $this->cogs_is_enabled() ) {
			$schema = $this->add_cogs_related_schema( $schema );
		}

		return $schema;
	}

	/**
	 * Add the Cost of Goods Sold related fields to the schema.
	 *
	 * @param array $schema The original schema.
	 * @return array The updated schema.
	 */
	private static function add_cogs_related_schema( array $schema ): array {
		$schema['cost_of_goods_sold'] = array(
			'description' => __( 'Cost of Goods Sold data.', 'woocommerce' ),
			'type'        => 'object',
			'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			'properties'  => array(
				'total_value' => array(
					'description' => __( 'Total value of the Cost of Goods Sold for the order.', 'woocommerce' ),
					'type'        => 'number',
					'readonly'    => true,
					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				),
			),
		);
		return $schema;
	}

	/**
	 * Get an item response.
	 *
	 * @param WC_Order        $order Order instance.
	 * @param WP_REST_Request $request Request object.
	 * @param array           $include_fields Fields to include in the response.
	 * @return array
	 */
	public function get_item_response( $order, WP_REST_Request $request, array $include_fields = array() ): array {
		$dp   = is_null( $request['num_decimals'] ) ? wc_get_price_decimals() : absint( $request['num_decimals'] );
		$data = array(
			'id'                   => $order->get_id(),
			'parent_id'            => $order->get_parent_id(),
			'number'               => $order->get_order_number(),
			'order_key'            => $order->get_order_key(),
			'created_via'          => $order->get_created_via(),
			'version'              => $order->get_version(),
			'status'               => OrderUtil::remove_status_prefix( $order->get_status() ),
			'currency'             => $order->get_currency(),
			'currency_symbol'      => html_entity_decode( get_woocommerce_currency_symbol( $order->get_currency() ), ENT_QUOTES ),
			'date_created'         => wc_rest_prepare_date_response( $order->get_date_created(), false ),
			'date_created_gmt'     => wc_rest_prepare_date_response( $order->get_date_created() ),
			'date_modified'        => wc_rest_prepare_date_response( $order->get_date_modified(), false ),
			'date_modified_gmt'    => wc_rest_prepare_date_response( $order->get_date_modified() ),
			'discount_total'       => wc_format_decimal( $order->get_discount_total(), $dp ),
			'discount_tax'         => wc_format_decimal( $order->get_discount_tax(), $dp ),
			'shipping_total'       => wc_format_decimal( $order->get_shipping_total(), $dp ),
			'shipping_tax'         => wc_format_decimal( $order->get_shipping_tax(), $dp ),
			'cart_tax'             => wc_format_decimal( $order->get_cart_tax(), $dp ),
			'total'                => wc_format_decimal( $order->get_total(), $dp ),
			'total_tax'            => wc_format_decimal( $order->get_total_tax(), $dp ),
			'prices_include_tax'   => $order->get_prices_include_tax(),
			'customer_id'          => $order->get_customer_id(),
			'customer_ip_address'  => $order->get_customer_ip_address(),
			'customer_user_agent'  => $order->get_customer_user_agent(),
			'customer_note'        => $order->get_customer_note(),
			'billing'              => array(
				'first_name' => $order->get_billing_first_name(),
				'last_name'  => $order->get_billing_last_name(),
				'company'    => $order->get_billing_company(),
				'address_1'  => $order->get_billing_address_1(),
				'address_2'  => $order->get_billing_address_2(),
				'city'       => $order->get_billing_city(),
				'state'      => $order->get_billing_state(),
				'postcode'   => $order->get_billing_postcode(),
				'country'    => $order->get_billing_country(),
				'email'      => $order->get_billing_email(),
				'phone'      => $order->get_billing_phone(),
			),
			'shipping'             => array(
				'first_name' => $order->get_shipping_first_name(),
				'last_name'  => $order->get_shipping_last_name(),
				'company'    => $order->get_shipping_company(),
				'address_1'  => $order->get_shipping_address_1(),
				'address_2'  => $order->get_shipping_address_2(),
				'city'       => $order->get_shipping_city(),
				'state'      => $order->get_shipping_state(),
				'postcode'   => $order->get_shipping_postcode(),
				'country'    => $order->get_shipping_country(),
				'phone'      => $order->get_shipping_phone(),
			),
			'payment_method'       => $order->get_payment_method(),
			'payment_method_title' => $order->get_payment_method_title(),
			'transaction_id'       => $order->get_transaction_id(),
			'date_paid'            => wc_rest_prepare_date_response( $order->get_date_paid(), false ),
			'date_paid_gmt'        => wc_rest_prepare_date_response( $order->get_date_paid() ),
			'date_completed'       => wc_rest_prepare_date_response( $order->get_date_completed(), false ),
			'date_completed_gmt'   => wc_rest_prepare_date_response( $order->get_date_completed() ),
			'cart_hash'            => $order->get_cart_hash(),
			'payment_url'          => $order->get_checkout_payment_url(),
			'is_editable'          => $order->is_editable(),
			'needs_payment'        => $order->needs_payment(),
			'needs_processing'     => $order->needs_processing(),
			'fulfillment_status'   => FulfillmentUtils::get_order_fulfillment_status( $order ),
		);

		if ( in_array( 'refund_total', $include_fields, true ) ) {
			$data['refund_total'] = wc_format_decimal( $order->get_total_refunded(), $dp );
		}

		if ( in_array( 'refund_tax', $include_fields, true ) ) {
			$data['refund_tax'] = wc_format_decimal( $order->get_total_tax_refunded(), $dp );
		}

		if ( in_array( 'line_items', $include_fields, true ) ) {
			$line_items         = $order->get_items( 'line_item' );
			$data['line_items'] = array();
			foreach ( $line_items as $line_item ) {
				$data['line_items'][] = $this->order_item_schema->get_item_response( $line_item, $request );
			}
		}

		if ( in_array( 'shipping_lines', $include_fields, true ) ) {
			$line_items             = $order->get_items( 'shipping' );
			$data['shipping_lines'] = array();
			foreach ( $line_items as $line_item ) {
				$data['shipping_lines'][] = $this->order_shipping_schema->get_item_response( $line_item, $request );
			}
		}

		if ( in_array( 'coupon_lines', $include_fields, true ) ) {
			$line_items           = $order->get_items( 'coupon' );
			$data['coupon_lines'] = array();
			foreach ( $line_items as $line_item ) {
				$data['coupon_lines'][] = $this->order_coupon_schema->get_item_response( $line_item, $request );
			}
		}

		if ( in_array( 'fee_lines', $include_fields, true ) ) {
			$line_items        = $order->get_items( 'fee' );
			$data['fee_lines'] = array();
			foreach ( $line_items as $line_item ) {
				$data['fee_lines'][] = $this->order_fee_schema->get_item_response( $line_item, $request );
			}
		}

		if ( in_array( 'tax_lines', $include_fields, true ) ) {
			$line_items        = $order->get_items( 'tax' );
			$data['tax_lines'] = array();
			foreach ( $line_items as $line_item ) {
				$data['tax_lines'][] = $this->order_tax_schema->get_item_response( $line_item, $request );
			}
		}

		if ( in_array( 'meta_data', $include_fields, true ) ) {
			$filtered_meta_data = $this->filter_internal_meta_keys( $order->get_meta_data() );
			$data['meta_data']  = array();
			foreach ( $filtered_meta_data as $meta_item ) {
				$data['meta_data'][] = array(
					'id'    => $meta_item->id,
					'key'   => $meta_item->key,
					'value' => $meta_item->value,
				);
			}
		}

		// Add COGS data.
		if ( $this->cogs_is_enabled() && in_array( 'cost_of_goods_sold', $include_fields, true ) ) {
			$data['cost_of_goods_sold']['total_value'] = $order->get_cogs_total_value();
		}

		$data = array_intersect_key( $data, array_flip( $include_fields ) );

		return $data;
	}

	/**
	 * With HPOS, few internal meta keys such as _billing_address_index, _shipping_address_index are not considered internal anymore (since most internal keys were flattened into dedicated columns).
	 *
	 * This function helps in filtering out any remaining internal meta keys with HPOS is enabled.
	 *
	 * @param array $meta_data Order meta data.
	 * @return array Filtered order meta data.
	 */
	protected function filter_internal_meta_keys( $meta_data ) {
		if ( ! OrderUtil::custom_orders_table_usage_is_enabled() ) {
			return $meta_data;
		}
		$cpt_hidden_keys = ( new \WC_Order_Data_Store_CPT() )->get_internal_meta_keys();
		$meta_data       = array_filter(
			$meta_data,
			function ( $meta ) use ( $cpt_hidden_keys ) {
				return ! in_array( $meta->key, $cpt_hidden_keys, true );
			}
		);
		return array_values( $meta_data );
	}
}
PK     [1]j^    2  RestApi/Routes/V4/Orders/Schema/OrderTaxSchema.phpnu         <?php
/**
 * OrderTaxSchema class.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema;

defined( 'ABSPATH' ) || exit;

use WC_Order_Item_Tax;
use WP_REST_Request;

/**
 * OrderFeeSchema class.
 */
class OrderTaxSchema extends AbstractLineItemSchema {
	/**
	 * The schema item identifier.
	 *
	 * @var string
	 */
	const IDENTIFIER = 'order-tax';

	/**
	 * Return all properties for the item schema.
	 *
	 * Note that context determines under which context data should be visible. For example, edit would be the context
	 * used when getting records with the intent of editing them. embed context allows the data to be visible when the
	 * item is being embedded in another response.
	 *
	 * @return array
	 */
	public function get_item_schema_properties(): array {
		$schema = array(
			'id'                 => array(
				'description' => __( 'Item ID.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'rate_code'          => array(
				'description' => __( 'Tax rate code.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'rate_id'            => array(
				'description' => __( 'Tax rate ID.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'label'              => array(
				'description' => __( 'Tax rate label.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'compound'           => array(
				'description' => __( 'Show if is a compound tax rate.', 'woocommerce' ),
				'type'        => 'boolean',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'tax_total'          => array(
				'description' => __( 'Tax total (not including shipping taxes).', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'shipping_tax_total' => array(
				'description' => __( 'Shipping tax total.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'meta_data'          => $this->get_meta_data_schema(),
		);

		return $schema;
	}

	/**
	 * Get an item response.
	 *
	 * @param WC_Order_Item_Tax $order_item Order item instance.
	 * @param WP_REST_Request   $request Request object.
	 * @param array             $include_fields Fields to include in the response.
	 * @return array
	 */
	public function get_item_response( $order_item, WP_REST_Request $request, array $include_fields = array() ): array {
		$dp   = is_null( $request['num_decimals'] ) ? wc_get_price_decimals() : absint( $request['num_decimals'] );
		$data = array(
			'id'                 => $order_item->get_id(),
			'rate_code'          => $order_item->get_rate_code(),
			'rate_id'            => $order_item->get_rate_id(),
			'label'              => $order_item->get_label(),
			'compound'           => $order_item->get_compound(),
			'tax_total'          => wc_format_decimal( $order_item->get_tax_total(), $dp ),
			'shipping_tax_total' => wc_format_decimal( $order_item->get_shipping_tax_total(), $dp ),
			'meta_data'          => $this->prepare_meta_data( $order_item ),
		);

		return $data;
	}
}
PK     [1]`'  `'  3  RestApi/Routes/V4/Orders/Schema/OrderItemSchema.phpnu         <?php
/**
 * OrderItemSchema class.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
use Automattic\WooCommerce\Utilities\OrderUtil;
use WC_Order_Item_Product;
use WP_REST_Request;
use WC_Product;

/**
 * OrderItemSchema class.
 */
class OrderItemSchema extends AbstractLineItemSchema {
	use CogsAwareTrait;

	/**
	 * The schema item identifier.
	 *
	 * @var string
	 */
	const IDENTIFIER = 'order-item';

	/**
	 * Return all properties for the item schema.
	 *
	 * Note that context determines under which context data should be visible. For example, edit would be the context
	 * used when getting records with the intent of editing them. embed context allows the data to be visible when the
	 * item is being embedded in another response.
	 *
	 * @return array
	 */
	public function get_item_schema_properties(): array {
		$schema = array(
			'id'              => array(
				'description' => __( 'Item ID.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'name'            => array(
				'description' => __( 'Item name.', 'woocommerce' ),
				'type'        => array( 'string', 'null' ),
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'image'           => array(
				'description' => __( 'Line item image, if available.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'product_id'      => array(
				'description' => __( 'Product or variation ID.', 'woocommerce' ),
				'type'        => array( 'integer', 'null' ),
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'product_data'    => array(
				'description' => __( 'Product data this item is linked to.', 'woocommerce' ),
				'type'        => array( 'object', 'null' ),
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'properties'  => $this->get_product_data_schema(),
			),
			'quantity'        => array(
				'description' => __( 'Quantity ordered.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'price'           => array(
				'description' => __( 'Item price. Calculated as total / quantity.', 'woocommerce' ),
				'type'        => 'number',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'tax_class'       => array(
				'description' => __( 'Tax class of product.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'subtotal'        => array(
				'description' => __( 'Line subtotal (before discounts).', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'subtotal_tax'    => array(
				'description' => __( 'Line subtotal tax (before discounts).', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'total'           => array(
				'description' => __( 'Line total (after discounts).', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'total_tax'       => array(
				'description' => __( 'Line total tax (after discounts).', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'taxes'           => $this->get_taxes_schema(),
			'meta_data'       => $this->get_meta_data_schema(),
			'currency'        => array(
				'description' => __( 'Currency the order item was created with, in ISO format.', 'woocommerce' ),
				'type'        => 'string',
				'default'     => get_woocommerce_currency(),
				'enum'        => array_keys( get_woocommerce_currencies() ),
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'currency_symbol' => array(
				'description' => __( 'Currency symbol for the currency which can be used to format returned prices.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
		);

		if ( $this->cogs_is_enabled() ) {
			$schema = $this->add_cogs_related_schema( $schema );
		}

		return $schema;
	}

	/**
	 * Add the Cost of Goods Sold related fields to the schema.
	 *
	 * @param array $schema The original schema.
	 * @return array The updated schema.
	 */
	private function add_cogs_related_schema( array $schema ): array {
		$schema['cost_of_goods_sold'] = array(
			'description' => __( 'Cost of Goods Sold data. Only present for product line items.', 'woocommerce' ),
			'type'        => 'object',
			'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			'properties'  => array(
				'total_value' => array(
					'description' => __( 'Value of the Cost of Goods Sold for the order item.', 'woocommerce' ),
					'type'        => 'number',
					'readonly'    => true,
					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				),
			),
		);
		return $schema;
	}

	/**
	 * Get an item response.
	 *
	 * @param WC_Order_Item_Product $order_item Order item instance.
	 * @param WP_REST_Request       $request Request object.
	 * @param array                 $include_fields Fields to include in the response.
	 * @return array
	 */
	public function get_item_response( $order_item, WP_REST_Request $request, array $include_fields = array() ): array {
		$dp              = is_null( $request['num_decimals'] ) ? wc_get_price_decimals() : absint( $request['num_decimals'] );
		$quantity_amount = (float) $order_item->get_quantity();
		$data            = array(
			'id'              => $order_item->get_id(),
			'name'            => $order_item->get_name(),
			'image'           => $this->get_image( $order_item ),
			'product_id'      => $order_item->get_variation_id() ? $order_item->get_variation_id() : $order_item->get_product_id(),
			'product_data'    => $this->get_product_data( $order_item ),
			'quantity'        => $order_item->get_quantity(),
			'price'           => $quantity_amount ? $order_item->get_total() / $quantity_amount : 0,
			'tax_class'       => $order_item->get_tax_class(),
			'subtotal'        => wc_format_decimal( $order_item->get_subtotal(), $dp ),
			'subtotal_tax'    => wc_format_decimal( $order_item->get_subtotal_tax(), $dp ),
			'total'           => wc_format_decimal( $order_item->get_total(), $dp ),
			'total_tax'       => wc_format_decimal( $order_item->get_total_tax(), $dp ),
			'taxes'           => $this->prepare_taxes( $order_item, $request ),
			'meta_data'       => $this->prepare_meta_data( $order_item ),
			'currency'        => $order_item->get_order()->get_currency(),
			'currency_symbol' => html_entity_decode( get_woocommerce_currency_symbol( $order_item->get_order()->get_currency() ), ENT_QUOTES ),
		);

		// Add COGS data.
		if ( self::cogs_is_enabled() ) {
			$data['cost_of_goods_sold']['total_value'] = isset( $data['cogs_value'] ) ? $data['cogs_value'] : 0;
			unset( $data['cogs_value'] );
		}

		return $data;
	}

	/**
	 * Get embedded product schema.
	 *
	 * @return array
	 */
	private function get_product_data_schema(): array {
		return array(
			'name'             => array(
				'description' => __( 'Product name.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'permalink'        => array(
				'description' => __( 'Product permalink.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'sku'              => array(
				'description' => __( 'Product SKU.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'global_unique_id' => array(
				'description' => __( 'Product global unique ID.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'type'             => array(
				'description' => __( 'Product type.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'is_virtual'       => array(
				'description' => __( 'Product is virtual.', 'woocommerce' ),
				'type'        => 'boolean',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'is_downloadable'  => array(
				'description' => __( 'Product is downloadable.', 'woocommerce' ),
				'type'        => 'boolean',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'needs_shipping'   => array(
				'description' => __( 'Product needs shipping.', 'woocommerce' ),
				'type'        => 'boolean',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
		);
	}

	/**
	 * Get product data.
	 *
	 * @param WC_Order_Item_Product $order_item Order item instance.
	 * @return array|null
	 */
	private function get_product_data( WC_Order_Item_Product $order_item ) {
		$product = $order_item->get_product();

		if ( ! $product instanceof \WC_Product ) {
			return null;
		}

		return array(
			'name'             => $product->get_name(),
			'permalink'        => $product->get_permalink(),
			'sku'              => $product->get_sku(),
			'global_unique_id' => $product->get_global_unique_id(),
			'type'             => $product->get_type(),
			'is_virtual'       => $product->is_virtual(),
			'is_downloadable'  => $product->is_downloadable(),
			'needs_shipping'   => $product->needs_shipping(),
		);
	}

	/**
	 * Get image.
	 *
	 * @param WC_Order_Item_Product $order_item Order item instance.
	 * @return string
	 */
	private function get_image( WC_Order_Item_Product $order_item ) {
		$product = $order_item->get_product();

		if ( ! $product instanceof \WC_Product ) {
			return '';
		}

		$image_id = $product->get_image_id() ? $product->get_image_id() : 0;
		return $image_id ? wp_get_attachment_image_url( $image_id, 'full' ) : '';
	}
}
PK     [1]u^  ^  2  RestApi/Routes/V4/Orders/Schema/OrderFeeSchema.phpnu         <?php
/**
 * OrderFeeSchema class.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema;

defined( 'ABSPATH' ) || exit;

use WC_Order_Item_Fee;
use WP_REST_Request;

/**
 * OrderFeeSchema class.
 */
class OrderFeeSchema extends AbstractLineItemSchema {
	/**
	 * The schema item identifier.
	 *
	 * @var string
	 */
	const IDENTIFIER = 'order-fee';

	/**
	 * Return all properties for the item schema.
	 *
	 * Note that context determines under which context data should be visible. For example, edit would be the context
	 * used when getting records with the intent of editing them. embed context allows the data to be visible when the
	 * item is being embedded in another response.
	 *
	 * @return array
	 */
	public function get_item_schema_properties(): array {
		$schema = array(
			'id'         => array(
				'description' => __( 'Item ID.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'name'       => array(
				'description' => __( 'Fee name.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'tax_class'  => array(
				'description' => __( 'Tax class of fee.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'tax_status' => array(
				'description' => __( 'Tax status of fee.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'enum'        => array( 'taxable', 'none' ),
			),
			'total'      => array(
				'description' => __( 'Line total (after discounts).', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'total_tax'  => array(
				'description' => __( 'Line total tax (after discounts).', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'taxes'      => $this->get_taxes_schema(),
			'meta_data'  => $this->get_meta_data_schema(),
		);

		return $schema;
	}

	/**
	 * Get an item response.
	 *
	 * @param WC_Order_Item_Fee $order_item Order item instance.
	 * @param WP_REST_Request   $request Request object.
	 * @param array             $include_fields Fields to include in the response.
	 * @return array
	 */
	public function get_item_response( $order_item, WP_REST_Request $request, array $include_fields = array() ): array {
		$dp   = is_null( $request['num_decimals'] ) ? wc_get_price_decimals() : absint( $request['num_decimals'] );
		$data = array(
			'id'         => $order_item->get_id(),
			'name'       => $order_item->get_name(),
			'tax_class'  => $order_item->get_tax_class(),
			'tax_status' => $order_item->get_tax_status(),
			'total'      => wc_format_decimal( $order_item->get_total(), $dp ),
			'total_tax'  => wc_format_decimal( $order_item->get_total_tax(), $dp ),
			'taxes'      => $this->prepare_taxes( $order_item, $request ),
			'meta_data'  => $this->prepare_meta_data( $order_item ),
		);

		return $data;
	}
}
PK     [1]N    7  RestApi/Routes/V4/Orders/Schema/OrderShippingSchema.phpnu         <?php
/**
 * OrderShippingSchema class.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema;

defined( 'ABSPATH' ) || exit;

use WC_Order_Item_Shipping;
use WP_REST_Request;

/**
 * OrderShippingSchema class.
 */
class OrderShippingSchema extends AbstractLineItemSchema {
	/**
	 * The schema item identifier.
	 *
	 * @var string
	 */
	const IDENTIFIER = 'order-shipping';

	/**
	 * Return all properties for the item schema.
	 *
	 * Note that context determines under which context data should be visible. For example, edit would be the context
	 * used when getting records with the intent of editing them. embed context allows the data to be visible when the
	 * item is being embedded in another response.
	 *
	 * @return array
	 */
	public function get_item_schema_properties(): array {
		$schema = array(
			'id'           => array(
				'description' => __( 'Item ID.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'method_title' => array(
				'description' => __( 'Shipping method name.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'method_id'    => array(
				'description' => __( 'Shipping method ID.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'instance_id'  => array(
				'description' => __( 'Shipping instance ID.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'total'        => array(
				'description' => __( 'Line total (after discounts).', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			),
			'total_tax'    => array(
				'description' => __( 'Line total tax (after discounts).', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'taxes'        => $this->get_taxes_schema(),
			'meta_data'    => $this->get_meta_data_schema(),
		);

		return $schema;
	}

	/**
	 * Get an item response.
	 *
	 * @param WC_Order_Item_Shipping $order_item Order item instance.
	 * @param WP_REST_Request        $request Request object.
	 * @param array                  $include_fields Fields to include in the response.
	 * @return array
	 */
	public function get_item_response( $order_item, WP_REST_Request $request, array $include_fields = array() ): array {
		$dp   = is_null( $request['num_decimals'] ) ? wc_get_price_decimals() : absint( $request['num_decimals'] );
		$data = array(
			'id'           => $order_item->get_id(),
			'method_title' => $order_item->get_method_title(),
			'method_id'    => $order_item->get_method_id(),
			'instance_id'  => $order_item->get_instance_id(),
			'total'        => wc_format_decimal( $order_item->get_total(), $dp ),
			'total_tax'    => wc_format_decimal( $order_item->get_total_tax(), $dp ),
			'taxes'        => $this->prepare_taxes( $order_item, $request ),
			'meta_data'    => $this->prepare_meta_data( $order_item ),
		);

		return $data;
	}
}
PK     [1]L%  %  5  RestApi/Routes/V4/Orders/Schema/OrderCouponSchema.phpnu         <?php
/**
 * OrderCouponSchema class.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema;

defined( 'ABSPATH' ) || exit;

use WC_Order_Item_Coupon;
use WP_REST_Request;
use WC_Coupon;

/**
 * OrderCouponSchema class.
 */
class OrderCouponSchema extends AbstractLineItemSchema {
	/**
	 * The schema item identifier.
	 *
	 * @var string
	 */
	const IDENTIFIER = 'order-coupon';

	/**
	 * Return all properties for the item schema.
	 *
	 * Note that context determines under which context data should be visible. For example, edit would be the context
	 * used when getting records with the intent of editing them. embed context allows the data to be visible when the
	 * item is being embedded in another response.
	 *
	 * @return array
	 */
	public function get_item_schema_properties(): array {
		$schema = array(
			'id'             => array(
				'description' => __( 'Item ID.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'code'           => array(
				'description' => __( 'Coupon code.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'discount'       => array(
				'description' => __( 'Discount total.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'discount_tax'   => array(
				'description' => __( 'Discount total tax.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'discount_type'  => array(
				'description' => __( 'Discount type.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => array( 'view' ),
				'readonly'    => true,
			),
			'nominal_amount' => array(
				'description' => __( 'Discount amount as defined in the coupon (absolute value or a percent, depending on the discount type).', 'woocommerce' ),
				'type'        => 'number',
				'context'     => array( 'view' ),
				'readonly'    => true,
			),
			'free_shipping'  => array(
				'description' => __( 'Whether the coupon grants free shipping or not.', 'woocommerce' ),
				'type'        => 'boolean',
				'context'     => array( 'view' ),
				'readonly'    => true,
			),
			'meta_data'      => $this->get_meta_data_schema(),
		);

		return $schema;
	}

	/**
	 * Get an item response.
	 *
	 * @param WC_Order_Item_Coupon $order_item Order item instance.
	 * @param WP_REST_Request      $request Request object.
	 * @param array                $include_fields Fields to include in the response.
	 * @return array
	 */
	public function get_item_response( $order_item, WP_REST_Request $request, array $include_fields = array() ): array {
		$dp          = is_null( $request['num_decimals'] ) ? wc_get_price_decimals() : absint( $request['num_decimals'] );
		$temp_coupon = new WC_Coupon();
		$coupon_info = $order_item->get_meta( 'coupon_info', true );
		if ( $coupon_info ) {
			$temp_coupon->set_short_info( $coupon_info );
		} else {
			$coupon_meta = $order_item->get_meta( 'coupon_data', true );
			if ( $coupon_meta ) {
				$temp_coupon->set_props( (array) $coupon_meta );
			}
		}

		$data = array(
			'id'             => $order_item->get_id(),
			'code'           => $order_item->get_code(),
			'discount'       => wc_format_decimal( $order_item->get_discount(), $dp ),
			'discount_tax'   => wc_format_decimal( $order_item->get_discount_tax(), $dp ),
			'discount_type'  => $temp_coupon->get_discount_type(),
			'nominal_amount' => (float) $temp_coupon->get_amount(),
			'free_shipping'  => $temp_coupon->get_free_shipping(),
			'meta_data'      => $this->prepare_meta_data( $order_item ),
		);

		return $data;
	}
}
PK     [1]	_	  	  $  RestApi/Routes/V4/AbstractSchema.phpnu         <?php
/**
 * Abstract REST Schema.
 *
 * Holds schema for REST API routes.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4;

defined( 'ABSPATH' ) || exit;

use WP_REST_Request;

/**
 * Abstract REST Schema for WooCommerce REST API V4.
 *
 * Provides common functionality for all V4 schema controllers including
 * property generation, context filtering, and validation.
 *
 * @since 10.2.0
 */
abstract class AbstractSchema {
	/**
	 * The schema item identifier.
	 *
	 * @var string
	 * @since 10.2.0
	 */
	const IDENTIFIER = '';

	/**
	 * Context for the item schema - view, edit, and embed.
	 *
	 * @var array
	 * @since 10.2.0
	 */
	const VIEW_EDIT_EMBED_CONTEXT = array( 'view', 'edit', 'embed' );

	/**
	 * Context for the item schema - view and edit only.
	 *
	 * @var array
	 * @since 10.2.0
	 */
	const VIEW_EDIT_CONTEXT = array( 'view', 'edit' );

	/**
	 * Get the item schema.
	 *
	 * @return array The item schema.
	 * @since 10.2.0
	 */
	public function get_item_schema(): array {
		return array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => static::IDENTIFIER,
			'type'       => 'object',
			'properties' => $this->get_item_schema_properties(),
		);
	}

	/**
	 * Get the item response.
	 *
	 * @param mixed           $item WordPress representation of the item.
	 * @param WP_REST_Request $request Request object.
	 * @param array           $include_fields Fields to include in the response.
	 * @return array The item response.
	 */
	abstract public function get_item_response( $item, WP_REST_Request $request, array $include_fields = array() ): array;

	/**
	 * Return all properties for the item schema.
	 *
	 * @return array The schema properties.
	 * @since 10.2.0
	 */
	public function get_item_schema_properties(): array {
		return array();
	}

	/**
	 * Return all writable properties for the item schema.
	 *
	 * @return array The schema properties.
	 * @since 10.2.0
	 */
	public function get_writable_item_schema_properties(): array {
		return array_filter( $this->get_item_schema_properties(), array( $this, 'filter_writable_props' ) );
	}

	/**
	 * Filter schema properties to only return writable ones.
	 *
	 * @param array $schema The schema property to check.
	 * @return bool True if the property is writable, false otherwise.
	 * @since 10.2.0
	 */
	protected function filter_writable_props( array $schema ): bool {
		return empty( $schema['readonly'] );
	}
}
PK     [1]\Q    7  RestApi/Routes/V4/ShippingZones/ShippingZoneService.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\ShippingZones;

use WC_Shipping_Zones;
use WC_Shipping_Zone;
use WP_Error;
use WP_Http;

/**
 * A service class to manage shipping zones their locations.
 */
class ShippingZoneService {
		/**
		 * Get all shipping zones sorted by order.
		 *
		 * @return array Array of shipping zones sorted by zone_order.
		 */
	public function get_sorted_shipping_zones() {
		$zones             = WC_Shipping_Zones::get_zones();
		$rest_of_the_world = WC_Shipping_Zones::get_zone_by( 'zone_id', 0 );

		$rest_data                            = $rest_of_the_world->get_data();
		$rest_data['zone_id']                 = $rest_of_the_world->get_id();
		$rest_data['formatted_zone_location'] = array();
		$rest_data['shipping_methods']        = $rest_of_the_world->get_shipping_methods( false, 'admin' );
		$zones[0]                             = $rest_data;

		uasort(
			$zones,
			function ( $a, $b ) {
				return $a['zone_order'] <=> $b['zone_order'];
			}
		);

		return $zones;
	}

	/**
	 * Create a new shipping zone.
	 *
	 * @param array $params {
	 *     Zone parameters.
	 *
	 *     @type string $name      Zone name.
	 *     @type int    $order     Zone order for sorting.
	 *     @type array  $locations Array of location objects with 'code' and 'type' keys.
	 * }
	 * @return WC_Shipping_Zone|WP_Error Zone object on success, WP_Error on failure.
	 */
	public function create_shipping_zone( $params ) {
		$zone   = new WC_Shipping_Zone( null );
		$result = $this->update_shipping_zone( $zone, $params );
		if ( is_wp_error( $result ) ) {
			return $result;
		}
		return $zone;
	}

	/**
	 * Update an existing shipping zone.
	 *
	 * @param WC_Shipping_Zone $zone   Zone object to update.
	 * @param array            $params {
	 *     Zone parameters to update. All parameters are optional.
	 *
	 *     @type string $name      Zone name. Cannot be changed for "Rest of the World" zone (ID 0).
	 *     @type int    $order     Zone order for sorting. Cannot be changed for "Rest of the World" zone.
	 *     @type array  $locations Array of location objects. Cannot be changed for "Rest of the World" zone.
	 *                             Each location should have 'code' (string) and 'type' (string) keys.
	 *                             Valid types: 'postcode', 'state', 'country', 'continent'.
	 * }
	 * @return WC_Shipping_Zone|WP_Error Updated zone object on success, WP_Error on failure.
	 */
	public function update_shipping_zone( $zone, $params ) {
		$params = wp_parse_args(
			$params,
			array(
				'name'      => null,
				'order'     => null,
				'locations' => null,
			)
		);

		$is_rest_of_world = 0 === $zone->get_id();

		if ( ! is_null( $params['name'] ) ) {
			if ( $is_rest_of_world ) {
				return new WP_Error(
					'woocommerce_rest_cannot_edit_zone',
					__( 'Cannot change name of "Rest of the World" zone.', 'woocommerce' ),
					array( 'status' => WP_Http::BAD_REQUEST )
				);
			}

			$name = trim( $params['name'] );
			if ( '' === $name ) {
				return new WP_Error(
					'woocommerce_rest_invalid_zone_name',
					__( 'Zone name cannot be empty.', 'woocommerce' ),
					array( 'status' => WP_Http::BAD_REQUEST )
				);
			}
			$zone->set_zone_name( $name );
		}

		if ( ! is_null( $params['order'] ) ) {
			if ( $is_rest_of_world ) {
				return new WP_Error(
					'woocommerce_rest_cannot_edit_zone',
					__( 'Cannot change order of "Rest of the World" zone.', 'woocommerce' ),
					array( 'status' => WP_Http::BAD_REQUEST )
				);
			}
			$zone->set_zone_order( $params['order'] );
		}

		$locations_being_cleared = false;
		if ( ! is_null( $params['locations'] ) ) {
			if ( $is_rest_of_world ) {
				return new WP_Error(
					'woocommerce_rest_cannot_edit_zone',
					__( 'Cannot change locations of "Rest of the World" zone.', 'woocommerce' ),
					array( 'status' => WP_Http::BAD_REQUEST )
				);
			}
			$raw_locations = $params['locations'];
			$locations     = array();

			foreach ( (array) $raw_locations as $raw_location ) {
				$locations_being_cleared = false;
				if ( empty( $raw_location['code'] ) ) {
					continue;
				}

				$type = ! empty( $raw_location['type'] ) ? $raw_location['type'] : 'country';

				// Normalize 'country:state' to 'state' for v4 API backward compatibility.
				if ( 'country:state' === $type ) {
					$type = 'state';
				}

				if ( ! $zone->is_valid_location_type( $type ) ) {
					continue;
				}

				$locations[] = array(
					'code' => $raw_location['code'],
					'type' => $type,
				);
			}

			$locations_being_cleared = empty( $locations );

			$zone->set_locations( $locations );
		}

		$zone->save();

		// WORKAROUND: WC_Data::apply_changes() uses array_replace_recursive() which doesn't
		// properly clear array properties when set to empty arrays. After save(), get_zone_locations()
		// returns stale cached data. Only reload when clearing locations to get accurate state.
		if ( $locations_being_cleared ) {
			$zone = WC_Shipping_Zones::get_zone( $zone->get_id() );
		}

		return $zone;
	}
}
PK     [1]W5E    6  RestApi/Routes/V4/ShippingZones/ShippingZoneSchema.phpnu         <?php
/**
 * ShippingZoneSchema class.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\ShippingZones;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractSchema;
use WC_Shipping_Zone;
use WP_REST_Request;

/**
 * ShippingZoneSchema class.
 */
class ShippingZoneSchema extends AbstractSchema {
	/**
	 * The schema item identifier.
	 *
	 * @var string
	 */
	const IDENTIFIER = 'shipping_zone';

	/**
	 * Return all properties for the item schema.
	 *
	 * @return array
	 */
	public function get_item_schema_properties(): array {
		$schema = array(
			'id'        => array(
				'description' => __( 'Unique identifier for the shipping zone.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
			'name'      => array(
				'description'       => __( 'Shipping zone name.', 'woocommerce' ),
				'type'              => 'string',
				'context'           => array( 'view', 'edit' ),
				'required'          => true,
				'sanitize_callback' => 'sanitize_text_field',
			),
			'order'     => array(
				'description' => __( 'Shipping zone order.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'default'     => 0,
			),
			'locations' => array(
				'description' => __( 'Array of locations for this zone. Can be empty array but must be explicitly provided.', 'woocommerce' ),
				'type'        => 'array',
				'context'     => array( 'view', 'edit' ),
				'required'    => true,
				'items'       => array(
					'type'       => 'object',
					'properties' => array(
						'code' => array(
							'description' => __( 'Shipping zone location code.', 'woocommerce' ),
							'type'        => 'string',
						),
						'type' => array(
							'description' => __( 'Shipping zone location type.', 'woocommerce' ),
							'type'        => 'string',
							'default'     => 'country',
						),
						'name' => array(
							'description' => __( 'Shipping zone location name (readonly, auto-generated from code).', 'woocommerce' ),
							'type'        => 'string',
							'readonly'    => true,
						),
					),
				),
			),
			'methods'   => array(
				'description' => __( 'Shipping methods for this zone.', 'woocommerce' ),
				'type'        => 'array',
				'readonly'    => true,
				'items'       => array(
					'type'       => 'object',
					'properties' => array(
						'instance_id' => array(
							'description' => __( 'Shipping method instance ID.', 'woocommerce' ),
							'type'        => 'integer',
						),
						'title'       => array(
							'description' => __( 'Shipping method title.', 'woocommerce' ),
							'type'        => 'string',
						),
						'enabled'     => array(
							'description' => __( 'Whether the shipping method is enabled.', 'woocommerce' ),
							'type'        => 'boolean',
						),
						'method_id'   => array(
							'description' => __( 'Shipping method ID (e.g., flat_rate, free_shipping).', 'woocommerce' ),
							'type'        => 'string',
						),
						'settings'    => array(
							'description' => __( 'Raw shipping method settings for frontend processing.', 'woocommerce' ),
							'type'        => 'object',
						),
					),
				),
			),
		);

		return $schema;
	}

	/**
	 * Get the item response.
	 *
	 * @param WC_Shipping_Zone $zone WordPress representation of the zone.
	 * @param WP_REST_Request  $request Request object.
	 * @param array            $include_fields Fields to include in the response.
	 * @return array The item response.
	 */
	public function get_item_response( $zone, WP_REST_Request $request, array $include_fields = array() ): array {
		return array(
			'id'        => $zone->get_id(),
			'name'      => $zone->get_zone_name(),
			'order'     => $zone->get_zone_order(),
			'locations' => $this->get_formatted_zone_locations( $zone ),
			'methods'   => $this->get_formatted_zone_methods( $zone ),
		);
	}

	/**
	 * Get array of location objects for API response.
	 *
	 * @param WC_Shipping_Zone $zone Shipping zone object.
	 * @return array Array of location objects with code, type, and name.
	 */
	protected function get_formatted_zone_locations( WC_Shipping_Zone $zone ): array {
		if ( 0 === $zone->get_id() ) {
			return array();
		}

		$locations           = $zone->get_zone_locations();
		$formatted_locations = array();

		foreach ( $locations as $location ) {
			$formatted_locations[] = array(
				'code' => isset( $location->code ) ? $location->code : '',
				'type' => isset( $location->type ) ? $location->type : 'country',
				'name' => $this->get_location_name( $location ),
			);
		}

		return $formatted_locations;
	}

	/**
	 * Get formatted methods for a zone.
	 *
	 * @param WC_Shipping_Zone $zone Shipping zone object.
	 * @return array
	 */
	protected function get_formatted_zone_methods( $zone ) {
		$methods           = $zone->get_shipping_methods( false, 'json' );
		$formatted_methods = array();

		foreach ( $methods as $method ) {
			$formatted_method = array(
				'instance_id' => $method->instance_id,
				'title'       => $method->title,
				'enabled'     => 'yes' === $method->enabled,
				'method_id'   => $method->id,
				'settings'    => $this->get_method_settings( $method ),
			);

			$formatted_methods[] = $formatted_method;
		}

		return $formatted_methods;
	}

	/**
	 * Get raw method settings for frontend processing.
	 *
	 * @param object $method Shipping method object.
	 * @return array
	 */
	protected function get_method_settings( $method ) {
		$settings = array();

		// Common settings that most methods have.
		$common_fields = array( 'cost', 'min_amount', 'requires', 'class_cost', 'no_class_cost' );

		foreach ( $common_fields as $field ) {
			if ( isset( $method->$field ) ) {
				$settings[ $field ] = $method->$field;
			}
		}

		// Return all available settings for maximum flexibility.
		if ( isset( $method->instance_settings ) && is_array( $method->instance_settings ) ) {
			$settings = array_merge( $settings, $method->instance_settings );
		}

		return $settings;
	}

	/**
	 * Get location name from location object.
	 *
	 * @param object $location Location object.
	 * @return string
	 */
	protected function get_location_name( $location ) {
		switch ( $location->type ) {
			case 'continent':
				$continents = WC()->countries->get_continents();
				return isset( $continents[ $location->code ] ) ? $continents[ $location->code ]['name'] : $location->code;

			case 'country':
				$countries = WC()->countries->get_countries();
				return isset( $countries[ $location->code ] ) ? $countries[ $location->code ] : $location->code;

			case 'state':
			case 'country:state':
				$parts = explode( ':', $location->code );
				if ( count( $parts ) === 2 ) {
					$states = WC()->countries->get_states( $parts[0] );
					return isset( $states[ $parts[1] ] ) ? $states[ $parts[1] ] : $location->code;
				}
				return $location->code;

			case 'postcode':
				return $location->code;

			default:
				return $location->code;
		}
	}
}
PK     [1])(Z%  Z%  .  RestApi/Routes/V4/ShippingZones/Controller.phpnu         <?php
/**
 * REST API Shipping Zones Controller
 *
 * Handles requests to the /shipping-zones endpoint.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\ShippingZones;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractController;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\ShippingZones\ShippingZoneService;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use WP_Http;
use WC_Shipping_Zone;
use WC_Shipping_Zones;

defined( 'ABSPATH' ) || exit;

/**
 * REST API Shipping Zones Controller Class.
 *
 * @extends AbstractController
 */
class Controller extends AbstractController {
	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'shipping-zones';

	/**
	 * Schema instance.
	 *
	 * @var ShippingZoneSchema
	 */
	protected $item_schema;

	/**
	 * Shipping service instance.
	 *
	 * @var ShippingZoneSchemaService
	 */
	protected $shipping_zone_service;

	/**
	 * Custom error constant for shipping-specific errors.
	 */
	const INVALID_ZONE_ID = 'invalid_zone_id';

	/**
	 * Initialize the controller.
	 *
	 * @param ShippingZoneSchema  $zone_schema           Order schema class.
	 * @param ShippingZoneService $shipping_zone_service Service for shipping zone operations.
	 * @internal
	 */
	final public function init( ShippingZoneSchema $zone_schema, ShippingZoneService $shipping_zone_service ) {
		$this->item_schema           = $zone_schema;
		$this->shipping_zone_service = $shipping_zone_service;
	}

	/**
	 * Get the schema for the current resource. This use consumed by the AbstractController to generate the item schema
	 * after running various hooks on the response.
	 */
	protected function get_schema(): array {
		return $this->item_schema->get_item_schema();
	}

	/**
	 * Register the routes for shipping zones.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => array( $this, 'check_permissions' ),
				),
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'create_item' ),
					'permission_callback' => array( $this, 'check_permissions' ),
					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[\d]+)',
			array(
				'schema' => array( $this, 'get_public_item_schema' ),
				'args'   => array(
					'id' => array(
						'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
						'type'        => 'integer',
					),
				),
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_item' ),
					'permission_callback' => array( $this, 'check_permissions' ),
				),
				array(
					'methods'             => WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'update_item' ),
					'permission_callback' => array( $this, 'check_permissions' ),
					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
				),
				array(
					'methods'             => WP_REST_Server::DELETABLE,
					'callback'            => array( $this, 'delete_item' ),
					'permission_callback' => array( $this, 'check_permissions' ),
					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::DELETABLE ),
				),
			)
		);
	}

	/**
	 * Get shipping zone by ID.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_item( $request ) {
		$zone_id = (int) $request['id'];

		$zone = WC_Shipping_Zones::get_zone_by( 'zone_id', $zone_id );

		if ( ! $zone ) {
			return $this->get_route_error_response(
				$this->get_error_prefix() . 'invalid_id',
				__( 'Invalid resource ID.', 'woocommerce' ),
				WP_Http::NOT_FOUND
			);
		}

		return rest_ensure_response( $this->prepare_item_for_response( $zone, $request ) );
	}

	/**
	 * Get all shipping zones.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_items( $request ) {
		$zones = $this->shipping_zone_service->get_sorted_shipping_zones();

		$items = array();
		foreach ( $zones as $zone_data ) {
			$zone_id = $zone_data['zone_id'];
			$zone    = WC_Shipping_Zones::get_zone( $zone_id );
			$items[] = $this->prepare_response_for_collection( $this->prepare_item_for_response( $zone, $request ) );
		}

		return rest_ensure_response( $items );
	}

	/**
	 * Prepare a single order object for response.
	 *
	 * @param WC_Shipping_Zone $zone Shipping zone object.
	 * @param WP_REST_Request  $request Request object.
	 * @return array
	 */
	protected function get_item_response( $zone, WP_REST_Request $request ): array {
		return $this->item_schema->get_item_response( $zone, $request, $this->get_fields_for_response( $request ) );
	}

	/**
	 * Check if a given request has permission to manage shipping zones.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return true|WP_Error True if the request has permission, WP_Error otherwise.
	 */
	public function check_permissions( $request ) {
		if ( ! wc_shipping_enabled() ) {
			return $this->get_route_error_response(
				$this->get_error_prefix() . 'disabled',
				__( 'Shipping is disabled.', 'woocommerce' ),
				WP_Http::SERVICE_UNAVAILABLE
			);
		}

		$method = $request->get_method();

		if ( 'GET' === $method ) {
			$context = 'read';
		} elseif ( 'DELETE' === $method ) {
			$context = 'delete';
		} else {
			$context = 'edit';
		}

		if ( ! wc_rest_check_manager_permissions( 'settings', $context ) ) {
			return $this->get_authentication_error_by_method( $method );
		}

		return true;
	}

	/**
	 * Create a new shipping zone.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response Response object or WP_Error.
	 */
	public function create_item( $request ) {
		$zone = $this->shipping_zone_service->create_shipping_zone( $request->get_params() );
		if ( is_wp_error( $zone ) ) {
			return $zone;
		}

		if ( 0 === $zone->get_id() ) {
			return $this->get_route_error_response(
				$this->get_error_prefix() . 'cannot_create',
				__( 'Resource cannot be created. Check for validation errors or server logs for details.', 'woocommerce' ),
				WP_Http::INTERNAL_SERVER_ERROR
			);
		}

		$response = rest_ensure_response( $this->prepare_item_for_response( $zone, $request ) );
		$response->set_status( 201 );
		$response->header( 'Location', rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $zone->get_id() ) ) );

		return $response;
	}

	/**
	 * Delete a shipping zone by zone id.
	 *
	 * Note: In v2/v3, this endpoint required a `force` parameter, but since shipping zones
	 * do not support trashing, it would either delete (force=true) or return a 501 error (force=false).
	 * We removed the `force` parameter in v4 as it serves no purpose when soft delete is not supported.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response Response object or WP_Error.
	 */
	public function delete_item( $request ) {
		$zone_id = (int) $request['id'];

		$zone = $this->validate_zone( $zone_id );
		if ( is_wp_error( $zone ) ) {
			return $zone;
		}

		$response = rest_ensure_response( $this->prepare_item_for_response( $zone, $request ) );

		WC_Shipping_Zones::delete_zone( $zone_id );

		return $response;
	}

	/**
	 * Get route error by code, including custom shipping zone errors.
	 *
	 * @param string $error_code Error code.
	 * @return WP_Error
	 */
	protected function get_route_error_by_code( string $error_code ): WP_Error {
		$custom_errors = array(
			self::INVALID_ZONE_ID => array(
				'message' => __( 'Invalid shipping zone ID.', 'woocommerce' ),
				'status'  => WP_Http::NOT_FOUND,
			),
		);

		if ( isset( $custom_errors[ $error_code ] ) ) {
			return $this->get_route_error_response(
				$this->get_error_prefix() . $error_code,
				$custom_errors[ $error_code ]['message'],
				$custom_errors[ $error_code ]['status']
			);
		}

		return parent::get_route_error_by_code( $error_code );
	}

	/**
	 * Validate that a shipping zone exists.
	 *
	 * @param int $zone_id Zone ID.
	 * @return WC_Shipping_Zone|WP_Error Zone object or error.
	 */
	protected function validate_zone( $zone_id ) {
		$zone = WC_Shipping_Zones::get_zone( $zone_id );

		if ( ! $zone || ( 0 !== $zone->get_id() && ! $zone->get_zone_name() ) ) {
			return $this->get_route_error_by_code( self::INVALID_ZONE_ID );
		}

		return $zone;
	}

	/**
	 * Update a shipping zone.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response Response object or WP_Error.
	 */
	public function update_item( $request ) {
		$zone_id = (int) $request['id'];

		$zone = $this->validate_zone( $zone_id );
		if ( is_wp_error( $zone ) ) {
			return $zone;
		}

		$result = $this->shipping_zone_service->update_shipping_zone( $zone, $request->get_params() );
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		return rest_ensure_response( $this->prepare_item_for_response( $result, $request ) );
	}
}
PK     [1]}(#  #  9  RestApi/Routes/V4/Settings/PaymentGateways/Controller.phpnu         <?php
/**
 * REST API Payment Gateways Controller
 *
 * Handles requests to the /settings/payment-gateways endpoint.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\PaymentGateways;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractController;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\PaymentGateways\Schema\AbstractPaymentGatewaySettingsSchema;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\PaymentGateways\Schema\BacsGatewaySettingsSchema;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\PaymentGateways\Schema\CodGatewaySettingsSchema;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\PaymentGateways\Schema\PaymentGatewaySettingsSchema;
use WC_Payment_Gateway;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * REST API Payment Gateways Controller Class.
 *
 * @extends AbstractController
 */
class Controller extends AbstractController {
	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'settings/payment-gateways';

	/**
	 * Post type.
	 *
	 * @var string
	 */
	protected string $post_type = 'payment_gateways';

	/**
	 * Get the schema for the current resource. This use consumed by the AbstractController to generate the item schema
	 * after running various hooks on the response.
	 *
	 * @return array
	 */
	protected function get_schema(): array {
		// Use generic schema for schema generation.
		$schema = new PaymentGatewaySettingsSchema();
		return $schema->get_item_schema();
	}

	/**
	 * Register the routes for payment gateways.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[\w-]+)',
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_item' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
				),
				array(
					'methods'             => WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'update_item' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
					'args'                => array(
						'values' => array(
							'description' => __( 'Payment gateway field values to update.', 'woocommerce' ),
							'type'        => 'object',
							'required'    => true,
						),
					),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
				'args'   => array(
					'id' => array(
						'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
						'type'        => 'string',
						'pattern'     => '^[\w-]+$',
					),
				),
			)
		);
	}

	/**
	 * Get a single payment gateway.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
	 */
	public function get_item( $request ) {
		$id               = $request['id'];
		$payment_gateways = WC()->payment_gateways->payment_gateways();

		if ( ! isset( $payment_gateways[ $id ] ) ) {
			return new WP_Error( 'woocommerce_rest_payment_gateway_invalid_id', __( 'Invalid payment gateway ID.', 'woocommerce' ), array( 'status' => 404 ) );
		}

		$gateway = $payment_gateways[ $id ];

		// Get gateway-specific schema.
		$schema = $this->get_schema_for_gateway( $id );

		$data = $schema->get_item_response( $gateway, $request );

		return rest_ensure_response( $data );
	}

	/**
	 * Check if a given request has access to read payment gateways.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function get_items_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( $this->post_type, 'read' ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );
		}
		return true;
	}

	/**
	 * Check if a given request has access to update payment gateways.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function update_item_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'payment_gateways', 'edit' ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );
		}
		return true;
	}

	/**
	 * Get a gateway based on the current request object.
	 *
	 * @param string $id Gateway ID.
	 *
	 * @return WC_Payment_Gateway|null
	 */
	private function get_payment_gateway( $id ): ?WC_Payment_Gateway {
		$payment_gateways = WC()->payment_gateways->payment_gateways();
		return $payment_gateways[ $id ] ?? null;
	}

	/**
	 * Get the appropriate schema for a payment gateway.
	 *
	 * @param string $gateway_id Gateway ID.
	 * @return AbstractPaymentGatewaySettingsSchema
	 */
	private function get_schema_for_gateway( string $gateway_id ): AbstractPaymentGatewaySettingsSchema {
		switch ( $gateway_id ) {
			case 'bacs':
				return new BacsGatewaySettingsSchema();
			case 'cod':
				return new CodGatewaySettingsSchema();
			default:
				// Use generic schema for unknown gateways.
				return new PaymentGatewaySettingsSchema();
		}
	}

	/**
	 * Update a payment gateway's settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function update_item( $request ) {
		$id      = $request['id'];
		$gateway = $this->get_payment_gateway( $id );

		if ( ! $gateway ) {
			return new WP_Error(
				'woocommerce_rest_payment_gateway_invalid_id',
				__( 'Invalid payment gateway ID.', 'woocommerce' ),
				array( 'status' => 404 )
			);
		}

		// Get gateway-specific schema.
		$schema = $this->get_schema_for_gateway( $id );

		// Get field values from the values parameter.
		$params           = $request->get_params();
		$values_to_update = $params['values'] ?? null;

		if ( empty( $values_to_update ) || ! is_array( $values_to_update ) ) {
			return new WP_Error(
				'rest_missing_callback_param',
				__( 'Missing parameter(s): values', 'woocommerce' ),
				array( 'status' => 400 )
			);
		}

		// Handle top-level gateway fields from within values.
		$gateway->init_form_fields();

		if ( isset( $values_to_update['enabled'] ) ) {
			$gateway->enabled             = wc_bool_to_string( $values_to_update['enabled'] );
			$gateway->settings['enabled'] = $gateway->enabled;
			unset( $values_to_update['enabled'] );
		}

		if ( isset( $values_to_update['title'] ) ) {
			$gateway->title             = sanitize_text_field( $values_to_update['title'] );
			$gateway->settings['title'] = $gateway->title;
			unset( $values_to_update['title'] );
		}

		if ( isset( $values_to_update['description'] ) ) {
			$gateway->description             = wp_kses_post( $values_to_update['description'] );
			$gateway->settings['description'] = $gateway->description;
			unset( $values_to_update['description'] );
		}

		if ( isset( $values_to_update['order'] ) ) {
			$order                = absint( $values_to_update['order'] );
			$gateway_order        = (array) get_option( 'woocommerce_gateway_order', array() );
			$gateway_order[ $id ] = $order;
			update_option( 'woocommerce_gateway_order', $gateway_order );
			unset( $values_to_update['order'] );
		}

		// Separate standard fields from special fields.
		$standard_values = array();
		$special_values  = array();

		foreach ( $values_to_update as $key => $value ) {
			// Check if this is a special field.
			if ( $schema->is_special_field( $key ) ) {
				$special_values[ $key ] = $value;
			} elseif ( isset( $gateway->form_fields[ $key ] ) ) {
				$standard_values[ $key ] = $value;
			}
			// Silently skip unknown fields.
		}

		// Validate and sanitize standard settings.
		$validated_settings = $schema->validate_and_sanitize_settings(
			$gateway,
			$standard_values
		);

		if ( is_wp_error( $validated_settings ) ) {
			return $validated_settings;
		}

		// Validate and sanitize special fields.
		$validated_special = $schema->validate_and_sanitize_special_fields(
			$gateway,
			$special_values
		);

		if ( is_wp_error( $validated_special ) ) {
			return $validated_special;
		}

		// Update standard settings.
		foreach ( $validated_settings as $key => $value ) {
			$gateway->settings[ $key ] = $value;
		}

		// Save standard settings to database.
		update_option( $gateway->get_option_key(), $gateway->settings );

		// Update special fields.
		$schema->update_special_fields( $gateway, $validated_special );

		// Return updated gateway data.
		$data = $schema->get_item_response( $gateway, $request );
		return rest_ensure_response( $data );
	}

	/**
	 * Get the item response for a payment gateway.
	 *
	 * @param WC_Payment_Gateway $item    Payment gateway object.
	 * @param WP_REST_Request    $request Request object.
	 * @return array The item response.
	 */
	protected function get_item_response( $item, WP_REST_Request $request ): array {
		// Get gateway-specific schema.
		$schema = $this->get_schema_for_gateway( $item->id );

		return $schema->get_item_response( $item, $request );
	}
}
PK     [1]#LQ  Q  Z  RestApi/Routes/V4/Settings/PaymentGateways/Schema/AbstractPaymentGatewaySettingsSchema.phpnu         <?php
/**
 * AbstractPaymentGatewaySettingsSchema class.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\PaymentGateways\Schema;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractSchema;
use WC_Payment_Gateway;
use WP_Error;
use WP_REST_Request;

/**
 * AbstractPaymentGatewaySettingsSchema class.
 *
 * Base class for payment gateway settings schemas in the REST API.
 *
 * The `settings` property is an object where keys are arbitrary setting IDs
 * and values are setting configuration objects with the following structure:
 *
 * - id (string, readonly): A unique identifier for the setting
 * - label (string, readonly): A human readable label for the setting used in interfaces
 * - description (string, readonly): A human readable description for the setting used in interfaces
 * - type (string, readonly): Type of setting (text, email, number, color, password, textarea, select, multiselect, radio, image_width, checkbox)
 * - value (string): Setting value
 * - default (string, readonly): Default value for the setting
 * - tip (string, readonly): Additional help text shown to the user about the setting
 * - placeholder (string, readonly): Placeholder text to be displayed in text inputs
 * - options (object, optional): Available options for select/multiselect type settings
 */
abstract class AbstractPaymentGatewaySettingsSchema extends AbstractSchema {
	/**
	 * The schema item identifier.
	 *
	 * @var string
	 */
	const IDENTIFIER = 'payment_gateway_settings';

	/**
	 * Return all properties for the item schema.
	 *
	 * Note that context determines under which context data should be visible. For example, edit would be the context
	 * used when getting records with the intent of editing them. embed context allows the data to be visible when the
	 * item is being embedded in another response.
	 *
	 * @return array
	 */
	public function get_item_schema_properties(): array {
		return array(
			'id'                 => array(
				'description' => __( 'Payment gateway ID.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'title'              => array(
				'description' => __( 'Payment gateway title on checkout.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
			),
			'description'        => array(
				'description' => __( 'Payment gateway description on checkout.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
			),
			'order'              => array(
				'description' => __( 'Payment gateway sort order.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'arg_options' => array(
					'sanitize_callback' => 'absint',
				),
			),
			'enabled'            => array(
				'description' => __( 'Payment gateway enabled status.', 'woocommerce' ),
				'type'        => 'boolean',
				'context'     => self::VIEW_EDIT_CONTEXT,
			),
			'method_title'       => array(
				'description' => __( 'Payment gateway method title.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'method_description' => array(
				'description' => __( 'Payment gateway method description.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'method_supports'    => array(
				'description' => __( 'Supported features for this payment gateway.', 'woocommerce' ),
				'type'        => 'array',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
				'items'       => array(
					'type' => 'string',
				),
			),
			'values'             => array(
				'description'          => __( 'Flat key-value mapping of all setting field values.', 'woocommerce' ),
				'type'                 => 'object',
				'context'              => array( 'view', 'edit' ),
				'additionalProperties' => array(
					'description' => __( 'Setting field value.', 'woocommerce' ),
					'type'        => array( 'string', 'number', 'array', 'boolean' ),
				),
			),
			'groups'             => array(
				'description'          => __( 'Collection of setting groups.', 'woocommerce' ),
				'type'                 => 'object',
				'context'              => array( 'view', 'edit' ),
				'additionalProperties' => array(
					'type'        => 'object',
					'description' => __( 'Settings group.', 'woocommerce' ),
					'properties'  => array(
						'title'       => array(
							'description' => __( 'Group title.', 'woocommerce' ),
							'type'        => 'string',
							'context'     => array( 'view', 'edit' ),
						),
						'description' => array(
							'description' => __( 'Group description.', 'woocommerce' ),
							'type'        => 'string',
							'context'     => array( 'view', 'edit' ),
						),
						'order'       => array(
							'description' => __( 'Display order for the group.', 'woocommerce' ),
							'type'        => 'integer',
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
						'fields'      => array(
							'description' => __( 'Settings fields.', 'woocommerce' ),
							'type'        => 'array',
							'context'     => array( 'view', 'edit' ),
							'items'       => $this->get_field_schema(),
						),
					),
				),
			),
		);
	}

	/**
	 * Get the schema for individual setting fields.
	 *
	 * @return array
	 */
	private function get_field_schema(): array {
		return array(
			'type'       => 'object',
			'properties' => array(
				'id'      => array(
					'description' => __( 'Setting field ID.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
				'label'   => array(
					'description' => __( 'Setting field label.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
				'type'    => array(
					'description' => __( 'Setting field type.', 'woocommerce' ),
					'type'        => 'string',
					'enum'        => array( 'text', 'number', 'select', 'multiselect', 'checkbox' ),
					'context'     => array( 'view', 'edit' ),
				),
				'options' => array(
					'description' => __( 'Available options for select/multiselect fields.', 'woocommerce' ),
					'type'        => 'object',
					'context'     => array( 'view', 'edit' ),
				),
				'desc'    => array(
					'description' => __( 'Description for the setting field.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
			),
		);
	}

	/**
	 * Get flat key-value mapping of all setting values.
	 *
	 * @param WC_Payment_Gateway $gateway Gateway instance.
	 * @return array
	 */
	private function get_values( WC_Payment_Gateway $gateway ): array {
		$values = array();
		$gateway->init_form_fields();

		foreach ( $gateway->form_fields as $id => $field ) {
			$field_type = $field['type'] ?? '';

			// Skip non-data fields.
			if ( in_array( $field_type, array( 'title', 'sectionend' ), true ) ) {
				continue;
			}

			// Get value from gateway settings.
			$values[ $id ] = $gateway->settings[ $id ] ?? ( $field['default'] ?? '' );
		}

		// Add special fields for this gateway.
		$special_fields = $this->get_special_field_values( $gateway );
		$values         = array_merge( $values, $special_fields );

		return $values;
	}

	/**
	 * Get values for gateway-specific special fields.
	 *
	 * Override this method in gateway-specific schema classes to provide special field values.
	 *
	 * @param WC_Payment_Gateway $gateway Gateway instance.
	 * @return array
	 */
	protected function get_special_field_values( WC_Payment_Gateway $gateway ): array {
		return array();
	}

	/**
	 * Get grouped settings structure with field metadata.
	 *
	 * @param WC_Payment_Gateway $gateway Gateway instance.
	 * @return array
	 */
	private function get_groups( WC_Payment_Gateway $gateway ): array {
		// Check if gateway has custom grouping.
		$custom_groups = $this->get_custom_groups_for_gateway( $gateway );
		if ( ! empty( $custom_groups ) ) {
			return $custom_groups;
		}

		// Default: single group with all fields.
		return $this->get_default_group( $gateway );
	}

	/**
	 * Get custom groups for specific gateways.
	 *
	 * Override this method in gateway-specific schema classes to provide custom groupings.
	 *
	 * @param WC_Payment_Gateway $gateway Gateway instance.
	 * @return array
	 */
	protected function get_custom_groups_for_gateway( WC_Payment_Gateway $gateway ): array {
		return array();
	}

	/**
	 * Get default single group with all gateway fields.
	 *
	 * @param WC_Payment_Gateway $gateway Gateway instance.
	 * @return array
	 */
	private function get_default_group( WC_Payment_Gateway $gateway ): array {
		$gateway->init_form_fields();

		$group = array(
			'title'       => __( 'Settings', 'woocommerce' ),
			'description' => '',
			'order'       => 1,
			'fields'      => array(),
		);

		// Add standard top-level fields first.
		$group['fields'][] = array(
			'id'    => 'enabled',
			'label' => __( 'Enable/Disable', 'woocommerce' ),
			'type'  => 'checkbox',
			'desc'  => __( 'Enable this payment gateway', 'woocommerce' ),
		);

		$group['fields'][] = array(
			'id'    => 'title',
			'label' => __( 'Title', 'woocommerce' ),
			'type'  => 'text',
			'desc'  => __( 'This controls the title which the user sees during checkout.', 'woocommerce' ),
		);

		$group['fields'][] = array(
			'id'    => 'description',
			'label' => __( 'Description', 'woocommerce' ),
			'type'  => 'text',
			'desc'  => __( 'This controls the description which the user sees during checkout.', 'woocommerce' ),
		);

		$group['fields'][] = array(
			'id'    => 'order',
			'label' => __( 'Order', 'woocommerce' ),
			'type'  => 'number',
			'desc'  => __( 'Determines the display order of payment gateways during checkout.', 'woocommerce' ),
		);

		foreach ( $gateway->form_fields as $id => $field ) {
			$field_type = $field['type'] ?? '';

			// Skip non-data fields, top-level fields (already added above), and special fields.
			if ( in_array( $field_type, array( 'title', 'sectionend' ), true ) ||
				in_array( $id, array( 'enabled', 'description', 'title' ), true ) ||
				$this->is_special_field( $id ) ) {
				continue;
			}

			$group['fields'][] = $this->transform_field_to_schema( $id, $field, $gateway );
		}

		// Add special fields.
		$special_fields  = $this->get_special_field_schemas( $gateway );
		$group['fields'] = array_merge( $group['fields'], $special_fields );

		if ( empty( $group['fields'] ) ) {
			return array();
		}

		return array( 'settings' => $group );
	}

	/**
	 * Get field schemas for gateway-specific special fields.
	 *
	 * Override this method in gateway-specific schema classes to provide special field schemas.
	 *
	 * @param WC_Payment_Gateway $gateway Gateway instance.
	 * @return array
	 */
	protected function get_special_field_schemas( WC_Payment_Gateway $gateway ): array {
		return array();
	}

	/**
	 * Transform WooCommerce field definition to API field schema.
	 *
	 * @param string             $id      Field ID.
	 * @param array              $field   Field definition.
	 * @param WC_Payment_Gateway $gateway Gateway instance.
	 * @return array
	 */
	private function transform_field_to_schema( string $id, array $field, WC_Payment_Gateway $gateway ): array {
		$field_type = $field['type'] ?? 'text';

		$schema_field = array(
			'id'    => $id,
			'label' => $field['title'] ?? $field['label'] ?? '',
			'type'  => $this->normalize_field_type( $field_type ),
			'desc'  => $field['description'] ?? '',
		);

		// For checkbox fields, use the 'label' field as description if no explicit description exists.
		if ( 'checkbox' === $field_type && empty( $schema_field['desc'] ) && ! empty( $field['label'] ) ) {
			$schema_field['desc'] = $field['label'];
		}

		// Add options for select/multiselect fields.
		if ( in_array( $schema_field['type'], array( 'select', 'multiselect' ), true ) ) {
			if ( ! empty( $field['options'] ) ) {
				$schema_field['options'] = $field['options'];
			} else {
				// Generate options dynamically for specific fields.
				$schema_field['options'] = $this->get_field_options( $id );
			}
		}

		return $schema_field;
	}

	/**
	 * Get options for specific gateway fields.
	 *
	 * Override this method in gateway-specific schema classes to provide
	 * dynamic options for select/multiselect fields.
	 *
	 * @param string $field_id Field ID.
	 * @return array Field options.
	 */
	protected function get_field_options( string $field_id ): array {
		return array();
	}

	/**
	 * Normalize WooCommerce field types to standard REST API types.
	 *
	 * @param string $wc_type WooCommerce field type.
	 * @return string
	 */
	private function normalize_field_type( string $wc_type ): string {
		$type_map = array(
			'email'       => 'text',
			'password'    => 'text',
			'textarea'    => 'text',
			'safe_text'   => 'text',
			'color'       => 'text',
			'image_width' => 'text',
			'radio'       => 'select',
		);

		return $type_map[ $wc_type ] ?? $wc_type;
	}

	/**
	 * Return settings associated with this payment gateway.
	 *
	 * Note: Some gateways may conditionally populate the 'options' array for select/multiselect fields
	 * based on context (e.g., only when accessing settings pages) for performance reasons.
	 * For example, the COD gateway's `enable_for_methods` field loads shipping method options only
	 * when `is_accessing_settings()` returns true. This means the options array may be empty when
	 * accessed via the REST API, even though the field type is multiselect.
	 *
	 * @param WC_Payment_Gateway $gateway Gateway instance.
	 *
	 * @return array
	 */
	public function get_settings( WC_Payment_Gateway $gateway ): array {
		$settings = array();
		$gateway->init_form_fields();
		foreach ( $gateway->form_fields as $id => $field ) {
			// Make sure we at least have a title and type.
			if ( empty( $field['title'] ) || empty( $field['type'] ) ) {
				continue;
			}

			// Ignore 'enabled' and 'description' which get included elsewhere.
			if ( in_array( $id, array( 'enabled', 'description' ), true ) ) {
				continue;
			}

			$data = array(
				'id'          => $id,
				'label'       => empty( $field['label'] ) ? $field['title'] : $field['label'],
				'description' => empty( $field['description'] ) ? '' : $field['description'],
				'type'        => $field['type'],
				'value'       => empty( $gateway->settings[ $id ] ) ? '' : $gateway->settings[ $id ],
				'default'     => empty( $field['default'] ) ? '' : $field['default'],
				'tip'         => empty( $field['description'] ) ? '' : $field['description'],
				'placeholder' => empty( $field['placeholder'] ) ? '' : $field['placeholder'],
			);
			if ( ! empty( $field['options'] ) ) {
				$data['options'] = $field['options'];
			}
			$settings[ $id ] = $data;
		}
		return $settings;
	}

	/**
	 * Get the item response.
	 *
	 * @param WC_Payment_Gateway $gateway Payment gateway object.
	 * @param WP_REST_Request    $request Request object.
	 * @param array              $include_fields Fields to include in the response.
	 * @return array The item response.
	 */
	public function get_item_response( $gateway, WP_REST_Request $request, array $include_fields = array() ): array {
		$order = (array) get_option( 'woocommerce_gateway_order' );
		return array(
			'id'                 => $gateway->id,
			'title'              => $gateway->title,
			'description'        => $gateway->description,
			'order'              => $order[ $gateway->id ] ?? '',
			'enabled'            => ( 'yes' === $gateway->enabled ),
			'method_title'       => $gateway->get_method_title(),
			'method_description' => $gateway->get_method_description(),
			'method_supports'    => $gateway->supports,
			'values'             => $this->get_values( $gateway ),
			'groups'             => $this->get_groups( $gateway ),
		);
	}

	/**
	 * Check if a field is a special field.
	 *
	 * Override this method in gateway-specific schema classes to identify special fields.
	 *
	 * @param string $field_id Field ID.
	 * @return bool
	 */
	public function is_special_field( string $field_id ): bool {
		return false;
	}

	/**
	 * Validate and sanitize standard gateway settings.
	 *
	 * @param WC_Payment_Gateway $gateway Gateway instance.
	 * @param array              $values  Values to validate and sanitize.
	 * @return array|WP_Error Validated settings or error.
	 */
	public function validate_and_sanitize_settings( WC_Payment_Gateway $gateway, array $values ) {
		$gateway->init_form_fields();
		$validated = array();

		foreach ( $values as $key => $value ) {
			// Security: only allow valid form fields.
			if ( ! isset( $gateway->form_fields[ $key ] ) ) {
				continue;
			}

			$field      = $gateway->form_fields[ $key ];
			$field_type = $field['type'] ?? 'text';

			// Sanitize by type.
			$sanitized = $this->sanitize_field_value( $field_type, $value );

			// Validate.
			$validation = $this->validate_field_value( $key, $sanitized, $field, $gateway );
			if ( is_wp_error( $validation ) ) {
				return $validation;
			}

			$validated[ $key ] = $sanitized;
		}

		return $validated;
	}

	/**
	 * Sanitize field value based on type.
	 *
	 * @param string $type  Field type.
	 * @param mixed  $value Field value.
	 * @return mixed Sanitized value.
	 */
	private function sanitize_field_value( string $type, $value ) {
		switch ( $type ) {
			case 'checkbox':
				return wc_bool_to_string( $value );

			case 'number':
				if ( ! is_numeric( $value ) ) {
					return '';
				}
				$int_value = filter_var( $value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE );
				return null !== $int_value ? $int_value : floatval( $value );

			case 'multiselect':
				if ( is_array( $value ) ) {
					return array_map( 'sanitize_text_field', $value );
				}
				return is_string( $value ) ? array( sanitize_text_field( $value ) ) : array();

			case 'textarea':
				return sanitize_textarea_field( $value );

			case 'email':
				return sanitize_email( $value );

			case 'password':
			case 'color':
				return sanitize_text_field( $value );

			case 'text':
			case 'safe_text':
			case 'select':
			case 'radio':
			case 'image_width':
			default:
				return sanitize_text_field( $value );
		}
	}

	/**
	 * Validate field value.
	 *
	 * @param string             $key     Field key.
	 * @param mixed              $value   Sanitized value.
	 * @param array              $field   Field definition.
	 * @param WC_Payment_Gateway $gateway Gateway instance.
	 * @return true|WP_Error True if valid, WP_Error otherwise.
	 */
	private function validate_field_value( string $key, $value, array $field, WC_Payment_Gateway $gateway ) {
		$field_type = $this->normalize_field_type( $field['type'] ?? 'text' );

		// Validate select/radio options.
		if ( in_array( $field_type, array( 'select', 'radio' ), true ) && ! empty( $field['options'] ) ) {
			if ( ! array_key_exists( $value, $field['options'] ) && '' !== $value ) {
				return new WP_Error(
					'rest_invalid_param',
					sprintf(
						/* translators: 1: field key, 2: valid options */
						__( 'Invalid value for %1$s. Valid options: %2$s', 'woocommerce' ),
						$key,
						implode( ', ', array_keys( $field['options'] ) )
					),
					array( 'status' => 400 )
				);
			}
		}

		// Validate multiselect options.
		if ( 'multiselect' === $field_type && ! empty( $field['options'] ) ) {
			if ( is_array( $value ) ) {
				foreach ( $value as $v ) {
					if ( ! array_key_exists( $v, $field['options'] ) ) {
						return new WP_Error(
							'rest_invalid_param',
							sprintf(
								/* translators: 1: field key, 2: invalid value */
								__( 'Invalid option "%2$s" for %1$s.', 'woocommerce' ),
								$key,
								$v
							),
							array( 'status' => 400 )
						);
					}
				}
			}
		}

		// Add more validations as needed.

		return true;
	}

	/**
	 * Validate and sanitize special fields.
	 *
	 * Override this method in gateway-specific schema classes to provide custom validation.
	 *
	 * @param WC_Payment_Gateway $gateway Gateway instance.
	 * @param array              $values  Special field values.
	 * @return array|WP_Error Validated values or error.
	 */
	public function validate_and_sanitize_special_fields( WC_Payment_Gateway $gateway, array $values ) {
		return array();
	}

	/**
	 * Update special fields in database.
	 *
	 * Override this method in gateway-specific schema classes to provide custom update logic.
	 *
	 * @param WC_Payment_Gateway $gateway Gateway instance.
	 * @param array              $values  Validated special field values.
	 * @return void
	 */
	public function update_special_fields( WC_Payment_Gateway $gateway, array $values ): void {
		// Base implementation does nothing.
	}
}
PK     [1]/1b  b  N  RestApi/Routes/V4/Settings/PaymentGateways/Schema/CodGatewaySettingsSchema.phpnu         <?php
/**
 * CodGatewaySettingsSchema class.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\PaymentGateways\Schema;

defined( 'ABSPATH' ) || exit;

use WC_Data_Store;
use WC_Shipping_Zone;

/**
 * CodGatewaySettingsSchema class.
 *
 * Extends AbstractPaymentGatewaySettingsSchema for Cash on Delivery payment gateway.
 *
 * Note: The COD gateway has enable_for_methods and enable_for_virtual fields
 * which are standard fields stored in gateway settings.
 */
class CodGatewaySettingsSchema extends AbstractPaymentGatewaySettingsSchema {

	/**
	 * Get options for specific COD gateway fields.
	 *
	 * @param string $field_id Field ID.
	 * @return array Field options.
	 */
	protected function get_field_options( string $field_id ): array {
		switch ( $field_id ) {
			case 'enable_for_methods':
				return $this->load_shipping_method_options();
			default:
				return array();
		}
	}

	/**
	 * Load all shipping method options for the enable_for_methods field.
	 *
	 * This method replicates the logic from WC_Gateway_COD::load_shipping_method_options()
	 * to provide shipping method options for the REST API without relying on the gateway class.
	 *
	 * @return array Nested array of shipping method options.
	 */
	private function load_shipping_method_options(): array {
		$data_store = WC_Data_Store::load( 'shipping-zone' );
		$raw_zones  = $data_store->get_zones();
		$zones      = array();

		foreach ( $raw_zones as $raw_zone ) {
			$zones[] = new WC_Shipping_Zone( $raw_zone );
		}

		$zones[] = new WC_Shipping_Zone( 0 );

		$options = array();
		foreach ( WC()->shipping()->load_shipping_methods() as $method ) {

			$options[ $method->get_method_title() ] = array();

			// Translators: %1$s shipping method name.
			$options[ $method->get_method_title() ][ $method->id ] = sprintf( __( 'Any &quot;%1$s&quot; method', 'woocommerce' ), $method->get_method_title() );

			foreach ( $zones as $zone ) {

				$shipping_method_instances = $zone->get_shipping_methods();

				foreach ( $shipping_method_instances as $shipping_method_instance_id => $shipping_method_instance ) {

					if ( $shipping_method_instance->id !== $method->id ) {
						continue;
					}

					$option_id = $shipping_method_instance->get_rate_id();

					// Translators: %1$s shipping method title, %2$s shipping method id.
					$option_instance_title = sprintf( __( '%1$s (#%2$s)', 'woocommerce' ), $shipping_method_instance->get_title(), $shipping_method_instance_id );

					// Translators: %1$s zone name, %2$s shipping method instance name.
					$option_title = sprintf( __( '%1$s &ndash; %2$s', 'woocommerce' ), $zone->get_id() ? $zone->get_zone_name() : __( 'Other locations', 'woocommerce' ), $option_instance_title );

					$options[ $method->get_method_title() ][ $option_id ] = $option_title;
				}
			}
		}

		return $options;
	}
}
PK     [1]TPV  V  R  RestApi/Routes/V4/Settings/PaymentGateways/Schema/PaymentGatewaySettingsSchema.phpnu         <?php
/**
 * PaymentGatewaySettingsSchema class.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\PaymentGateways\Schema;

defined( 'ABSPATH' ) || exit;

/**
 * PaymentGatewaySettingsSchema class.
 *
 * Generic payment gateway settings schema for gateways without special requirements.
 * Extends AbstractPaymentGatewaySettingsSchema with default implementations.
 */
class PaymentGatewaySettingsSchema extends AbstractPaymentGatewaySettingsSchema {
	// All functionality inherited from abstract base class.
}
PK     [1]    O  RestApi/Routes/V4/Settings/PaymentGateways/Schema/BacsGatewaySettingsSchema.phpnu         <?php
/**
 * BacsGatewaySettingsSchema class.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\PaymentGateways\Schema;

defined( 'ABSPATH' ) || exit;

use WC_Payment_Gateway;
use WP_Error;

/**
 * BacsGatewaySettingsSchema class.
 *
 * Extends AbstractPaymentGatewaySettingsSchema to handle BACS-specific settings.
 */
class BacsGatewaySettingsSchema extends AbstractPaymentGatewaySettingsSchema {
	/**
	 * Get values for BACS-specific special fields.
	 *
	 * @param WC_Payment_Gateway $gateway Gateway instance.
	 * @return array
	 */
	protected function get_special_field_values( WC_Payment_Gateway $gateway ): array {
		return array(
			'account_details' => get_option( 'woocommerce_bacs_accounts', array() ),
		);
	}

	/**
	 * Get field schemas for BACS-specific special fields.
	 *
	 * @param WC_Payment_Gateway $gateway Gateway instance.
	 * @return array
	 */
	protected function get_special_field_schemas( WC_Payment_Gateway $gateway ): array {
		$gateway->init_form_fields();

		// Start with information from the gateway's form_fields if available.
		$field = $gateway->form_fields['account_details'] ?? array();

		return array(
			array(
				'id'    => 'account_details',
				'label' => $field['title'] ?? __( 'Account details', 'woocommerce' ),
				'type'  => 'array',
				'desc'  => $field['description'] ?? __( 'Bank account details for direct bank transfer.', 'woocommerce' ),
			),
		);
	}

	/**
	 * Check if a field is a special field for BACS.
	 *
	 * @param string $field_id Field ID.
	 * @return bool
	 */
	public function is_special_field( string $field_id ): bool {
		return 'account_details' === $field_id;
	}

	/**
	 * Validate and sanitize BACS special fields.
	 *
	 * @param WC_Payment_Gateway $gateway Gateway instance.
	 * @param array              $values  Special field values.
	 * @return array|WP_Error Validated values or error.
	 */
	public function validate_and_sanitize_special_fields( WC_Payment_Gateway $gateway, array $values ) {
		$validated = array();

		foreach ( $values as $field_id => $value ) {
			if ( 'account_details' === $field_id ) {
				$validated[ $field_id ] = $this->validate_bacs_accounts( $value );
				if ( is_wp_error( $validated[ $field_id ] ) ) {
					return $validated[ $field_id ];
				}
			}
		}

		return $validated;
	}

	/**
	 * Update BACS special fields in database.
	 *
	 * @param WC_Payment_Gateway $gateway Gateway instance.
	 * @param array              $values  Validated special field values.
	 * @return void
	 */
	public function update_special_fields( WC_Payment_Gateway $gateway, array $values ): void {
		foreach ( $values as $field_id => $value ) {
			if ( 'account_details' === $field_id ) {
				update_option( 'woocommerce_bacs_accounts', $value );
			}
		}
	}

	/**
	 * Validate BACS account details array.
	 *
	 * @param mixed $value Account details value.
	 * @return array|WP_Error Validated accounts or error.
	 */
	private function validate_bacs_accounts( $value ) {
		if ( ! is_array( $value ) ) {
			return new WP_Error(
				'rest_invalid_param',
				__( 'Account details must be an array.', 'woocommerce' ),
				array( 'status' => 400 )
			);
		}

		$validated_accounts = array();
		$valid_fields       = array( 'account_name', 'account_number', 'sort_code', 'bank_name', 'iban', 'bic' );

		foreach ( $value as $index => $account ) {
			if ( ! is_array( $account ) ) {
				return new WP_Error(
					'rest_invalid_param',
					sprintf(
						/* translators: %d: account index */
						__( 'Account at index %d must be an object.', 'woocommerce' ),
						$index
					),
					array( 'status' => 400 )
				);
			}

			$validated_account = array();

			// Sanitize each field.
			foreach ( $valid_fields as $field ) {
				$validated_account[ $field ] = isset( $account[ $field ] )
					? sanitize_text_field( $account[ $field ] )
					: '';
			}

			// Only add if at least one field is filled.
			if ( array_filter( $validated_account ) ) {
				$validated_accounts[] = $validated_account;
			}
		}

		return $validated_accounts;
	}
}
PK     [1]3Cn#  #  0  RestApi/Routes/V4/Settings/Emails/Controller.phpnu         <?php
/**
 * REST API Emails Settings Controller
 *
 * Handles requests to the /settings/emails endpoints.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\Emails;

use WP_Error;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractController;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\Emails\Schema\EmailsSettingsSchema;
use WC_Emails;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;

defined( 'ABSPATH' ) || exit;

/**
 * REST API Emails Settings Controller Class.
 */
class Controller extends AbstractController {
	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'settings/emails';

	/**
	 * Schema instance.
	 *
	 * @var EmailsSettingsSchema
	 */
	protected $schema;

	/**
	 * Initialize the controller.
	 *
	 * @param EmailsSettingsSchema $schema Schema class.
	 * @internal
	 */
	final public function init( EmailsSettingsSchema $schema ) {
		$this->schema = $schema;
	}

	/**
	 * Register routes.
	 */
	public function register_routes() {
		// Collection endpoint.
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => array(
						'post_id' => array(
							'description' => __( 'Filter by template post ID.', 'woocommerce' ),
							'type'        => 'integer',
						),
					),
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);

		// Single item endpoint.
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<email_id>[\w-]+)',
			array(
				'args'   => array(
					'email_id' => array(
						'description' => __( 'Email template ID.', 'woocommerce' ),
						'type'        => 'string',
					),
				),
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_item' ),
					'permission_callback' => array( $this, 'get_item_permissions_check' ),
				),
				array(
					'methods'             => WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'update_item' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);
	}

	/**
	 * Check permissions for reading email settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return bool|WP_Error
	 */
	public function get_items_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) {
			return new WP_Error(
				'rest_forbidden',
				__( 'Sorry, you are not allowed to access email settings.', 'woocommerce' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}
		return true;
	}

	/**
	 * Check permissions for reading a single email setting.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return bool|WP_Error
	 */
	public function get_item_permissions_check( $request ) {
		return $this->get_items_permissions_check( $request );
	}

	/**
	 * Check permissions for updating email settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return bool|WP_Error
	 */
	public function update_item_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'settings', 'edit' ) ) {
			return new WP_Error(
				'rest_forbidden',
				__( 'Sorry, you are not allowed to edit email settings.', 'woocommerce' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}
		return true;
	}

	/**
	 * Get all email settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_items( $request ) {
		try {
			$emails = WC_Emails::instance()->get_emails();
			$items  = array();

			foreach ( $emails as $email ) {
				$item = $this->schema->get_item_response( $email, $request );
				// Filter by post_id if provided.
				$post_id = $request->get_param( 'post_id' );
				if ( $post_id && (int) $item['post_id'] !== (int) $post_id ) {
					continue;
				}
				$items[] = $item;
			}

			return rest_ensure_response( $items );
		} catch ( \Exception $e ) {
			return new WP_Error(
				'woocommerce_rest_emails_settings_error',
				$e->getMessage(),
				array( 'status' => 500 )
			);
		}
	}

	/**
	 * Get a single email setting.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_item( $request ) {
		$email_id = $request['email_id'];
		$email    = $this->get_email_by_id( $email_id );

		if ( ! $email ) {
			return new WP_Error(
				'woocommerce_rest_email_not_found',
				__( 'Email template not found.', 'woocommerce' ),
				array( 'status' => 404 )
			);
		}

		try {
			$response = $this->schema->get_item_response( $email, $request );
			return rest_ensure_response( $response );
		} catch ( \Exception $e ) {
			return new WP_Error(
				'woocommerce_rest_email_settings_error',
				$e->getMessage(),
				array( 'status' => 500 )
			);
		}
	}

	/**
	 * Update a single email setting.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function update_item( $request ) {
		$email_id = $request['email_id'];
		$email    = $this->get_email_by_id( $email_id );

		if ( ! $email ) {
			return new WP_Error(
				'woocommerce_rest_email_not_found',
				__( 'Email template not found.', 'woocommerce' ),
				array( 'status' => 404 )
			);
		}

		$params = $request->get_json_params();

		if ( ! is_array( $params ) || empty( $params ) ) {
			return new WP_Error(
				'rest_invalid_param',
				__( 'Invalid or empty request body.', 'woocommerce' ),
				array( 'status' => 400 )
			);
		}

		// Check if the request contains a 'values' field with the flat key-value mapping.
		$values_to_update = array();
		if ( isset( $params['values'] ) && is_array( $params['values'] ) ) {
			$values_to_update = $params['values'];
		} else {
			// Fallback to the old format for backward compatibility.
			$values_to_update = $params;
		}

		// Validate and sanitize.
		$validated = $this->schema->validate_and_sanitize_settings( $email, $values_to_update );
		if ( is_wp_error( $validated ) ) {
			return $validated;
		}

		// Update options.
		$updated_fields = array();
		foreach ( $validated as $key => $value ) {
			$email->update_option( $key, $value );
			$updated_fields[] = $key;
		}

		// Reload emails after the update.
		WC_Emails::instance()->init();

		// Get updated email and return formatted response.
		$updated_email = $this->get_email_by_id( $email_id );
		if ( ! $updated_email ) {
			return new WP_Error(
				'woocommerce_rest_email_update_error',
				__( 'Failed to retrieve updated email settings.', 'woocommerce' ),
				array( 'status' => 500 )
			);
		}

		// Trigger action for settings update.
		if ( ! empty( $updated_fields ) ) {
			/**
			 * Fires when WooCommerce email settings are updated.
			 *
			 * @param array  $updated_fields Array of updated field IDs.
			 * @param string $rest_base      The REST base of the settings.
			 * @since 10.2.0
			 */
			do_action( 'woocommerce_settings_updated', $updated_fields, $this->rest_base );
		}

		try {
			$response = $this->schema->get_item_response( $updated_email, $request );
			return rest_ensure_response( $response );
		} catch ( \Exception $e ) {
			return new WP_Error(
				'woocommerce_rest_email_settings_error',
				$e->getMessage(),
				array( 'status' => 500 )
			);
		}
	}

	/**
	 * Get the item response for a single email.
	 *
	 * @param mixed           $item    Email instance.
	 * @param WP_REST_Request $request Request object.
	 * @return array
	 */
	protected function get_item_response( $item, WP_REST_Request $request ): array {
		return $this->schema->get_item_response( $item, $request );
	}

	/**
	 * Get email instance by ID.
	 *
	 * @param string $email_id Email ID.
	 * @return \WC_Email|null Email instance or null if not found.
	 */
	private function get_email_by_id( string $email_id ) {
		$emails = WC_Emails::instance()->get_emails();

		foreach ( $emails as $email ) {
			if ( $email->id === $email_id ) {
				return $email;
			}
		}

		return null;
	}

	/**
	 * Get the schema for the current resource.
	 *
	 * @return array
	 */
	protected function get_schema(): array {
		return $this->schema->get_item_schema();
	}

	/**
	 * Get the item schema for the controller.
	 *
	 * @return array
	 */
	public function get_item_schema(): array {
		return $this->get_schema();
	}

	/**
	 * Get the endpoint args for item schema.
	 *
	 * @param string $method HTTP method of the request.
	 * @return array Endpoint arguments.
	 */
	public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ): array {
		return rest_get_endpoint_args_for_schema( $this->get_item_schema(), $method );
	}
}
PK     [1]A
M  
M  A  RestApi/Routes/V4/Settings/Emails/Schema/EmailsSettingsSchema.phpnu         <?php
/**
 * EmailsSettingsSchema class.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\Emails\Schema;

use Automattic\WooCommerce\EmailEditor\Email_Editor_Container;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractSchema;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalization_Tags_Registry;
use WC_Email;
use WP_Error;
use WP_REST_Request;

defined( 'ABSPATH' ) || exit;

/**
 * EmailsSettingsSchema class.
 *
 * Schema for individual email template settings in the REST API.
 */
class EmailsSettingsSchema extends AbstractSchema {
	/**
	 * The schema item identifier.
	 *
	 * @var string
	 */
	const IDENTIFIER = 'emails_settings';

	/**
	 * This fields support personalization tags and need to be unwrapped before returning to the client.
	 *
	 * @var array
	 */
	const FIELDS_SUPPORTING_PERSONALIZATION_TAGS = array( 'subject', 'preheader' );

	/**
	 * Personalization tags registry.
	 *
	 * @var Personalization_Tags_Registry|null
	 */
	private $personalization_tags_registry;

	/**
	 * Cached array of personalization tag prefixes.
	 *
	 * @var array|null
	 */
	private $cached_prefixes = null;

	/**
	 * Initialize the schema with dependencies.
	 *
	 * @internal This method is not intended to be used externally.
	 */
	final public function init() {
		$this->personalization_tags_registry = Email_Editor_Container::container()->get( Personalization_Tags_Registry::class );
	}

	/**
	 * Return all properties for the item schema.
	 *
	 * @return array
	 */
	public function get_item_schema_properties(): array {
		return array(
			'id'                => array(
				'description' => __( 'Email template ID.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'title'             => array(
				'description' => __( 'Email title.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'description'       => array(
				'description' => __( 'Email description.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'post_id'           => array(
				'description' => __( 'Template post ID.', 'woocommerce' ),
				'type'        => array( 'integer', 'null' ),
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'link'              => array(
				'description' => __( 'Link to template editor.', 'woocommerce' ),
				'type'        => 'string',
				'format'      => 'uri',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'email_group'       => array(
				'description' => __( 'Email group identifier.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'email_group_title' => array(
				'description' => __( 'Email group title.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'is_customer_email' => array(
				'description' => __( 'Whether this is a customer email.', 'woocommerce' ),
				'type'        => 'boolean',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'is_manual'         => array(
				'description' => __( 'Whether this is sent only manually.', 'woocommerce' ),
				'type'        => 'boolean',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'values'            => array(
				'description'          => __( 'Flat key-value mapping of all setting field values.', 'woocommerce' ),
				'type'                 => 'object',
				'context'              => self::VIEW_EDIT_CONTEXT,
				'additionalProperties' => array(
					'description' => __( 'Setting field value.', 'woocommerce' ),
					'type'        => array( 'string', 'number', 'array', 'boolean' ),
				),
			),
			'groups'            => array(
				'description'          => __( 'Collection of setting groups.', 'woocommerce' ),
				'type'                 => 'object',
				'context'              => self::VIEW_EDIT_CONTEXT,
				'additionalProperties' => array(
					'type'        => 'object',
					'description' => __( 'Settings group.', 'woocommerce' ),
					'properties'  => array(
						'title'       => array(
							'description' => __( 'Group title.', 'woocommerce' ),
							'type'        => 'string',
							'context'     => self::VIEW_EDIT_CONTEXT,
						),
						'description' => array(
							'description' => __( 'Group description.', 'woocommerce' ),
							'type'        => 'string',
							'context'     => self::VIEW_EDIT_CONTEXT,
						),
						'order'       => array(
							'description' => __( 'Display order for the group.', 'woocommerce' ),
							'type'        => 'integer',
							'context'     => self::VIEW_EDIT_CONTEXT,
							'readonly'    => true,
						),
						'fields'      => array(
							'description' => __( 'Settings fields.', 'woocommerce' ),
							'type'        => 'array',
							'context'     => self::VIEW_EDIT_CONTEXT,
							'items'       => $this->get_field_schema(),
						),
					),
				),
			),
		);
	}

	/**
	 * Get the schema for individual setting fields.
	 *
	 * @return array
	 */
	private function get_field_schema(): array {
		return array(
			'type'       => 'object',
			'properties' => array(
				'id'      => array(
					'description' => __( 'Setting field ID.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
				'label'   => array(
					'description' => __( 'Setting field label.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
				'type'    => array(
					'description' => __( 'Setting field type.', 'woocommerce' ),
					'type'        => 'string',
					'enum'        => array( 'text', 'email', 'number', 'select', 'multiselect', 'checkbox', 'textarea', 'color', 'password' ),
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
				'desc'    => array(
					'description' => __( 'Description for the setting field.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
				'options' => array(
					'description' => __( 'Available options for select/multiselect fields.', 'woocommerce' ),
					'type'        => 'object',
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
			),
		);
	}

	/**
	 * Get the item response for a single email.
	 *
	 * @param WC_Email        $email   Email instance.
	 * @param WP_REST_Request $request Request object.
	 * @param array           $include_fields Fields to include in the response.
	 * @return array The item response.
	 */
	public function get_item_response( $email, WP_REST_Request $request, array $include_fields = array() ): array {
		// Get template post ID.
		$email_post_manager = WCTransactionalEmailPostsManager::get_instance();
		$post_id            = $email_post_manager->get_email_template_post_id( $email->id ?? '' );
		// Convert false to null, ensure int otherwise.
		$post_id = $post_id ? (int) $post_id : null;

		$link = '';
		if ( $post_id ) {
			$permalink = get_permalink( $post_id );
			$link      = is_string( $permalink ) ? $permalink : '';
		}

		$email->init_form_fields();
		$response = array(
			'id'                => $email->id ?? '',
			'title'             => $email->title ?? '',
			'description'       => $email->description ?? '',
			'post_id'           => $post_id,
			'link'              => $link,
			'email_group'       => $email->email_group ?? '',
			'email_group_title' => method_exists( $email, 'get_email_group_title' ) ? $email->get_email_group_title() : '',
			'is_customer_email' => method_exists( $email, 'is_customer_email' ) ? $email->is_customer_email() : false,
			'is_manual'         => method_exists( $email, 'is_manual' ) ? $email->is_manual() : false,
			'values'            => $this->get_values( $email ),
			'groups'            => $this->get_groups( $email ),
		);

		if ( ! empty( $include_fields ) ) {
			$response = array_intersect_key( $response, array_flip( $include_fields ) );
		}

		return $response;
	}

	/**
	 * Get flat key-value mapping of all setting values.
	 *
	 * @param WC_Email $email Email instance.
	 * @return array
	 */
	private function get_values( WC_Email $email ): array {
		$values      = array();
		$form_fields = $email->get_form_fields();

		if ( ! is_array( $form_fields ) ) {
			return $values;
		}

		// Create a dummy order object, as some of the getter methods require one.
		$email->object = new \WC_Order();

		foreach ( $form_fields as $id => $field ) {
			$field_type = $field['type'] ?? 'text';

			// Skip non-data fields.
			if ( in_array( $field_type, array( 'title', 'sectionend' ), true ) ) {
				continue;
			}

			// Get saved value an fallback to default.
			$default = $this->get_field_default_value( $email, $id, $field );
			$value   = $email->get_option( $id, $default );

			// Unwrap personalization tags if the field supports them.
			if ( in_array( $id, self::FIELDS_SUPPORTING_PERSONALIZATION_TAGS, true ) ) {
				$value = $this->unwrap_woocommerce_tags( $value );
			}

			// Convert checkbox to boolean for API.
			if ( 'checkbox' === $field_type ) {
				$value = ( 'yes' === $value );
			}

			$values[ $id ] = $value;
		}

		return $values;
	}

	/**
	 * Prepare the default value for a field.
	 * We use special methods for well known core fields and use fallback to default value if no special method is available.
	 *
	 * @param WC_Email $email  Email instance.
	 * @param string   $id     Field ID.
	 * @param array    $field  Field definition.
	 * @return mixed The default value for the field.
	 */
	private function get_field_default_value( WC_Email $email, string $id, array $field ) {
		switch ( $id ) {
			case 'enabled':
				return method_exists( $email, 'is_enabled' ) ? $email->is_enabled() : false;
			case 'recipient':
				return method_exists( $email, 'get_recipient' ) ? $email->get_recipient() : '';
			case 'subject':
				return method_exists( $email, 'get_subject' ) ? $email->get_subject() : '';
			case 'heading':
				return method_exists( $email, 'get_heading' ) ? $email->get_heading() : '';
			case 'preheader':
				return method_exists( $email, 'get_preheader' ) ? $email->get_preheader() : '';
			case 'additional_content':
				return method_exists( $email, 'get_additional_content' ) ? $email->get_additional_content() : '';
			case 'cc':
				return $email->cc ?? '';
			case 'bcc':
				return $email->bcc ?? '';
			case 'email_type':
				return $email->email_type ?? '';
			default:
				return $field['default'] ?? ( $field['placeholder'] ?? '' );
		}
	}

	/**
	 * Remove HTML comment wrappers from personalization tags.
	 *
	 * Converts tags from <!--[prefix/tag-name]--> back to [prefix/tag-name] for all registered prefixes.
	 * For example: <!--[woocommerce/customer-name]--> becomes [woocommerce/customer-name].
	 *
	 * This is required because the email editor personalization tags are wrapped in HTML comment wrappers.
	 * We need to remove the tags to make editing easier for the end-users and also because the tags are not well formatted in the current DataForm implementation.
	 *
	 * @param string $value The value to unwrap.
	 * @return string The unwrapped value.
	 */
	private function unwrap_woocommerce_tags( $value ) {
		if ( ! is_string( $value ) ) {
			return $value;
		}

		// Get all registered prefixes dynamically.
		$prefixes = $this->get_personalization_tag_prefixes();

		// If no prefixes, return the value unchanged.
		if ( empty( $prefixes ) ) {
			return $value;
		}

		// Escape prefixes for use in regex and join with |.
		$escaped_prefixes = array_map( 'preg_quote', $prefixes );
		$prefixes_pattern = implode( '|', $escaped_prefixes );

		// Remove HTML comment wrappers from personalization tags.
		$unwrapped_value = preg_replace( '/<!--(\[(?:' . $prefixes_pattern . ')\/[^\]]+\])-->/i', '$1', $value );
		return $unwrapped_value;
	}

	/**
	 * Wrap personalization tags in HTML comments for the email editor.
	 * This is required for the email editor personalization.
	 * Use negative lookbehind and lookahead to avoid double-wrapping already wrapped tags.
	 *
	 * @param mixed $value The value to wrap.
	 * @return mixed The wrapped value.
	 */
	private function wrap_woocommerce_tags( $value ) {
		if ( ! is_string( $value ) ) {
			return $value;
		}

		$prefixes = $this->get_personalization_tag_prefixes();

		if ( empty( $prefixes ) ) {
			return $value;
		}

		// Escape prefixes for use in regex and join with |.
		$escaped_prefixes = array_map( 'preg_quote', $prefixes );
		$prefixes_pattern = implode( '|', $escaped_prefixes );

		// Wrap tags that aren't already wrapped.
		return preg_replace( '/(?<!<!--)(\[(?:' . $prefixes_pattern . ')\/[^\]]+\])(?!-->)/i', '<!--$1-->', $value );
	}

	/**
	 * Get grouped settings structure with field metadata.
	 *
	 * @param WC_Email $email Email instance.
	 * @return array
	 */
	private function get_groups( WC_Email $email ): array {
		$group = array(
			'title'       => __( 'Email Settings', 'woocommerce' ),
			'description' => '',
			'order'       => 1,
			'fields'      => array(),
		);

		$form_fields = $email->get_form_fields();
		foreach ( $form_fields as $id => $field ) {
			$field_type = $field['type'] ?? 'text';

			// Skip non-data fields.
			if ( in_array( $field_type, array( 'title', 'sectionend' ), true ) ) {
				continue;
			}

			$field_schema = array(
				'id'    => $id,
				'label' => $field['title'] ?? $id,
				'type'  => $field_type,
				'desc'  => $field['description'] ?? '',
			);

			// Add options for select/multiselect fields.
			if ( isset( $field['options'] ) && is_array( $field['options'] ) ) {
				$field_schema['options'] = $field['options'];
			}

			$group['fields'][] = $field_schema;
		}

		if ( empty( $group['fields'] ) ) {
			return array();
		}

		return array( 'settings' => $group );
	}

	/**
	 * Validate and sanitize email settings.
	 *
	 * @param WC_Email $email  Email instance.
	 * @param array    $values Values to validate and sanitize.
	 * @return array|WP_Error Validated settings or error.
	 */
	public function validate_and_sanitize_settings( WC_Email $email, array $values ) {
		$email->init_form_fields();
		$validated = array();

		foreach ( $values as $field_id => $value ) {
			// Only allow valid form fields.
			if ( ! isset( $email->form_fields[ $field_id ] ) ) {
				continue;
			}

			$field      = $email->form_fields[ $field_id ];
			$field_type = $field['type'] ?? 'text';

			// Unwrap personalization tags if the field supports them to make sure we don't strip them in the sanitization process.
			if ( in_array( $field_id, self::FIELDS_SUPPORTING_PERSONALIZATION_TAGS, true ) ) {
				$value = $this->unwrap_woocommerce_tags( $value );
			}

			// Sanitize by type.
			$sanitized = $this->sanitize_field_value( $field_type, $value );

			// Sanitize Personalization tags. Wrap them in HTML comments for the email editor.
			if ( in_array( $field_id, self::FIELDS_SUPPORTING_PERSONALIZATION_TAGS, true ) ) {
				$sanitized = $this->wrap_woocommerce_tags( $sanitized );
			}

			// Validate.
			$validation = $this->validate_field_value( $field_id, $sanitized, $field );
			if ( is_wp_error( $validation ) ) {
				return $validation;
			}

			$validated[ $field_id ] = $sanitized;
		}

		return $validated;
	}

	/**
	 * Sanitize field value based on type.
	 *
	 * @param string $type  Field type.
	 * @param mixed  $value Field value.
	 * @return mixed Sanitized value.
	 */
	private function sanitize_field_value( string $type, $value ) {
		switch ( $type ) {
			case 'checkbox':
				// Ensure we have a scalar value for checkbox settings.
				if ( is_array( $value ) ) {
					$value = ! empty( $value ); // Convert array to boolean based on emptiness.
				}
				return wc_bool_to_string( $value );

			case 'email':
				return sanitize_email( $value );

			case 'textarea':
				return sanitize_textarea_field( $value );

			case 'number':
				if ( ! is_numeric( $value ) ) {
					return 0;
				}
				$int_value = filter_var( $value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE );
				return null !== $int_value ? $int_value : floatval( $value );

			case 'multiselect':
				if ( is_array( $value ) ) {
					return array_map( 'sanitize_text_field', $value );
				}
				return is_string( $value ) ? array( sanitize_text_field( $value ) ) : array();

			case 'color':
			case 'password':
			case 'text':
			case 'select':
			default:
				return sanitize_text_field( $value );
		}
	}

	/**
	 * Validate field value.
	 *
	 * @param string $key   Field key.
	 * @param mixed  $value Sanitized value.
	 * @param array  $field Field definition.
	 * @return true|WP_Error True if valid, WP_Error otherwise.
	 */
	private function validate_field_value( string $key, $value, array $field ) {
		$field_type = $field['type'] ?? 'text';

		// Validate email format.
		if ( 'email' === $field_type && ! empty( $value ) && ! is_email( $value ) ) {
			return new WP_Error(
				'rest_invalid_param',
				sprintf(
					/* translators: %s: field key */
					__( 'Invalid email format for %s.', 'woocommerce' ),
					$key
				),
				array( 'status' => 400 )
			);
		}

		// Validate select options.
		if ( 'select' === $field_type && ! empty( $field['options'] ) ) {
			if ( ! array_key_exists( $value, $field['options'] ) && '' !== $value ) {
				return new WP_Error(
					'rest_invalid_param',
					sprintf(
						/* translators: 1: field key, 2: valid options */
						__( 'Invalid value for %1$s. Valid options: %2$s', 'woocommerce' ),
						$key,
						implode( ', ', array_keys( $field['options'] ) )
					),
					array( 'status' => 400 )
				);
			}
		}

		// Validate multiselect options.
		if ( 'multiselect' === $field_type && ! empty( $field['options'] ) ) {
			if ( is_array( $value ) ) {
				foreach ( $value as $v ) {
					if ( ! array_key_exists( $v, $field['options'] ) ) {
						return new WP_Error(
							'rest_invalid_param',
							sprintf(
								/* translators: 1: field key, 2: invalid value */
								__( 'Invalid option "%2$s" for %1$s.', 'woocommerce' ),
								$key,
								$v
							),
							array( 'status' => 400 )
						);
					}
				}
			}
		}

		return true;
	}

	/**
	 * Get all unique prefixes from registered personalization tags.
	 *
	 * Extracts the prefix part (before the /) from all registered personalization tags.
	 * For example, from [woocommerce/customer-name] it extracts 'woocommerce'.
	 * Results are cached to avoid repeated processing.
	 *
	 * @return array Array of unique prefixes, escaped for use in regex patterns.
	 */
	private function get_personalization_tag_prefixes(): array {
		if ( null === $this->personalization_tags_registry ) {
			return array();
		}

		// Return cached prefixes if available.
		if ( null !== $this->cached_prefixes ) {
			return $this->cached_prefixes;
		}

		$prefixes = array();
		$tags     = $this->personalization_tags_registry->get_all();

		foreach ( $tags as $tag ) {
			$token = $tag->get_token(); // E.g., [woocommerce/customer-name].

			// Extract the prefix from the token (the part before the /).
			// Remove brackets and get the part before /.
			if ( preg_match( '/^\[([^\/\]]+)\//', $token, $matches ) ) {
				$prefixes[ $matches[1] ] = true; // Use array key to ensure uniqueness.
			}
		}

		// Convert to array of values and cache.
		$this->cached_prefixes = array_keys( $prefixes );
		return $this->cached_prefixes;
	}
}
PK     [1]ICS3  S3  1  RestApi/Routes/V4/Settings/General/Controller.phpnu         <?php
/**
 * REST API General Settings Controller
 *
 * Handles requests to the /settings/general endpoints.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\General;

use WP_Error;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractController;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\General\Schema\GeneralSettingsSchema;
use WC_Settings_General;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;

defined( 'ABSPATH' ) || exit;

/**
 * REST API General Settings Controller Class.
 */
class Controller extends AbstractController {
	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'settings/general';

	/**
	 * WC_Settings_General instance.
	 *
	 * @var WC_Settings_General
	 */
	protected $settings_general_instance;

	/**
	 * Schema instance.
	 *
	 * @var GeneralSettingsSchema
	 */
	protected $schema;

	/**
	 * Initialize the controller.
	 *
	 * @param GeneralSettingsSchema $schema Schema class.
	 * @internal
	 */
	final public function init( GeneralSettingsSchema $schema ) {
		$this->schema = $schema;
	}

	/**
	 * Get the WC_Settings_General instance.
	 *
	 * @return WC_Settings_General
	 */
	private function get_settings_general_instance() {
		if ( is_null( $this->settings_general_instance ) ) {
			$this->settings_general_instance = new WC_Settings_General();
		}
		return $this->settings_general_instance;
	}

	/**
	 * Register routes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_item' ),
					'permission_callback' => array( $this, 'get_item_permissions_check' ),
				),
				array(
					'methods'             => WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'update_item' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);
	}

	/**
	 * Check permissions for reading general settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return bool|WP_Error
	 */
	public function get_item_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) {
			return new WP_Error(
				'rest_forbidden',
				__( 'Sorry, you are not allowed to access general settings.', 'woocommerce' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}
		return true;
	}

	/**
	 * Check permissions for updating general settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return bool|WP_Error
	 */
	public function update_item_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'settings', 'edit' ) ) {
			return new WP_Error(
				'rest_forbidden',
				__( 'Sorry, you are not allowed to edit general settings.', 'woocommerce' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}
		return true;
	}

	/**
	 * Get general settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_item( $request ) {
		try {
			$settings = $this->get_all_settings();
		} catch ( \Exception $e ) {
			return new WP_Error(
				'woocommerce_rest_general_settings_error',
				$e->getMessage(),
				array( 'status' => 500 )
			);
		}

		$response = $this->get_item_response( $settings, $request );
		return rest_ensure_response( $response );
	}

	/**
	 * Update general settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function update_item( $request ) {
		$params = $request->get_json_params();

		if ( ! is_array( $params ) || empty( $params ) ) {
			return new WP_Error(
				'rest_invalid_param',
				__( 'Invalid or empty request body.', 'woocommerce' ),
				array( 'status' => 400 )
			);
		}

		// Check if the request contains a 'values' field with the flat key-value mapping.
		$values_to_update = array();
		if ( isset( $params['values'] ) && is_array( $params['values'] ) ) {
			$values_to_update = $params['values'];
		} else {
			// Fallback to the old format for backward compatibility.
			$values_to_update = $params;
		}

		// Filter out the woocommerce_share_key_display field as it's not allowed to be updated via API.
		if ( isset( $values_to_update['woocommerce_share_key_display'] ) ) {
			unset( $values_to_update['woocommerce_share_key_display'] );
		}

		// Get all general settings definitions.
		$settings       = $this->get_all_settings();
		$settings_by_id = array_column( $settings, null, 'id' );

		// Exclude non-editable markers like 'title' and 'sectionend'.
		$settings_by_id = array_filter(
			$settings_by_id,
			static function ( $def ) {
				$type = $def['type'] ?? '';
				return isset( $def['id'] ) && ! in_array( $type, array( 'title', 'sectionend' ), true );
			}
		);

		$valid_setting_ids  = array_keys( $settings_by_id );
		$validated_settings = array();

		// Process each setting in the payload.
		foreach ( $values_to_update as $setting_id => $setting_value ) {
			// Sanitize the setting ID.
			$setting_id = sanitize_text_field( $setting_id );

			// Security check: only allow updating valid WooCommerce general settings.
			if ( ! in_array( $setting_id, $valid_setting_ids, true ) ) {
				continue;
			}

			// Sanitize the value based on the setting type.
			$setting_definition = $settings_by_id[ $setting_id ];
			$setting_type       = $setting_definition['type'] ?? 'text';
			$sanitized_value    = $this->sanitize_setting_value( $setting_type, $setting_value );

			// Additional validation for specific settings.
			$validation_result = $this->validate_setting_value( $setting_id, $sanitized_value );
			if ( is_wp_error( $validation_result ) ) {
				return $validation_result;
			}

			// Store validated values first.
			$validated_settings[ $setting_id ] = $sanitized_value;
		}

		// After validation loop, update all settings.
		$updated_settings = array();
		foreach ( $validated_settings as $setting_id => $value ) {
			$update_result = update_option( $setting_id, $value );
			if ( $update_result ) {
				$updated_settings[] = $setting_id;
			}
		}

		// Log the update if settings were changed.
		if ( ! empty( $updated_settings ) ) {
			/**
			* Fires when WooCommerce settings are updated.
			*
			* @param array $updated_settings Array of updated settings IDs.
			* @param string $rest_base The REST base of the settings.
			* @since 4.0.0
			*/
			do_action( 'woocommerce_settings_updated', $updated_settings, $this->rest_base );
		}

		// Get all settings after update.
		$settings = $this->get_all_settings();

		// Return updated settings.
		$response = $this->get_item_response( $settings, $request );
		return rest_ensure_response( $response );
	}

	/**
	 * Validate a setting value before updating.
	 *
	 * @param string $setting_id Setting ID.
	 * @param mixed  $value      Setting value.
	 * @return bool|WP_Error True if valid, WP_Error if invalid.
	 */
	private function validate_setting_value( $setting_id, $value ) {
		// Custom validation rules for specific settings.
		switch ( $setting_id ) {
			case 'woocommerce_price_num_decimals':
				$int = filter_var( $value, FILTER_VALIDATE_INT );
				if ( false === $int || $int < 0 || $int > 10 ) {
					return new WP_Error(
						'rest_invalid_param',
						__( 'Number of decimals must be between 0 and 10.', 'woocommerce' ),
						array( 'status' => 400 )
					);
				}
				break;

			case 'woocommerce_default_country':
				if ( ! $this->validate_country_or_state_code( $value ) ) {
					return new WP_Error(
						'rest_invalid_param',
						__( 'Invalid country/state format.', 'woocommerce' ),
						array( 'status' => 400 )
					);
				}
				break;

			case 'woocommerce_allowed_countries':
				$valid_options = array( 'all', 'all_except', 'specific' );
				if ( ! in_array( $value, $valid_options, true ) ) {
					return new WP_Error(
						'rest_invalid_param',
						__( 'Invalid selling location option.', 'woocommerce' ),
						array( 'status' => 400 )
					);
				}
				break;

			case 'woocommerce_ship_to_countries':
				$valid_options = array( '', 'all', 'specific', 'disabled' );
				if ( ! in_array( $value, $valid_options, true ) ) {
					return new WP_Error(
						'rest_invalid_param',
						__( 'Invalid shipping location option.', 'woocommerce' ),
						array( 'status' => 400 )
					);
				}
				break;

			case 'woocommerce_specific_allowed_countries':
			case 'woocommerce_specific_ship_to_countries':
				if ( ! is_array( $value ) ) {
					return new WP_Error(
						'rest_invalid_param',
						__( 'Expected an array of country codes.', 'woocommerce' ),
						array( 'status' => 400 )
					);
				}

				foreach ( $value as $code ) {
					if ( ! is_string( $code ) || ! $this->validate_country_or_state_code( $code ) ) {
						return new WP_Error(
							'rest_invalid_param',
							__( 'Invalid country code in list.', 'woocommerce' ),
							array( 'status' => 400 )
						);
					}
				}
				break;
		}

		return true;
	}

	/**
	 * Sanitize setting value based on its type.
	 *
	 * @param string $setting_type Setting type.
	 * @param mixed  $value        Setting value.
	 * @return mixed Sanitized value.
	 */
	private function sanitize_setting_value( $setting_type, $value ) {
		// Normalize WooCommerce setting types to REST API schema types.
		$type_map     = array(
			'single_select_country'  => 'select',
			'multi_select_countries' => 'multiselect',
		);
		$setting_type = $type_map[ $setting_type ] ?? $setting_type;

		switch ( $setting_type ) {
			case 'text':
				return sanitize_text_field( $value );

			case 'number':
				if ( ! is_numeric( $value ) ) {
					return 0;
				}

				return filter_var( $value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? floatval( $value );

			case 'checkbox':
				// Ensure we have a scalar value for checkbox settings.
				if ( is_array( $value ) ) {
					$value = ! empty( $value ); // Convert array to boolean based on emptiness.
				}
				return wc_bool_to_string( $value );

			case 'select':
				return sanitize_text_field( $value );

			case 'multiselect':
				if ( is_array( $value ) ) {
					return array_map( 'sanitize_text_field', $value );
				}

				if ( is_string( $value ) ) {
					return array( sanitize_text_field( $value ) );
				}

				if ( is_scalar( $value ) ) {
					return array( sanitize_text_field( (string) $value ) );
				}

				return array();

			default:
				// If a type is not explicitly handled, treat it as text.
				return sanitize_text_field( $value );
		}
	}

	/**
	 * Get all general settings definitions.
	 *
	 * @return array Array of setting definitions.
	 */
	private function get_all_settings(): array {
		$settings_instance = $this->get_settings_general_instance();
		$sections          = $settings_instance->get_sections();
		$settings          = array();

		foreach ( array_keys( $sections ) as $section ) {
			$section_settings = $settings_instance->get_settings_for_section( $section );
			$settings         = array_merge( $settings, $section_settings );
		}

		return $settings;
	}

	/**
	 * Validate country or state code.
	 *
	 * @param string $country_or_state Country or state code.
	 * @return boolean Valid or not valid.
	 */
	private function validate_country_or_state_code( $country_or_state ) {
		list( $country, $state ) = array_pad( explode( ':', (string) $country_or_state, 2 ), 2, '' );
		if ( '' === $country ) {
			return false;
		}
		$country_codes = array_keys( WC()->countries->get_countries() );
		if ( ! in_array( $country, $country_codes, true ) ) {
			return false;
		}
		if ( '' === $state ) {
			return true;
		}
		$states_for_country = WC()->countries->get_states( $country );
		if ( empty( $states_for_country ) ) {
			return false;
		}
		return isset( $states_for_country[ $state ] );
	}

	/**
	 * Get the schema for the current resource.
	 *
	 * @return array
	 */
	public function get_schema(): array {
		return $this->schema->get_item_schema();
	}

	/**
	 * Get the item schema for the controller.
	 *
	 * @return array
	 */
	public function get_item_schema(): array {
		return $this->get_schema();
	}

	/**
	 * Get the item response for a single settings group.
	 *
	 * @param mixed           $item Settings data.
	 * @param WP_REST_Request $request Request object.
	 * @return array
	 */
	protected function get_item_response( $item, WP_REST_Request $request ): array {
		return $this->schema->get_item_response( $item, $request );
	}

	/**
	 * Get the endpoint args for item schema.
	 *
	 * @param string $method HTTP method of the request.
	 * @return array Endpoint arguments.
	 */
	public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ): array {
		return rest_get_endpoint_args_for_schema( $this->get_item_schema(), $method );
	}
}
PK     [1]D(  (  C  RestApi/Routes/V4/Settings/General/Schema/GeneralSettingsSchema.phpnu         <?php
/**
 * GeneralSettingsSchema class.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\General\Schema;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractSchema;
use WP_REST_Request;

defined( 'ABSPATH' ) || exit;

/**
 * GeneralSettingsSchema class.
 */
class GeneralSettingsSchema extends AbstractSchema {
	/**
	 * The schema item identifier.
	 *
	 * @var string
	 */
	const IDENTIFIER = 'general_settings';

	/**
	 * Return all properties for the item schema.
	 *
	 * @return array
	 */
	public function get_item_schema_properties(): array {
		return array(
			'id'          => array(
				'description' => __( 'Unique identifier for the settings group.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'title'       => array(
				'description' => __( 'Settings title.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'description' => array(
				'description' => __( 'Settings description.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'values'      => array(
				'description'          => __( 'Flat key-value mapping of all setting field values.', 'woocommerce' ),
				'type'                 => 'object',
				'context'              => self::VIEW_EDIT_CONTEXT,
				'additionalProperties' => array(
					'description' => __( 'Setting field value.', 'woocommerce' ),
					'type'        => array( 'string', 'number', 'array', 'boolean' ),
				),
			),
			'groups'      => array(
				'description'          => __( 'Collection of setting groups.', 'woocommerce' ),
				'type'                 => 'object',
				'context'              => self::VIEW_EDIT_CONTEXT,
				'additionalProperties' => array(
					'type'        => 'object',
					'description' => __( 'Settings group.', 'woocommerce' ),
					'properties'  => array(
						'title'       => array(
							'description' => __( 'Group title.', 'woocommerce' ),
							'type'        => 'string',
							'context'     => self::VIEW_EDIT_CONTEXT,
						),
						'description' => array(
							'description' => __( 'Group description.', 'woocommerce' ),
							'type'        => 'string',
							'context'     => self::VIEW_EDIT_CONTEXT,
						),
						'order'       => array(
							'description' => __( 'Display order for the group.', 'woocommerce' ),
							'type'        => 'integer',
							'context'     => self::VIEW_EDIT_CONTEXT,
							'readonly'    => true,
						),
						'fields'      => array(
							'description' => __( 'Settings fields.', 'woocommerce' ),
							'type'        => 'array',
							'context'     => self::VIEW_EDIT_CONTEXT,
							'items'       => $this->get_field_schema(),
						),
					),
				),
			),
		);
	}

	/**
	 * Get the schema for individual setting fields.
	 *
	 * @return array
	 */
	private function get_field_schema(): array {
		return array(
			'type'       => 'object',
			'properties' => array(
				'id'      => array(
					'description' => __( 'Setting field ID.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
				'label'   => array(
					'description' => __( 'Setting field label.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
				'type'    => array(
					'description' => __( 'Setting field type.', 'woocommerce' ),
					'type'        => 'string',
					'enum'        => array( 'text', 'number', 'select', 'multiselect', 'checkbox' ),
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
				'options' => array(
					'description' => __( 'Available options for select/multiselect fields.', 'woocommerce' ),
					'type'        => 'object',
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
				'desc'    => array(
					'description' => __( 'Description for the setting field.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
			),
		);
	}

	/**
	 * Get general settings data by transforming raw settings into REST API format.
	 *
	 * @param mixed           $item             Raw settings array.
	 * @param WP_REST_Request $request          Request object.
	 * @param array           $include_fields   Fields to include.
	 * @return array
	 */
	public function get_item_response( $item, WP_REST_Request $request, array $include_fields = array() ): array {
		$raw_settings = $item;

		// Transform raw settings into grouped format based on title/sectionend markers.
		$groups           = array();
		$values           = array();
		$current_group    = null;
		$current_group_id = null;

		foreach ( $raw_settings as $setting ) {
			$setting_type = $setting['type'] ?? '';

			// Handle section titles - start of a new group.
			if ( 'title' === $setting_type ) {
				$current_group_id = $setting['id'] ?? '';
				$current_group    = array(
					'title'       => $setting['title'] ?? '',
					'description' => $setting['desc'] ?? '',
					'order'       => isset( $setting['order'] ) ? (int) $setting['order'] : 999,
					'fields'      => array(),
				);
				continue;
			}

			// Handle section ends - save the current group.
			if ( 'sectionend' === $setting_type ) {
				if ( $current_group && $current_group_id ) {
					$groups[ $current_group_id ] = $current_group;
				}
				$current_group    = null;
				$current_group_id = null;
				continue;
			}

			// Skip title and sectionend types.
			if ( in_array( $setting_type, array( 'title', 'sectionend' ), true ) ) {
				continue;
			}

			// Convert setting to field format.
			if ( isset( $setting['id'] ) && $current_group ) {
				$field = $this->transform_setting_to_field( $setting );
				if ( $field ) {
					$current_group['fields'][] = $field;
					// Add field value to the flat values array.
					$raw_value              = get_option( $field['id'], $setting['default'] ?? '' );
					$values[ $field['id'] ] = $this->validate_field_value( $raw_value, $field['type'] );
				}
			}
		}

		// Sort groups by their order if available.
		uasort(
			$groups,
			function ( $a, $b ) {
				$a_order = $a['order'] ?? 999;
				$b_order = $b['order'] ?? 999;
				return $a_order - $b_order;
			}
		);

		$response = array(
			'id'          => 'general',
			'title'       => __( 'General', 'woocommerce' ),
			'description' => __( 'Set your store\'s address, visibility, currency, language, and timezone.', 'woocommerce' ),
			'values'      => $values,
			'groups'      => $groups,
		);

		if ( ! empty( $include_fields ) ) {
			$response = array_intersect_key( $response, array_flip( $include_fields ) );
		}

		return $response;
	}

	/**
	 * Transform a WooCommerce setting into REST API field format.
	 *
	 * @param array $setting WooCommerce setting array.
	 * @return array|null Transformed field or null if should be skipped.
	 */
	private function transform_setting_to_field( array $setting ): ?array {
		$setting_id   = $setting['id'] ?? '';
		$setting_type = $setting['type'] ?? 'text';

		$field = array(
			'id'    => $setting_id,
			'label' => $setting['title'] ?? $setting_id,
			'type'  => $this->normalize_field_type( $setting_type ),
			'desc'  => $setting['desc'] ?? '',
		);

		// Add options for select fields.
		if ( isset( $setting['options'] ) && is_array( $setting['options'] ) ) {
			$field['options'] = $setting['options'];
		} else {
			// Generate options for special field types.
			$field['options'] = $this->get_field_options( $setting_id );
		}

		return $field;
	}

	/**
	 * Get options for specific field types.
	 *
	 * @param string $field_id Field ID.
	 * @return array Field options.
	 */
	private function get_field_options( string $field_id ): array {
		switch ( $field_id ) {
			case 'woocommerce_currency':
				if ( ! function_exists( 'get_woocommerce_currencies' ) || ! function_exists( 'get_woocommerce_currency_symbol' ) ) {
					return array();
				}

				$currencies = get_woocommerce_currencies();
				$options    = array();

				foreach ( $currencies as $code => $name ) {
					$label            = wp_specialchars_decode( (string) $name );
					$symbol           = wp_specialchars_decode( (string) get_woocommerce_currency_symbol( $code ) );
					$options[ $code ] = $label . ' (' . $symbol . ') — ' . $code;
				}

				return $options;

			case 'woocommerce_default_country':
			case 'woocommerce_specific_allowed_countries':
			case 'woocommerce_specific_ship_to_countries':
				if ( ! function_exists( 'WC' ) ) {
					return array();
				}

				$countries = WC()->countries->get_countries();
				$states    = WC()->countries->get_states();
				$options   = array();

				foreach ( $countries as $country_code => $country_name ) {
					$country_states = $states[ $country_code ] ?? array();

					if ( empty( $country_states ) ) {
						$options[ $country_code ] = $country_name;
					} else {
						foreach ( $country_states as $state_code => $state_name ) {
							$options[ $country_code . ':' . $state_code ] = $country_name . ' — ' . $state_name;
						}
					}
				}

				return $options;
		}

		return array();
	}

	/**
	 * Normalize WooCommerce field types to REST API field types.
	 *
	 * @param string $wc_type WooCommerce field type.
	 * @return string Normalized field type.
	 */
	private function normalize_field_type( string $wc_type ): string {
		$type_map = array(
			'single_select_country'  => 'select',
			'multi_select_countries' => 'multiselect',
		);

		return $type_map[ $wc_type ] ?? $wc_type;
	}

	/**
	 * Validate and sanitize field value based on its type.
	 *
	 * @param mixed  $value Field value.
	 * @param string $type  Field type.
	 * @return mixed Validated value.
	 */
	private function validate_field_value( $value, string $type ) {
		switch ( $type ) {
			case 'number':
				return is_numeric( $value ) ? (float) $value : 0;
			case 'checkbox':
				if ( function_exists( 'wc_string_to_bool' ) ) {
					return wc_string_to_bool( $value );
				}
				if ( is_bool( $value ) ) {
					return $value;
				}
				return filter_var( $value, FILTER_VALIDATE_BOOLEAN );
			case 'multiselect':
				if ( ! is_array( $value ) ) {
					return array();
				}
				return array_map( 'sanitize_text_field', $value );
			case 'text':
			case 'select':
			default:
				return is_string( $value ) ? $value : (string) $value;
		}
	}
}
PK     [1]+d;vw#  w#  C  RestApi/Routes/V4/Settings/Account/Schema/AccountSettingsSchema.phpnu         <?php
/**
 * AccountSettingsSchema class.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\Account\Schema;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractSchema;
use WP_REST_Request;

defined( 'ABSPATH' ) || exit;

/**
 * AccountSettingsSchema class.
 */
class AccountSettingsSchema extends AbstractSchema {
	/**
	 * The schema item identifier.
	 *
	 * @var string
	 */
	const IDENTIFIER = 'account_settings';

	/**
	 * Return all properties for the item schema.
	 *
	 * @return array
	 */
	public function get_item_schema_properties(): array {
		return array(
			'id'          => array(
				'description' => __( 'Unique identifier for the settings group.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'title'       => array(
				'description' => __( 'Settings title.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'description' => array(
				'description' => __( 'Settings description.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'values'      => array(
				'description'          => __( 'Flat key-value mapping of all setting field values.', 'woocommerce' ),
				'type'                 => 'object',
				'context'              => self::VIEW_EDIT_CONTEXT,
				'additionalProperties' => array(
					'description' => __( 'Setting field value.', 'woocommerce' ),
					'type'        => array( 'string', 'number', 'array', 'boolean' ),
				),
			),
			'groups'      => array(
				'description'          => __( 'Collection of setting groups.', 'woocommerce' ),
				'type'                 => 'object',
				'context'              => self::VIEW_EDIT_CONTEXT,
				'additionalProperties' => array(
					'type'        => 'object',
					'description' => __( 'Settings group.', 'woocommerce' ),
					'properties'  => array(
						'title'       => array(
							'description' => __( 'Group title.', 'woocommerce' ),
							'type'        => 'string',
							'context'     => self::VIEW_EDIT_CONTEXT,
						),
						'description' => array(
							'description' => __( 'Group description.', 'woocommerce' ),
							'type'        => 'string',
							'context'     => self::VIEW_EDIT_CONTEXT,
						),
						'order'       => array(
							'description' => __( 'Display order for the group.', 'woocommerce' ),
							'type'        => 'integer',
							'context'     => self::VIEW_EDIT_CONTEXT,
							'readonly'    => true,
						),
						'fields'      => array(
							'description' => __( 'Settings fields.', 'woocommerce' ),
							'type'        => 'array',
							'context'     => self::VIEW_EDIT_CONTEXT,
							'items'       => $this->get_field_schema(),
						),
					),
				),
			),
		);
	}

	/**
	 * Get the schema for individual setting fields.
	 *
	 * @return array
	 */
	private function get_field_schema(): array {
		return array(
			'type'       => 'object',
			'properties' => array(
				'id'      => array(
					'description' => __( 'Setting field ID.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
				'label'   => array(
					'description' => __( 'Setting field label.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
				'type'    => array(
					'description' => __( 'Setting field type.', 'woocommerce' ),
					'type'        => 'string',
					'enum'        => array( 'text', 'textarea', 'number', 'select', 'multiselect', 'checkbox' ),
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
				'options' => array(
					'description' => __( 'Available options for select/multiselect fields.', 'woocommerce' ),
					'type'        => 'object',
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
				'desc'    => array(
					'description' => __( 'Description for the setting field.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
			),
		);
	}

	/**
	 * Get account settings data by transforming raw settings into REST API format.
	 *
	 * @param mixed           $item             Raw settings array.
	 * @param WP_REST_Request $request          Request object.
	 * @param array           $include_fields   Fields to include.
	 * @return array
	 */
	public function get_item_response( $item, WP_REST_Request $request, array $include_fields = array() ): array {
		$raw_settings = $item;

		// Transform raw settings into grouped format based on title/sectionend markers.
		$groups           = array();
		$values           = array();
		$current_group    = null;
		$current_group_id = null;

		foreach ( $raw_settings as $setting ) {
			$setting_type = $setting['type'] ?? '';

			// Handle section titles - start of a new group.
			if ( 'title' === $setting_type ) {
				$current_group_id = $setting['id'] ?? '';
				$current_group    = array(
					'title'       => $setting['title'] ?? '',
					'description' => $setting['desc'] ?? '',
					'order'       => isset( $setting['order'] ) ? (int) $setting['order'] : 999,
					'fields'      => array(),
				);
				continue;
			}

			// Handle section ends - save the current group.
			if ( 'sectionend' === $setting_type ) {
				if ( $current_group && $current_group_id ) {
					$groups[ $current_group_id ] = $current_group;
				}
				$current_group    = null;
				$current_group_id = null;
				continue;
			}

			// Convert setting to field format.
			if ( isset( $setting['id'] ) && $current_group ) {
				$field = $this->transform_setting_to_field( $setting );
				if ( $field ) {
					$current_group['fields'][] = $field;
					// Add field value to the flat values array.
					$raw_value              = get_option( $field['id'], $setting['default'] ?? '' );
					$values[ $field['id'] ] = $this->validate_field_value( $raw_value, $field['type'] );
				}
			}
		}

		// Sort groups by their order if available.
		uasort(
			$groups,
			function ( $a, $b ) {
				$a_order = $a['order'] ?? 999;
				$b_order = $b['order'] ?? 999;
				return $a_order - $b_order;
			}
		);

		$response = array(
			'id'          => 'account',
			'title'       => __( 'Accounts & Privacy', 'woocommerce' ),
			'description' => __( 'Set options relating to customer accounts and data privacy.', 'woocommerce' ),
			'values'      => $values,
			'groups'      => $groups,
		);

		if ( ! empty( $include_fields ) ) {
			$response = array_intersect_key( $response, array_flip( $include_fields ) );
		}

		return $response;
	}

	/**
	 * Transform a WooCommerce setting into REST API field format.
	 *
	 * @param array $setting WooCommerce setting array.
	 * @return array|null Transformed field or null if should be skipped.
	 */
	private function transform_setting_to_field( array $setting ): ?array {
		$setting_id   = $setting['id'] ?? '';
		$setting_type = $setting['type'] ?? 'text';

		$field = array(
			'id'    => $setting_id,
			'label' => $setting['title'] ?? $setting_id,
			'type'  => $this->normalize_field_type( $setting_type ),
			'desc'  => $setting['desc'] ?? '',
		);

		// Add options for select fields.
		if ( isset( $setting['options'] ) && is_array( $setting['options'] ) ) {
			$field['options'] = $setting['options'];
		} else {
			// Generate options for special field types.
			$field['options'] = $this->get_field_options( $setting_id );
		}

		return $field;
	}

	/**
	 * Get options for specific field types.
	 *
	 * @param string $field_id Field ID.
	 * @return array Field options.
	 */
	private function get_field_options( string $field_id ): array {
		// No field has options for now.
		return array();
	}

	/**
	 * Normalize WooCommerce field types to REST API field types.
	 *
	 * @param string $wc_type WooCommerce field type.
	 * @return string Normalized field type.
	 */
	private function normalize_field_type( string $wc_type ): string {
		$type_map = array(
			'single_select_page'             => 'select',
			'single_select_page_with_search' => 'select',
		);

		return $type_map[ $wc_type ] ?? $wc_type;
	}

	/**
	 * Validate and sanitize field value based on its type.
	 *
	 * @param mixed  $value Field value.
	 * @param string $type  Field type.
	 * @return mixed Validated value.
	 */
	private function validate_field_value( $value, string $type ) {
		switch ( $type ) {
			case 'number':
				return is_numeric( $value ) ? (float) $value : 0;
			case 'checkbox':
				if ( function_exists( 'wc_string_to_bool' ) ) {
					return wc_string_to_bool( $value );
				}
				if ( is_bool( $value ) ) {
					return $value;
				}
				return filter_var( $value, FILTER_VALIDATE_BOOLEAN );
			case 'multiselect':
				if ( ! is_array( $value ) ) {
					return array();
				}
				return array_map( 'sanitize_text_field', $value );
			case 'textarea':
				return sanitize_textarea_field( $value );
			case 'text':
			case 'select':
			default:
				return sanitize_text_field( $value );
		}
	}
}
PK     [1]U(  (  1  RestApi/Routes/V4/Settings/Account/Controller.phpnu         <?php
/**
 * REST API Account Settings Controller
 *
 * Handles requests to the /settings/account endpoints.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\Account;

use WP_Error;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractController;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\Account\Schema\AccountSettingsSchema;
use WC_Settings_Accounts;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;

defined( 'ABSPATH' ) || exit;

/**
 * REST API Account Settings Controller Class.
 */
class Controller extends AbstractController {
	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'settings/account';

	/**
	 * WC_Settings_Accounts instance.
	 *
	 * @var WC_Settings_Accounts
	 */
	protected $settings_account_instance;

	/**
	 * Schema instance.
	 *
	 * @var AccountSettingsSchema
	 */
	protected $schema;

	/**
	 * Initialize the controller.
	 *
	 * @param AccountSettingsSchema $schema Schema class.
	 * @internal
	 */
	final public function init( AccountSettingsSchema $schema ) {
		$this->schema = $schema;
	}

	/**
	 * Get the WC_Settings_Accounts instance.
	 *
	 * @return WC_Settings_Accounts
	 */
	private function get_settings_account_instance() {
		if ( is_null( $this->settings_account_instance ) ) {
			// We need to mock the admin environment to get the settings.
			if ( ! class_exists( 'WC_Admin_Settings' ) ) {
				require_once WC_ABSPATH . 'includes/admin/class-wc-admin-settings.php';
			}
			$this->settings_account_instance = new WC_Settings_Accounts();
		}
		return $this->settings_account_instance;
	}

	/**
	 * Register routes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_item' ),
					'permission_callback' => array( $this, 'get_item_permissions_check' ),
				),
				array(
					'methods'             => WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'update_item' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);
	}

	/**
	 * Check permissions for reading account settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return bool|WP_Error
	 */
	public function get_item_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) {
			return new WP_Error(
				'rest_forbidden',
				__( 'Sorry, you are not allowed to access account settings.', 'woocommerce' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}
		return true;
	}

	/**
	 * Check permissions for updating account settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return bool|WP_Error
	 */
	public function update_item_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'settings', 'edit' ) ) {
			return new WP_Error(
				'rest_forbidden',
				__( 'Sorry, you are not allowed to edit account settings.', 'woocommerce' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}
		return true;
	}

	/**
	 * Get account settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_item( $request ) {
		try {
			$settings = $this->get_all_settings();
		} catch ( \Exception $e ) {
			return new WP_Error(
				'woocommerce_rest_account_settings_error',
				$e->getMessage(),
				array( 'status' => 500 )
			);
		}

		$response = $this->get_item_response( $settings, $request );
		return rest_ensure_response( $response );
	}

	/**
	 * Update account settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function update_item( $request ) {
		$params = $request->get_json_params();

		if ( ! is_array( $params ) || empty( $params ) ) {
			return new WP_Error(
				'rest_invalid_param',
				__( 'Invalid or empty request body.', 'woocommerce' ),
				array( 'status' => 400 )
			);
		}

		// Check if the request contains a 'values' field with the flat key-value mapping.
		$values_to_update = array();
		if ( isset( $params['values'] ) && is_array( $params['values'] ) ) {
			$values_to_update = $params['values'];
		} else {
			// Fallback to the old format for backward compatibility.
			$values_to_update = $params;
		}

		// Get all account settings definitions.
		$settings       = $this->get_all_settings();
		$settings_by_id = array_column( $settings, null, 'id' );

		// Exclude non-editable markers like 'title' and 'sectionend'.
		$settings_by_id = array_filter(
			$settings_by_id,
			static function ( $def ) {
				$type = $def['type'] ?? '';
				return isset( $def['id'] ) && ! in_array( $type, array( 'title', 'sectionend' ), true );
			}
		);

		$valid_setting_ids  = array_keys( $settings_by_id );
		$validated_settings = array();

		// Process each setting in the payload.
		foreach ( $values_to_update as $setting_id => $setting_value ) {
			// Sanitize the setting ID.
			$setting_id = sanitize_text_field( $setting_id );

			// Security check: only allow updating valid WooCommerce account settings.
			if ( ! in_array( $setting_id, $valid_setting_ids, true ) ) {
				continue;
			}

			// Sanitize the value based on the setting type.
			$setting_definition = $settings_by_id[ $setting_id ];
			$setting_type       = $setting_definition['type'] ?? 'text';
			$sanitized_value    = $this->sanitize_setting_value( $setting_type, $setting_value );

			// Additional validation for specific settings.
			$validation_result = $this->validate_setting_value( $setting_id, $sanitized_value );
			if ( is_wp_error( $validation_result ) ) {
				return $validation_result;
			}

			// Store validated values first.
			$validated_settings[ $setting_id ] = $sanitized_value;
		}

		// After validation loop, update all settings.
		$updated_settings = array();
		foreach ( $validated_settings as $setting_id => $value ) {
			$update_result = update_option( $setting_id, $value );
			if ( $update_result ) {
				$updated_settings[] = $setting_id;
			}
		}

		// Log the update if settings were changed.
		if ( ! empty( $updated_settings ) ) {
			/**
			* Fires when WooCommerce settings are updated.
			*
			* @param array $updated_settings Array of updated settings IDs.
			* @param string $rest_base The REST base of the settings.
			* @since 4.0.0
			*/
			do_action( 'woocommerce_settings_updated', $updated_settings, $this->rest_base );
		}

		// Get all settings after update.
		$settings = $this->get_all_settings();

		// Return updated settings.
		$response = $this->get_item_response( $settings, $request );
		return rest_ensure_response( $response );
	}

	/**
	 * Validate a setting value before updating.
	 *
	 * @param string $setting_id Setting ID.
	 * @param mixed  $value      Setting value.
	 * @return bool|WP_Error True if valid, WP_Error if invalid.
	 */
	private function validate_setting_value( $setting_id, $value ) {
		// No specific validation for account settings yet.
		return true;
	}

	/**
	 * Sanitize setting value based on its type.
	 *
	 * @param string $setting_type Setting type.
	 * @param mixed  $value        Setting value.
	 * @return mixed Sanitized value.
	 */
	private function sanitize_setting_value( $setting_type, $value ) {
		// Normalize WooCommerce setting types to REST API schema types.
		$type_map     = array(
			'single_select_page'             => 'select',
			'single_select_page_with_search' => 'select',
		);
		$setting_type = $type_map[ $setting_type ] ?? $setting_type;

		switch ( $setting_type ) {
			case 'text':
				return sanitize_text_field( $value );

			case 'textarea':
				return sanitize_textarea_field( $value );

			case 'number':
				if ( ! is_numeric( $value ) ) {
					return 0;
				}

				return filter_var( $value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? floatval( $value );

			case 'checkbox':
				// Ensure we have a scalar value for checkbox settings.
				if ( is_array( $value ) ) {
					$value = ! empty( $value ); // Convert array to boolean based on emptiness.
				}
				return wc_bool_to_string( $value );

			case 'select':
				return sanitize_text_field( $value );

			case 'multiselect':
				if ( is_array( $value ) ) {
					return array_map( 'sanitize_text_field', $value );
				}

				if ( is_string( $value ) ) {
					return array( sanitize_text_field( $value ) );
				}

				if ( is_scalar( $value ) ) {
					return array( sanitize_text_field( (string) $value ) );
				}

				return array();

			default:
				// If a type is not explicitly handled, treat it as text.
				return sanitize_text_field( $value );
		}
	}

	/**
	 * Get all account settings definitions.
	 *
	 * @return array Array of setting definitions.
	 */
	private function get_all_settings(): array {
		$settings_instance = $this->get_settings_account_instance();
		return $settings_instance->get_settings();
	}

	/**
	 * Get the schema for the current resource.
	 *
	 * @return array
	 */
	public function get_schema(): array {
		return $this->schema->get_item_schema();
	}

	/**
	 * Get the item schema for the controller.
	 *
	 * @return array
	 */
	public function get_item_schema(): array {
		return $this->get_schema();
	}

	/**
	 * Get the item response for a single settings group.
	 *
	 * @param mixed           $item Settings data.
	 * @param WP_REST_Request $request Request object.
	 * @return array
	 */
	protected function get_item_response( $item, WP_REST_Request $request ): array {
		return $this->schema->get_item_response( $item, $request );
	}

	/**
	 * Get the endpoint args for item schema.
	 *
	 * @param string $method HTTP method of the request.
	 * @return array Endpoint arguments.
	 */
	public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ): array {
		return rest_get_endpoint_args_for_schema( $this->get_item_schema(), $method );
	}
}
PK     [1]<m  m  V  RestApi/Routes/V4/Settings/OfflinePaymentMethods/Schema/OfflinePaymentMethodSchema.phpnu         <?php
/**
 * OfflinePaymentMethodSchema class.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\OfflinePaymentMethods\Schema;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractSchema;
use WP_REST_Request;

defined( 'ABSPATH' ) || exit;

/**
 * OfflinePaymentMethodSchema class.
 */
class OfflinePaymentMethodSchema extends AbstractSchema {
	/**
	 * The schema item identifier.
	 *
	 * @var string
	 */
	const IDENTIFIER = 'offline_payment_method';

	/**
	 * Return all properties for the item schema.
	 *
	 * @return array
	 */
	public function get_item_schema_properties(): array {
		return array(
			'id'          => array(
				'description' => __( 'Unique identifier for the settings group.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'title'       => array(
				'description' => __( 'Title of the settings group.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'description' => array(
				'description' => __( 'Description of the settings group.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'values'      => array(
				'description'          => __( 'Current enabled state for all payment methods.', 'woocommerce' ),
				'type'                 => 'object',
				'context'              => self::VIEW_EDIT_CONTEXT,
				'readonly'             => true,
				'additionalProperties' => array(
					'type' => 'boolean',
				),
			),
			'groups'      => array(
				'description' => __( 'Grouped settings for offline payment methods.', 'woocommerce' ),
				'type'        => 'object',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
				'properties'  => array(
					'payment_methods' => array(
						'description'          => __( 'Available offline payment methods.', 'woocommerce' ),
						'type'                 => 'object',
						'context'              => self::VIEW_EDIT_CONTEXT,
						'readonly'             => true,
						'additionalProperties' => array(
							'type'       => 'object',
							'properties' => array(
								'id'          => array(
									'description' => __( 'Unique identifier for the payment method.', 'woocommerce' ),
									'type'        => 'string',
									'context'     => self::VIEW_EDIT_CONTEXT,
								),
								'_order'      => array(
									'description' => __( 'Sort order for the payment method.', 'woocommerce' ),
									'type'        => 'integer',
									'context'     => self::VIEW_EDIT_CONTEXT,
								),
								'title'       => array(
									'description' => __( 'Title of the payment method.', 'woocommerce' ),
									'type'        => 'string',
									'context'     => self::VIEW_EDIT_CONTEXT,
								),
								'description' => array(
									'description' => __( 'Description of the payment method.', 'woocommerce' ),
									'type'        => 'string',
									'context'     => self::VIEW_EDIT_CONTEXT,
								),
								'icon'        => array(
									'description' => __( 'Icon URL for the payment method.', 'woocommerce' ),
									'type'        => 'string',
									'format'      => 'uri',
									'context'     => self::VIEW_EDIT_CONTEXT,
								),
								'state'       => array(
									'description'          => __( 'Current state configuration of the payment method.', 'woocommerce' ),
									'type'                 => 'object',
									'context'              => self::VIEW_EDIT_CONTEXT,
									'additionalProperties' => array(
										'type' => 'boolean',
									),
								),
								'management'  => array(
									'description'          => __( 'Management options for the payment method.', 'woocommerce' ),
									'type'                 => 'object',
									'context'              => self::VIEW_EDIT_CONTEXT,
									'properties'           => array(
										'_links' => array(
											'description' => __( 'Management links for the payment method.', 'woocommerce' ),
											'type'        => 'object',
											'context'     => self::VIEW_EDIT_CONTEXT,
											'additionalProperties' => array(
												'type' => 'object',
												'properties' => array(
													'href' => array(
														'description' => __( 'URL for the management link.', 'woocommerce' ),
														'type'        => 'string',
														'format'      => 'uri',
														'context'     => self::VIEW_EDIT_CONTEXT,
													),
												),
												'additionalProperties' => false,
											),
										),
									),
									'additionalProperties' => false,
								),
							),
						),
					),
				),
			),
		);
	}

	/**
	 * Get the item response.
	 *
	 * @param mixed           $item Payment method data array.
	 * @param WP_REST_Request $request Request object.
	 * @param array           $include_fields Fields to include in the response.
	 * @return array The item response.
	 * @SuppressWarnings(PHPMD.UnusedFormalParameter) $request is unused; filtering handled by REST server.
	 */
	public function get_item_response( $item, WP_REST_Request $request, array $include_fields = array() ): array {
		$response = (array) $item;

		if ( ! empty( $include_fields ) ) {
			$response = array_intersect_key( $response, array_flip( $include_fields ) );
		}

		return $response;
	}
}
PK     [1]^Z!  Z!  ?  RestApi/Routes/V4/Settings/OfflinePaymentMethods/Controller.phpnu         <?php
/**
 * REST API Offline Payment Methods Controller
 *
 * Handles requests to the /settings/payments/offline-methods endpoint.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\OfflinePaymentMethods;

use Automattic\WooCommerce\Internal\Admin\Settings\Payments;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractController;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\OfflinePaymentMethods\Schema\OfflinePaymentMethodSchema;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * REST API Offline Payment Methods Controller Class.
 *
 * @extends AbstractController
 */
class Controller extends AbstractController {
	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'settings/payments/offline-methods';

	/**
	 * Payments instance.
	 *
	 * @var Payments
	 */
	protected $payments;

	/**
	 * Schema instance.
	 *
	 * @var OfflinePaymentMethodSchema
	 */
	protected $item_schema;

	/**
	 * Initialize the controller.
	 *
	 * @param Payments                   $payments Payments service.
	 * @param OfflinePaymentMethodSchema $schema   Schema class.
	 * @internal
	 */
	final public function init( Payments $payments, OfflinePaymentMethodSchema $schema ) {
		$this->payments    = $payments;
		$this->item_schema = $schema;
	}

	/**
	 * Register the routes for offline payment methods.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => array_merge(
						$this->get_collection_params(),
						array(
							'location' => array(
								'description'       => __( 'Country code to retrieve offline payment methods for.', 'woocommerce' ),
								'type'              => 'string',
								'required'          => false,
								'sanitize_callback' => static function ( $value ) {
									return sanitize_text_field( $value );
								},
							),
						)
					),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}

	/**
	 * Check permissions for reading offline payment methods.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return bool|WP_Error
	 */
	public function get_items_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'payment_gateways', 'read' ) ) {
			return new WP_Error(
				'woocommerce_rest_cannot_read',
				__( 'Sorry, you cannot list resources.', 'woocommerce' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}

		return true;
	}

	/**
	 * Get offline payment methods.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_items( $request ) {
		try {
			$offline_methods = $this->get_offline_payment_methods_data( $request );
		} catch ( \Exception $e ) {
			return new WP_Error(
				'woocommerce_rest_offline_payment_methods_error',
				$e->getMessage(),
				array( 'status' => 500 )
			);
		}

		if ( is_wp_error( $offline_methods ) ) {
			return $offline_methods;
		}

		// Transform data to match the new format.
		$response_data = array(
			'id'          => 'payments/offline-methods',
			'title'       => __( 'Offline Payment Methods', 'woocommerce' ),
			'description' => __( 'Manage offline payment methods available for your store.', 'woocommerce' ),
			'values'      => array(),
			'groups'      => array(
				'payment_methods' => array(),
			),
		);

		// Validate input is an array.
		if ( ! is_array( $offline_methods ) ) {
			return new WP_Error(
				'woocommerce_rest_invalid_data',
				__( 'Invalid payment methods data received.', 'woocommerce' ),
				array( 'status' => 500 )
			);
		}

		// Process each offline payment method.
		foreach ( $offline_methods as $method ) {
			// Skip if method is not an array.
			if ( ! is_array( $method ) ) {
				continue;
			}

			$method_id = $method['id'] ?? '';
			if ( empty( $method_id ) || ! is_string( $method_id ) ) {
				continue;
			}

			// Add method to values (current settings/state).
			$enabled_state = false;
			if ( isset( $method['state'] ) && is_array( $method['state'] ) ) {
				$enabled_state = $method['state']['enabled'] ?? false;
			}
			if ( is_array( $enabled_state ) ) {
				$enabled_state = $enabled_state['value'] ?? false;
			}
			if ( is_string( $enabled_state ) ) {
				$enabled_state = wc_string_to_bool( $enabled_state );
			} elseif ( ! is_bool( $enabled_state ) ) {
				$enabled_state = (bool) $enabled_state;
			}
			$response_data['values'][ $method_id ] = $enabled_state;

			// Add complete payment method data to groups.payment_methods.
			$response_data['groups']['payment_methods'][ $method_id ] = array(
				'id'          => $method_id,
				'_order'      => isset( $method['_order'] ) ? absint( $method['_order'] ) : 0,
				'title'       => sanitize_text_field( $method['title'] ?? '' ),
				'description' => wp_kses_post( $method['description'] ?? '' ),
				'icon'        => esc_url_raw( $method['icon'] ?? '' ),
				'state'       => array_map(
					'rest_sanitize_boolean',
					wp_parse_args(
						is_array( $method['state'] ?? null ) ? $method['state'] : array(),
						array(
							'enabled'           => false,
							'account_connected' => false,
							'needs_setup'       => false,
							'test_mode'         => false,
							'dev_mode'          => false,
						)
					)
				),
				'management'  => $this->sanitize_management_field( $method['management'] ?? array() ),
			);
		}

		return rest_ensure_response( $response_data );
	}

	/**
	 * Get offline payment methods data.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return array|WP_Error The offline payment methods data or error.
	 * @throws \Exception If there's an error retrieving the data.
	 */
	private function get_offline_payment_methods_data( $request ) {
		$location = sanitize_text_field( $request->get_param( 'location' ) );

		if ( empty( $location ) ) {
			// Fall back to the payments country if no location is provided.
			$location = $this->payments->get_country();
		}

		try {
			$providers = $this->payments->get_payment_providers( $location );
		} catch ( \Exception $e ) {
			return new \WP_Error( 'woocommerce_rest_payment_providers_error', $e->getMessage(), array( 'status' => 500 ) );
		}

		if ( is_wp_error( $providers ) ) {
			return $providers;
		}

		// Retrieve the offline PMs from the main providers list.
		$offline_payment_providers = array_values(
			array_filter(
				$providers,
				fn( $provider ) => isset( $provider['_type'] ) && PaymentsProviders::TYPE_OFFLINE_PM === $provider['_type']
			)
		);

		return $offline_payment_providers;
	}


	/**
	 * Get the schema for the current resource.
	 *
	 * @return array
	 */
	protected function get_schema(): array {
		return $this->item_schema->get_item_schema();
	}

	/**
	 * Get the item response for a single payment method.
	 *
	 * @param mixed           $item Payment method data.
	 * @param WP_REST_Request $request Request object.
	 * @return array
	 */
	protected function get_item_response( $item, WP_REST_Request $request ): array {
		return $this->item_schema->get_item_response( $item, $request );
	}

	/**
	 * Sanitize the management field data.
	 *
	 * @param mixed $management The management data to sanitize.
	 * @return array Sanitized management array.
	 */
	private function sanitize_management_field( $management ) {
		if ( ! is_array( $management ) ) {
			return array( '_links' => array() );
		}

		$sanitized = array(
			'_links' => array(),
		);

		if ( isset( $management['_links'] ) && is_array( $management['_links'] ) ) {
			foreach ( $management['_links'] as $key => $link ) {
				$sanitized_key = sanitize_key( $key );
				if ( is_array( $link ) && isset( $link['href'] ) ) {
					// Handle link objects with href property.
					$sanitized['_links'][ $sanitized_key ] = array(
						'href' => esc_url_raw( $link['href'] ),
					);
				} elseif ( is_string( $link ) ) {
					// Handle direct URL strings.
					$sanitized['_links'][ $sanitized_key ] = array(
						'href' => esc_url_raw( $link ),
					);
				}
			}
		}

		return $sanitized;
	}
}
PK     [1],sA1.  1.  /  RestApi/Routes/V4/Settings/Email/Controller.phpnu         <?php
/**
 * REST API Email Settings Controller
 *
 * Handles requests to the /settings/email endpoints.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\Email;

use WP_Error;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractController;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\Email\Schema\EmailSettingsSchema;
use WC_Settings_Emails;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;

defined( 'ABSPATH' ) || exit;

/**
 * REST API Email Settings Controller Class.
 */
class Controller extends AbstractController {
	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'settings/email';

	/**
	 * WC_Settings_Emails instance.
	 *
	 * @var WC_Settings_Emails
	 */
	protected $settings_emails_instance;

	/**
	 * Schema instance.
	 *
	 * @var EmailSettingsSchema
	 */
	protected $schema;

	/**
	 * Initialize the controller.
	 *
	 * @param EmailSettingsSchema $schema Schema class.
	 * @internal
	 */
	final public function init( EmailSettingsSchema $schema ) {
		$this->schema = $schema;
	}

	/**
	 * Register routes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_item' ),
					'permission_callback' => array( $this, 'get_item_permissions_check' ),
				),
				array(
					'methods'             => WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'update_item' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);
	}

	/**
	 * Get the WC_Settings_Emails instance.
	 *
	 * @return WC_Settings_Emails
	 */
	private function get_settings_emails_instance() {
		if ( is_null( $this->settings_emails_instance ) ) {
			$this->settings_emails_instance = new WC_Settings_Emails();
		}
		return $this->settings_emails_instance;
	}

	/**
	 * Get all email settings definitions.
	 *
	 * @return array Array of setting definitions.
	 */
	private function get_all_settings(): array {
		$settings_instance = $this->get_settings_emails_instance();
		$sections          = $settings_instance->get_sections();
		$settings          = array();

		foreach ( array_keys( $sections ) as $section ) {
			$section_settings = $settings_instance->get_settings_for_section( $section );
			$settings         = array_merge( $settings, $section_settings );
		}

		return $settings;
	}

	/**
	 * Check permissions for reading email settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return bool|WP_Error
	 */
	public function get_item_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) {
			return new WP_Error(
				'rest_forbidden',
				__( 'Sorry, you are not allowed to access email settings.', 'woocommerce' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}
		return true;
	}

	/**
	 * Check permissions for updating email settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return bool|WP_Error
	 */
	public function update_item_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'settings', 'edit' ) ) {
			return new WP_Error(
				'rest_forbidden',
				__( 'Sorry, you are not allowed to edit email settings.', 'woocommerce' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}
		return true;
	}

	/**
	 * Get email settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_item( $request ) {
		try {
			$settings = $this->get_all_settings();
		} catch ( \Exception $e ) {
			return new WP_Error(
				'woocommerce_rest_email_settings_error',
				$e->getMessage(),
				array( 'status' => 500 )
			);
		}

		$response = $this->get_item_response( $settings, $request );
		return rest_ensure_response( $response );
	}

	/**
	 * Update email settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function update_item( $request ) {
		$params = $request->get_json_params();

		if ( ! is_array( $params ) || empty( $params ) ) {
			return new WP_Error(
				'rest_invalid_param',
				__( 'Invalid or empty request body.', 'woocommerce' ),
				array( 'status' => 400 )
			);
		}

		// Check if the request contains a 'values' field with the flat key-value mapping.
		$values_to_update = array();
		if ( isset( $params['values'] ) && is_array( $params['values'] ) ) {
			$values_to_update = $params['values'];
		} else {
			// Fallback to the old format for backward compatibility.
			$values_to_update = $params;
		}

		// Get all email settings definitions.
		$settings       = $this->get_all_settings();
		$settings_by_id = array_column( $settings, null, 'id' );

		// Exclude non-editable markers like 'title' and 'sectionend, ...'.
		$settings_by_id = array_filter(
			$settings_by_id,
			static function ( $def ) {
				$type = $def['type'] ?? '';
				return isset( $def['id'] ) && ! in_array( $type, EmailSettingsSchema::NON_EDITABLE_TYPES, true );
			}
		);

		$valid_setting_ids  = array_keys( $settings_by_id );
		$validated_settings = array();

		// Get reply_to_enabled for validation context.
		$reply_to_enabled = get_option( 'woocommerce_email_reply_to_enabled', 'no' );
		if ( isset( $values_to_update['woocommerce_email_reply_to_enabled'] ) ) {
			$reply_to_enabled = wc_bool_to_string( $values_to_update['woocommerce_email_reply_to_enabled'] );
		}

		// Process each setting in the payload.
		foreach ( $values_to_update as $setting_id => $setting_value ) {
			// Sanitize the setting ID.
			$setting_id = sanitize_text_field( $setting_id );

			// Security check: only allow updating valid WooCommerce email settings.
			if ( ! in_array( $setting_id, $valid_setting_ids, true ) ) {
				continue;
			}

			// Sanitize the value based on the setting type.
			$setting_definition = $settings_by_id[ $setting_id ];
			$setting_type       = $setting_definition['type'] ?? 'text';
			$sanitized_value    = $this->sanitize_setting_value( $setting_type, $setting_value );

			// Additional validation for specific settings.
			$validation_result = $this->validate_setting_value( $setting_id, $sanitized_value, $reply_to_enabled );
			if ( is_wp_error( $validation_result ) ) {
				return $validation_result;
			}

			// Store validated values first.
			$validated_settings[ $setting_id ] = $sanitized_value;
		}

		// After validation loop, update all settings.
		$updated_settings = array();
		foreach ( $validated_settings as $setting_id => $value ) {
			$update_result = update_option( $setting_id, $value );
			if ( $update_result ) {
				$updated_settings[] = $setting_id;
			}
		}

		// Log the update if settings were changed.
		if ( ! empty( $updated_settings ) ) {
			/**
			 * Fires when WooCommerce settings are updated.
			 *
			 * @param array $updated_settings Array of updated settings IDs.
			 * @param string $rest_base The REST base of the settings.
			 * @since 4.0.0
			 */
			do_action( 'woocommerce_settings_updated', $updated_settings, $this->rest_base );
		}

		// Get all settings after update.
		$settings = $this->get_all_settings();

		// Return updated settings.
		$response = $this->get_item_response( $settings, $request );
		return rest_ensure_response( $response );
	}

	/**
	 * Validate a setting value before updating.
	 *
	 * @param string $setting_id Setting ID.
	 * @param mixed  $value      Setting value.
	 * @param string $reply_to_enabled Reply-to enabled.
	 * @return bool|WP_Error True if valid, WP_Error if invalid.
	 */
	private function validate_setting_value( $setting_id, $value, $reply_to_enabled ) {
		$check_reply_to = 'yes' === $reply_to_enabled;
		switch ( $setting_id ) {
			case 'woocommerce_email_from_name':
				if ( empty( $value ) || ! is_string( $value ) ) {
					return new WP_Error(
						'rest_invalid_param',
						__( 'Email sender name cannot be empty.', 'woocommerce' ),
						array( 'status' => 400 )
					);
				}
				break;

			case 'woocommerce_email_from_address':
				if ( empty( $value ) || ! is_email( $value ) ) {
					return new WP_Error(
						'rest_invalid_param',
						__( 'Please enter a valid email address.', 'woocommerce' ),
						array( 'status' => 400 )
					);
				}
				break;

			case 'woocommerce_email_reply_to_enabled':
				// Convert string 'true'/'false' to boolean if needed.
				if ( is_string( $value ) ) {
					$value = filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
				}
				if ( ! is_bool( $value ) && null !== $value ) {
					return new WP_Error(
						'rest_invalid_param',
						__( 'Reply-to enabled must be a boolean value.', 'woocommerce' ),
						array( 'status' => 400 )
					);
				}
				break;

			case 'woocommerce_email_reply_to_name':
				// Only validate if reply-to is enabled.
				if ( $check_reply_to && ( empty( $value ) || ! is_string( $value ) ) ) {
					return new WP_Error(
						'rest_invalid_param',
						__( 'Reply-to name cannot be empty when reply-to is enabled.', 'woocommerce' ),
						array( 'status' => 400 )
					);
				}
				break;

			case 'woocommerce_email_reply_to_address':
				// Only validate if reply-to is enabled.
				if ( $check_reply_to && ( empty( $value ) || ! is_email( $value ) ) ) {
					return new WP_Error(
						'rest_invalid_param',
						__( 'Please enter a valid reply-to email address.', 'woocommerce' ),
						array( 'status' => 400 )
					);
				}
				break;
		}

		return true;
	}

	/**
	 * Sanitize setting value based on its type.
	 *
	 * @param string $setting_type Setting type.
	 * @param mixed  $value        Setting value.
	 * @return mixed Sanitized value.
	 */
	private function sanitize_setting_value( $setting_type, $value ) {
		switch ( $setting_type ) {
			case 'text':
			case 'select':
			case 'color':
				return sanitize_text_field( $value );

			case 'email':
				return sanitize_email( $value );

			case 'checkbox':
				// Ensure we have a scalar value for checkbox settings.
				if ( is_array( $value ) ) {
					$value = ! empty( $value ); // Convert array to boolean based on emptiness.
				}
				return wc_bool_to_string( $value );

			case 'number':
				if ( ! is_numeric( $value ) ) {
					return 0;
				}

				return filter_var( $value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? floatval( $value );

			default:
				// If a type is not explicitly handled, treat it as text.
				return sanitize_text_field( $value );
		}
	}

	/**
	 * Get the schema for the current resource.
	 *
	 * @return array
	 */
	protected function get_schema(): array {
		return $this->schema->get_item_schema();
	}

	/**
	 * Get the item schema for the controller.
	 *
	 * @return array
	 */
	public function get_item_schema(): array {
		return $this->get_schema();
	}

	/**
	 * Get the item response for a single settings group.
	 *
	 * @param mixed           $item Settings data.
	 * @param WP_REST_Request $request Request object.
	 * @return array
	 */
	protected function get_item_response( $item, WP_REST_Request $request ): array {
		return $this->schema->get_item_response( $item, $request );
	}

	/**
	 * Get the endpoint args for item schema.
	 *
	 * @param string $method HTTP method of the request.
	 * @return array Endpoint arguments.
	 */
	public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ): array {
		return rest_get_endpoint_args_for_schema( $this->get_item_schema(), $method );
	}
}
PK     [1]̦C    ?  RestApi/Routes/V4/Settings/Email/Schema/EmailSettingsSchema.phpnu         <?php
/**
 * EmailSettingsSchema class.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\Email\Schema;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractSchema;
use WP_REST_Request;

defined( 'ABSPATH' ) || exit;

/**
 * EmailSettingsSchema class.
 */
class EmailSettingsSchema extends AbstractSchema {
	/**
	 * The schema item identifier.
	 *
	 * @var string
	 */
	const IDENTIFIER = 'email_settings';

	/**
	 * List of non-editable field types.
	 *
	 * @var string[]
	 */
	const NON_EDITABLE_TYPES = array( 'title', 'sectionend', 'email_color_palette', 'previewing_new_templates', 'email_improvements_button', 'email_notification', 'email_notification_block_emails', 'hidden' );

	/**
	 * Return all properties for the item schema.
	 *
	 * @return array
	 */
	public function get_item_schema_properties(): array {
		return array(
			'id'          => array(
				'description' => __( 'Unique identifier for the settings group.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'title'       => array(
				'description' => __( 'Settings title.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'description' => array(
				'description' => __( 'Settings description.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'values'      => array(
				'description' => __( 'Flat key-value mapping of all setting field values.', 'woocommerce' ),
				'type'        => 'object',
				'context'     => self::VIEW_EDIT_CONTEXT,
			),
			'groups'      => array(
				'description'          => __( 'Collection of setting groups.', 'woocommerce' ),
				'type'                 => 'object',
				'context'              => self::VIEW_EDIT_CONTEXT,
				'additionalProperties' => array(
					'type'        => 'object',
					'description' => __( 'Settings group.', 'woocommerce' ),
					'properties'  => array(
						'title'       => array(
							'description' => __( 'Group title.', 'woocommerce' ),
							'type'        => 'string',
							'context'     => self::VIEW_EDIT_CONTEXT,
						),
						'description' => array(
							'description' => __( 'Group description.', 'woocommerce' ),
							'type'        => 'string',
							'context'     => self::VIEW_EDIT_CONTEXT,
						),
						'order'       => array(
							'description' => __( 'Display order for the group.', 'woocommerce' ),
							'type'        => 'integer',
							'context'     => self::VIEW_EDIT_CONTEXT,
							'readonly'    => true,
						),
						'fields'      => array(
							'description' => __( 'Settings fields.', 'woocommerce' ),
							'type'        => 'array',
							'context'     => self::VIEW_EDIT_CONTEXT,
							'items'       => $this->get_field_schema(),
						),
					),
				),
			),
		);
	}

	/**
	 * Get the schema for individual setting fields.
	 *
	 * @return array
	 */
	private function get_field_schema(): array {
		return array(
			'type'       => 'object',
			'properties' => array(
				'id'      => array(
					'description' => __( 'Setting field ID.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
				'label'   => array(
					'description' => __( 'Setting field label.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
				'type'    => array(
					'description' => __( 'Setting field type.', 'woocommerce' ),
					'type'        => 'string',
					'enum'        => array( 'text', 'email', 'checkbox', 'number', 'color', 'select' ),
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
				'desc'    => array(
					'description' => __( 'Setting field description.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
				'options' => array(
					'description' => __( 'Available options for selectable fields.', 'woocommerce' ),
					'type'        => 'object',
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
			),
		);
	}

	/**
	 * Get email settings data by transforming email settings into REST API format.
	 *
	 * @param mixed           $item             Settings array from WC_Settings_Emails.
	 * @param WP_REST_Request $request          Request object.
	 * @param array           $include_fields   Fields to include.
	 * @return array
	 */
	public function get_item_response( $item, WP_REST_Request $request, array $include_fields = array() ): array {
		$settings = is_array( $item ) ? $item : array();

		// Transform settings into grouped format based on title/sectionend markers.
		$groups           = array();
		$values           = array();
		$current_group    = null;
		$current_group_id = null;

		foreach ( $settings as $setting ) {
			$setting_type = $setting['type'] ?? '';

			// Handle section titles and email_color_palette - start of a new group.
			if ( 'title' === $setting_type || 'email_color_palette' === $setting_type ) {
				$current_group_id = $setting['id'] ?? '';
				$current_group    = array(
					'title'       => $setting['title'] ?? '',
					'description' => $setting['desc'] ?? '',
					'order'       => isset( $setting['order'] ) ? (int) $setting['order'] : 999,
					'fields'      => array(),
				);
				continue;
			}

			// Handle section ends - save the current group.
			if ( 'sectionend' === $setting_type ) {
				if ( $current_group && $current_group_id ) {
					$groups[ $current_group_id ] = $current_group;
				}
				$current_group    = null;
				$current_group_id = null;
				continue;
			}

			// Skip non-editable field types.
			if ( in_array( $setting_type, self::NON_EDITABLE_TYPES, true ) ) {
				continue;
			}

			// Process field if we have a current group and the setting has an ID.
			if ( isset( $setting['id'] ) && $current_group ) {
				$setting_id   = $setting['id'];
				$setting_type = $setting['type'] ?? 'text';

				// Map WooCommerce field types to REST API types.
				$api_type = $this->map_setting_type_to_api_type( $setting_type );

				// Build field definition.
				$field = array(
					'id'    => $setting_id,
					'label' => $setting['title'] ?? $setting_id,
					'type'  => $api_type,
				);

				// Add description if available.
				if ( ! empty( $setting['desc'] ) ) {
					$field['desc'] = $setting['desc'];
				}

				// Add options if available.
				if ( isset( $setting['options'] ) && is_array( $setting['options'] ) ) {
					$field['options'] = $setting['options'];
				}

				$current_group['fields'][] = $field;

				// Get current value.
				$default_value = $setting['default'] ?? '';
				$current_value = get_option( $setting_id, $default_value );

				// Convert checkbox values to boolean for API.
				if ( 'checkbox' === $setting_type ) {
					$current_value = 'yes' === $current_value;
				}

				$values[ $setting_id ] = $current_value;
			}
		}

		// Filter groups without fields.
		$groups = array_filter(
			$groups,
			function ( $group ) {
				return ! empty( $group['fields'] );
			}
		);

		$response = array(
			'id'          => 'email',
			'title'       => __( 'Email design', 'woocommerce' ),
			'description' => __( 'Customize the look and feel of all you notification emails.', 'woocommerce' ),
			'values'      => $values,
			'groups'      => $groups,
		);

		if ( ! empty( $include_fields ) ) {
			$response = array_intersect_key( $response, array_flip( $include_fields ) );
		}

		return $response;
	}

	/**
	 * Map WooCommerce setting type to REST API type.
	 *
	 * @param string $setting_type WooCommerce setting type.
	 * @return string REST API type.
	 */
	private function map_setting_type_to_api_type( string $setting_type ): string {
		$type_map = array(
			'text'     => 'text',
			'email'    => 'email',
			'checkbox' => 'checkbox',
			'number'   => 'number',
			'color'    => 'color',
			'select'   => 'select',
		);

		return $type_map[ $setting_type ] ?? 'text';
	}
}
PK     [1]ɥg)*  )*  2  RestApi/Routes/V4/Settings/Products/Controller.phpnu         <?php
/**
 * REST API Product Settings controller
 *
 * Handles requests to the /settings/products endpoints.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\Products;

use WP_Error;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractController;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\Products\Schema\ProductSettingsSchema;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use WC_Settings_Products;

defined( 'ABSPATH' ) || exit;

/**
 * Product Settings controller class.
 */
class Controller extends AbstractController {
	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'settings/products';

	/**
	 * Schema class instance.
	 *
	 * @var ProductSettingsSchema
	 */
	protected $schema;

	/**
	 * WC_Settings_Products instance.
	 *
	 * @var \WC_Settings_Products
	 */
	protected $settings_products_instance;

	/**
	 * Initialize dependencies.
	 *
	 * @param ProductSettingsSchema $schema Schema class instance.
	 * @internal
	 */
	final public function init( ProductSettingsSchema $schema ) {
		$this->schema = $schema;
	}

	/**
	 * Get the WC_Settings_Products instance.
	 *
	 * @return \WC_Settings_Products
	 */
	private function get_settings_products_instance() {
		if ( is_null( $this->settings_products_instance ) ) {
			$this->settings_products_instance = new WC_Settings_Products();
		}
		return $this->settings_products_instance;
	}

	/**
	 * Register routes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_item' ),
					'permission_callback' => array( $this, 'get_item_permissions_check' ),
				),
				array(
					'methods'             => WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'update_item' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);
	}

	/**
	 * Check if a given request has access to read settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return bool|WP_Error
	 */
	public function get_item_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) {
			return new WP_Error(
				'rest_forbidden',
				__( 'Sorry, you are not allowed to access product settings.', 'woocommerce' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}
		return true;
	}

	/**
	 * Check if a given request has access to update settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return bool|WP_Error
	 */
	public function update_item_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'settings', 'edit' ) ) {
			return new WP_Error(
				'rest_forbidden',
				__( 'Sorry, you are not allowed to edit product settings.', 'woocommerce' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}
		return true;
	}

	/**
	 * Get product settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_item( $request ) {
		$settings = $this->get_all_settings();

		$response = $this->schema->get_item_response( $settings, $request );
		return rest_ensure_response( $response );
	}

	/**
	 * Update product settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function update_item( $request ) {
		$params = $request->get_json_params();

		if ( ! is_array( $params ) || empty( $params ) ) {
			return new WP_Error(
				'rest_invalid_param',
				__( 'Invalid or empty request body.', 'woocommerce' ),
				array( 'status' => 400 )
			);
		}

		// Check if the request contains a 'values' field with the flat key-value mapping.
		$values_to_update = array();
		if ( isset( $params['values'] ) && is_array( $params['values'] ) ) {
			$values_to_update = $params['values'];
		} else {
			// Fallback to the old format for backward compatibility.
			$values_to_update = $params;
		}

		// Get all product settings definitions.
		$settings           = $this->get_all_settings();
		$settings_by_id     = array_column( $settings, null, 'id' );
		$valid_setting_ids  = array_keys( $settings_by_id );
		$validated_settings = array();

		// Process each setting in the payload.
		foreach ( $values_to_update as $setting_id => $setting_value ) {
			// Sanitize the setting ID.
			$setting_id = sanitize_text_field( $setting_id );

			// Security check: only allow updating valid WooCommerce product settings.
			if ( ! in_array( $setting_id, $valid_setting_ids, true ) ) {
				continue;
			}

			// Sanitize the value based on the setting type.
			$setting_definition = $settings_by_id[ $setting_id ];
			$setting_type       = $setting_definition['type'] ?? 'text';
			$sanitized_value    = $this->sanitize_setting_value( $setting_type, $setting_value );

			// Additional validation for specific settings.
			$validation_result = $this->validate_setting_value( $setting_id, $sanitized_value );
			if ( is_wp_error( $validation_result ) ) {
				return $validation_result;
			}

			// Store validated values first.
			$validated_settings[ $setting_id ] = $sanitized_value;
		}

		// After validation loop, update all settings.
		$updated_settings = array();
		foreach ( $validated_settings as $setting_id => $value ) {
			$update_result = update_option( $setting_id, $value );
			if ( $update_result ) {
				$updated_settings[] = $setting_id;
			}
		}

		// Log the update if settings were changed.
		if ( ! empty( $updated_settings ) ) {
			/**
			* Fires when WooCommerce product settings are updated.
			*
			* @param array $updated_settings Array of updated settings IDs.
			* @param string $rest_base The REST base of the settings.
			* @since 4.0.0
			*/
			do_action( 'woocommerce_settings_updated', $updated_settings, $this->rest_base );
		}

		// Get all settings after update.
		$settings = $this->get_all_settings();

		// Return updated settings.
		$response = $this->schema->get_item_response( $settings, $request );
		return rest_ensure_response( $response );
	}

	/**
	 * Validate a setting value before updating.
	 *
	 * @param string $setting_id Setting ID.
	 * @param mixed  $value      Setting value.
	 * @return bool|WP_Error True if valid, WP_Error if invalid.
	 */
	private function validate_setting_value( string $setting_id, $value ) {
		// Custom validation rules for specific product settings.
		switch ( $setting_id ) {
			case 'woocommerce_weight_unit':
				/**
				 * Filter the available weight units.
				 *
				 * @since 10.4.0
				 *
				 * @param array $weight_units Array of weight unit strings.
				 */
				$valid_units = apply_filters( 'woocommerce_weight_units', array( 'kg', 'g', 'lbs', 'oz' ) );
				if ( ! in_array( $value, $valid_units, true ) ) {
					return new WP_Error(
						'rest_invalid_param',
						__( 'Invalid weight unit. Valid units are: kg, g, lbs, oz.', 'woocommerce' ),
						array( 'status' => 400 )
					);
				}
				break;

			case 'woocommerce_dimension_unit':
				/**
				 * Filter the available dimension units.
				 *
				 * @since 10.4.0
				 *
				 * @param array $dimension_units Array of dimension unit strings.
				 */
				$valid_units = apply_filters( 'woocommerce_dimension_units', array( 'm', 'cm', 'mm', 'in', 'yd' ) );
				if ( ! in_array( $value, $valid_units, true ) ) {
					return new WP_Error(
						'rest_invalid_param',
						__( 'Invalid dimension unit. Valid units are: m, cm, mm, in, yd.', 'woocommerce' ),
						array( 'status' => 400 )
					);
				}
				break;

			case 'woocommerce_product_type':
				$valid_types = array_keys( wc_get_product_types() );
				if ( ! in_array( $value, $valid_types, true ) ) {
					return new WP_Error(
						'rest_invalid_param',
						__( 'Invalid product type.', 'woocommerce' ),
						array( 'status' => 400 )
					);
				}
				break;
		}

		return true;
	}

	/**
	 * Sanitize setting value based on its type.
	 *
	 * @param string $setting_type Setting type.
	 * @param mixed  $value        Setting value.
	 * @return mixed Sanitized value.
	 */
	private function sanitize_setting_value( string $setting_type, $value ) {
		switch ( $setting_type ) {
			case 'text':
				return sanitize_text_field( $value );

			case 'number':
				if ( ! is_numeric( $value ) ) {
					return 0;
				}

				return filter_var( $value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? floatval( $value );

			case 'checkbox':
				// Ensure we have a scalar value for checkbox settings.
				if ( is_array( $value ) ) {
					$value = ! empty( $value ); // Convert array to boolean based on emptiness.
				}
				return wc_bool_to_string( $value );

			case 'select':
				return sanitize_text_field( $value );

			case 'multiselect':
				if ( is_array( $value ) ) {
					return array_map( 'sanitize_text_field', $value );
				}

				if ( is_string( $value ) ) {
					return array( sanitize_text_field( $value ) );
				}

				if ( is_scalar( $value ) ) {
					return array( sanitize_text_field( (string) $value ) );
				}

				return array();
			default:
				return sanitize_text_field( $value );
		}
	}

	/**
	 * Get the schema for the current resource. This use consumed by the AbstractController to generate the item schema
	 * after running various hooks on the response.
	 */
	protected function get_schema(): array {
		return $this->schema->get_item_schema();
	}

	/**
	 * Get the item schema for the controller.
	 *
	 * @return array
	 */
	public function get_item_schema(): array {
		return $this->get_schema();
	}

	/**
	 * Prepare a single item for response.
	 *
	 * @param mixed           $item    Object to prepare.
	 * @param WP_REST_Request $request Request object.
	 * @return array Response data.
	 */
	protected function get_item_response( $item, WP_REST_Request $request ): array {
		return $this->schema->get_item_response( $item, $request );
	}

	/**
	 * Get all product settings definitions.
	 *
	 * @return array Array of setting definitions.
	 */
	private function get_all_settings(): array {
		$settings_instance = $this->get_settings_products_instance();
		$sections          = $settings_instance->get_sections();
		$settings          = array();

		foreach ( array_keys( $sections ) as $section ) {
			$section_settings = $settings_instance->get_settings_for_section( $section );
			$settings         = array_merge( $settings, $section_settings );
		}

		return $settings;
	}
}
PK     [1]x&  &  D  RestApi/Routes/V4/Settings/Products/Schema/ProductSettingsSchema.phpnu         <?php
/**
 * REST API Product Settings Schema
 *
 * Handles schema definition for product settings.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\Products\Schema;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractSchema;
use WP_REST_Request;

/**
 * Product Settings Schema Class.
 */
class ProductSettingsSchema extends AbstractSchema {
	/**
	 * The schema item identifier.
	 *
	 * @var string
	 */
	const IDENTIFIER = 'product_settings';

	/**
	 * Return all properties for the item schema.
	 *
	 * @return array The schema properties.
	 */
	public function get_item_schema_properties(): array {
		return array(
			'id'          => array(
				'description' => __( 'Unique identifier for the settings group.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
			'title'       => array(
				'description' => __( 'Settings title.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
			'description' => array(
				'description' => __( 'Settings description.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
			'values'      => array(
				'description'          => __( 'Flat key-value mapping of all setting field values.', 'woocommerce' ),
				'type'                 => 'object',
				'context'              => array( 'view', 'edit' ),
				'additionalProperties' => array(
					'description' => __( 'Setting field value.', 'woocommerce' ),
					'type'        => array( 'string', 'number', 'array', 'boolean' ),
				),
			),
			'groups'      => array(
				'description'          => __( 'Collection of setting groups.', 'woocommerce' ),
				'type'                 => 'object',
				'context'              => array( 'view', 'edit' ),
				'additionalProperties' => array(
					'type'        => 'object',
					'description' => __( 'Settings group.', 'woocommerce' ),
					'properties'  => array(
						'title'       => array(
							'description' => __( 'Group title.', 'woocommerce' ),
							'type'        => 'string',
							'context'     => array( 'view', 'edit' ),
						),
						'description' => array(
							'description' => __( 'Group description.', 'woocommerce' ),
							'type'        => 'string',
							'context'     => array( 'view', 'edit' ),
						),
						'order'       => array(
							'description' => __( 'Display order for the group.', 'woocommerce' ),
							'type'        => 'integer',
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
						'fields'      => array(
							'description' => __( 'Settings fields.', 'woocommerce' ),
							'type'        => 'array',
							'context'     => array( 'view', 'edit' ),
							'items'       => $this->get_field_schema(),
						),
					),
				),
			),
		);
	}

	/**
	 * Get the schema for individual setting fields.
	 *
	 * @return array
	 */
	private function get_field_schema(): array {
		return array(
			'type'       => 'object',
			'properties' => array(
				'id'      => array(
					'description' => __( 'Setting field ID.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
				'label'   => array(
					'description' => __( 'Setting field label.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
				'type'    => array(
					'description' => __( 'Setting field type.', 'woocommerce' ),
					'type'        => 'string',
					'enum'        => array( 'text', 'number', 'select', 'multiselect', 'checkbox' ),
					'context'     => array( 'view', 'edit' ),
				),
				'options' => array(
					'description' => __( 'Available options for select/multiselect fields.', 'woocommerce' ),
					'type'        => 'object',
					'context'     => array( 'view', 'edit' ),
				),
				'desc'    => array(
					'description' => __( 'Description for the setting field.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
				),
			),
		);
	}

	/**
	 * Get product settings data by transforming WC_Settings_Products data into REST API format.
	 *
	 * @param mixed           $item             Settings products instance.
	 * @param WP_REST_Request $request          Request object.
	 * @param array           $include_fields   Fields to include.
	 * @return array
	 */
	public function get_item_response( $item, WP_REST_Request $request, array $include_fields = array() ): array {
		$raw_settings = $item;

		// Transform raw settings into grouped format based on title/sectionend markers.
		$groups           = array();
		$values           = array();
		$current_group    = null;
		$current_group_id = null;

		foreach ( $raw_settings as $setting ) {
			$setting_type = $setting['type'] ?? '';

			// Handle section titles - start of a new group.
			if ( 'title' === $setting_type ) {
				$current_group_id = $setting['id'] ?? '';
				$current_group    = array(
					'title'       => $setting['title'] ?? '',
					'description' => $setting['desc'] ?? '',
					'order'       => isset( $setting['order'] ) ? (int) $setting['order'] : 999,
					'fields'      => array(),
				);
				continue;
			}

			// Handle section ends - save the current group.
			if ( 'sectionend' === $setting_type ) {
				if ( $current_group && $current_group_id ) {
					$groups[ $current_group_id ] = $current_group;
				}
				$current_group    = null;
				$current_group_id = null;
				continue;
			}

			// Skip title and sectionend types.
			if ( in_array( $setting_type, array( 'title', 'sectionend' ), true ) ) {
				continue;
			}

			// Convert setting to field format.
			if ( isset( $setting['id'] ) && $current_group ) {
				$field = $this->transform_setting_to_field( $setting );
				if ( $field ) {
					$current_group['fields'][] = $field;
					// Add field value to the flat values array.
					$raw_value              = get_option( $field['id'], $setting['default'] ?? '' );
					$values[ $field['id'] ] = $this->validate_field_value( $raw_value, $field['type'] );
				}
			}
		}

		// Sort groups by their order if available.
		uasort(
			$groups,
			function ( $a, $b ) {
				$a_order = $a['order'] ?? 999;
				$b_order = $b['order'] ?? 999;
				return $a_order - $b_order;
			}
		);

		return array(
			'id'          => 'products',
			'title'       => __( 'Products', 'woocommerce' ),
			'description' => __( 'Manage product settings including dimensions, weight units, and display options.', 'woocommerce' ),
			'values'      => $values,
			'groups'      => $groups,
		);
	}

	/**
	 * Transform a WooCommerce setting into REST API field format.
	 *
	 * @param array $setting WooCommerce setting array.
	 * @return array|null Transformed field or null if should be skipped.
	 */
	private function transform_setting_to_field( array $setting ): ?array {
		$setting_id   = $setting['id'] ?? '';
		$setting_type = $setting['type'] ?? 'text';

		$field = array(
			'id'    => $setting_id,
			'label' => $setting['title'] ?? $setting_id,
			'type'  => $this->normalize_field_type( $setting_type ),
			'desc'  => $setting['desc'] ?? '',
		);

		// Add options for select fields.
		if ( isset( $setting['options'] ) && is_array( $setting['options'] ) ) {
			$field['options'] = $setting['options'];
		} else {
			// Generate options for special field types.
			$field['options'] = $this->get_field_options( $setting_id );
		}

		return $field;
	}

	/**
	 * Get options for specific field types.
	 *
	 * @param string $field_id Field ID.
	 * @return array Field options.
	 */
	private function get_field_options( string $field_id ): array {
		switch ( $field_id ) {
			case 'woocommerce_weight_unit':
				return array(
					'kg'  => __( 'kg', 'woocommerce' ),
					'g'   => __( 'g', 'woocommerce' ),
					'lbs' => __( 'lbs', 'woocommerce' ),
					'oz'  => __( 'oz', 'woocommerce' ),
				);

			case 'woocommerce_dimension_unit':
				return array(
					'm'  => __( 'm', 'woocommerce' ),
					'cm' => __( 'cm', 'woocommerce' ),
					'mm' => __( 'mm', 'woocommerce' ),
					'in' => __( 'in', 'woocommerce' ),
					'yd' => __( 'yd', 'woocommerce' ),
				);

			case 'woocommerce_product_type':
				if ( ! function_exists( 'wc_get_product_types' ) ) {
					return array();
				}

				$product_types = wc_get_product_types();
				return is_array( $product_types ) ? $product_types : array();
		}

		return array();
	}

	/**
	 * Normalize WooCommerce field types to REST API field types.
	 *
	 * @param string $wc_type WooCommerce field type.
	 * @return string Normalized field type.
	 */
	private function normalize_field_type( string $wc_type ): string {
		$type_map = array(
			'single_select_product' => 'select',
			'multi_select_product'  => 'multiselect',
		);

		return $type_map[ $wc_type ] ?? $wc_type;
	}

	/**
	 * Validate and sanitize field value based on its type.
	 *
	 * @param mixed  $value Field value.
	 * @param string $type  Field type.
	 * @return mixed Validated value.
	 */
	private function validate_field_value( $value, string $type ) {
		switch ( $type ) {
			case 'number':
				return is_numeric( $value ) ? (float) $value : 0;
			case 'checkbox':
				if ( function_exists( 'wc_string_to_bool' ) ) {
					return wc_string_to_bool( $value );
				}
				if ( is_bool( $value ) ) {
					return $value;
				}
				return filter_var( $value, FILTER_VALIDATE_BOOLEAN );
			case 'multiselect':
				return is_array( $value ) ? $value : array();
			case 'text':
			case 'select':
			default:
				return is_string( $value ) ? $value : (string) $value;
		}
	}
}
PK     [1]K?"  ?"  ;  RestApi/Routes/V4/Settings/Tax/Schema/TaxSettingsSchema.phpnu         <?php
/**
 * TaxSettingsSchema class.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\Tax\Schema;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractSchema;
use WP_REST_Request;

defined( 'ABSPATH' ) || exit;

/**
 * TaxSettingsSchema class.
 */
class TaxSettingsSchema extends AbstractSchema {
	/**
	 * The schema item identifier.
	 *
	 * @var string
	 */
	const IDENTIFIER = 'tax_settings';

	/**
	 * Return all properties for the item schema.
	 *
	 * @return array
	 */
	public function get_item_schema_properties(): array {
		return array(
			'id'          => array(
				'description' => __( 'Unique identifier for the settings group.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'title'       => array(
				'description' => __( 'Settings title.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'description' => array(
				'description' => __( 'Settings description.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'values'      => array(
				'description'          => __( 'Flat key-value mapping of all setting field values.', 'woocommerce' ),
				'type'                 => 'object',
				'context'              => self::VIEW_EDIT_CONTEXT,
				'additionalProperties' => array(
					'description' => __( 'Setting field value.', 'woocommerce' ),
					'type'        => array( 'string', 'number', 'array', 'boolean' ),
				),
			),
			'groups'      => array(
				'description'          => __( 'Collection of setting groups.', 'woocommerce' ),
				'type'                 => 'object',
				'context'              => self::VIEW_EDIT_CONTEXT,
				'additionalProperties' => array(
					'type'        => 'object',
					'description' => __( 'Settings group.', 'woocommerce' ),
					'properties'  => array(
						'title'       => array(
							'description' => __( 'Group title.', 'woocommerce' ),
							'type'        => 'string',
							'context'     => self::VIEW_EDIT_CONTEXT,
						),
						'description' => array(
							'description' => __( 'Group description.', 'woocommerce' ),
							'type'        => 'string',
							'context'     => self::VIEW_EDIT_CONTEXT,
						),
						'order'       => array(
							'description' => __( 'Display order for the group.', 'woocommerce' ),
							'type'        => 'integer',
							'context'     => self::VIEW_EDIT_CONTEXT,
							'readonly'    => true,
						),
						'fields'      => array(
							'description' => __( 'Settings fields.', 'woocommerce' ),
							'type'        => 'array',
							'context'     => self::VIEW_EDIT_CONTEXT,
							'items'       => $this->get_field_schema(),
						),
					),
				),
			),
		);
	}

	/**
	 * Get the schema for individual setting fields.
	 *
	 * @return array
	 */
	private function get_field_schema(): array {
		return array(
			'type'       => 'object',
			'properties' => array(
				'id'      => array(
					'description' => __( 'Setting field ID.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
				'label'   => array(
					'description' => __( 'Setting field label.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
				'type'    => array(
					'description' => __( 'Setting field type.', 'woocommerce' ),
					'type'        => 'string',
					'enum'        => array( 'text', 'number', 'select', 'multiselect', 'checkbox', 'radio', 'textarea' ),
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
				'options' => array(
					'description' => __( 'Available options for select/radio fields.', 'woocommerce' ),
					'type'        => 'object',
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
				'desc'    => array(
					'description' => __( 'Description for the setting field.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => self::VIEW_EDIT_CONTEXT,
				),
			),
		);
	}

	/**
	 * Get tax settings data by transforming raw settings into REST API format.
	 *
	 * @param mixed           $item             Raw settings array.
	 * @param WP_REST_Request $request          Request object.
	 * @param array           $include_fields   Fields to include.
	 * @return array
	 */
	public function get_item_response( $item, WP_REST_Request $request, array $include_fields = array() ): array {
		$raw_settings = $item;

		// Transform raw settings into grouped format based on title/sectionend markers.
		$groups           = array();
		$values           = array();
		$current_group    = null;
		$current_group_id = null;

		foreach ( $raw_settings as $setting ) {
			$setting_type = $setting['type'] ?? '';

			// Handle section titles - start of a new group.
			if ( 'title' === $setting_type ) {
				$current_group_id = $setting['id'] ?? '';
				$current_group    = array(
					'title'       => $setting['title'] ?? '',
					'description' => $setting['desc'] ?? '',
					'order'       => isset( $setting['order'] ) ? (int) $setting['order'] : 999,
					'fields'      => array(),
				);
				continue;
			}

			// Handle section ends - save the current group.
			if ( 'sectionend' === $setting_type ) {
				if ( $current_group && $current_group_id ) {
					$groups[ $current_group_id ] = $current_group;
				}
				$current_group    = null;
				$current_group_id = null;
				continue;
			}

			// Skip special marker types.
			if ( in_array( $setting_type, array( 'title', 'sectionend', 'conflict_error', 'add_settings_slot' ), true ) ) {
				continue;
			}

			// Convert setting to field format.
			if ( isset( $setting['id'] ) && $current_group ) {
				$field = $this->transform_setting_to_field( $setting );
				if ( $field ) {
					$current_group['fields'][] = $field;
					// Add field value to the flat values array.
					$raw_value              = get_option( $field['id'], $setting['default'] ?? '' );
					$values[ $field['id'] ] = $this->validate_field_value( $raw_value, $field['type'] );
				}
			}
		}

		// Sort groups by their order if available.
		uasort(
			$groups,
			function ( $a, $b ) {
				$a_order = $a['order'] ?? 999;
				$b_order = $b['order'] ?? 999;
				return $a_order - $b_order;
			}
		);

		$response = array(
			'id'          => 'tax',
			'title'       => __( 'Taxes', 'woocommerce' ),
			'description' => __( 'Manage your store’s tax setup.', 'woocommerce' ),
			'values'      => $values,
			'groups'      => $groups,
		);

		if ( ! empty( $include_fields ) ) {
			$response = array_intersect_key( $response, array_flip( $include_fields ) );
		}

		return $response;
	}

	/**
	 * Transform a WooCommerce setting into REST API field format.
	 *
	 * @param array $setting WooCommerce setting array.
	 * @return array|null Transformed field or null if should be skipped.
	 */
	private function transform_setting_to_field( array $setting ): ?array {
		$setting_id   = $setting['id'] ?? '';
		$setting_type = $setting['type'] ?? 'text';

		$field = array(
			'id'    => $setting_id,
			'label' => $setting['title'] ?? $setting_id,
			'type'  => $this->normalize_field_type( $setting_type ),
			'desc'  => $setting['desc'] ?? '',
		);

		// Add options for select/radio fields.
		if ( isset( $setting['options'] ) && is_array( $setting['options'] ) ) {
			$field['options'] = $setting['options'];
		}

		return $field;
	}

	/**
	 * Normalize WooCommerce field types to REST API field types.
	 *
	 * @param string $wc_type WooCommerce field type.
	 * @return string Normalized field type.
	 */
	private function normalize_field_type( string $wc_type ): string {
		$type_map = array(
			'single_select_country'  => 'select',
			'multi_select_countries' => 'multiselect',
			'radio'                  => 'radio',
		);

		return $type_map[ $wc_type ] ?? $wc_type;
	}

	/**
	 * Validate and sanitize field value based on its type.
	 *
	 * @param mixed  $value Field value.
	 * @param string $type  Field type.
	 * @return mixed Validated value.
	 */
	private function validate_field_value( $value, string $type ) {
		switch ( $type ) {
			case 'number':
				return is_numeric( $value ) ? (float) $value : 0;
			case 'checkbox':
				if ( function_exists( 'wc_string_to_bool' ) ) {
					return wc_string_to_bool( $value );
				}
				if ( is_bool( $value ) ) {
					return $value;
				}
				return filter_var( $value, FILTER_VALIDATE_BOOLEAN );
			case 'multiselect':
				return is_array( $value ) ? $value : array();
			case 'radio':
			case 'select':
			case 'text':
			case 'textarea':
			default:
				return is_string( $value ) ? $value : (string) $value;
		}
	}
}
PK     [1]S'  '  -  RestApi/Routes/V4/Settings/Tax/Controller.phpnu         <?php
/**
 * REST API Tax Settings Controller
 *
 * Handles requests to the /settings/tax endpoints.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\Tax;

use WP_Error;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractController;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\Tax\Schema\TaxSettingsSchema;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use WC_Settings_Tax;

defined( 'ABSPATH' ) || exit;

/**
 * REST API Tax Settings Controller Class.
 */
class Controller extends AbstractController {
	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'settings/tax';

	/**
	 * WC_Settings_Tax instance.
	 *
	 * @var WC_Settings_Tax
	 */
	protected $settings_tax_instance;

	/**
	 * Schema instance.
	 *
	 * @var TaxSettingsSchema
	 */
	protected $item_schema;

	/**
	 * Initialize the controller.
	 *
	 * @param TaxSettingsSchema $item_schema Schema class.
	 * @internal
	 */
	final public function init( TaxSettingsSchema $item_schema ) {
		$this->item_schema = $item_schema;
	}

	/**
	 * Get the WC_Settings_Tax instance.
	 *
	 * @return WC_Settings_Tax
	 */
	private function get_settings_tax_instance() {
		if ( is_null( $this->settings_tax_instance ) ) {
			$this->settings_tax_instance = new WC_Settings_Tax();
		}
		return $this->settings_tax_instance;
	}

	/**
	 * Register routes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_item' ),
					'permission_callback' => array( $this, 'get_item_permissions_check' ),
				),
				array(
					'methods'             => WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'update_item' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);
	}

	/**
	 * Check permissions for reading tax settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return bool|WP_Error
	 */
	public function get_item_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );

		}
		return true;
	}

	/**
	 * Check permissions for updating tax settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return bool|WP_Error
	 */
	public function update_item_permissions_check( $request ) {
		if ( ! wc_rest_check_manager_permissions( 'settings', 'edit' ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );

		}
		return true;
	}

	/**
	 * Get tax settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_item( $request ) {
		$settings = $this->get_all_settings();
		return $this->prepare_item_for_response( $settings, $request );
	}

	/**
	 * Update tax settings.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function update_item( $request ) {
		$params = $request->get_json_params();

		if ( ! is_array( $params ) || empty( $params ) ) {
			return $this->get_route_error_response(
				$this->get_error_prefix() . 'invalid_param',
				__( 'Invalid or empty request body.', 'woocommerce' ),
				400
			);
		}

		// Check if the request contains a 'values' field with the flat key-value mapping.
		$values_to_update = array();
		if ( isset( $params['values'] ) && is_array( $params['values'] ) ) {
			$values_to_update = $params['values'];
		} else {
			// Fallback to the old format for backward compatibility.
			$values_to_update = $params;
		}

		// Get all tax settings definitions.
		$settings       = $this->get_all_settings();
		$settings_by_id = array_column( $settings, null, 'id' );

		// Exclude non-editable markers like 'title' and 'sectionend'.
		$settings_by_id = array_filter(
			$settings_by_id,
			static function ( $def ) {
				$type = $def['type'] ?? '';
				return isset( $def['id'] ) && ! in_array( $type, array( 'title', 'sectionend', 'conflict_error', 'add_settings_slot' ), true );
			}
		);

		$valid_setting_ids  = array_keys( $settings_by_id );
		$validated_settings = array();

		// Process each setting in the payload.
		foreach ( $values_to_update as $setting_id => $setting_value ) {
			// Sanitize the setting ID.
			$setting_id = sanitize_text_field( $setting_id );

			// Security check: only allow updating valid WooCommerce tax settings.
			if ( ! in_array( $setting_id, $valid_setting_ids, true ) ) {
				continue;
			}

			// Sanitize the value based on the setting type.
			$setting_definition = $settings_by_id[ $setting_id ];
			$setting_type       = $setting_definition['type'] ?? 'text';
			$sanitized_value    = $this->sanitize_setting_value( $setting_type, $setting_value );

			// Additional validation for specific settings.
			$validation_result = $this->validate_setting_value( $setting_definition, $sanitized_value );
			if ( is_wp_error( $validation_result ) ) {
				return $validation_result;
			}

			// Store validated values first.
			$validated_settings[ $setting_id ] = $sanitized_value;
		}

		// After validation loop, update all settings.
		$updated_settings = array();
		foreach ( $validated_settings as $setting_id => $value ) {
			$update_result = update_option( $setting_id, $value );
			if ( $update_result ) {
				$updated_settings[] = $setting_id;
			}
		}

		// Log the update if settings were changed.
		if ( ! empty( $updated_settings ) ) {
			/**
			 * Fires when WooCommerce settings are updated.
			 *
			 * @param array $updated_settings Array of updated settings IDs.
			 * @param string $rest_base The REST base of the settings.
			 * @since 4.0.0
			 */
			do_action( 'woocommerce_settings_updated', $updated_settings, $this->rest_base );
		}

		// Get all settings after update.
		$settings = $this->get_all_settings();

		// Return updated settings.
		return $this->prepare_item_for_response( $settings, $request );
	}

	/**
	 * Validate a setting value before updating.
	 *
	 * @param array $setting Setting definition.
	 * @param mixed $value      Setting value.
	 * @return bool|WP_Error True if valid, WP_Error if invalid.
	 */
	private function validate_setting_value( $setting, $value ) {
		$setting_id = $setting['id'] ?? '';
		$options    = $setting['options'] ?? array();

		if ( empty( $options ) ) {
			return true;
		}

		$allowed_values = array_map( 'strval', array_keys( (array) $options ) );

		// Normalize value to array for consistent validation.
		$check_values = is_array( $value ) ? array_map( 'strval', $value ) : array( (string) $value );

		$invalid_values = array_diff( $check_values, $allowed_values );
		if ( ! empty( $invalid_values ) ) {
			// Note: Using setting_id instead of setting_label because plugins can filter settings
			// and clear the 'title' field (e.g., ciab-next), making the label unreliable.
			// The setting_id is always present and provides a clear, machine-readable identifier.
			return $this->get_route_error_response(
				$this->get_error_prefix() . 'invalid_param',
				sprintf(
				/* translators: 1: Setting ID, 2: Allowed values list. */
					__( 'Invalid value for "%1$s". Allowed values: %2$s.', 'woocommerce' ),
					$setting_id,
					implode( ', ', $allowed_values )
				),
				400
			);
		}

		return true;
	}

	/**
	 * Sanitize setting value based on its type.
	 *
	 * @param string $setting_type Setting type.
	 * @param mixed  $value        Setting value.
	 * @return mixed Sanitized value.
	 */
	private function sanitize_setting_value( $setting_type, $value ) {
		// Normalize WooCommerce setting types to REST API schema types.
		$type_map     = array(
			'single_select_country'  => 'select',
			'multi_select_countries' => 'multiselect',
		);
		$setting_type = $type_map[ $setting_type ] ?? $setting_type;

		switch ( $setting_type ) {
			case 'text':
				return sanitize_text_field( $value );

			case 'number':
				if ( ! is_numeric( $value ) ) {
					return 0;
				}

				return filter_var( $value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? floatval( $value );

			case 'checkbox':
				// Ensure we have a scalar value for checkbox settings.
				if ( is_array( $value ) ) {
					$value = ! empty( $value ); // Convert array to boolean based on emptiness.
				}
				return wc_bool_to_string( $value );

			case 'radio':
			case 'select':
				return sanitize_text_field( $value );

			case 'multiselect':
				if ( is_array( $value ) ) {
					return array_map( 'sanitize_text_field', $value );
				}

				if ( is_string( $value ) ) {
					return array( sanitize_text_field( $value ) );
				}

				if ( is_scalar( $value ) ) {
					return array( sanitize_text_field( (string) $value ) );
				}

				return array();

			case 'textarea':
				return sanitize_textarea_field( $value );

			default:
				// If a type is not explicitly handled, treat it as text.
				return sanitize_text_field( $value );
		}
	}

	/**
	 * Get all tax settings definitions.
	 *
	 * @return array Array of setting definitions.
	 */
	private function get_all_settings(): array {
		$settings_instance = $this->get_settings_tax_instance();
		$settings          = $settings_instance->get_settings_for_section( '' );

		return $settings;
	}

	/**
	 * Get the schema for the current resource.
	 *
	 * @return array
	 */
	public function get_schema(): array {
		return $this->item_schema->get_item_schema();
	}

	/**
	 * Get the item response for a single settings group.
	 *
	 * @param mixed           $item Settings data.
	 * @param WP_REST_Request $request Request object.
	 * @return array
	 */
	protected function get_item_response( $item, WP_REST_Request $request ): array {
		return $this->item_schema->get_item_response( $item, $request );
	}
}
PK     [1]֧(  (  (  RestApi/Routes/V4/AbstractController.phpnu         <?php
/**
 * Abstract REST Controller.
 *
 * Extends WP_REST_Controller. Implements functionality that applies to all route controllers.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4;

use WP_Error;
use WP_Http;
use WP_REST_Controller;
use WP_REST_Response;
use WP_REST_Request;

defined( 'ABSPATH' ) || exit;

/**
 * Abstract REST Controller for WooCommerce REST API V4.
 *
 * Provides common functionality for all V4 route controllers including
 * schema generation, error handling, and hook management.
 *
 * @since 10.2.0
 */
abstract class AbstractController extends WP_REST_Controller {
	/**
	 * Shared error codes.
	 */
	const INVALID_ID          = 'invalid_id';
	const RESOURCE_EXISTS     = 'resource_exists';
	const CANNOT_CREATE       = 'cannot_create';
	const CANNOT_DELETE       = 'cannot_delete';
	const CANNOT_UPDATE       = 'cannot_update';
	const CANNOT_TRASH        = 'cannot_trash';
	const TRASH_NOT_SUPPORTED = 'trash_not_supported';

	/**
	 * Route namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc/v4';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = '';

	/**
	 * Cache for the item schema populated after calling get_item_schema().
	 *
	 * @var array
	 */
	protected $schema;

	/**
	 * Get the schema for the current resource. This use consumed by the AbstractController to generate the item schema
	 * after running various hooks on the response.
	 *
	 * This should return the full schema object, not just the properties.
	 *
	 * @return array The full item schema.
	 */
	abstract protected function get_schema(): array;

	/**
	 * Get the collection args schema.
	 *
	 * @return array
	 */
	protected function get_query_schema(): array {
		return array();
	}

	/**
	 * List of args for endpoints. These may alter how data is returned or formatted. Extended by routes.
	 *
	 * @return array
	 */
	protected function get_endpoint_args(): array {
		return array();
	}

	/**
	 * Add default context collection params and filter the result. This does not inherit from
	 * WP_REST_Controller::get_collection_params because some endpoints do not paginate results.
	 *
	 * @return array
	 */
	public function get_collection_params() {
		$params            = $this->get_query_schema();
		$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );

		/**
		 * Filter the collection params.
		 *
		 * @param array $params The collection params.
		 * @since 10.2.0
		 */
		return apply_filters( $this->get_hook_prefix() . 'collection_params', $params, $this );
	}

	/**
	 * Get item schema, conforming to JSON Schema. Extended by routes.
	 *
	 * @return array The item schema.
	 * @since 10.2.0
	 */
	public function get_item_schema() {
		// Cache the schema for the route.
		if ( null === $this->schema ) {
			/**
			 * Filter the item schema for this route.
			 *
			 * @param array $schema The item schema.
			 * @since 10.2.0
			 */
			$this->schema = apply_filters( $this->get_hook_prefix() . 'item_schema', $this->add_additional_fields_schema( $this->get_schema() ) );
		}
		return $this->schema;
	}

	/**
	 * Get the item response.
	 *
	 * @param mixed           $item    WooCommerce representation of the item.
	 * @param WP_REST_Request $request Request object.
	 * @return array The item response.
	 * @since 10.2.0
	 */
	abstract protected function get_item_response( $item, WP_REST_Request $request ): array;

	/**
	 * Prepare links for the request.
	 *
	 * @param mixed            $item WordPress representation of the item.
	 * @param WP_REST_Request  $request Request object.
	 * @param WP_REST_Response $response Response object.
	 * @return array
	 */
	protected function prepare_links( $item, WP_REST_Request $request, WP_REST_Response $response ): array {
		return array();
	}

	/**
	 * Prepares the item for the REST response. Controllers do not need to override this method as they can define a
	 * get_item_response method to prepare items. This method will take care of filter hooks.
	 *
	 * @param mixed           $item    WordPress representation of the item.
	 * @param WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
	 * @since 10.2.0
	 */
	public function prepare_item_for_response( $item, $request ) {
		$response_data = $this->get_item_response( $item, $request );
		$response_data = $this->add_additional_fields_to_object( $response_data, $request );
		$response_data = $this->filter_response_by_context( $response_data, $request['context'] ?? 'view' );

		$response = rest_ensure_response( $response_data );
		$response->add_links( $this->prepare_links( $item, $request, $response ) );

		/**
		 * Filter the data for a response.
		 *
		 * @param WP_REST_Response $response The response object.
		 * @param mixed           $item    WordPress representation of the item.
		 * @param WP_REST_Request  $request  Request object.
		 * @since 10.2.0
		 */
		return rest_ensure_response( apply_filters( $this->get_hook_prefix() . 'item_response', $response, $item, $request ) );
	}

	/**
	 * Get the hook prefix for actions and filters.
	 *
	 * Example: woocommerce_rest_api_v4_orders_
	 *
	 * @return string The hook prefix.
	 * @since 10.2.0
	 */
	protected function get_hook_prefix(): string {
		return 'woocommerce_rest_api_v4_' . str_replace( '-', '_', $this->rest_base ) . '_';
	}

	/**
	 * Get the error prefix for errors.
	 *
	 * Example: woocommerce_rest_api_v4_orders_
	 *
	 * @return string The error prefix.
	 * @since 10.2.0
	 */
	protected function get_error_prefix(): string {
		return 'woocommerce_rest_api_v4_' . str_replace( '-', '_', $this->rest_base ) . '_';
	}

	/**
	 * Get route response when something went wrong.
	 *
	 * @param string $error_code String based error code.
	 * @param string $error_message User facing error message.
	 * @param int    $http_status_code HTTP status. Defaults to 400.
	 * @param array  $additional_data Extra data (key value pairs) to expose in the error response.
	 * @return WP_Error WP Error object.
	 * @since 10.2.0
	 */
	protected function get_route_error_response( string $error_code, string $error_message, int $http_status_code = WP_Http::BAD_REQUEST, array $additional_data = array() ): WP_Error {
		if ( empty( $error_code ) ) {
			$error_code = 'invalid_request';
		}

		if ( empty( $error_message ) ) {
			$error_message = __( 'An error occurred while processing your request.', 'woocommerce' );
		}

		return new WP_Error(
			$error_code,
			$error_message,
			array_merge(
				$additional_data,
				array( 'status' => $http_status_code )
			)
		);
	}

	/**
	 * Get route response when something went wrong and the supplied error is a WP_Error.
	 *
	 * @param WP_Error $error_object The WP_Error object containing the error.
	 * @param int      $http_status_code HTTP status. Defaults to 400.
	 * @param array    $additional_data Extra data (key value pairs) to expose in the error response.
	 * @return WP_Error WP Error object.
	 * @since 10.2.0
	 */
	protected function get_route_error_response_from_object( WP_Error $error_object, int $http_status_code = WP_Http::BAD_REQUEST, array $additional_data = array() ): WP_Error {
		if ( ! $error_object instanceof WP_Error ) {
			return $this->get_route_error_response( 'invalid_error_object', __( 'Invalid error object provided.', 'woocommerce' ), $http_status_code, $additional_data );
		}

		$error_object->add_data( array_merge( $additional_data, array( 'status' => $http_status_code ) ) );
		return $error_object;
	}

	/**
	 * Returns an authentication error for a given HTTP verb.
	 *
	 * @param string $method HTTP method.
	 * @return WP_Error|false WP Error object or false if no error is found.
	 */
	protected function get_authentication_error_by_method( string $method ) {
		$errors = array(
			'GET'    => array(
				'code'    => $this->get_error_prefix() . 'cannot_view',
				'message' => __( 'Sorry, you cannot view resources.', 'woocommerce' ),
			),
			'POST'   => array(
				'code'    => $this->get_error_prefix() . 'cannot_create',
				'message' => __( 'Sorry, you cannot create resources.', 'woocommerce' ),
			),
			'PUT'    => array(
				'code'    => $this->get_error_prefix() . 'cannot_update',
				'message' => __( 'Sorry, you cannot update resources.', 'woocommerce' ),
			),
			'PATCH'  => array(
				'code'    => $this->get_error_prefix() . 'cannot_update',
				'message' => __( 'Sorry, you cannot update resources.', 'woocommerce' ),
			),
			'DELETE' => array(
				'code'    => $this->get_error_prefix() . 'cannot_delete',
				'message' => __( 'Sorry, you cannot delete resources.', 'woocommerce' ),
			),
		);

		if ( ! isset( $errors[ $method ] ) ) {
			return false;
		}

		return new WP_Error(
			$errors[ $method ]['code'],
			$errors[ $method ]['message'],
			array( 'status' => rest_authorization_required_code() )
		);
	}

	/**
	 * Get an error response for a given error code.
	 *
	 * @param string $error_code The error code.
	 * @return WP_Error WP Error object.
	 */
	protected function get_route_error_by_code( string $error_code ): WP_Error {
		$error_messages    = array(
			self::INVALID_ID          => __( 'Invalid ID.', 'woocommerce' ),
			self::RESOURCE_EXISTS     => __( 'Resource already exists.', 'woocommerce' ),
			self::CANNOT_CREATE       => __( 'Cannot create resource.', 'woocommerce' ),
			self::CANNOT_DELETE       => __( 'Cannot delete resource.', 'woocommerce' ),
			self::CANNOT_UPDATE       => __( 'Cannot update resource.', 'woocommerce' ),
			self::CANNOT_TRASH        => __( 'Cannot trash resource.', 'woocommerce' ),
			self::TRASH_NOT_SUPPORTED => __( 'Trash not supported.', 'woocommerce' ),
		);
		$http_status_codes = array(
			self::INVALID_ID          => WP_Http::NOT_FOUND,
			self::RESOURCE_EXISTS     => WP_Http::BAD_REQUEST,
			self::CANNOT_CREATE       => WP_Http::INTERNAL_SERVER_ERROR,
			self::CANNOT_DELETE       => WP_Http::INTERNAL_SERVER_ERROR,
			self::CANNOT_UPDATE       => WP_Http::INTERNAL_SERVER_ERROR,
			self::CANNOT_TRASH        => WP_Http::GONE,
			self::TRASH_NOT_SUPPORTED => WP_Http::NOT_IMPLEMENTED,
		);
		return $this->get_route_error_response(
			$this->get_error_prefix() . $error_code,
			$error_messages[ $error_code ] ?? __( 'An error occurred while processing your request.', 'woocommerce' ),
			$http_status_codes[ $error_code ] ?? WP_Http::BAD_REQUEST
		);
	}
}
PK     [1](o{J  {J  -  RestApi/Routes/V4/Fulfillments/Controller.phpnu         <?php
/**
 * Order Fulfillments REST Controller for API Version 4
 *
 * Handles route registration, permissions, CRUD operations, and schema definition.
 * This is a completely independent base controller for WooCommerce API v4.
 * Unlike previous versions, this does not inherit from v3, v2, or v1 controllers.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Fulfillments;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\Admin\Settings\Exceptions\ApiException;
use Automattic\WooCommerce\Internal\Fulfillments\Fulfillment;
use Automattic\WooCommerce\Internal\Fulfillments\OrderFulfillmentsRestController;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractController;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Fulfillments\Schema\FulfillmentSchema;
use WP_Http;
use WP_Error;
use WC_Order;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Fulfillments Controller.
 */
class Controller extends AbstractController {
	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'fulfillments';

	/**
	 * Schema class for this route.
	 *
	 * @var FulfillmentSchema
	 */
	protected $item_schema;

	/**
	 * Order fulfillments controller instance.
	 *
	 * @var OrderFulfillmentsRestController
	 */
	protected $order_fulfillments_controller;

	/**
	 * Initialize the controller.
	 *
	 * @param FulfillmentSchema               $item_schema                   Fulfillment schema class.
	 * @param OrderFulfillmentsRestController $order_fulfillments_controller Order fulfillments controller.
	 *
	 * @internal
	 */
	final public function init( FulfillmentSchema $item_schema, OrderFulfillmentsRestController $order_fulfillments_controller ) {
		$this->item_schema                   = $item_schema;
		$this->order_fulfillments_controller = $order_fulfillments_controller;
	}

	/**
	 * Register the routes for fulfillments.
	 *
	 * @since 4.0.0
	 */
	public function register_routes() {
		// Register the route for getting and setting order fulfillments.
		register_rest_route(
			$this->namespace,
			$this->rest_base,
			array(
				'schema' => array( $this, 'get_public_item_schema' ),
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_fulfillments' ),
					'permission_callback' => array( $this, 'check_permission_for_fulfillments' ),
					'args'                => array(
						'order_id' => array(
							'description' => __( 'Unique identifier for the order.', 'woocommerce' ),
							'type'        => 'integer',
							'required'    => true,
						),
					),
				),
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'create_fulfillment' ),
					'permission_callback' => array( $this, 'check_permission_for_fulfillments' ),
					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
				),
			),
		);

		// Register the route for getting a specific fulfillment.
		register_rest_route(
			$this->namespace,
			$this->rest_base . '/(?P<fulfillment_id>[\d]+)',
			array(
				'schema' => array( $this, 'get_public_item_schema' ),
				'args'   => array(
					'fulfillment_id' => array(
						'description' => __( 'Unique identifier for the fulfillment.', 'woocommerce' ),
						'type'        => 'integer',
						'required'    => true,
					),
				),
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_fulfillment' ),
					'permission_callback' => array( $this, 'check_permission_for_fulfillments' ),
				),
				array(
					'methods'             => WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'update_fulfillment' ),
					'permission_callback' => array( $this, 'check_permission_for_fulfillments' ),
					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
				),
				array(
					'methods'             => WP_REST_Server::DELETABLE,
					'callback'            => array( $this, 'delete_fulfillment' ),
					'permission_callback' => array( $this, 'check_permission_for_fulfillments' ),
					'args'                => array(
						'notify_customer' => array(
							'description' => __( 'Whether to notify the customer about the fulfillment update.', 'woocommerce' ),
							'type'        => 'boolean',
							'default'     => false,
							'required'    => false,
						),
					),
				),
			),
		);

		// Register the route for getting shipping providers.
		register_rest_route(
			$this->namespace,
			$this->rest_base . '/providers',
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_providers' ),
					'permission_callback' => array( $this, 'check_permission_for_providers' ),
					'schema'              => array( $this, 'get_schema_for_providers' ),
				),
			)
		);
	}

	/**
	 * Get a list of fulfillments for a specific order.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response
	 */
	public function get_fulfillments( WP_REST_Request $request ): WP_REST_Response {
		$order_id = (int) $request->get_param( 'order_id' );

		// Validate the order ID.
		if ( ! $order_id ) {
			return $this->prepare_error_response(
				'woocommerce_rest_order_id_required',
				__( 'The order ID is required.', 'woocommerce' ),
				array( 'status' => esc_attr( WP_Http::BAD_REQUEST ) )
			);
		}

		$order = wc_get_order( $order_id );
		if ( ! $order ) {
			return $this->prepare_error_response(
				'woocommerce_rest_order_invalid_id',
				__( 'Invalid order ID.', 'woocommerce' ),
				array( 'status' => esc_attr( WP_Http::NOT_FOUND ) )
			);
		}

		$request->set_param( 'order_id', $order_id );
		return $this->order_fulfillments_controller->get_fulfillments( $request );
	}

	/**
	 * Create a fulfillment for a specific order.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response
	 */
	public function create_fulfillment( WP_REST_Request $request ): WP_REST_Response {
		$params    = $request->get_json_params();
		$entity_id = $params['entity_id'] ?? null;

		// Validate the entity ID.
		if ( ! $entity_id ) {
			return $this->prepare_error_response(
				'woocommerce_rest_entity_id_required',
				__( 'The entity ID is required.', 'woocommerce' ),
				array( 'status' => esc_attr( WP_Http::BAD_REQUEST ) )
			);
		}
		$order = wc_get_order( (int) $entity_id );
		if ( ! $order ) {
			return $this->prepare_error_response(
				'woocommerce_rest_order_invalid_id',
				__( 'Invalid order ID.', 'woocommerce' ),
				array( 'status' => esc_attr( WP_Http::NOT_FOUND ) )
			);
		}

		$request->set_param( 'order_id', $entity_id );
		return $this->order_fulfillments_controller->create_fulfillment( $request );
	}

	/**
	 * Get a specific fulfillment for a specific order.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response
	 */
	public function get_fulfillment( WP_REST_Request $request ): WP_REST_Response {
		$fulfillment_id = (int) $request->get_param( 'fulfillment_id' );
		$fulfillment    = new Fulfillment( $fulfillment_id );

		if ( ! $fulfillment->get_id() ) {
			return $this->prepare_error_response(
				'woocommerce_rest_fulfillment_invalid_id',
				__( 'Invalid fulfillment ID.', 'woocommerce' ),
				array( 'status' => esc_attr( WP_Http::NOT_FOUND ) )
			);
		}

		if ( $fulfillment->get_entity_type() !== WC_Order::class ) {
			return $this->prepare_error_response(
				'woocommerce_rest_invalid_entity_type',
				__( 'The entity type must be "order".', 'woocommerce' ),
				array( 'status' => esc_attr( WP_Http::BAD_REQUEST ) )
			);
		}

		$order_id = (int) $fulfillment->get_entity_id();
		$request->set_param( 'order_id', $order_id );
		return $this->order_fulfillments_controller->get_fulfillment( $request );
	}

	/**
	 * Update a specific fulfillment for a specific order.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response
	 */
	public function update_fulfillment( WP_REST_Request $request ): WP_REST_Response {
		$fulfillment_id = (int) $request->get_param( 'fulfillment_id' );
		$fulfillment    = new Fulfillment( $fulfillment_id );

		if ( ! $fulfillment->get_id() ) {
			return $this->prepare_error_response(
				'woocommerce_rest_fulfillment_invalid_id',
				__( 'Invalid fulfillment ID.', 'woocommerce' ),
				array( 'status' => esc_attr( WP_Http::NOT_FOUND ) )
			);
		}

		if ( $fulfillment->get_entity_type() !== WC_Order::class ) {
			return $this->prepare_error_response(
				'woocommerce_rest_invalid_entity_type',
				__( 'The entity type must be "order".', 'woocommerce' ),
				array( 'status' => esc_attr( WP_Http::BAD_REQUEST ) )
			);
		}

		$order_id = (int) $fulfillment->get_entity_id();
		$request->set_param( 'order_id', $order_id );
		return $this->order_fulfillments_controller->update_fulfillment( $request );
	}

	/**
	 * Delete a specific fulfillment for a specific order.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response
	 */
	public function delete_fulfillment( WP_REST_Request $request ): WP_REST_Response {
		$fulfillment_id = (int) $request->get_param( 'fulfillment_id' );
		$fulfillment    = new Fulfillment( $fulfillment_id );
		$order_id       = (int) $fulfillment->get_entity_id();
		$request->set_param( 'order_id', $order_id );
		return $this->order_fulfillments_controller->delete_fulfillment( $request );
	}

	/**
	 * Permission check for REST API endpoints, given the request method.
	 * For all fulfillments methods that have an order_id, we need to be sure the user has permission to view the order.
	 * For all other methods, we check if the user is logged in as admin and has the required capability.
	 *
	 * @param WP_REST_Request $request The request for which the permission is checked.
	 * @return bool|WP_Error True if the current user has the capability, otherwise an "Unauthorized" error or False if no error is available for the request method.
	 *
	 * @throws WP_Error If the URL contains an order, but the order does not exist.
	 */
	public function check_permission_for_fulfillments( WP_REST_Request $request ) {
		// Fetch the order first if there's an order_id in the request.
		$order = null;

		// If there's an order_id in the request, try to get the order.
		if ( $request->has_param( 'order_id' ) ) {
			$order_id = (int) $request->get_param( 'order_id' );
			$order    = wc_get_order( $order_id );
		}

		// If there's a fulfillment_id in the request, try to get the order from the fulfillment.
		if ( ! $order && $request->has_param( 'fulfillment_id' ) ) {
			$fulfillment_id = (int) $request->get_param( 'fulfillment_id' );
			if ( $fulfillment_id ) {
				try {
					$fulfillment = new Fulfillment( $fulfillment_id );
					$order_id    = (int) $fulfillment->get_entity_id();
					$order       = wc_get_order( $order_id );
				} catch ( ApiException $ex ) {
					return new WP_Error(
						$ex->getErrorCode(),
						$ex->getMessage(),
						array( 'status' => esc_attr( WP_Http::BAD_REQUEST ) )
					);
				} catch ( \Exception $e ) {
					return new WP_Error(
						'woocommerce_rest_fulfillment_invalid_id',
						$e->getMessage(),
						array( 'status' => esc_attr( WP_Http::BAD_REQUEST ) )
					);
				}
			}
		}

		// If there's no order_id in the request, try to get it from the request body.
		$body_params = $request->get_json_params();
		if ( ! $order && isset( $body_params['entity_id'] ) && isset( $body_params['entity_type'] ) ) {
			if ( WC_Order::class !== $body_params['entity_type'] ) {
				return new WP_Error(
					'woocommerce_rest_invalid_entity_type',
					esc_html__( 'The entity type must be "order".', 'woocommerce' ),
					array( 'status' => esc_attr( WP_Http::BAD_REQUEST ) )
				);
			}

			$order_id = (int) $body_params['entity_id'];
			$order    = wc_get_order( $order_id );
		}

		// If there's still no order, return an error.
		if ( ! $order ) {
			return new WP_Error(
				'woocommerce_rest_order_id_required',
				esc_html__( 'The order ID is required.', 'woocommerce' ),
				array( 'status' => esc_attr( WP_Http::BAD_REQUEST ) )
			);
		}

		// Check if the user is logged in as admin, and has the required capability.
		// Admins who can manage WooCommerce can view all fulfillments.
		if ( current_user_can( 'manage_woocommerce' ) ) { // phpcs:ignore WordPress.WP.Capabilities.Unknown
			return true;
		}

		// Check if the order exists, and if the current user is the owner of the order, and the request is a read request.
		// We allow this because we need to render the order fulfillments on the customer's order details and order tracking pages.
		// But they will be only able to view them, not edit.
		if ( get_current_user_id() === $order->get_customer_id() && WP_REST_Server::READABLE === $request->get_method() ) {
			return true;
		}

		// Return an error related to the request method.
		$error_information = $this->get_authentication_error_by_method( $request->get_method() );

		if ( false === $error_information ) {
			return false;
		}

		return $error_information;
	}

	/**
	 * Get the schema for the fulfillment resource. This is consumed by the AbstractController to generate the item schema
	 * after running various hooks on the response.
	 *
	 * @return array The schema for the fulfillment resource.
	 */
	protected function get_schema(): array {
		return $this->item_schema->get_item_schema();
	}

	/**
	 * Get the item response for a fulfillment.
	 *
	 * @param mixed           $item    The fulfillment item.
	 * @param WP_REST_Request $request The request object.
	 * @return array The item response.
	 */
	protected function get_item_response( $item, WP_REST_Request $request ): array {
		return $this->item_schema->get_item_response( $item, $request, $this->get_fields_for_response( $request ) );
	}


	/**
	 * Prepare an error response.
	 *
	 * @param string $code The error code.
	 * @param string $message The error message.
	 * @param array  $data Additional error data, including 'status' key for HTTP status code.
	 *
	 * @return WP_REST_Response The error response.
	 */
	private function prepare_error_response( $code, $message, $data ): WP_REST_Response {
		return new WP_REST_Response(
			array(
				'code'    => $code,
				'message' => $message,
				'data'    => $data,
			),
			$data['status'] ?? WP_Http::BAD_REQUEST
		);
	}

	/**
	 * Get all shipping providers.
	 *
	 * @since 10.5.0
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response
	 */
	public function get_providers( WP_REST_Request $request ): WP_REST_Response {
		$providers = \Automattic\WooCommerce\Internal\Fulfillments\FulfillmentUtils::get_shipping_providers_object();

		/**
		 * Filters the shipping providers response before it is returned.
		 *
		 * Each provider in the array must have the following structure:
		 * - 'label' (string): The display name of the provider.
		 * - 'icon' (string): URL to the provider's icon.
		 * - 'value' (string): The provider's unique identifier.
		 * - 'url' (string): The tracking URL template.
		 *
		 * @param array           $providers The shipping providers data.
		 * @param WP_REST_Request $request   The request object.
		 *
		 * @since 10.5.0
		 */
		$providers = apply_filters( 'woocommerce_rest_prepare_fulfillments_providers', $providers, $request );

		// Validate filtered result to prevent extensions from returning invalid structures.
		if ( ! is_array( $providers ) ) {
			_doing_it_wrong(
				'woocommerce_rest_prepare_fulfillments_providers',
				esc_html__( 'The filter must return an array of providers.', 'woocommerce' ),
				'10.5.0'
			);
			$providers = array();
		} else {
			$providers = $this->validate_providers_structure( $providers );
		}

		return new WP_REST_Response( $providers, WP_Http::OK );
	}

	/**
	 * Validate the structure of providers returned by a filter.
	 *
	 * Removes any providers that don't have the required keys (label, icon, value, url).
	 *
	 * @since 10.5.0
	 * @param array $providers The providers array to validate.
	 * @return array The validated providers array with invalid entries removed.
	 */
	private function validate_providers_structure( array $providers ): array {
		$required_keys   = array( 'label', 'icon', 'value', 'url' );
		$valid_providers = array();
		$has_invalid     = false;

		foreach ( $providers as $key => $provider ) {
			if ( ! is_array( $provider ) ) {
				$has_invalid = true;
				continue;
			}

			$missing_keys = array_diff( $required_keys, array_keys( $provider ) );
			if ( ! empty( $missing_keys ) ) {
				$has_invalid = true;
				continue;
			}

			$valid_providers[ $key ] = $provider;
		}

		if ( $has_invalid ) {
			_doing_it_wrong(
				'woocommerce_rest_prepare_fulfillments_providers',
				esc_html__( 'Some providers were removed because they are missing required keys (label, icon, value, url).', 'woocommerce' ),
				'10.5.0'
			);
		}

		return $valid_providers;
	}

	/**
	 * Check permissions for accessing shipping providers.
	 *
	 * @since 10.5.0
	 * @param WP_REST_Request $request Full details about the request.
	 * @return bool|WP_Error True if the current user has the capability, otherwise a WP_Error.
	 */
	public function check_permission_for_providers( WP_REST_Request $request ) {
		if ( ! current_user_can( 'manage_woocommerce' ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );
		}

		return true;
	}

	/**
	 * Get the schema for the providers endpoint.
	 *
	 * @since 10.5.0
	 * @return array The schema for the providers endpoint.
	 */
	public function get_schema_for_providers(): array {
		return array(
			'$schema'              => 'http://json-schema.org/draft-04/schema#',
			'title'                => __( 'Shipping providers', 'woocommerce' ),
			'type'                 => 'object',
			'additionalProperties' => array(
				'type'       => 'object',
				'properties' => array(
					'label' => array(
						'description' => __( 'The display name of the shipping provider.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => array( 'view' ),
						'readonly'    => true,
					),
					'icon'  => array(
						'description' => __( 'The icon URL for the shipping provider.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => array( 'view' ),
						'readonly'    => true,
					),
					'value' => array(
						'description' => __( 'The unique key for the shipping provider.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => array( 'view' ),
						'readonly'    => true,
					),
					'url'   => array(
						'description' => __( 'The tracking URL template for the shipping provider.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => array( 'view' ),
						'readonly'    => true,
					),
				),
			),
		);
	}
}
PK     [1]SB    ;  RestApi/Routes/V4/Fulfillments/Schema/FulfillmentSchema.phpnu         <?php
/**
 * FulfillmentSchema class.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Fulfillments\Schema;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\Fulfillments\Fulfillment;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractSchema;
use WP_REST_Request;

/**
 * FulfillmentSchema class.
 */
class FulfillmentSchema extends AbstractSchema {
	/**
	 * The schema item identifier.
	 *
	 * @var string
	 */
	const IDENTIFIER = 'fulfillment';

	/**
	 * Return all properties for the item schema.
	 *
	 * Note that context determines under which context data should be visible. For example, edit would be the context
	 * used when getting records with the intent of editing them. embed context allows the data to be visible when the
	 * item is being embedded in another response.
	 *
	 * @return array
	 */
	public function get_item_schema_properties(): array {
		return array(
			'id'           => array(
				'description' => __( 'Unique identifier for the fulfillment.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'entity_type'  => array(
				'description' => __( 'The type of entity for which the fulfillment is created.', 'woocommerce' ),
				'type'        => 'string',
				'required'    => true,
				'context'     => self::VIEW_EDIT_CONTEXT,
			),
			'entity_id'    => array(
				'description' => __( 'Unique identifier for the entity.', 'woocommerce' ),
				'type'        => 'string',
				'required'    => true,
				'context'     => self::VIEW_EDIT_CONTEXT,
			),
			'status'       => array(
				'description' => __( 'The status of the fulfillment.', 'woocommerce' ),
				'type'        => 'string',
				'default'     => 'unfulfilled',
				'required'    => true,
				'context'     => self::VIEW_EDIT_CONTEXT,
			),
			'is_fulfilled' => array(
				'description' => __( 'Whether the fulfillment is fulfilled.', 'woocommerce' ),
				'type'        => 'boolean',
				'default'     => false,
				'required'    => true,
				'context'     => self::VIEW_EDIT_CONTEXT,
			),
			'date_updated' => array(
				'description' => __( 'The date the fulfillment was last updated.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
				'required'    => true,
			),
			'date_deleted' => array(
				'description' => __( 'The date the fulfillment was deleted.', 'woocommerce' ),
				'anyOf'       => array(
					array(
						'type' => 'string',
					),
					array(
						'type' => 'null',
					),
				),
				'default'     => null,
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
				'required'    => true,
			),
			'meta_data'    => array(
				'description' => __( 'Meta data for the fulfillment.', 'woocommerce' ),
				'type'        => 'array',
				'required'    => true,
				'items'       => array(
					'type'       => 'object',
					'properties' => array(
						'id'    => array(
							'description' => __( 'The unique identifier for the meta data. Set `0` for new records.', 'woocommerce' ),
							'type'        => 'integer',
							'context'     => self::VIEW_EDIT_CONTEXT,
							'readonly'    => true,
						),
						'key'   => array(
							'description' => __( 'The key of the meta data.', 'woocommerce' ),
							'type'        => 'string',
							'required'    => true,
							'context'     => self::VIEW_EDIT_CONTEXT,
						),
						'value' => array(
							'description' => __( 'The value of the meta data.', 'woocommerce' ),
							'type'        => array( 'string', 'number', 'boolean', 'object', 'array', 'null' ),
							'required'    => true,
							'context'     => self::VIEW_EDIT_CONTEXT,
						),
					),
					'required'   => true,
					'context'    => self::VIEW_EDIT_CONTEXT,
					'readonly'   => true,
				),
			),
		);
	}

	/**
	 * Get the item response.
	 *
	 * @param Fulfillment     $fulfillment Fulfillment object.
	 * @param WP_REST_Request $request Request object.
	 * @param array           $include_fields Fields to include in the response.
	 * @return array The item response.
	 */
	public function get_item_response( $fulfillment, WP_REST_Request $request, array $include_fields = array() ): array {
		$date_deleted = $fulfillment->get_date_deleted();

		return array(
			'id'           => $fulfillment->get_id(),
			'entity_type'  => $fulfillment->get_entity_type(),
			'entity_id'    => (string) $fulfillment->get_entity_id(),
			'status'       => $fulfillment->get_status(),
			'is_fulfilled' => $fulfillment->get_is_fulfilled(),
			'date_updated' => wc_rest_prepare_date_response( $fulfillment->get_date_updated() ),
			'date_deleted' => $date_deleted ? wc_rest_prepare_date_response( $date_deleted ) : null,
			'meta_data'    => $fulfillment->get_meta_data(),
		);
	}
}
PK     [1]`B6i@  i@  *  RestApi/Routes/V4/Customers/Controller.phpnu         <?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
 * REST API Customers controller
 *
 * Handles route registration, permissions, CRUD operations, and schema definition.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Customers;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractController;
use Automattic\WooCommerce\StoreApi\Utilities\Pagination;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Customers\CustomerSchema;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Customers\CollectionQuery;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Customers\UpdateUtils;
use WP_Http;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WC_Customer;

/**
 * Customers Controller.
 */
class Controller extends AbstractController {
	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'customers';

	/**
	 * Schema class for this route.
	 *
	 * @var CustomerSchema
	 */
	protected $item_schema;

	/**
	 * Collection query class.
	 *
	 * @var CollectionQuery
	 */
	protected $collection_query;

	/**
	 * Update utils class.
	 *
	 * @var UpdateUtils
	 */
	protected $update_utils;

	/**
	 * Initialize the controller.
	 *
	 * @param CustomerSchema  $item_schema Customer schema class.
	 * @param CollectionQuery $collection_query Collection query class.
	 * @param UpdateUtils     $update_utils Update utils class.
	 * @internal
	 */
	final public function init( CustomerSchema $item_schema, CollectionQuery $collection_query, UpdateUtils $update_utils ) {
		$this->item_schema      = $item_schema;
		$this->collection_query = $collection_query;
		$this->update_utils     = $update_utils;
	}

	/**
	 * Get the schema for the current resource. This use consumed by the AbstractController to generate the item schema
	 * after running various hooks on the response.
	 */
	protected function get_schema(): array {
		return $this->item_schema->get_item_schema();
	}

	/**
	 * Get the collection args schema.
	 *
	 * @return array
	 */
	protected function get_query_schema(): array {
		return $this->collection_query->get_query_schema();
	}

	/**
	 * Register the routes for customers.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				'schema' => array( $this, 'get_public_item_schema' ),
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => $this->get_collection_params(),
				),
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'create_item' ),
					'permission_callback' => array( $this, 'create_item_permissions_check' ),
					'args'                => array_merge(
						$this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
						array(
							'email'    => array(
								'required'    => true,
								'type'        => 'string',
								'description' => __( 'New user email address.', 'woocommerce' ),
							),
							'username' => array(
								'required'    => 'no' === get_option( 'woocommerce_registration_generate_username', 'yes' ),
								'description' => __( 'New user username.', 'woocommerce' ),
								'type'        => 'string',
							),
							'password' => array(
								'required'    => 'no' === get_option( 'woocommerce_registration_generate_password', 'no' ),
								'description' => __( 'New user password.', 'woocommerce' ),
								'type'        => 'string',
							),
						)
					),
				),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[\d]+)',
			array(
				'schema' => array( $this, 'get_public_item_schema' ),
				'args'   => array(
					'id' => array(
						'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
						'type'        => 'integer',
					),
				),
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_item' ),
					'permission_callback' => array( $this, 'get_item_permissions_check' ),
					'args'                => array(
						'context' => $this->get_context_param( array( 'default' => 'view' ) ),
					),
				),
				array(
					'methods'             => WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'update_item' ),
					'permission_callback' => array( $this, 'update_item_permissions_check' ),
					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
				),
				array(
					'methods'             => WP_REST_Server::DELETABLE,
					'callback'            => array( $this, 'delete_item' ),
					'permission_callback' => array( $this, 'delete_item_permissions_check' ),
					'args'                => array(
						'force' => array(
							'default'     => false,
							'type'        => 'boolean',
							'description' => __( 'Required to be true, as resource does not support trashing.', 'woocommerce' ),
						),
					),
				),
			)
		);
	}

	/**
	 * Get a single customer.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response
	 */
	public function get_item( $request ) {
		$user = \Automattic\WooCommerce\Internal\Utilities\Users::get_user_in_current_site( $request['id'] );
		if ( is_wp_error( $user ) ) {
			return $this->get_route_error_by_code( self::INVALID_ID );
		}

		$customer = $this->prepare_item_for_response( new WC_Customer( $user->ID ), $request );
		$response = rest_ensure_response( $customer );

		return $response;
	}

	/**
	 * Prepare a single customer object for response.
	 *
	 * @param WC_Customer     $customer Customer object.
	 * @param WP_REST_Request $request Request object.
	 * @return array
	 */
	protected function get_item_response( $customer, WP_REST_Request $request ): array {
		return $this->item_schema->get_item_response( $customer, $request, $this->get_fields_for_response( $request ) );
	}

	/**
	 * Get collection of customers.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response
	 */
	public function get_items( $request ) {
		$query_args = $this->collection_query->get_query_args( $request );
		$results    = $this->collection_query->get_query_results( $query_args, $request );
		$items      = array();

		foreach ( $results['results'] as $customer ) {
			$items[] = $this->prepare_response_for_collection( $this->prepare_item_for_response( $customer, $request ) );
		}

		$pagination_util = new Pagination();
		$response        = $pagination_util->add_headers( rest_ensure_response( $items ), $request, $results['total'], $results['pages'] );

		return $response;
	}

	/**
	 * Create a single customer.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response
	 */
	public function create_item( $request ) {
		if ( ! empty( $request['id'] ) ) {
			return $this->get_route_error_by_code( self::RESOURCE_EXISTS );
		}

		try {
			// Sets the username.
			$request['username'] = ! empty( $request['username'] ) ? $request['username'] : '';

			// Sets the password.
			$request['password'] = ! empty( $request['password'] ) ? $request['password'] : '';

			// Create customer.
			$customer = new WC_Customer();
			$customer->set_username( $request['username'] );
			$customer->set_password( $request['password'] );
			$customer->set_email( $request['email'] );

			$this->update_utils->update_customer_from_request( $customer, $request, true );

			if ( ! $customer->get_id() ) {
				return $this->get_route_error_by_code( self::CANNOT_CREATE );
			}

			$user_data = get_userdata( $customer->get_id() );
			$this->update_additional_fields_for_object( $user_data, $request );

			/**
			 * Fires after a customer is created via the REST API.
			 *
			 * @param WP_User         $user_data Data used to create the customer.
			 * @param WP_REST_Request $request   Request object.
			 * @since 10.2.0
			 */
			do_action( $this->get_hook_prefix() . 'created', $user_data, $request );

			$request->set_param( 'context', 'edit' );
			$response = $this->prepare_item_for_response( $customer, $request );
			$response->set_status( WP_Http::CREATED );
			$response->header( 'Location', rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $customer->get_id() ) ) );

			return $response;
		} catch ( \WC_REST_Exception $e ) {
			return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
		} catch ( \Exception $e ) {
			return $this->get_route_error_by_code( self::CANNOT_CREATE );
		}
	}

	/**
	 * Update a single customer.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response
	 */
	public function update_item( $request ) {
		$user = \Automattic\WooCommerce\Internal\Utilities\Users::get_user_in_current_site( $request['id'] );
		if ( is_wp_error( $user ) ) {
			return $this->get_route_error_by_code( self::INVALID_ID );
		}

		$customer = new WC_Customer( $user->ID );

		if ( ! $customer->get_id() ) {
			return $this->get_route_error_by_code( self::INVALID_ID );
		}

		try {
			$this->update_utils->update_customer_from_request( $customer, $request, false );

			$user_data = get_userdata( $customer->get_id() );
			$this->update_additional_fields_for_object( $user_data, $request );

			/**
			 * Fires after a customer is updated via the REST API.
			 *
			 * @param WP_User         $user_data Data used to update the customer.
			 * @param WP_REST_Request $request   Request object.
			 * @since 10.2.0
			 */
			do_action( $this->get_hook_prefix() . 'updated', $user_data, $request );

			$request->set_param( 'context', 'edit' );
			return $this->prepare_item_for_response( $customer, $request );
		} catch ( \WC_REST_Exception $e ) {
			return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
		} catch ( \Exception $e ) {
			return $this->get_route_error_by_code( self::CANNOT_UPDATE );
		}
	}

	/**
	 * Delete a single customer.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response
	 */
	public function delete_item( $request ) {
		$id    = (int) $request['id'];
		$force = isset( $request['force'] ) ? (bool) $request['force'] : false;

		// We don't support trashing for this type, error out.
		if ( ! $force ) {
			return $this->get_route_error_by_code( self::TRASH_NOT_SUPPORTED );
		}

		$user_data = \Automattic\WooCommerce\Internal\Utilities\Users::get_user_in_current_site( $id );
		if ( is_wp_error( $user_data ) ) {
			return $this->get_route_error_by_code( self::INVALID_ID );
		}

		$request->set_param( 'context', 'edit' );
		$response = $this->prepare_item_for_response( new WC_Customer( $id ), $request );

		/** Include admin customer functions to get access to wp_delete_user() */
		require_once ABSPATH . 'wp-admin/includes/user.php';

		$customer = new WC_Customer( $id );

		$result = $customer->delete();

		if ( ! $result ) {
			return $this->get_route_error_by_code( self::CANNOT_DELETE );
		}

		/**
		 * Fires after a customer is deleted via the REST API.
		 *
		 * @param WP_User          $user_data User data.
		 * @param WP_REST_Response $response  The response returned from the API.
		 * @param WP_REST_Request  $request   The request sent to the API.
		 * @since 10.2.0
		 */
		do_action( $this->get_hook_prefix() . 'deleted', $user_data, $response, $request );

		return $response;
	}

	/**
	 * Check if a given request has access to read items.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function get_items_permissions_check( $request ) {
		if ( ! wc_rest_check_user_permissions( 'read' ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );
		}
		return true;
	}

	/**
	 * Check if a given request has access to read an item.
	 *
	 * @param  WP_REST_Request $request The request object.
	 * @return WP_Error|boolean
	 */
	public function get_item_permissions_check( $request ) {
		$user = \Automattic\WooCommerce\Internal\Utilities\Users::get_user_in_current_site( $request['id'] );

		if ( is_wp_error( $user ) ) {
			return $this->get_route_error_by_code( self::INVALID_ID );
		}

		if ( ! wc_rest_check_user_permissions( 'read', $user->ID ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );
		}
		return true;
	}

	/**
	 * Check if a given request has access to create an item.
	 *
	 * @param  WP_REST_Request $request The request object.
	 * @return WP_Error|boolean
	 */
	public function create_item_permissions_check( $request ) {
		if ( ! wc_rest_check_user_permissions( 'create' ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );
		}
		return true;
	}

	/**
	 * Check if a given request has access to update an item.
	 *
	 * @param  WP_REST_Request $request The request object.
	 * @return WP_Error|boolean
	 */
	public function update_item_permissions_check( $request ) {
		$user = \Automattic\WooCommerce\Internal\Utilities\Users::get_user_in_current_site( $request['id'] );

		if ( is_wp_error( $user ) ) {
			return $this->get_route_error_by_code( self::INVALID_ID );
		}

		if ( ! wc_rest_check_user_permissions( 'edit', $user->ID ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );
		}

		// Check if user role is allowed to be updated.
		$allowed_roles = $this->get_allowed_roles();
		$customer      = new WC_Customer( $user->ID );

		if ( $customer && ! in_array( $customer->get_role(), $allowed_roles, true ) ) {
			// Check against existing props to be compatible with clients that will send the entire user object.
			$non_editable_props = array( 'email', 'password' );
			$customer_prop      = array( 'email' => $customer->get_email() );
			foreach ( $non_editable_props as $prop ) {
				if ( isset( $request[ $prop ] ) && ( 'password' === $prop || $request[ $prop ] !== $customer_prop[ $prop ] ) ) {
					return new WP_Error(
						'woocommerce_rest_cannot_edit',
						sprintf(
							/* translators: 1s: name of the property (email, role), 2: Role of the user (administrator, customer). */
							__( 'Sorry, %1$s cannot be updated via this endpoint for a user with role %2$s.', 'woocommerce' ),
							$prop,
							$customer->get_role()
						),
						array( 'status' => rest_authorization_required_code() )
					);
				}
			}
		}

		return true;
	}

	/**
	 * Check if a given request has access to delete an item.
	 *
	 * @param  WP_REST_Request $request The request object.
	 * @return bool|WP_Error
	 */
	public function delete_item_permissions_check( $request ) {
		$user = \Automattic\WooCommerce\Internal\Utilities\Users::get_user_in_current_site( $request['id'] );

		if ( is_wp_error( $user ) ) {
			return $this->get_route_error_by_code( self::INVALID_ID );
		}

		if ( ! wc_rest_check_user_permissions( 'delete', $user->ID ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );
		}

		$id = (int) $request['id'];

		$allowed_roles = $this->get_allowed_roles();
		$customer      = new WC_Customer( $id );

		if ( ! in_array( $customer->get_role(), $allowed_roles, true ) ) {
			return new WP_Error(
				'woocommerce_rest_cannot_delete',
				sprintf(
					/* translators: 1: Role of the user (administrator, customer), 2: comma separated list of allowed roles. egs customer, subscriber */
					__( 'Sorry, users with %1$s role cannot be deleted via this endpoint. Allowed roles: %2$s', 'woocommerce' ),
					$customer->get_role(),
					implode( ', ', $allowed_roles )
				),
				array( 'status' => rest_authorization_required_code() )
			);
		}

		return true;
	}

	/**
	 * Returns list of allowed roles for the REST API.
	 *
	 * @return array $roles Allowed roles to be updated via the REST API.
	 */
	private function get_allowed_roles(): array {
		/**
		 * Filter the allowed roles for the REST API.
		 *
		 * Danger: Make sure that the roles listed here cannot manage the shop.
		 *
		 * @param array $roles Array of allowed roles.
		 *
		 * @since 9.5.2
		 */
		return apply_filters( 'woocommerce_rest_customer_allowed_roles', array( 'customer', 'subscriber' ) );
	}
}
PK     [1]ݲ*  *  .  RestApi/Routes/V4/Customers/CustomerSchema.phpnu         <?php
/**
 * CustomerSchema class.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Customers;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractSchema;
use WC_Customer;
use WP_REST_Request;

/**
 * CustomerSchema class.
 */
class CustomerSchema extends AbstractSchema {
	/**
	 * The schema item identifier.
	 *
	 * @var string
	 */
	const IDENTIFIER = 'customer';

	/**
	 * Return all properties for the item schema.
	 *
	 * @return array
	 */
	public function get_item_schema_properties(): array {
		$schema = array(
			'id'                 => array(
				'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'date_created'       => array(
				'description' => __( "The date the customer was created, in the site's timezone.", 'woocommerce' ),
				'type'        => 'date-time',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'date_created_gmt'   => array(
				'description' => __( 'The date the customer was created, as GMT.', 'woocommerce' ),
				'type'        => 'date-time',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'date_modified'      => array(
				'description' => __( "The date the customer was last modified, in the site's timezone.", 'woocommerce' ),
				'type'        => 'date-time',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'date_modified_gmt'  => array(
				'description' => __( 'The date the customer was last modified, as GMT.', 'woocommerce' ),
				'type'        => 'date-time',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'email'              => array(
				'description' => __( 'The email address for the customer.', 'woocommerce' ),
				'type'        => 'string',
				'format'      => 'email',
				'context'     => self::VIEW_EDIT_CONTEXT,
			),
			'first_name'         => array(
				'description' => __( 'Customer first name.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'arg_options' => array(
					'sanitize_callback' => 'sanitize_text_field',
				),
			),
			'last_name'          => array(
				'description' => __( 'Customer last name.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'arg_options' => array(
					'sanitize_callback' => 'sanitize_text_field',
				),
			),
			'role'               => array(
				'description' => __( 'Customer role.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'username'           => array(
				'description' => __( 'Customer login name.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'arg_options' => array(
					'sanitize_callback' => 'sanitize_user',
				),
			),
			'billing'            => array(
				'description' => __( 'List of billing address data.', 'woocommerce' ),
				'type'        => 'object',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'properties'  => array(
					'first_name' => array(
						'description' => __( 'First name.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_CONTEXT,
					),
					'last_name'  => array(
						'description' => __( 'Last name.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_CONTEXT,
					),
					'company'    => array(
						'description' => __( 'Company name.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_CONTEXT,
					),
					'address_1'  => array(
						'description' => __( 'Address line 1', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_CONTEXT,
					),
					'address_2'  => array(
						'description' => __( 'Address line 2', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_CONTEXT,
					),
					'city'       => array(
						'description' => __( 'City name.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_CONTEXT,
					),
					'state'      => array(
						'description' => __( 'ISO code or name of the state, province or district.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_CONTEXT,
					),
					'postcode'   => array(
						'description' => __( 'Postal code.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_CONTEXT,
					),
					'country'    => array(
						'description' => __( 'ISO code of the country.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_CONTEXT,
					),
					'email'      => array(
						'description' => __( 'Email address.', 'woocommerce' ),
						'type'        => 'string',
						'format'      => 'email',
						'context'     => self::VIEW_EDIT_CONTEXT,
					),
					'phone'      => array(
						'description' => __( 'Phone number.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_CONTEXT,
					),
				),
			),
			'shipping'           => array(
				'description' => __( 'List of shipping address data.', 'woocommerce' ),
				'type'        => 'object',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'properties'  => array(
					'first_name' => array(
						'description' => __( 'First name.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_CONTEXT,
					),
					'last_name'  => array(
						'description' => __( 'Last name.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_CONTEXT,
					),
					'company'    => array(
						'description' => __( 'Company name.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_CONTEXT,
					),
					'address_1'  => array(
						'description' => __( 'Address line 1', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_CONTEXT,
					),
					'address_2'  => array(
						'description' => __( 'Address line 2', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_CONTEXT,
					),
					'city'       => array(
						'description' => __( 'City name.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_CONTEXT,
					),
					'state'      => array(
						'description' => __( 'ISO code or name of the state, province or district.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_CONTEXT,
					),
					'postcode'   => array(
						'description' => __( 'Postal code.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_CONTEXT,
					),
					'country'    => array(
						'description' => __( 'ISO code of the country.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_CONTEXT,
					),
					'phone'      => array(
						'description' => __( 'Phone number.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_CONTEXT,
					),
				),
			),
			'is_paying_customer' => array(
				'description' => __( 'Is the customer a paying customer?', 'woocommerce' ),
				'type'        => 'boolean',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'orders_count'       => array(
				'description' => __( 'Quantity of orders made by the customer.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'total_spent'        => array(
				'description' => __( 'Total amount spent.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'avatar_url'         => array(
				'description' => __( 'Avatar URL.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'last_active'        => array(
				'description' => __( "When the customer was last active in the site's timezone.", 'woocommerce' ),
				'type'        => array( 'null', 'string' ),
				'format'      => 'date-time',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
			'last_active_gmt'    => array(
				'description' => __( 'When the customer was last active, as GMT.', 'woocommerce' ),
				'type'        => array( 'null', 'string' ),
				'format'      => 'date-time',
				'context'     => self::VIEW_EDIT_CONTEXT,
				'readonly'    => true,
			),
		);

		return $schema;
	}

	/**
	 * Get the item response.
	 *
	 * @param mixed           $item WordPress representation of the item.
	 * @param WP_REST_Request $request Request object.
	 * @param array           $include_fields Fields to include in the response.
	 * @return array The item response.
	 */
	public function get_item_response( $item, WP_REST_Request $request, array $include_fields = array() ): array {
		if ( ! $item instanceof WC_Customer ) {
			return array();
		}

		$data = $item->get_data();

		// Normalize last active timestamp - treat empty string, '0', 0, or false as null.
		$last_active = $item->get_meta( 'wc_last_active' );
		$last_active = empty( $last_active ) ? null : $last_active;

		$formatted_data = array(
			'id'                 => $item->get_id(),
			'date_created'       => wc_rest_prepare_date_response( $item->get_date_created(), false ),
			'date_created_gmt'   => wc_rest_prepare_date_response( $item->get_date_created() ),
			'date_modified'      => wc_rest_prepare_date_response( $item->get_date_modified(), false ),
			'date_modified_gmt'  => wc_rest_prepare_date_response( $item->get_date_modified() ),
			'email'              => $data['email'],
			'first_name'         => $data['first_name'],
			'last_name'          => $data['last_name'],
			'role'               => $data['role'],
			'username'           => $data['username'],
			'billing'            => $data['billing'],
			'shipping'           => $data['shipping'],
			'is_paying_customer' => $data['is_paying_customer'],
			'orders_count'       => $item->get_order_count(),
			'total_spent'        => $item->get_total_spent(),
			'avatar_url'         => $item->get_avatar_url(),
			'last_active'        => $last_active ? wc_rest_prepare_date_response( $last_active, false ) : null,
			'last_active_gmt'    => $last_active ? wc_rest_prepare_date_response( $last_active ) : null,
		);

		// Filter fields if specified.
		if ( ! empty( $include_fields ) ) {
			$formatted_data = array_intersect_key( $formatted_data, array_flip( $include_fields ) );
		}

		return $formatted_data;
	}
}
PK     [1](     /  RestApi/Routes/V4/Customers/CollectionQuery.phpnu         <?php
/**
 * CollectionQuery class.
 *
 * @package WooCommerce\RestApi
 * @internal This file is for internal use only and should not be used by external code.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Customers;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractCollectionQuery;
use WP_REST_Request;
use WP_User_Query;

/**
 * CollectionQuery class.
 *
 * @internal This class is for internal use only and should not be used by external code.
 */
final class CollectionQuery extends AbstractCollectionQuery {
	/**
	 * Get query schema.
	 *
	 * @return array
	 */
	public function get_query_schema(): array {
		return array(
			'page'     => array(
				'description'       => __( 'Current page of the collection.', 'woocommerce' ),
				'type'              => 'integer',
				'default'           => 1,
				'sanitize_callback' => 'absint',
				'validate_callback' => 'rest_validate_request_arg',
				'minimum'           => 1,
			),
			'per_page' => array(
				'description'       => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ),
				'type'              => 'integer',
				'default'           => 10,
				'minimum'           => 1,
				'maximum'           => 100,
				'sanitize_callback' => 'absint',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'search'   => array(
				'description'       => __( 'Limit results to those matching a string.', 'woocommerce' ),
				'type'              => 'string',
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'exclude'  => array(
				'description'       => __( 'Ensure result set excludes specific IDs.', 'woocommerce' ),
				'type'              => 'array',
				'items'             => array(
					'type' => 'integer',
				),
				'default'           => array(),
				'sanitize_callback' => 'wp_parse_id_list',
			),
			'include'  => array(
				'description'       => __( 'Limit result set to specific IDs.', 'woocommerce' ),
				'type'              => 'array',
				'items'             => array(
					'type' => 'integer',
				),
				'default'           => array(),
				'sanitize_callback' => 'wp_parse_id_list',
			),
			'order'    => array(
				'description'       => __( 'Order sort attribute ascending or descending.', 'woocommerce' ),
				'type'              => 'string',
				'default'           => 'asc',
				'enum'              => array( 'asc', 'desc' ),
				'sanitize_callback' => 'sanitize_key',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'orderby'  => array(
				'description'       => __( 'Sort collection by object attribute.', 'woocommerce' ),
				'type'              => 'string',
				'default'           => 'name',
				'enum'              => array(
					'id',
					'name',
					'registered_date',
					'order_count',
					'total_spent',
					'last_active',
				),
				'sanitize_callback' => 'sanitize_key',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'role'     => array(
				'description'       => __( 'Limit result set to resources with a specific role.', 'woocommerce' ),
				'type'              => 'string',
				'default'           => 'customer',
				'enum'              => array( 'customer', 'all' ),
				'validate_callback' => 'rest_validate_request_arg',
			),
		);
	}

	/**
	 * Prepares query args.
	 *
	 * @param WP_REST_Request $request The request object.
	 * @return array
	 */
	public function get_query_args( WP_REST_Request $request ): array {
		$prepared_args            = array();
		$prepared_args['exclude'] = $request['exclude'];
		$prepared_args['include'] = $request['include'];
		$prepared_args['order']   = $request['order'];
		$prepared_args['number']  = $request['per_page'];
		$prepared_args['page']    = max( 1, intval( $request['page'] ) );

		$orderby_possibles = array(
			'id'              => 'ID',
			'name'            => 'display_name',
			'registered_date' => 'user_registered',
			'order_count'     => 'wc_order_count',
			'total_spent'     => 'wc_money_spent',
			'last_active'     => 'wc_last_active',
		);

		$prepared_args['orderby'] = $orderby_possibles[ $request['orderby'] ];
		$prepared_args['search']  = $request['search'];

		if ( ! empty( $prepared_args['search'] ) ) {
			$prepared_args['search'] = '*' . $prepared_args['search'] . '*';
		}

		// Always pass role through (datastore handles 'all' vs 'customer').
		$prepared_args['role'] = $request['role'];

		/**
		 * Filter arguments, before passing to WP_User_Query, when querying users via the REST API.
		 *
		 * @see https://developer.wordpress.org/reference/classes/wp_user_query/
		 *
		 * @param array           $prepared_args Array of arguments for WP_User_Query.
		 * @param WP_REST_Request $request       The current request.
		 * @since 10.2.0
		 */
		$prepared_args = apply_filters( 'woocommerce_rest_customer_query', $prepared_args, $request );

		return $prepared_args;
	}

	/**
	 * Get results of the query.
	 *
	 * @param array           $query_args The query arguments.
	 * @param WP_REST_Request $request The request object.
	 * @return array
	 */
	public function get_query_results( array $query_args, WP_REST_Request $request ): array {
		$method_args = array(
			'order'    => $query_args['order'] ?? 'asc',
			'orderby'  => $query_args['orderby'] ?? 'user_registered',
			'per_page' => $query_args['number'] ?? 10,
			'search'   => $query_args['search'] ?? '',
			'role'     => $query_args['role'] ?? 'customer',
			'include'  => $query_args['include'] ?? array(),
			'exclude'  => $query_args['exclude'] ?? array(),
			'page'     => $query_args['page'] ?? 1,
		);

		$data_store             = \WC_Data_Store::load( 'customer' );
		$customer_query_results = $data_store->query_customers( $method_args );
		$users                  = $customer_query_results->customers;
		$total_users            = $customer_query_results->total;
		$max_pages              = $customer_query_results->max_num_pages;

		return array(
			'results' => $users,
			'total'   => $total_users,
			'pages'   => $max_pages,
		);
	}
}
PK     [1]2\    +  RestApi/Routes/V4/Customers/UpdateUtils.phpnu         <?php
/**
 * UpdateUtils class.
 *
 * @package WooCommerce\RestApi
 * @internal This file is for internal use only and should not be used by external code.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Customers;

defined( 'ABSPATH' ) || exit;

use WC_Customer;
use WC_REST_Exception;
use WP_REST_Request;

/**
 * UpdateUtils class.
 *
 * @internal This class is for internal use only and should not be used by external code.
 */
final class UpdateUtils {
	/**
	 * Update customer from request data.
	 *
	 * @param WC_Customer     $customer Customer object.
	 * @param WP_REST_Request $request  Request object.
	 * @param bool            $creating Whether creating a new customer. Unused parameter.
	 * @return void
	 * @throws WC_REST_Exception If there's an error updating the customer.
	 */
	public function update_customer_from_request( WC_Customer $customer, WP_REST_Request $request, bool $creating = false ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
		// Customer email.
		if ( isset( $request['email'] ) ) {
			$customer->set_email( sanitize_email( $request['email'] ) );
		}

		// Customer password.
		if ( isset( $request['password'] ) ) {
			$customer->set_password( $request['password'] );
		}

		// Customer first name.
		if ( isset( $request['first_name'] ) ) {
			$customer->set_first_name( wc_clean( $request['first_name'] ) );
		}

		// Customer last name.
		if ( isset( $request['last_name'] ) ) {
			$customer->set_last_name( wc_clean( $request['last_name'] ) );
		}

		// Customer billing address.
		if ( isset( $request['billing'] ) && is_array( $request['billing'] ) ) {
			$this->update_customer_address( $customer, $request['billing'], 'billing' );
		}

		// Customer shipping address.
		if ( isset( $request['shipping'] ) && is_array( $request['shipping'] ) ) {
			$this->update_customer_address( $customer, $request['shipping'], 'shipping' );
		}

		// Save the customer.
		$customer->save();

		// Additional fields for user data.
		$user_data = get_userdata( $customer->get_id() );
		if ( $user_data ) {
			$this->update_additional_fields_for_object( $user_data, $request );

			// Ensure user is a member of the blog and has customer role.
			if ( ! is_user_member_of_blog( $user_data->ID ) ) {
				$user_data->add_role( 'customer' );
			}
		}
	}

	/**
	 * Update customer address fields.
	 *
	 * @param WC_Customer $customer Customer object.
	 * @param array       $address  Address data.
	 * @param string      $type     Address type (billing or shipping).
	 * @return void
	 */
	private function update_customer_address( WC_Customer $customer, array $address, string $type ): void {
		$address = wc_clean( $address );

		$address_fields = array(
			'first_name',
			'last_name',
			'company',
			'address_1',
			'address_2',
			'city',
			'state',
			'postcode',
			'country',
			'email',
			'phone',
		);

		foreach ( $address_fields as $field ) {
			if ( isset( $address[ $field ] ) && is_callable( array( $customer, "set_{$type}_{$field}" ) ) ) {
				$value = ( 'email' === $field ) ? sanitize_email( $address[ $field ] ) : $address[ $field ];
				$customer->{"set_{$type}_{$field}"}( $value );
			}
		}
	}

	/**
	 * Update additional fields for object.
	 *
	 * @param mixed           $item    Object to update.
	 * @param WP_REST_Request $request Request object.
	 * @return void
	 * @throws WC_REST_Exception If there's an error updating additional fields.
	 */
	private function update_additional_fields_for_object( $item, WP_REST_Request $request ): void {
		$additional_fields = $this->get_additional_fields();

		foreach ( $additional_fields as $field_name => $field_options ) {
			if ( ! $field_options['update_callback'] || ! is_callable( $field_options['update_callback'] ) ) {
				continue;
			}

			if ( ! isset( $request[ $field_name ] ) ) {
				continue;
			}

			$result = call_user_func( $field_options['update_callback'], $request[ $field_name ], $item, $field_name, $request );

			if ( is_wp_error( $result ) ) {
				throw new WC_REST_Exception( 'woocommerce_rest_cannot_update', esc_html( $result->get_error_message() ), 400 );
			}
		}
	}

	/**
	 * Get additional fields for this object.
	 *
	 * @return array
	 */
	private function get_additional_fields(): array {
		$fields = array();

		/**
		 * Filter additional fields for objects of this type.
		 *
		 * @param array  $fields Additional fields registered for the object type.
		 * @param string $object_type Object type.
		 * @since 10.2.0
		 */
		$fields = apply_filters( 'rest_additional_fields', $fields, 'user' );

		/**
		 * Filter additional fields for objects of this type.
		 *
		 * @param array  $fields Additional fields registered for the object type.
		 * @param string $object_type Object type.
		 * @since 10.2.0
		 */
		$fields = apply_filters( "rest_{$this->get_object_type()}_additional_fields", $fields, 'user' );

		return $fields;
	}

	/**
	 * Get object type.
	 *
	 * @return string
	 */
	private function get_object_type(): string {
		return 'user';
	}
}
PK     [1]-=  =  1  RestApi/Routes/V4/Refunds/Schema/RefundSchema.phpnu         <?php
/**
 * RefundSchema class.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractSchema;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
use Automattic\WooCommerce\Utilities\OrderUtil;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema\OrderItemSchema;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema\OrderFeeSchema;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema\OrderTaxSchema;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema\OrderShippingSchema;
use WP_REST_Request;

/**
 * RefundSchema class.
 */
class RefundSchema extends AbstractSchema {
	use CogsAwareTrait;

	/**
	 * The schema item identifier.
	 *
	 * @var string
	 */
	const IDENTIFIER = 'order';

	/**
	 * The order item schema.
	 *
	 * @var OrderItemSchema
	 */
	private $order_item_schema;

	/**
	 * The order fee schema.
	 *
	 * @var OrderFeeSchema
	 */
	private $order_fee_schema;

	/**
	 * The order tax schema.
	 *
	 * @var OrderTaxSchema
	 */
	private $order_tax_schema;

	/**
	 * The order shipping schema.
	 *
	 * @var OrderShippingSchema
	 */
	private $order_shipping_schema;

	/**
	 * Initialize the schema.
	 *
	 * @internal
	 * @param OrderItemSchema     $order_item_schema The order item schema.
	 * @param OrderFeeSchema      $order_fee_schema The order fee schema.
	 * @param OrderTaxSchema      $order_tax_schema The order tax schema.
	 * @param OrderShippingSchema $order_shipping_schema The order shipping schema.
	 */
	final public function init( OrderItemSchema $order_item_schema, OrderFeeSchema $order_fee_schema, OrderTaxSchema $order_tax_schema, OrderShippingSchema $order_shipping_schema ) {
		$this->order_item_schema     = $order_item_schema;
		$this->order_fee_schema      = $order_fee_schema;
		$this->order_tax_schema      = $order_tax_schema;
		$this->order_shipping_schema = $order_shipping_schema;
	}

	/**
	 * Return all properties for the item schema.
	 *
	 * Note that context determines under which context data should be visible. For example, edit would be the context
	 * used when getting records with the intent of editing them. embed context allows the data to be visible when the
	 * item is being embedded in another response.
	 *
	 * @return array
	 */
	public function get_item_schema_properties(): array {
		$schema = array(
			'id'               => array(
				'description' => __( 'Unique identifier for the refund.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'order_id'         => array(
				'description'       => __( 'The ID of the order that was refunded.', 'woocommerce' ),
				'type'              => 'integer',
				'context'           => self::VIEW_EDIT_EMBED_CONTEXT,
				'required'          => true,
				'sanitize_callback' => 'absint',
			),
			'amount'           => array(
				'description'       => __( 'Amount that was refunded. This is calculated from the line items if not provided.', 'woocommerce' ),
				'type'              => 'number',
				'context'           => self::VIEW_EDIT_EMBED_CONTEXT,
				'default'           => 0,
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'reason'           => array(
				'description'       => __( 'Reason for the refund.', 'woocommerce' ),
				'type'              => 'string',
				'context'           => self::VIEW_EDIT_EMBED_CONTEXT,
				'default'           => '',
				'sanitize_callback' => 'sanitize_text_field',
			),
			'currency'         => array(
				'description' => __( 'Currency the refund was created with, in ISO format.', 'woocommerce' ),
				'type'        => 'string',
				'default'     => get_woocommerce_currency(),
				'enum'        => array_keys( get_woocommerce_currencies() ),
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'currency_symbol'  => array(
				'description' => __( 'Currency symbol for the currency which can be used to format returned prices.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'date_created'     => array(
				'description' => __( "The date the refund was created, in the site's timezone.", 'woocommerce' ),
				'type'        => 'string',
				'format'      => 'date-time',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'date_created_gmt' => array(
				'description' => __( 'The date the refund was created, as GMT.', 'woocommerce' ),
				'type'        => 'string',
				'format'      => 'date-time',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'refunded_by'      => array(
				'description' => __( 'User who created the refund.', 'woocommerce' ),
				'type'        => 'object',
				'properties'  => array(
					'id'           => array(
						'description' => __( 'User ID of user who created the refund.', 'woocommerce' ),
						'type'        => 'integer',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
						'readonly'    => true,
					),
					'display_name' => array(
						'description' => __( 'Display name of the user who created the refund.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
						'readonly'    => true,
					),
					'avatar_url'   => array(
						'description' => __( 'Avatar URL of the user who created the refund.', 'woocommerce' ),
						'type'        => 'string',
						'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
						'readonly'    => true,
						'format'      => 'uri',
					),
				),
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'refunded_payment' => array(
				'description' => __( 'If the payment was refunded via the API.', 'woocommerce' ),
				'type'        => 'boolean',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
			),
			'meta_data'        => array(
				'description' => __( 'Meta data.', 'woocommerce' ),
				'type'        => 'array',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'readonly'    => true,
				'items'       => array(
					'type'       => 'object',
					'properties' => array(
						'id'    => array(
							'description' => __( 'Meta ID.', 'woocommerce' ),
							'type'        => 'integer',
							'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
							'readonly'    => true,
						),
						'key'   => array(
							'description' => __( 'Meta key.', 'woocommerce' ),
							'type'        => 'string',
							'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
						),
						'value' => array(
							'description' => __( 'Meta value.', 'woocommerce' ),
							'type'        => array( 'null', 'object', 'string', 'number', 'boolean', 'integer', 'array' ),
							'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
						),
					),
				),
			),
			'line_items'       => array(
				'description' => __( 'Refunded line items. This can include products, fees, and shipping lines, combined into a single array.', 'woocommerce' ),
				'type'        => 'array',
				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				'default'     => array(),
				'items'       => array(
					'type'       => 'object',
					'properties' => array(
						'id'           => array(
							'description' => __( 'ID of the refund line item. This is not the ID of the original line item.', 'woocommerce' ),
							'type'        => 'integer',
							'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
							'readonly'    => true,
						),
						'line_item_id' => array(
							'description'       => __( 'ID of the original line item.', 'woocommerce' ),
							'type'              => 'integer',
							'context'           => self::VIEW_EDIT_EMBED_CONTEXT,
							'required'          => true,
							'sanitize_callback' => 'absint',
							'validate_callback' => 'rest_validate_request_arg',
						),
						'quantity'     => array(
							'description'       => __( 'Quantity refunded.', 'woocommerce' ),
							'type'              => 'integer',
							'context'           => self::VIEW_EDIT_EMBED_CONTEXT,
							'default'           => 0,
							'sanitize_callback' => 'wc_stock_amount',
							'validate_callback' => 'rest_validate_request_arg',
						),
						'refund_total' => array(
							'description'       => __( 'Total refunded for this item.', 'woocommerce' ),
							'type'              => 'number',
							'context'           => self::VIEW_EDIT_EMBED_CONTEXT,
							'default'           => 0,
							'sanitize_callback' => 'sanitize_text_field',
							'validate_callback' => 'rest_validate_request_arg',
						),
						'refund_tax'   => array(
							'description' => __( 'Optional: Taxes refunded for this item. If not provided, tax will be automatically extracted from refund_total using the order\'s tax rates.', 'woocommerce' ),
							'type'        => 'array',
							'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
							'default'     => array(),
							'items'       => array(
								'type'       => 'object',
								'properties' => array(
									'id'           => array(
										'description' => __( 'Tax ID.', 'woocommerce' ),
										'type'        => 'integer',
										'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
										'required'    => true,
										'sanitize_callback' => 'absint',
										'validate_callback' => 'rest_validate_request_arg',
									),
									'refund_total' => array(
										'description' => __( 'Amount refunded for this tax.', 'woocommerce' ),
										'type'        => 'number',
										'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
										'required'    => true,
										'sanitize_callback' => 'sanitize_text_field',
										'validate_callback' => 'rest_validate_request_arg',
									),
								),
							),
						),
					),
				),
			),
		);

		if ( $this->cogs_is_enabled() ) {
			$schema = $this->add_cogs_related_schema( $schema );
		}

		return $schema;
	}

	/**
	 * Add the Cost of Goods Sold related fields to the schema.
	 *
	 * @param array $schema The original schema.
	 * @return array The updated schema.
	 */
	private static function add_cogs_related_schema( array $schema ): array {
		$schema['cost_of_goods_sold'] = array(
			'description' => __( 'Cost of Goods Sold data.', 'woocommerce' ),
			'type'        => 'object',
			'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
			'readonly'    => true,
			'properties'  => array(
				'total_value' => array(
					'description' => __( 'Total value of the Cost of Goods Sold for the refund.', 'woocommerce' ),
					'type'        => 'number',
					'readonly'    => true,
					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
				),
			),
		);
		return $schema;
	}

	/**
	 * Get an item response.
	 *
	 * @param WC_Order_Refund $refund Refund instance.
	 * @param WP_REST_Request $request Request object.
	 * @param array           $include_fields Fields to include in the response.
	 * @return array
	 */
	public function get_item_response( $refund, WP_REST_Request $request, array $include_fields = array() ): array {
		$dp   = is_null( $request['num_decimals'] ) ? wc_get_price_decimals() : absint( $request['num_decimals'] );
		$data = array(
			'id'               => $refund->get_id(),
			'order_id'         => $refund->get_parent_id(),
			'currency'         => $refund->get_currency(),
			'currency_symbol'  => html_entity_decode( get_woocommerce_currency_symbol( $refund->get_currency() ), ENT_QUOTES ),
			'date_created'     => wc_rest_prepare_date_response( $refund->get_date_created(), false ),
			'date_created_gmt' => wc_rest_prepare_date_response( $refund->get_date_created() ),
			'amount'           => wc_format_decimal( $refund->get_amount(), $dp ),
			'reason'           => $refund->get_reason(),
			'refunded_payment' => $refund->get_refunded_payment(),
		);

		if ( in_array( 'refunded_by', $include_fields, true ) ) {
			$refunded_user = new \WP_User( $refund->get_refunded_by() );
			if ( $refunded_user->exists() ) {
				$data['refunded_by'] = array(
					'id'           => $refunded_user->ID,
					'display_name' => $refunded_user->display_name,
					'avatar_url'   => get_avatar_url( $refunded_user ),
				);
			} else {
				$data['refunded_by'] = null;
			}
		}

		if ( in_array( 'line_items', $include_fields, true ) ) {
			$data['line_items'] = array_merge(
				$this->get_line_items_response( $refund->get_items( 'line_item' ), $request ),
				$this->get_line_items_response( $refund->get_items( 'fee' ), $request ),
				$this->get_line_items_response( $refund->get_items( 'shipping' ), $request ),
			);
		}

		if ( in_array( 'meta_data', $include_fields, true ) ) {
			$filtered_meta_data = $this->filter_internal_meta_keys( $refund->get_meta_data() );
			$data['meta_data']  = array();
			foreach ( $filtered_meta_data as $meta_item ) {
				$data['meta_data'][] = array(
					'id'    => $meta_item->id,
					'key'   => $meta_item->key,
					'value' => $meta_item->value,
				);
			}
		}

		// Add COGS data.
		if ( $this->cogs_is_enabled() && in_array( 'cost_of_goods_sold', $include_fields, true ) ) {
			$data['cost_of_goods_sold']['total_value'] = $refund->get_cogs_total_value();
		}

		$data = array_intersect_key( $data, array_flip( $include_fields ) );

		return $data;
	}

	/**
	 * Standardize the line items response.
	 *
	 * @param array           $line_items Line items.
	 * @param WP_REST_Request $request Request object.
	 * @return array
	 */
	protected function get_line_items_response( $line_items, WP_REST_Request $request ) {
		$line_items_response = array();
		foreach ( $line_items as $line_item ) {
			$line_items_response[] = $this->prepare_line_item( $line_item, $request );
		}
		return $line_items_response;
	}

	/**
	 * Standardize the line item response.
	 *
	 * @param WC_Order_Item   $line_item Line item instance.
	 * @param WP_REST_Request $request Request object.
	 * @return array
	 */
	protected function prepare_line_item( $line_item, WP_REST_Request $request ) {
		$dp           = is_null( $request['num_decimals'] ) ? wc_get_price_decimals() : absint( $request['num_decimals'] );
		$tax_response = array();
		$taxes        = $line_item->get_taxes();
		foreach ( $taxes['total'] ?? array() as $tax_rate_id => $tax ) {
			$tax_response[] = array(
				'id'           => absint( $tax_rate_id ),
				'refund_total' => wc_format_decimal( abs( (float) $tax ), $dp ),
			);
		}
		return array(
			'id'           => absint( $line_item->get_id() ),
			'line_item_id' => absint( $line_item->get_meta( '_refunded_item_id' ) ),
			'quantity'     => wc_stock_amount( abs( (float) $line_item->get_quantity() ) ),
			'refund_total' => wc_format_decimal( abs( (float) $line_item->get_total() ), $dp ),
			'refund_tax'   => $tax_response,
		);
	}

	/**
	 * With HPOS, few internal meta keys such as _billing_address_index, _shipping_address_index are not considered internal anymore (since most internal keys were flattened into dedicated columns).
	 *
	 * This function helps in filtering out any remaining internal meta keys with HPOS is enabled.
	 *
	 * @param array $meta_data Order meta data.
	 * @return array Filtered order meta data.
	 */
	protected function filter_internal_meta_keys( $meta_data ) {
		if ( ! OrderUtil::custom_orders_table_usage_is_enabled() ) {
			return $meta_data;
		}
		$cpt_hidden_keys = ( new \WC_Order_Data_Store_CPT() )->get_internal_meta_keys();
		$meta_data       = array_filter(
			$meta_data,
			function ( $meta ) use ( $cpt_hidden_keys ) {
				return ! in_array( $meta->key, $cpt_hidden_keys, true );
			}
		);
		return array_values( $meta_data );
	}
}
PK     [1]֐9  9  (  RestApi/Routes/V4/Refunds/Controller.phpnu         <?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
 * REST API Refunds controller
 *
 * Handles route registration, permissions, CRUD operations, and schema definition.
 *
 * @package WooCommerce\RestApi
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractController;
use Automattic\WooCommerce\StoreApi\Utilities\Pagination;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundSchema;
use WP_Http;
use WP_Error;
use WC_Order_Refund;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

/**
 * Refunds Controller.
 */
class Controller extends AbstractController {
	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'refunds';

	/**
	 * Post type used for orders.
	 *
	 * @var string
	 */
	protected $post_type = 'shop_order_refund';

	/**
	 * Schema class for this route.
	 *
	 * @var OrderSchema
	 */
	protected $item_schema;

	/**
	 * Collection query class.
	 *
	 * @var CollectionQuery
	 */
	protected $collection_query;

	/**
	 * Data utils class.
	 *
	 * @var DataUtils
	 */
	protected $data_utils;

	/**
	 * Initialize the controller.
	 *
	 * @param RefundSchema    $item_schema Refund schema class.
	 * @param CollectionQuery $collection_query Collection query class.
	 * @param DataUtils       $data_utils Data utils class.
	 * @internal
	 */
	final public function init( RefundSchema $item_schema, CollectionQuery $collection_query, DataUtils $data_utils ) {
		$this->item_schema      = $item_schema;
		$this->collection_query = $collection_query;
		$this->data_utils       = $data_utils;
	}

	/**
	 * Get the schema for the current resource. This use consumed by the AbstractController to generate the item schema
	 * after running various hooks on the response.
	 */
	protected function get_schema(): array {
		return $this->item_schema->get_item_schema();
	}

	/**
	 * Get the collection args schema.
	 *
	 * @return array
	 */
	protected function get_query_schema(): array {
		return $this->collection_query->get_query_schema();
	}

	/**
	 * List of args for endpoints. These may alter how data is returned or formatted. Extended by routes.
	 *
	 * @return array
	 */
	protected function get_endpoint_args(): array {
		return array(
			'num_decimals' => array(
				'default'           => wc_get_price_decimals(),
				'description'       => __( 'Number of decimal points to use in each resource.', 'woocommerce' ),
				'type'              => 'integer',
				'sanitize_callback' => 'absint',
				'validate_callback' => 'rest_validate_request_arg',
			),
		);
	}

	/**
	 * Register the routes for orders.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				'args'   => $this->get_endpoint_args(),
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => $this->get_collection_params(),
				),
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'create_item' ),
					'permission_callback' => array( $this, 'create_item_permissions_check' ),
					'args'                => array_merge(
						$this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
						array(
							'api_refund'  => array(
								'description'       => __( 'When true, the payment gateway API is used to perform the refund. If the payment gateway does not support refunds, the refund will fail.', 'woocommerce' ),
								'type'              => 'boolean',
								'context'           => array( 'edit' ),
								'default'           => false,
								'sanitize_callback' => 'rest_sanitize_boolean',
							),
							'api_restock' => array(
								'description'       => __( 'When true, refunded items are restocked.', 'woocommerce' ),
								'type'              => 'boolean',
								'context'           => array( 'edit' ),
								'default'           => false,
								'sanitize_callback' => 'rest_sanitize_boolean',
							),
						)
					),
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[\d]+)',
			array(
				'schema' => array( $this, 'get_public_item_schema' ),
				'args'   => array_merge(
					$this->get_endpoint_args(),
					array(
						'id' => array(
							'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
							'type'        => 'integer',
						),
					),
				),
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_item' ),
					'permission_callback' => array( $this, 'get_item_permissions_check' ),
					'args'                => array(
						'context' => $this->get_context_param( array( 'default' => 'view' ) ),
					),
				),
				array(
					'methods'             => WP_REST_Server::DELETABLE,
					'callback'            => array( $this, 'delete_item' ),
					'permission_callback' => array( $this, 'delete_item_permissions_check' ),
				),
			)
		);
	}

	/**
	 * Prepare links for the request.
	 *
	 * @param mixed            $item WordPress representation of the item.
	 * @param WP_REST_Request  $request Request object.
	 * @param WP_REST_Response $response Response object.
	 * @return array
	 */
	protected function prepare_links( $item, WP_REST_Request $request, WP_REST_Response $response ): array {
		$links = array(
			'self'       => array(
				'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $item->get_id() ) ),
			),
			'collection' => array(
				'href' => rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ),
			),
			'up'         => array(
				'href' => rest_url( sprintf( '/%s/orders/%d', $this->namespace, $item->get_parent_id() ) ),
			),
		);

		if ( $item->get_refunded_by() ) {
			$links['refunded_by'] = array(
				'href'       => rest_url( sprintf( '/wp/v2/users/%d', $item->get_refunded_by() ) ),
				'embeddable' => true,
			);
		}

		return $links;
	}

	/**
	 * Prepare a single order object for response.
	 *
	 * @param WC_Order_Refund $refund Refund object.
	 * @param WP_REST_Request $request Request object.
	 * @return array
	 */
	protected function get_item_response( $refund, WP_REST_Request $request ): array {
		return $this->item_schema->get_item_response( $refund, $request, $this->get_fields_for_response( $request ) );
	}

	/**
	 * Get a single item.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response
	 */
	public function get_item( $request ) {
		$refund = wc_get_order( (int) $request['id'] );

		if ( ! $this->is_valid_refund_for_request( $refund ) ) {
			return $this->get_route_error_by_code( self::INVALID_ID );
		}

		return $this->prepare_item_for_response( $refund, $request );
	}

	/**
	 * Get collection of refunds.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response
	 */
	public function get_items( $request ) {
		$query_args = $this->collection_query->get_query_args( $request );
		$results    = $this->collection_query->get_query_results(
			array_merge(
				$query_args,
				array(
					'post_type'   => $this->post_type,
					'post_status' => array_keys( wc_get_order_statuses() ),
				)
			),
			$request
		);
		$items      = array();

		foreach ( $results['results'] as $result ) {
			$items[] = $this->prepare_response_for_collection( $this->prepare_item_for_response( $result, $request ) );
		}

		$pagination_util = new Pagination();
		$response        = $pagination_util->add_headers( rest_ensure_response( $items ), $request, $results['total'], $results['pages'] );

		return $response;
	}

	/**
	 * Create a single item.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response
	 */
	public function create_item( $request ) {
		if ( ! empty( $request['id'] ) ) {
			/* translators: %s: post type */
			return $this->get_route_error_by_code( self::RESOURCE_EXISTS );
		}

		try {
			$order = wc_get_order( $request['order_id'] );

			if ( ! $order ) {
				return $this->get_route_error_by_code( self::INVALID_ID );
			}

			// Validate request line_items before proceeding against the order being refunded.
			$validation_error = $this->data_utils->validate_line_items( $request['line_items'], $order );

			if ( is_wp_error( $validation_error ) ) {
				return $this->get_route_error_response( $validation_error->get_error_code(), $validation_error->get_error_message() );
			}

			// Convert line items to internal format.
			$line_item_data   = $this->data_utils->convert_line_items_to_internal_format( $request['line_items'], $order );
			$calculated_total = ! empty( $request['line_items'] ) ? $this->data_utils->calculate_refund_amount( $request['line_items'] ) : 0;
			$refund_amount    = ! empty( $request['amount'] ) ? $request['amount'] : $calculated_total;

			if ( 0 > $refund_amount || ! $refund_amount ) {
				return $this->get_route_error_response( 'invalid_refund_amount', __( 'Refund total must be greater than zero.', 'woocommerce' ) );
			}

			// Prevent under-refunding: amount cannot be less than calculated line items total.
			// Over-refunding is allowed for goodwill/compensation scenarios.
			if ( ! empty( $request['amount'] ) && $calculated_total > 0 && $refund_amount < $calculated_total ) {
				return $this->get_route_error_response(
					'invalid_refund_amount',
					sprintf(
						/* translators: %s: calculated total from line items */
						__( 'Refund amount cannot be less than the total of line items (%s).', 'woocommerce' ),
						wc_format_decimal( $calculated_total, 2 )
					)
				);
			}

			$refund = wc_create_refund(
				array(
					'order_id'       => $order->get_id(),
					'amount'         => $refund_amount,
					'reason'         => $request['reason'],
					'line_items'     => $line_item_data,
					'refund_payment' => $request['api_refund'],
					'restock_items'  => $request['api_restock'],
				)
			);

			if ( ! $refund ) {
				return $this->get_route_error_response( 'cannot_create_refund', __( 'Cannot create order refund.', 'woocommerce' ) );
			}

			if ( is_wp_error( $refund ) ) {
				return $this->get_route_error_response( 'cannot_create_refund', $refund->get_error_message() );
			}

			if ( ! empty( $request['meta_data'] ) && is_array( $request['meta_data'] ) ) {
				foreach ( $request['meta_data'] as $meta ) {
					$refund->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
				}
				$refund->save_meta_data();
			}

			$this->update_additional_fields_for_object( $refund, $request );

			/**
			 * Fires after a single object is created via the REST API.
			 *
			 * @param WC_Order_Refund         $refund    Inserted object.
			 * @param WP_REST_Request $request   Request object.
			 * @since 10.2.0
			 */
			do_action( $this->get_hook_prefix() . 'created', $refund, $request );

			$request->set_param( 'context', 'edit' );
			$response = $this->prepare_item_for_response( $refund, $request );
			$response->set_status( WP_Http::CREATED );
			$response->header( 'Location', rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $refund->get_id() ) ) );

			return $response;
		} catch ( \WC_Data_Exception $e ) {
			return $this->get_route_error_response( $e->getErrorCode(), $e->getMessage() );
		} catch ( \WC_REST_Exception $e ) {
			return $this->get_route_error_response( $e->getErrorCode(), $e->getMessage() );
		}
	}

	/**
	 * Delete a single item.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function delete_item( $request ) {
		$refund = wc_get_order( (int) $request['id'] );

		if ( ! $this->is_valid_refund_for_request( $refund ) ) {
			return $this->get_route_error_by_code( self::INVALID_ID );
		}

		$request->set_param( 'context', 'edit' );

		$response = new WP_REST_Response( null, 204 );
		$result   = $refund->delete( true );

		if ( ! $result ) {
			return $this->get_route_error_by_code( self::CANNOT_DELETE );
		}

		/**
		 * Fires after a single object is deleted or trashed via the REST API.
		 *
		 * @param WC_Order_Refund  $refund   The deleted object.
		 * @param WP_REST_Response $response The response data.
		 * @param WP_REST_Request  $request  The request sent to the API.
		 * @since 10.2.0
		 */
		do_action( $this->get_hook_prefix() . 'deleted', $refund, $response, $request );

		return $response;
	}

	/**
	 * Check if an order is valid.
	 *
	 * @param WC_Order_Refund $refund The refund object.
	 * @return bool True if the refund is valid, false otherwise.
	 */
	protected function is_valid_refund_for_request( $refund ): bool {
		return $refund instanceof WC_Order_Refund && $refund->get_id() !== 0 && 'shop_order_refund' === $refund->get_type();
	}

	/**
	 * Check if a given request has access to read items.
	 *
	 * @param  WP_REST_Request $request Full details about the request.
	 * @return WP_Error|boolean
	 */
	public function get_items_permissions_check( $request ) {
		if ( ! wc_rest_check_post_permissions( $this->post_type, 'read' ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );
		}
		return true;
	}

	/**
	 * Check if a given request has access to read an item.
	 *
	 * @param  WP_REST_Request $request The request object.
	 * @return WP_Error|boolean
	 */
	public function get_item_permissions_check( $request ) {
		if ( ! wc_rest_check_post_permissions( $this->post_type, 'read', $request['id'] ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );
		}
		return true;
	}

	/**
	 * Check if a given request has access to create an item.
	 *
	 * @param  WP_REST_Request $request The request object.
	 * @return WP_Error|boolean
	 */
	public function create_item_permissions_check( $request ) {
		if ( ! wc_rest_check_post_permissions( $this->post_type, 'create' ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );
		}
		return true;
	}

	/**
	 * Check if a given request has access to delete an item.
	 *
	 * @param  WP_REST_Request $request The request object.
	 * @return bool|WP_Error
	 */
	public function delete_item_permissions_check( $request ) {
		if ( ! wc_rest_check_post_permissions( $this->post_type, 'delete', $request['id'] ) ) {
			return $this->get_authentication_error_by_method( $request->get_method() );
		}
		return true;
	}
}
PK     [1]~'8K  K  -  RestApi/Routes/V4/Refunds/CollectionQuery.phpnu         <?php
/**
 * CollectionQuery class.
 *
 * @package WooCommerce\RestApi
 * @internal This file is for internal use only and should not be used by external code.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds;

defined( 'ABSPATH' ) || exit;

use WP_REST_Request;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractCollectionQuery;
use WC_Order_Query;

/**
 * CollectionQuery class.
 *
 * @internal This class is for internal use only and should not be used by external code.
 */
class CollectionQuery extends AbstractCollectionQuery {
	/**
	 * Get query schema.
	 *
	 * @return array
	 */
	public function get_query_schema(): array {
		return array(
			'order_id'      => array(
				'description'       => __( 'Filter refunds by order ID.', 'woocommerce' ),
				'type'              => 'integer',
				'sanitize_callback' => 'absint',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'page'          => array(
				'description'       => __( 'Current page of the collection.', 'woocommerce' ),
				'type'              => 'integer',
				'default'           => 1,
				'sanitize_callback' => 'absint',
				'validate_callback' => 'rest_validate_request_arg',
				'minimum'           => 1,
			),
			'per_page'      => array(
				'description'       => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ),
				'type'              => 'integer',
				'default'           => 10,
				'minimum'           => 1,
				'maximum'           => 100,
				'sanitize_callback' => 'absint',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'order'         => array(
				'description'       => __( 'Order sort attribute ascending or descending.', 'woocommerce' ),
				'type'              => 'string',
				'default'           => 'desc',
				'enum'              => array( 'asc', 'desc' ),
				'validate_callback' => 'rest_validate_request_arg',
			),
			'orderby'       => array(
				'description'       => __( 'Sort collection by object attribute.', 'woocommerce' ),
				'type'              => 'string',
				'default'           => 'date',
				'enum'              => array(
					'date',
					'id',
					'include',
					'title',
					'slug',
					'modified',
					'total',
				),
				'validate_callback' => 'rest_validate_request_arg',
			),
			'after'         => array(
				'description'       => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'woocommerce' ),
				'type'              => 'string',
				'format'            => 'date-time',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'before'        => array(
				'description'       => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'woocommerce' ),
				'type'              => 'string',
				'format'            => 'date-time',
				'validate_callback' => 'rest_validate_request_arg',
			),
			'dates_are_gmt' => array(
				'description'       => __( 'Whether to consider GMT post dates when limiting response by published or modified date.', 'woocommerce' ),
				'type'              => 'boolean',
				'default'           => false,
				'validate_callback' => 'rest_validate_request_arg',
			),
		);
	}

	/**
	 * Prepares query args.
	 *
	 * @param WP_REST_Request $request The request object.
	 * @return array
	 */
	public function get_query_args( WP_REST_Request $request ): array {
		$args = array(
			'order'          => $request['order'],
			'orderby'        => $request['orderby'],
			'page'           => $request['page'],
			'posts_per_page' => $request['per_page'],
		);

		if ( 'date' === $args['orderby'] ) {
			$args['orderby'] = 'date ID';
		}

		$date_query = array();
		$use_gmt    = $request['dates_are_gmt'];

		if ( isset( $request['before'] ) ) {
			$date_query[] = array(
				'column' => $use_gmt ? 'post_date_gmt' : 'post_date',
				'before' => $request['before'],
			);
		}

		if ( isset( $request['after'] ) ) {
			$date_query[] = array(
				'column' => $use_gmt ? 'post_date_gmt' : 'post_date',
				'after'  => $request['after'],
			);
		}

		if ( ! empty( $date_query ) ) {
			$date_query['relation'] = 'AND';
			$args['date_query']     = $date_query;
		}

		$order_id = absint( $request['order_id'] ?? 0 );

		if ( $order_id ) {
			$args['post_parent__in'] = array( $order_id );
		}

		return $args;
	}

	/**
	 * Get results of the query.
	 *
	 * @param array           $query_args The query arguments from prepare_query().
	 * @param WP_REST_Request $request The request object.
	 * @return array
	 */
	public function get_query_results( array $query_args, WP_REST_Request $request ): array {
		$query   = new WC_Order_Query(
			array_merge(
				$query_args,
				array(
					'paginate' => true,
				)
			)
		);
		$results = $query->get_orders();

		return array(
			'results' => $results->orders,
			'total'   => $results->total,
			'pages'   => $results->max_num_pages,
		);
	}
}
PK     [1]i[      '  RestApi/Routes/V4/Refunds/DataUtils.phpnu         <?php
/**
 * DataUtils class file.
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds;

defined( 'ABSPATH' ) || exit;

use WP_Error;
use WC_Order;
use WC_Tax;

/**
 * Helper methods for the REST API.
 *
 * Class DataUtils
 *
 * @package Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds
 */
class DataUtils {
	/**
	 * Convert line items (schema format) to internal format. This keys arrays by item ID and has some different naming
	 * conventions.
	 *
	 * 111 => [
	 *   "qty" => 1,
	 *   "refund_total" => 123,
	 *   "refund_tax" => [
	 *     1 => 123,
	 *     2 => 456,
	 *   ],
	 * ]
	 *
	 * @param array    $line_items The line items to convert.
	 * @param WC_Order $order The order being refunded.
	 * @return array The converted line items.
	 */
	public function convert_line_items_to_internal_format( $line_items, WC_Order $order ) {
		$prepared_line_items = array();

		foreach ( $line_items as $line_item ) {
			if ( ! isset( $line_item['line_item_id'], $line_item['quantity'], $line_item['refund_total'] ) ) {
				continue;
			}

			// If no explicit refund_tax provided, extract tax from refund_total using WC_Tax.
			if ( ! isset( $line_item['refund_tax'] ) ) {
				$original_item = $order->get_item( $line_item['line_item_id'] );
				if ( $original_item ) {
					$original_taxes = $original_item->get_taxes();
					$tax_ids        = array_keys( $original_taxes['total'] ?? array() );

					if ( ! empty( $tax_ids ) ) {
						$tax_rates = $this->build_tax_rates_array( $order, $tax_ids );

						// Always assume refund_total includes tax - extract it using WC_Tax.
						$calculated_taxes = WC_Tax::calc_inclusive_tax(
							(float) $line_item['refund_total'],
							$tax_rates
						);

						$line_item['refund_tax'] = $this->convert_proportional_taxes_to_schema_format(
							$calculated_taxes
						);

						// Subtract extracted tax from refund_total to get the amount excluding tax.
						$total_tax                 = array_sum( $calculated_taxes );
						$line_item['refund_total'] = $line_item['refund_total'] - $total_tax;
					}
				}
			}

			$prepared_line_items[ $line_item['line_item_id'] ] = array(
				'qty'          => $line_item['quantity'],
				'refund_total' => $line_item['refund_total'],
				'refund_tax'   => $this->convert_line_item_taxes_to_internal_format( $line_item['refund_tax'] ?? array() ),
			);
		}

		return $prepared_line_items;
	}

	/**
	 * Convert line item taxes (schema format) to internal format. This keys arrays by tax ID and has some different naming
	 *
	 * @param array $line_item_taxes The taxes to convert.
	 * @return array The converted taxes.
	 */
	private function convert_line_item_taxes_to_internal_format( $line_item_taxes ) {
		$prepared_taxes = array();

		foreach ( $line_item_taxes as $line_item_tax ) {
			if ( ! isset( $line_item_tax['id'], $line_item_tax['refund_total'] ) ) {
				continue;
			}
			$prepared_taxes[ $line_item_tax['id'] ] = $line_item_tax['refund_total'];
		}

		return $prepared_taxes;
	}

	/**
	 * Calculate the refund amount from line items.
	 *
	 * @param array $line_items The line items to calculate the refund amount from.
	 * @return float|null The refund amount, or null if it can't be calculated.
	 */
	public function calculate_refund_amount( array $line_items ): ?float {
		if ( empty( $line_items ) ) {
			return null;
		}

		$amount = 0;

		foreach ( $line_items as $line_item ) {
			if ( ! empty( $line_item['refund_total'] ) && is_numeric( $line_item['refund_total'] ) ) {
				$amount += $line_item['refund_total'];
			}

			if ( ! empty( $line_item['refund_tax'] ) && is_array( $line_item['refund_tax'] ) ) {
				foreach ( $line_item['refund_tax'] as $tax ) {
					if ( ! empty( $tax['refund_total'] ) && is_numeric( $tax['refund_total'] ) ) {
						$amount += $tax['refund_total'];
					}
				}
			}
		}

		return $amount;
	}

	/**
	 * Validate line items (schema format) before conversion to internal format.
	 *
	 * @param array    $line_items The line items to validate.
	 * @param WC_Order $order The order object.
	 * @return boolean|WP_Error
	 */
	public function validate_line_items( $line_items, WC_Order $order ) {
		foreach ( $line_items as $line_item ) {
			$line_item_id = $line_item['line_item_id'] ?? null;

			if ( ! $line_item_id ) {
				return new WP_Error( 'invalid_line_item', __( 'Line item ID is required.', 'woocommerce' ) );
			}

			$item = $order->get_item( $line_item_id );

			// Validate item exists and belongs to the order.
			if ( ! $item || $item->get_order_id() !== $order->get_id() ) {
				return new WP_Error( 'invalid_line_item', __( 'Line item not found.', 'woocommerce' ) );
			}

			if ( ! $item instanceof \WC_Order_Item_Product && ! $item instanceof \WC_Order_Item_Fee && ! $item instanceof \WC_Order_Item_Shipping ) {
				return new WP_Error( 'invalid_line_item', __( 'Line item is not a product, fee, or shipping line.', 'woocommerce' ) );
			}

			// Validate item quantity is not greater than the item quantity.
			if ( $item->get_quantity() < $line_item['quantity'] ) {
				/* translators: %s: item quantity */
				return new WP_Error( 'invalid_line_item', sprintf( __( 'Line item quantity cannot be greater than the item quantity (%s).', 'woocommerce' ), $item->get_quantity() ) );
			}

			// Validate refund total is not greater than the item total (including tax).
			$item_total_with_tax = $item->get_total() + $item->get_total_tax();
			if ( $item_total_with_tax < $line_item['refund_total'] ) {
				return new WP_Error(
					'invalid_refund_amount',
					sprintf(
						/* translators: %s: item total with tax */
						__( 'Refund total cannot be greater than the line item total including tax (%s).', 'woocommerce' ),
						$item_total_with_tax
					)
				);
			}

			if ( isset( $line_item['refund_tax'] ) ) {
				$item_taxes = $item->get_taxes();

				if ( $item_taxes ) {
					$allowed_tax_ids = array_keys( $item_taxes['total'] ?? array() );

					foreach ( $line_item['refund_tax'] as $refund_tax ) {
						if ( ! isset( $refund_tax['id'], $refund_tax['refund_total'] ) ) {
							return new WP_Error( 'invalid_line_item', __( 'Tax id and refund_total are required.', 'woocommerce' ) );
						}
						$tax_id           = $refund_tax['id'];
						$tax_refund_total = $refund_tax['refund_total'];

						if ( ! in_array( $tax_id, $allowed_tax_ids, true ) ) {
							return new WP_Error(
								'invalid_line_item',
								sprintf(
								/* translators: %s: tax IDs */
									__( 'Line item tax not found. Must be: %s.', 'woocommerce' ),
									implode( ', ', $allowed_tax_ids )
								)
							);
						}

						if ( $item_taxes['total'][ $tax_id ] < $tax_refund_total ) {
							return new WP_Error(
								'invalid_refund_amount',
								sprintf(
								/* translators: %s: tax total */
									__( 'Refund tax total cannot be greater than the line item tax total (%s).', 'woocommerce' ),
									$item_taxes['total'][ $tax_id ]
								)
							);
						}
					}
				}
			}
		}

		return true;
	}

	/**
	 * Convert calculated taxes (internal format) to schema format.
	 *
	 * @param array $calculated_taxes Taxes keyed by tax ID with amounts.
	 * @return array Schema format with id and refund_total keys.
	 */
	private function convert_proportional_taxes_to_schema_format( array $calculated_taxes ): array {
		$result = array();
		foreach ( $calculated_taxes as $tax_id => $amount ) {
			$result[] = array(
				'id'           => (int) $tax_id,
				'refund_total' => $amount,
			);
		}
		return $result;
	}

	/**
	 * Build tax rate array from order tax items for use with WC_Tax calculations.
	 *
	 * @param WC_Order $order The order.
	 * @param array    $tax_ids Array of tax rate IDs that apply to an item.
	 * @return array Tax rates array formatted for WC_Tax::calc_*_tax() methods.
	 */
	private function build_tax_rates_array( WC_Order $order, array $tax_ids ): array {
		$tax_rates = array();
		$tax_items = $order->get_items( 'tax' );

		foreach ( $tax_ids as $tax_id ) {
			foreach ( $tax_items as $tax_item ) {
				if ( $tax_item->get_rate_id() === (int) $tax_id ) {
					$tax_rates[ $tax_id ] = array(
						'rate'     => $tax_item->get_rate_percent(),
						'label'    => $tax_item->get_label(),
						'compound' => $tax_item->is_compound() ? 'yes' : 'no',
					);
					break;
				}
			}
		}

		return $tax_rates;
	}
}
PK     [1]3JVO%  O%  '  ComingSoon/ComingSoonRequestHandler.phpnu         <?php
namespace Automattic\WooCommerce\Internal\ComingSoon;

use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Blocks\BlockTemplatesController;
use Automattic\WooCommerce\Blocks\BlockTemplatesRegistry;
use Automattic\WooCommerce\Blocks\Package as BlocksPackage;
use Automattic\Jetpack\Constants;

/**
 * Handles the template_include hook to determine whether the current page needs
 * to be replaced with a coming soon screen.
 */
class ComingSoonRequestHandler {

	/**
	 * Coming soon helper.
	 *
	 * @var ComingSoonHelper
	 */
	private $coming_soon_helper = null;

	/**
	 * Whether the coming soon screen should be shown. Cache the result to avoid multiple calls to the helper.
	 *
	 * @var bool
	 */
	private static $show_coming_soon = false;

	/**
	 * Sets up the hook.
	 *
	 * @internal
	 *
	 * @param ComingSoonHelper $coming_soon_helper Dependency.
	 */
	final public function init( ComingSoonHelper $coming_soon_helper ) {
		$this->coming_soon_helper = $coming_soon_helper;
		// Hook into plugins_loaded to ensure features are initialized to determine coming soon status.
		add_action(
			'plugins_loaded',
			function () {
				// Skip if the site is live.
				if ( $this->coming_soon_helper->is_site_live() ) {
					return;
				}

				add_filter( 'template_include', array( $this, 'handle_template_include' ) );
				add_filter( 'wp_theme_json_data_theme', array( $this, 'experimental_filter_theme_json_theme' ) );
				add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_styles' ) );
				add_action( 'after_setup_theme', array( $this, 'possibly_init_block_templates' ), 999 );
			}
		);
	}

	/**
	 * Initializes block templates so we can show coming soon page in non-FSE themes.
	 */
	public function possibly_init_block_templates() {
		// No need to initialize block templates since we've already initialized them in the Block Bootstrap.
		if ( wp_is_block_theme() || current_theme_supports( 'block-template-parts' ) ) {
			return;
		}

		$container = BlocksPackage::container();
		$container->get( BlockTemplatesRegistry::class )->init();
		$container->get( BlockTemplatesController::class )->init();
	}

	/**
	 * Replaces the page template with a 'coming soon' when the site is in coming soon mode.
	 *
	 * @internal
	 *
	 * @param string $template The path to the previously determined template.
	 * @return string The path to the 'coming soon' template or any empty string to prevent further template loading in FSE themes.
	 */
	public function handle_template_include( $template ) {
		if ( ! $this->should_show_coming_soon() ) {
			return $template;
		}

		// A coming soon page needs to be displayed. Set a short cache duration to prevents ddos attacks.
		header( 'Cache-Control: max-age=60' );

		$is_fse_theme         = wp_is_block_theme();
		$is_store_coming_soon = $this->coming_soon_helper->is_store_coming_soon();
		add_theme_support( 'block-templates' );

		$coming_soon_template = get_query_template( 'coming-soon' );

		if ( ! $is_fse_theme && $is_store_coming_soon ) {
			get_header();
		}

		add_action(
			'wp_head',
			function () {
				echo "<meta name='woo-coming-soon-page' content='yes'>";
			}
		);

		if ( ! empty( $coming_soon_template ) && file_exists( $coming_soon_template ) ) {
			if ( ! $is_fse_theme && $is_store_coming_soon && function_exists( 'get_the_block_template_html' ) ) {
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
				echo get_the_block_template_html();
			} else {
				include $coming_soon_template;
			}
		}

		if ( ! $is_fse_theme && $is_store_coming_soon ) {
			get_footer();
		}

		if ( $is_fse_theme ) {
			// Since we've already rendered a template, return empty string to ensure no other template is rendered.
			return '';
		} else {
			// In non-FSE themes, other templates will still be rendered.
			// We need to exit to prevent further processing.
			exit();
		}
	}

	/**
	 * Determines whether the coming soon screen should be shown.
	 *
	 * @return bool
	 */
	private function should_show_coming_soon() {
		// Early exit if already determined that the coming soon screen should be shown.
		if ( self::$show_coming_soon ) {
			return true;
		}

		// Early exit if LYS feature is disabled.
		if ( ! Features::is_enabled( 'launch-your-store' ) ) {
			return false;
		}

		// Early exit if the user is logged in as administrator / shop manager.
		if ( current_user_can( 'manage_woocommerce' ) ) {
			return false;
		}

		// Do not show coming soon on 404 pages when applied to store pages only.
		if ( $this->coming_soon_helper->is_store_coming_soon() && is_404() ) {
			return false;
		}

		// Early exit if the current page doesn't need a coming soon screen.
		if ( ! $this->coming_soon_helper->is_current_page_coming_soon() ) {
			return false;
		}

		/**
		 * Check if there is an exclusion.
		 *
		 * @since 9.1.0
		 *
		 * @param bool $is_excluded If the request should be excluded from Coming soon mode. Defaults to false.
		 */
		if ( apply_filters( 'woocommerce_coming_soon_exclude', false ) ) {
			return false;
		}

		// Check if the private link option is enabled.
		if ( get_option( 'woocommerce_private_link' ) === 'yes' ) {
			// Exclude users with a private link.
			if ( isset( $_GET['woo-share'] ) && get_option( 'woocommerce_share_key' ) === $_GET['woo-share'] ) { //phpcs:ignore WordPress.Security.NonceVerification.Recommended
				// Persist the share link with a cookie for 90 days.
				setcookie( 'woo-share', sanitize_text_field( wp_unslash( $_GET['woo-share'] ) ), time() + 60 * 60 * 24 * 90, '/' ); //phpcs:ignore WordPress.Security.NonceVerification.Recommended
				return false;
			}
			if ( isset( $_COOKIE['woo-share'] ) && get_option( 'woocommerce_share_key' ) === $_COOKIE['woo-share'] ) {
				return false;
			}
		}

		self::$show_coming_soon = true;
		return true;
	}

	/**
	 * Filters the theme.json data to add Coming Soon fonts.
	 * This runs after child theme merging to ensure parent theme fonts are included.
	 *
	 * @param WP_Theme_JSON_Data $theme_json The theme json data object.
	 * @return WP_Theme_JSON_Data The filtered theme json data.
	 */
	public function experimental_filter_theme_json_theme( $theme_json ) {
		if ( ! Features::is_enabled( 'launch-your-store' ) ) {
			return $theme_json;
		}

		$theme_data = $theme_json->get_data();
		$font_data  = $theme_data['settings']['typography']['fontFamilies']['theme'] ?? array();

		// Check if the current theme is a child theme. And if so, merge the parent theme fonts with the existing fonts.
		if ( wp_get_theme()->parent() ) {
			$parent_theme           = wp_get_theme()->parent();
			$parent_theme_json_file = $parent_theme->get_file_path( 'theme.json' );

			if ( is_readable( $parent_theme_json_file ) ) {
				$parent_theme_json_data = json_decode( file_get_contents( $parent_theme_json_file ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents

				if ( isset( $parent_theme_json_data['settings']['typography']['fontFamilies'] ) ) {
					$parent_fonts = $parent_theme_json_data['settings']['typography']['fontFamilies'];

					// Merge parent theme fonts with existing fonts.
					foreach ( $parent_fonts as $parent_font ) {
						$found = false;
						foreach ( $font_data as $existing_font ) {
							if ( isset( $parent_font['name'] ) && isset( $existing_font['name'] ) &&
							$parent_font['name'] === $existing_font['name'] ) {
								$found = true;
								break;
							}
						}

						if ( ! $found ) {
							$font_data[] = $parent_font;
						}
					}
				}
			}
		}

		$fonts_to_add = array(
			array(
				'fontFamily' => '"Inter", sans-serif',
				'name'       => 'Inter',
				'slug'       => 'inter',
				'fontFace'   => array(
					array(
						'fontFamily'  => 'Inter',
						'fontStretch' => 'normal',
						'fontStyle'   => 'normal',
						'fontWeight'  => '300 900',
						'src'         => array( WC()->plugin_url() . '/assets/fonts/Inter-VariableFont_slnt,wght.woff2' ),
					),
				),
			),
			array(
				'fontFamily' => 'Cardo',
				'name'       => 'Cardo',
				'slug'       => 'cardo',
				'fontFace'   => array(
					array(
						'fontFamily' => 'Cardo',
						'fontStyle'  => 'normal',
						'fontWeight' => '400',
						'src'        => array( WC()->plugin_url() . '/assets/fonts/cardo_normal_400.woff2' ),
					),
				),
			),
		);

		// Add WooCommerce fonts if they don't already exist.
		foreach ( $fonts_to_add as $font_to_add ) {
			$found = false;
			foreach ( $font_data as $font ) {
				if ( isset( $font['name'] ) && $font['name'] === $font_to_add['name'] ) {
					$found = true;
					break;
				}
			}

			if ( ! $found ) {
				$font_data[] = $font_to_add;
			}
		}

		$new_data = array(
			'version'  => 1,
			'settings' => array(
				'typography' => array(
					'fontFamilies' => array(
						'theme' => $font_data,
					),
				),
			),
		);
		$theme_json->update_with( $new_data );
		return $theme_json;
	}

	/**
	 * Enqueues the coming soon banner styles.
	 */
	public function enqueue_styles() {
		// Early exit if the user is not logged in as administrator / shop manager.
		if ( ! current_user_can( 'manage_woocommerce' ) ) {
			return;
		}

		// Early exit if LYS feature is disabled.
		if ( ! Features::is_enabled( 'launch-your-store' ) ) {
			return;
		}

		if ( $this->coming_soon_helper->is_site_live() ) {
			return;
		}

		wp_enqueue_style(
			'woocommerce-coming-soon',
			WC()->plugin_url() . '/assets/css/coming-soon' . ( is_rtl() ? '-rtl' : '' ) . '.css',
			array(),
			Constants::get_constant( 'WC_VERSION' )
		);
	}
}
PK     [1].KŌ    )  ComingSoon/ComingSoonCacheInvalidator.phpnu         <?php
namespace Automattic\WooCommerce\Internal\ComingSoon;

/**
 * Adds hooks to invalidate caches when the coming soon settings are changed.
 */
class ComingSoonCacheInvalidator {

	/**
	 * Sets up the hooks.
	 *
	 * @internal
	 */
	final public function init() {
		add_action( 'update_option_woocommerce_coming_soon', array( $this, 'invalidate_caches' ) );
		add_action( 'update_option_woocommerce_store_pages_only', array( $this, 'invalidate_caches' ) );
	}

	/**
	 * Invalidate the WordPress object cache and other known caches.
	 *
	 * @internal
	 */
	public function invalidate_caches() {
		// Standard WordPress object cache invalidation.
		wp_cache_flush();

		/**
		 * Temporary solution to invalidate the WordPress.com Edge Cache. We can trigger
		 * invalidation by publishing any post. It should be refactored with a supported integration.
		 */
		$cart_page_id = get_option( 'woocommerce_cart_page_id' ) ?? null;
		if ( $cart_page_id ) {
			// Re-publish the coming soon page. Has the side-effect of invalidating the Edge Cache.
			wp_update_post(
				array(
					'ID'          => $cart_page_id,
					'post_status' => 'publish',
				)
			);
		}
	}
}
PK     [1];    &  ComingSoon/ComingSoonAdminBarBadge.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\ComingSoon;

use Automattic\WooCommerce\Utilities\FeaturesUtil;


/**
 * Adds hooks to add a badge to the WordPress admin bar showing site visibility.
 */
class ComingSoonAdminBarBadge {

	/**
	 * Sets up the hooks.
	 *
	 * @internal
	 */
	final public function init() {
		add_action( 'init', array( $this, 'init_hooks' ) );
	}

	/**
	 * Sets up the hooks if user has required capabilities.
	 *
	 * @internal
	 */
	public function init_hooks() {
		// Early exit if the user is not logged in as administrator / shop manager.
		if ( ! is_user_logged_in() || ! current_user_can( 'manage_woocommerce' ) ) {
			return;
		}

		add_action( 'admin_bar_menu', array( $this, 'site_visibility_badge' ), 31 );
		add_action( 'wp_head', array( $this, 'output_css' ) );
		add_action( 'admin_head', array( $this, 'output_css' ) );
	}

	/**
	 * Add site visibility cache badge to WP admin bar.
	 *
	 * @internal
	 * @param WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance.
	 */
	public function site_visibility_badge( $wp_admin_bar ) {
		// Early exit if LYS feature is disabled.
		if ( ! FeaturesUtil::feature_is_enabled( 'site_visibility_badge' ) ) {
			return;
		}

		$labels = array(
			'coming-soon'       => __( 'Coming soon', 'woocommerce' ),
			'store-coming-soon' => __( 'Store coming soon', 'woocommerce' ),
			'live'              => __( 'Live', 'woocommerce' ),
		);

		if ( get_option( 'woocommerce_coming_soon' ) === 'yes' ) {
			if ( get_option( 'woocommerce_store_pages_only' ) === 'yes' ) {
				$key = 'store-coming-soon';
			} else {
				$key = 'coming-soon';
			}
		} else {
			$key = 'live';
		}

		$args = array(
			'id'    => 'woocommerce-site-visibility-badge',
			'title' => $labels[ $key ],
			'href'  => admin_url( 'admin.php?page=wc-settings&tab=site-visibility' ),
			'meta'  => array(
				'class' => 'woocommerce-site-status-badge-' . $key,
			),
		);
		$wp_admin_bar->add_node( $args );
	}

	/**
	 * Output CSS for site visibility badge.
	 *
	 * @internal
	 */
	public function output_css() {
		// Early exit if LYS feature is disabled.
		if ( ! FeaturesUtil::feature_is_enabled( 'site_visibility_badge' ) ) {
			return;
		}

		if ( is_admin_bar_showing() ) {
			echo '<style>
				#wpadminbar .quicklinks #wp-admin-bar-woocommerce-site-visibility-badge {
					padding: 7px 0;
				}

				#wpadminbar .quicklinks #wp-admin-bar-woocommerce-site-visibility-badge a.ab-item {
					/* Layout  */
					background-color: #F6F7F7;
					border-radius: 2px;
					display: flex;
					height: 18px;
					padding: 0px 6px;
					align-items: center;
					gap: 8px;

					/* Typography  */
					color: #3C434A;
					font-size: 12px;
					font-style: normal;
					font-weight: 500;
					line-height: 16px;
				}

				#wpadminbar .quicklinks #wp-admin-bar-woocommerce-site-visibility-badge a.ab-item:hover,
				#wpadminbar .quicklinks #wp-admin-bar-woocommerce-site-visibility-badge a.ab-item:focus {
					background-color: #DCDCDE;
				}

				#wpadminbar .quicklinks #wp-admin-bar-woocommerce-site-visibility-badge a.ab-item:focus {
					outline: var(--wp-admin-border-width-focus) solid var(--wp-admin-theme-color-darker-20);
				}

				#wpadminbar .quicklinks #wp-admin-bar-woocommerce-site-visibility-badge.woocommerce-site-status-badge-live a.ab-item {
					background-color: #E6F2E8;
					color: #00450C;
				}

				#wpadminbar .quicklinks #wp-admin-bar-woocommerce-site-visibility-badge.woocommerce-site-status-badge-live a.ab-item:hover,
				#wpadminbar .quicklinks #wp-admin-bar-woocommerce-site-visibility-badge.woocommerce-site-status-badge-live a.ab-item:focus {
					background-color: #B8E6BF;
				}
			</style>';
		}
	}
}
PK     [1]f  f    ComingSoon/ComingSoonHelper.phpnu         <?php
namespace Automattic\WooCommerce\Internal\ComingSoon;

use Automattic\WooCommerce\Admin\WCAdminHelper;

/**
 * Provides helper methods for coming soon functionality.
 */
class ComingSoonHelper {

	/**
	 * Returns true when the entire site is live.
	 */
	public function is_site_live(): bool {
		return 'yes' !== get_option( 'woocommerce_coming_soon' );
	}

	/**
	 * Returns true when the entire site is coming soon mode.
	 */
	public function is_site_coming_soon(): bool {
		return 'yes' === get_option( 'woocommerce_coming_soon' ) && 'yes' !== get_option( 'woocommerce_store_pages_only' );
	}

	/**
	 * Returns true when only the store pages are in coming soon mode.
	 */
	public function is_store_coming_soon(): bool {
		return 'yes' === get_option( 'woocommerce_coming_soon' ) && 'yes' === get_option( 'woocommerce_store_pages_only' );
	}

	/**
	 * Return true if the current page should be shown in coming soon mode.
	 */
	public function is_current_page_coming_soon(): bool {
		// Early exit if coming soon mode not active.
		if ( $this->is_site_live() ) {
			return false;
		}

		if ( $this->is_site_coming_soon() ) {
			return true;
		}

		// Check the current page is a store page when in "store coming soon" mode.
		if ( $this->is_store_coming_soon() && WCAdminHelper::is_current_page_store_page() ) {
			return true;
		}

		// Default to false.
		return false;
	}
}
PK     [1]cOb%  %  5  AddressProvider/AbstractAutomatticAddressProvider.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\AddressProvider;

use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
use Automattic\Jetpack\Constants;
use WC_Address_Provider;

/**
 * Abstract Automattic address provider is an abstract implementation of the WC_Address_Provider that is meant to be used by Automattic services to get support for address autocomplete and maps with minimal code maintenance.
 *
 * @since 10.1.0
 * @package WooCommerce
 */
abstract class AbstractAutomatticAddressProvider extends WC_Address_Provider {

	/**
	 * The JWT for the address service.
	 *
	 * @var string
	 */
	private $jwt = null;

	/**
	 * Loads up the JWT for the address service and saves it to transient.
	 */
	public function __construct() {
		add_filter( 'pre_update_option_woocommerce_address_autocomplete_enabled', array( $this, 'refresh_cache' ) );
		add_action( 'wp_enqueue_scripts', array( $this, 'load_scripts' ) );

		// Powered by Google branding.
		$this->branding_html = 'Powered by&nbsp;<img style="height: 15px; width: 45px; margin-bottom: -2px;" src="' . plugins_url( '/assets/images/address-autocomplete/google.svg', WC_PLUGIN_FILE ) . '" alt="Google logo" />';
	}

	/**
	 * Get the JWT for the address service, a service should implement an A8C hosted API or some mechanism to get a JWT, this will be passed to frontend code to be used in the address autocomplete and maps.
	 *
	 * This method shouldn't implement any caching, it should only fetch the token or throw an exception, if you must handle caching, consider also overriding get_jwt.
	 *
	 * @return string The JWT for the address service.
	 */
	abstract public function get_address_service_jwt();

	/**
	 * Get the telemetry status for the address service, this is meant to be overridden by the implementor to return true if the service has permission to send telemetry data.
	 *
	 * @return bool The telemetry status for the address service.
	 */
	public function can_telemetry() {
		return false;
	}

	/**
	 * Loads up a JWT from cache or from the implementor side.
	 *
	 * @return void
	 *
	 * phpcs:ignore Squiz.Commenting.FunctionCommentThrowTag.Missing -- As we wrap the throw in a try/catch.
	 */
	public function load_jwt() {

		// If the address autocomplete is disabled, we don't load the JWT.
		if ( wc_string_to_bool( get_option( 'woocommerce_address_autocomplete_enabled', 'no' ) ) !== true ) {
			return;
		}

		// If we already have a loaded, valid token, we return early.
		if ( $this->jwt && is_string( $this->jwt ) && JsonWebToken::shallow_validate( $this->jwt ) ) {
			return;
		}

		$cached_jwt = $this->get_cached_option( 'address_autocomplete_jwt' );
		// If we have a cached, valid token, we load it to class and return early.
		if ( $cached_jwt && is_string( $cached_jwt ) && JsonWebToken::shallow_validate( $cached_jwt ) ) {
			$this->jwt = $cached_jwt;
			return;
		}

		$retry_data = $this->get_cached_option( 'jwt_retry_data' );

		if ( $retry_data && isset( $retry_data['try_after'] ) && $retry_data['try_after'] > time() ) {
			return;
		}

		try {
			$fresh_jwt = $this->get_address_service_jwt();
			if ( $fresh_jwt && is_string( $fresh_jwt ) && JsonWebToken::shallow_validate( $fresh_jwt ) ) {
				$this->set_jwt( $fresh_jwt );
				// Clear retry data on success.
				$this->delete_cached_option( 'jwt_retry_data' );
				return;
			} else {
				throw new \Exception( 'Invalid JWT received from address service.' );
			}
		} catch ( \Exception $e ) {
			$retry_data['attempts'] = isset( $retry_data['attempts'] ) ? $retry_data['attempts'] + 1 : 1;
			wc_get_logger()->error(
				sprintf(
					'Failed loading JWT for %1$s address autocomplete service (attempt %2$d) with error %3$s.',
					$this->name,
					$retry_data['attempts'],
					$e->getMessage()
				),
				'address-autocomplete'
			);
			$backoff_hours           = pow( 2, $retry_data['attempts'] - 1 ); // 1, 2, 4, 8 hours.
			$retry_data['try_after'] = time() + ( $backoff_hours * HOUR_IN_SECONDS );
			$this->update_cached_option( 'jwt_retry_data', $retry_data, DAY_IN_SECONDS );
		}
	}

	/**
	 * Gets the JWT for the address service.
	 *
	 * @return string The JWT for the address service.
	 */
	public function get_jwt() {
		if ( null === $this->jwt ) {
			$this->load_jwt();
		}

		return $this->jwt;
	}

	/**
	 * Sets the JWT for the address service.
	 *
	 * @param string $jwt The JWT for the address service.
	 */
	public function set_jwt( $jwt ) {
		$this->jwt = $jwt;
		if ( null !== $jwt ) {
			$cache_duration = $this->get_jwt_cache_duration( $jwt );
			// If the token is expired, we don't cache it and we fetch a new one.
			if ( 0 === $cache_duration ) {
				$this->jwt = null;
				$this->load_jwt();
				return;
			}
			$this->update_cached_option( 'address_autocomplete_jwt', $jwt, $cache_duration );
		} else {
			$this->delete_cached_option( 'address_autocomplete_jwt' );
		}
	}

	/**
	 * Gets the cache duration for the JWT.
	 *
	 * @param string $jwt The JWT for the address service.
	 * @return int The cache duration for the JWT.
	 */
	public function get_jwt_cache_duration( $jwt ) {
		$parts = JsonWebToken::get_parts( $jwt );
		if ( property_exists( $parts->payload, 'exp' ) ) {
			return max( $parts->payload->exp - time(), 0 );
		}
	}

	/**
	 * Deletes the cached token if we disable the autocomplete service or fetches a new one if it's enabled.
	 *
	 * @param string $setting If the service is enabled or disabled.
	 * @return string the setting value.
	 */
	public function refresh_cache( $setting ) {
		if ( wc_string_to_bool( $setting ) ) {
			$this->load_jwt();
		} else {
			$this->set_jwt( null );
		}

		return $setting;
	}

	/**
	 * Gets the cached option.
	 *
	 * @param string $key The key of the option.
	 * @return mixed|null The cached option.
	 */
	private function get_cached_option( $key ) {
		$data = get_option( $this->id . '_' . $key );
		if ( is_array( $data ) && isset( $data['data'] ) ) {
			if ( ! self::is_expired( $data ) ) {
				return $data['data'];
			}
			$this->delete_cached_option( $key );
		}
		return null;
	}

	/**
	 * Updates the cached option.
	 *
	 * @param string $key The key of the option.
	 * @param mixed  $value The value of the option.
	 * @param int    $ttl The TTL of the option.
	 */
	private function update_cached_option( $key, $value, $ttl = DAY_IN_SECONDS ) {
		$result = update_option(
			$this->id . '_' . $key,
			array(
				'data'    => $value,
				'updated' => time(),
				'ttl'     => $ttl,
			),
			false
		);
		if ( false === $result ) {
			wp_cache_delete( $this->id . '_' . $key, 'options' );
		}
	}

	/**
	 * Deletes the cached option.
	 *
	 * @param string $key The key of the option.
	 */
	private function delete_cached_option( $key ) {
		if ( delete_option( $this->id . '_' . $key ) ) {
			wp_cache_delete( $this->id . '_' . $key, 'options' );
		}
	}

	/**
	 * Checks if the cache value is expired.
	 *
	 * @param array $cache_contents The cache contents.
	 *
	 * @return boolean True if the contents are expired. False otherwise.
	 */
	private static function is_expired( $cache_contents ) {
		if ( ! is_array( $cache_contents ) || ! isset( $cache_contents['updated'] ) || ! isset( $cache_contents['ttl'] ) ) {
			// Treat bad/invalid cache contents as expired.
			return true;
		}

		// Double-check that we have integers for `updated` and `ttl`.
		if ( ! is_int( $cache_contents['updated'] ) || ! is_int( $cache_contents['ttl'] ) ) {
			return true;
		}

		$expires = $cache_contents['updated'] + $cache_contents['ttl'];
		$now     = time();
		return $expires < $now;
	}

	/**
	 * Return asset URL, copied from WC_Frontend_Scripts::get_asset_url.
	 *
	 * @param string $path Assets path.
	 * @return string
	 */
	public static function get_asset_url( $path ) {
		/**
		 * Filters the asset URL.
		 *
		 * @since 3.2.0
		 *
		 * @param string $url The asset URL.
		 * @param string $path The asset path.
		 * @return string The filtered asset URL.
		 */
		return apply_filters( 'woocommerce_get_asset_url', plugins_url( $path, Constants::get_constant( 'WC_PLUGIN_FILE' ) ), $path );
	}


	/**
	 * Enqueues the checkout script, checks if it's already registered or not so we don't duplicate, and prints out the JWT to the page to be consumed.
	 */
	public function load_scripts() {
		// If the address autocomplete setting is disabled, don't load the scripts.
		if ( wc_string_to_bool( get_option( 'woocommerce_address_autocomplete_enabled', 'no' ) ) !== true ) {
			return;
		}

		if ( ! is_checkout() ) {
			return;
		}

		if ( ! $this->get_jwt() ) {
			return;
		}

		$suffix  = Constants::is_true( 'SCRIPT_DEBUG' ) ? '' : '.min';
		$version = Constants::get_constant( 'WC_VERSION' );

		if ( ! wp_script_is( 'a8c-address-autocomplete-service', 'registered' ) ) {
			wp_register_script( 'a8c-address-autocomplete-service', self::get_asset_url( 'assets/js/frontend/a8c-address-autocomplete-service' . $suffix . '.js' ), array( 'wc-address-autocomplete' ), $version, array( 'strategy' => 'defer' ) );
		}

		if ( ! wp_script_is( 'a8c-address-autocomplete-service', 'enqueued' ) ) {
			wp_enqueue_script( 'a8c-address-autocomplete-service' );
		}

		wp_add_inline_script(
			'a8c-address-autocomplete-service',
			sprintf(
				'var a8cAddressAutocompleteServiceKeys = a8cAddressAutocompleteServiceKeys || {}; a8cAddressAutocompleteServiceKeys[ %1$s ] = { key: %2$s, canTelemetry: %3$s };',
				wp_json_encode( $this->id, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
				wp_json_encode( $this->get_jwt(), JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
				wp_json_encode( false !== $this->can_telemetry() && (bool) $this->can_telemetry(), JSON_HEX_TAG | JSON_UNESCAPED_SLASHES )
			),
			'before'
		);
	}
}
PK     [1]*    -  AddressProvider/AddressProviderController.phpnu         <?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\AddressProvider;

use WC_Address_Provider;

/**
 * Service class for managing address providers.
 */
class AddressProviderController {
	/**
	 * Registered provider instances.
	 *
	 * @var WC_Address_Provider[]
	 */
	private $providers = array();

	/**
	 * Preferred provider from options.
	 *
	 * @var string ID of preferred address provider.
	 */
	private $preferred_provider_option = '';

	/**
	 * Constructor.
	 *
	 * @internal
	 */
	public function __construct() {
		add_action( 'init', array( $this, 'init' ) );
	}

	/**
	 * Init function runs after this provider was added to DI container.
	 *
	 * @internal
	 */
	final public function init() {
		$this->preferred_provider_option = get_option( 'woocommerce_address_autocomplete_provider', '' );
		$this->providers                 = $this->get_registered_providers();
	}

	/**
	 * Get the registered providers.
	 *
	 * @return WC_Address_Provider[] array of WC_Address_Providers.
	 */
	public function get_providers(): array {
		return $this->providers;
	}

	/**
	 * Get all registered providers.
	 *
	 * @return WC_Address_Provider[] array of WC_Address_Providers.
	 */
	private function get_registered_providers(): array {
		/**
		 * Filter the registered address providers.
		 *
		 * @since 9.9.0
		 * @param array $providers Array of fully qualified class names (strings) or WC_Address_Provider instances.
		 *                         Class names will be instantiated automatically.
		 *                         Example: array( 'My_Provider_Class', new My_Other_Provider() )
		 */
		$provider_items = apply_filters( 'woocommerce_address_providers', array() );

		// The filter returned nothing but an empty array, so we can skip the rest of the function.
		if ( empty( $provider_items ) && is_array( $provider_items ) ) {
			return array();
		}

		$logger = wc_get_logger();

		if ( ! is_array( $provider_items ) ) {
			$logger->error(
				'Invalid return value for woocommerce_address_providers, expected an array of class names or instances.',
				array(
					'context' => 'address_provider_service',
				)
			);
			return array();
		}

		$providers = array();
		$seen_ids  = array();

		foreach ( $provider_items as $provider_item ) {
			if ( is_string( $provider_item ) && class_exists( $provider_item ) ) {
				$provider_item = new $provider_item();
			}

			// Providers need to be valid and extend WC_Address_Provider.
			if ( ! is_a( $provider_item, WC_Address_Provider::class ) ) {
				$logger->error(
					sprintf(
						'Invalid address provider item "%s", expected a string class name or WC_Address_Provider instance.',
						is_object( $provider_item ) ? get_class( $provider_item ) : gettype( $provider_item )
					),
					array(
						'context' => 'address_provider_service',
					)
				);
				continue;
			}

			// Validate the instance has the necessary properties.
			if ( empty( $provider_item->id ) || empty( $provider_item->name ) ) {
				$logger->error(
					'Invalid address provider instance, id or name property is missing or empty: ' . get_class( $provider_item ),
					array(
						'context' => 'address_provider_service',
					)
				);
				continue;
			}

			// Check for duplicate IDs.
			if ( isset( $seen_ids[ $provider_item->id ] ) ) {
				$logger->error(
					sprintf(
						'Duplicate provider ID found. ID "%s" is used by both %s and %s.',
						$provider_item->id,
						$seen_ids[ $provider_item->id ],
						get_class( $provider_item )
					),
					array(
						'context' => 'address_provider_service',
					)
				);
				continue;
			}

			// Track the ID and its provider class for error reporting.
			$seen_ids[ $provider_item->id ] = get_class( $provider_item );

			// Add the provider instance to the array after all checks are completed.
			$providers[] = $provider_item;
		}

		if ( ! empty( $this->preferred_provider_option ) && ! empty( $providers ) ) {
			// Look for the preferred provider in the array.
			foreach ( $providers as $key => $provider ) {
				if ( $provider->id === $this->preferred_provider_option ) {
					// Found the preferred provider, move it to the beginning of the array.
					$preferred_provider = $provider;
					unset( $providers[ $key ] );
					array_unshift( $providers, $preferred_provider );
					break;
				}
			}
		}

		return $providers;
	}

	/**
	 * Check if a specific provider is registered and available.
	 *
	 * @param string $provider_id The provider ID to check.
	 * @return bool
	 */
	public function is_provider_available( string $provider_id ): bool {

		foreach ( $this->providers as $provider ) {
			if ( $provider->id === $provider_id ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Get the preferred provider; this is what was selected in the WooCommerce "preferred provider" setting *or* the
	 * first registered provider if no preference was set. If the provider selected in WC Settings is not registered
	 * anymore, it will fall back to the first registered provider. Any other case will return an empty string.
	 *
	 * @return string
	 */
	public function get_preferred_provider(): string {

		if ( $this->is_provider_available( $this->preferred_provider_option ) ) {
			return $this->preferred_provider_option;
		}

		// Get the first provider's ID.
		return $this->providers[0]->id ?? '';
	}
}
PK     [1]lɭ      Customers/SearchService.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\Customers;

use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Utilities\OrderUtil;

/**
 * Internal API for searching users/customers: no backward compatibility obligation.
 */
final class SearchService {
	/**
	 * Searches users having the billing email (when applicable lookup orders as well) as specified and returns their id.
	 *
	 * @param string[] $emails Emails to search for.
	 *
	 * @return int[]
	 */
	public function find_user_ids_by_billing_email_for_coupons_usage_lookup( array $emails ): array {
		$emails = array_unique( array_map( 'strtolower', array_map( 'sanitize_email', $emails ) ) );

		$include_user_ids = array();
		if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
			global $wpdb;

			// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$placeholders     = implode( ', ', array_fill( 0, count( $emails ), '%s' ) );
			$include_user_ids = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT DISTINCT customer_id FROM %i WHERE billing_email IN ($placeholders)",
					OrdersTableDataStore::get_orders_table_name(),
					...$emails
				)
			);
			// phpcs:enable

			if ( array() === $include_user_ids ) {
				return array();
			}
		}

		$users_query = new \WP_User_Query(
			array(
				'fields'     => 'ID',
				'include'    => $include_user_ids,
				'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
					array(
						'key'     => 'billing_email',
						'value'   => $emails,
						'compare' => 'IN',
					),
				),
			)
		);
		return array_map( 'intval', array_unique( $users_query->get_results() ) );
	}
}
PK     [1]ypr    #  Agentic/Enums/Specs/MessageType.phpnu         <?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Agentic\Enums\Specs;

/**
 * Message types as defined in the Agentic Commerce Protocol.
 */
class MessageType {
	/**
	 * Informational message.
	 */
	const INFO = 'info';

	/**
	 * Warning message (deprecated in favor of info).
	 */
	const WARNING = 'warning';

	/**
	 * Error message.
	 */
	const ERROR = 'error';
}
PK     [1]t?  ?  '  Agentic/Enums/Specs/FulfillmentType.phpnu         <?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Agentic\Enums\Specs;

/**
 * Fulfillment types as defined in the Agentic Commerce Protocol.
 */
class FulfillmentType {
	/**
	 * Physical shipping.
	 */
	const SHIPPING = 'shipping';

	/**
	 * Digital delivery.
	 */
	const DIGITAL = 'digital';
}
PK     [1]ܙR  R  *  Agentic/Enums/Specs/MessageContentType.phpnu         <?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Agentic\Enums\Specs;

/**
 * Content types for messages as defined in the Agentic Commerce Protocol.
 */
class MessageContentType {
	/**
	 * Plain text content.
	 */
	const PLAIN = 'plain';

	/**
	 * Markdown formatted content.
	 */
	const MARKDOWN = 'markdown';
}
PK     [1]    !  Agentic/Enums/Specs/TotalType.phpnu         <?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Agentic\Enums\Specs;

/**
 * Total types as defined in the Agentic Commerce Protocol.
 */
class TotalType {
	/**
	 * Base amount of all items before discounts.
	 */
	const ITEMS_BASE_AMOUNT = 'items_base_amount';

	/**
	 * Total discount on items.
	 */
	const ITEMS_DISCOUNT = 'items_discount';

	/**
	 * Subtotal after item discounts.
	 */
	const SUBTOTAL = 'subtotal';

	/**
	 * Additional discount applied to order.
	 */
	const DISCOUNT = 'discount';

	/**
	 * Fulfillment/shipping cost.
	 */
	const FULFILLMENT = 'fulfillment';

	/**
	 * Tax amount.
	 */
	const TAX = 'tax';

	/**
	 * Additional fee.
	 */
	const FEE = 'fee';

	/**
	 * Final total amount.
	 */
	const TOTAL = 'total';
}
PK     [1]99       Agentic/Enums/Specs/LinkType.phpnu         <?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Agentic\Enums\Specs;

/**
 * Link types as defined in the Agentic Commerce Protocol.
 */
class LinkType {
	/**
	 * Terms of use/service.
	 */
	const TERMS_OF_USE = 'terms_of_use';

	/**
	 * Privacy policy.
	 */
	const PRIVACY_POLICY = 'privacy_policy';

	/**
	 * Seller shop policies.
	 */
	const SELLER_SHOP_POLICIES = 'seller_shop_policies';
}
PK     [1]C    #  Agentic/Enums/Specs/OrderStatus.phpnu         <?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Agentic\Enums\Specs;

/**
 * Order status values as defined in the Agentic Commerce Protocol.
 *
 * @since 10.3.0
 */
class OrderStatus {
	/**
	 * Order has been created.
	 */
	const CREATED = 'created';

	/**
	 * Order requires manual review.
	 */
	const MANUAL_REVIEW = 'manual_review';

	/**
	 * Order has been confirmed.
	 */
	const CONFIRMED = 'confirmed';

	/**
	 * Order has been canceled.
	 */
	const CANCELED = 'canceled';

	/**
	 * Order has been shipped.
	 */
	const SHIPPED = 'shipped';

	/**
	 * Order has been fulfilled.
	 */
	const FULFILLED = 'fulfilled';

	/**
	 * Get all valid order statuses.
	 *
	 * @return array Array of valid order status values.
	 */
	public static function get_all() {
		return array(
			self::CREATED,
			self::MANUAL_REVIEW,
			self::CONFIRMED,
			self::CANCELED,
			self::SHIPPED,
			self::FULFILLED,
		);
	}

	/**
	 * Check if a status is valid.
	 *
	 * @param string $status Status to check.
	 * @return bool True if valid, false otherwise.
	 */
	public static function is_valid( $status ) {
		return in_array( $status, self::get_all(), true );
	}
}
PK     [1]-i      %  Agentic/Enums/Specs/PaymentMethod.phpnu         <?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Agentic\Enums\Specs;

/**
 * Payment methods as defined in the Agentic Commerce Protocol.
 */
class PaymentMethod {
	/**
	 * Card payment method.
	 */
	const CARD = 'card';
}
PK     [1]qJZm  m  -  Agentic/Enums/Specs/CheckoutSessionStatus.phpnu         <?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Agentic\Enums\Specs;

/**
 * Checkout session status values as defined in the Agentic Commerce Protocol.
 */
class CheckoutSessionStatus {
	/**
	 * Session is not ready for payment (missing required information).
	 */
	const NOT_READY_FOR_PAYMENT = 'not_ready_for_payment';

	/**
	 * Session is ready for payment.
	 */
	const READY_FOR_PAYMENT = 'ready_for_payment';

	/**
	 * Session has been completed (payment successful).
	 */
	const COMPLETED = 'completed';

	/**
	 * Session has been canceled.
	 */
	const CANCELED = 'canceled';

	/**
	 * Session is in progress (payment initiated but not complete).
	 */
	const IN_PROGRESS = 'in_progress';

	/**
	 * Allowed statuses for update operations.
	 */
	const ALLOWED_STATUSES_FOR_UPDATE = array( self::NOT_READY_FOR_PAYMENT, self::READY_FOR_PAYMENT );
}
PK     [1]#
s    !  Agentic/Enums/Specs/ErrorCode.phpnu         <?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Agentic\Enums\Specs;

/**
 * Error codes for message errors as defined in the Agentic Commerce Protocol.
 */
class ErrorCode {
	/**
	 * Required field is missing.
	 */
	const MISSING = 'missing';

	/**
	 * Field value is invalid.
	 */
	const INVALID = 'invalid';

	/**
	 * Product is out of stock.
	 */
	const OUT_OF_STOCK = 'out_of_stock';

	/**
	 * Payment was declined.
	 */
	const PAYMENT_DECLINED = 'payment_declined';

	/**
	 * User sign-in is required.
	 */
	const REQUIRES_SIGN_IN = 'requires_sign_in';

	/**
	 * 3D Secure authentication is required.
	 */
	const REQUIRES_3DS = 'requires_3ds';
}
PK     [1]
  
  !  Agentic/Enums/Specs/ErrorType.phpnu         <?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Agentic\Enums\Specs;

/**
 * Error types as defined in the Agentic Commerce Protocol.
 */
class ErrorType {
	/**
	 * Invalid request.
	 */
	const INVALID_REQUEST = 'invalid_request';

	/**
	 * Request not idempotent.
	 */
	const REQUEST_NOT_IDEMPOTENT = 'request_not_idempotent';

	/**
	 * Processing error.
	 */
	const PROCESSING_ERROR = 'processing_error';

	/**
	 * Service unavailable.
	 */
	const SERVICE_UNAVAILABLE = 'service_unavailable';
}
PK     [1]]    '  Agentic/Enums/Specs/PaymentProvider.phpnu         <?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Agentic\Enums\Specs;

/**
 * Payment provider identifiers as defined in the Agentic Commerce Protocol.
 */
class PaymentProvider {
	/**
	 * Stripe payment provider.
	 */
	const STRIPE = 'stripe';
}
PK     [1]ie  e  "  Agentic/Enums/Specs/RefundType.phpnu         <?php
declare(strict_types=1);
namespace Automattic\WooCommerce\Internal\Agentic\Enums\Specs;

/**
 * Refund types as defined in the Agentic Commerce Protocol.
 */
class RefundType {
	/**
	 * Refund to store credit.
	 */
	const STORE_CREDIT = 'store_credit';

	/**
	 * Refund to original payment method.
	 */
	const ORIGINAL_PAYMENT = 'original_payment';
}
PK     [1]U6
  6
  $  Orders/OrderStatusRestController.phpnu         <?php
declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\Orders;

use Automattic\WooCommerce\Internal\RestApiControllerBase;
use WP_Error;
use WP_REST_Request;
use WP_REST_Server;

/**
 * Controller for the REST endpoint to add order statuses to the WooCommerce REST API.
 */
class OrderStatusRestController extends RestApiControllerBase {

	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc/v3';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'orders/statuses';

	/**
	 * Get the WooCommerce REST API namespace for the class.
	 *
	 * @return string
	 */
	protected function get_rest_api_namespace(): string {
		return $this->namespace;
	}

	/**
	 * Register the routes for order statuses.
	 */
	public function register_routes() {
		register_rest_route(
			$this->get_rest_api_namespace(),
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => '__return_true',
				),
				'schema' => array( $this, 'get_item_schema' ),
			)
		);
	}

	/**
	 * Get all order statuses.
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_Error|WP_REST_Response
	 */
	public function get_items( WP_REST_Request $request ) {
		$order_statuses     = wc_get_order_statuses();
		$formatted_statuses = array();

		foreach ( $order_statuses as $status_slug => $status_name ) {
			$slug = str_replace( 'wc-', '', $status_slug );

			$formatted_statuses[] = array(
				'slug' => $slug,
				'name' => wc_get_order_status_name( $slug ),
			);
		}

		if ( ! $formatted_statuses ) {
			return new WP_Error( 'woocommerce_rest_not_found', __( 'Order statuses not found', 'woocommerce' ), array( 'status' => 404 ) );
		}

		return rest_ensure_response( $formatted_statuses );
	}

	/**
	 * Get the order status schema, conforming to JSON Schema.
	 *
	 * @return array
	 */
	public function get_item_schema() {
		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'order_status',
			'type'       => 'object',
			'properties' => array(
				'slug' => array(
					'description' => __( 'Order status slug.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view' ),
					'readonly'    => true,
				),
				'name' => array(
					'description' => __( 'Order status name.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view' ),
					'readonly'    => true,
				),
			),
		);

		return $schema;
	}
}
PK     [1]9      Orders/CardIcons/jcb.svgnu         <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg viewBox="0 0 750 471" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" preserveAspectRatio="xMidYMid meet">
    <!-- Generator: Sketch 3.3.1 (12005) - http://www.bohemiancoding.com/sketch -->
    <title>Slice 1</title>
    <desc>Created with Sketch.</desc>
    <defs>
        <linearGradient x1="0.031607858%" y1="49.9998574%" x2="99.9743153%" y2="49.9998574%" id="linearGradient-1">
            <stop stop-color="#007B40" offset="0%"></stop>
            <stop stop-color="#55B330" offset="100%"></stop>
        </linearGradient>
        <linearGradient x1="0.471693172%" y1="49.999826%" x2="99.9860086%" y2="49.999826%" id="linearGradient-2">
            <stop stop-color="#1D2970" offset="0%"></stop>
            <stop stop-color="#006DBA" offset="100%"></stop>
        </linearGradient>
        <linearGradient x1="0.113880772%" y1="50.0008964%" x2="99.9860003%" y2="50.0008964%" id="linearGradient-3">
            <stop stop-color="#6E2B2F" offset="0%"></stop>
            <stop stop-color="#E30138" offset="100%"></stop>
        </linearGradient>
    </defs>
    <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
        <g id="jcb" sketch:type="MSLayerGroup">
            <rect id="Rectangle-1" fill="#0E4C96" sketch:type="MSShapeGroup" x="0" y="0" width="750" height="471" rx="40"></rect>
            <path d="M617.243183,346.766281 C617.243183,388.380887 583.514892,422.125974 541.88349,422.125974 L132.756823,422.125974 L132.756823,124.244916 C132.756823,82.6186826 166.489851,48.8744567 208.121683,48.8744567 L617.242752,48.874026 L617.242752,346.766281 L617.243183,346.766281 L617.243183,346.766281 Z" id="path3494" fill="#FFFFFF" sketch:type="MSShapeGroup"></path>
            <path d="M483.858874,242.044797 C495.542699,242.298285 507.296188,241.528806 518.936004,242.444883 C530.723244,244.645678 533.563915,262.487874 523.09234,268.332511 C515.950746,272.182115 507.459496,269.764696 499.713328,270.446208 L483.858874,270.446208 L483.858874,242.044797 L483.858874,242.044797 Z M525.691826,209.900487 C528.288491,219.064679 519.453903,227.292118 510.625917,226.030566 L483.858874,226.030566 C484.043758,217.388441 483.491345,208.008973 484.131053,199.821663 C494.854942,200.123386 505.679576,199.205849 516.340394,200.301853 C520.921799,201.451558 524.753935,205.217712 525.691826,209.900487 L525.691826,209.900487 Z M590.120412,73.9972254 C590.617872,91.498454 590.191471,109.92365 590.33359,127.780192 C590.299137,200.376358 590.405942,272.974174 590.278896,345.569303 C589.81042,372.776592 565.696524,396.413678 538.678749,396.956694 C511.63292,397.068451 484.584297,396.972628 457.537396,397.004497 L457.537396,287.253291 C487.007,287.099803 516.49604,287.561 545.953521,287.021594 C559.62072,286.162769 574.586027,277.145695 575.22328,262.107374 C576.833661,247.005483 562.592128,236.557185 549.071096,234.905684 C543.872773,234.770542 544.027132,233.390846 549.071096,232.788972 C561.96307,230.002483 572.090675,216.655787 568.296786,203.290229 C565.06052,189.232374 549.523839,183.79142 536.600366,183.817768 C510.248548,183.638612 483.891299,183.792359 457.537396,183.74111 C457.708585,163.252408 457.182916,142.740653 457.82271,122.267364 C459.910361,95.5513766 484.628603,73.5195319 511.269759,73.997656 C537.553166,73.9973692 563.837737,73.9982301 590.120412,73.9972254 L590.120412,73.9972254 Z" id="path3496" fill="url(#linearGradient-1)" sketch:type="MSShapeGroup"></path>
            <path d="M159.740429,125.040498 C160.413689,97.8766592 184.628619,74.4290299 211.614797,74.0325398 C238.559493,73.9499686 265.506204,74.0209119 292.451671,73.9972254 C292.37764,164.882488 292.599905,255.773672 292.340301,346.655222 C291.302298,373.488802 267.350548,396.488661 240.661356,396.962292 C213.665015,397.060957 186.666275,396.976074 159.669012,397.004497 L159.669012,283.550875 C185.891623,289.745491 213.391138,292.382518 240.142406,288.272242 C256.134509,285.697368 273.629935,277.848026 279.044261,261.257567 C283.030122,247.066267 280.785723,232.131602 281.378027,217.566465 L281.378027,183.741541 L235.081246,183.741541 C234.873106,206.112145 235.507258,228.522447 234.746146,250.867107 C233.49785,264.601214 219.900147,273.326996 206.946428,272.861801 C190.879747,273.030535 159.04755,261.221796 159.04755,261.221796 C158.967492,219.3048 159.514314,166.814385 159.740429,125.040498 L159.740429,125.040498 Z" id="path3498" fill="url(#linearGradient-2)" sketch:type="MSShapeGroup"></path>
            <path d="M309.719995,197.390136 C307.285788,197.90738 309.229141,189.089459 308.606298,185.743964 C308.772233,164.593637 308.260045,143.420951 308.889718,122.285827 C310.972541,95.4570827 335.881262,73.3701105 362.628748,73.997656 L441.39456,73.997656 C441.320658,164.882346 441.542493,255.77294 441.283406,346.653934 C440.244412,373.488027 416.291344,396.487102 389.602087,396.962292 C362.604605,397.061991 335.604707,396.976504 308.606298,397.004928 L308.606298,272.707624 C327.04641,287.835846 352.105738,290.192248 375.077953,290.233484 C392.39501,290.227455 409.611861,287.557865 426.428143,283.562934 L426.428143,260.790297 C407.474658,270.236609 385.194808,276.235815 364.184745,270.807966 C349.529051,267.157367 338.89089,252.996683 339.128513,237.872204 C337.43001,222.143684 346.652631,205.536885 362.110237,200.860855 C381.300923,194.852545 402.217787,199.448454 420.206344,207.258795 C424.060526,209.27695 427.97066,211.780342 426.428143,205.338044 L426.428143,187.438358 C396.343581,180.280951 364.326644,177.646405 334.099438,185.433619 C325.351193,187.901774 316.82819,191.644647 309.719995,197.390136 L309.719995,197.390136 Z" id="path3500" fill="url(#linearGradient-3)" sketch:type="MSShapeGroup"></path>
        </g>
    </g>
</svg>PK     [1]D8n0  n0    Orders/CardIcons/mastercard.svgnu         <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg viewBox="0 0 750 471" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid meet">
    <!-- Generator: Sketch 39.1 (31720) - http://www.bohemiancoding.com/sketch -->
    <title>Slice 1</title>
    <desc>Created with Sketch.</desc>
    <defs></defs>
    <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
        <g id="mastercard">
            <rect id="Rectangle-1" fill="#F4F4F4" x="0" y="0" width="750" height="471" rx="40"></rect>
            <g id="mark" transform="translate(125.719997, 41.850862)">
                <g id="text" transform="translate(25.142679, 328.360022)" fill="#000000">
                    <path d="M467.715561,51.9326899 C466.502604,51.9623585 465.503405,52.3648948 464.717962,53.1403001 C463.932516,53.9157321 463.526027,54.8861098 463.498494,56.0514362 C463.526027,57.2079497 463.932516,58.1758036 464.717963,58.9550005 C465.503406,59.7342125 466.502604,60.1392726 467.715561,60.1701825 C468.900764,60.1392726 469.887352,59.7342123 470.675326,58.9550002 C471.463285,58.175803 471.872297,57.2079493 471.902362,56.0514362 C471.872927,54.8861098 471.465177,53.915732 470.679109,53.1402998 C469.893026,52.3648943 468.905178,51.9623581 467.715561,51.9326899 L467.715561,51.9326899 L467.715561,51.9326899 Z M467.715561,59.2616355 C466.791392,59.2389292 466.029277,58.9259854 465.429214,58.3228033 C464.829145,57.7196374 464.518499,56.9625159 464.497273,56.0514362 C464.518499,55.1363804 464.829146,54.379679 465.429214,53.78133 C466.029277,53.1830062 466.791392,52.8730071 467.715561,52.8513318 C468.620383,52.8730076 469.370728,53.1830066 469.966597,53.7813302 C470.562452,54.379679 470.871417,55.1363804 470.893494,56.0514362 C470.871417,56.9625161 470.562452,57.7196378 469.966597,58.3228033 C469.370728,58.925985 468.620384,59.2389287 467.715561,59.2616355 L467.715561,59.2616355 L467.715561,59.2616355 Z M467.957689,54.1232975 L466.19217,54.1232975 L466.19217,57.9492899 L467.009353,57.9492899 L467.009353,56.5158046 L467.382634,56.5158046 L468.542832,57.9492899 L469.521434,57.9492899 L468.270438,56.5057097 C468.661158,56.4567169 468.961716,56.330109 469.172113,56.1258861 C469.382498,55.9216836 469.488849,55.6613181 469.491168,55.3447885 C469.488429,54.9670796 469.355174,54.6701195 469.091404,54.4539073 C468.827621,54.237719 468.449717,54.1275158 467.957689,54.1232975 L467.957689,54.1232975 L467.957689,54.1232975 Z M467.9476,54.8400402 C468.166813,54.840262 468.338741,54.8827453 468.463383,54.9674885 C468.588015,55.0522552 468.651489,55.1780218 468.653808,55.3447882 C468.651483,55.5164129 468.588015,55.6451235 468.463383,55.73092 C468.338741,55.8167385 468.166813,55.8596412 467.9476,55.859631 L467.009353,55.859631 L467.009353,54.8400393 L467.9476,54.8400402 L467.9476,54.8400402 Z" id="path3078"></path>
                    <path d="M9.34331724,57.5428029 L0.588175916,57.5428029 L0.588175916,16.6600045 L9.17164757,16.6600045 L9.17164757,21.6415186 C9.17164757,21.6415186 16.7107355,15.5532485 21.1885083,15.6293508 C29.8949298,15.7773317 35.093729,23.1875098 35.093729,23.1875098 C35.093729,23.1875098 39.3109893,15.6293508 48.8272918,15.6293508 C62.8997988,15.6293508 64.9642149,28.5125858 64.9642149,28.5125858 L64.9642149,57.3710273 L56.5524108,57.3710273 L56.5524108,31.9481087 C56.5524108,31.9481087 56.5825922,24.2181741 47.4539323,24.2181741 C38.0139747,24.2181741 37.1537629,31.9481087 37.1537629,31.9481087 L37.1537629,57.3710273 L28.3986215,57.3710273 L28.3986215,31.7763331 C28.3986215,31.7763331 27.5575496,23.7028366 19.6434834,23.7028366 C9.3650113,23.7028366 9.17164757,31.9481087 9.17164757,31.9481087 L9.34331724,57.5428029 L9.34331724,57.5428029 Z" id="path3006"></path>
                    <path d="M275.596898,15.623773 C271.119122,15.5476814 263.580741,21.6355967 263.580741,21.6355967 L263.580741,16.6649268 L254.988182,16.6649268 L254.988182,57.5386115 L263.748565,57.5386115 L263.580741,31.9463783 C263.580741,31.9463783 263.77445,23.7179044 274.052923,23.7179044 C275.961824,23.7179044 277.444363,24.180569 278.61772,24.8934007 L278.61772,24.8598688 L281.470718,16.9000798 C279.749092,16.1750176 277.791223,15.6610664 275.596898,15.623773 L275.596898,15.623773 L275.596898,15.623773 Z" id="path3008"></path>
                    <path d="M398.92774,15.623773 C394.449964,15.5476814 386.911582,21.6355967 386.911582,21.6355967 L386.911582,16.6649268 L378.319023,16.6649268 L378.319023,57.5386115 L387.079406,57.5386115 L386.911582,31.9463783 C386.911582,31.9463783 387.105291,23.7179044 397.383764,23.7179044 C399.292666,23.7179044 400.775204,24.180569 401.948561,24.8934007 L401.948561,24.8598688 L404.801559,16.9000798 C403.079933,16.1750713 401.122064,15.6611201 398.92774,15.6238267 L398.92774,15.623773 L398.92774,15.623773 Z" id="path3013"></path>
                    <path d="M93.2735295,15.4558449 C80.1708646,15.4558449 73.2368626,27.2396859 73.2018479,37.0849763 C73.1658666,47.1762746 81.0955959,58.8148646 93.6427411,58.8148646 C100.962678,58.8148646 106.976041,53.4075817 106.976041,53.4075817 L106.960145,57.5721971 L115.577998,57.5721971 L115.577998,16.6488272 L106.929792,16.6488272 L106.929792,21.8035248 C106.929792,21.8035248 101.282654,15.4558449 93.2735725,15.4558449 L93.2735295,15.4558449 L93.2735295,15.4558449 Z M94.9517638,23.7850756 C101.991433,23.7850756 107.706344,29.9122942 107.706344,37.454418 C107.706344,44.9965418 101.991433,51.0901748 94.9517638,51.0901748 C87.9120947,51.0901748 82.2307482,44.9965418 82.2307482,37.454418 C82.2307482,29.9122942 87.9120947,23.7850756 94.9517638,23.7850756 L94.9517638,23.7850756 L94.9517638,23.7850756 Z" id="path3015"></path>
                    <path d="M344.597578,15.4558449 C331.494913,15.4558449 324.560911,27.2396859 324.525896,37.0849763 C324.489915,47.1762746 332.419644,58.8148646 344.966789,58.8148646 C352.286726,58.8148646 358.300089,53.4075817 358.300089,53.4075817 L358.284193,57.5721971 L366.902046,57.5721971 L366.902046,16.6488272 L358.25384,16.6488272 L358.25384,21.8035248 C358.25384,21.8035248 352.606702,15.4558449 344.59762,15.4558449 L344.597578,15.4558449 L344.597578,15.4558449 Z M346.275812,23.7850756 C353.315481,23.7850756 359.030392,29.9122942 359.030392,37.454418 C359.030392,44.9965418 353.315481,51.0901748 346.275812,51.0901748 C339.236143,51.0901748 333.554796,44.9965418 333.554796,37.454418 C333.554796,29.9122942 339.236143,23.7850756 346.275812,23.7850756 L346.275812,23.7850756 L346.275812,23.7850756 Z" id="path3020"></path>
                    <path d="M427.342249,15.4558449 C414.239584,15.4558449 407.305582,27.2396859 407.270567,37.0849763 C407.234586,47.1762746 415.164315,58.8148646 427.71146,58.8148646 C435.031397,58.8148646 441.04476,53.4075817 441.04476,53.4075817 L441.028864,57.5721971 L449.646718,57.5721971 L449.646718,0.49407462 L440.998511,0.49407462 L440.998511,21.8035248 C440.998511,21.8035248 435.351373,15.4558449 427.342292,15.4558449 L427.342249,15.4558449 L427.342249,15.4558449 Z M429.020483,23.7850756 C436.060152,23.7850756 441.775063,29.9122942 441.775063,37.454418 C441.775063,44.9965418 436.060152,51.0901748 429.020483,51.0901748 C421.980814,51.0901748 416.299467,44.9965418 416.299467,37.454418 C416.299467,29.9122942 421.980814,23.7850756 429.020483,23.7850756 L429.020483,23.7850756 L429.020483,23.7850756 Z" id="path3022"></path>
                    <path d="M141.872122,58.9170078 C132.94558,58.9170078 124.705176,53.4201669 124.705176,53.4201669 L128.481907,47.5797641 C128.481907,47.5797641 136.278978,51.1870733 141.872122,51.1870733 C145.50613,51.1870733 151.583937,50.0128667 151.657274,46.3773348 C151.734822,42.5349478 141.442945,41.39581 141.442945,41.39581 C141.442945,41.39581 126.078536,41.1860853 126.078536,28.5125858 C126.078536,20.5421246 133.751938,15.4575752 143.588818,15.4575752 C149.272667,15.4575752 159.89741,20.4390893 159.89741,20.4390893 L155.605674,27.1383702 C155.605674,27.1383702 147.402218,23.858921 143.073802,23.7028366 C139.418806,23.5710413 135.005346,25.3221465 135.005346,28.5125858 C135.005346,37.1806926 160.584084,27.837198 160.584084,45.3466704 C160.584084,56.8338188 150.166691,58.9170078 141.872122,58.9170078 L141.872122,58.9170078 L141.872122,58.9170078 Z" id="path3024"></path>
                    <path d="M174.802149,4.80920724 L174.802149,16.6985124 L167.182966,16.6985124 L167.182966,25.296428 L174.802149,25.296428 L174.802149,45.85082 C174.802149,45.85082 174.127827,59.7552616 189.067141,59.7552616 C193.19753,59.7552616 201.284686,56.6989713 201.284686,56.6989713 L197.827523,47.7651996 C197.827523,47.7651996 194.611468,50.5102454 190.980328,50.4184626 C184.076195,50.2440217 184.267391,45.8172343 184.267391,45.8172343 L184.267391,25.296428 L198.498817,25.296428 L198.498817,16.6985124 L184.267391,16.6985124 L184.267391,4.80920724 L174.802149,4.80920724 L174.802149,4.80920724 L174.802149,4.80920724 Z" id="path3026"></path>
                    <path d="M226.659588,15.959629 C212.610087,15.959629 205.590417,27.5389793 205.648095,37.5887604 C205.707384,47.9238419 212.040304,59.5537479 227.498705,59.5537479 C234.115072,59.5537479 243.408366,53.7434378 243.408366,53.7434378 L239.414168,46.791217 C239.414168,46.791217 233.072548,51.2916884 227.498705,51.2916884 C216.339172,51.2916884 215.616806,40.3763659 215.616806,40.3763659 L245.489376,40.3763659 C245.489376,40.3763659 247.717985,15.959629 226.659588,15.959629 L226.659588,15.959629 L226.659588,15.959629 Z M225.38413,23.9865893 C225.715416,23.9677813 226.070568,23.9865893 226.424635,23.9865893 C236.937954,23.9865893 236.863252,33.9279292 236.863252,33.9279292 L215.616806,33.9279292 C215.616806,33.9279292 215.114206,24.5707962 225.38413,23.9865893 L225.38413,23.9865893 L225.38413,23.9865893 Z" id="path3034"></path>
                    <path d="M315.5162,46.686 L319.52203,54.7026427 C319.52203,54.7026427 313.17302,58.8324258 306.047898,58.8324258 C291.296557,58.8324258 283.105442,47.7234997 283.105442,37.2117848 C283.105442,20.6912339 296.140698,15.8340672 304.955397,15.8340672 C312.956369,15.8340672 319.886193,20.4497077 319.886193,20.4497077 L315.394819,28.4663505 C315.394819,28.4663505 312.671859,24.2151004 304.712614,24.2151004 C296.766655,24.2151004 292.573755,31.0702185 292.573755,37.5761752 C292.573755,44.8669927 297.454114,51.0587064 304.834005,51.0587064 C310.623393,51.0587064 315.5162,46.686 315.5162,46.686 L315.5162,46.686 L315.5162,46.686 Z" id="path3037"></path>
                </g>
                <path d="M498.787985,236.781279 L498.787985,231.260623 L497.347484,231.260623 L495.690436,235.057252 L494.033388,231.260623 L492.592886,231.260623 L492.592886,236.781279 L493.609711,236.781279 L493.609711,232.617235 L495.163193,236.206603 L496.217678,236.206603 L497.771161,232.607814 L497.771161,236.781279 L498.787985,236.781279 L498.787985,236.781279 Z M489.664807,236.781279 L489.664807,232.202715 L491.510156,232.202715 L491.510156,231.270044 L486.812049,231.270044 L486.812049,232.202715 L488.657397,232.202715 L488.657397,236.781279 L489.664807,236.781279 L489.664807,236.781279 Z" id="path3057" fill="#F79F1A"></path>
                <path d="M499.076678,154.709802 C499.076678,240.135159 429.999707,309.386105 344.788929,309.386105 C259.578151,309.386105 190.501159,240.135159 190.501159,154.709802 C190.501159,69.2844326 259.578151,0.0334920174 344.788929,0.0334920174 C429.999707,0.0334920174 499.076678,69.2844326 499.076678,154.709802 L499.076678,154.709802 L499.076678,154.709802 Z" id="path2997" fill="#F79F1A"></path>
                <path d="M308.73932,154.709802 C308.73932,240.135159 239.662349,309.386105 154.451571,309.386105 C69.2407931,309.386105 0.163801275,240.135159 0.163801275,154.709802 C0.163801275,69.2844326 69.2407931,0.0334920174 154.451571,0.0334920174 C239.662349,0.0334920174 308.73932,69.2844326 308.73932,154.709802 L308.73932,154.709802 L308.73932,154.709802 Z" id="path2995" fill="#EA001B"></path>
                <path d="M249.620562,32.9474812 C213.621326,61.2636823 190.513152,105.265345 190.513152,154.695309 C190.513152,204.125274 213.621326,248.16052 249.620562,276.476723 C285.619799,248.16052 308.727973,204.125274 308.727973,154.695309 C308.727973,105.265345 285.619799,61.2636823 249.620562,32.9474812 L249.620562,32.9474812 L249.620562,32.9474812 Z" id="path2999" fill="#FF5F01"></path>
            </g>
        </g>
    </g>
</svg>PK     [1]B      Orders/CardIcons/diners.svgnu         <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg viewBox="0 0 750 471" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" preserveAspectRatio="xMidYMid meet">
    <!-- Generator: Sketch 3.3.2 (12043) - http://www.bohemiancoding.com/sketch -->
    <title>diners</title>
    <desc>Created with Sketch.</desc>
    <defs></defs>
    <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
        <g id="diners" sketch:type="MSLayerGroup">
            <rect id="rectangle" fill="#0079BE" sketch:type="MSShapeGroup" x="0" y="0" width="750" height="471" rx="40"></rect>
            <path d="M584.933911,237.947339 C584.933911,138.53154 501.952976,69.8140806 411.038924,69.8471464 L332.79674,69.8471464 C240.793699,69.8140806 165.066089,138.552041 165.066089,237.947339 C165.066089,328.877778 240.793699,403.587432 332.79674,403.150963 L411.038924,403.150963 C501.952976,403.586771 584.933911,328.857939 584.933911,237.947339 L584.933911,237.947339 Z" id="Shape-path" fill="#FFFFFF" sketch:type="MSShapeGroup"></path>
            <path d="M333.280302,83.9308394 C249.210378,83.9572921 181.085889,152.238282 181.066089,236.510581 C181.085889,320.768331 249.209719,389.042708 333.280302,389.069161 C417.370025,389.042708 485.508375,320.768331 485.520254,236.510581 C485.507715,152.238282 417.370025,83.9572921 333.280302,83.9308394 L333.280302,83.9308394 Z" id="Shape-path" fill="#0079BE" sketch:type="MSShapeGroup"></path>
            <path d="M237.066089,236.09774 C237.145288,194.917524 262.812421,159.801587 299.006443,145.847134 L299.006443,326.327183 C262.812421,312.380667 237.144628,277.283907 237.066089,236.09774 Z M368.066089,326.372814 L368.066089,145.847134 C404.273312,159.767859 429.980043,194.903637 430.046043,236.103692 C429.980043,277.316312 404.273312,312.425636 368.066089,326.372814 Z" id="Path" fill="#FFFFFF" sketch:type="MSShapeGroup"></path>
        </g>
    </g>
</svg>PK     [1]z:{  {    Orders/CardIcons/unknown.svgnu         <svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 750 471" preserveAspectRatio="xMidYMid meet"><defs><style>.cls-1{fill:#75787c;}</style></defs><title>credit-card</title><g id="Page-1"><g id="amex"><g id="Rectangle-1"><path class="cls-1" d="M711,40V431H41V40H711m0-40H41A40,40,0,0,0,1,40V431a40,40,0,0,0,40,40H711a40,40,0,0,0,40-40V40A40,40,0,0,0,711,0Z" transform="translate(-1)"/></g></g></g><rect class="cls-1" x="11" y="113" width="728" height="100.73"/><rect class="cls-1" x="45" y="354.08" width="93" height="32.92"/><rect class="cls-1" x="172" y="354.08" width="155.94" height="32.92"/></svg>PK     [1]d      Orders/CardIcons/visa.svgnu         <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg viewBox="0 0 750 471" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" preserveAspectRatio="xMidYMid meet">
    <!-- Generator: Sketch 3.3.1 (12005) - http://www.bohemiancoding.com/sketch -->
    <title>Slice 1</title>
    <desc>Created with Sketch.</desc>
    <defs></defs>
    <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
        <g id="visa" sketch:type="MSLayerGroup">
            <rect id="Rectangle-1" fill="#0E4595" sketch:type="MSShapeGroup" x="0" y="0" width="750" height="471" rx="40"></rect>
            <path d="M278.1975,334.2275 L311.5585,138.4655 L364.9175,138.4655 L331.5335,334.2275 L278.1975,334.2275 L278.1975,334.2275 Z" id="Shape" fill="#FFFFFF" sketch:type="MSShapeGroup"></path>
            <path d="M524.3075,142.6875 C513.7355,138.7215 497.1715,134.4655 476.4845,134.4655 C423.7605,134.4655 386.6205,161.0165 386.3045,199.0695 C386.0075,227.1985 412.8185,242.8905 433.0585,252.2545 C453.8275,261.8495 460.8105,267.9695 460.7115,276.5375 C460.5795,289.6595 444.1255,295.6545 428.7885,295.6545 C407.4315,295.6545 396.0855,292.6875 378.5625,285.3785 L371.6865,282.2665 L364.1975,326.0905 C376.6605,331.5545 399.7065,336.2895 423.6355,336.5345 C479.7245,336.5345 516.1365,310.2875 516.5505,269.6525 C516.7515,247.3835 502.5355,230.4355 471.7515,216.4645 C453.1005,207.4085 441.6785,201.3655 441.7995,192.1955 C441.7995,184.0585 451.4675,175.3575 472.3565,175.3575 C489.8055,175.0865 502.4445,178.8915 512.2925,182.8575 L517.0745,185.1165 L524.3075,142.6875" id="path13" fill="#FFFFFF" sketch:type="MSShapeGroup"></path>
            <path d="M661.6145,138.4655 L620.3835,138.4655 C607.6105,138.4655 598.0525,141.9515 592.4425,154.6995 L513.1975,334.1025 L569.2285,334.1025 C569.2285,334.1025 578.3905,309.9805 580.4625,304.6845 C586.5855,304.6845 641.0165,304.7685 648.7985,304.7685 C650.3945,311.6215 655.2905,334.1025 655.2905,334.1025 L704.8025,334.1025 L661.6145,138.4655 L661.6145,138.4655 Z M596.1975,264.8725 C600.6105,253.5935 617.4565,210.1495 617.4565,210.1495 C617.1415,210.6705 621.8365,198.8155 624.5315,191.4655 L628.1385,208.3435 C628.1385,208.3435 638.3555,255.0725 640.4905,264.8715 L596.1975,264.8715 L596.1975,264.8725 L596.1975,264.8725 Z" id="Path" fill="#FFFFFF" sketch:type="MSShapeGroup"></path>
            <path d="M232.9025,138.4655 L180.6625,271.9605 L175.0965,244.8315 C165.3715,213.5575 135.0715,179.6755 101.1975,162.7125 L148.9645,333.9155 L205.4195,333.8505 L289.4235,138.4655 L232.9025,138.4655" id="path16" fill="#FFFFFF" sketch:type="MSShapeGroup"></path>
            <path d="M131.9195,138.4655 L45.8785,138.4655 L45.1975,142.5385 C112.1365,158.7425 156.4295,197.9015 174.8155,244.9525 L156.1065,154.9925 C152.8765,142.5965 143.5085,138.8975 131.9195,138.4655" id="path18" fill="#F2AE14" sketch:type="MSShapeGroup"></path>
        </g>
    </g>
</svg>PK     [1]b}      Orders/CardIcons/interac.svgnu         <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 174.271 174.27402"><defs><clipPath id="a" transform="translate(-0.0004)"><rect width="174.271" height="174.27399" fill="none"/></clipPath></defs><title>Asset 1</title><g clip-path="url(#a)"><path d="M16.99622,2.96921h140.277a14.029,14.029,0,0,1,14.029,14.029v140.279a14.028,14.028,0,0,1-14.028,14.028H16.9942a14.026,14.026,0,0,1-14.026-14.026V16.99722A14.028,14.028,0,0,1,16.99622,2.96921Z" fill="#fdb913"/><path d="M157.2764,2.969a14.03026,14.03026,0,0,1,14.026,14.029V157.277a14.02878,14.02878,0,0,1-14.026,14.028H16.99739a14.02473,14.02473,0,0,1-14.028-14.028V16.998a14.02622,14.02622,0,0,1,14.028-14.029h140.279m0-2.969H16.99739A17.01543,17.01543,0,0,0,.0004,16.998V157.277a17.01584,17.01584,0,0,0,16.997,16.997h140.279a17.01477,17.01477,0,0,0,16.995-16.997V16.998A17.01436,17.01436,0,0,0,157.2764,0" transform="translate(-0.0004)" fill="#fff"/><path d="M96.95889,88.95432l-.014-30.139,7.867-1.867v3.951s2.038-5.196,6.767-6.402a4.52753,4.52753,0,0,1,2.188-.147v7.69a11.02253,11.02253,0,0,0-4.122.858c-2.907,1.148-4.37,3.653-4.37,7.477l.005,16.642Z" transform="translate(-0.0004)" fill="#231f20"/><path d="M55.4872,98.75491s-1.279-1.918-1.279-8.699v-15.007l-4.048.963v-6.182l4.057-.958v-6.739l8.365-1.976v6.739l5.911-1.404v6.175l-5.911,1.403s-.009,12.295,0,15.281c0,6.948,1.844,8.282,1.844,8.282Z" transform="translate(-0.0004)" fill="#231f20"/><path d="M69.3675,80.82492c0-5.358.763-9.267,2.401-12.267,1.949-3.56,5.145-5.88,9.801-6.94,9.177-2.087,12.489,3.345,12.361,10.629-.049,2.605-.037,3.874-.037,3.874l-16.168,3.8v.261c0,5.089,1.067,7.504,4.292,6.82,2.786-.588,3.561-2.333,3.781-4.491.036-.348.053-1.233.053-1.233l7.574-1.815s.018.62.007,1.316c-.066,2.892-.907,10.066-11.434,12.555-9.972,2.367-12.631-3.723-12.631-12.509m12.538-13.593c-2.679.608-4.084,3.236-4.145,7.59l8.133-1.937c.014-.197.016-.631.014-1.311-.014-3.287-1.03-5.022-4.002-4.342" transform="translate(-0.0004)" fill="#231f20"/><path d="M142.07961,63.2c-.313-9.179,2.068-16.464,12.309-18.784,6.527-1.483,9.006.215,10.261,1.856,1.207,1.566,1.673,3.678,1.673,6.636l.007.543-8.012,1.904s-.006-1.08-.006-1.115c.006-3.508-.969-4.842-3.517-4.204-3.03.761-4.254,3.637-4.254,9.355,0,2.03.009,2.394.009,2.668,0,5.811.794,8.471,4.286,7.74,3.029-.626,3.444-3.286,3.493-5.618.006-.366.021-1.538.021-1.538l8-1.89s.008.591.008,1.248c-.02,7.62-4.036,12.422-11.56,14.173-10.366,2.43-12.396-3.56-12.718-12.974" transform="translate(-0.0004)" fill="#231f20"/><path d="M114.38309,76.051c0-6.982,4.09-9.19,10.246-11.737,5.536-2.29,5.656-3.427,5.679-5.417.031-1.675-.746-3.108-3.502-2.405a4.15967,4.15967,0,0,0-3.336,3.943,14.36836,14.36836,0,0,0-.052,1.547l-7.762,1.833a15.44174,15.44174,0,0,1,.536-4.586c1.241-4.175,4.923-6.984,11.279-8.455,8.258-1.903,11.011,1.721,11.021,7.358V71.481c0,6.456,1.198,7.402,1.198,7.402l-7.62,1.803a16.55966,16.55966,0,0,1-1.021-2.737s-1.669,4.204-7.423,5.556c-6.043,1.425-9.243-2.32-9.243-7.454m15.872-9.534a28.771,28.771,0,0,1-4.054,2.374c-2.54,1.241-3.688,2.772-3.688,5.13,0,2.042,1.265,3.383,3.564,2.815,2.466-.622,4.178-2.923,4.178-6.12Z" transform="translate(-0.0004)" fill="#231f20"/><path d="M11.749,119.88239a4.83715,4.83715,0,0,1-4.053-7.468l.054-.07.091-.024,11.378-2.686v8.641l-.177.041c-2.581.621-5.666,1.328-6.301,1.463a4.75831,4.75831,0,0,1-.992.103" transform="translate(-0.0004)" fill="#231f20"/><path d="M11.749,130.93631a4.82982,4.82982,0,0,1-4.831-4.825,4.77892,4.77892,0,0,1,.778-2.627l.054-.081.091-.023,11.378-2.685v8.641l-.177.046c-2.581.619-5.666,1.334-6.301,1.461a5.11572,5.11572,0,0,1-.992.093" transform="translate(-0.0004)" fill="#231f20"/><path d="M11.749,142.01151a4.83388,4.83388,0,0,1-4.831-4.829,4.76191,4.76191,0,0,1,.778-2.627l.054-.087,11.469-2.703v8.641l-.177.046c-2.726.656-5.753,1.34-6.301,1.461a5.04086,5.04086,0,0,1-.992.098" transform="translate(-0.0004)" fill="#231f20"/><polygon points="10.406 109.294 10.403 60.945 19.218 58.862 19.218 107.21 10.406 109.294" fill="#231f20"/><path d="M32.3625,105.18069a5.4385,5.4385,0,1,0-10.877,0l.009,45.189a14.74433,14.74433,0,0,0,14.716,14.726c4.117,0,15.395-.02,15.395-.02l.005-18.852c.003-10.3.005-20.784.005-21.023a7.11906,7.11906,0,0,0-3.163-5.927l-13.721-9.319s-.003,20.232-.003,21.162a1.17,1.17,0,1,1-2.34,0c0-.227-.026-23.141-.026-25.936" transform="translate(-0.0004)" fill="#231f20"/><path d="M39.7075,71.4653a11.89208,11.89208,0,0,0-7.846,6.066v-3.398l-7.937,1.878.009,21.7a8.12,8.12,0,0,1,8.357,1.461v-14.011c0-3.356,1.67-6.037,4.056-6.563,1.795-.394,3.294.248,3.294,3.445l.006,20.442,8.36-1.96v-21.625c0-5.243-2.019-8.908-8.299-7.435" transform="translate(-0.0004)" fill="#231f20"/><path d="M161.17329,40.52094a5.16751,5.16751,0,1,1,5.163-5.165,5.17,5.17,0,0,1-5.163,5.165m0-9.685a4.519,4.519,0,1,0,4.518,4.52,4.52365,4.52365,0,0,0-4.518-4.52" transform="translate(-0.0004)" fill="#231f20"/><path d="M159.2916,32.27661h2.227a1.45621,1.45621,0,0,1,1.601,1.621c0,.818-.363,1.447-1.051,1.554v.012c.626.064.955.409.987,1.296.012.4.018.896.037,1.282a.6492.6492,0,0,0,.299.545h-1.138a1.05074,1.05074,0,0,1-.17-.56c-.035-.377-.026-.733-.043-1.191-.017-.688-.228-.989-.919-.989h-.824v2.74h-1.006Zm1.812,2.81a.92762.92762,0,0,0,1.007-1.023c0-.673-.291-1.027-.954-1.027h-.859v2.05Z" transform="translate(-0.0004)" fill="#231f20"/></g></svg>PK     [1](  (    Orders/CardIcons/amex.svgnu         <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg viewBox="0 0 752 471" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" preserveAspectRatio="xMidYMid meet">
    <!-- Generator: Sketch 3.3.1 (12005) - http://www.bohemiancoding.com/sketch -->
    <title>Slice 1</title>
    <desc>Created with Sketch.</desc>
    <defs></defs>
    <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
        <g id="amex" sketch:type="MSLayerGroup">
            <rect id="Rectangle-1" fill="#2557D6" sketch:type="MSShapeGroup" x="1" y="0" width="750" height="471" rx="40"></rect>
            <path d="M1.002688,221.18508 L37.026849,221.18508 L45.149579,201.67506 L63.334596,201.67506 L71.436042,221.18508 L142.31637,221.18508 L142.31637,206.26909 L148.64322,221.24866 L185.43894,221.24866 L191.76579,206.04654 L191.76579,221.18508 L367.91701,221.18508 L367.83451,189.15941 L371.2427,189.15941 C373.62924,189.24161 374.3263,189.46144 374.3263,193.38516 L374.3263,221.18508 L465.43232,221.18508 L465.43232,213.72973 C472.78082,217.6508 484.21064,221.18508 499.25086,221.18508 L537.57908,221.18508 L545.78163,201.67506 L563.96664,201.67506 L571.98828,221.18508 L645.84844,221.18508 L645.84844,202.65269 L657.0335,221.18508 L716.22061,221.18508 L716.22061,98.67789 L657.64543,98.67789 L657.64543,113.14614 L649.44288,98.67789 L589.33787,98.67789 L589.33787,113.14614 L581.80579,98.67789 L500.61839,98.67789 C487.02818,98.67789 475.08221,100.5669 465.43232,105.83121 L465.43232,98.67789 L409.40596,98.67789 L409.40596,105.83121 C403.26536,100.40529 394.89786,98.67789 385.59383,98.67789 L180.90796,98.67789 L167.17407,130.3194 L153.07037,98.67789 L88.59937,98.67789 L88.59937,113.14614 L81.516924,98.67789 L26.533518,98.67789 L0.999997,156.92445 L0.999997,221.18508 L1.002597,221.18508 L1.002688,221.18508 Z M228.39922,203.51436 L206.78472,203.51436 L206.70492,134.72064 L176.13228,203.51436 L157.62,203.51436 L126.96754,134.6597 L126.96754,203.51436 L84.084427,203.51436 L75.982981,183.92222 L32.083524,183.92222 L23.8996,203.51436 L1.000047,203.51436 L38.756241,115.67692 L70.08183,115.67692 L105.94103,198.84086 L105.94103,115.67692 L140.35289,115.67692 L167.94569,175.26406 L193.29297,115.67692 L228.39657,115.67692 L228.39657,203.51436 L228.39957,203.51436 L228.39922,203.51436 Z M68.777214,165.69287 L54.346265,130.67606 L39.997794,165.69287 L68.777214,165.69287 L68.777214,165.69287 Z M314.41947,203.51436 L243.98611,203.51436 L243.98611,115.67692 L314.41947,115.67692 L314.41947,133.96821 L265.07116,133.96821 L265.07116,149.8009 L313.23551,149.8009 L313.23551,167.80606 L265.07116,167.80606 L265.07116,185.34759 L314.41947,185.34759 L314.41947,203.51436 L314.41947,203.51436 Z M413.67528,139.33321 C413.67528,153.33782 404.28877,160.57326 398.81863,162.74575 C403.43206,164.49434 407.37237,167.58351 409.24808,170.14281 C412.22525,174.51164 412.73875,178.41416 412.73875,186.25897 L412.73875,203.51436 L391.47278,203.51436 L391.39298,192.43732 C391.39298,187.1518 391.90115,179.55074 388.0646,175.32499 C384.98366,172.23581 380.28774,171.56552 372.69714,171.56552 L350.06363,171.56552 L350.06363,203.51436 L328.98125,203.51436 L328.98125,115.67692 L377.47552,115.67692 C388.25084,115.67692 396.18999,115.9604 403.00639,119.88413 C409.67644,123.80786 413.67529,129.53581 413.67529,139.33321 L413.67528,139.33321 Z M387.02277,152.37632 C384.1254,154.12756 380.69859,154.18584 376.59333,154.18584 L350.97998,154.18584 L350.97998,134.67583 L376.94186,134.67583 C380.61611,134.67583 384.44999,134.8401 386.94029,136.26016 C389.67536,137.53981 391.36749,140.26337 391.36749,144.02548 C391.36749,147.86443 389.75784,150.95361 387.02277,152.37632 L387.02277,152.37632 Z M447.48908,203.51436 L425.97569,203.51436 L425.97569,115.67692 L447.48908,115.67692 L447.48908,203.51436 L447.48908,203.51436 Z M697.22856,203.51436 L667.35032,203.51436 L627.38585,137.58727 L627.38585,203.51436 L584.44687,203.51436 L576.24166,183.92222 L532.44331,183.92222 L524.48287,203.51436 L499.81137,203.51436 C489.56284,203.51436 476.58722,201.25709 469.23872,193.79909 C461.82903,186.3411 457.97386,176.23903 457.97386,160.26593 C457.97386,147.23895 460.27791,135.33 469.33983,125.91941 C476.15621,118.90916 486.83044,115.67692 501.35982,115.67692 L521.77174,115.67692 L521.77174,134.49809 L501.78818,134.49809 C494.0938,134.49809 489.74909,135.63733 485.564,139.70147 C481.96957,143.4 479.50322,150.39171 479.50322,159.59829 C479.50322,169.00887 481.38158,175.79393 485.30061,180.22633 C488.5465,183.70232 494.445,184.75677 499.99495,184.75677 L509.46393,184.75677 L539.17987,115.67957 L570.77152,115.67957 L606.46843,198.76138 L606.46843,115.67957 L638.5709,115.67957 L675.6327,176.85368 L675.6327,115.67957 L697.22856,115.67957 L697.22856,203.51436 L697.22856,203.51436 Z M569.07051,165.69287 L554.47993,130.67606 L539.96916,165.69287 L569.07051,165.69287 L569.07051,165.69287 Z" id="Path" fill="#FFFFFF" sketch:type="MSShapeGroup"></path>
            <path d="M750.95644,343.76716 C745.83485,351.22516 735.85504,355.00582 722.34464,355.00582 L681.62723,355.00582 L681.62723,336.1661 L722.17969,336.1661 C726.20248,336.1661 729.01736,335.63887 730.71215,333.99096 C732.18079,332.63183 733.2051,330.65804 733.2051,328.26036 C733.2051,325.70107 732.18079,323.66899 730.62967,322.45028 C729.09984,321.10969 726.87294,320.50033 723.20135,320.50033 C703.40402,319.83005 678.70592,321.10969 678.70592,293.30714 C678.70592,280.56363 686.83131,267.14983 708.95664,267.14983 L750.95379,267.14983 L750.95379,249.66925 L711.93382,249.66925 C700.15812,249.66925 691.60438,252.47759 685.54626,256.84375 L685.54626,249.66925 L627.83044,249.66925 C618.60091,249.66925 607.76706,251.94771 602.64279,256.84375 L602.64279,249.66925 L499.57751,249.66925 L499.57751,256.84375 C491.37496,250.95154 477.53466,249.66925 471.14663,249.66925 L403.16366,249.66925 L403.16366,256.84375 C396.67452,250.58593 382.24357,249.66925 373.44772,249.66925 L297.3633,249.66925 L279.95252,268.43213 L263.64586,249.66925 L149.99149,249.66925 L149.99149,372.26121 L261.50676,372.26121 L279.447,353.20159 L296.34697,372.26121 L365.08554,372.32211 L365.08554,343.48364 L371.84339,343.48364 C380.96384,343.62405 391.72054,343.25845 401.21079,339.17311 L401.21079,372.25852 L457.90762,372.25852 L457.90762,340.30704 L460.64268,340.30704 C464.13336,340.30704 464.47657,340.45011 464.47657,343.92344 L464.47657,372.25587 L636.71144,372.25587 C647.64639,372.25587 659.07621,369.46873 665.40571,364.41107 L665.40571,372.25587 L720.03792,372.25587 C731.40656,372.25587 742.50913,370.66889 750.95644,366.60475 L750.95644,343.76712 L750.95644,343.76716 Z M409.45301,296.61266 C409.45301,321.01872 391.16689,326.05784 372.7371,326.05784 L346.42935,326.05784 L346.42935,355.52685 L305.44855,355.52685 L279.48667,326.44199 L252.5058,355.52685 L168.9904,355.52685 L168.9904,267.66822 L253.79086,267.66822 L279.73144,296.46694 L306.55002,267.66822 L373.92106,267.66822 C390.6534,267.66822 409.45301,272.28078 409.45301,296.61266 L409.45301,296.61266 Z M241.82781,337.04655 L189.9892,337.04655 L189.9892,319.56596 L236.27785,319.56596 L236.27785,301.64028 L189.9892,301.64028 L189.9892,285.66718 L242.84947,285.66718 L265.91132,311.27077 L241.82781,337.04655 L241.82781,337.04655 Z M325.3545,347.10668 L292.9833,311.3189 L325.3545,276.6677 L325.3545,347.10668 L325.3545,347.10668 Z M373.2272,308.04117 L345.98027,308.04117 L345.98027,285.66718 L373.47197,285.66718 C381.08388,285.66718 386.36777,288.75636 386.36777,296.43956 C386.36777,304.03796 381.32865,308.04117 373.2272,308.04117 L373.2272,308.04117 Z M515.97053,267.66822 L586.34004,267.66822 L586.34004,285.83764 L536.96778,285.83764 L536.96778,301.81074 L585.1348,301.81074 L585.1348,319.73642 L536.96778,319.73642 L536.96778,337.21701 L586.34004,337.29641 L586.34004,355.52678 L515.97053,355.52678 L515.97053,267.66815 L515.97053,267.66822 Z M488.91724,314.6973 C493.61049,316.42205 497.44703,319.51387 499.24559,322.07317 C502.22276,326.36251 502.65378,330.36571 502.73891,338.10985 L502.73891,355.52685 L481.5714,355.52685 L481.5714,344.53458 C481.5714,339.24908 482.08223,331.42282 478.1632,327.33748 C475.08226,324.19002 470.38635,323.4376 462.69463,323.4376 L440.16223,323.4376 L440.16223,355.52685 L418.97609,355.52685 L418.97609,267.66822 L467.65393,267.66822 C478.32816,267.66822 486.10236,268.13716 493.02251,271.81449 C499.6766,275.8177 503.86168,281.30191 503.86168,291.3245 C503.85868,305.34765 494.46719,312.50362 488.91724,314.6973 L488.91724,314.6973 Z M476.99899,303.59022 C474.17879,305.25668 470.69077,305.39975 466.58817,305.39975 L440.97483,305.39975 L440.97483,285.66718 L466.9367,285.66718 C470.69077,285.66718 474.4475,285.74658 476.99899,287.25416 C479.7314,288.67687 481.36499,291.39779 481.36499,295.15725 C481.36499,298.91672 479.7314,301.94496 476.99899,303.59022 L476.99899,303.59022 Z M667.33539,309.1866 C671.44067,313.41766 673.64095,318.7588 673.64095,327.80112 C673.64095,346.70178 661.78278,355.5242 640.51948,355.5242 L599.45353,355.5242 L599.45353,336.68449 L640.35453,336.68449 C644.35337,336.68449 647.18954,336.15726 648.9668,334.50934 C650.41681,333.15021 651.45709,331.17643 651.45709,328.77875 C651.45709,326.21944 650.33167,324.18738 648.88433,322.96866 C647.27201,321.62807 645.04778,321.01872 641.37619,321.01872 C621.65868,320.34843 596.9659,321.62807 596.9659,293.82551 C596.9659,281.08201 605.00615,267.66822 627.11019,267.66822 L669.37872,267.66822 L669.37872,286.36752 L630.70196,286.36752 C626.86809,286.36752 624.37512,286.51059 622.25464,287.9545 C619.94527,289.37721 619.08856,291.48876 619.08856,294.2759 C619.08856,297.59028 621.04941,299.8449 623.702,300.81987 C625.92624,301.59084 628.31543,301.81603 631.9072,301.81603 L643.25722,302.12071 C654.703,302.39889 662.55967,304.37003 667.33539,309.1866 L667.33539,309.1866 Z M751,285.66718 L712.57335,285.66718 C708.7368,285.66718 706.18797,285.81025 704.04088,287.25416 C701.81665,288.67687 700.95995,290.78843 700.95995,293.57558 C700.95995,296.88994 702.83831,299.14456 705.57071,300.11953 C707.79495,300.8905 710.18415,301.1157 713.6961,301.1157 L725.12327,301.42038 C736.65419,301.70387 744.35123,303.67765 749.04448,308.49157 C749.89852,309.16186 750.41202,309.91428 751,310.6667 L751,285.66718 L751,285.66718 Z" id="path13" fill="#FFFFFF" sketch:type="MSShapeGroup"></path>
        </g>
    </g>
</svg>PK     [1]P  P    Orders/CardIcons/discover.svgnu         <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg viewBox="0 0 780 501" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" preserveAspectRatio="xMidYMid meet">
    <!-- Generator: Sketch 3.3.2 (12043) - http://www.bohemiancoding.com/sketch -->
    <title>discover</title>
    <desc>Created with Sketch.</desc>
    <defs></defs>
    <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
        <g id="discover" sketch:type="MSLayerGroup">
            <path d="M54.992188,0 C24.626565,0 -4.7369516e-15,24.629374 0,55.003906 L0,445.99609 C0,476.37636 24.618673,501 54.992188,501 L725.00781,501 C755.37344,501 780,476.37062 780,445.99609 L780,268.55664 L780,55.003906 C780,24.623637 755.38133,-4.7369516e-15 725.00781,0 L54.992188,0 L54.992188,0 Z" id="rectangle" fill="#4D4D4D" sketch:type="MSShapeGroup"></path>
            <path d="M415.13086,161.21289 C446.07103,161.21289 471.15234,184.79287 471.15234,213.92188 L471.15234,213.95508 C471.15234,243.08408 446.07103,266.69727 415.13086,266.69727 C384.19069,266.69727 359.10938,243.08408 359.10938,213.95508 L359.10938,213.92188 C359.10938,184.79287 384.19069,161.21289 415.13086,161.21289 L415.13086,161.21289 Z M327.15234,161.89258 C335.9889,161.89258 343.40028,163.67723 352.41992,167.98242 L352.41992,190.73438 C343.87628,182.87089 336.46483,179.58008 326.66406,179.58008 C307.4002,179.58008 292.25,194.59455 292.25,213.63086 C292.25,233.70517 306.93133,247.82617 327.61914,247.82617 C336.93171,247.82617 344.20582,244.70584 352.41992,236.96875 L352.41992,259.73242 C343.07888,263.87291 335.50876,265.50781 326.66406,265.50781 C295.38621,265.50781 271.08203,242.91198 271.08203,213.77148 C271.08203,184.94507 296.03316,161.89258 327.15234,161.89258 L327.15234,161.89258 Z M230.03906,162.51953 C241.58477,162.51953 252.14952,166.24004 260.98242,173.51367 L250.23438,186.76172 C244.88362,181.11594 239.82337,178.73438 233.66992,178.73438 C224.81668,178.73437 218.36914,183.47936 218.36914,189.72266 C218.36914,195.07734 221.98883,197.91138 234.31445,202.20508 C257.67927,210.24859 264.60352,217.3809 264.60352,233.13086 C264.60352,252.32421 249.62806,265.68359 228.2832,265.68359 C212.65323,265.68359 201.29008,259.88895 191.82617,246.8125 L205.09375,234.78125 C209.82489,243.39164 217.71615,248.00391 227.51367,248.00391 C236.67693,248.00391 243.46094,242.05155 243.46094,234.01953 C243.46094,229.85606 241.40612,226.28585 237.30273,223.76172 C235.2368,222.56668 231.1447,220.78491 223.10352,218.11523 C203.81198,211.57701 197.19336,204.58834 197.19336,190.92969 C197.19336,174.70478 211.40702,162.51953 230.03906,162.51953 L230.03906,162.51953 Z M464.76172,164.24805 L487.19922,164.24805 L515.2832,230.83984 L543.72852,164.24805 L565.99609,164.24805 L520.50195,265.93359 L509.44922,265.93359 L464.76172,164.24805 L464.76172,164.24805 Z M67.414062,164.40039 L97.564453,164.40039 C130.87609,164.40039 154.09766,184.78179 154.09766,214.04102 C154.09766,228.63041 146.99364,242.73654 134.98047,252.09766 C124.87172,259.99945 113.35396,263.54297 97.40625,263.54297 L67.414062,263.54297 L67.414062,164.40039 L67.414062,164.40039 Z M163.54883,164.40039 L184.08984,164.40039 L184.08984,263.54297 L163.54883,263.54297 L163.54883,164.40039 L163.54883,164.40039 Z M575.2832,164.40039 L633.53516,164.40039 L633.53516,181.19922 L595.80859,181.19922 L595.80859,203.20508 L632.14453,203.20508 L632.14453,219.99609 L595.80859,219.99609 L595.80859,246.75781 L633.53516,246.75781 L633.53516,263.54297 L575.2832,263.54297 L575.2832,164.40039 L575.2832,164.40039 Z M647.14062,164.40039 L677.5957,164.40039 C701.28599,164.40039 714.86133,175.11052 714.86133,193.67188 C714.86133,208.85113 706.34712,218.81273 690.875,221.77734 L724.02344,263.54297 L698.76367,263.54297 L670.33398,223.71484 L667.65625,223.71484 L667.65625,263.54297 L647.14062,263.54297 L647.14062,164.40039 L647.14062,164.40039 Z M667.65625,180.01562 L667.65625,210.04102 L673.6582,210.04102 C686.77472,210.04102 693.72656,204.67918 693.72656,194.71289 C693.72656,185.06451 686.77347,180.01562 673.98242,180.01562 L667.65625,180.01562 L667.65625,180.01562 Z M87.939453,181.19922 L87.939453,246.75781 L93.451172,246.75781 C106.72432,246.75781 115.10685,244.36382 121.56055,238.87891 C128.66438,232.92288 132.9375,223.41276 132.9375,213.89844 C132.9375,204.39943 128.66438,195.17283 121.56055,189.2168 C114.77608,183.43696 106.72432,181.19922 93.451172,181.19922 L87.939453,181.19922 L87.939453,181.19922 Z" fill="#FFFFFF" sketch:type="MSShapeGroup"></path>
            <path d="M779.981917,288.361069 C753.932037,306.691919 558.904907,437.700579 221.228007,500.98412 L724.989727,500.98412 C755.355357,500.98412 779.981917,476.35474 779.981917,445.980209 L779.981917,288.361069 L779.981917,288.361069 Z" id="Shape-9" fill="#F47216" sketch:type="MSShapeGroup"></path>
        </g>
    </g>
</svg>PK     [1]1K      Orders/IppFunctions.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Orders;

use WC_Order;
use WC_Gateway_COD;

/**
 * Class with methods for handling order In-Person Payments.
 */
class IppFunctions {

	/**
	 * Returns if order is eligible to accept In-Person Payments.
	 *
	 * @param WC_Order $order order that the conditions are checked for.
	 *
	 * @return bool true if order is eligible, false otherwise
	 */
	public static function is_order_in_person_payment_eligible( WC_Order $order ): bool {
		$has_status            = in_array( $order->get_status(), array( 'pending', 'on-hold', 'processing' ), true );
		$has_payment_method    = in_array( $order->get_payment_method(), array( WC_Gateway_COD::ID, 'woocommerce_payments', 'none' ), true );
		$order_is_not_paid     = null === $order->get_date_paid();
		$order_is_not_refunded = empty( $order->get_refunds() );

		$order_has_no_subscription_products = true;
		foreach ( $order->get_items() as $item ) {
			$product = $item->get_product();

			if ( is_object( $product ) && $product->is_type( 'subscription' ) ) {
				$order_has_no_subscription_products = false;
				break;
			}
		}

		return $has_status && $has_payment_method && $order_is_not_paid && $order_is_not_refunded && $order_has_no_subscription_products;
	}

	/**
	 * Returns if store is eligible to accept In-Person Payments.
	 *
	 * @return bool true if store is eligible, false otherwise
	 */
	public static function is_store_in_person_payment_eligible(): bool {
		$is_store_usa_based    = self::has_store_specified_country_currency( 'US', 'USD' );
		$is_store_canada_based = self::has_store_specified_country_currency( 'CA', 'CAD' );

		return $is_store_usa_based || $is_store_canada_based;
	}

	/**
	 * Checks if the store has specified country location and currency used.
	 *
	 * @param string $country country to compare store's country with.
	 * @param string $currency currency to compare store's currency with.
	 *
	 * @return bool true if specified country and currency match the store's ones. false otherwise
	 */
	public static function has_store_specified_country_currency( string $country, string $currency ): bool {
		return ( WC()->countries->get_base_country() === $country && get_woocommerce_currency() === $currency );
	}
}
PK     [1]x  x  +  Orders/OrderAttributionBlocksController.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Orders;

use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\RegisterHooksInterface;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
use Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema;
use Automattic\WooCommerce\Internal\Traits\ScriptDebug;
use WP_Error;

/**
 * Class OrderAttributionBlocksController
 *
 * @since 8.5.0
 */
class OrderAttributionBlocksController implements RegisterHooksInterface {

	use ScriptDebug;

	/**
	 * Instance of the features controller.
	 *
	 * @var FeaturesController
	 */
	private $features_controller;

	/**
	 * ExtendSchema instance.
	 *
	 * @var ExtendSchema
	 */
	private $extend_schema;

	/**
	 * Instance of the order attribution controller.
	 *
	 * @var OrderAttributionController
	 */
	private $order_attribution_controller;

	/**
	 * Bind dependencies on init.
	 *
	 * @internal
	 *
	 * @param ExtendSchema               $extend_schema                 ExtendSchema instance.
	 * @param FeaturesController         $features_controller           Features controller.
	 * @param OrderAttributionController $order_attribution_controller Instance of the order attribution controller.
	 */
	final public function init(
		ExtendSchema $extend_schema,
		FeaturesController $features_controller,
		OrderAttributionController $order_attribution_controller
	) {
		$this->extend_schema                = $extend_schema;
		$this->features_controller          = $features_controller;
		$this->order_attribution_controller = $order_attribution_controller;
	}

	/**
	 * Register this class instance to the appropriate hooks.
	 *
	 * @return void
	 */
	public function register() {
		add_action( 'init', array( $this, 'on_init' ) );
	}

	/**
	 * Hook into WordPress on init.
	 */
	public function on_init() {
		// Bail if the feature is not enabled.
		if ( ! $this->features_controller->feature_is_enabled( 'order_attribution' ) ) {
			return;
		}

		$this->extend_api();
	}

	/**
	 * Extend the Store API.
	 *
	 * @return void
	 */
	private function extend_api() {
		$this->extend_schema->register_endpoint_data(
			array(
				'endpoint'        => CheckoutSchema::IDENTIFIER,
				'namespace'       => 'woocommerce/order-attribution',
				'schema_callback' => $this->get_schema_callback(),
			)
		);
		// Update order based on extended data.
		add_action(
			'woocommerce_store_api_checkout_update_order_from_request',
			function ( $order, $request ) {
				$extensions = $request->get_param( 'extensions' );
				$params     = $extensions['woocommerce/order-attribution'] ?? array();

				if ( empty( $params ) ) {
					return;
				}

				// Check if this order already has any attribution data to prevent duplicates attribution data.
				if ( $this->order_attribution_controller->has_attribution( $order ) ) {
					return;
				}

				/**
				 * Run an action to save order attribution data.
				 *
				 * @since 8.5.0
				 *
				 * @param WC_Order $order  The order object.
				 * @param array    $params Unprefixed order attribution data.
				 */
				do_action( 'woocommerce_order_save_attribution_data', $order, $params );
			},
			10,
			2
		);
	}

	/**
	 * Get the schema callback.
	 *
	 * @return callable
	 */
	private function get_schema_callback() {
		return function() {
			$schema      = array();
			$field_names = $this->order_attribution_controller->get_field_names();

			$validate_callback = function( $value ) {
				if ( ! is_string( $value ) && null !== $value ) {
					return new WP_Error(
						'api-error',
						sprintf(
							/* translators: %s is the property type */
							esc_html__( 'Value of type %s was posted to the order attribution callback', 'woocommerce' ),
							gettype( $value )
						)
					);
				}

				return true;
			};

			$sanitize_callback = function( $value ) {
				return sanitize_text_field( $value );
			};

			foreach ( $field_names as $field_name ) {
				$schema[ $field_name ] = array(
					'description' => sprintf(
						/* translators: %s is the field name */
						__( 'Order attribution field: %s', 'woocommerce' ),
						esc_html( $field_name )
					),
					'type'        => array( 'string', 'null' ),
					'context'     => array(),
					'arg_options' => array(
						'validate_callback' => $validate_callback,
						'sanitize_callback' => $sanitize_callback,
					),
				);
			}

			return $schema;
		};
	}
}
PK     [1]+    !  Orders/MobileMessagingHandler.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Orders;

use DateTime;
use Exception;
use WC_Order;
use WC_Tracker;

/**
 * Prepares formatted mobile deep link navigation link for order mails.
 */
class MobileMessagingHandler {

	private const OPEN_ORDER_INTERVAL_DAYS = 30;

	/**
	 * Prepares mobile messaging with a deep link.
	 *
	 * @param WC_Order $order order that mobile message is created for.
	 * @param ?int     $blog_id  of blog to make a deep link for (will be null if Jetpack is not enabled).
	 * @param DateTime $now      current DateTime.
	 * @param string   $domain URL of the current site.
	 *
	 * @return ?string
	 */
	public static function prepare_mobile_message(
		WC_Order $order,
		?int $blog_id,
		DateTime $now,
		string $domain
	): ?string {
		try {
			$last_mobile_used = self::get_closer_mobile_usage_date();

			$used_app_in_last_month = null !== $last_mobile_used && $last_mobile_used->diff( $now )->days <= self::OPEN_ORDER_INTERVAL_DAYS;
			$has_jetpack            = null !== $blog_id;

			if ( IppFunctions::is_store_in_person_payment_eligible() && IppFunctions::is_order_in_person_payment_eligible( $order ) ) {
				return self::accept_payment_message( $blog_id, $domain );
			} else {
				if ( $used_app_in_last_month && $has_jetpack ) {
					return self::manage_order_message( $blog_id, $order->get_id(), $domain );
				} else {
					return self::no_app_message( $blog_id, $domain );
				}
			}
		} catch ( Exception $e ) {
			return null;
		}
	}

	/**
	 * Returns the closest date of last usage of any mobile app platform.
	 *
	 * @return ?DateTime
	 */
	private static function get_closer_mobile_usage_date(): ?DateTime {
		$mobile_usage = WC_Tracker::get_woocommerce_mobile_usage();

		if ( ! $mobile_usage ) {
			return null;
		}

		$last_ios_used     = self::get_last_used_or_null( 'ios', $mobile_usage );
		$last_android_used = self::get_last_used_or_null( 'android', $mobile_usage );

		return max( $last_android_used, $last_ios_used );
	}

	/**
	 * Returns last used date of specified mobile app platform.
	 *
	 * @param string $platform     mobile platform to check.
	 * @param array  $mobile_usage mobile apps usage data.
	 *
	 * @return ?DateTime last used date of specified mobile app
	 */
	private static function get_last_used_or_null(
		string $platform, array $mobile_usage
	): ?DateTime {
		try {
			if ( array_key_exists( $platform, $mobile_usage ) ) {
				return new DateTime( $mobile_usage[ $platform ]['last_used'] );
			} else {
				return null;
			}
		} catch ( Exception $e ) {
			return null;
		}
	}

	/**
	 * Prepares message with a deep link to mobile payment.
	 *
	 * @param ?int   $blog_id blog id to deep link to.
	 * @param string $domain URL of the current site.
	 *
	 * @return string formatted message
	 */
	private static function accept_payment_message( ?int $blog_id, $domain ): string {
		$deep_link_url = add_query_arg(
			array_merge(
				array(
					'blog_id' => absint( $blog_id ),
				),
				self::prepare_utm_parameters( 'deeplinks_payments', $blog_id, $domain )
			),
			'https://woocommerce.com/mobile/payments'
		);

		return sprintf(
			/* translators: 1: opening link tag 2: closing link tag. */
			esc_html__(
				'%1$sCollect payments easily%2$s from your customers anywhere with our mobile app.',
				'woocommerce'
			),
			'<a href="' . esc_url( $deep_link_url ) . '">',
			'</a>'
		);
	}

	/**
	 * Prepares message with a deep link to manage order details.
	 *
	 * @param int    $blog_id blog id to deep link to.
	 * @param int    $order_id order id to deep link to.
	 * @param string $domain URL of the current site.
	 *
	 * @return string formatted message
	 */
	private static function manage_order_message( int $blog_id, int $order_id, string $domain ): string {
		$deep_link_url = add_query_arg(
			array_merge(
				array(
					'blog_id'  => absint( $blog_id ),
					'order_id' => absint( $order_id ),
				),
				self::prepare_utm_parameters( 'deeplinks_orders_details', $blog_id, $domain )
			),
			'https://woocommerce.com/mobile/orders/details'
		);

		return sprintf(
			/* translators: 1: opening link tag 2: closing link tag. */
			esc_html__(
				'%1$sManage the order%2$s with the app.',
				'woocommerce'
			),
			'<a href="' . esc_url( $deep_link_url ) . '">',
			'</a>'
		);
	}

	/**
	 * Prepares message with a deep link to learn more about mobile app.
	 *
	 * @param ?int   $blog_id blog id used for tracking.
	 * @param string $domain URL of the current site.
	 *
	 * @return string formatted message
	 */
	private static function no_app_message( ?int $blog_id, string $domain ): string {
		$deep_link_url = add_query_arg(
			array_merge(
				array(
					'blog_id' => absint( $blog_id ),
				),
				self::prepare_utm_parameters( 'deeplinks_promote_app', $blog_id, $domain )
			),
			'https://woocommerce.com/mobile'
		);
		return sprintf(
			/* translators: 1: opening link tag 2: closing link tag. */
			esc_html__(
				'Process your orders on the go. %1$sGet the app%2$s.',
				'woocommerce'
			),
			'<a href="' . esc_url( $deep_link_url ) . '">',
			'</a>'
		);
	}

	/**
	 * Prepares array of parameters used by WooCommerce.com for tracking.
	 *
	 * @param string   $campaign name of the deep link campaign.
	 * @param int|null $blog_id blog id of the current site.
	 * @param string   $domain URL of the current site.
	 *
	 * @return array
	 */
	private static function prepare_utm_parameters(
		string $campaign,
		?int $blog_id,
		string $domain
	): array {
		return array(
			'utm_campaign' => $campaign,
			'utm_medium'   => 'email',
			'utm_source'   => $domain,
			'utm_term'     => absint( $blog_id ),
		);
	}
}
PK     [1]Uu35B  5B  %  Orders/OrderAttributionController.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Orders;

use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Integrations\WPConsentAPI;
use Automattic\WooCommerce\Internal\RegisterHooksInterface;
use Automattic\WooCommerce\Internal\Traits\ScriptDebug;
use Automattic\WooCommerce\Internal\Traits\OrderAttributionMeta;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\OrderUtil;
use Exception;
use WC_Customer;
use WC_Log_Levels;
use WC_Logger_Interface;
use WC_Order;

/**
 * Class OrderAttributionController
 *
 * @since 8.5.0
 */
class OrderAttributionController implements RegisterHooksInterface {

	use ScriptDebug;
	use OrderAttributionMeta {
		get_prefixed_field_name as public;
	}

	/**
	 * The WPConsentAPI integration instance.
	 *
	 * @var WPConsentAPI
	 */
	private $consent;

	/**
	 * The FeatureController instance.
	 *
	 * @var FeaturesController
	 */
	private $feature_controller;

	/**
	 * WooCommerce logger class instance.
	 *
	 * @var WC_Logger_Interface
	 */
	private $logger;

	/**
	 * The LegacyProxy instance.
	 *
	 * @var LegacyProxy
	 */
	private $proxy;

	/**
	 * Tracks whether stamp_html_element() has been called in single-output mode during the current request.
	 *
	 * When wc_order_attribution_allow_multiple_elements filter returns false,
	 * this flag prevents duplicate outputs across multiple action hooks within a single request.
	 *
	 * Note: This flag is reset at the start of each request in on_init() to ensure
	 * proper behavior in persistent PHP environments (PHP-FPM, OpCache).
	 *
	 * @var bool
	 */
	private static $is_stamp_html_called = false;

	/**
	 * Initialization method.
	 *
	 * Takes the place of the constructor within WooCommerce Dependency injection.
	 *
	 * @internal
	 *
	 * @param LegacyProxy        $proxy      The legacy proxy.
	 * @param FeaturesController $controller The feature controller.
	 * @param WPConsentAPI       $consent    The WPConsentAPI integration.
	 */
	final public function init( LegacyProxy $proxy, FeaturesController $controller, WPConsentAPI $consent ) {
		$this->proxy              = $proxy;
		$this->feature_controller = $controller;
		$this->consent            = $consent;
		$this->logger             = $proxy->call_function( 'wc_get_logger' );
		$this->set_fields_and_prefix();
	}

	/**
	 * Register this class instance to the appropriate hooks.
	 *
	 * @return void
	 */
	public function register() {
		// Don't run during install.
		if ( Constants::get_constant( 'WC_INSTALLING' ) ) {
			return;
		}

		add_action( 'init', array( $this, 'on_init' ) );
	}

	/**
	 * Hook into WordPress on init.
	 */
	public function on_init() {
		// Bail if the feature is not enabled.
		if ( ! $this->feature_controller->feature_is_enabled( 'order_attribution' ) ) {
			return;
		}

		// Reset the static flag at the start of each request to prevent issues in persistent PHP environments.
		self::$is_stamp_html_called = false;

		// Register WPConsentAPI integration.
		$this->consent->register();

		add_action(
			'wp_enqueue_scripts',
			function () {
				$this->enqueue_scripts_and_styles();
			}
		);

		add_action(
			'admin_enqueue_scripts',
			function () {
				$this->enqueue_admin_scripts_and_styles();
			}
		);

		/**
		 * Filter set of actions used to stamp the checkout order attribution HTML container element.
		 *
		 * @since 9.0.0
		 *
		 * @param array $stamp_checkout_html_actions The set of actions used to stamp the checkout order attribution HTML container element.
		 */
		$stamp_checkout_html_actions = apply_filters(
			'wc_order_attribution_stamp_checkout_html_actions',
			array(
				'woocommerce_checkout_billing',
				'woocommerce_after_checkout_billing_form',
				'woocommerce_checkout_shipping',
				'woocommerce_after_order_notes',
				'woocommerce_checkout_after_customer_details',
			)
		);
		foreach ( $stamp_checkout_html_actions as $action ) {
			add_action( $action, array( $this, 'stamp_html_element' ) );
		}

		add_action( 'woocommerce_register_form', array( $this, 'stamp_html_element' ) );

		// Update order based on submitted fields.
		add_action(
			'woocommerce_checkout_order_created',
			function ( $order ) {

				// Check if this order already has any attribution data to prevent duplicates attribution data.
				if ( $this->has_attribution( $order ) ) {
					return;
				}

				// Nonce check is handled by WooCommerce before woocommerce_checkout_order_created hook.
				// phpcs:ignore WordPress.Security.NonceVerification
				$params = $this->get_unprefixed_field_values( $_POST );
				/**
				 * Run an action to save order attribution data.
				 *
				 * @since 8.5.0
				 *
				 * @param WC_Order $order The order object.
				 * @param array    $params Unprefixed order attribution data.
				 */
				do_action( 'woocommerce_order_save_attribution_data', $order, $params );
			}
		);

		add_action(
			'woocommerce_order_save_attribution_data',
			function ( $order, $data ) {
				$source_data = $this->get_source_values( $data );
				$this->send_order_tracks( $source_data, $order );
				$this->set_order_source_data( $source_data, $order );
			},
			10,
			2
		);

		add_action(
			'user_register',
			function ( $customer_id ) {
				try {
					$customer = new WC_Customer( $customer_id );
					$this->set_customer_source_data( $customer );
				} catch ( Exception $e ) {
					$this->log( $e->getMessage(), __METHOD__, WC_Log_Levels::ERROR );
				}
			}
		);

		// Add origin data to the order table.
		add_action(
			'admin_init',
			function () {
				$this->register_order_origin_column();
			}
		);

		add_action(
			'woocommerce_new_order',
			function ( $order_id, $order ) {
				$this->maybe_set_admin_source( $order );
			},
			2,
			10
		);
	}

	/**
	 * If the order is created in the admin, set the source type and origin to admin/Web admin.
	 * Only execute this if the order is created in the admin interface (or via ajax in the admin interface).
	 *
	 * @param WC_Order $order The recently created order object.
	 *
	 * @since 8.5.0
	 */
	private function maybe_set_admin_source( WC_Order $order ) {

		// For ajax requests, bail if the referer is not an admin page.
		$http_referer     = esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ?? '' ) );
		$referer_is_admin = 0 === strpos( $http_referer, get_admin_url() );
		if ( ! $referer_is_admin && wp_doing_ajax() ) {
			return;
		}

		// If not admin interface page, bail.
		if ( ! is_admin() ) {
			return;
		}

		$order->add_meta_data( $this->get_meta_prefixed_field_name( 'source_type' ), 'admin' );
		$order->save();
	}

	/**
	 * Get all of the field names.
	 *
	 * @return array
	 */
	public function get_field_names(): array {
		return $this->field_names;
	}

	/**
	 * Get the prefix for the fields.
	 *
	 * @return string
	 */
	public function get_prefix(): string {
		return $this->field_prefix;
	}

	/**
	 * Scripts & styles for custom source tracking and cart tracking.
	 */
	private function enqueue_scripts_and_styles() {
		wp_enqueue_script(
			'sourcebuster-js',
			plugins_url( "assets/js/sourcebuster/sourcebuster{$this->get_script_suffix()}.js", WC_PLUGIN_FILE ),
			array(),
			Constants::get_constant( 'WC_VERSION' ),
			true
		);

		wp_enqueue_script(
			'wc-order-attribution',
			plugins_url( "assets/js/frontend/order-attribution{$this->get_script_suffix()}.js", WC_PLUGIN_FILE ),
			// Technically we do depend on 'wp-data', 'wc-blocks-checkout' for blocks checkout,
			// but as implementing conditional dependency on the server-side would be too complex,
			// we resolve this condition at the client-side.
			array( 'sourcebuster-js' ),
			Constants::get_constant( 'WC_VERSION' ),
			true
		);

		/**
		 * Filter the lifetime of the cookie used for source tracking.
		 *
		 * @since 8.5.0
		 *
		 * @param float $lifetime The lifetime of the Sourcebuster cookies in months.
		 *
		 * The default value forces Sourcebuster into making the cookies valid for the current session only.
		 */
		$lifetime = (float) apply_filters( 'wc_order_attribution_cookie_lifetime_months', 0.00001 );

		/**
		 * Filter the session length for source tracking.
		 *
		 * @since 8.5.0
		 *
		 * @param int $session_length The session length in minutes.
		 */
		$session_length = (int) apply_filters( 'wc_order_attribution_session_length_minutes', 30 );

		/**
		 * Filter to enable base64 encoding for cookie values.
		 *
		 * @since 9.0.0
		 *
		 * @param bool $use_base64_cookies True to enable base64 encoding, default is false.
		 */
		$use_base64_cookies = apply_filters( 'wc_order_attribution_use_base64_cookies', false );

		/**
		 * Filter to allow tracking.
		 *
		 * @since 8.5.0
		 *
		 * @param bool $allow_tracking True to allow tracking, false to disable.
		 */
		$allow_tracking = wc_bool_to_string( apply_filters( 'wc_order_attribution_allow_tracking', true ) );

		// Create Order Attribution JS namespace with parameters.
		$namespace = array(
			'params' => array(
				'lifetime'      => $lifetime,
				'session'       => $session_length,
				'base64'        => $use_base64_cookies,
				'ajaxurl'       => admin_url( 'admin-ajax.php' ),
				'prefix'        => $this->field_prefix,
				'allowTracking' => 'yes' === $allow_tracking,
			),
			'fields' => $this->fields,
		);

		wp_localize_script( 'wc-order-attribution', 'wc_order_attribution', $namespace );
	}

	/**
	 * Enqueue the stylesheet for admin pages.
	 *
	 * @return void
	 */
	private function enqueue_admin_scripts_and_styles() {
		$screen = get_current_screen();
		if ( $screen->id !== $this->get_order_screen_id() ) {
			return;
		}

		// phpcs:ignore WordPress.WP.EnqueuedResourceParameters.NotInFooter
		wp_enqueue_script(
			'woocommerce-order-attribution-admin-js',
			plugins_url( "assets/js/admin/order-attribution-admin{$this->get_script_suffix()}.js", WC_PLUGIN_FILE ),
			array( 'jquery' ),
			Constants::get_constant( 'WC_VERSION' )
		);
	}

	/**
	 * Display the origin column in the orders table.
	 *
	 * @param int $order_id The order ID.
	 *
	 * @return void
	 */
	private function display_origin_column( $order_id ): void {
		try {
			// Ensure we've got a valid order.
			$order = $this->get_hpos_order_object( $order_id );
			$this->output_origin_column( $order );
		} catch ( Exception $e ) {
			return;
		}
	}

	/**
	 * Output the translated origin label for the Origin column in the orders table.
	 *
	 * Default to "Unknown" if no origin is set.
	 *
	 * @param WC_Order $order The order object.
	 *
	 * @return void
	 */
	private function output_origin_column( WC_Order $order ) {
		$source_type = $order->get_meta( $this->get_meta_prefixed_field_name( 'source_type' ) );
		$source      = $order->get_meta( $this->get_meta_prefixed_field_name( 'utm_source' ) );
		$origin      = $this->get_origin_label( $source_type, $source );
		echo esc_html( $origin );
	}

	/**
	 * Handles the `<wc-order-attribution-inputs>` element for checkout forms.
	 *
	 * @since 9.0.0
	 * @deprecated 10.5.0 Use stamp_html_element() instead.
	 *
	 * @return void
	 */
	public function stamp_checkout_html_element_once() {
		wc_deprecated_function( __METHOD__, '10.5.0', 'stamp_html_element' );
		$this->stamp_html_element();
	}

	/**
	 * Output `<wc-order-attribution-inputs>` element that contributes the order attribution values to the enclosing form.
	 *
	 * Used for customer register forms and checkout forms.
	 *
	 * Note: By default, this method may output multiple instances of the element when called
	 * multiple times (e.g., during checkout form pre-generation and actual rendering).
	 * The JavaScript layer will remove duplicate elements and ensure only one set of data is submitted.
	 *
	 * @return void
	 */
	public function stamp_html_element() {
		/**
		 * Filter to allow sites to opt back into single-output behavior.
		 *
		 * @since 10.5.0
		 *
		 * @param bool $allow_multiple_elements True to allow multiple elements (new behavior), false for single element (old behavior).
		 */
		$allow_multiple = apply_filters( 'wc_order_attribution_allow_multiple_elements', true );

		// If single-output mode is enabled, use the static flag to prevent multiple outputs.
		if ( ! $allow_multiple && self::$is_stamp_html_called ) {
			return;
		}

		printf( '<wc-order-attribution-inputs></wc-order-attribution-inputs>' );

		if ( ! $allow_multiple ) {
			self::$is_stamp_html_called = true;
		}
	}

	/**
	 * Save source data for a Customer object.
	 *
	 * @param WC_Customer $customer The customer object.
	 *
	 * @return void
	 */
	private function set_customer_source_data( WC_Customer $customer ) {
		// Nonce check is handled before user_register hook.
		// phpcs:ignore WordPress.Security.NonceVerification
		foreach ( $this->get_source_values( $this->get_unprefixed_field_values( $_POST ) ) as $key => $value ) {
			$customer->add_meta_data( $this->get_meta_prefixed_field_name( $key ), $value );
		}

		$customer->save_meta_data();
	}

	/**
	 * Save source data for an Order object.
	 *
	 * @param array    $source_data The source data.
	 * @param WC_Order $order       The order object.
	 *
	 * @return void
	 */
	private function set_order_source_data( array $source_data, WC_Order $order ) {
		// If all the values are empty, bail.
		if ( empty( array_filter( $source_data ) ) ) {
			return;
		}
		foreach ( $source_data as $key => $value ) {
			$order->add_meta_data( $this->get_meta_prefixed_field_name( $key ), $value );
		}

		$order->save_meta_data();
	}

	/**
	 * Log a message as a debug log entry.
	 *
	 * @param string $message The message to log.
	 * @param string $method  The method that is logging the message.
	 * @param string $level   The log level.
	 */
	private function log( string $message, string $method, string $level = WC_Log_Levels::DEBUG ) {
		/**
		 * Filter to enable debug mode.
		 *
		 * @since 8.5.0
		 *
		 * @param string $enabled 'yes' to enable debug mode, 'no' to disable.
		 */
		if ( 'yes' !== apply_filters( 'wc_order_attribution_debug_mode_enabled', 'no' ) ) {
			return;
		}

		$this->logger->log(
			$level,
			sprintf( '%s %s', $method, $message ),
			array( 'source' => 'woocommerce-order-attribution' )
		);
	}

	/**
	 * Send order source data to Tracks.
	 *
	 * @param array    $source_data The source data.
	 * @param WC_Order $order       The order object.
	 *
	 * @return void
	 */
	private function send_order_tracks( array $source_data, WC_Order $order ) {
		$origin_label = $this->get_origin_label(
			$source_data['source_type'] ?? '',
			$source_data['utm_source'] ?? '',
			false
		);

		$tracks_data = array(
			'order_id'            => $order->get_id(),
			'source_type'         => $source_data['source_type'] ?? '',
			'medium'              => $source_data['utm_medium'] ?? '',
			'source'              => $source_data['utm_source'] ?? '',
			'device_type'         => strtolower( $source_data['device_type'] ?? 'unknown' ),
			'origin_label'        => strtolower( $origin_label ),
			'session_pages'       => $source_data['session_pages'] ?? 0,
			'session_count'       => $source_data['session_count'] ?? 0,
			'order_total'         => $order->get_total(),
			'customer_registered' => $order->get_customer_id() ? 'yes' : 'no',
		);

		if ( function_exists( 'wc_admin_record_tracks_event' ) ) {
			wc_admin_record_tracks_event( 'order_attribution', $tracks_data );
		}
	}

	/**
	 * Get the screen ID for the orders page.
	 *
	 * @return string
	 */
	private function get_order_screen_id(): string {
		return OrderUtil::custom_orders_table_usage_is_enabled() ? wc_get_page_screen_id( 'shop-order' ) : 'shop_order';
	}

	/**
	 * Register the origin column in the orders table.
	 *
	 * This accounts for the differences in hooks based on whether HPOS is enabled or not.
	 *
	 * @return void
	 */
	private function register_order_origin_column() {
		$screen_id = $this->get_order_screen_id();

		$add_column = function ( $columns ) {
			$columns['origin'] = esc_html__( 'Origin', 'woocommerce' );

			return $columns;
		};
		// HPOS and non-HPOS use different hooks.
		add_filter( "manage_{$screen_id}_columns", $add_column );
		add_filter( "manage_edit-{$screen_id}_columns", $add_column );

		$display_column = function ( $column_name, $order_id ) {
			if ( 'origin' !== $column_name ) {
				return;
			}
			$this->display_origin_column( $order_id );
		};
		// HPOS and non-HPOS use different hooks.
		add_action( "manage_{$screen_id}_custom_column", $display_column, 10, 2 );
		add_action( "manage_{$screen_id}_posts_custom_column", $display_column, 10, 2 );
	}

	/**
	 * Check if this order already has any attribution data
	 *
	 * @param WC_Order $order The order object.
	 *
	 * @return bool
	 * @since 9.8.0
	 */
	public function has_attribution( $order ) {
		foreach ( $this->field_names as $field ) {
			if ( $order->meta_exists( $this->get_meta_prefixed_field_name( $field ) ) ) {
				return true;
			}
		}
		return false;
	}
}
PK     [1]3Ŗ      Orders/OrderNoteGroup.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\Orders;

/**
 * Enum class for order note groups. This is stored as meta data to categorize order notes.
 *
 * This is not surfaced in core UI presently.
 */
final class OrderNoteGroup {
	/**
	 * Any note concerning errors.
	 *
	 * @var string
	 */
	public const ERROR = 'error';

	/**
	 * Any note concerning emails to customers.
	 *
	 * @var string
	 */
	public const EMAIL_NOTIFICATION = 'email_notification';

	/**
	 * Any note concerning stock levels.
	 *
	 * @var string
	 */
	public const PRODUCT_STOCK = 'product_stock';

	/**
	 * Any note concerning payments.
	 *
	 * @var string
	 */
	public const PAYMENT = 'payment';

	/**
	 * Any note concerning order updates.
	 *
	 * @var string
	 */
	public const ORDER_UPDATE = 'order_update';

	/**
	 * Get the default group title for a given group.
	 *
	 * @param string $group The group.
	 * @return string The default group title.
	 */
	public static function get_default_group_title( string $group ): string {
		switch ( $group ) {
			case self::PRODUCT_STOCK:
				return __( 'Product stock', 'woocommerce' );
			case self::PAYMENT:
				return __( 'Payment', 'woocommerce' );
			case self::EMAIL_NOTIFICATION:
				return __( 'Email notification', 'woocommerce' );
			case self::ERROR:
				return __( 'Error', 'woocommerce' );
			default:
				return __( 'Order updated', 'woocommerce' );
		}
	}
}
PK     [1]2  2    Orders/TaxesController.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Orders;

/**
 * Class with methods for handling order taxes.
 */
class TaxesController {

	/**
	 * Calculate line taxes via Ajax call.
	 */
	public function calc_line_taxes_via_ajax(): void {
		check_ajax_referer( 'calc-totals', 'security' );

		if ( ! current_user_can( 'edit_shop_orders' ) || ! isset( $_POST['order_id'], $_POST['items'] ) ) {
			wp_die( -1 );
		}

		$order = $this->calc_line_taxes( $_POST );

		include __DIR__ . '/../../../includes/admin/meta-boxes/views/html-order-items.php';
		wp_die();
	}

	/**
	 * Calculate line taxes programmatically.
	 *
	 * @param array $post_variables Contents of the $_POST array that would be passed in an Ajax call.
	 * @return object The retrieved order object.
	 */
	public function calc_line_taxes( array $post_variables ): object {
		$order_id           = absint( $post_variables['order_id'] );
		$calculate_tax_args = array(
			'country'  => isset( $post_variables['country'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['country'] ) ) ) : '',
			'state'    => isset( $post_variables['state'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['state'] ) ) ) : '',
			'postcode' => isset( $post_variables['postcode'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['postcode'] ) ) ) : '',
			'city'     => isset( $post_variables['city'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['city'] ) ) ) : '',
		);

		// Parse the jQuery serialized items.
		$items = array();
		parse_str( wp_unslash( $post_variables['items'] ), $items );

		// Save order items first.
		wc_save_order_items( $order_id, $items );

		// Grab the order and recalculate taxes.
		$order = wc_get_order( $order_id );
		$order->calculate_taxes( $calculate_tax_args );
		$order->calculate_totals( false );

		return $order;
	}
}
PK     [1](HZ      Orders/PointOfSaleOrderUtil.phpnu         <?php
/**
 * PointOfSaleOrderUtil class file.
 */

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\Orders;

use WC_Abstract_Order;

/**
 * Helper class for POS order related functionality.
 *
 * @internal Just for internal use.
 */
class PointOfSaleOrderUtil {
	/**
	 * Check if the order is a POS (Point of Sale) order.
	 *
	 * This method determines if an order was created via the POS REST API
	 * by checking the 'created_via' property of the order.
	 *
	 * @param WC_Abstract_Order $order Order instance.
	 * @return bool True if the order is a POS order, false otherwise.
	 */
	public static function is_pos_order( WC_Abstract_Order $order ): bool {
		return 'pos-rest-api' === $order->get_created_via();
	}
}
PK     [1]QuB      Orders/PaymentInfo.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Orders;

use Automattic\WooCommerce\Utilities\StringUtil;
use WC_Abstract_Order;

/**
 * Class PaymentInfo.
 */
class PaymentInfo {
	/**
	 * This array must contain all the names of the files in the CardIcons directory (without extension),
	 * except 'unknown'.
	 */
	private const KNOWN_CARD_BRANDS = array(
		'amex',
		'diners',
		'discover',
		'interac',
		'jcb',
		'mastercard',
		'visa',
	);

	/**
	 * Get info about the card used for payment on an order.
	 *
	 * @param WC_Abstract_Order $order The order in question.
	 *
	 * @return array
	 */
	public static function get_card_info( WC_Abstract_Order $order ): array {
		$method = $order->get_payment_method();

		/**
		 * Filter to allow payment gateways to provide payment card info for an order.
		 *
		 * @since 9.5.0
		 *
		 * @param array|null        $info  The card info.
		 * @param WC_Abstract_Order $order The order.
		 */
		$info = apply_filters( 'wc_order_payment_card_info', array(), $order );
		if ( ! is_array( $info ) ) {
			$info = array();
		}

		// Fallback for WooPayments.
		if ( empty( $info ) && 'woocommerce_payments' === $method ) {
			$info = self::get_wcpay_card_info( $order );
		}

		$defaults = array(
			'payment_method' => $method,
			'brand'          => '',
			'icon'           => '',
			'last4'          => '',
		);
		$info     = wp_parse_args( $info, $defaults );

		if ( empty( $info['icon'] ) ) {
			$info['icon'] = self::get_card_icon( $info['brand'] );
		}

		return $info;
	}

	/**
	 * Generate a CSS-compatible SVG icon of a card brand.
	 *
	 * @param string $brand The brand of the card.
	 *
	 * @return string
	 */
	private static function get_card_icon( ?string $brand ): string {
		$brand = strtolower( (string) $brand );

		if ( ! in_array( $brand, self::KNOWN_CARD_BRANDS, true ) ) {
			$brand = 'unknown';
		}

		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		return base64_encode( file_get_contents( __DIR__ . "/CardIcons/{$brand}.svg" ) );
	}

	/**
	 * Get info about the card used for payment on an order, when the payment gateway is WooPayments.
	 *
	 * @see https://docs.stripe.com/api/charges/object#charge_object-payment_method_details
	 *
	 * @param WC_Abstract_Order $order The order in question.
	 *
	 * @return array
	 */
	private static function get_wcpay_card_info( WC_Abstract_Order $order ): array {
		if ( 'woocommerce_payments' !== $order->get_payment_method() ) {
			return array();
		}

		// This is a Woo-specific meta key, not used within WooPayments.
		$cache_meta_key         = '_wcpay_raw_payment_method_details';
		$payment_details        = null;
		$stored_payment_details = $order->get_meta( $cache_meta_key );
		if ( is_string( $stored_payment_details ) && strlen( $stored_payment_details ) > 0 ) {
			$payment_details = json_decode( $stored_payment_details, true );
		}

		if ( ! $payment_details ) {
			if ( ! class_exists( \WC_Payments::class ) ) {
				return array();
			}

			$payment_method_id = $order->get_meta( '_payment_method_id' );
			if ( ! $payment_method_id ) {
				return array();
			}

			try {
				$payment_details = \WC_Payments::get_payments_api_client()->get_payment_method( $payment_method_id );
			} catch ( \Throwable $ex ) {
				$order_id = $order->get_id();
				$message  = $ex->getMessage();
				wc_get_logger()->error(
					sprintf(
						'%s - retrieving info for payment method %s for order %s: %s',
						StringUtil::class_name_without_namespace( static::class ),
						$payment_method_id,
						$order_id,
						$message
					),
					array(
						'source' => 'payment-info',
					)
				);

				return array();
			}

			// Cache payment method details.
			$order->update_meta_data( $cache_meta_key, wp_json_encode( $payment_details ) );
			$order->save_meta_data();
		}

		$card_info = array();

		if ( isset( $payment_details['type'], $payment_details[ $payment_details['type'] ] ) ) {
			$details = $payment_details[ $payment_details['type'] ];
			switch ( $payment_details['type'] ) {
				case 'card':
				default:
					$card_info['brand'] = $details['brand'] ?? '';
					$card_info['last4'] = $details['last4'] ?? '';
					break;
				case 'card_present':
				case 'interac_present':
					$card_info['brand']        = $details['brand'] ?? '';
					$card_info['last4']        = $details['last4'] ?? '';
					$card_info['account_type'] = $details['receipt']['account_type'] ?? '';
					$card_info['aid']          = $details['receipt']['dedicated_file_name'] ?? '';
					$card_info['app_name']     = $details['receipt']['application_preferred_name'] ?? '';
					break;
			}
		}

		return array_map( 'sanitize_text_field', $card_info );
	}
}
PK     [1]_:4I  I    Orders/CouponsController.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Orders;

use Automattic\WooCommerce\Utilities\ArrayUtil;
use Automattic\WooCommerce\Utilities\StringUtil;
use Exception;

/**
 * Class with methods for handling order coupons.
 */
class CouponsController {

	/**
	 * Add order discount via Ajax.
	 *
	 * @throws Exception If order or coupon is invalid.
	 */
	public function add_coupon_discount_via_ajax(): void {
		check_ajax_referer( 'order-item', 'security' );

		if ( ! current_user_can( 'edit_shop_orders' ) ) {
			wp_die( -1 );
		}

		$response = array();

		try {
			$order = $this->add_coupon_discount( $_POST );

			ob_start();
			include __DIR__ . '/../../../includes/admin/meta-boxes/views/html-order-items.php';
			$response['html'] = ob_get_clean();

			ob_start();
			$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
			include __DIR__ . '/../../../includes/admin/meta-boxes/views/html-order-notes.php';
			$response['notes_html'] = ob_get_clean();
		} catch ( Exception $e ) {
			wp_send_json_error( array( 'error' => $e->getMessage() ) );
		}

		// wp_send_json_success must be outside the try block not to break phpunit tests.
		wp_send_json_success( $response );
	}

	/**
	 * Add order discount programmatically.
	 *
	 * @param array $post_variables Contents of the $_POST array that would be passed in an Ajax call.
	 * @return object The retrieved order object.
	 * @throws \Exception Invalid order or coupon.
	 */
	public function add_coupon_discount( array $post_variables ): object {
		$order_id           = isset( $post_variables['order_id'] ) ? absint( $post_variables['order_id'] ) : 0;
		$order              = wc_get_order( $order_id );
		$calculate_tax_args = array(
			'country'  => isset( $post_variables['country'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['country'] ) ) ) : '',
			'state'    => isset( $post_variables['state'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['state'] ) ) ) : '',
			'postcode' => isset( $post_variables['postcode'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['postcode'] ) ) ) : '',
			'city'     => isset( $post_variables['city'] ) ? wc_strtoupper( wc_clean( wp_unslash( $post_variables['city'] ) ) ) : '',
		);

		if ( ! $order ) {
			throw new Exception( __( 'Invalid order', 'woocommerce' ) );
		}

		$coupon = ArrayUtil::get_value_or_default( $post_variables, 'coupon' );
		if ( StringUtil::is_null_or_whitespace( $coupon ) ) {
			throw new Exception( __( 'Invalid coupon', 'woocommerce' ) );
		}

		// Add user ID and/or email so validation for coupon limits works.
		$user_id_arg    = isset( $post_variables['user_id'] ) ? absint( $post_variables['user_id'] ) : 0;
		$user_email_arg = isset( $post_variables['user_email'] ) ? sanitize_email( wp_unslash( $post_variables['user_email'] ) ) : '';

		if ( $user_id_arg ) {
			$order->set_customer_id( $user_id_arg );
		}
		if ( $user_email_arg ) {
			$order->set_billing_email( $user_email_arg );
		}

		$order->calculate_taxes( $calculate_tax_args );
		$order->calculate_totals( false );

		$code   = wc_format_coupon_code( wp_unslash( $coupon ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$result = $order->apply_coupon( $code );

		if ( is_wp_error( $result ) ) {
			throw new Exception( html_entity_decode( wp_strip_all_tags( $result->get_error_message() ) ) );
		}

		// translators: %s coupon code.
		$order->add_order_note( esc_html( sprintf( __( 'Coupon applied: "%s".', 'woocommerce' ), $code ) ), 0, true, array( 'note_group' => OrderNoteGroup::ORDER_UPDATE ) );

		return $order;
	}
}
PK     [1]e+T  +T  %  Orders/OrderActionsRestController.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Orders;

use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Internal\RestApiControllerBase;
use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
use WC_Data_Exception;
use WC_Email;
use WC_Order;
use WP_Error;
use WP_REST_Request, WP_REST_Response, WP_REST_Server;

/**
 * Controller for the REST endpoint to run actions on orders.
 *
 * This first version only supports sending the order details to the customer (`send_order_details`).
 */
class OrderActionsRestController extends RestApiControllerBase {
	/**
	 * Get the WooCommerce REST API namespace for the class.
	 *
	 * @return string
	 */
	protected function get_rest_api_namespace(): string {
		return 'order-actions';
	}

	/**
	 * Register the REST API endpoints handled by this controller.
	 */
	public function register_routes(): void {
		register_rest_route(
			$this->route_namespace,
			'/orders/(?P<id>[\d]+)/actions/email_templates',
			array(
				'args'   => array(
					'id' => array(
						'description' => __( 'Unique identifier of the order.', 'woocommerce' ),
						'type'        => 'integer',
					),
				),
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'get_email_templates' ),
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => array(),
				),
				'schema' => array( $this, 'get_schema_for_email_templates' ),
			)
		);

		register_rest_route(
			$this->route_namespace,
			'/orders/(?P<id>[\d]+)/actions/send_email',
			array(
				'args'   => array(
					'id' => array(
						'description' => __( 'Unique identifier of the order.', 'woocommerce' ),
						'type'        => 'integer',
					),
				),
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'send_email' ),
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => $this->get_args_for_order_actions( 'send_email', WP_REST_Server::CREATABLE ),
				),
				'schema' => array( $this, 'get_schema_for_order_actions' ),
			)
		);

		register_rest_route(
			$this->route_namespace,
			'/orders/(?P<id>[\d]+)/actions/send_order_details',
			array(
				'args'   => array(
					'id' => array(
						'description' => __( 'Unique identifier of the order.', 'woocommerce' ),
						'type'        => 'integer',
					),
				),
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'send_order_details' ),
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => $this->get_args_for_order_actions( 'send_order_details', WP_REST_Server::CREATABLE ),
				),
				'schema' => array( $this, 'get_schema_for_order_actions' ),
			)
		);
	}

	/**
	 * Validate the order ID that is part of the endpoint URL.
	 *
	 * @param WP_REST_Request $request The incoming HTTP REST request.
	 *
	 * @return int|WP_Error
	 */
	private function validate_order_id( WP_REST_Request $request ) {
		$order_id = $request->get_param( 'id' );
		$order    = wc_get_order( $order_id );

		if ( ! $order ) {
			return new WP_Error( 'woocommerce_rest_not_found', __( 'Order not found', 'woocommerce' ), array( 'status' => 404 ) );
		}

		return $order_id;
	}

	/**
	 * Handle a request for one of the provided REST API endpoints.
	 *
	 * @param WP_REST_Request $request     The incoming HTTP REST request.
	 * @param string          $method_name The name of the class method to execute.
	 *
	 * @return WP_REST_Response|WP_Error
	 */
	protected function run( WP_REST_Request $request, string $method_name ) {
		$order_id = $this->validate_order_id( $request );

		if ( is_wp_error( $order_id ) ) {
			return $order_id;
		}

		return parent::run( $request, $method_name );
	}

	/**
	 * Permission check for REST API endpoint.
	 *
	 * @param WP_REST_Request $request The request for which the permission is checked.
	 * @return bool|WP_Error True if the current user has the capability, otherwise a WP_Error object.
	 */
	private function check_permissions( WP_REST_Request $request ) {
		$order_id = $this->validate_order_id( $request );

		if ( is_wp_error( $order_id ) ) {
			return $order_id;
		}

		return $this->check_permission( $request, 'read_shop_order', $order_id );
	}

	/**
	 * Get the accepted arguments for the POST request.
	 *
	 * @param string $action_slug The endpoint slug for the order action.
	 *
	 * @return array[]
	 */
	private function get_args_for_order_actions( string $action_slug ): array {
		$args = array(
			'email'              => array(
				'description'       => __( 'Email address to send the order details to.', 'woocommerce' ),
				'type'              => 'string',
				'format'            => 'email',
				'context'           => array( 'edit' ),
				'required'          => false,
				'validate_callback' => 'rest_validate_request_arg',
			),
			'force_email_update' => array(
				'description'       => __( 'Whether to update the billing email of the order, even if it already has one.', 'woocommerce' ),
				'type'              => 'boolean',
				'context'           => array( 'edit' ),
				'required'          => false,
				'sanitize_callback' => 'rest_sanitize_boolean',
				'validate_callback' => 'rest_validate_request_arg',
			),
		);

		if ( 'send_email' === $action_slug ) {
			$args['template_id'] = array(
				'description'       => __( 'The ID of the template to use for sending the email.', 'woocommerce' ),
				'type'              => 'string',
				'enum'              => $this->get_template_id_enum(),
				'context'           => array( 'edit' ),
				'required'          => true,
				'validate_callback' => 'rest_validate_request_arg',
			);
		}

		return $args;
	}

	/**
	 * Get the schema for the email_templates action.
	 *
	 * @return array
	 */
	public function get_schema_for_email_templates(): array {
		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => __( 'Email Template', 'woocommerce' ),
			'type'       => 'object',
			'properties' => array(
				'id'          => array(
					'description' => __( 'A unique ID string for the email template.', 'woocommerce' ),
					'type'        => 'string',
					'enum'        => $this->get_template_id_enum(),
					'context'     => array( 'view', 'embed' ),
				),
				'title'       => array(
					'description' => __( 'The display name of the email template.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view' ),
				),
				'description' => array(
					'description' => __( 'A description of the purpose of the email template.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view' ),
				),
			),
		);

		return $schema;
	}

	/**
	 * Get the schema for all order actions that don't have a separate schema.
	 *
	 * @return array
	 */
	public function get_schema_for_order_actions(): array {
		$schema = array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => __( 'Order Actions', 'woocommerce' ),
			'type'       => 'object',
			'properties' => array(
				'message' => array(
					'description' => __( 'A message indicating that the action completed successfully.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'edit' ),
					'readonly'    => true,
				),
			),
		);

		return $schema;
	}

	/**
	 * Get the list of possible template ID values.
	 *
	 * Note that this gets the IDs of all email templates. This does not mean all of these templates are available to
	 * send through the API endpoint.
	 *
	 * @return string[]
	 */
	private function get_template_id_enum(): array {
		$enum = array();

		if ( is_array( WC()->mailer()->emails ) ) {
			$enum = array_map(
				function ( $template ) {
					if ( ! $template instanceof WC_Email || empty( $template->id ) ) {
						return null;
					}

					return $template->id;
				},
				WC()->mailer()->emails,
				array() // Strip off the associative array keys.
			);
		}

		return array_filter( $enum );
	}

	/**
	 * Determine which email templates are available for the given order.
	 *
	 * @param WC_Order $order The order in question.
	 *
	 * @return WC_Email[]
	 */
	private function get_available_email_templates( WC_Order $order ): array {
		$all_email_templates = WC()->mailer()->emails;
		$order_status        = $order->get_status( 'edit' );

		$unavailable_statuses = array(
			OrderStatus::AUTO_DRAFT,
			OrderStatus::DRAFT,
			OrderStatus::NEW,
			OrderStatus::TRASH,
		);

		if ( ! $order->get_billing_email() || in_array( $order_status, $unavailable_statuses, true ) ) {
			return array();
		}

		$valid_template_classes = array(
			'WC_Email_Customer_Invoice',
		);
		if ( $this->order_is_partially_refunded( $order ) ) {
			$valid_template_classes[] = 'WC_Email_Customer_Refunded_Order';
		}

		switch ( $order_status ) {
			case OrderStatus::COMPLETED:
				$valid_template_classes[] = 'WC_Email_Customer_Completed_Order';
				break;
			case OrderStatus::FAILED:
				$valid_template_classes[] = 'WC_Email_Customer_Failed_Order';
				break;
			case OrderStatus::ON_HOLD:
				$valid_template_classes[] = 'WC_Email_Customer_On_Hold_Order';
				break;
			case OrderStatus::PROCESSING:
				$valid_template_classes[] = 'WC_Email_Customer_Processing_Order';
				break;
			case OrderStatus::REFUNDED:
				$valid_template_classes[] = 'WC_Email_Customer_Refunded_Order';
				break;
		}

		/**
		 * Filter the list of valid email templates for a given order.
		 *
		 * Note that the email class must also exist in WC_Emails::$emails.
		 *
		 * When adding a custom email template to this list, a callback must also be added to trigger the sending
		 * of the email. See the `woocommerce_rest_order_actions_email_send` action hook.
		 *
		 * @since 9.8.0
		 *
		 * @param string[] $valid_template_classes Array of email template class names that are valid for a given order.
		 * @param WC_Order $order                  The order.
		 */
		$valid_template_classes = apply_filters(
			'woocommerce_rest_order_actions_email_valid_template_classes',
			$valid_template_classes,
			$order
		);

		$valid_template_classes = array_filter( array_unique( $valid_template_classes ), 'is_string' );
		$valid_templates        = array_fill_keys( $valid_template_classes, '' );

		return array_intersect_key( $all_email_templates, $valid_templates );
	}

	/**
	 * Retrieve an email template class using its ID, if it is available.
	 *
	 * @param string     $template_id         The ID of the desired email template class.
	 * @param array|null $available_templates Optional. An array of available email template classes in the same
	 *                                        associative format as WC_Emails::$emails. If not provided, all classes
	 *                                        in WC_Emails::$emails will be considered available.
	 *
	 * @return WC_Email|null The email template class if it is available, otherwise null.
	 */
	private function get_email_template_by_id( string $template_id, ?array $available_templates = null ): ?WC_Email {
		if ( is_null( $available_templates ) ) {
			$available_templates = WC()->mailer()->emails;
		}

		$matching_templates = array_filter(
			$available_templates,
			fn( $template ) => $template->id === $template_id
		);

		if ( empty( $matching_templates ) ) {
			return null;
		}

		return reset( $matching_templates );
	}

	/**
	 * Callback to run for GET wc/v3/orders/(?P<id>[\d]+)/actions/email_templates.
	 *
	 * @param WP_REST_Request $request The incoming HTTP REST request.
	 *
	 * @return array
	 */
	protected function get_email_templates( WP_REST_Request $request ): array {
		$order = wc_get_order( $request->get_param( 'id' ) );

		$available_templates = $this->get_available_email_templates( $order );
		$templates           = array();

		foreach ( $available_templates as $template ) {
			$templates[] = array(
				'id'          => $template->id,
				'title'       => $template->get_title(),
				'description' => $template->get_description(),
			);
		}

		usort(
			$templates,
			fn( $a, $b ) => strcmp( $a['id'], $b['id'] )
		);

		$schema            = $this->get_schema_for_email_templates();
		$context           = $request->get_param( 'context' ) ?? 'view';
		$filtered_response = array_map(
			function ( $template ) use ( $schema, $context ) {
				return rest_filter_response_by_context( $template, $schema, $context );
			},
			$templates
		);

		return $filtered_response;
	}

	/**
	 * Callback to run for POST wc/v3/orders/(?P<id>[\d]+)/actions/send_email.
	 *
	 * @param WP_REST_Request $request The incoming HTTP REST request.
	 *
	 * @return array|WP_Error
	 */
	protected function send_email( WP_REST_Request $request ) {
		$order       = wc_get_order( $request->get_param( 'id' ) );
		$email       = $request->get_param( 'email' );
		$force       = wp_validate_boolean( $request->get_param( 'force_email_update' ) );
		$template_id = $request->get_param( 'template_id' );
		$messages    = array();

		if ( $email ) {
			$message = $this->maybe_update_billing_email( $order, $email, $force );
			if ( is_wp_error( $message ) ) {
				return $message;
			}
			$messages[] = $message;
		}

		if ( ! is_email( $order->get_billing_email() ) ) {
			return new WP_Error(
				'woocommerce_rest_missing_email',
				__( 'Order does not have an email address.', 'woocommerce' ),
				array( 'status' => 400 )
			);
		}

		$available_templates = $this->get_available_email_templates( $order );
		$template            = $this->get_email_template_by_id( $template_id, $available_templates );

		if ( is_null( $template ) ) {
			return new WP_Error(
				'woocommerce_rest_invalid_email_template',
				sprintf(
					// translators: %s is a string ID for an email template.
					__( '%s is not a valid template for this order.', 'woocommerce' ),
					esc_html( $template_id )
				),
				array( 'status' => 400 )
			);
		}

		switch ( $template_id ) {
			// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
			case 'customer_completed_order':
				/** This action is documented in includes/class-wc-emails.php */
				do_action( 'woocommerce_order_status_completed_notification', $order->get_id(), $order );
				break;
			case 'customer_failed_order':
				/** This action is documented in includes/class-wc-emails.php */
				do_action( 'woocommerce_order_status_failed_notification', $order->get_id(), $order );
				break;
			case 'customer_on_hold_order':
				/** This action is documented in includes/class-wc-emails.php */
				do_action( 'woocommerce_order_status_pending_to_on-hold_notification', $order->get_id(), $order ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
				break;
			case 'customer_processing_order':
				/** This action is documented in includes/class-wc-emails.php */
				do_action( 'woocommerce_order_status_pending_to_processing_notification', $order->get_id(), $order );
				break;
			case 'customer_refunded_order':
				if ( $this->order_is_partially_refunded( $order ) ) {
					/** This action is documented in includes/class-wc-emails.php */
					do_action( 'woocommerce_order_partially_refunded_notification', $order->get_id() );
				} else {
					/** This action is documented in includes/class-wc-emails.php */
					do_action( 'woocommerce_order_fully_refunded_notification', $order->get_id() );
				}
				break;
			// phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment

			case 'customer_invoice':
				return $this->send_order_details( $request );

			default:
				/**
				 * Action to trigger sending a custom order email template from a REST API request.
				 *
				 * The email template must first be made available for the associated order.
				 * See the `woocommerce_rest_order_actions_email_valid_template_classes` filter hook.
				 *
				 * @since 9.8.0
				 *
				 * @param int    $order_id    The ID of the order.
				 * @param string $template_id The ID of the template specified in the API request.
				 */
				do_action( 'woocommerce_rest_order_actions_email_send', $order->get_id(), $template_id );
				break;
		}

		$user_agent = esc_html( $request->get_header( 'User-Agent' ) );
		$messages[] = sprintf(
			// translators: 1. The name of an email template; 2. Email address.
			esc_html__( 'Email template "%1$s" sent to %2$s.', 'woocommerce' ),
			esc_html( $template->get_title() ),
			esc_html( $order->get_billing_email() )
		);

		$messages = array_filter( $messages );
		foreach ( $messages as $message ) {
			$order->add_order_note(
				$message,
				false,
				true,
				array(
					'user_agent' => $user_agent ? $user_agent : 'REST API',
					'note_group' => OrderNoteGroup::EMAIL_NOTIFICATION,
				)
			);
		}

		return array(
			'message' => implode( ' ', $messages ),
		);
	}

	/**
	 * Handle the POST /orders/{id}/actions/send_order_details.
	 *
	 * @param WP_REST_Request $request The received request.
	 * @return array|WP_Error Request response or an error.
	 */
	protected function send_order_details( WP_REST_Request $request ) {
		$order    = wc_get_order( $request->get_param( 'id' ) );
		$email    = $request->get_param( 'email' );
		$force    = wp_validate_boolean( $request->get_param( 'force_email_update' ) );
		$messages = array();

		if ( $email ) {
			$message = $this->maybe_update_billing_email( $order, $email, $force );
			if ( is_wp_error( $message ) ) {
				return $message;
			}
			$messages[] = $message;
		}

		if ( ! is_email( $order->get_billing_email() ) ) {
			return new WP_Error(
				'woocommerce_rest_missing_email',
				__( 'Order does not have an email address.', 'woocommerce' ),
				array( 'status' => 400 )
			);
		}

		// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
		/** This action is documented in includes/admin/meta-boxes/class-wc-meta-box-order-actions.php */
		do_action( 'woocommerce_before_resend_order_emails', $order, 'customer_invoice' );

		WC()->payment_gateways();
		WC()->shipping();
		WC()->mailer()->customer_invoice( $order );

		$user_agent = esc_html( $request->get_header( 'User-Agent' ) );
		$messages[] = sprintf(
			// translators: %s is an email address.
			esc_html__( 'Order details sent to %s.', 'woocommerce' ),
			esc_html( $order->get_billing_email() )
		);

		$messages = array_filter( $messages );
		foreach ( $messages as $message ) {
			$order->add_order_note(
				$message,
				false,
				true,
				array(
					'user_agent' => $user_agent ? $user_agent : 'REST API',
					'note_title' => __( 'Order confirmation email', 'woocommerce' ),
					'note_group' => OrderNoteGroup::EMAIL_NOTIFICATION,
				)
			);
		}

		// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
		/** This action is documented in includes/admin/meta-boxes/class-wc-meta-box-order-actions.php */
		do_action( 'woocommerce_after_resend_order_email', $order, 'customer_invoice' );

		return array(
			'message' => implode( ' ', $messages ),
		);
	}

	/**
	 * Update the billing email of an order when certain conditions are met.
	 *
	 * If the order does not already have a billing email, it will be updated. If it does have one, but `$force` is set
	 * to `true`, it will be updated. Otherwise this will return an error. This can also return an error if the given
	 * email address is not valid.
	 *
	 * @param WC_Order $order The order to update.
	 * @param string   $email The email address to maybe add to the order.
	 * @param bool     $force Optional. True to update the order even if it already has a billing email. Default false.
	 *
	 * @return string|WP_Error A message upon success, otherwise an error.
	 */
	private function maybe_update_billing_email( WC_Order $order, string $email, ?bool $force = false ) {
		$existing_email = $order->get_billing_email( 'edit' );

		if ( $existing_email === $email ) {
			return '';
		}

		if ( $existing_email && true !== $force ) {
			return new WP_Error(
				'woocommerce_rest_order_billing_email_exists',
				__( 'Order already has a billing email.', 'woocommerce' ),
				array( 'status' => 400 )
			);
		}

		try {
			$order->set_billing_email( $email );
			$order->save();
		} catch ( WC_Data_Exception $e ) {
			return new WP_Error(
				$e->getErrorCode(),
				$e->getMessage()
			);
		}

		return sprintf(
			// translators: %s is an email address.
			__( 'Billing email updated to %s.', 'woocommerce' ),
			esc_html( $email )
		);
	}

	/**
	 * Check if a given order has any partial refunds.
	 *
	 * Based on heuristics in the `wc_create_refund()` function.
	 *
	 * @param WC_Order $order An order object.
	 *
	 * @return bool
	 */
	private function order_is_partially_refunded( WC_Order $order ): bool {
		$remaining_amount = $order->get_remaining_refund_amount();
		$remaining_items  = $order->get_remaining_refund_items();
		$refunds          = $order->get_refunds();
		$last_refund      = reset( $refunds );

		// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
		/** This filter is documented in includes/wc-order-functions.php */
		$partially_refunded = apply_filters(
			'woocommerce_order_is_partially_refunded',
			count( $refunds ) > 0 && ( $remaining_amount > 0 || ( $order->has_free_item() && $remaining_items > 0 ) ),
			$order->get_id(),
			$last_refund ? $last_refund->get_id() : 0
		);

		return (bool) $partially_refunded;
	}
}
PK     [1]5  5  $  ProductAttributesLookup/Filterer.phpnu         <?php
/**
 * Filterer class file.
 */

namespace Automattic\WooCommerce\Internal\ProductAttributesLookup;

defined( 'ABSPATH' ) || exit;


/**
 * Helper class for filtering products using the product attributes lookup table.
 */
class Filterer {

	/**
	 * The product attributes lookup data store to use.
	 *
	 * @var LookupDataStore
	 */
	private $data_store;

	/**
	 * The name of the product attributes lookup table.
	 *
	 * @var string
	 */
	private $lookup_table_name;

	/**
	 * Class initialization, invoked by the DI container.
	 *
	 * @internal
	 * @param LookupDataStore $data_store The data store to use.
	 */
	final public function init( LookupDataStore $data_store ) {
		$this->data_store        = $data_store;
		$this->lookup_table_name = $data_store->get_lookup_table_name();
	}

	/**
	 * Checks if the product attribute filtering via lookup table feature is enabled.
	 *
	 * @return bool
	 */
	public function filtering_via_lookup_table_is_active() {
		return 'yes' === get_option( 'woocommerce_attribute_lookup_enabled' );
	}

	/**
	 * Adds post clauses for filtering via lookup table.
	 * This method should be invoked within a 'posts_clauses' filter.
	 *
	 * @param array     $args Product query clauses as supplied to the 'posts_clauses' filter.
	 * @param \WP_Query $wp_query Current product query as supplied to the 'posts_clauses' filter.
	 * @param array     $attributes_to_filter_by Attribute filtering data as generated by WC_Query::get_layered_nav_chosen_attributes.
	 * @return array The updated product query clauses.
	 */
	public function filter_by_attribute_post_clauses( array $args, \WP_Query $wp_query, array $attributes_to_filter_by ) {
		global $wpdb;

		/**
		 * Filter whether to add the filter post clauses
		 *
		 * @param bool     $is_main_query Whether the current query is 'is_main_query'.
		 * @param WP_Query $wp_query      The current WP_Query object.
		 *
		 * @since 9.9.0
		 */
		$enable_filtering = apply_filters( 'woocommerce_enable_post_clause_filtering', $wp_query->is_main_query(), $wp_query );

		if ( ! $enable_filtering || ! $this->filtering_via_lookup_table_is_active() ) {
			return $args;
		}

		// The extra derived table ("SELECT product_or_parent_id FROM") is needed for performance
		// (causes the filtering subquery to be executed only once).
		$clause_root = " {$wpdb->posts}.ID IN ( SELECT product_or_parent_id FROM (";

		/**
		 * Filters the woocommerce_hide_out_of_stock_items option to override the default behavior in product filtering by attribute.
		 *
		 * @param bool $option_value The behavior configured in WooCommerce settings.
		 * @return bool The behavior to use in the catalog when product filtering by attribute.
		 *
		 * @since 9.8.0.
		 */
		$hide_out_of_stock = apply_filters( 'woocommerce_product_attributes_filterer_hide_out_of_stock', 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) );
		if ( $hide_out_of_stock ) {
			$in_stock_clause = ' AND in_stock = 1';
		} else {
			$in_stock_clause = '';
		}

		$attribute_ids_for_and_filtering = array();
		$clauses                         = array();
		foreach ( $attributes_to_filter_by as $taxonomy => $data ) {
			$all_terms                  = get_terms( $taxonomy, array( 'hide_empty' => false ) );
			$term_ids_by_slug           = wp_list_pluck( $all_terms, 'term_id', 'slug' );
			$term_ids_to_filter_by      = array_values( array_intersect_key( $term_ids_by_slug, array_flip( $data['terms'] ) ) );
			$term_ids_to_filter_by      = array_map( 'absint', $term_ids_to_filter_by );
			$term_ids_to_filter_by_list = '(' . join( ',', $term_ids_to_filter_by ) . ')';
			$is_and_query               = 'and' === $data['query_type'];

			$count = count( $term_ids_to_filter_by );

			if ( 0 !== $count ) {
				if ( $is_and_query && $count > 1 ) {
					$attribute_ids_for_and_filtering = array_merge( $attribute_ids_for_and_filtering, $term_ids_to_filter_by );
				} else {
					$clauses[] = "
							{$clause_root}
							SELECT product_or_parent_id
							FROM {$this->lookup_table_name} lt
							WHERE term_id in {$term_ids_to_filter_by_list}
							{$in_stock_clause}
						)";
				}
			}
		}

		if ( ! empty( $attribute_ids_for_and_filtering ) ) {
			$count                      = count( $attribute_ids_for_and_filtering );
			$term_ids_to_filter_by_list = '(' . join( ',', $attribute_ids_for_and_filtering ) . ')';
			$clauses[]                  = "
				{$clause_root}
				SELECT product_or_parent_id
				FROM {$this->lookup_table_name} lt
				WHERE is_variation_attribute=0
				{$in_stock_clause}
				AND term_id in {$term_ids_to_filter_by_list}
				GROUP BY product_id
				HAVING COUNT(product_id)={$count}
				UNION
				SELECT product_or_parent_id
				FROM {$this->lookup_table_name} lt
				WHERE is_variation_attribute=1
				{$in_stock_clause}
				AND term_id in {$term_ids_to_filter_by_list}
				GROUP BY product_or_parent_id
				HAVING COUNT(DISTINCT term_id)={$count}
			)";
		}

		if ( ! empty( $clauses ) ) {
			// "temp" is needed because the extra derived tables require an alias.
			$args['where'] .= ' AND (' . join( ' temp ) AND ', $clauses ) . ' temp ))';
		} elseif ( ! empty( $attributes_to_filter_by ) ) {
			$args['where'] .= ' AND 1=0';
		}

		return $args;
	}

	/**
	 * Count products within certain terms, taking the main WP query into consideration,
	 * for the WC_Widget_Layered_Nav widget.
	 *
	 * This query allows counts to be generated based on the viewed products, not all products.
	 *
	 * @param  array  $term_ids Term IDs.
	 * @param  string $taxonomy Taxonomy.
	 * @param  string $query_type Query Type.
	 * @return array
	 */
	public function get_filtered_term_product_counts( $term_ids, $taxonomy, $query_type ) {
		global $wpdb;

		$use_lookup_table = $this->filtering_via_lookup_table_is_active();

		$tax_query  = \WC_Query::get_main_tax_query();
		$meta_query = \WC_Query::get_main_meta_query();
		if ( 'or' === $query_type ) {
			foreach ( $tax_query as $key => $query ) {
				if ( is_array( $query ) && $taxonomy === $query['taxonomy'] ) {
					unset( $tax_query[ $key ] );
				}
			}
		}

		$meta_query = new \WP_Meta_Query( $meta_query );
		$tax_query  = new \WP_Tax_Query( $tax_query );

		if ( $use_lookup_table ) {
			$query = $this->get_product_counts_query_using_lookup_table( $tax_query, $meta_query, $taxonomy, $term_ids );
		} else {
			$query = $this->get_product_counts_query_not_using_lookup_table( $tax_query, $meta_query, $term_ids );
		}

		$query     = apply_filters( 'woocommerce_get_filtered_term_product_counts_query', $query );
		$query_sql = implode( ' ', $query );

		// We have a query - let's see if cached results of this query already exist.
		$query_hash = md5( $query_sql );
		// Maybe store a transient of the count values.
		$cache = apply_filters( 'woocommerce_layered_nav_count_maybe_cache', true );
		if ( true === $cache ) {
			$cached_counts = (array) get_transient( 'wc_layered_nav_counts_' . sanitize_title( $taxonomy ) );
		} else {
			$cached_counts = array();
		}
		if ( ! isset( $cached_counts[ $query_hash ] ) ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
			$results                      = $wpdb->get_results( $query_sql, ARRAY_A );
			$counts                       = array_map( 'absint', wp_list_pluck( $results, 'term_count', 'term_count_id' ) );
			$cached_counts[ $query_hash ] = $counts;
			if ( true === $cache ) {
				set_transient( 'wc_layered_nav_counts_' . sanitize_title( $taxonomy ), $cached_counts, DAY_IN_SECONDS );
			}
		}
		return array_map( 'absint', (array) $cached_counts[ $query_hash ] );
	}

	/**
	 * Get the query for counting products by terms using the product attributes lookup table.
	 *
	 * @param \WP_Tax_Query  $tax_query The current main tax query.
	 * @param \WP_Meta_Query $meta_query The current main meta query.
	 * @param string         $taxonomy The attribute name to get the term counts for.
	 * @param string         $term_ids The term ids to include in the search.
	 * @return array An array of SQL query parts.
	 */
	private function get_product_counts_query_using_lookup_table( $tax_query, $meta_query, $taxonomy, $term_ids ) {
		global $wpdb;

		$meta_query_sql = $meta_query->get_sql( 'post', $this->lookup_table_name, 'product_or_parent_id' );
		$tax_query_sql  = $tax_query->get_sql( $this->lookup_table_name, 'product_or_parent_id' );

		/**
		 * Filters the woocommerce_hide_out_of_stock_items option to override the default behavior in product filtering by attribute.
		 *
		 * @param bool $option_value The behavior configured in WooCommerce settings.
		 * @return bool The behavior to use in the catalog when product filtering by attribute.
		 *
		 * @since 9.5.0.
		 */
		$hide_out_of_stock = apply_filters( 'woocommerce_product_attributes_filterer_hide_out_of_stock', 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) );
		$in_stock_clause   = $hide_out_of_stock ? ' AND in_stock = 1' : '';

		$query           = array();
		$query['select'] = 'SELECT COUNT(DISTINCT product_or_parent_id) as term_count, term_id as term_count_id';
		$query['from']   = "FROM {$this->lookup_table_name}";
		$query['join']   = "
			{$tax_query_sql['join']} {$meta_query_sql['join']}
			INNER JOIN {$wpdb->posts} ON {$wpdb->posts}.ID = {$this->lookup_table_name}.product_or_parent_id";

		$encoded_taxonomy = sanitize_title( $taxonomy );
		$term_ids_sql     = $this->get_term_ids_sql( $term_ids );
		$query['where']   = "
			WHERE {$wpdb->posts}.post_type IN ( 'product' )
			AND {$wpdb->posts}.post_status = 'publish'
			{$tax_query_sql['where']} {$meta_query_sql['where']}
			AND {$this->lookup_table_name}.taxonomy='{$encoded_taxonomy}'
			AND {$this->lookup_table_name}.term_id IN $term_ids_sql
			{$in_stock_clause}";

		if ( ! empty( $term_ids ) ) {
			$attributes_to_filter_by = \WC_Query::get_layered_nav_chosen_attributes();

			if ( ! empty( $attributes_to_filter_by ) ) {
				$and_term_ids = array();

				foreach ( $attributes_to_filter_by as $taxonomy => $data ) {
					if ( 'and' !== $data['query_type'] ) {
						continue;
					}
					$all_terms             = get_terms( $taxonomy, array( 'hide_empty' => false ) );
					$term_ids_by_slug      = wp_list_pluck( $all_terms, 'term_id', 'slug' );
					$term_ids_to_filter_by = array_values( array_intersect_key( $term_ids_by_slug, array_flip( $data['terms'] ) ) );
					$and_term_ids          = array_merge( $and_term_ids, $term_ids_to_filter_by );
				}

				if ( ! empty( $and_term_ids ) ) {
					$terms_count   = count( $and_term_ids );
					$term_ids_list = '(' . join( ',', $and_term_ids ) . ')';
					// The extra derived table ("SELECT product_or_parent_id FROM") is needed for performance
					// (causes the filtering subquery to be executed only once).
					$query['where'] .= "
						AND product_or_parent_id IN ( SELECT product_or_parent_id FROM (
							SELECT product_or_parent_id
							FROM {$this->lookup_table_name} lt
							WHERE is_variation_attribute=0
							{$in_stock_clause}
							AND term_id in {$term_ids_list}
							GROUP BY product_id
							HAVING COUNT(product_id)={$terms_count}
							UNION
							SELECT product_or_parent_id
							FROM {$this->lookup_table_name} lt
							WHERE is_variation_attribute=1
							{$in_stock_clause}
							AND term_id in {$term_ids_list}
							GROUP BY product_or_parent_id
							HAVING COUNT(DISTINCT term_id)={$terms_count}
						) temp )";
				}
			} else {
				$query['where'] .= $in_stock_clause;
			}
		} elseif ( $hide_out_of_stock ) {
			$query['where'] .= " AND {$this->lookup_table_name}.in_stock=1";
		}

		$search_query_sql = \WC_Query::get_main_search_query_sql();
		if ( $search_query_sql ) {
			$query['where'] .= ' AND ' . $search_query_sql;
		}

		$query['group_by'] = 'GROUP BY terms.term_id';
		$query['group_by'] = "GROUP BY {$this->lookup_table_name}.term_id";

		return $query;
	}

	/**
	 * Get the query for counting products by terms NOT using the product attributes lookup table.
	 *
	 * @param \WP_Tax_Query  $tax_query The current main tax query.
	 * @param \WP_Meta_Query $meta_query The current main meta query.
	 * @param string         $term_ids The term ids to include in the search.
	 * @return array An array of SQL query parts.
	 */
	private function get_product_counts_query_not_using_lookup_table( $tax_query, $meta_query, $term_ids ) {
		global $wpdb;

		$meta_query_sql = $meta_query->get_sql( 'post', $wpdb->posts, 'ID' );
		$tax_query_sql  = $tax_query->get_sql( $wpdb->posts, 'ID' );

		// Generate query.
		$query           = array();
		$query['select'] = "SELECT COUNT( DISTINCT {$wpdb->posts}.ID ) AS term_count, terms.term_id AS term_count_id";
		$query['from']   = "FROM {$wpdb->posts}";
		$query['join']   = "
			INNER JOIN {$wpdb->term_relationships} AS term_relationships ON {$wpdb->posts}.ID = term_relationships.object_id
			INNER JOIN {$wpdb->term_taxonomy} AS term_taxonomy USING( term_taxonomy_id )
			INNER JOIN {$wpdb->terms} AS terms USING( term_id )
			" . $tax_query_sql['join'] . $meta_query_sql['join'];

		$term_ids_sql   = $this->get_term_ids_sql( $term_ids );
		$query['where'] = "
			WHERE {$wpdb->posts}.post_type IN ( 'product' )
			AND {$wpdb->posts}.post_status = 'publish'
			{$tax_query_sql['where']} {$meta_query_sql['where']}
			AND terms.term_id IN $term_ids_sql";

		$search_query_sql = \WC_Query::get_main_search_query_sql();
		if ( $search_query_sql ) {
			$query['where'] .= ' AND ' . $search_query_sql;
		}

		$query['group_by'] = 'GROUP BY terms.term_id';

		return $query;
	}

	/**
	 * Formats a list of term ids as "(id,id,id)".
	 *
	 * @param array $term_ids The list of terms to format.
	 * @return string The formatted list.
	 */
	private function get_term_ids_sql( $term_ids ) {
		return '(' . implode( ',', array_map( 'absint', $term_ids ) ) . ')';
	}
}
PK     [1]=!    +  ProductAttributesLookup/LookupDataStore.phpnu         <?php
/**
 * LookupDataStore class file.
 */

namespace Automattic\WooCommerce\Internal\ProductAttributesLookup;

use Automattic\WooCommerce\Enums\ProductStockStatus;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Enums\CatalogVisibility;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Automattic\WooCommerce\Utilities\StringUtil;

defined( 'ABSPATH' ) || exit;

/**
 * Data store class for the product attributes lookup table.
 */
class LookupDataStore {

	/**
	 * Types of updates to perform depending on the current changest
	 */

	public const ACTION_NONE         = 0;
	public const ACTION_INSERT       = 1;
	public const ACTION_UPDATE_STOCK = 2;
	public const ACTION_DELETE       = 3;

	/**
	 * The lookup table name.
	 *
	 * @var string
	 */
	private $lookup_table_name;

	/**
	 * True if the optimized database access setting is enabled AND products are stored as custom post types.
	 *
	 * @var bool
	 */
	private bool $optimized_db_access_is_enabled;

	/**
	 * Flag indicating if the last lookup table creation operation failed.
	 *
	 * @var bool
	 */
	private bool $last_create_operation_failed = false;

	/**
	 * LookupDataStore constructor.
	 */
	public function __construct() {
		global $wpdb;

		$this->lookup_table_name              = $wpdb->prefix . 'wc_product_attributes_lookup';
		$this->optimized_db_access_is_enabled =
			$this->can_use_optimized_db_access() &&
			'yes' === get_option( 'woocommerce_attribute_lookup_optimized_updates' );

		$this->init_hooks();
	}

	/**
	 * Initialize the hooks used by the class.
	 */
	private function init_hooks() {
		add_action( 'woocommerce_run_product_attribute_lookup_update_callback', array( $this, 'run_update_callback' ), 10, 2 );
		add_filter( 'woocommerce_get_sections_products', array( $this, 'add_advanced_section_to_product_settings' ), 100, 1 );
		add_action( 'woocommerce_rest_insert_product', array( $this, 'on_product_created_or_updated_via_rest_api' ), 100, 2 );
		add_filter( 'woocommerce_get_settings_products', array( $this, 'add_product_attributes_lookup_table_settings' ), 100, 2 );
	}

	/**
	 * Check if optimized database access can be used when creating lookup table entries.
	 *
	 * @return bool True if optimized database access can be used.
	 */
	public function can_use_optimized_db_access() {
		try {
			return is_a( \WC_Data_Store::load( 'product' )->get_current_class_name(), 'WC_Product_Data_Store_CPT', true );
		} catch ( \Exception $ex ) {
			return false;
		}
	}

	/**
	 * Check if the lookup table exists in the database.
	 *
	 * @return bool
	 */
	public function check_lookup_table_exists() {
		global $wpdb;

		$query = $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $this->lookup_table_name ) );

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		return $this->lookup_table_name === $wpdb->get_var( $query );
	}

	/**
	 * Get the name of the lookup table.
	 *
	 * @return string
	 */
	public function get_lookup_table_name() {
		return $this->lookup_table_name;
	}

	/**
	 * Check if the last lookup data creation operation failed.
	 *
	 * @return bool True if the last lookup data creation operation failed.
	 */
	public function get_last_create_operation_failed() {
		return $this->last_create_operation_failed;
	}

	/**
	 * Insert/update the appropriate lookup table entries for a new or modified product or variation.
	 * This must be invoked after a product or a variation is created (including untrashing and duplication)
	 * or modified.
	 *
	 * @param int|\WC_Product $product Product object or product id.
	 * @param null|array      $changeset Changes as provided by 'get_changes' method in the product object, null if it's being created.
	 */
	public function on_product_changed( $product, $changeset = null ) {
		if ( ! $this->check_lookup_table_exists() ) {
			return;
		}

		if ( ! is_a( $product, \WC_Product::class ) ) {
			$product = WC()->call_function( 'wc_get_product', $product );
		}

		$action = $this->get_update_action( $changeset );
		if ( self::ACTION_NONE !== $action ) {
			$this->maybe_schedule_update( $product->get_id(), $action );
		}
	}

	/**
	 * Schedule an update of the product attributes lookup table for a given product.
	 * If an update for the same action is already scheduled, nothing is done.
	 *
	 * If the 'woocommerce_attribute_lookup_direct_update' option is set to 'yes',
	 * the update is done directly, without scheduling.
	 *
	 * @param int $product_id The product id to schedule the update for.
	 * @param int $action The action to perform, one of the ACTION_ constants.
	 */
	private function maybe_schedule_update( int $product_id, int $action ) {
		if ( get_option( 'woocommerce_attribute_lookup_direct_updates' ) === 'yes' ) {
			$this->run_update_callback( $product_id, $action );
			return;
		}

		$args = array( $product_id, $action );

		$queue             = WC()->get_instance_of( \WC_Queue::class );
		$already_scheduled = $queue->search(
			array(
				'hook'   => 'woocommerce_run_product_attribute_lookup_update_callback',
				'args'   => $args,
				'status' => \ActionScheduler_Store::STATUS_PENDING,
			),
			'ids'
		);

		if ( empty( $already_scheduled ) ) {
			$queue->schedule_single(
				WC()->call_function( 'time' ) + 1,
				'woocommerce_run_product_attribute_lookup_update_callback',
				$args,
				'woocommerce-db-updates'
			);
		}
	}

	/**
	 * Perform an update of the lookup table for a specific product.
	 *
	 * @param int $product_id The product id to perform the update for.
	 * @param int $action The action to perform, one of the ACTION_ constants.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function run_update_callback( int $product_id, int $action ) {
		if ( ! $this->check_lookup_table_exists() ) {
			return;
		}

		$product = WC()->call_function( 'wc_get_product', $product_id );
		if ( ! $product ) {
			$action = self::ACTION_DELETE;
		}

		switch ( $action ) {
			case self::ACTION_INSERT:
				$this->delete_data_for( $product_id );
				if ( $this->optimized_db_access_is_enabled ) {
					$this->create_data_for_product_cpt( $product_id );
				} else {
					$this->create_data_for( $product );
				}
				break;
			case self::ACTION_UPDATE_STOCK:
				$this->update_stock_status_for( $product );
				break;
			case self::ACTION_DELETE:
				$this->delete_data_for( $product_id );
				break;
		}
	}

	/**
	 * Determine the type of action to perform depending on the received changeset.
	 *
	 * @param array|null $changeset The changeset received by on_product_changed.
	 * @return int One of the ACTION_ constants.
	 */
	private function get_update_action( $changeset ) {
		if ( is_null( $changeset ) ) {
			// No changeset at all means that the product is new.
			return self::ACTION_INSERT;
		}

		$keys = array_keys( $changeset );

		// Order matters:
		// - The change with the most precedence is a change in catalog visibility
		// (which will result in all data being regenerated or deleted).
		// - Then a change in attributes (all data will be regenerated).
		// - And finally a change in stock status (existing data will be updated).
		// Thus these conditions must be checked in that same order.

		if ( in_array( 'catalog_visibility', $keys, true ) ) {
			$new_visibility = $changeset['catalog_visibility'];
			if ( CatalogVisibility::VISIBLE === $new_visibility || CatalogVisibility::CATALOG === $new_visibility ) {
				return self::ACTION_INSERT;
			} else {
				return self::ACTION_DELETE;
			}
		}

		if ( in_array( 'attributes', $keys, true ) ) {
			return self::ACTION_INSERT;
		}

		if ( array_intersect( $keys, array( 'stock_quantity', 'stock_status', 'manage_stock' ) ) ) {
			return self::ACTION_UPDATE_STOCK;
		}

		return self::ACTION_NONE;
	}

	/**
	 * Update the stock status of the lookup table entries for a given product.
	 *
	 * @param \WC_Product $product The product to update the entries for.
	 */
	private function update_stock_status_for( \WC_Product $product ) {
		global $wpdb;

		$in_stock = $product->is_in_stock();

		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
		$wpdb->query(
			$wpdb->prepare(
				'UPDATE ' . $this->lookup_table_name . ' SET in_stock = %d WHERE product_id = %d',
				$in_stock ? 1 : 0,
				$product->get_id()
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
	}

	/**
	 * Delete the lookup table contents related to a given product or variation,
	 * if it's a variable product it deletes the information for variations too.
	 * This must be invoked after a product or a variation is trashed or deleted.
	 *
	 * @param int|\WC_Product $product Product object or product id.
	 */
	public function on_product_deleted( $product ) {
		if ( ! $this->check_lookup_table_exists() ) {
			return;
		}

		if ( is_a( $product, \WC_Product::class ) ) {
			$product_id = $product->get_id();
		} else {
			$product_id = $product;
		}

		$this->maybe_schedule_update( $product_id, self::ACTION_DELETE );
	}

	/**
	 * Create the lookup data for a given product, if a variable product is passed
	 * the information is created for all of its variations.
	 * This method is intended to be called from the data regenerator.
	 *
	 * @param int|WC_Product $product Product object or id.
	 * @param bool           $use_optimized_db_access Use direct database access for data retrieval if possible.
	 */
	public function create_data_for_product( $product, $use_optimized_db_access = false ) {
		if ( $use_optimized_db_access ) {
			$product_id = intval( ( $product instanceof \WC_Product ) ? $product->get_id() : $product );
			$this->create_data_for_product_cpt( $product_id );
		} else {
			if ( ! is_a( $product, \WC_Product::class ) ) {
				$product = WC()->call_function( 'wc_get_product', $product );
			}

			$this->delete_data_for( $product->get_id() );
			$this->create_data_for( $product );
		}
	}

	/**
	 * Create lookup table data for a given product.
	 *
	 * @param \WC_Product $product The product to create the data for.
	 */
	private function create_data_for( \WC_Product $product ) {
		$this->last_create_operation_failed = false;

		try {
			if ( $this->is_variation( $product ) ) {
				$this->create_data_for_variation( $product );
			} elseif ( $this->is_variable_product( $product ) ) {
				$this->create_data_for_variable_product( $product );
			} else {
				$this->create_data_for_simple_product( $product );
			}
		} catch ( \Exception $e ) {
			$product_id = $product->get_id();
			WC()->call_function( 'wc_get_logger' )->error(
				"Lookup data creation (not optimized) failed for product $product_id: " . $e->getMessage(),
				array(
					'source'     => 'palt-updates',
					'exception'  => $e,
					'product_id' => $product_id,
				)
			);

			$this->last_create_operation_failed = true;
		}
	}

	/**
	 * Delete all the lookup table entries for a given product,
	 * if it's a variable product information for variations is deleted too.
	 *
	 * @param int $product_id Simple product id, or main/parent product id for variable products.
	 */
	private function delete_data_for( int $product_id ) {
		global $wpdb;

		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
		$wpdb->query(
			$wpdb->prepare(
				'DELETE FROM ' . $this->lookup_table_name . ' WHERE product_id = %d OR product_or_parent_id = %d',
				$product_id,
				$product_id
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
	}

	/**
	 * Create lookup table entries for a simple (non variable) product.
	 * Assumes that no entries exist yet.
	 *
	 * @param \WC_Product $product The product to create the entries for.
	 */
	private function create_data_for_simple_product( \WC_Product $product ) {
		$product_attributes_data = $this->get_attribute_taxonomies( $product );
		$has_stock               = $product->is_in_stock();
		$product_id              = $product->get_id();
		foreach ( $product_attributes_data as $taxonomy => $data ) {
			$term_ids = $data['term_ids'];
			foreach ( $term_ids as $term_id ) {
				$this->insert_lookup_table_data( $product_id, $product_id, $taxonomy, $term_id, false, $has_stock );
			}
		}
	}

	/**
	 * Create lookup table entries for a variable product.
	 * Assumes that no entries exist yet.
	 *
	 * @param \WC_Product_Variable $product The product to create the entries for.
	 */
	private function create_data_for_variable_product( \WC_Product_Variable $product ) {
		$product_attributes_data       = $this->get_attribute_taxonomies( $product );
		$variation_attributes_data     = array_filter(
			$product_attributes_data,
			function ( $item ) {
				return $item['used_for_variations'];
			}
		);
		$non_variation_attributes_data = array_filter(
			$product_attributes_data,
			function ( $item ) {
				return ! $item['used_for_variations'];
			}
		);

		$main_product_has_stock = $product->is_in_stock();
		$main_product_id        = $product->get_id();

		foreach ( $non_variation_attributes_data as $taxonomy => $data ) {
			$term_ids = $data['term_ids'];
			foreach ( $term_ids as $term_id ) {
				$this->insert_lookup_table_data( $main_product_id, $main_product_id, $taxonomy, $term_id, false, $main_product_has_stock );
			}
		}

		$term_ids_by_slug_cache = $this->get_term_ids_by_slug_cache( array_keys( $variation_attributes_data ) );
		$variations             = $this->get_variations_of( $product );

		foreach ( $variation_attributes_data as $taxonomy => $data ) {
			foreach ( $variations as $variation ) {
				$this->insert_lookup_table_data_for_variation( $variation, $taxonomy, $main_product_id, $data['term_ids'], $term_ids_by_slug_cache );
			}
		}
	}

	/**
	 * Create all the necessary lookup data for a given variation.
	 *
	 * @param \WC_Product_Variation $variation The variation to create entries for.
	 * @throws \Exception Can't retrieve the details of the parent product.
	 */
	private function create_data_for_variation( \WC_Product_Variation $variation ) {
		$main_product = WC()->call_function( 'wc_get_product', $variation->get_parent_id() );
		if ( false === $main_product ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
			throw new \Exception( "The product is a variation, and the retrieval of data for the parent product (id {$variation->get_parent_id()}) failed." );
		}

		$product_attributes_data   = $this->get_attribute_taxonomies( $main_product );
		$variation_attributes_data = array_filter(
			$product_attributes_data,
			function ( $item ) {
				return $item['used_for_variations'];
			}
		);

		$term_ids_by_slug_cache = $this->get_term_ids_by_slug_cache( array_keys( $variation_attributes_data ) );

		foreach ( $variation_attributes_data as $taxonomy => $data ) {
			$this->insert_lookup_table_data_for_variation( $variation, $taxonomy, $main_product->get_id(), $data['term_ids'], $term_ids_by_slug_cache );
		}
	}

	/**
	 * Create lookup table entries for a given variation, corresponding to a given taxonomy and a set of term ids.
	 *
	 * @param \WC_Product_Variation $variation The variation to create entries for.
	 * @param string                $taxonomy The taxonomy to create the entries for.
	 * @param int                   $main_product_id The parent product id.
	 * @param array                 $term_ids The term ids to create entries for.
	 * @param array                 $term_ids_by_slug_cache A dictionary of term ids by term slug, as returned by 'get_term_ids_by_slug_cache'.
	 */
	private function insert_lookup_table_data_for_variation( \WC_Product_Variation $variation, string $taxonomy, int $main_product_id, array $term_ids, array $term_ids_by_slug_cache ) {
		$variation_id                 = $variation->get_id();
		$variation_has_stock          = $variation->is_in_stock();
		$variation_definition_term_id = $this->get_variation_definition_term_id( $variation, $taxonomy, $term_ids_by_slug_cache );
		if ( $variation_definition_term_id ) {
			$this->insert_lookup_table_data( $variation_id, $main_product_id, $taxonomy, $variation_definition_term_id, true, $variation_has_stock );
		} else {
			$term_ids_for_taxonomy = $term_ids;
			foreach ( $term_ids_for_taxonomy as $term_id ) {
				$this->insert_lookup_table_data( $variation_id, $main_product_id, $taxonomy, $term_id, true, $variation_has_stock );
			}
		}
	}

	/**
	 * Get a cache of term ids by slug for a set of taxonomies, with this format:
	 *
	 * [
	 *   'taxonomy' => [
	 *     'slug_1' => id_1,
	 *     'slug_2' => id_2,
	 *     ...
	 *   ], ...
	 * ]
	 *
	 * @param array $taxonomies List of taxonomies to build the cache for.
	 * @return array A dictionary of taxonomies => dictionary of term slug => term id.
	 */
	private function get_term_ids_by_slug_cache( $taxonomies ) {
		$result = array();
		foreach ( $taxonomies as $taxonomy ) {
			$terms               = WC()->call_function(
				'get_terms',
				array(
					'taxonomy'   => wc_sanitize_taxonomy_name( $taxonomy ),
					'hide_empty' => false,
					'fields'     => 'id=>slug',
				)
			);
			$result[ $taxonomy ] = array_flip( $terms );
		}
		return $result;
	}

	/**
	 * Get the id of the term that defines a variation for a given taxonomy,
	 * or null if there's no such defining id (for variations having "Any <taxonomy>" as the definition)
	 *
	 * @param \WC_Product_Variation $variation The variation to get the defining term id for.
	 * @param string                $taxonomy The taxonomy to get the defining term id for.
	 * @param array                 $term_ids_by_slug_cache A term ids by slug as generated by get_term_ids_by_slug_cache.
	 * @return int|null The term id, or null if there's no defining id for that taxonomy in that variation.
	 */
	private function get_variation_definition_term_id( \WC_Product_Variation $variation, string $taxonomy, array $term_ids_by_slug_cache ) {
		$variation_attributes = $variation->get_attributes();
		$term_slug            = ArrayUtil::get_value_or_default( $variation_attributes, $taxonomy );
		if ( $term_slug ) {
			return $term_ids_by_slug_cache[ $taxonomy ][ $term_slug ];
		} else {
			return null;
		}
	}

	/**
	 * Get the variations of a given variable product.
	 *
	 * @param \WC_Product_Variable $product The product to get the variations for.
	 * @return array An array of WC_Product_Variation objects.
	 */
	private function get_variations_of( \WC_Product_Variable $product ) {
		$variation_ids = $product->get_children();
		return array_map(
			function ( $id ) {
				return WC()->call_function( 'wc_get_product', $id );
			},
			$variation_ids
		);
	}

	/**
	 * Check if a given product is a variable product.
	 *
	 * @param \WC_Product $product The product to check.
	 * @return bool True if it's a variable product, false otherwise.
	 */
	private function is_variable_product( \WC_Product $product ) {
		return is_a( $product, \WC_Product_Variable::class );
	}

	/**
	 * Check if a given product is a variation.
	 *
	 * @param \WC_Product $product The product to check.
	 * @return bool True if it's a variation, false otherwise.
	 */
	private function is_variation( \WC_Product $product ) {
		return is_a( $product, \WC_Product_Variation::class );
	}

	/**
	 * Return the list of taxonomies used for variations on a product together with
	 * the associated term ids, with the following format:
	 *
	 * [
	 *   'taxonomy_name' =>
	 *   [
	 *     'term_ids' => [id, id, ...],
	 *     'used_for_variations' => true|false
	 *   ], ...
	 * ]
	 *
	 * @param \WC_Product $product The product to get the attribute taxonomies for.
	 * @return array Information about the attribute taxonomies of the product.
	 */
	private function get_attribute_taxonomies( \WC_Product $product ) {
		$product_attributes = $product->get_attributes();
		$result             = array();
		foreach ( $product_attributes as $taxonomy_name => $attribute_data ) {
			if ( ! $attribute_data->get_id() ) {
				// Custom product attribute, not suitable for attribute-based filtering.
				continue;
			}

			$result[ $taxonomy_name ] = array(
				'term_ids'            => $attribute_data->get_options(),
				'used_for_variations' => $attribute_data->get_variation(),
			);
		}

		return $result;
	}

	/**
	 * Insert one entry in the lookup table.
	 *
	 * @param int    $product_id The product id.
	 * @param int    $product_or_parent_id The product id for non-variable products, the main/parent product id for variations.
	 * @param string $taxonomy Taxonomy name.
	 * @param int    $term_id Term id.
	 * @param bool   $is_variation_attribute True if the taxonomy corresponds to an attribute used to define variations.
	 * @param bool   $has_stock True if the product is in stock.
	 */
	private function insert_lookup_table_data( int $product_id, int $product_or_parent_id, string $taxonomy, int $term_id, bool $is_variation_attribute, bool $has_stock ) {
		global $wpdb;

		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
		$wpdb->query(
			$wpdb->prepare(
				'INSERT INTO ' . $this->lookup_table_name . ' (
					  product_id,
					  product_or_parent_id,
					  taxonomy,
					  term_id,
					  is_variation_attribute,
					  in_stock)
					VALUES
					  ( %d, %d, %s, %d, %d, %d )',
				$product_id,
				$product_or_parent_id,
				$taxonomy,
				$term_id,
				$is_variation_attribute ? 1 : 0,
				$has_stock ? 1 : 0
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
	}

	/**
	 * Handler for the woocommerce_rest_insert_product hook.
	 * Needed to update the lookup table when the REST API batch insert/update endpoints are used.
	 *
	 * @param \WP_Post         $product The post representing the created or updated product.
	 * @param \WP_REST_Request $request The REST request that caused the hook to be fired.
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function on_product_created_or_updated_via_rest_api( \WP_Post $product, \WP_REST_Request $request ): void {
		if ( StringUtil::ends_with( $request->get_route(), '/batch' ) ) {
			$this->on_product_changed( $product->ID );
		}
	}

	/**
	 * Tells if a lookup table regeneration is currently in progress.
	 *
	 * @return bool True if a lookup table regeneration is already in progress.
	 */
	public function regeneration_is_in_progress() {
		return get_option( 'woocommerce_attribute_lookup_regeneration_in_progress', null ) === 'yes';
	}

	/**
	 * Set a permanent flag (via option) indicating that the lookup table regeneration is in process.
	 */
	public function set_regeneration_in_progress_flag() {
		update_option( 'woocommerce_attribute_lookup_regeneration_in_progress', 'yes' );
	}

	/**
	 * Remove the flag indicating that the lookup table regeneration is in process.
	 */
	public function unset_regeneration_in_progress_flag() {
		delete_option( 'woocommerce_attribute_lookup_regeneration_in_progress' );
	}

	/**
	 * Set a flag indicating that the last lookup table regeneration process started was aborted.
	 */
	public function set_regeneration_aborted_flag() {
		update_option( 'woocommerce_attribute_lookup_regeneration_aborted', 'yes' );
	}

	/**
	 * Remove the flag indicating that the last lookup table regeneration process started was aborted.
	 */
	public function unset_regeneration_aborted_flag() {
		delete_option( 'woocommerce_attribute_lookup_regeneration_aborted' );
	}

	/**
	 * Tells if the last lookup table regeneration process started was aborted
	 * (via deleting the 'woocommerce_attribute_lookup_regeneration_in_progress' option).
	 *
	 * @return bool True if the last lookup table regeneration process was aborted.
	 */
	public function regeneration_was_aborted(): bool {
		return get_option( 'woocommerce_attribute_lookup_regeneration_aborted' ) === 'yes';
	}

	/**
	 * Check if the lookup table contains any entry at all.
	 *
	 * @return bool True if the table contains entries, false if the table is empty.
	 */
	public function lookup_table_has_data(): bool {
		global $wpdb;

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		return ( (int) $wpdb->get_var( "SELECT EXISTS (SELECT 1 FROM {$this->lookup_table_name})" ) ) !== 0;
	}

	/**
	 * Handler for 'woocommerce_get_sections_products', adds the "Advanced" section to the product settings.
	 *
	 * @param array $products Original array of settings sections.
	 * @return array New array of settings sections.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function add_advanced_section_to_product_settings( array $products ): array {
		if ( $this->check_lookup_table_exists() ) {
			$products['advanced'] = __( 'Advanced', 'woocommerce' );
		}

		return $products;
	}

	/**
	 * Handler for 'woocommerce_get_settings_products', adds the settings related to the product attributes lookup table.
	 *
	 * @param array  $settings Original settings configuration array.
	 * @param string $section_id Settings section identifier.
	 * @return array New settings configuration array.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function add_product_attributes_lookup_table_settings( array $settings, string $section_id ): array {
		if ( 'advanced' === $section_id && $this->check_lookup_table_exists() ) {
			$title_item = array(
				'title' => __( 'Product attributes lookup table', 'woocommerce' ),
				'type'  => 'title',
			);

			$regeneration_is_in_progress = $this->regeneration_is_in_progress();

			if ( $regeneration_is_in_progress ) {
				$title_item['desc'] = __( 'These settings are not available while the lookup table regeneration is in progress.', 'woocommerce' );
			}

			$settings[] = $title_item;

			if ( ! $regeneration_is_in_progress ) {
				$regeneration_aborted_warning =
					$this->regeneration_was_aborted() ?
						sprintf(
							"<p><strong style='color: #E00000'>%s</strong></p><p>%s</p>",
							__( 'WARNING: The product attributes lookup table regeneration process was aborted.', 'woocommerce' ),
							__( 'This means that the table is probably in an inconsistent state. It\'s recommended to run a new regeneration process or to resume the aborted process (Status - Tools - Regenerate the product attributes lookup table/Resume the product attributes lookup table regeneration) before enabling the table usage.', 'woocommerce' )
						) : null;

				$settings[] = array(
					'title'         => __( 'Enable table usage', 'woocommerce' ),
					'desc'          => __( 'Use the product attributes lookup table for catalog filtering.', 'woocommerce' ),
					'desc_tip'      => $regeneration_aborted_warning,
					'id'            => 'woocommerce_attribute_lookup_enabled',
					'default'       => 'no',
					'type'          => 'checkbox',
					'checkboxgroup' => 'start',
				);

				$settings[] = array(
					'title'         => __( 'Direct updates', 'woocommerce' ),
					'desc'          => __( 'Update the table directly upon product changes, instead of scheduling a deferred update.', 'woocommerce' ),
					'id'            => 'woocommerce_attribute_lookup_direct_updates',
					'default'       => 'no',
					'type'          => 'checkbox',
					'checkboxgroup' => 'start',
				);

				$settings[] = array(
					'title'         => __( 'Optimized updates', 'woocommerce' ),
					'desc'          => __( 'Uses much more performant queries to update the lookup table, but may not be compatible with some extensions.', 'woocommerce' ),
					'desc_tip'      => __( 'This setting only works when product data is stored in the posts table.', 'woocommerce' ),
					'id'            => 'woocommerce_attribute_lookup_optimized_updates',
					'default'       => 'no',
					'type'          => 'checkbox',
					'checkboxgroup' => 'start',
				);
			}

			$settings[] = array( 'type' => 'sectionend' );
		}

		return $settings;
	}

	/**
	 * Check if the optimized database access setting is enabled.
	 *
	 * @return bool True if the optimized database access setting is enabled.
	 */
	public function optimized_data_access_is_enabled() {
		return 'yes' === get_option( 'woocommerce_attribute_lookup_optimized_updates' );
	}

	/**
	 * Create the lookup table data for a product or variation using optimized database access.
	 * For variable products entries are created for the main product and for all the variations.
	 *
	 * @param int $product_id Product or variation id.
	 */
	private function create_data_for_product_cpt( int $product_id ) {
		$this->last_create_operation_failed = false;

		try {
			$this->create_data_for_product_cpt_core( $product_id );
		} catch ( \Exception $e ) {
			$data = array(
				'source'     => 'palt-updates',
				'product_id' => $product_id,
			);

			if ( $e instanceof \WC_Data_Exception ) {
				$data = array_merge( $data, $e->getErrorData() );
			} else {
				$data['exception'] = $e;
			}

			WC()->call_function( 'wc_get_logger' )
				->error( "Lookup data creation (optimized) failed for product $product_id: " . $e->getMessage(), $data );

			$this->last_create_operation_failed = true;
		}
	}

	/**
	 * Core version of create_data_for_product_cpt (doesn't catch exceptions).
	 *
	 * @param int $product_id Product or variation id.
	 * @return void
	 * @throws \WC_Data_Exception Wrongly serialized attribute data found, or INSERT statement failed.
	 */
	private function create_data_for_product_cpt_core( int $product_id ) {
		global $wpdb;

		// phpcs:disable WordPress.DB.PreparedSQL
		$sql = $wpdb->prepare(
			"delete from {$this->lookup_table_name} where product_or_parent_id=%d",
			$product_id
		);
		$wpdb->query( $sql );
		// phpcs:enable WordPress.DB.PreparedSQL

		// * Obtain list of product variations, together with stock statuses; also get the product type.
		// For a variation this will return just one entry, with type 'variation'.
		// Output: $product_ids_with_stock_status = associative array where 'id' is the key and values are the stock status (1 for "in stock", 0 otherwise).
		// $variation_ids = raw list of variation ids.
		// $is_variable_product = true or false.
		// $is_variation = true or false.

		$sql = $wpdb->prepare(
			"(select p.ID as id, null parent, m.meta_value as stock_status, t.name as product_type from {$wpdb->posts} p
			left join {$wpdb->postmeta} m on p.id=m.post_id and m.meta_key='_stock_status'
			left join {$wpdb->term_relationships} tr on tr.object_id=p.id
			left join {$wpdb->term_taxonomy} tt on tt.term_taxonomy_id=tr.term_taxonomy_id
			left join {$wpdb->terms} t on t.term_id=tt.term_id
			where p.post_type = 'product'
			and p.post_status in ('publish', 'draft', 'pending', 'private')
			and tt.taxonomy='product_type'
			and t.name != 'exclude-from-search'
			and p.id=%d
			limit 1)
				union
			(select p.ID as id, p.post_parent as parent, m.meta_value as stock_status, 'variation' as product_type from {$wpdb->posts} p
			left join {$wpdb->postmeta} m on p.id=m.post_id and m.meta_key='_stock_status'
			where p.post_type = 'product_variation'
			and p.post_status in ('publish', 'draft', 'pending', 'private')
			and (p.ID=%d or p.post_parent=%d));
		",
			$product_id,
			$product_id,
			$product_id
		);

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		$product_ids_with_stock_status = $wpdb->get_results( $sql, ARRAY_A );

		$main_product_row = array_filter( $product_ids_with_stock_status, fn( $item ) => ProductType::VARIATION !== $item['product_type'] );
		$is_variation     = empty( $main_product_row );

		$main_product_id =
			$is_variation ?
			current( $product_ids_with_stock_status )['parent'] :
			$product_id;

		$is_variable_product = ! $is_variation && ( ProductType::VARIABLE === current( $main_product_row )['product_type'] );

		$product_ids_with_stock_status = ArrayUtil::group_by_column( $product_ids_with_stock_status, 'id', true );
		$variation_ids                 = $is_variation ? array( $product_id ) : array_keys( array_diff_key( $product_ids_with_stock_status, array( $product_id => null ) ) );
		$product_ids_with_stock_status = ArrayUtil::select( $product_ids_with_stock_status, 'stock_status' );

		$product_ids_with_stock_status = array_map( fn( $item ) => ProductStockStatus::IN_STOCK === $item ? 1 : 0, $product_ids_with_stock_status );

		// * Obtain the list of attributes used for variations and not.
		// Output: two lists of attribute slugs, all starting with 'pa_'.

		$sql = $wpdb->prepare(
			"select meta_value from {$wpdb->postmeta} where post_id=%d and meta_key=%s",
			$main_product_id,
			'_product_attributes'
		);

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		$temp = $wpdb->get_var( $sql );

		if ( is_null( $temp ) ) {
			// The product has no attributes, thus there's no attributes lookup data to generate.
			return;
		}

		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
		$temp = unserialize( $temp );
		if ( false === $temp ) {
			throw new \WC_Data_Exception( 0, 'The product attributes metadata row is not properly serialized' );
		}

		$temp = array_filter( $temp, fn( $item, $slug ) => StringUtil::starts_with( $slug, 'pa_' ) && '' === $item['value'], ARRAY_FILTER_USE_BOTH );

		$attributes_not_for_variations =
			$is_variation || $is_variable_product ?
			array_keys( array_filter( $temp, fn( $item ) => 0 === $item['is_variation'] ) ) :
			array_keys( $temp );

		// * Obtain the terms used for each attribute.
		// Output: $terms_used_per_attribute =
		// [
		// 'pa_...' => [
		// [
		// 'term_id' => <term id>,
		// 'attribute' => 'pa_...'
		// 'slug' => <term slug>
		// ],...
		// ],...
		// ]

		$sql = $wpdb->prepare(
			"select tt.term_id, tt.taxonomy as attribute, t.slug from {$wpdb->prefix}term_relationships tr
			join {$wpdb->term_taxonomy} tt on tt.term_taxonomy_id = tr.term_taxonomy_id
			join {$wpdb->terms} t on t.term_id=tt.term_id
			where tr.object_id=%d and taxonomy like %s;",
			$main_product_id,
			'pa_%'
		);

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		$terms_used_per_attribute = $wpdb->get_results( $sql, ARRAY_A );
		foreach ( $terms_used_per_attribute as &$term ) {
			$term['attribute'] = strtolower( rawurlencode( $term['attribute'] ) );
		}
		$terms_used_per_attribute = ArrayUtil::group_by_column( $terms_used_per_attribute, 'attribute' );

		// * Obtain the actual variations defined (only if variations exist).
		// Output: $variations_defined =
		// [
		// <variation id> => [
		// [
		// 'variation_id' => <variation id>,
		// 'attribute' => 'pa_...'
		// 'slug' => <term slug>
		// ],...
		// ],...
		// ]
		//
		// Note that this does NOT include "any..." attributes!

		if ( ! $is_variation && ( ! $is_variable_product || empty( $variation_ids ) ) ) {
			$variations_defined = array();
		} else {
			$sql = $wpdb->prepare(
				"select post_id as variation_id, substr(meta_key,11) as attribute, meta_value as slug from {$wpdb->postmeta}
				where post_id in (select ID from {$wpdb->posts} where (id=%d or post_parent=%d) and post_type = 'product_variation')
				and meta_key like %s
				and meta_value != ''",
				$product_id,
				$product_id,
				'attribute_pa_%'
			);
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
			$variations_defined = $wpdb->get_results( $sql, ARRAY_A );
			$variations_defined = ArrayUtil::group_by_column( $variations_defined, 'variation_id' );
		}

		// Now we'll fill an array with all the data rows to be inserted in the lookup table.

		$insert_data = array();

		// * Insert data for the main product

		if ( ! $is_variation ) {
			foreach ( $attributes_not_for_variations as $attribute_name ) {
				foreach ( ( $terms_used_per_attribute[ $attribute_name ] ?? array() ) as $attribute_data ) {
					$insert_data[] = array( $product_id, $main_product_id, $attribute_name, $attribute_data['term_id'], 0, $product_ids_with_stock_status[ $product_id ] );
				}
			}
		}

		// * Insert data for the variations defined

		// Remove the non-variation attributes data first.
		$terms_used_per_attribute = array_diff_key( $terms_used_per_attribute, array_flip( $attributes_not_for_variations ) );

		$used_attributes_per_variation = array();
		foreach ( $variations_defined as $variation_id => $variation_data ) {
			$used_attributes_per_variation[ $variation_id ] = array();
			foreach ( $variation_data as $variation_attribute_data ) {
				$attribute_name                                   = $variation_attribute_data['attribute'];
				$used_attributes_per_variation[ $variation_id ][] = $attribute_name;
				$term_id = current( array_filter( ( $terms_used_per_attribute[ $attribute_name ] ?? array() ), fn( $item ) => $item['slug'] === $variation_attribute_data['slug'] ) )['term_id'] ?? null;
				if ( is_null( $term_id ) ) {
					continue;
				}
				$insert_data[] = array( $variation_id, $main_product_id, $attribute_name, $term_id, 1, $product_ids_with_stock_status[ $variation_id ] ?? false );
			}
		}

		// * Insert data for variations that have "any..." attributes and at least one defined attribute

		foreach ( $used_attributes_per_variation as $variation_id => $attributes_list ) {
			$any_attributes = array_diff_key( $terms_used_per_attribute, array_flip( $attributes_list ) );
			foreach ( $any_attributes as $attributes_data ) {
				foreach ( $attributes_data as $attribute_data ) {
					$insert_data[] = array( $variation_id, $main_product_id, $attribute_data['attribute'], $attribute_data['term_id'], 1, $product_ids_with_stock_status[ $variation_id ] ?? false );
				}
			}
		}

		// * Insert data for variations that have all their attributes defined as "any..."

		$variations_with_all_any = array_keys( array_diff_key( array_flip( $variation_ids ), $used_attributes_per_variation ) );
		foreach ( $variations_with_all_any as $variation_id ) {
			foreach ( $terms_used_per_attribute as $attribute_name => $attribute_terms ) {
				foreach ( $attribute_terms as $attribute_term ) {
					$insert_data[] = array( $variation_id, $main_product_id, $attribute_name, $attribute_term['term_id'], 1, $product_ids_with_stock_status[ $variation_id ] ?? false );
				}
			}
		}

		// * We have all the data to insert, let's go and insert it.

		$insert_data_chunks = array_chunk( $insert_data, 100 );
		foreach ( $insert_data_chunks as $insert_data_chunk ) {
			$sql = 'INSERT INTO ' . $this->lookup_table_name . ' (
					  product_id,
					  product_or_parent_id,
					  taxonomy,
					  term_id,
					  is_variation_attribute,
					  in_stock)
					VALUES (';

			$values_strings = array();
			foreach ( $insert_data_chunk as $dataset ) {
				$attribute_name   = esc_sql( $dataset[2] );
				$values_strings[] = "{$dataset[0]},{$dataset[1]},'{$attribute_name}',{$dataset[3]},{$dataset[4]},{$dataset[5]}";
			}

			$sql .= implode( '),(', $values_strings ) . ')';

			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
			$result = $wpdb->query( $sql );
			if ( false === $result ) {
				throw new \WC_Data_Exception(
					0,
					'INSERT statement failed',
					0,
					array(
						'db_error' => esc_html( $wpdb->last_error ),
						'db_query' => esc_html( $wpdb->last_query ),
					)
				);
			}
		}
	}
}
PK     [1]C]S  ]S  %  ProductAttributesLookup/CLIRunner.phpnu         <?php

namespace Automattic\WooCommerce\Internal\ProductAttributesLookup;

use WP_CLI;

/**
 * Command line tools to handle the regeneration of the product attributes lookup table.
 */
class CLIRunner {

	/**
	 * The instance of DataRegenerator to use.
	 *
	 * @var DataRegenerator
	 */
	private DataRegenerator $data_regenerator;

	/**
	 * The instance of DataRegenerator to use.
	 *
	 * @var LookupDataStore
	 */
	private LookupDataStore $lookup_data_store;

	/**
	 * Creates a new instance of the class.
	 *
	 * Normally we define a public 'init' method with the class dependencies passed as arguments
	 * and then the DI container executes it, but if we do that a dummy command will be created
	 * for that method. Therefore, in this case we retrieve the dependencies manually instead.
	 */
	public function __construct() {
		$container               = wc_get_container();
		$this->data_regenerator  = $container->get( DataRegenerator::class );
		$this->lookup_data_store = $container->get( LookupDataStore::class );
	}

	// phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed

	/**
	 * Enable the usage of the product attributes lookup table.
	 *
	 * @param array $args Positional arguments passed to the command.
	 * @param array $assoc_args Associative arguments (options) passed to the command.
	 */
	public function enable( array $args = array(), array $assoc_args = array() ) {
		return $this->invoke( 'enable_core', $args, $assoc_args );
	}

	/**
	 * Core method for the "enable" command.
	 *
	 * @param array $args Positional arguments passed to the command.
	 * @param array $assoc_args Associative arguments (options) passed to the command.
	 */
	private function enable_core( array $args, array $assoc_args ) {
		$table_name = $this->lookup_data_store->get_lookup_table_name();
		if ( 'yes' === get_option( 'woocommerce_attribute_lookup_enabled' ) ) {
			$this->warning( "The usage of the of the %W{$table_name}%n table is already enabled." );
			return;
		}

		if ( ! array_key_exists( 'force', $assoc_args ) ) {
			$must_confirm = true;
			if ( $this->lookup_data_store->regeneration_is_in_progress() ) {
				$this->warning( "The regeneration of the %W{$table_name}%n table is currently in process." );
			} elseif ( $this->lookup_data_store->regeneration_was_aborted() ) {
				$this->warning( "The regeneration of the %W{$table_name}%n table was aborted." );
			} elseif ( 0 === $this->get_lookup_table_info()['total_rows'] ) {
				$this->warning( "The %W{$table_name}%n table is empty." );
			} else {
				$must_confirm = false;
			}

			if ( $must_confirm ) {
				WP_CLI::confirm( 'Are you sure that you want to enable the table usage?' );
			}
		}

		update_option( 'woocommerce_attribute_lookup_enabled', 'yes' );
		$table_name = $this->lookup_data_store->get_lookup_table_name();
		$this->success( "The usage of the %W{$table_name}%n table for product attribute lookup has been enabled." );
	}

	/**
	 * Disable the usage of the product attributes lookup table.
	 *
	 * @param array $args Positional arguments passed to the command.
	 * @param array $assoc_args Associative arguments (options) passed to the command.
	 */
	public function disable( array $args = array(), array $assoc_args = array() ) {
		return $this->invoke( 'disable_core', $args, $assoc_args );
	}

	/**
	 * Core method for the "disable" command.
	 *
	 * @param array $args Positional arguments passed to the command.
	 * @param array $assoc_args Associative arguments (options) passed to the command.
	 */
	private function disable_core( array $args, array $assoc_args ) {
		if ( 'yes' !== get_option( 'woocommerce_attribute_lookup_enabled' ) ) {
			$table_name = $this->lookup_data_store->get_lookup_table_name();
			$this->warning( "The usage of the of the %W{$table_name}%n table is already disabled." );
			return;
		}
		update_option( 'woocommerce_attribute_lookup_enabled', 'no' );
		$table_name = $this->lookup_data_store->get_lookup_table_name();
		$this->success( "The usage of the %W{$table_name}%n table for product attribute lookup has been disabled." );
	}

	/**
	 * Regenerate the product attributes lookup table data for one single product.
	 *
	 * ## OPTIONS
	 *
	 * <product-id>
	 * : The id of the product for which the data will be regenerated.
	 *
	 * [--disable-db-optimization]
	 * : Don't use optimized database access even if products are stored as custom post types.
	 *
	 * ## EXAMPLES
	 *
	 *     wp wc palt regenerate_for_product 34 --disable-db-optimization
	 *
	 * @param array $args Positional arguments passed to the command.
	 * @param array $assoc_args Associative arguments (options) passed to the command.
	 */
	public function regenerate_for_product( array $args = array(), array $assoc_args = array() ) {
		return $this->invoke( 'regenerate_for_product_core', $args, $assoc_args );
	}

	/**
	 * Core method for the "regenerate_for_product" command.
	 *
	 * @param array $args Positional arguments passed to the command.
	 * @param array $assoc_args Associative arguments (options) passed to the command.
	 */
	private function regenerate_for_product_core( array $args = array(), array $assoc_args = array() ) {
		$product_id = current( $args );
		$this->data_regenerator->check_can_do_lookup_table_regeneration( $product_id );
		$use_db_optimization = ! array_key_exists( 'disable-db-optimization', $assoc_args );
		$this->check_can_use_db_optimization( $use_db_optimization );
		$start_time = microtime( true );
		$this->lookup_data_store->create_data_for_product( $product_id, $use_db_optimization );

		if ( $this->lookup_data_store->get_last_create_operation_failed() ) {
			$this->error( "Lookup data regeneration failed.\nSee the WooCommerce logs (source is %9palt-updates%n) for details." );
		} else {
			$total_time = microtime( true ) - $start_time;
			WP_CLI::success( sprintf( 'Attributes lookup data for product %d regenerated in %f seconds.', $product_id, $total_time ) );
		}
	}

	/**
	 * If database access optimization is requested but can't be used, show a warning.
	 *
	 * @param bool $use_db_optimization True if database access optimization is requested.
	 */
	private function check_can_use_db_optimization( bool $use_db_optimization ) {
		if ( $use_db_optimization && ! $this->lookup_data_store->can_use_optimized_db_access() ) {
			$this->warning( "Optimized database access can't be used (products aren't stored as custom post types)." );
		}
	}

	/**
	 * Obtain information about the product attributes lookup table.
	 *
	 * @param array $args Positional arguments passed to the command.
	 * @param array $assoc_args Associative arguments (options) passed to the command.
	 */
	public function info( array $args = array(), array $assoc_args = array() ) {
		return $this->invoke( 'info_core', $args, $assoc_args );
	}

	/**
	 * Core method for the "info" command.
	 *
	 * @param array $args Positional arguments passed to the command.
	 * @param array $assoc_args Associative arguments (options) passed to the command.
	 */
	private function info_core( array $args, array $assoc_args ) {
		global $wpdb;

		$enabled = 'yes' === get_option( 'woocommerce_attribute_lookup_enabled' );

		$table_name = $this->lookup_data_store->get_lookup_table_name();
		$info       = $this->get_lookup_table_info();

		$this->log( "Table name: %W{$table_name}%n" );
		$this->log( 'Table usage is ' . ( $enabled ? '%Genabled%n' : '%Ydisabled%n' ) );
		$this->log( "The table contains %C{$info['total_rows']}%n rows corresponding to %G{$info['products_count']}%n products." );

		if ( $info['total_rows'] > 0 ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
			$highest_product_id_in_table = $wpdb->get_var( 'select max(product_or_parent_id) from ' . $table_name );
			$this->log( "The highest product id in the table is %B{$highest_product_id_in_table}%n." );
		}

		if ( $this->lookup_data_store->regeneration_is_in_progress() ) {
			$max_product_id_to_process = get_option( 'woocommerce_attribute_lookup_last_product_id_to_process', '???' );
			WP_CLI::log( '' );
			$this->warning( 'Full regeneration of the table is currently %Gin progress.%n' );
			if ( ! $this->data_regenerator->has_scheduled_action_for_regeneration_step() ) {
				$this->log( 'However, there are %9NO%n actions scheduled to run the regeneration steps (a %9wp cli palt regenerate%n command was aborted?).' );
			}
			$this->log( "The last product id that will be processed is %Y{$max_product_id_to_process}%n." );
			$this->log( "\nRun %9wp cli palt abort_regeneration%n to abort the regeneration process," );
			$this->log( "then you'll be able to run %9wp cli palt resume_regeneration%n to resume the regeneration process," );
		} elseif ( $this->lookup_data_store->regeneration_was_aborted() ) {
			$max_product_id_to_process = get_option( 'woocommerce_attribute_lookup_last_product_id_to_process', '???' );
			WP_CLI::log( '' );
			$this->warning( "Full regeneration of the table has been %Raborted.%n\nThe last product id that will be processed is %Y{$max_product_id_to_process}%n." );
			$this->log( "\nRun %9wp cli palt resume_regeneration%n to resume the regeneration process." );
		}
	}

	/**
	 * Abort the background regeneration of the product attributes lookup table that is happening in the background.
	 *
	 * [--cleanup]
	 * : Also cleanup temporary data (so regeneration can't be resumed, but it can be restarted).
	 *
	 *  ## EXAMPLES
	 *
	 *      wp wc palt abort_regeneration --cleanup
	 *
	 * @param array $args Positional arguments passed to the command.
	 * @param array $assoc_args Associative arguments (options) passed to the command.
	 */
	public function abort_regeneration( array $args = array(), array $assoc_args = array() ) {
		return $this->invoke( 'abort_regeneration_core', $args, $assoc_args );
	}

	/**
	 * Core method for the "abort_regeneration" command.
	 *
	 * @param array $args Positional arguments passed to the command.
	 * @param array $assoc_args Associative arguments (options) passed to the command.
	 */
	private function abort_regeneration_core( array $args, array $assoc_args ) {
		$this->data_regenerator->abort_regeneration( false );
		$table_name = $this->lookup_data_store->get_lookup_table_name();
		$this->success( "The regeneration of the data in the %W{$table_name}%n table has been aborted." );
		if ( array_key_exists( 'cleanup', $assoc_args ) ) {
			$this->cleanup_regeneration_progress( array(), array() );
		}
	}

	/**
	 * Resume the background regeneration of the product attributes lookup table after it has been aborted.
	 *
	 * @param array $args Positional arguments passed to the command.
	 * @param array $assoc_args Associative arguments (options) passed to the command.
	 */
	public function resume_regeneration( array $args = array(), array $assoc_args = array() ) {
		return $this->invoke( 'resume_regeneration_core', $args, $assoc_args );
	}

	/**
	 * Core method for the "resume_regeneration" command.
	 *
	 * @param array $args Positional arguments passed to the command.
	 * @param array $assoc_args Associative arguments (options) passed to the command.
	 */
	private function resume_regeneration_core( array $args, array $assoc_args ) {
		$this->data_regenerator->resume_regeneration( false );
		$table_name = $this->lookup_data_store->get_lookup_table_name();
		$this->success( "The regeneration of the data in the %W{$table_name}%n table has been resumed." );
	}

	/**
	 * Delete the temporary data used during the regeneration of the product attributes lookup table. This data is normally deleted automatically after the regeneration process finishes.
	 *
	 * @param array $args Positional arguments passed to the command.
	 * @param array $assoc_args Associative arguments (options) passed to the command.
	 */
	public function cleanup_regeneration_progress( array $args = array(), array $assoc_args = array() ) {
		return $this->invoke( 'cleanup_regeneration_progress_core', $args, $assoc_args );
	}

	/**
	 * Core method for the "cleanup_regeneration_progress" command.
	 *
	 * @param array $args Positional arguments passed to the command.
	 * @param array $assoc_args Associative arguments (options) passed to the command.
	 */
	private function cleanup_regeneration_progress_core( array $args, array $assoc_args ) {
		$this->data_regenerator->finalize_regeneration( false );
		$table_name = $this->lookup_data_store->get_lookup_table_name();
		$this->success( "The temporary data used for regeneration of the data in the %W{$table_name}%n table has been deleted." );
	}

	/**
	 * Initiate the background regeneration of the product attributes lookup table. The regeneration will happen in the background, using scheduled actions.
	 *
	 * ## OPTIONS
	 *
	 * [--force]
	 * : Don't prompt for confirmation if the product attributes lookup table isn't empty.
	 *
	 *   ## EXAMPLES
	 *
	 *       wp wc palt initiate_regeneration --force
	 *
	 * @param array $args Positional arguments passed to the command.
	 * @param array $assoc_args Associative arguments (options) passed to the command.
	 */
	public function initiate_regeneration( array $args = array(), array $assoc_args = array() ) {
		return $this->invoke( 'initiate_regeneration_core', $args, $assoc_args );
	}

	/**
	 * Core method for the "initiate_regeneration" command.
	 *
	 * @param array $args Positional arguments passed to the command.
	 * @param array $assoc_args Associative arguments (options) passed to the command.
	 */
	private function initiate_regeneration_core( array $args, array $assoc_args ) {
		$this->data_regenerator->check_can_do_lookup_table_regeneration();
		$info = $this->get_lookup_table_info();
		if ( $info['total_rows'] > 0 && ! array_key_exists( 'force', $assoc_args ) ) {
			$table_name = $this->lookup_data_store->get_lookup_table_name();
			$this->warning( "The %W{$table_name}%n table contains %C{$info['total_rows']}%n rows corresponding to %G{$info['products_count']}%n products." );
			WP_CLI::confirm( 'Initiating the regeneration will first delete the data. Are you sure?' );
		}

		$this->data_regenerator->initiate_regeneration();
		$table_name = $this->lookup_data_store->get_lookup_table_name();
		$this->log( "%GSuccess:%n The regeneration of the data in the %W{$table_name}%n table has been initiated." );
	}

	/**
	 * Regenerate the product attributes lookup table immediately, without using scheduled tasks.
	 *
	 * ## OPTIONS
	 *
	 * [--force]
	 * : Don't prompt for confirmation if the product attributes lookup table isn't empty.
	 *
	 * [--from-scratch]
	 * : Start table regeneration from scratch even if a regeneration is already in progress.
	 *
	 * [--disable-db-optimization]
	 * : Don't use optimized database access even if products are stored as custom post types.
	 *
	 * [--batch-size=<size>]
	 * : How many products to process in each iteration of the loop.
	 * ---
	 * default: 10
	 * ---
	 *
	 * ## EXAMPLES
	 *
	 *     wp wc palt regenerate --force --from-scratch --batch-size=20
	 *
	 * @param array $args Positional arguments passed to the command.
	 * @param array $assoc_args Associative arguments (options) passed to the command.
	 */
	public function regenerate( array $args = array(), array $assoc_args = array() ) {
		return $this->invoke( 'regenerate_core', $args, $assoc_args );
	}

	/**
	 * Core method for the "regenerate" command.
	 *
	 * @param array $args Positional arguments passed to the command.
	 * @param array $assoc_args Associative arguments (options) passed to the command.
	 * @throws \Exception Invalid batch size argument.
	 */
	private function regenerate_core( array $args = array(), array $assoc_args = array() ) {
		global $wpdb;

		$table_name = $this->lookup_data_store->get_lookup_table_name();

		$batch_size = $assoc_args['batch-size'] ?? DataRegenerator::PRODUCTS_PER_GENERATION_STEP;
		if ( ! is_numeric( $batch_size ) || $batch_size < 1 ) {
			throw new \Exception( 'batch_size must be a number bigger than 0' );
		}

		$was_enabled = 'yes' === get_option( 'woocommerce_attribute_lookup_enabled' );

		// phpcs:ignore Generic.Commenting.Todo.TaskFound
		// TODO: adjust for non-CPT datastores (this is only used for the progress bar, though).
		$products_count = wp_count_posts( 'product' );
		$products_count = intval( $products_count->publish ) + intval( $products_count->pending ) + intval( $products_count->draft );

		if ( ! $this->lookup_data_store->regeneration_is_in_progress() || array_key_exists( 'from-scratch', $assoc_args ) ) {
			$info = $this->get_lookup_table_info();
			if ( $info['total_rows'] > 0 && ! array_key_exists( 'force', $assoc_args ) ) {
				$this->warning( "The %W{$table_name}%n table contains %C{$info['total_rows']}%n rows corresponding to %G{$info['products_count']}%n products." );
				WP_CLI::confirm( 'Triggering the regeneration will first delete the data. Are you sure?' );
			}

			$this->data_regenerator->finalize_regeneration( false );
			$last_product_id = $this->data_regenerator->initiate_regeneration( false );
			if ( 0 === $last_product_id ) {
				$this->data_regenerator->finalize_regeneration( $was_enabled );
				WP_CLI::log( 'No products exist in the database, the table is left empty.' );
				return;
			}
			$processed_count = 0;
		} else {
			$last_product_id = get_option( 'woocommerce_attribute_lookup_last_product_id_to_process' );
			if ( false === $last_product_id ) {
				WP_CLI::error( 'Regeneration seems to be already in progress, but the woocommerce_attribute_lookup_last_product_id_to_process option isn\'t there. Try %9wp cli palt cleanup_regeneration_progress%n first." );' );
				return 1;
			}
			$processed_count = get_option( 'woocommerce_attribute_lookup_processed_count', 0 );
			$this->log( "Resuming regeneration, %C{$processed_count}%n products have been processed already" );
			$this->lookup_data_store->set_regeneration_in_progress_flag();
		}

		$this->data_regenerator->cancel_regeneration_scheduled_action();

		$use_db_optimization = ! array_key_exists( 'disable-db-optimization', $assoc_args );
		$this->check_can_use_db_optimization( $use_db_optimization );
		$progress = WP_CLI\Utils\make_progress_bar( '', $products_count );
		$this->log( "Regenerating %W{$table_name}%n..." );
		$progress->tick( $processed_count );

		$regeneration_step_failed = false;
		while ( $this->data_regenerator->do_regeneration_step( $batch_size, $use_db_optimization ) ) {
			$progress->tick( $batch_size );
			$regeneration_step_failed = $regeneration_step_failed || $this->data_regenerator->get_last_regeneration_step_failed();
		}

		$this->data_regenerator->finalize_regeneration( $was_enabled );
		$time = $progress->formatTime( $progress->elapsed() );
		$progress->finish();

		if ( $regeneration_step_failed ) {
			$this->warning( "Lookup data regeneration failed for at least one product.\nSee the WooCommerce logs (source is %9palt-updates%n) for details.\n" );
			$this->log( "Table %W{$table_name}%n regenerated in {$time}." );
		} else {
			$this->log( "%GSuccess:%n Table %W{$table_name}%n regenerated in {$time}." );
		}

		$info = $this->get_lookup_table_info();
		$this->log( "The table contains now %C{$info['total_rows']}%n rows corresponding to %G{$info['products_count']}%n products." );
	}

	// phpcs:enable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed

	/**
	 * Get information about the product attributes lookup table.
	 *
	 * @return array Array containing the 'total_rows' and 'products_count' keys.
	 */
	private function get_lookup_table_info(): array {
		global $wpdb;

		$table_name = $this->lookup_data_store->get_lookup_table_name();
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		$info = $wpdb->get_row( 'select count(1), count(distinct(product_or_parent_id)) from ' . $table_name, ARRAY_N );
		return array(
			'total_rows'     => absint( $info[0] ),
			'products_count' => absint( $info[1] ),
		);
	}

	/**
	 * Invoke a method from the class, and if an exception is thrown, show it using WP_CLI::error.
	 *
	 * @param string $method_name Name of the method to invoke.
	 * @param array  $args Positional arguments to pass to the method.
	 * @param array  $assoc_args Associative arguments to pass to the method.
	 * @return mixed Result from the method, or 1 if an exception is thrown.
	 */
	private function invoke( string $method_name, array $args, array $assoc_args ) {
		try {
			return call_user_func( array( $this, $method_name ), $args, $assoc_args );
		} catch ( \Exception $e ) {
			WP_CLI::error( $e->getMessage() );
			return 1;
		}
	}

	/**
	 * Show a log message using the WP_CLI text colorization feature.
	 *
	 * @param string $text Text to show.
	 */
	private function log( string $text ) {
		WP_CLI::log( WP_CLI::colorize( $text ) );
	}

	/**
	 * Show a warning message using the WP_CLI text colorization feature.
	 *
	 * @param string $text Text to show.
	 */
	private function warning( string $text ) {
		WP_CLI::warning( WP_CLI::colorize( $text ) );
	}

	/**
	 * Show a success message using the WP_CLI text colorization feature.
	 *
	 * @param string $text Text to show.
	 */
	private function success( string $text ) {
		WP_CLI::success( WP_CLI::colorize( $text ) );
	}

	/**
	 * Show an error message using the WP_CLI text colorization feature.
	 *
	 * @param string $text Text to show.
	 */
	private function error( string $text ) {
		WP_CLI::error( WP_CLI::colorize( $text ) );
	}
}
PK     [1]ZY  Y  +  ProductAttributesLookup/DataRegenerator.phpnu         <?php
/**
 * DataRegenerator class file.
 */

namespace Automattic\WooCommerce\Internal\ProductAttributesLookup;

use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;

defined( 'ABSPATH' ) || exit;

/**
 * This class handles the (re)generation of the product attributes lookup table.
 * It schedules the regeneration in small product batches by itself, so it can be used outside the
 * regular WooCommerce data regenerations mechanism.
 *
 * After the regeneration is completed a wp_wc_product_attributes_lookup table will exist with entries for
 * all the products that existed when initiate_regeneration was invoked; entries for products created after that
 * are supposed to be created/updated by the appropriate data store classes (or by the code that uses
 * the data store classes) whenever a product is created/updated.
 *
 * Additionally, after the regeneration is completed a 'woocommerce_attribute_lookup_enabled' option
 * with a value of 'yes' will have been created, thus effectively enabling the table usage
 * (with an exception: if the regeneration was manually aborted via deleting the
 * 'woocommerce_attribute_lookup_regeneration_in_progress' option) the option will be set to 'no'.
 *
 * This class also adds two entries to the Status - Tools menu: one for manually regenerating the table contents,
 * and another one for enabling or disabling the actual lookup table usage.
 */
class DataRegenerator {

	public const PRODUCTS_PER_GENERATION_STEP = 100;

	/**
	 * The data store to use.
	 *
	 * @var LookupDataStore
	 */
	private $data_store;

	/**
	 * The lookup table name.
	 *
	 * @var string
	 */
	private $lookup_table_name;

	/**
	 * Flag indicating if the last regeneration step failed.
	 *
	 * @var bool
	 */
	private $last_regeneration_step_failed;

	/**
	 * DataRegenerator constructor.
	 */
	public function __construct() {
		global $wpdb;

		$this->lookup_table_name = $wpdb->prefix . 'wc_product_attributes_lookup';

		add_filter( 'woocommerce_debug_tools', array( $this, 'add_initiate_regeneration_entry_to_tools_array' ), 1, 999 );
		add_action( 'woocommerce_run_product_attribute_lookup_regeneration_callback', array( $this, 'run_regeneration_step_callback' ) );
		add_action( 'woocommerce_installed', array( $this, 'run_woocommerce_installed_callback' ) );
	}

	/**
	 * Class initialization, invoked by the DI container.
	 *
	 * @internal
	 * @param LookupDataStore $data_store The data store to use.
	 */
	final public function init( LookupDataStore $data_store ) {
		$this->data_store = $data_store;
	}

	/**
	 * Check if the last regeneration step failed.
	 *
	 * @return bool True if the last regeneration step failed.
	 */
	public function get_last_regeneration_step_failed() {
		return $this->last_regeneration_step_failed;
	}

	/**
	 * Initialize the regeneration procedure:
	 * deletes the lookup table and related options if they exist,
	 * then it creates the table and runs the first step of the regeneration process.
	 *
	 * If $in_background is true, regeneration will continue in the background using scheduled actions.
	 * If $in_background is false, do_regeneration_step and finalize_regeneration must be invoked explicitly.
	 *
	 * This method is intended to be used as a callback for a db update in wc-update-functions
	 * and in the CLI commands, regeneration triggered from the tools page will use
	 * initiate_regeneration_from_tools_page instead.
	 *
	 * @param bool $in_background True if regeneration will continue in the background using scheduled actions.
	 * @return int Highest product id that will be processed.
	 */
	public function initiate_regeneration( bool $in_background = true ): int {
		$this->check_can_do_lookup_table_regeneration();

		$this->enable_or_disable_lookup_table_usage( false );

		$this->delete_all_attributes_lookup_data( true );
		$last_product_id = $this->initialize_table_and_data();
		if ( $last_product_id > 0 ) {
			$this->data_store->set_regeneration_in_progress_flag();
			if ( $in_background ) {
				$this->enqueue_regeneration_step_run();
			}
		} else {
			$this->finalize_regeneration( true );
		}
		return $last_product_id;
	}

	/**
	 * Delete all the existing data related to the lookup table, optionally including the table itself.
	 *
	 * @param bool $truncate_table True to truncate the lookup table too.
	 */
	private function delete_all_attributes_lookup_data( bool $truncate_table ) {
		global $wpdb;

		delete_option( 'woocommerce_attribute_lookup_enabled' );
		delete_option( 'woocommerce_attribute_lookup_last_product_id_to_process' );
		delete_option( 'woocommerce_attribute_lookup_processed_count' );
		$this->data_store->unset_regeneration_in_progress_flag();
		$this->data_store->unset_regeneration_aborted_flag();

		if ( $truncate_table && $this->data_store->check_lookup_table_exists() ) {
			$this->truncate_lookup_table();
		}
	}

	/**
	 * Delete all the data from the lookup table.
	 */
	public function truncate_lookup_table() {
		global $wpdb;

		$wpdb->query( "TRUNCATE TABLE {$this->lookup_table_name}" ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}

	/**
	 * Create the lookup table and initialize the options that will be temporarily used
	 * while the regeneration is in progress.
	 *
	 * @return int Id of the last product id that will be processed.
	 */
	private function initialize_table_and_data(): int {
		$database_util = wc_get_container()->get( DatabaseUtil::class );
		$database_util->dbdelta( $this->get_table_creation_sql() );

		$last_existing_product_id = $this->get_last_existing_product_id();
		if ( ! $last_existing_product_id ) {
			// No products exist, nothing to (re)generate.
			return 0;
		}

		update_option( 'woocommerce_attribute_lookup_last_product_id_to_process', $last_existing_product_id );
		update_option( 'woocommerce_attribute_lookup_processed_count', 0 );

		return $last_existing_product_id;
	}

	/**
	 * Get the highest existing product id.
	 *
	 * @return int|null Highest existing product id, or null if no products exist at all.
	 */
	private function get_last_existing_product_id(): ?int {
		$last_existing_product_id_array =
			WC()->call_function(
				'wc_get_products',
				array(
					'return'  => 'ids',
					'limit'   => 1,
					'orderby' => array(
						'ID' => 'DESC',
					),
				)
			);

		return empty( $last_existing_product_id_array ) ? null : current( $last_existing_product_id_array );
	}

	/**
	 * Action scheduler callback, performs one regeneration step and then
	 * schedules the next step if necessary.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function run_regeneration_step_callback() {
		if ( ! $this->data_store->regeneration_is_in_progress() ) {
			// No regeneration in progress at this point means that the regeneration process
			// was manually aborted via deleting the 'woocommerce_attribute_lookup_regeneration_in_progress' option.
			$this->data_store->set_regeneration_aborted_flag();
			$this->finalize_regeneration( false );
			return;
		}

		$result = $this->do_regeneration_step( null, $this->data_store->optimized_data_access_is_enabled() );
		if ( $result ) {
			$this->enqueue_regeneration_step_run();
		} else {
			$this->finalize_regeneration( true );
		}
	}

	/**
	 * Enqueue one regeneration step in action scheduler.
	 */
	private function enqueue_regeneration_step_run() {
		$queue = WC()->get_instance_of( \WC_Queue::class );
		$queue->schedule_single(
			WC()->call_function( 'time' ) + 1,
			'woocommerce_run_product_attribute_lookup_regeneration_callback',
			array(),
			'woocommerce-db-updates'
		);
	}

	/**
	 * Perform one regeneration step: grabs a chunk of products and creates
	 * the appropriate entries for them in the lookup table.
	 *
	 * @param int|null $step_size How many products to process, by default PRODUCTS_PER_GENERATION_STEP will be used.
	 * @param bool     $use_optimized_db_access Use direct database access for data retrieval if possible.
	 * @return bool True if more steps need to be run, false otherwise.
	 */
	public function do_regeneration_step( ?int $step_size = null, bool $use_optimized_db_access = false ) {
		/**
		 * Filter to alter the count of products that will be processed in each step of the product attributes lookup table regeneration process.
		 *
		 * @since 6.3
		 * @param int $count Default processing step size.
		 */
		$products_per_generation_step = apply_filters( 'woocommerce_attribute_lookup_regeneration_step_size', $step_size ?? self::PRODUCTS_PER_GENERATION_STEP );

		$products_already_processed = get_option( 'woocommerce_attribute_lookup_processed_count', 0 );

		$product_ids = WC()->call_function(
			'wc_get_products',
			array(
				'limit'   => $products_per_generation_step,
				'offset'  => $products_already_processed,
				'orderby' => array(
					'ID' => 'ASC',
				),
				'return'  => 'ids',
			)
		);

		if ( ! is_array( $product_ids ) || empty( $product_ids ) ) {
			return false;
		}

		$this->last_regeneration_step_failed = false;
		foreach ( $product_ids as $id ) {
			$this->data_store->create_data_for_product( $id, $use_optimized_db_access );
			$this->last_regeneration_step_failed = $this->last_regeneration_step_failed || $this->data_store->get_last_create_operation_failed();
		}

		$products_already_processed += count( $product_ids );
		update_option( 'woocommerce_attribute_lookup_processed_count', $products_already_processed );

		$last_product_id_to_process = get_option( 'woocommerce_attribute_lookup_last_product_id_to_process', PHP_INT_MAX );
		return end( $product_ids ) < $last_product_id_to_process;
	}

	/**
	 * Cleanup/final option setup after the regeneration has been completed.
	 *
	 * @param bool $enable_usage Whether the table usage should be enabled or not.
	 */
	public function finalize_regeneration( bool $enable_usage ) {
		$this->cancel_regeneration_scheduled_action();
		$this->delete_all_attributes_lookup_data( false );
		update_option( 'woocommerce_attribute_lookup_enabled', $enable_usage ? 'yes' : 'no' );
	}

	/**
	 * Add a 'Regenerate product attributes lookup table' entry to the Status - Tools page.
	 *
	 * @param array $tools_array The tool definitions array that is passed ro the woocommerce_debug_tools filter.
	 * @return array The tools array with the entry added.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function add_initiate_regeneration_entry_to_tools_array( array $tools_array ) {
		if ( ! $this->data_store->check_lookup_table_exists() ) {
			return $tools_array;
		}

		$generation_is_in_progress = $this->data_store->regeneration_is_in_progress();
		$generation_was_aborted    = $this->data_store->regeneration_was_aborted();

		$entry = array(
			'name'             => __( 'Regenerate the product attributes lookup table', 'woocommerce' ),
			'desc'             => __( 'This tool will regenerate the product attributes lookup table data from existing product(s) data. This process may take a while.', 'woocommerce' ),
			'requires_refresh' => true,
			'callback'         => function () {
				$this->initiate_regeneration_from_tools_page();
				return __( 'Product attributes lookup table data is regenerating', 'woocommerce' );
			},
			'selector'         => array(
				'description'   => __( 'Select a product to regenerate the data for, or leave empty for a full table regeneration:', 'woocommerce' ),
				'class'         => 'wc-product-search',
				'search_action' => 'woocommerce_json_search_products',
				'name'          => 'regenerate_product_attribute_lookup_data_product_id',
				'placeholder'   => esc_attr__( 'Search for a product&hellip;', 'woocommerce' ),
			),
		);

		if ( $generation_is_in_progress ) {
			$entry['button'] = sprintf(
				/* translators: %d: How many products have been processed so far. */
				__( 'Filling in progress (%d)', 'woocommerce' ),
				get_option( 'woocommerce_attribute_lookup_processed_count', 0 )
			);
			$entry['disabled'] = true;
		} else {
			$entry['button'] = __( 'Regenerate', 'woocommerce' );
		}

		$tools_array['regenerate_product_attributes_lookup_table'] = $entry;

		if ( $generation_is_in_progress ) {
			$entry = array(
				'name'             => __( 'Abort the product attributes lookup table regeneration', 'woocommerce' ),
				'desc'             => __( 'This tool will abort the regenerate product attributes lookup table regeneration. After this is done the process can be either started over, or resumed to continue where it stopped.', 'woocommerce' ),
				'requires_refresh' => true,
				'callback'         => function () {
					$this->abort_regeneration( true );
					return __( 'Product attributes lookup table regeneration process has been aborted.', 'woocommerce' );
				},
				'button'           => __( 'Abort', 'woocommerce' ),
			);
			$tools_array['abort_product_attributes_lookup_table_regeneration'] = $entry;
		} elseif ( $generation_was_aborted ) {
			$processed_count = get_option( 'woocommerce_attribute_lookup_processed_count', 0 );
			$entry           = array(
				'name'             => __( 'Resume the product attributes lookup table regeneration', 'woocommerce' ),
				'desc'             =>
					sprintf(
						/* translators: %1$s = count of products already processed. */
						__( 'This tool will resume the product attributes lookup table regeneration at the point in which it was aborted (%1$s products were already processed).', 'woocommerce' ),
						$processed_count
					),
				'requires_refresh' => true,
				'callback'         => function () {
					$this->resume_regeneration( true );
					return __( 'Product attributes lookup table regeneration process has been resumed.', 'woocommerce' );
				},
				'button'           => __( 'Resume', 'woocommerce' ),
			);
			$tools_array['resume_product_attributes_lookup_table_regeneration'] = $entry;
		}

		return $tools_array;
	}

	/**
	 * Callback to initiate the regeneration process from the Status - Tools page.
	 *
	 * @throws \Exception The regeneration is already in progress.
	 */
	private function initiate_regeneration_from_tools_page() {
		$this->verify_tool_execution_nonce();

		//phpcs:disable WordPress.Security.NonceVerification.Recommended
		if ( isset( $_REQUEST['regenerate_product_attribute_lookup_data_product_id'] ) ) {
			$product_id = (int) $_REQUEST['regenerate_product_attribute_lookup_data_product_id'];
			$this->check_can_do_lookup_table_regeneration( $product_id );
			$this->data_store->create_data_for_product( $product_id, $this->data_store->optimized_data_access_is_enabled() );
		} else {
			$this->initiate_regeneration();
		}
		//phpcs:enable WordPress.Security.NonceVerification.Recommended
	}

	/**
	 * Enable or disable the actual lookup table usage.
	 *
	 * @param bool $enable True to enable, false to disable.
	 * @throws \Exception A lookup table regeneration is currently in progress.
	 */
	private function enable_or_disable_lookup_table_usage( $enable ) {
		if ( $this->data_store->regeneration_is_in_progress() ) {
			throw new \Exception( "Can't enable or disable the attributes lookup table usage while it's regenerating." );
		}

		update_option( 'woocommerce_attribute_lookup_enabled', $enable ? 'yes' : 'no' );
	}

	/**
	 * Check if everything is good to go to perform a complete or per product lookup table data regeneration
	 * and throw an exception if not.
	 *
	 * @param mixed $product_id The product id to check the regeneration viability for, or null to check if a complete regeneration is possible.
	 * @throws \Exception Something prevents the regeneration from starting.
	 */
	public function check_can_do_lookup_table_regeneration( $product_id = null ) {
		if ( $product_id && ! $this->data_store->check_lookup_table_exists() ) {
			throw new \Exception( "Can't do product attribute lookup data regeneration: lookup table doesn't exist" );
		}
		if ( $this->data_store->regeneration_is_in_progress() ) {
			throw new \Exception( "Can't do product attribute lookup data regeneration: regeneration is already in progress" );
		}
		if ( $product_id && ! wc_get_product( $product_id ) ) {
			throw new \Exception( "Can't do product attribute lookup data regeneration: product doesn't exist" );
		}
	}

	/**
	 * Callback to abort the regeneration process from the Status - Tools page or from CLI.
	 *
	 * @param bool $verify_nonce True to perform nonce verification (needed when running the tool from the tools page).
	 * @throws \Exception The lookup table doesn't exist, or there's no regeneration process in progress to abort.
	 */
	public function abort_regeneration( bool $verify_nonce ) {
		if ( $verify_nonce ) {
			$this->verify_tool_execution_nonce();
		}

		if ( ! $this->data_store->check_lookup_table_exists() ) {
			throw new \Exception( "Can't abort the product attribute lookup data regeneration process: lookup table doesn't exist" );
		}
		if ( ! $this->data_store->regeneration_is_in_progress() ) {
			throw new \Exception( "Can't abort the product attribute lookup data regeneration process since it's not currently in progress" );
		}

		$this->cancel_regeneration_scheduled_action();
		$this->data_store->unset_regeneration_in_progress_flag();
		$this->data_store->set_regeneration_aborted_flag();
		$this->enable_or_disable_lookup_table_usage( false );

		// Note that we are NOT deleting the options that track the regeneration progress (processed count, last product id to process).
		// This is on purpose so that the regeneration can be resumed where it stopped.
	}

	/**
	 * Cancel any existing regeneration step scheduled action.
	 */
	public function cancel_regeneration_scheduled_action() {
		$queue = WC()->get_instance_of( \WC_Queue::class );
		$queue->cancel_all( 'woocommerce_run_product_attribute_lookup_regeneration_callback' );
	}

	/**
	 * Check if any pending regeneration step scheduled action exists.
	 *
	 * @return bool True if any pending regeneration step scheduled action exists.
	 */
	public function has_scheduled_action_for_regeneration_step(): bool {
		$queue   = WC()->get_instance_of( \WC_Queue::class );
		$actions = $queue->search(
			array(
				'hook'   => 'woocommerce_run_product_attribute_lookup_regeneration_callback',
				'status' => \ActionScheduler_Store::STATUS_PENDING,
			),
			'ids'
		);
		return ! empty( $actions );
	}

	/**
	 * Callback to resume the regeneration process from the Status - Tools page or from CLI.
	 *
	 * @param bool $verify_nonce True to perform nonce verification (needed when running the tool from the tools page).
	 * @throws \Exception The lookup table doesn't exist, or a regeneration process is already in place or hasn't been aborted.
	 */
	public function resume_regeneration( bool $verify_nonce ) {
		if ( $verify_nonce ) {
			$this->verify_tool_execution_nonce();
		}

		if ( ! $this->data_store->check_lookup_table_exists() ) {
			throw new \Exception( "Can't resume the product attribute lookup data regeneration process: lookup table doesn't exist" );
		}
		if ( $this->data_store->regeneration_is_in_progress() ) {
			throw new \Exception( "Can't resume the product attribute lookup data regeneration process: regeneration is already in progress" );
		}
		if ( ! $this->data_store->regeneration_was_aborted() ) {
			throw new \Exception( "Can't resume the product attribute lookup data regeneration process: no aborted regeneration process exists" );
		}

		$this->data_store->unset_regeneration_aborted_flag();
		$this->data_store->set_regeneration_in_progress_flag();
		$this->enqueue_regeneration_step_run();
	}

	/**
	 * Verify the validity of the nonce received when executing a tool from the Status - Tools page.
	 *
	 * @throws \Exception Missing or invalid nonce received.
	 */
	private function verify_tool_execution_nonce() {
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
		if ( ! isset( $_REQUEST['_wpnonce'] ) || wp_verify_nonce( $_REQUEST['_wpnonce'], 'debug_action' ) === false ) {
			throw new \Exception( 'Invalid nonce' );
		}
	}

	/**
	 * Get the name of the product attributes lookup table.
	 *
	 * @return string
	 */
	public function get_lookup_table_name() {
		return $this->lookup_table_name;
	}

	/**
	 * Get the SQL statement that creates the product attributes lookup table, including the indices.
	 *
	 * @return string
	 */
	public function get_table_creation_sql() {
		global $wpdb;

		$collate = $wpdb->has_cap( 'collation' ) ? $wpdb->get_charset_collate() : '';

		return "CREATE TABLE {$this->lookup_table_name} (
 product_id bigint(20) NOT NULL,
 product_or_parent_id bigint(20) NOT NULL,
 taxonomy varchar(32) NOT NULL,
 term_id bigint(20) NOT NULL,
 is_variation_attribute tinyint(1) NOT NULL,
 in_stock tinyint(1) NOT NULL,
 INDEX is_variation_attribute_term_id (is_variation_attribute, term_id),
 PRIMARY KEY  ( `product_or_parent_id`, `term_id`, `product_id`, `taxonomy` )
) $collate;";
	}

	/**
	 * Create the primary key for the table if it doesn't exist already.
	 * It also deletes the product_or_parent_id_term_id index if it exists, since it's now redundant.
	 *
	 * @return void
	 */
	public function create_table_primary_index() {
		$database_util = wc_get_container()->get( DatabaseUtil::class );
		$database_util->create_primary_key( $this->lookup_table_name, array( 'product_or_parent_id', 'term_id', 'product_id', 'taxonomy' ) );
		$database_util->drop_table_index( $this->lookup_table_name, 'product_or_parent_id_term_id' );

		if ( empty( $database_util->get_index_columns( $this->lookup_table_name ) ) ) {
			wc_get_logger()->error( "The creation of the primary key for the {$this->lookup_table_name} table failed" );
		}

		if ( ! empty( $database_util->get_index_columns( $this->lookup_table_name, 'product_or_parent_id_term_id' ) ) ) {
			wc_get_logger()->error( "Dropping the product_or_parent_id_term_id index from the {$this->lookup_table_name} table failed" );
		}
	}

	/**
	 * Run additional setup needed after a WooCommerce install or update finishes.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function run_woocommerce_installed_callback() {
		// The table must exist at this point (created via dbDelta), but we check just in case.
		if ( ! $this->data_store->check_lookup_table_exists() ) {
			return;
		}

		// If a table regeneration is in progress, leave it alone.
		if ( $this->data_store->regeneration_is_in_progress() ) {
			return;
		}

		// If the lookup table has data, or if it's empty because there are no products yet, we're good.
		// Otherwise (lookup table is empty but products exist) we need to initiate a regeneration if one isn't already in progress.
		if ( $this->data_store->lookup_table_has_data() || ! $this->get_last_existing_product_id() ) {
			$must_enable = get_option( 'woocommerce_attribute_lookup_enabled' ) !== 'no';
			$this->delete_all_attributes_lookup_data( false );
			update_option( 'woocommerce_attribute_lookup_enabled', $must_enable ? 'yes' : 'no' );
		} else {
			$this->initiate_regeneration();
		}
	}
}
PK     [1]:	  	    ProductFeed/ProductFeed.phpnu         <?php
/**
 *  Plugin class.
 *
 * @package Automattic\WooCommerce\Internal\ProductFeed
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFeed;

use Automattic\WooCommerce\Internal\ProductFeed\Integrations\IntegrationInterface;
use Automattic\WooCommerce\Internal\RegisterHooksInterface;
use Automattic\WooCommerce\Internal\ProductFeed\Integrations\IntegrationRegistry;
use Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\POSIntegration;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Main Product Feed class.
 *
 * @since 10.5.0
 */
final class ProductFeed implements RegisterHooksInterface {
	/**
	 * Integration registry.
	 *
	 * @var IntegrationRegistry
	 */
	private IntegrationRegistry $integration_registry;

	/**
	 * Dependency injector.
	 *
	 * @param IntegrationRegistry $integration_registry The integration registry.
	 * @param POSIntegration      $pos_integration The POS integration.
	 * @internal
	 */
	public function init( // phpcs:ignore WooCommerce.Functions.InternalInjectionMethod.MissingFinal
		IntegrationRegistry $integration_registry,
		POSIntegration $pos_integration
	): void {
		$this->integration_registry = $integration_registry;
		$this->integration_registry->register_integration( $pos_integration );
	}

	/**
	 * Allows extensions to register integrations.
	 *
	 * @since 10.5.0
	 * @param IntegrationInterface $integration The integration to register.
	 * @return void
	 */
	public function register_integration( IntegrationInterface $integration ): void {
		$this->integration_registry->register_integration( $integration );
	}

	/**
	 * Initialize plugin components
	 *
	 * @since 10.5.0
	 */
	public function register(): void {
		// Let all integrations register their hooks.
		foreach ( $this->integration_registry->get_integrations() as $integration ) {
			$integration->register_hooks();
		}
	}

	/**
	 * Plugin activation
	 *
	 * @since 10.5.0
	 */
	public function activate(): void {
		foreach ( $this->integration_registry->get_integrations() as $integration ) {
			$integration->activate();
		}
	}

	/**
	 * Plugin deactivation
	 *
	 * @since 10.5.0
	 */
	public function deactivate(): void {
		foreach ( $this->integration_registry->get_integrations() as $integration ) {
			$integration->deactivate();
		}
	}
}
PK     [1]h    +  ProductFeed/Feed/ProductMapperInterface.phpnu         <?php
/**
 * Product Mapper Interface.
 *
 * @package Automattic\WooCommerce\Internal\ProductFeed
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;

/**
 * Product Mapper Interface.
 *
 * @since 10.5.0
 */
interface ProductMapperInterface {
	/**
	 * Map a product to a feed row.
	 *
	 * @param \WC_Product $product The product to map.
	 * @return array The feed row.
	 */
	public function map_product( \WC_Product $product ): array;
}
PK     [1]    "  ProductFeed/Feed/ProductLoader.phpnu         <?php
/**
 * Product Loader class.
 *
 * @package Automattic\WooCommerce\Internal\ProductFeed
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Loader for products.
 *
 * @since 10.5.0
 */
class ProductLoader {
	/**
	 * Retrieves products from WooCommerce.
	 *
	 * @since 10.5.0
	 *
	 * @see wc_get_products()
	 *
	 * @param array $args The arguments to pass to wc_get_products().
	 * @return array|\stdClass Number of pages and an array of product objects if
	 *                         paginate is true, or just an array of values.
	 */
	public function get_products( array $args ) {
		return wc_get_products( $args );
	}
}
PK     [1]B    "  ProductFeed/Feed/ProductWalker.phpnu         <?php
/**
 * Product Walker class.
 *
 * @package Automattic\WooCommerce\Internal\ProductFeed
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;

use Automattic\WooCommerce\Internal\ProductFeed\Integrations\IntegrationInterface;
use Automattic\WooCommerce\Internal\ProductFeed\Utils\MemoryManager;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Walker for products.
 *
 * @since 10.5.0
 */
class ProductWalker {
	/**
	 * The product loader.
	 *
	 * @var ProductLoader
	 */
	private ProductLoader $product_loader;

	/**
	 * The product mapper.
	 *
	 * @var ProductMapperInterface
	 */
	private ProductMapperInterface $mapper;

	/**
	 * The feed.
	 *
	 * @var FeedInterface
	 */
	private FeedInterface $feed;

	/**
	 * The feed validator.
	 *
	 * @var FeedValidatorInterface
	 */
	private FeedValidatorInterface $validator;

	/**
	 * The memory manager.
	 *
	 * @var MemoryManager
	 */
	private MemoryManager $memory_manager;

	/**
	 * The number of products to iterate through per batch.
	 *
	 * @var int
	 */
	private int $per_page = 100;

	/**
	 * The time limit to extend the execution time limit per batch.
	 *
	 * @var int
	 */
	private int $time_limit = 0;

	/**
	 * The query arguments to apply to the product query.
	 *
	 * @var array
	 */
	private array $query_args;

	/**
	 * Class constructor.
	 *
	 * This class will not be available through DI. Instead, it needs to be instantiated directly.
	 *
	 * @param ProductMapperInterface $mapper The product mapper.
	 * @param FeedValidatorInterface $validator The feed validator.
	 * @param FeedInterface          $feed The feed.
	 * @param ProductLoader          $product_loader The product loader.
	 * @param MemoryManager          $memory_manager The memory manager.
	 * @param array                  $query_args The query arguments.
	 */
	private function __construct(
		ProductMapperInterface $mapper,
		FeedValidatorInterface $validator,
		FeedInterface $feed,
		ProductLoader $product_loader,
		MemoryManager $memory_manager,
		array $query_args
	) {
		$this->mapper         = $mapper;
		$this->validator      = $validator;
		$this->feed           = $feed;
		$this->product_loader = $product_loader;
		$this->memory_manager = $memory_manager;
		$this->query_args     = $query_args;
	}

	/**
	 * Creates a new instance of the ProductWalker class based on an integration.
	 *
	 * The walker will mostly be set up based on the integration.
	 * The feed is provided externally, as it might be based on the context (CLI, REST, Action Scheduler, etc.).
	 *
	 * @since 10.5.0
	 *
	 * @param IntegrationInterface $integration The integration.
	 * @param FeedInterface        $feed        The feed.
	 * @return self The ProductWalker instance.
	 */
	public static function from_integration(
		IntegrationInterface $integration,
		FeedInterface $feed
	): self {
		$query_args = array_merge(
			array(
				'status' => array( 'publish' ),
				'return' => 'objects',
			),
			$integration->get_product_feed_query_args()
		);

		/**
		 * Allows the base arguments for querying products for product feeds to be changed.
		 *
		 * Variable products are not included by default, as their variations will be included.
		 *
		 * @since 10.5.0
		 *
		 * @param array                $query_args The arguments to pass to wc_get_products().
		 * @param IntegrationInterface $integration The integration that the query belongs to.
		 * @return array
		 */
		$query_args = apply_filters(
			'woocommerce_product_feed_args',
			$query_args,
			$integration
		);

		$instance = new self(
			$integration->get_product_mapper(),
			$integration->get_feed_validator(),
			$feed,
			wc_get_container()->get( ProductLoader::class ),
			wc_get_container()->get( MemoryManager::class ),
			$query_args
		);

		return $instance;
	}

	/**
	 * Set the number of products to iterate through per batch.
	 *
	 * @since 10.5.0
	 *
	 * @param int $batch_size The number of products to iterate through per batch.
	 * @return self
	 */
	public function set_batch_size( int $batch_size ): self {
		if ( $batch_size < 1 ) {
			$batch_size = 1;
		}

		$this->per_page = $batch_size;
		return $this;
	}

	/**
	 * Set the time limit to extend the execution time limit per batch.
	 *
	 * @since 10.5.0
	 *
	 * @param int $time_limit Time limit in seconds.
	 * @return self
	 */
	public function add_time_limit( int $time_limit ): self {
		if ( $time_limit < 0 ) {
			$time_limit = 0;
		}

		$this->time_limit = $time_limit;
		return $this;
	}

	/**
	 * Walks through all products.
	 *
	 * @since 10.5.0
	 *
	 * @param callable $callback The callback to call after each batch of products is processed.
	 * @return int The total number of products processed.
	 */
	public function walk( ?callable $callback = null ): int {
		$progress = null;

		// Instruct the feed to start.
		$this->feed->start();

		// Check how much memory is available at first.
		$initial_available_memory = $this->memory_manager->get_available_memory();

		do {
			$result   = $this->iterate( $this->query_args, $progress ? $progress->processed_batches + 1 : 1, $this->per_page );
			$iterated = count( $result->products );

			// Only done when the progress is not set. Will be modified otherwise.
			if ( is_null( $progress ) ) {
				$progress = WalkerProgress::from_wc_get_products_result( $result );
			}
			$progress->processed_items += $iterated;
			++$progress->processed_batches;

			if ( is_callable( $callback ) && $iterated > 0 ) {
				$callback( $progress );
			}

			if ( $this->time_limit > 0 ) {
				set_time_limit( $this->time_limit );
			}

			// We don't want to use more than half of the available memory at the beginning of the script.
			$current_memory = $this->memory_manager->get_available_memory();
			if ( $initial_available_memory - $current_memory >= $initial_available_memory / 2 ) {
				$this->memory_manager->flush_caches();
			}
		} while (
			// If `wc_get_products()` returns less than the batch size, it was the last page.
			$iterated === $this->per_page

			// For the cases where the above is true, make sure that we do not exceed the total number of pages.
			&& $progress->processed_batches < $progress->total_batch_count
		);

		// Instruct the feed to end.
		$this->feed->end();

		return $progress->processed_items;
	}

	/**
	 * Iterates through a batch of products.
	 *
	 * @param array $args The arguments to pass to wc_get_products().
	 * @param int   $page The page number to iterate through.
	 * @param int   $limit The maximum number of products to iterate through.
	 * @return \stdClass The result of the query with properties: products, total, max_num_pages.
	 */
	private function iterate( array $args = array(), int $page = 1, int $limit = 100 ): \stdClass {
		/**
		 * Result is always stdClass when paginate=true.
		 *
		 * @var \stdClass $result
		 */
		$result = $this->product_loader->get_products(
			array_merge(
				$args,
				array(
					'page'     => $page,
					'limit'    => $limit,
					'paginate' => true,
				)
			)
		);

		foreach ( $result->products as $product ) {
			$mapped_data = $this->mapper->map_product( $product );

			if ( ! empty( $this->validator->validate_entry( $mapped_data, $product ) ) ) {
				continue;
			}

			$this->feed->add_entry( $mapped_data );
		}

		return $result;
	}
}
PK     [1]H(&    #  ProductFeed/Feed/WalkerProgress.phpnu         <?php
/**
 * Walker Progress class.
 *
 * @package Automattic\WooCommerce\Internal\ProductFeed
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;

/**
 * Simple class that tracks/indicates the progress of a walker.
 *
 * @since 10.5.0
 */
final class WalkerProgress {
	/**
	 * Total number of items to process.
	 *
	 * @var int
	 */
	public int $total_count;

	/**
	 * Total number of batches to process.
	 *
	 * @var int
	 */
	public int $total_batch_count;

	/**
	 * Number of items processed so far.
	 *
	 * @var int
	 */
	public int $processed_items = 0;

	/**
	 * Number of batches processed so far.
	 *
	 * @var int
	 */
	public int $processed_batches = 0;

	/**
	 * Creates a WalkerProgress instance from a WooCommerce products query result.
	 *
	 * @since 10.5.0
	 *
	 * @param \stdClass $result The result object from wc_get_products() with total and max_num_pages properties.
	 * @return self
	 */
	public static function from_wc_get_products_result( \stdClass $result ): self {
		$progress = new self();

		$progress->total_count       = $result->total;
		$progress->total_batch_count = $result->max_num_pages;
		$progress->processed_items   = 0;
		$progress->processed_batches = 0;

		return $progress;
	}
}
PK     [1]SbdZ  Z  +  ProductFeed/Feed/FeedValidatorInterface.phpnu         <?php
/**
 * Feed Validator Interface.
 *
 * @package Automattic\WooCommerce\Internal\ProductFeed
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;

/**
 * Feed Validator Interface.
 *
 * @since 10.5.0
 */
interface FeedValidatorInterface {
	/**
	 * Validate a single entry.
	 *
	 * @param array       $row     The entry to validate.
	 * @param \WC_Product $product The related product. Will be updated with validation status.
	 * @return string[]            Validation issues.
	 */
	public function validate_entry( array $row, \WC_Product $product ): array;
}
PK     [1]Xc    "  ProductFeed/Feed/FeedInterface.phpnu         <?php
/**
 * Feed Interface.
 *
 * @package Automattic\WooCommerce\Internal\ProductFeed
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;

/**
 * Feed Interface.
 *
 * @since 10.5.0
 */
interface FeedInterface {
	/**
	 * Start the feed.
	 * This can create an empty file, eventually put something in it, or add a database entry.
	 *
	 * @return void
	 */
	public function start(): void;

	/**
	 * Add an entry to the feed.
	 *
	 * @param array $entry The entry to add.
	 * @return void
	 */
	public function add_entry( array $entry ): void;

	/**
	 * End the feed.
	 *
	 * @return void
	 */
	public function end(): void;

	/**
	 * Get the file path of the feed.
	 *
	 * @return string|null The path to the feed file, null if not ready.
	 */
	public function get_file_path(): ?string;

	/**
	 * Get the URL of the feed file.
	 *
	 * @return string|null The URL of the feed file, null if not ready.
	 */
	public function get_file_url(): ?string;
}
PK     [1]^̚
  
  6  ProductFeed/Integrations/POSCatalog/POSIntegration.phpnu         <?php
/**
 * POS Catalog Integration class.
 *
 * @package Automattic\WooCommerce\Internal\ProductFeed
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog;

use Automattic\WooCommerce\Container;
use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedInterface;
use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedValidatorInterface;
use Automattic\WooCommerce\Internal\ProductFeed\Integrations\IntegrationInterface;
use Automattic\WooCommerce\Internal\ProductFeed\Storage\JsonFileFeed;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * POS Catalog Integration
 *
 * @since 10.5.0
 */
class POSIntegration implements IntegrationInterface {
	/**
	 * Container instance.
	 *
	 * @var Container
	 */
	private Container $container;

	/**
	 * Dependency injector.
	 *
	 * @param Container $container Dependency container.
	 * @internal
	 */
	final public function init( Container $container ): void {
		$this->container = $container;
	}

	/**
	 * {@inheritdoc}
	 */
	public function get_id(): string {
		return 'pos';
	}

	/**
	 * {@inheritdoc}
	 */
	public function get_product_feed_query_args(): array {
		return array(
			'type'      => array( 'simple', 'variable', 'variation' ),
			// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
			'tax_query' => array(
				array(
					'taxonomy' => 'pos_product_visibility',
					'field'    => 'slug',
					'terms'    => 'pos-hidden',
					'operator' => 'NOT IN',
				),
			),
		);
	}

	/**
	 * {@inheritdoc}
	 */
	public function register_hooks(): void {
		add_action( 'rest_api_init', array( $this, 'rest_api_init' ) );
		$this->container->get( AsyncGenerator::class )->register_hooks();
		$this->container->get( POSProductVisibilitySync::class )->register_hooks();
	}

	/**
	 * Initialize the REST API.
	 *
	 * @return void
	 */
	public function rest_api_init(): void {
		// Only load the controller when necessary.
		$this->container->get( ApiController::class )->register_routes();
	}

	/**
	 * {@inheritdoc}
	 */
	public function activate(): void {
		// At the moment, there are no activation steps for the POS catalog.
	}

	/**
	 * {@inheritdoc}
	 */
	public function deactivate(): void {
		// At the moment, there are no deactivation steps for the POS catalog.
	}

	/**
	 * {@inheritdoc}
	 */
	public function create_feed(): FeedInterface {
		return new JsonFileFeed( 'pos-catalog-feed' );
	}

	/**
	 * {@inheritdoc}
	 */
	public function get_product_mapper(): ProductMapper {
		return $this->container->get( ProductMapper::class );
	}

	/**
	 * {@inheritdoc}
	 */
	public function get_feed_validator(): FeedValidatorInterface {
		return $this->container->get( FeedValidator::class );
	}
}
PK     [1]?    5  ProductFeed/Integrations/POSCatalog/ApiController.phpnu         <?php
/**
 * POS Catalog API Controller.
 *
 * @package Automattic\WooCommerce\Internal\ProductFeed
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog;

use Automattic\WooCommerce\Container;
use WP_REST_Request;
use WP_REST_Response;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * POS Catalog API Controller.
 *
 * @since 10.5.0
 */
class ApiController {
	const ROUTE_NAMESPACE = 'wc/pos/v1/catalog';

	/**
	 * Container instance.
	 *
	 * @var Container
	 */
	private $container;

	/**
	 * Dependency injector.
	 *
	 * @param Container $container The container instance. Everything else will be dynamic.
	 * @internal
	 */
	final public function init( Container $container ): void {
		$this->container = $container;
	}

	/**
	 * Register the routes for the API controller.
	 */
	public function register_routes(): void {
		register_rest_route(
			self::ROUTE_NAMESPACE,
			'/create',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'generate_feed' ),
				'permission_callback' => array( $this, 'is_authorized' ),
				'args'                => array(
					'force'             => array(
						'type'        => 'boolean',
						'default'     => false,
						'description' => 'Force regeneration of the feed. NOOP if generation is in progress.',
					),
					'_product_fields'   => array(
						'type'        => 'string',
						'description' => 'Comma-separated list of fields to include for non-variable products.',
						'required'    => false,
					),
					'_variation_fields' => array(
						'type'        => 'string',
						'description' => 'Comma-separated list of fields to include for variations.',
						'required'    => false,
					),
				),
			)
		);
	}

	/**
	 * Checks if the current user has the necessary permissions to access the API.
	 *
	 * @return bool True if the user has the necessary permissions, false otherwise.
	 */
	public function is_authorized() {
		return is_user_logged_in() && (
			current_user_can( 'manage_woocommerce' ) || current_user_can( 'manage_options' )
		);
	}

	/**
	 * Starts generating a feed.
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request The request object.
	 * @return WP_REST_Response The response object.
	 */
	public function generate_feed( WP_REST_Request $request ): WP_REST_Response { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
		$generator = $this->container->get( AsyncGenerator::class );
		try {
			$params = array();
			if ( null !== $request['_product_fields'] ) {
				$params['_product_fields'] = $request['_product_fields'];
			}
			if ( null !== $request['_variation_fields'] ) {
				$params['_variation_fields'] = $request['_variation_fields'];
			}

			$response = $request->get_param( 'force' )
				? $generator->force_regeneration( $params )
				: $generator->get_status( $params );

			// Use the right datetime format.
			if ( isset( $response['scheduled_at'] ) ) {
				$response['scheduled_at'] = wc_rest_prepare_date_response( $response['scheduled_at'] );
			}
			if ( isset( $response['completed_at'] ) ) {
				$response['completed_at'] = wc_rest_prepare_date_response( $response['completed_at'] );
			}

			// Remove sensitive data from the response.
			if ( isset( $response['action_id'] ) ) {
				unset( $response['action_id'] );
			}
			if ( isset( $response['path'] ) ) {
				unset( $response['path'] );
			}
		} catch ( \Exception $e ) {
			wc_get_logger()->error(
				'Feed generation failed',
				array( 'error' => $e->getMessage() )
			);
			return new WP_REST_Response(
				array(
					'success' => false,
					'message' => __( 'An error occurred while generating the feed.', 'woocommerce' ),
				),
				500
			);
		}
		return new WP_REST_Response( $response );
	}
}
PK     [1]L9+  +  6  ProductFeed/Integrations/POSCatalog/AsyncGenerator.phpnu         <?php
/**
 *  Async Generator class.
 *
 * @package Automattic\WooCommerce\Internal\ProductFeed
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog;

use ActionScheduler_AsyncRequest_QueueRunner;
use ActionScheduler_Store;
use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductWalker;
use Automattic\WooCommerce\Internal\ProductFeed\Feed\WalkerProgress;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Async Generator for feeds.
 *
 * @since 10.5.0
 */
class AsyncGenerator {
	/**
	 * The Action Scheduler action hook for the feed generation.
	 *
	 * @var string
	 */
	const FEED_GENERATION_ACTION = 'woocommerce_product_feed_generation';

	/**
	 * The Action Scheduler action hook for the feed deletion.
	 *
	 * @var string
	 */
	const FEED_DELETION_ACTION = 'woocommerce_product_feed_deletion';

	/**
	 * Feed expiry time, once completed.
	 * If the feed is not downloaded within this timeframe, a new one will need to be generated.
	 *
	 * @var int
	 */
	const FEED_EXPIRY = 20 * HOUR_IN_SECONDS;

	/**
	 * Possible states of generation.
	 */
	const STATE_SCHEDULED   = 'scheduled';
	const STATE_IN_PROGRESS = 'in_progress';
	const STATE_COMPLETED   = 'completed';
	const STATE_FAILED      = 'failed';

	/**
	 * Integration instance.
	 *
	 * @var POSIntegration
	 */
	private $integration;

	/**
	 * Dependency injector.
	 *
	 * @param POSIntegration $integration The integration instance.
	 * @internal
	 */
	final public function init( POSIntegration $integration ): void {
		$this->integration = $integration;
	}

	/**
	 * Register hooks for the async generator.
	 *
	 * @since 10.5.0
	 *
	 * @return void
	 */
	public function register_hooks(): void {
		add_action( self::FEED_GENERATION_ACTION, array( $this, 'feed_generation_action' ) );
		add_action( self::FEED_DELETION_ACTION, array( $this, 'feed_deletion_action' ), 10, 2 );
	}

	/**
	 * Returns the current feed generation status.
	 * Initiates one if not already running.
	 *
	 * @since 10.5.0
	 *
	 * @param array|null $args The arguments to pass to the action.
	 * @return array           The feed generation status.
	 */
	public function get_status( ?array $args = null ): array {
		// Determine the option key based on the integration ID and arguments.
		$option_key = $this->get_option_key( $args );
		$status     = get_option( $option_key );

		// For existing jobs, make sure that everything in the status makes sense.
		if ( is_array( $status ) && ! $this->validate_status( $status ) ) {
			$status = false;
		}

		// If the status is an array, it means that there is nothing to schedule in this method.
		if ( is_array( $status ) ) {
			return $status;
		}

		// Clear all previous actions to avoid race conditions.
		as_unschedule_all_actions( self::FEED_GENERATION_ACTION, array( $option_key ), 'woo-product-feed' ); // @phpstan-ignore function.notFound

		$status = array(
			'scheduled_at' => time(),
			'completed_at' => null,
			'state'        => self::STATE_SCHEDULED,
			'progress'     => 0,
			'processed'    => 0,
			'total'        => -1,
			'args'         => $args ?? array(),
		);

		update_option(
			$option_key,
			$status
		);

		// Start an immediate async action to generate the feed.
		// @phpstan-ignore-next-line function.notFound -- Action Scheduler.
		as_enqueue_async_action(
			self::FEED_GENERATION_ACTION,
			array( $option_key ),
			'woo-product-feed',
			true,
			1
		);

		// Manually force an async request to be dispatched to process the action immediately.
		if ( class_exists( ActionScheduler_AsyncRequest_QueueRunner::class ) && class_exists( ActionScheduler_Store::class ) ) {
			$store         = ActionScheduler_Store::instance();
			$async_request = new ActionScheduler_AsyncRequest_QueueRunner( $store );
			$async_request->dispatch();
		}

		return $status;
	}

	/**
	 * Action scheduler callback for the feed generation.
	 *
	 * @since 10.5.0
	 *
	 * @param string $option_key The option key for the feed generation status.
	 * @return void
	 */
	public function feed_generation_action( string $option_key ) {
		$status = get_option( $option_key );

		if ( ! is_array( $status ) || ! isset( $status['state'] ) || self::STATE_SCHEDULED !== $status['state'] ) {
			wc_get_logger()->error( 'Invalid feed generation status', array( 'status' => $status ) );
			return;
		}

		$status['state'] = self::STATE_IN_PROGRESS;
		update_option( $option_key, $status );

		try {
			$feed   = $this->integration->create_feed();
			$walker = ProductWalker::from_integration( $this->integration, $feed );

			// Add dynamic args to the mapper.
			$args = $status['args'] ?? array();
			if (
				isset( $args['_product_fields'] )
				&& is_string( $args['_product_fields'] ) &&
				! empty( $args['_product_fields'] )
			) {
				$this->integration->get_product_mapper()->set_fields( $args['_product_fields'] );
			}
			if (
				isset( $args['_variation_fields'] )
				&& is_string( $args['_variation_fields'] ) &&
				! empty( $args['_variation_fields'] )
			) {
				$this->integration->get_product_mapper()->set_variation_fields( $args['_variation_fields'] );
			}

			$walker->walk(
				function ( WalkerProgress $progress ) use ( &$status, $option_key ) {
					$status = $this->update_feed_progress( $status, $progress );
					update_option( $option_key, $status );
				}
			);

			// Store the final details.
			$status['state']        = self::STATE_COMPLETED;
			$status['url']          = $feed->get_file_url();
			$status['path']         = $feed->get_file_path();
			$status['completed_at'] = time();
			update_option( $option_key, $status );

			// Schedule another action to delete the file after the expiry time.
			// @phpstan-ignore-next-line function.notFound -- Action Scheduler.
			as_schedule_single_action(
				time() + self::FEED_EXPIRY,
				self::FEED_DELETION_ACTION,
				array(
					$option_key,
					$feed->get_file_path(),
				),
				'woo-product-feed',
				true
			);
		} catch ( \Throwable $e ) {
			wc_get_logger()->error(
				'Feed generation failed',
				array(
					'error'      => $e->getMessage(),
					'option_key' => $option_key,
				)
			);

			$status['state']     = self::STATE_FAILED;
			$status['error']     = $e->getMessage();
			$status['failed_at'] = time();
			update_option( $option_key, $status );
		}
	}

	/**
	 * Forces a regeneration of the feed.
	 *
	 * @since 10.5.0
	 *
	 * @param array|null $args The arguments to pass to the action.
	 * @return array The feed generation status.
	 * @throws \Exception When there is a reason why the regeneration cannot be forced.
	 */
	public function force_regeneration( ?array $args = null ): array {
		$option_key = $this->get_option_key( $args );
		$status     = get_option( $option_key );

		// If there is no option, there is nothing to force. If the option is invalid, we can restart.
		if ( ! is_array( $status ) || ! $this->validate_status( $status ) ) {
			return $this->get_status( $args );
		}

		switch ( $status['state'] ?? '' ) {
			case self::STATE_SCHEDULED:
				// If generation is scheduled, we can just let it be and return the current status.
				// It should start shortly.
				return $status;

			case self::STATE_IN_PROGRESS:
				throw new \Exception( 'Feed generation is already in progress and cannot be stopped.' );

			case self::STATE_COMPLETED:
				// Delete the existing file, clear the option and let generation start again.
				wp_delete_file( (string) $status['path'] );
				delete_option( $option_key );
				return $this->get_status( $args );

			case self::STATE_FAILED:
				// Clear the failed status and restart generation.
				delete_option( $option_key );
				return $this->get_status( $args );

			default:
				throw new \Exception( 'Unknown feed generation state.' );
		}
	}

	/**
	 * Action scheduler callback for the feed deletion after expiry.
	 *
	 * @since 10.5.0
	 *
	 * @param string $option_key The option key for the feed generation status.
	 * @param string $path       The path to the feed file.
	 * @return void
	 */
	public function feed_deletion_action( string $option_key, string $path ) {
		delete_option( $option_key );
		wp_delete_file( $path );
	}

	/**
	 * Returns the option key for the feed generation status.
	 *
	 * @param array|null $args The arguments to pass to the action.
	 * @return string          The option key.
	 */
	private function get_option_key( ?array $args = null ): string {
		$normalized_args = $args ?? array();
		if ( ! empty( $normalized_args ) ) {
			ksort( $normalized_args );
		}

		return 'feed_status_' . md5(
			// WPCS dislikes serialize for security reasons, but it will be hashed immediately.
			// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
			serialize(
				array(
					'integration' => $this->integration->get_id(),
					'args'        => $normalized_args,
				)
			)
		);
	}

	/**
	 * Updates the feed progress while the feed is being generated.
	 *
	 * @param array          $status   The last previously known status.
	 * @param WalkerProgress $progress The progress of the walker.
	 * @return array                   Updated status of the feed generation.
	 */
	private function update_feed_progress( array $status, WalkerProgress $progress ): array {
		$status['progress']  = $progress->total_count > 0
			? round( ( $progress->processed_items / $progress->total_count ) * 100, 2 )
			: 0;
		$status['processed'] = $progress->processed_items;
		$status['total']     = $progress->total_count;
		return $status;
	}

	/**
	 * Validates the status of the feed generation.
	 *
	 * Makes sure that the file exists for completed jobs,
	 * that scheduled jobs are not stuck, etc.
	 *
	 * @param array $status The status of the feed generation.
	 * @return bool         True if the status is valid, false otherwise.
	 */
	private function validate_status( array $status ): bool {
		/**
		 * For completed jobs, make sure the file still exists. Regenerate otherwise.
		 *
		 * The file should typically get deleted at the same time as the status is cleared.
		 * However, something else could cause the file to disappear in the meantime (ex. manual delete).
		 *
		 * Also, if the cleanup job failed, the feed might appear as complete, but be expired.
		 */
		if ( self::STATE_COMPLETED === $status['state'] ) {
			if ( ! file_exists( $status['path'] ) ) {
				return false;
			}

			if ( ! isset( $status['completed_at'] ) ) {
				return false;
			}

			if ( $status['completed_at'] + self::FEED_EXPIRY < time() ) {
				return false;
			}
		}

		/**
		 * If the job has been scheduled more than 10 minutes ago but has not
		 * transitioned to IN_PROGRESS yet, ActionScheduler is typically stuck.
		 */

		/**
		 * Allows the timeout for a feed to remain in `scheduled` state to be changed.
		 *
		 * @param int $stuck_time The stuck time in seconds.
		 * @return int The stuck time in seconds.
		 * @since 10.5.0
		 */
		$scheduled_timeout = apply_filters( 'woocommerce_product_feed_scheduled_timeout', 10 * MINUTE_IN_SECONDS );
		if (
			self::STATE_SCHEDULED === $status['state']
			&& (
				! isset( $status['scheduled_at'] )
				|| time() - $status['scheduled_at'] > $scheduled_timeout
			)
		) {
			return false;
		}

		// All good.
		return true;
	}
}
PK     [1]GW  W  5  ProductFeed/Integrations/POSCatalog/FeedValidator.phpnu         <?php
/**
 *  Feed Validator class.
 *
 * @package Automattic\WooCommerce\Internal\ProductFeed
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog;

use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedValidatorInterface;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Simple field validator for the POS catalog.
 *
 * @since 10.5.0
 */
final class FeedValidator implements FeedValidatorInterface {
	/**
	 * Validate single feed row using schema.
	 *
	 * @param array       $entry   Product data row to validate.
	 * @param \WC_Product $product The related product. Will be updated with validation status.
	 * @return array Array of validation issues.
	 */
	public function validate_entry( array $entry, \WC_Product $product ): array { //phpcs:ignore VariableAnalysis
		return array();
	}
}
PK     [1]i_    5  ProductFeed/Integrations/POSCatalog/ProductMapper.phpnu         <?php
/**
 * ProductMapper class.
 *
 * @package Automattic\WooCommerce\Internal\ProductFeed
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog;

use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductMapperInterface;
use WC_Product;
use WC_REST_Products_Controller;
use WC_REST_Product_Variations_Controller;
use WP_REST_Request;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Product Mapper for the POS catalog.
 *
 * Uses WooCommerce REST API controllers to map product data.
 *
 * @since 10.5.0
 */
class ProductMapper implements ProductMapperInterface {
	/**
	 * Fields to include in the product mapping.
	 *
	 * @var string|null Fields to include in the product mapping.
	 */
	private ?string $fields = null;

	/**
	 * Fields to include in the variation mapping.
	 *
	 * @var string|null Fields to include in the variation mapping.
	 */
	private ?string $variation_fields = null;

	/**
	 * REST controller instance for products.
	 *
	 * @var WC_REST_Products_Controller|null
	 */
	private ?WC_REST_Products_Controller $products_controller = null;

	/**
	 * REST controller instance for variations.
	 *
	 * @var WC_REST_Product_Variations_Controller|null
	 */
	private ?WC_REST_Product_Variations_Controller $variations_controller = null;

	/**
	 * Cached REST request instance for products.
	 *
	 * @var WP_REST_Request<array<string, mixed>>|null
	 */
	private ?WP_REST_Request $products_request = null;

	/**
	 * Cached REST request instance for variations.
	 *
	 * @var WP_REST_Request<array<string, mixed>>|null
	 */
	private ?WP_REST_Request $variations_request = null;

	/**
	 * Initialize the mapper.
	 *
	 * @internal
	 * @return void
	 */
	final public function init(): void {
		$this->products_controller   = new WC_REST_Products_Controller();
		$this->variations_controller = new WC_REST_Product_Variations_Controller();
	}

	/**
	 * Set fields to include in the product mapping.
	 *
	 * @since 10.5.0
	 *
	 * @param string|null $fields Fields to include in the product mapping.
	 * @return void
	 */
	public function set_fields( ?string $fields = null ): void {
		$this->fields           = $fields;
		$this->products_request = null; // Invalidate the cached request.
	}

	/**
	 * Set fields to include in the variation mapping.
	 *
	 * @since 10.5.0
	 *
	 * @param string|null $fields Fields to include in the variation mapping.
	 * @return void
	 */
	public function set_variation_fields( ?string $fields = null ): void {
		$this->variation_fields   = $fields;
		$this->variations_request = null; // Invalidate the cached request.
	}

	/**
	 * Map WooCommerce product to catalog row
	 *
	 * @since 10.5.0
	 *
	 * @param WC_Product $product Product to map.
	 * @return array Mapped product data array.
	 * @throws \RuntimeException If the controller is not initialized.
	 */
	public function map_product( WC_Product $product ): array {
		$is_variation = $product->is_type( 'variation' );
		$controller   = $is_variation
			? $this->variations_controller
			: $this->products_controller;

		// This should never be the case, as the class should be loaded through DI.
		if ( null === $controller ) {
			throw new \RuntimeException( 'ProductMapper::init() must be called before map_product().' );
		}

		$request  = $is_variation ? $this->get_variations_request() : $this->get_products_request();
		$response = $controller->prepare_object_for_response( $product, $request );

		// Apply _fields filtering (normally done by REST server dispatch).
		$fields = $is_variation ? $this->variation_fields : $this->fields;
		if ( null !== $fields ) {
			$response = rest_filter_response_fields( $response, rest_get_server(), $request );
		}

		$row = array(
			'type' => $product->get_type(),
			'data' => $response->get_data(),
		);

		/**
		 * Filter mapped catalog product data.
		 *
		 * @since 10.5.0
		 * @param array      $row     Mapped product data.
		 * @param WC_Product $product Product object.
		 */
		return apply_filters( 'woocommerce_pos_catalog_map_product', $row, $product );
	}

	/**
	 * Get the REST request instance for products.
	 *
	 * @return WP_REST_Request<array<string, mixed>>
	 */
	protected function get_products_request(): WP_REST_Request {
		if ( null === $this->products_request ) {
			/**
			 * Type hint for PHPStan generics.
			 *
			 * @var WP_REST_Request<array<string, mixed>> $request
			 * */
			$request                = new WP_REST_Request( 'GET' );
			$this->products_request = $request;
			$this->products_request->set_param( 'context', 'view' );

			if ( null !== $this->fields ) {
				$this->products_request->set_param( '_fields', $this->fields );
			}
		}

		return $this->products_request;
	}

	/**
	 * Get the REST request instance for variations.
	 *
	 * @return WP_REST_Request<array<string, mixed>>
	 */
	protected function get_variations_request(): WP_REST_Request {
		if ( null === $this->variations_request ) {
			/**
			 * Type hint for PHPStan generics.
			 *
			 * @var WP_REST_Request<array<string, mixed>> $request
			 */
			$request                  = new WP_REST_Request( 'GET' );
			$this->variations_request = $request;
			$this->variations_request->set_param( 'context', 'view' );

			if ( null !== $this->variation_fields ) {
				$this->variations_request->set_param( '_fields', $this->variation_fields );
			}
		}

		return $this->variations_request;
	}
}
PK     [1]     @  ProductFeed/Integrations/POSCatalog/POSProductVisibilitySync.phpnu         <?php
/**
 * POS Product Visibility Sync class.
 *
 * @package Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Handles syncing pos_product_visibility taxonomy to products and variations.
 *
 * When a variable product is marked as hidden from POS, all its variations
 * should also be marked as hidden. This class ensures that:
 * - Products and their variations have the correct pos-hidden term
 * - New variations inherit the pos-hidden term from their parent
 *
 * @since 10.5.0
 */
class POSProductVisibilitySync {

	/**
	 * Register hooks for syncing POS visibility.
	 *
	 * @since 10.5.0
	 *
	 * @return void
	 */
	public function register_hooks(): void {
		add_action( 'woocommerce_new_product_variation', array( $this, 'inherit_parent_pos_visibility' ), 10, 2 );
	}

	/**
	 * Set POS visibility for a product and its variations.
	 *
	 * This method sets or removes the pos-hidden term on the product,
	 * and if it's a variable product, syncs the visibility to all variations.
	 *
	 * @since 10.5.0
	 *
	 * @param int  $product_id     The product ID.
	 * @param bool $visible_in_pos Whether the product should be visible in POS.
	 * @return void
	 */
	public function set_product_pos_visibility( int $product_id, bool $visible_in_pos ): void {
		$is_currently_visible = ! has_term( 'pos-hidden', 'pos_product_visibility', $product_id );

		if ( $is_currently_visible === $visible_in_pos ) {
			return; // No change detected.
		}

		if ( $visible_in_pos ) {
			wp_remove_object_terms( $product_id, 'pos-hidden', 'pos_product_visibility' );
		} else {
			wp_set_object_terms( $product_id, 'pos-hidden', 'pos_product_visibility' );
		}

		$product = wc_get_product( $product_id );
		if ( $product && $product->is_type( 'variable' ) ) {
			$this->sync_pos_visibility_to_variations( $product, $visible_in_pos );
		}
	}

	/**
	 * Sync POS visibility to all variations of a variable product.
	 *
	 * @since 10.5.0
	 *
	 * @param \WC_Product $product        The variable product.
	 * @param bool        $visible_in_pos Whether the product should be visible in POS.
	 * @return void
	 */
	private function sync_pos_visibility_to_variations( \WC_Product $product, bool $visible_in_pos ): void {
		$variation_ids = $product->get_children();
		foreach ( $variation_ids as $variation_id ) {
			if ( $visible_in_pos ) {
				wp_remove_object_terms( $variation_id, 'pos-hidden', 'pos_product_visibility' );
			} else {
				wp_set_object_terms( $variation_id, 'pos-hidden', 'pos_product_visibility' );
			}

			// Save variation to update date_modified.
			$variation = wc_get_product( $variation_id );
			if ( $variation ) {
				$variation->save();
			}
		}
	}

	/**
	 * Inherit POS visibility from parent when a new variation is created.
	 *
	 * When a new variation is created, check if the parent product has the
	 * pos-hidden term and apply it to the variation if so.
	 *
	 * @since 10.5.0
	 *
	 * @param int                        $variation_id The variation ID.
	 * @param \WC_Product_Variation|null $variation    The variation object.
	 * @return void
	 */
	public function inherit_parent_pos_visibility( $variation_id, $variation ): void {
		if ( ! $variation instanceof \WC_Product_Variation ) {
			return;
		}

		$parent_id = $variation->get_parent_id();
		if ( has_term( 'pos-hidden', 'pos_product_visibility', $parent_id ) ) {
			wp_set_object_terms( $variation_id, 'pos-hidden', 'pos_product_visibility' );
		}
	}
}
PK     [1]0#%=  =  1  ProductFeed/Integrations/IntegrationInterface.phpnu         <?php
/**
 * Interface that should be implemented by all provider integrations.
 *
 * @package Automattic\WooCommerce\Internal\ProductFeed
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations;

use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedInterface;
use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedValidatorInterface;
use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductMapperInterface;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * IntegrationInterface
 *
 * @since 10.5.0
 */
interface IntegrationInterface {
	/**
	 * Get the ID of the provider.
	 *
	 * @return string The ID of the provider.
	 */
	public function get_id(): string;

	/**
	 * Register hooks for the integration.
	 *
	 * @return void
	 */
	public function register_hooks(): void;

	/**
	 * Activate the integration.
	 *
	 * This method is called when the plugin is activated.
	 * If there is ever a setting that controls active integrations,
	 * this method might also be called when the integration is activated.
	 *
	 * @return void
	 */
	public function activate(): void;

	/**
	 * Deactivate the integration.
	 *
	 * This method is called when the plugin is deactivated.
	 * If there is ever a setting that controls active integrations,
	 * this method might also be called when the integration is deactivated.
	 *
	 * @return void
	 */
	public function deactivate(): void;

	/**
	 * Get the query arguments for the product feed.
	 *
	 * @see wc_get_products()
	 * @return array The query arguments.
	 */
	public function get_product_feed_query_args(): array;

	/**
	 * Create a feed that is to be populated.
	 *
	 * @return FeedInterface The feed.
	 */
	public function create_feed(): FeedInterface;

	/**
	 * Get the product mapper for the provider.
	 *
	 * @return ProductMapperInterface The product mapper.
	 */
	public function get_product_mapper(): ProductMapperInterface;

	/**
	 * Get the feed validator for the provider.
	 *
	 * @return FeedValidatorInterface The feed validator.
	 */
	public function get_feed_validator(): FeedValidatorInterface;
}
PK     [1]Z5  5  0  ProductFeed/Integrations/IntegrationRegistry.phpnu         <?php
/**
 * Integration Registry class.
 *
 * Stores all provider integrations that are available.
 *
 * @package Automattic\WooCommerce\Internal\ProductFeed
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * IntegrationRegistry
 *
 * @since 10.5.0
 */
class IntegrationRegistry {
	/**
	 * List of all available Integrations.
	 *
	 * @var array<string,IntegrationInterface>
	 */
	private array $integrations = array();

	/**
	 * Register an Integration.
	 *
	 * @since 10.5.0
	 *
	 * @param IntegrationInterface $integration The integration to register.
	 */
	public function register_integration( IntegrationInterface $integration ): void {
		$this->integrations[ $integration->get_id() ] = $integration;
	}

	/**
	 * Get an Integration by ID.
	 *
	 * @since 10.5.0
	 *
	 * @param string $id The ID of the Integration.
	 * @return IntegrationInterface|null The Integration, or null if it is not registered.
	 */
	public function get_integration( string $id ): ?IntegrationInterface {
		return $this->integrations[ $id ] ?? null;
	}

	/**
	 * Get all registered integrations.
	 *
	 * @since 10.5.0
	 *
	 * @return array<string,IntegrationInterface>
	 */
	public function get_integrations(): array {
		return $this->integrations;
	}
}
PK     [1]7$  $  $  ProductFeed/Storage/JsonFileFeed.phpnu         <?php
/**
 * JSON File Feed class.
 *
 * @package Automattic\WooCommerce\Internal\ProductFeed
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFeed\Storage;

use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedInterface;
use Exception;

// This file works directly with local files. That's fine.
// phpcs:disable WordPress.WP.AlternativeFunctions

/**
 * File-backed JSON feed storage.
 *
 * This class writes JSON directly to a file, entry by entry, without keeping everything in memory.
 *
 * @since 10.5.0
 */
class JsonFileFeed implements FeedInterface {
	public const UPLOAD_DIR = 'product-feeds';

	/**
	 * Indicates if there are previous entries in the feed.
	 *
	 * @var bool
	 */
	private $has_entries = false;

	/**
	 * The base name of the feed file.
	 *
	 * @var string
	 */
	private $base_name;

	/**
	 * The name of the feed file, no directory.
	 *
	 * @var string
	 */
	private $file_name;

	/**
	 * The path to the feed file.
	 *
	 * @var string
	 */
	private $file_path;

	/**
	 * The file handle.
	 *
	 * @var resource|false|null
	 */
	private $file_handle = null;

	/**
	 * Indicates if the feed file has been completed.
	 *
	 * @var bool
	 */
	private $file_completed = false;

	/**
	 * The URL of the feed file.
	 *
	 * @var string|null
	 */
	private $file_url = null;

	/**
	 * Indicates if the feed file is in a temp directory.
	 *
	 * @var bool
	 */
	private $is_temp_filepath = false;

	/**
	 * Constructor.
	 *
	 * @param string $base_name The base name of the feed file.
	 */
	public function __construct( string $base_name ) {
		$this->base_name = $base_name;
	}

	/**
	 * Start the feed.
	 *
	 * @return void
	 * @throws Exception If the feed directory cannot be created.
	 */
	public function start(): void {
		/**
		 * Allows the current time to be overridden before a feed is stored.
		 *
		 * @param int           $time The current time.
		 * @param FeedInterface $feed The feed instance.
		 * @return int The current time.
		 * @since 10.5.0
		 */
		$current_time    = apply_filters( 'woocommerce_product_feed_time', time(), $this );
		$hash_data       = $this->base_name . gmdate( 'r', $current_time );
		$this->file_name = sprintf(
			'%s-%s-%s.json',
			$this->base_name,
			gmdate( 'Y-m-d', $current_time ),
			wp_hash( $hash_data )
		);

		// Start by trying to use a temp directory to generate the feed.
		$this->file_path   = get_temp_dir() . DIRECTORY_SEPARATOR . $this->file_name;
		$this->file_handle = fopen( $this->file_path, 'w' );
		if ( false === $this->file_handle ) {
			// Fall back to immediately using the upload directory for generation.
			$upload_dir        = $this->get_upload_dir();
			$this->file_path   = $upload_dir['path'] . $this->file_name;
			$this->file_handle = fopen( $this->file_path, 'w' );
		} else {
			$this->is_temp_filepath = true;
		}

		if ( false === $this->file_handle ) {
			throw new Exception(
				esc_html(
					sprintf(
						/* translators: %s: directory path */
						__( 'Unable to open feed file for writing: %s', 'woocommerce' ),
						$this->file_path
					)
				)
			);
		}

		// Open the array.
		fwrite( $this->file_handle, '[' );
	}

	/**
	 * Add an entry to the feed.
	 *
	 * @param array $entry The entry to add.
	 * @return void
	 */
	public function add_entry( array $entry ): void {
		if ( ! is_resource( $this->file_handle ) ) {
			return;
		}

		if ( ! $this->has_entries ) {
			$this->has_entries = true;
		} else {
			fwrite( $this->file_handle, ',' );
		}

		$json = wp_json_encode( $entry );
		if ( false !== $json ) {
			fwrite( $this->file_handle, $json );
		}
	}

	/**
	 * End the feed.
	 *
	 * @return void
	 */
	public function end(): void {
		if ( ! is_resource( $this->file_handle ) ) {
			return;
		}

		// Close the array and the file.
		fwrite( $this->file_handle, ']' );
		fclose( $this->file_handle );

		// Indicate that we have a complete file.
		$this->file_completed = true;
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_file_path(): ?string {
		if ( ! $this->file_completed ) {
			return null;
		}

		return $this->file_path;
	}

	/**
	 * {@inheritDoc}
	 *
	 * @throws Exception If the feed file cannot be moved to the upload directory.
	 */
	public function get_file_url(): ?string {
		if ( ! $this->file_completed ) {
			return null;
		}

		$upload_dir = $this->get_upload_dir();

		// Move the file to the upload directory if it is in temp.
		if ( $this->is_temp_filepath ) {
			$tmp_path        = $this->file_path;
			$this->file_path = $upload_dir['path'] . $this->file_name;
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			if ( ! @copy( $tmp_path, $this->file_path ) ) {
				$error         = error_get_last();
				$error_message = is_array( $error ) ? $error['message'] : 'Unknown error';
				throw new Exception(
					esc_html(
						sprintf(
							/* translators: %1$s: file path, %2$s: error message */
							__( 'Unable to move feed file %1$s to upload directory: %2$s', 'woocommerce' ),
							$this->file_path,
							$error_message
						)
					)
				);
			}

			unlink( $tmp_path );

			$this->is_temp_filepath = false;
		}

		// Generate the URL.
		$this->file_url = $upload_dir['url'] . $this->file_name;

		return $this->file_url;
	}

	/**
	 * Get the upload directory for the feed.
	 *
	 * @return array {
	 *     The upload directory for the feed. Both fields end with the right trailing slash.
	 *
	 *     @type string $path The path to the upload directory.
	 *     @type string $url The URL to the upload directory.
	 * }
	 * @throws Exception If the upload directory cannot be created.
	 */
	private function get_upload_dir(): array {
		// Only generate everything once.
		static $prepared;
		if ( isset( $prepared ) ) {
			return $prepared;
		}

		$upload_dir     = wp_upload_dir( null, true );
		$directory_path = $upload_dir['basedir'] . DIRECTORY_SEPARATOR . self::UPLOAD_DIR . DIRECTORY_SEPARATOR;

		// Try to create the directory if it does not exist.
		if ( ! is_dir( $directory_path ) ) {
			FilesystemUtil::mkdir_p_not_indexable( $directory_path );
		}

		// `mkdir_p_not_indexable()` returns `void`, we have to check again.
		if ( ! is_dir( $directory_path ) ) {
			throw new Exception(
				esc_html(
					sprintf(
						/* translators: %s: directory path */
						__( 'Unable to create feed directory: %s', 'woocommerce' ),
						$directory_path
					)
				)
			);
		}

		$directory_url = $upload_dir['baseurl'] . '/' . self::UPLOAD_DIR . '/';

		// Follow the format, returned by `wp_upload_dir()`.
		$prepared = array(
			'path' => $directory_path,
			'url'  => $directory_url,
		);
		return $prepared;
	}
}
PK     [1]2SC    #  ProductFeed/Utils/MemoryManager.phpnu         <?php
/**
 * Memory Manager class.
 *
 * @package Automattic\WooCommerce\Internal\ProductFeed
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFeed\Utils;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Helper class for managing memory.
 *
 * @since 10.5.0
 */
class MemoryManager {
	/**
	 * Get available memory as a percentage of the total memory limit.
	 *
	 * @since 10.5.0
	 *
	 * @return int Available memory as a percentage of the total memory limit.
	 */
	public function get_available_memory(): int {
		$memory_limit = wp_convert_hr_to_bytes( ini_get( 'memory_limit' ) );
		if ( 0 >= $memory_limit ) {
			// Some systems have "unlimited" memory.
			// We should treat that as if there is none left.
			return 0;
		}
		return (int) round( 100 - ( memory_get_usage( true ) / $memory_limit ) * 100 );
	}

	/**
	 * Flush all caches.
	 *
	 * @since 10.5.0
	 */
	public function flush_caches(): void {
		global $wpdb, $wp_object_cache;

		$wpdb->queries = array();

		wp_cache_flush();

		if ( ! is_object( $wp_object_cache ) ) {
			return;
		}

		// These properties exist on various object cache implementations.
		$wp_object_cache->group_ops      = array(); // @phpstan-ignore property.notFound
		$wp_object_cache->stats          = array(); // @phpstan-ignore property.notFound
		$wp_object_cache->memcache_debug = array(); // @phpstan-ignore property.notFound
		$wp_object_cache->cache          = array(); // @phpstan-ignore property.notFound

		// This method is specific to certain memcached implementations.
		if ( method_exists( $wp_object_cache, '__remoteset' ) ) {
			$wp_object_cache->__remoteset(); // important.
		}

		$this->collect_garbage();
	}

	/**
	 * Collect garbage.
	 */
	private function collect_garbage(): void {
		static $gc_threshold         = 5000;
		static $gc_too_low_in_a_row  = 0;
		static $gc_too_high_in_a_row = 0;

		$gc_threshold_step = 2_500;
		$gc_status         = gc_status();

		if ( $gc_threshold > $gc_status['threshold'] ) {
			// If PHP managed to collect memory in the meantime and established threshold lower than ours, just use theirs.
			$gc_threshold = $gc_status['threshold'];
		}

		if ( $gc_status['roots'] > $gc_threshold ) {
			$collected = gc_collect_cycles();
			if ( $collected < 100 ) {
				if ( $gc_too_low_in_a_row > 0 ) {
					$gc_too_low_in_a_row = 0;
					// Raise GC threshold if we collected too little twice in a row.
					$gc_threshold += $gc_threshold_step;
					$gc_threshold  = min( $gc_threshold, 1_000_000_000, $gc_status['threshold'] );
				} else {
					++$gc_too_low_in_a_row;
				}
				$gc_too_high_in_a_row = 0;
			} else {
				if ( $gc_too_high_in_a_row > 0 ) {
					$gc_too_high_in_a_row = 0;
					// Lower GC threshold if we collected more than enough twice in a row.
					$gc_threshold -= $gc_threshold_step;
					$gc_threshold  = max( $gc_threshold, 5_000 );
				} else {
					++$gc_too_high_in_a_row;
				}
				$gc_too_low_in_a_row = 0;
			}
		}
	}
}
PK     [1]q;    "  ProductFeed/Utils/StringHelper.phpnu         <?php
/**
 *  String Helper class.
 *
 * @package Automattic\WooCommerce\Internal\ProductFeed
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFeed\Utils;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * String utility helper functions
 *
 * @since 10.5.0
 */
class StringHelper {
	/**
	 * Convert value to boolean string ('true' or 'false')
	 *
	 * @since 10.5.0
	 *
	 * @param mixed $value Value to convert.
	 * @return string 'true' or 'false'.
	 */
	public static function bool_string( $value ): string {
		if ( is_bool( $value ) ) {
			return $value ? 'true' : 'false';
		}
		if ( is_scalar( $value ) || null === $value ) {
			$value = strtolower( (string) $value );
		} else {
			$value = '';
		}
		return ( 'true' === $value || '1' === $value || 'yes' === $value ) ? 'true' : 'false';
	}

	/**
	 * Truncate text to specified length
	 *
	 * @since 10.5.0
	 *
	 * @param string $text Text to truncate.
	 * @param int    $max_length Maximum length.
	 * @return string Truncated text.
	 */
	public static function truncate( string $text, int $max_length ): string {
		if ( mb_strlen( $text ) > $max_length ) {
			return mb_substr( $text, 0, $max_length );
		}
		return $text;
	}
}
PK     [1][!
  
  3  CLI/Migrator/Interfaces/PlatformMapperInterface.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\CLI\Migrator\Interfaces;

/**
 * Defines the contract for classes responsible for transforming
 * raw platform data into a standardized format suitable for the WooCommerce Importer.
 */
interface PlatformMapperInterface {

	/**
	 * Maps raw platform product data to a standardized array format.
	 *
	 * @param object $platform_data The raw product data object from the source platform (e.g., Shopify product node).
	 *
	 * @return array A standardized array representing the product, understandable by the WooCommerce_Product_Importer.
	 *               The specific structure of this array needs to be defined and adhered to.
	 */
	public function map_product_data( object $platform_data ): array;
}
PK     [1]e    4  CLI/Migrator/Interfaces/PlatformFetcherInterface.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\CLI\Migrator\Interfaces;

/**
 * Defines the contract for classes responsible for retrieving
 * data (like products or orders) from a source platform API.
 *
 * Implementations should accept platform credentials via constructor:
 * public function __construct(array $credentials)
 */
interface PlatformFetcherInterface {

	/**
	 * Fetches a batch of items from the source platform.
	 *
	 * @param array $args Arguments for fetching (e.g., limit, cursor, filters).
	 *                    Specific arguments depend on the implementation.
	 *
	 * @return array An array containing:
	 *               'items'       => array Raw items fetched from the platform.
	 *               'cursor'      => ?string The cursor for the next page, or null if no more pages.
	 *               'has_next_page' => bool Indicates if there are more pages to fetch.
	 */
	public function fetch_batch( array $args ): array;

	/**
	 * Fetches the estimated total count of items available for migration.
	 *
	 * Used primarily for progress indicators. If a total count is not available,
	 * this method should return 0.
	 *
	 * @param array $args Arguments for filtering the count (e.g., status, date range).
	 *                    Specific arguments depend on the implementation.
	 *
	 * @return int The total estimated count.
	 */
	public function fetch_total_count( array $args ): int;
}
PK     [1]A|l    (  CLI/Migrator/Core/ProductsController.phpnu         <?php
/**
 * Products Controller
 *
 * @package Automattic\WooCommerce\Internal\CLI\Migrator\Core
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\CLI\Migrator\Core;

use Automattic\WooCommerce\Internal\CLI\Migrator\Core\CredentialManager;
use Automattic\WooCommerce\Internal\CLI\Migrator\Core\MigratorTracker;
use Automattic\WooCommerce\Internal\CLI\Migrator\Core\PlatformRegistry;
use Automattic\WooCommerce\Internal\CLI\Migrator\Core\WooCommerceProductImporter;
use Automattic\WooCommerce\Internal\CLI\Migrator\Lib\ImportSession;
use Exception;
use WP_CLI;

defined( 'ABSPATH' ) || exit;

/**
 * ProductsController class.
 *
 * Main orchestration engine for product migration that integrates existing components
 * (PlatformRegistry, CredentialManager, ShopifyFetcher/Mapper, ImportSession) to create
 * a cohesive migration system with cursor-based resumption.
 *
 * @internal This class is part of the CLI Migrator feature and should not be used directly.
 */
class ProductsController {

	/**
	 * The credential manager.
	 *
	 * @var CredentialManager
	 */
	private CredentialManager $credential_manager;

	/**
	 * The platform registry.
	 *
	 * @var PlatformRegistry
	 */
	private PlatformRegistry $platform_registry;

	/**
	 * Current import session.
	 *
	 * @var ImportSession|null
	 */
	private ?ImportSession $session = null;

	/**
	 * Parsed command arguments.
	 *
	 * @var array
	 */
	private array $parsed_args = array();

	/**
	 * Fields to process during migration.
	 *
	 * @var array
	 */
	private array $fields_to_process = array();

	/**
	 * WooCommerce Product Importer instance.
	 *
	 * @var WooCommerceProductImporter
	 */
	private WooCommerceProductImporter $product_importer;

	/**
	 * Migration tracker instance.
	 *
	 * @var MigratorTracker
	 */
	private MigratorTracker $tracker;

	/**
	 * Run start time for this CLI invocation (used for timing metrics).
	 *
	 * @var int
	 */
	private int $session_start_time = 0;

	/**
	 * Initialize the controller with its dependencies.
	 * Called automatically by the WooCommerce DI container.
	 *
	 * @internal
	 *
	 * @param CredentialManager          $credential_manager The credential manager.
	 * @param PlatformRegistry           $platform_registry  The platform registry.
	 * @param WooCommerceProductImporter $product_importer   The product importer.
	 * @param MigratorTracker            $tracker            The migration tracker.
	 */
	final public function init(
		CredentialManager $credential_manager,
		PlatformRegistry $platform_registry,
		WooCommerceProductImporter $product_importer,
		MigratorTracker $tracker
	): void {
		$this->credential_manager = $credential_manager;
		$this->platform_registry  = $platform_registry;
		$this->product_importer   = $product_importer;
		$this->tracker            = $tracker;
	}

	/**
	 * Main entry point for migrating products.
	 *
	 * @param array  $assoc_args Command-line arguments.
	 * @param string $platform   Optional pre-resolved platform (to avoid duplicate resolution).
	 * @return void
	 */
	public function migrate_products( array $assoc_args, string $platform = '' ): void {
		$this->parsed_args = $this->parse_and_validate_args( $assoc_args, $platform );
		if ( empty( $this->parsed_args ) ) {
			return;
		}

		$this->session_start_time = time();

		if ( $this->parsed_args['dry_run'] ) {
			WP_CLI::line( WP_CLI::colorize( '%Y--- DRY RUN MODE ENABLED ---%n' ) );
			WP_CLI::line( 'No products will be created or modified. This is a simulation only.' );
			WP_CLI::line( '' );
		}

		if ( ! $this->parsed_args['dry_run'] ) {
			$this->session = $this->manage_session_lifecycle( $this->parsed_args );
			if ( ! $this->session ) {
				return;
			}

			/**
			 * Fires when a migration session starts.
			 *
			 * @since 10.3.0
			 *
			 * @param string $platform The platform being migrated from.
			 * @param array  $metadata Session metadata including session_id, filters, and fields.
			 */
			do_action(
				'wc_migrator_session_started',
				$this->parsed_args['platform'],
				array(
					'session_id' => $this->session->get_id(),
					'filters'    => $this->parsed_args['filters'],
					'fields'     => $this->fields_to_process,
					'is_dry_run' => $this->parsed_args['dry_run'],
					'resume'     => $this->parsed_args['resume'],
				)
			);
		}

		$fetcher = $this->platform_registry->get_fetcher( $this->parsed_args['platform'] );
		$mapper  = $this->platform_registry->get_mapper( $this->parsed_args['platform'], array( 'fields' => $this->fields_to_process ) );

		$total_count = $fetcher->fetch_total_count( $this->parsed_args['filters'] );

		if ( ! $this->parsed_args['dry_run'] ) {
			$existing_total = $this->session->count_all_total_entities();
			if ( 0 < $total_count && 0 === $existing_total ) {
				$this->session->bump_total_number_of_entities( array( 'post' => $total_count ) );
			}
		}

		WP_CLI::line( "Total entities found: {$total_count}" );
		$progress_label = $this->parsed_args['dry_run']
			? 'Simulating Products from ' . ucfirst( $this->parsed_args['platform'] )
			: 'Importing Products from ' . ucfirst( $this->parsed_args['platform'] );
		$progress       = \WP_CLI\Utils\make_progress_bar( $progress_label, $total_count );

		// Set initial progress - either show resumed progress or 1% for new sessions.
		$initial_tick = max( 1, (int) ceil( $total_count * 0.01 ) );

		if ( ! $this->parsed_args['dry_run'] ) {
			$already_imported = $this->session->count_all_imported_entities();
			if ( $already_imported > 0 ) {
				// Show actual resumed progress.
				$progress->tick( $already_imported );
			} else {
				// Show 1% for new sessions to indicate activity has started.
				$progress->tick( $initial_tick );
			}
		} else {
			// For dry runs, show initial 1% tick.
			$progress->tick( $initial_tick );
		}

		$this->configure_product_importer();

		$this->execute_migration_loop( $fetcher, $mapper, $progress );

		$progress->finish();

		$this->display_migration_summary();

		$this->display_feedback_survey();

		if ( ! $this->parsed_args['dry_run'] ) {
			$final_stats = array(
				'total_found'    => $total_count,
				'total_imported' => $this->session->count_all_imported_entities(),
			);
			/**
			 * Fires when a migration session completes.
			 *
			 * @since 10.3.0
			 *
			 * @param string $platform    The platform being migrated from.
			 * @param array  $final_stats Final migration statistics.
			 */
			do_action( 'wc_migrator_session_completed', $this->parsed_args['platform'], $final_stats );

			$this->log_session_time_metrics( $final_stats );
		}

		if ( $this->parsed_args['dry_run'] ) {
			WP_CLI::success( 'Dry-run completed successfully. No products were actually created or modified.' );
		} else {
			WP_CLI::success( 'Migration completed successfully.' );
		}
	}

	/**
	 * Execute the main cursor-based migration loop.
	 *
	 * @param object $fetcher  The platform fetcher instance.
	 * @param object $mapper   The platform mapper instance.
	 * @param object $progress The WP_CLI progress bar instance.
	 * @return void
	 */
	private function execute_migration_loop( $fetcher, $mapper, $progress ): void {
		$limit_remaining            = $this->parsed_args['limit'];
		$session_cursor             = $this->parsed_args['dry_run'] ? null : $this->session->get_reentrancy_cursor();
		$after_cursor               = ! empty( $session_cursor ) ? $session_cursor : null;
		$has_next_page              = true;
		$total_processed_in_session = 0;

		do {
			$batch_limit = min( $this->parsed_args['batch_size'], $limit_remaining );
			if ( $batch_limit <= 0 ) {
				break;
			}

			$batch_args = array(
				'limit'        => $batch_limit,
				'after_cursor' => $after_cursor,
			);

			if ( ! empty( $this->parsed_args['filters'] ) ) {
				$batch_args = array_merge( $batch_args, $this->parsed_args['filters'] );
			}

			try {
				$batch_data = $fetcher->fetch_batch( $batch_args );
			} catch ( Exception $e ) {
				/**
				 * Fires when an error occurs during migration.
				 *
				 * @since 10.3.0
				 *
				 * @param string $error_type The type of error (fetch, mapping, import).
				 * @param string $message    The error message.
				 * @param array  $context    Additional error context.
				 */
				do_action(
					'wc_migrator_error_occurred',
					'fetch',
					$e->getMessage(),
					array(
						'batch_args' => $batch_args,
						'platform'   => $this->parsed_args['platform'],
					)
				);

				WP_CLI::warning( "Error fetching batch: {$e->getMessage()}" );
				break;
			}

			if ( empty( $batch_data['items'] ) ) {
				break;
			}

			$processed_count = $this->process_batch( $batch_data['items'], $mapper );

			$total_processed_in_session += $processed_count;

			if ( ! $this->parsed_args['dry_run'] ) {
				$this->session->bump_imported_entities_counts( array( 'post' => $processed_count ) );
				$after_cursor = $batch_data['cursor'];
				$this->session->set_reentrancy_cursor( $after_cursor );
			} else {
				$after_cursor = $batch_data['cursor'];
			}

			$limit_remaining -= count( $batch_data['items'] );
			$has_next_page    = $batch_data['has_next_page'] ?? false;

			$progress->tick( $processed_count, sprintf( 'Processed %d products', $total_processed_in_session ) );
		} while ( $has_next_page && $limit_remaining > 0 );

		if ( ! $has_next_page && ! $this->parsed_args['dry_run'] ) {
			$this->session->set_stage( ImportSession::STAGE_FINISHED );
		}
	}

	/**
	 * Parse and validate command-line arguments.
	 *
	 * @param array  $assoc_args Raw associative arguments.
	 * @param string $platform   Optional pre-resolved platform.
	 * @return array Parsed and validated arguments or empty array on error.
	 */
	private function parse_and_validate_args( array $assoc_args, string $platform = '' ): array {
		$parsed = array();

		// Platform validation - use pre-resolved platform if provided, otherwise resolve.
		if ( empty( $platform ) ) {
			$platform = $this->platform_registry->resolve_platform( $assoc_args );
			if ( empty( $platform ) ) {
				return array();
			}
		}
		$parsed['platform'] = $platform;

		$this->fields_to_process = $this->parse_field_selection( $assoc_args );

		$parsed['fields']                  = $this->fields_to_process;
		$parsed['limit']                   = isset( $assoc_args['limit'] ) ? max( 1, (int) $assoc_args['limit'] ) : PHP_INT_MAX;
		$parsed['batch_size']              = isset( $assoc_args['batch-size'] ) ? max( 1, min( 250, (int) $assoc_args['batch-size'] ) ) : 20;
		$parsed['skip_existing']           = isset( $assoc_args['skip-existing'] );
		$parsed['dry_run']                 = isset( $assoc_args['dry-run'] );
		$parsed['resume']                  = isset( $assoc_args['resume'] );
		$parsed['verbose']                 = isset( $assoc_args['verbose'] );
		$parsed['assign_default_category'] = isset( $assoc_args['assign-default-category'] );

		$parsed['filters'] = $this->parse_query_filters( $assoc_args );

		if ( ! $this->credential_manager->has_credentials( $platform ) ) {
			$platform_display_name = $this->platform_registry->get_platform_display_name( $platform );
			WP_CLI::error(
				sprintf(
					"No credentials found for platform '%s'. Please run: wp wc migrate setup --platform=%s",
					$platform_display_name,
					$platform
				)
			);
			return array();
		}

		return $parsed;
	}

	/**
	 * Parse field selection from command arguments.
	 *
	 * @param array $assoc_args Command arguments.
	 * @return array Selected fields to process.
	 */
	private function parse_field_selection( array $assoc_args ): array {
		$default_fields = array(
			'name',
			'slug',
			'description',
			'status',
			'date_created',
			'catalog_visibility',
			'categories',
			'tags',
			'price',
			'sku',
			'stock',
			'weight',
			'brand',
			'images',
			'attributes',
			'metafields',
		);

		$excluded_fields     = array();
		$explicitly_selected = false;

		if ( isset( $assoc_args['fields'] ) ) {
			$explicitly_selected = true;
			$selected_fields     = array_map( 'trim', explode( ',', $assoc_args['fields'] ) );
			$selected_fields     = array_filter( $selected_fields );

			$invalid_fields = array_diff( $selected_fields, $default_fields );
			if ( ! empty( $invalid_fields ) ) {
				WP_CLI::warning(
					sprintf(
						'Invalid field names: %s. Valid fields: %s',
						implode( ', ', $invalid_fields ),
						implode( ', ', $default_fields )
					)
				);
			}

			$fields          = array_intersect( $selected_fields, $default_fields );
			$excluded_fields = array_diff( $default_fields, $fields );
		} else {
			$fields = $default_fields;
		}

		// Handle --exclude-fields argument.
		if ( isset( $assoc_args['exclude-fields'] ) ) {
			$exclude_fields_input = array_map( 'trim', explode( ',', $assoc_args['exclude-fields'] ) );
			$excluded_fields      = array_merge( $excluded_fields, $exclude_fields_input );
			$fields               = array_diff( $fields, $exclude_fields_input );
		}

		if ( empty( $fields ) ) {
			WP_CLI::error( 'No valid fields selected for migration.' );
			return array();
		}

		// Log field selection information.
		if ( $explicitly_selected || isset( $assoc_args['exclude-fields'] ) || ! empty( $assoc_args['verbose'] ) ) {
			$include_message = sprintf( 'Including fields: %s', implode( ', ', $fields ) );
			WP_CLI::log( $include_message );
			wc_get_logger()->info( $include_message, array( 'source' => 'wc-migrator' ) );

			if ( ! empty( $excluded_fields ) ) {
				$exclude_message = sprintf( 'Excluding fields: %s', implode( ', ', array_unique( $excluded_fields ) ) );
				WP_CLI::log( $exclude_message );
				wc_get_logger()->info( $exclude_message, array( 'source' => 'wc-migrator' ) );
			}
		}

		return $fields;
	}

	/**
	 * Parse query filters for platform-agnostic filtering.
	 *
	 * @param array $assoc_args Command arguments.
	 * @return array Parsed query filters.
	 */
	private function parse_query_filters( array $assoc_args ): array {
		$filters = array();

		if ( isset( $assoc_args['status'] ) ) {
			$valid_statuses = array( 'active', 'archived', 'draft' );
			$status         = strtolower( $assoc_args['status'] );
			if ( in_array( $status, $valid_statuses, true ) ) {
				$filters['status'] = $status;
			} else {
				WP_CLI::warning(
					sprintf(
						'Invalid status "%s". Valid options: %s',
						$status,
						implode( ', ', $valid_statuses )
					)
				);
			}
		}

		if ( isset( $assoc_args['created-after'] ) ) {
			$date = $this->validate_date_filter( $assoc_args['created-after'], 'created-after' );
			if ( $date ) {
				$filters['created_after'] = $date;
			}
		}

		if ( isset( $assoc_args['created-before'] ) ) {
			$date = $this->validate_date_filter( $assoc_args['created-before'], 'created-before' );
			if ( $date ) {
				$filters['created_before'] = $date;
			}
		}

		if ( isset( $assoc_args['product-type'] ) && 'all' !== $assoc_args['product-type'] ) {
			$filters['product_type'] = $assoc_args['product-type'];
		}

		if ( isset( $assoc_args['handle'] ) ) {
			$filters['handle'] = sanitize_title( $assoc_args['handle'] );
		}

		if ( isset( $assoc_args['vendor'] ) ) {
			$filters['vendor'] = $assoc_args['vendor'];
		}

		if ( isset( $assoc_args['ids'] ) ) {
			$filters['ids'] = $assoc_args['ids'];
		}

		return $filters;
	}

	/**
	 * Validate date filter input.
	 *
	 * @param string $date_input  The date input string.
	 * @param string $filter_name The filter name for error messages.
	 * @return string|null Formatted date string or null on error.
	 */
	private function validate_date_filter( string $date_input, string $filter_name ): ?string {
		$timestamp = strtotime( $date_input );
		if ( false === $timestamp ) {
			WP_CLI::warning(
				sprintf( 'Invalid date format for --%s: %s', $filter_name, $date_input )
			);
			return null;
		}

		return gmdate( 'Y-m-d\\TH:i:s\\Z', $timestamp );
	}

	/**
	 * Manage the session lifecycle - create new or resume existing.
	 *
	 * @param array $parsed_args Parsed command arguments.
	 * @return ImportSession|null Import session instance or null on error.
	 */
	private function manage_session_lifecycle( array $parsed_args ): ?ImportSession {
		$active_session = ImportSession::get_active();

		if ( $active_session && ! $active_session->is_finished() ) {
			return $this->handle_existing_session( $active_session, $parsed_args );
		}

		return $this->create_new_session( $parsed_args );
	}

	/**
	 * Handle existing session with user prompt for resume decision.
	 *
	 * @param ImportSession $session     The existing session.
	 * @param array         $parsed_args Parsed command arguments.
	 * @return ImportSession|null Session to use or null on error.
	 */
	private function handle_existing_session( ImportSession $session, array $parsed_args ): ?ImportSession {
		// Display session information.
		$metadata = $session->get_metadata();

		$total_imported    = $session->count_all_imported_entities();
		$total_entities    = $session->count_all_total_entities();
		$started_timestamp = $session->get_started_at();
		$started_at        = is_numeric( $started_timestamp ) ?
			get_date_from_gmt( gmdate( 'Y-m-d H:i:s', (int) $started_timestamp ) ) :
			$started_timestamp;

		WP_CLI::line( '' );
		WP_CLI::line( WP_CLI::colorize( '%YExisting Migration Session Found:%n' ) );
		WP_CLI::line( sprintf( '  Session ID: %d', $session->get_id() ) );
		WP_CLI::line( sprintf( '  Platform: %s', $metadata['data_source'] ) );
		WP_CLI::line( sprintf( '  Started: %s', $started_at ) );
		WP_CLI::line( sprintf( '  Progress: %d / %d products imported', $total_imported, $total_entities ) );

		if ( ( $parsed_args['verbose'] ?? false ) && $session->get_reentrancy_cursor() ) {
			WP_CLI::line( sprintf( '  Last Cursor: %s', substr( $session->get_reentrancy_cursor(), 0, 50 ) . '...' ) );
		}

		$original_args = $session->get_original_arguments();
		if ( $original_args ) {
			WP_CLI::line( '' );
			WP_CLI::line( WP_CLI::colorize( '%YOriginal Command Arguments:%n' ) );
			$this->display_saved_arguments( $original_args );
		}

		WP_CLI::line( '' );

		$should_resume = $parsed_args['resume'] ?? false;

		if ( ! $should_resume ) {
			WP_CLI::out( 'Do you want to resume this migration session? [y/n] ' );
			$answer = $this->get_user_input();
			if ( 'y' === $answer ) {
				$should_resume = true;
			} else {
				$should_resume = false;
			}
		}

		if ( $should_resume ) {
			WP_CLI::success( sprintf( 'Resuming migration session %d', $session->get_id() ) );

			$original_args = $session->get_original_arguments();
			if ( $original_args ) {
				$this->restore_original_arguments( $original_args );
				WP_CLI::line( 'Original command arguments have been restored.' );
			}

			return $session;
		} else {
			$session->archive();
			WP_CLI::line( 'Previous session archived. Starting a new import session.' );

			$new_session = $this->create_new_session( $parsed_args );

			if ( $new_session ) {
				WP_CLI::success( sprintf( 'Starting fresh migration from the beginning (Session %d)', $new_session->get_id() ) );
			}

			return $new_session;
		}
	}

	/**
	 * Create a new import session.
	 *
	 * @param array $parsed_args Parsed command arguments.
	 * @return ImportSession|null New session instance or null on error.
	 */
	private function create_new_session( array $parsed_args ): ?ImportSession {
		try {
			$session = ImportSession::create(
				array(
					'data_source' => $parsed_args['platform'],
					'file_name'   => sprintf(
						'%s Migration - %s',
						ucfirst( $parsed_args['platform'] ),
						current_time( 'mysql' )
					),
				)
			);

			$session->set_original_arguments( $parsed_args );

			return $session;

		} catch ( Exception $e ) {
			WP_CLI::error( sprintf( 'Failed to create migration session: %s', $e->getMessage() ) );
			return null;
		}
	}

	/**
	 * Process a batch of items using the mapper and importer.
	 *
	 * @param array  $batch_items Array of source platform items.
	 * @param object $mapper      Platform mapper instance.
	 * @return int Number of successfully processed items.
	 */
	private function process_batch( array $batch_items, $mapper ): int {
		$processed_count   = 0;
		$mapped_products   = array();
		$source_data_batch = array();

		foreach ( $batch_items as $item ) {
			try {
				// Extract the actual product node from GraphQL response structure.
				// Handle both object and array GraphQL shapes.
				if ( is_object( $item ) && isset( $item->node ) ) {
					$product_data = $item->node;
				} elseif ( is_array( $item ) && isset( $item['node'] ) ) {
					$product_data = $item['node'];
				} else {
					$product_data = $item;
				}

				$mapped_product = $mapper->map_product_data( $product_data );
				if ( ! empty( $mapped_product ) ) {
					$mapped_products[]   = $mapped_product;
					$source_data_batch[] = is_object( $product_data ) ? (array) $product_data : $product_data;
				}
			} catch ( Exception $e ) {
				/**
				 * Fires when an error occurs during migration.
				 *
				 * @since 10.3.0
				 *
				 * @param string $error_type The type of error (fetch, mapping, import).
				 * @param string $message    The error message.
				 * @param array  $context    Additional error context.
				 */
				do_action(
					'wc_migrator_error_occurred',
					'mapping',
					$e->getMessage(),
					array(
						'product_data' => $product_data,
						'platform'     => $this->parsed_args['platform'],
					)
				);

				WP_CLI::warning( sprintf( 'Error mapping product: %s', $e->getMessage() ) );
				continue;
			}
		}

		if ( ! empty( $mapped_products ) ) {
			if ( $this->parsed_args['dry_run'] ) {
				$batch_results = $this->simulate_import_batch( $mapped_products );
			} else {
				$batch_results = $this->product_importer->import_batch( $mapped_products, $source_data_batch );
			}

			/**
			 * Fires when a batch has been processed during migration.
			 *
			 * @since 10.3.0
			 *
			 * @param array $batch_results   Results from the batch import.
			 * @param array $source_data     Source platform data for the batch.
			 * @param array $mapped_products Mapped WooCommerce data for the batch.
			 */
			do_action( 'wc_migrator_batch_processed', $batch_results, $source_data_batch, $mapped_products );

			$this->log_batch_results( $batch_results );
			$processed_count = $batch_results['stats']['successful'];

			if ( $processed_count > 0 && ! $this->parsed_args['dry_run'] ) {
				$current_count = get_option( 'wc_migrator_products_count', 0 );
				update_option( 'wc_migrator_products_count', $current_count + $processed_count );
			}
		}

		return $processed_count;
	}

	/**
	 * Simulate the import process for dry-run mode.
	 *
	 * @param array $mapped_products Array of mapped product data.
	 * @return array Simulated batch results matching real import format.
	 */
	private function simulate_import_batch( array $mapped_products ): array {
		$results = array();
		$stats   = array(
			'successful' => 0,
			'failed'     => 0,
			'skipped'    => 0,
		);

		foreach ( $mapped_products as $product_data ) {
			$product_name = $product_data['name'] ?? 'Unknown Product';

			if ( empty( $product_data['name'] ) ) {
				$results[] = array(
					'status'  => 'error',
					'message' => 'Product name is required',
					'data'    => $product_data,
				);
				++$stats['failed'];
				$this->simulate_stats_increment( 'errors_encountered' );
				continue;
			}

			$existing_product_id = null;
			if ( ! empty( $product_data['sku'] ) ) {
				$existing_product_id = wc_get_product_id_by_sku( $product_data['sku'] );
			}

			$would_skip = false;
			if ( $existing_product_id && $this->parsed_args['skip_existing'] ) {
				$would_skip = true;
			}

			if ( $would_skip ) {
				$results[] = array(
					'status'  => 'skipped',
					'message' => "Product '{$product_name}' would be skipped (already exists)",
					'data'    => $product_data,
				);
				++$stats['skipped'];
				$this->simulate_stats_increment( 'products_skipped' );
			} else {
				$results[] = array(
					'status'  => 'success',
					'message' => "Product '{$product_name}' would be imported",
					'data'    => $product_data,
				);
				++$stats['successful'];

				if ( $existing_product_id ) {
					$this->simulate_stats_increment( 'products_updated' );
				} else {
					$this->simulate_stats_increment( 'products_created' );
				}

				if ( in_array( 'images', $this->fields_to_process, true ) && ! empty( $product_data['images'] ) ) {
					$image_count = is_array( $product_data['images'] ) ? count( $product_data['images'] ) : 1;
					for ( $i = 0; $i < $image_count; $i++ ) {
						$this->simulate_stats_increment( 'images_processed' );
					}
				}
			}

			wc_get_logger()->info( "DRY RUN: Would import product '{$product_name}'", array( 'source' => 'wc-migrator' ) );
		}

		return array(
			'results' => $results,
			'stats'   => $stats,
		);
	}

	/**
	 * Simulate incrementing stats by using reflection to access private properties.
	 * This ensures dry-run stats match what the real import would show.
	 *
	 * @param string $stat_key The stat key to increment.
	 */
	private function simulate_stats_increment( string $stat_key ): void {
		try {
			$reflection     = new \ReflectionClass( $this->product_importer );
			$stats_property = $reflection->getProperty( 'import_stats' );
			$stats_property->setAccessible( true );

			$current_stats = $stats_property->getValue( $this->product_importer );
			if ( isset( $current_stats[ $stat_key ] ) ) {
				++$current_stats[ $stat_key ];
				$stats_property->setValue( $this->product_importer, $current_stats );
			}
		} catch ( \ReflectionException $e ) {
			wc_get_logger()->warning(
				"DRY RUN: Could not update import stats for '{$stat_key}': " . $e->getMessage(),
				array( 'source' => 'wc-migrator' )
			);
		}
	}

	/**
	 * Configure the injected product importer with options based on parsed arguments.
	 */
	private function configure_product_importer(): void {
		$import_options = array(
			'skip_existing'           => $this->parsed_args['skip_existing'] ?? false,
			'update_existing'         => ! ( $this->parsed_args['skip_existing'] ?? false ),
			'import_images'           => in_array( 'images', $this->fields_to_process, true ),
			'skip_duplicate_images'   => true,
			'create_categories'       => in_array( 'categories', $this->fields_to_process, true ),
			'create_tags'             => in_array( 'tags', $this->fields_to_process, true ),
			'handle_variations'       => in_array( 'attributes', $this->fields_to_process, true ),
			'assign_default_category' => $this->parsed_args['assign_default_category'] ?? false,
			'verbose'                 => $this->parsed_args['verbose'] ?? false,
		);

		$this->product_importer->configure( $import_options );

		if ( $this->parsed_args['verbose'] ?? false ) {
			$this->product_importer->set_progress_callback( array( $this, 'display_product_progress' ) );
		}
	}

	/**
	 * Display progress indicator for individual product imports.
	 *
	 * @param int        $current_index Current product index (1-based).
	 * @param int        $total_count   Total number of products in batch.
	 * @param string     $product_name  Name of the product being processed.
	 * @param array|null $result        Import result (null when starting, array when finished).
	 */
	public function display_product_progress( int $current_index, int $total_count, string $product_name, ?array $result ): void {
		if ( null === $result ) {
			return;
		}

		$display_name = strlen( $product_name ) > 40 ? substr( $product_name, 0, 37 ) . '...' : $product_name;

		$status_char  = '✓';
		$status_color = '%G';

		if ( 'error' === $result['status'] ) {
			$status_char  = '✗';
			$status_color = '%R';
		} elseif ( 'success' === $result['status'] && 'skipped' === $result['action'] ) {
			$status_char  = '−';
			$status_color = '%Y';
		}

		$progress = sprintf( '[%d/%d]', $current_index, $total_count );

		if ( 1 === $current_index ) {
			WP_CLI::line( '' );
		}

		WP_CLI::line(
			WP_CLI::colorize(
				sprintf( '%s%s%s %s %s', $status_color, $status_char, '%n', $progress, $display_name )
			)
		);
	}

	/**
	 * Log batch import results.
	 *
	 * @param array $batch_results Results from batch import.
	 */
	private function log_batch_results( array $batch_results ): void {
		$stats = $batch_results['stats'];

		// Only log failures and errors when verbose flag is set.
		if ( $this->parsed_args['verbose'] && $stats['failed'] > 0 ) {
			WP_CLI::warning( sprintf( '%d products failed to import', $stats['failed'] ) );

			// Log first few errors for debugging.
			$error_count = 0;
			foreach ( $batch_results['results'] as $result ) {
				if ( 'error' === $result['status'] && $error_count < 3 ) {
					WP_CLI::warning( sprintf( 'Import error: %s', $result['message'] ) );
					++$error_count;
				}
			}
		}

		// Only log skipped products if there are many and verbose is enabled.
		if ( $this->parsed_args['verbose'] && $stats['skipped'] > 5 ) {
			WP_CLI::log( sprintf( 'Skipped %d existing products', $stats['skipped'] ) );
		}
	}

	/**
	 * Display final migration summary statistics.
	 */
	private function display_migration_summary(): void {
		if ( null === $this->product_importer ) {
			return;
		}

		$stats = $this->product_importer->get_import_stats();

		WP_CLI::line( '' );
		if ( $this->parsed_args['dry_run'] ) {
			WP_CLI::line( WP_CLI::colorize( '%YDry-Run Summary:%n' ) );
			WP_CLI::line( sprintf( '  Products Would Be Created: %d', $stats['products_created'] ) );
			WP_CLI::line( sprintf( '  Products Would Be Updated: %d', $stats['products_updated'] ) );
			WP_CLI::line( sprintf( '  Products Would Be Skipped: %d', $stats['products_skipped'] ) );
			WP_CLI::line( sprintf( '  Images Would Be Processed: %d', $stats['images_processed'] ) );
		} else {
			WP_CLI::line( WP_CLI::colorize( '%YMigration Summary:%n' ) );
			WP_CLI::line( sprintf( '  Products Created: %d', $stats['products_created'] ) );
			WP_CLI::line( sprintf( '  Products Updated: %d', $stats['products_updated'] ) );
			WP_CLI::line( sprintf( '  Products Skipped: %d', $stats['products_skipped'] ) );
			WP_CLI::line( sprintf( '  Images Processed: %d', $stats['images_processed'] ) );
		}

		if ( $stats['errors_encountered'] > 0 ) {
			if ( $this->parsed_args['dry_run'] ) {
				WP_CLI::line( WP_CLI::colorize( sprintf( '  %%RValidation Errors Found: %d%%n', $stats['errors_encountered'] ) ) );
			} else {
				WP_CLI::line( WP_CLI::colorize( sprintf( '  %%RErrors Encountered: %d%%n', $stats['errors_encountered'] ) ) );
			}
		}

		WP_CLI::line( '' );
	}

	/**
	 * Log session time metrics using session-specific data.
	 *
	 * @param array $final_stats Final migration statistics.
	 */
	private function log_session_time_metrics( array $final_stats ): void {
		$session_products = $final_stats['total_imported'] ?? 0;

		if ( empty( $session_products ) ) {
			return;
		}

		if ( empty( $this->session_start_time ) ) {
			return;
		}

		$session_duration_seconds = time() - $this->session_start_time;
		$platform                 = $this->parsed_args['platform'];

		$avg_time_per_product   = $session_duration_seconds / $session_products;
		$session_time_formatted = human_time_diff( 0, $session_duration_seconds );
		$avg_time_formatted     = number_format( $avg_time_per_product, 2 );

		$platform_display_name = $this->platform_registry->get_platform_display_name( $platform );
		$metrics_message       = sprintf(
			'Session completed for %s: %d products in %s (avg: %s seconds per product)',
			$platform_display_name,
			$session_products,
			$session_time_formatted,
			$avg_time_formatted
		);

		wc_get_logger()->info( $metrics_message, array( 'source' => 'wc-migrator' ) );
	}

	/**
	 * Display feedback survey link to collect user feedback.
	 */
	private function display_feedback_survey(): void {
		WP_CLI::line( '' );
		WP_CLI::line( WP_CLI::colorize( '%GHelp us improve the WooCommerce Migrator!%n' ) );
		WP_CLI::line( 'Please share your feedback about this migration experience:' );
		WP_CLI::line( WP_CLI::colorize( '%Chttps://developer.woocommerce.com/migrator-feedback/%n' ) );
		WP_CLI::line( '' );
	}

	/**
	 * Get user input from STDIN. Separate method for easier testing.
	 *
	 * @return string User input, trimmed and lowercased.
	 */
	protected function get_user_input(): string {
		return strtolower( trim( fgets( STDIN ) ) );
	}

	/**
	 * Display the saved arguments from a previous session.
	 *
	 * @param array $args The saved arguments to display.
	 */
	private function display_saved_arguments( array $args ): void {
		$important_args = array(
			'platform'                => 'Platform',
			'limit'                   => 'Product Limit',
			'batch_size'              => 'Batch Size',
			'skip_existing'           => 'Skip Existing',
			'dry_run'                 => 'Dry Run',
			'verbose'                 => 'Verbose',
			'assign_default_category' => 'Assign Default Category',
		);

		foreach ( $important_args as $key => $label ) {
			if ( isset( $args[ $key ] ) ) {
				$value = $args[ $key ];
				if ( is_bool( $value ) ) {
					$value = $value ? 'Yes' : 'No';
				} elseif ( is_array( $value ) ) {
					$value = implode( ', ', $value );
				} elseif ( 'limit' === $key && PHP_INT_MAX === (int) $value ) {
					$value = 'All';
				}
				WP_CLI::line( sprintf( '  %s: %s', $label, $value ) );
			}
		}

		if ( ! empty( $args['filters'] ) && is_array( $args['filters'] ) ) {
			WP_CLI::line( '  Filters:' );
			foreach ( $args['filters'] as $filter_key => $filter_value ) {
				if ( is_array( $filter_value ) ) {
					$filter_value = implode( ', ', $filter_value );
				}
				WP_CLI::line( sprintf( '    %s: %s', $filter_key, $filter_value ) );
			}
		}

		if ( ! empty( $args['fields'] ) && is_array( $args['fields'] ) ) {
			WP_CLI::line( sprintf( '  Fields: %s', implode( ', ', $args['fields'] ) ) );
		}
	}

	/**
	 * Restore the original arguments to the current parsed args.
	 *
	 * @param array $original_args The original arguments to restore.
	 */
	private function restore_original_arguments( array $original_args ): void {
		foreach ( $original_args as $key => $value ) {
			if ( 'resume' !== $key ) {
				$this->parsed_args[ $key ] = $value;
			}
		}

		if ( isset( $original_args['fields'] ) ) {
			$this->fields_to_process = $original_args['fields'];
		}
	}
}
PK     [1]6]9&  &  &  CLI/Migrator/Core/PlatformRegistry.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\CLI\Migrator\Core;

use InvalidArgumentException;
use Automattic\WooCommerce\Internal\CLI\Migrator\Interfaces\PlatformFetcherInterface;
use Automattic\WooCommerce\Internal\CLI\Migrator\Interfaces\PlatformMapperInterface;
use WP_CLI;

/**
 * PlatformRegistry class.
 *
 * This class is responsible for loading and providing access to registered migration platforms.
 */
class PlatformRegistry {

	/**
	 * An array to hold the configuration for all registered platforms.
	 *
	 * @var array
	 */
	private array $platforms = array();

	/**
	 * The credential manager instance.
	 *
	 * @var CredentialManager
	 */
	private CredentialManager $credential_manager;

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->load_platforms();
	}

	/**
	 * Initialize the registry with dependencies.
	 *
	 * @internal
	 * @param CredentialManager $credential_manager The credential manager.
	 */
	final public function init( CredentialManager $credential_manager ): void {
		$this->credential_manager = $credential_manager;
	}

	/**
	 * Loads platforms discovered via a filter.
	 *
	 * It also validates that each registered platform provides both a fetcher and a mapper class.
	 */
	private function load_platforms(): void {
		/**
		 * Filters the list of registered migration platforms.
		 *
		 * External platform plugins should hook into this filter to register themselves.
		 * Each platform plugin is responsible for its own autoloading and initialization.
		 *
		 * @param array $platforms An associative array of platform configurations.
		 *                         Each key is a unique platform ID (e.g., 'shopify'), and the value
		 *                         is another array containing 'name', 'fetcher', and 'mapper' class names.
		 * @since 1.0.0
		 */
		$platforms = apply_filters( 'woocommerce_migrator_platforms', array() );

		if ( ! is_array( $platforms ) ) {
			return;
		}

		foreach ( $platforms as $platform_id => $config ) {
			// Validate that required keys exist and have valid values.
			if ( isset( $config['fetcher'], $config['mapper'] ) &&
				is_string( $config['fetcher'] ) && ! empty( $config['fetcher'] ) &&
				is_string( $config['mapper'] ) && ! empty( $config['mapper'] ) ) {
				$this->platforms[ $platform_id ] = $config;
			}
		}
	}

	/**
	 * Returns the entire array of registered platform configurations.
	 *
	 * @return array
	 */
	public function get_platforms(): array {
		return $this->platforms;
	}

	/**
	 * Returns the configuration array for a single, specified platform ID.
	 *
	 * @param string $platform_id The ID of the platform (e.g., 'shopify').
	 *
	 * @return array|null The platform configuration or null if not found.
	 */
	public function get_platform( string $platform_id ): ?array {
		return $this->platforms[ $platform_id ] ?? null;
	}

	/**
	 * Retrieves and instantiates the fetcher class for a given platform.
	 *
	 * @param string $platform_id The ID of the platform.
	 *
	 * @return PlatformFetcherInterface An instance of the platform's fetcher class.
	 *
	 * @throws InvalidArgumentException If the platform is not found, fetcher class is invalid, or credentials are not configured.
	 */
	public function get_fetcher( string $platform_id ): PlatformFetcherInterface {
		$platform = $this->get_platform( $platform_id );

		if ( ! $platform ) {
			throw new InvalidArgumentException(
				sprintf(
					/* translators: %s: Platform ID */
					esc_html__( 'Platform %s not found.', 'woocommerce' ),
					esc_html( $platform_id )
				)
			);
		}

		$fetcher_class = $platform['fetcher'];

		// Validate that fetcher class is a non-empty string.
		if ( ! is_string( $fetcher_class ) || empty( $fetcher_class ) ) {
			throw new InvalidArgumentException(
				sprintf(
					/* translators: %s: Platform ID */
					esc_html__( 'Invalid fetcher class for platform %s. Fetcher must be a non-empty string.', 'woocommerce' ),
					esc_html( $platform_id )
				)
			);
		}

		if ( ! class_exists( $fetcher_class ) ) {
			throw new InvalidArgumentException(
				sprintf(
					/* translators: %1$s: Platform ID, %2$s: Class name */
					esc_html__( 'Invalid fetcher class for platform %1$s. Class %2$s does not exist.', 'woocommerce' ),
					esc_html( $platform_id ),
					esc_html( $fetcher_class )
				)
			);
		}

		if ( ! in_array( PlatformFetcherInterface::class, class_implements( $fetcher_class ), true ) ) {
			throw new InvalidArgumentException(
				sprintf(
					/* translators: %1$s: Platform ID, %2$s: Class name, %3$s: Interface name */
					esc_html__( 'Invalid fetcher class for platform %1$s. Class %2$s does not implement %3$s.', 'woocommerce' ),
					esc_html( $platform_id ),
					esc_html( $fetcher_class ),
					esc_html( PlatformFetcherInterface::class )
				)
			);
		}

		// Get credentials from credential manager and pass to fetcher constructor.
		$credentials = $this->credential_manager->get_credentials( $platform_id );
		if ( ! is_array( $credentials ) ) {
			throw new InvalidArgumentException(
				sprintf(
					/* translators: %s: platform ID */
					'No credentials found for platform "%s". Please configure credentials using: wp wc migrate setup',
					esc_html( $platform_id )
				)
			);
		}
		return new $fetcher_class( $credentials );
	}

	/**
	 * Retrieves and instantiates the mapper class for a given platform.
	 *
	 * @param string $platform_id The ID of the platform.
	 * @param array  $args Optional arguments to pass to the mapper constructor.
	 *
	 * @return PlatformMapperInterface An instance of the platform's mapper class.
	 *
	 * @throws InvalidArgumentException If the platform is not found or the mapper class is invalid.
	 */
	public function get_mapper( string $platform_id, array $args = array() ): PlatformMapperInterface {
		$platform = $this->get_platform( $platform_id );

		if ( ! $platform ) {
			throw new InvalidArgumentException(
				sprintf(
					/* translators: %s: Platform ID */
					esc_html__( 'Platform %s not found.', 'woocommerce' ),
					esc_html( $platform_id )
				)
			);
		}

		$mapper_class = $platform['mapper'];

		// Validate that mapper class is a non-empty string.
		if ( ! is_string( $mapper_class ) || empty( $mapper_class ) ) {
			throw new InvalidArgumentException(
				sprintf(
					/* translators: %s: Platform ID */
					esc_html__( 'Invalid mapper class for platform %s. Mapper must be a non-empty string.', 'woocommerce' ),
					esc_html( $platform_id )
				)
			);
		}

		if ( ! class_exists( $mapper_class ) ) {
			throw new InvalidArgumentException(
				sprintf(
					/* translators: %1$s: Platform ID, %2$s: Class name */
					esc_html__( 'Invalid mapper class for platform %1$s. Class %2$s does not exist.', 'woocommerce' ),
					esc_html( $platform_id ),
					esc_html( $mapper_class )
				)
			);
		}

		if ( ! in_array( PlatformMapperInterface::class, class_implements( $mapper_class ), true ) ) {
			throw new InvalidArgumentException(
				sprintf(
					/* translators: %1$s: Platform ID, %2$s: Class name, %3$s: Interface name */
					esc_html__( 'Invalid mapper class for platform %1$s. Class %2$s does not implement %3$s.', 'woocommerce' ),
					esc_html( $platform_id ),
					esc_html( $mapper_class ),
					esc_html( PlatformMapperInterface::class )
				)
			);
		}

		// If arguments are provided, instantiate manually to pass constructor args.
		// Otherwise, use the WooCommerce DI container for dependency injection.
		if ( ! empty( $args ) ) {
			return new $mapper_class( $args );
		} else {
			$container = wc_get_container();
			return $container->get( $mapper_class );
		}
	}

	/**
	 * Determines the platform to use from command arguments, with validation and fallback.
	 *
	 * @param array  $assoc_args     Associative arguments from the command.
	 * @param string $default_platform The default platform to use if none specified.
	 *
	 * @return string The validated platform slug.
	 */
	public function resolve_platform( array $assoc_args, string $default_platform = 'shopify' ): string {
		$platform = $assoc_args['platform'] ?? null;

		if ( empty( $platform ) ) {
			$platform              = $default_platform;
			$platform_display_name = $this->get_platform_display_name( $platform );
			WP_CLI::log( "Platform not specified, using default: '{$platform_display_name}'." );
		}

		// Validate the platform exists.
		if ( ! $this->get_platform( $platform ) ) {
			$available_platforms = array_keys( $this->get_platforms() );
			if ( empty( $available_platforms ) ) {
				WP_CLI::error( 'No platforms are currently registered. Please ensure platform plugins are installed and activated.' );
			} else {
				WP_CLI::error(
					sprintf(
						"Platform '%s' is not registered. Available platforms: %s",
						$platform,
						implode( ', ', $available_platforms )
					)
				);
			}
		}

		return $platform;
	}

	/**
	 * Get platform-specific credential fields for setup prompts.
	 *
	 * @param string $platform_slug The platform identifier.
	 *
	 * @return array Array of field_name => prompt_text pairs.
	 */
	public function get_platform_credential_fields( string $platform_slug ): array {
		$platform = $this->get_platform( $platform_slug );
		if ( ! is_array( $platform ) ) {
			return array();
		}
		$credentials = $platform['credentials'] ?? array();
		return is_array( $credentials ) ? $credentials : array();
	}

	/**
	 * Gets the display name for a platform.
	 *
	 * @param string $platform_slug The platform identifier (e.g., 'shopify').
	 *
	 * @return string The proper display name (e.g., 'Shopify').
	 */
	public function get_platform_display_name( string $platform_slug ): string {
		$platform = $this->get_platform( $platform_slug );

		if ( is_array( $platform ) && isset( $platform['name'] ) ) {
			return $platform['name'];
		}

		// Fallback to ucfirst if platform not found or no name configured.
		return ucfirst( $platform_slug );
	}
}
PK     [1]m)  )  '  CLI/Migrator/Core/CredentialManager.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\CLI\Migrator\Core;

use WP_CLI;

/**
 * Manages platform credentials.
 */
class CredentialManager {
	/**
	 * Retrieves the stored credentials for a given platform.
	 *
	 * @param string $platform_slug The slug for the platform.
	 *
	 * @return array|null An associative array of credentials, or null if not found.
	 */
	public function get_credentials( string $platform_slug ): ?array {
		$option_name      = "wc_migrator_credentials_{$platform_slug}";
		$credentials_json = get_option( $option_name, false );
		if ( ! $credentials_json ) {
			return null;
		}

		$credentials = json_decode( $credentials_json, true );

		return is_array( $credentials ) ? $credentials : null;
	}

	/**
	 * Checks if credentials exist for a given platform.
	 *
	 * @param string $platform_slug The slug for the platform.
	 *
	 * @return bool True if credentials exist, false otherwise.
	 */
	public function has_credentials( string $platform_slug ): bool {
		$credentials = $this->get_credentials( $platform_slug );

		return ! empty( $credentials );
	}

	/**
	 * Prompts the user for credentials via the command line.
	 *
	 * @param array $fields An associative array of fields to prompt for.
	 *
	 * @return array The collected credentials.
	 */
	public function prompt_for_credentials( array $fields ): array {
		$credentials = array();
		foreach ( $fields as $key => $prompt ) {
			$credentials[ $key ] = $this->readline( $prompt . ' ' );
		}

		return $credentials;
	}

	/**
	 * Saves credentials to the database for a given platform.
	 *
	 * @param string $platform_slug The slug for the platform.
	 * @param array  $credentials   An associative array of credentials.
	 */
	public function save_credentials( string $platform_slug, array $credentials ): void {
		$option_name = "wc_migrator_credentials_{$platform_slug}";
		update_option( $option_name, wp_json_encode( $credentials ) );
	}

	/**
	 * Deletes credentials from the database for a given platform.
	 *
	 * @param string $platform_slug The slug for the platform.
	 */
	public function delete_credentials( string $platform_slug ): void {
		$option_name = "wc_migrator_credentials_{$platform_slug}";
		delete_option( $option_name );
	}

	/**
	 * Handles the interactive credential setup process for a platform.
	 *
	 * @param string $platform_slug The platform slug to set up credentials for.
	 * @param array  $required_fields An array of field_key => prompt_text for credentials to collect.
	 *
	 * @return void
	 */
	public function setup_credentials( string $platform_slug, array $required_fields ): void {
		if ( empty( $required_fields ) ) {
			WP_CLI::error( 'No credential fields specified for setup.' );
			return;
		}

		WP_CLI::log( 'Configuring credentials for ' . ucfirst( $platform_slug ) . '...' );

		$credentials = $this->prompt_for_credentials( $required_fields );
		$this->save_credentials( $platform_slug, $credentials );
	}

	/**
	 * Reads a line from STDIN.
	 *
	 * A backward-compatible wrapper for WP_CLI::readline().
	 *
	 * @param string $prompt The prompt to show to the user.
	 *
	 * @return string
	 */
	private function readline( string $prompt ): string {
		if ( method_exists( 'WP_CLI', 'readline' ) ) {
			return WP_CLI::readline( $prompt );
		}

		WP_CLI::line( $prompt );
		return trim( fgets( STDIN ) );
	}
}
PK     [1]3~6  6  0  CLI/Migrator/Core/WooCommerceProductImporter.phpnu         <?php
/**
 * WooCommerce Product Importer
 *
 * @package Automattic\WooCommerce\Internal\CLI\Migrator\Core
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\CLI\Migrator\Core;

use WC_Product;
use WC_Product_Simple;
use WC_Product_Variable;
use WC_Product_Variation;
use WP_Error;
use Exception;
use Automattic\WooCommerce\Utilities\FeaturesUtil;

defined( 'ABSPATH' ) || exit;

/**
 * WooCommerceProductImporter class.
 *
 * Handles the creation and updating of WooCommerce products from mapped data.
 * This class focuses on the actual product creation logic, following WordPress
 * coding standards and our established architecture patterns.
 *
 * @internal This class is part of the CLI Migrator feature and should not be used directly.
 */
class WooCommerceProductImporter {

	/**
	 * Default timeout for image downloads in seconds.
	 *
	 * @var int
	 */
	private const DEFAULT_IMAGE_TIMEOUT = 10;

	/**
	 * Maximum number of images to process per product.
	 *
	 * @var int
	 */
	private const MAX_IMAGES_PER_PRODUCT = 50;

	/**
	 * Import options and configuration.
	 *
	 * @var array
	 */
	private array $import_options;

	/**
	 * Progress callback function for per-product updates.
	 *
	 * @var callable|null
	 */
	private $progress_callback = null;

	/**
	 * Statistics tracking for import operations.
	 *
	 * @var array
	 */
	private array $import_stats = array(
		'products_created'   => 0,
		'products_updated'   => 0,
		'products_skipped'   => 0,
		'images_processed'   => 0,
		'errors_encountered' => 0,
	);

	/**
	 * Migration data including image and variation mappings for session persistence.
	 *
	 * @var array
	 */
	private array $migration_data = array(
		'images_mapping'     => array(),
		'variations_mapping' => array(),
	);

	/**
	 * Mapping of original attribute names to taxonomy names for current product.
	 *
	 * @var array
	 */
	private array $current_attribute_mapping = array();

	/**
	 * Constructor - parameterless to support WooCommerce DI container.
	 */
	public function __construct() {
		$this->import_options = $this->get_default_options();
	}

	/**
	 * Configure the importer with options.
	 *
	 * @param array $options Import options and configuration.
	 */
	public function configure( array $options ): void {
		$this->import_options = array_merge( $this->import_options, $options );
	}

	/**
	 * Set progress callback for per-product import updates.
	 *
	 * @param callable|null $callback Function to call with progress updates.
	 * Receives: (current_index, total_count, product_name, result).
	 */
	public function set_progress_callback( ?callable $callback ): void {
		$this->progress_callback = $callback;
	}

	/**
	 * Import a single product from mapped data.
	 *
	 * @param array $product_data Mapped WooCommerce product data.
	 * @param array $source_data  Original source platform data for reference.
	 * @return array Import result with status and details.
	 */
	public function import_product( array $product_data, array $source_data = array() ): array {
		$start_time   = microtime( true );
		$product_name = $product_data['name'] ?? 'Unknown Product';

		$this->current_attribute_mapping = array();

		try {
			wc_get_logger()->info( "Starting import for product: {$product_name}", array( 'source' => 'wc-migrator' ) );

			$validation_result = $this->validate_product_data( $product_data );
			if ( ! $validation_result['valid'] ) {
				wc_get_logger()->error( "Validation failed for product: {$product_name} - " . $validation_result['message'], array( 'source' => 'wc-migrator' ) );
				return $this->create_error_result( 'validation_failed', $validation_result['message'], $product_data );
			}

			$existing_product_id = $this->find_existing_product( $product_data, $source_data );

			if ( $existing_product_id && $this->import_options['skip_existing'] ) {
				++$this->import_stats['products_skipped'];
				return $this->create_success_result( 'skipped', $existing_product_id, 'Product already exists and skip_existing is enabled' );
			}

			$product_type = $this->determine_product_type( $product_data );
			$product      = $this->get_or_create_product_object( $existing_product_id, $product_type );

			if ( ! $product ) {
				return $this->create_error_result( 'product_creation_failed', 'Failed to create product object', $product_data );
			}

			if ( $existing_product_id ) {
				$existing_migration_data = $product->get_meta( '_migration_data' );
				if ( is_array( $existing_migration_data ) ) {
					$this->migration_data['images_mapping']     = $existing_migration_data['images_mapping'] ?? array();
					$this->migration_data['variations_mapping'] = $existing_migration_data['variations_mapping'] ?? array();
				}
			}

			$this->set_basic_product_properties( $product, $product_data );

			$this->set_product_taxonomies( $product, $product_data );

			$this->handle_product_images( $product, $product_data['images'] ?? array() );

			wc_get_logger()->debug( "Processing {$product_type} product: {$product_name}", array( 'source' => 'wc-migrator' ) );

			switch ( $product_type ) {
				case 'variable':
					$this->handle_variable_product( $product, $product_data );
					break;
				case 'simple':
				default:
					$this->handle_simple_product( $product, $product_data );
					break;
			}

			$product_id = $product->save();

			if ( ! $product_id ) {
				return $this->create_error_result( 'save_failed', 'Failed to save product to database', $product_data );
			}

			$this->handle_post_save_operations( $product_id, $product_data, $source_data );

			if ( $existing_product_id ) {
				++$this->import_stats['products_updated'];
			} else {
				++$this->import_stats['products_created'];
			}

			$duration = microtime( true ) - $start_time;
			$action   = $existing_product_id ? 'updated' : 'created';

			wc_get_logger()->info(
				"Successfully {$action} product: {$product_name} (ID: {$product_id}) in {$duration}s",
				array( 'source' => 'wc-migrator' )
			);

			return $this->create_success_result( $action, $product_id, "Product {$action} successfully in {$duration}s" );

		} catch ( Exception $e ) {
			++$this->import_stats['errors_encountered'];
			$duration = microtime( true ) - $start_time;

			wc_get_logger()->error(
				"Exception importing product: {$product_name} after {$duration}s - " . $e->getMessage(),
				array(
					'source'    => 'wc-migrator',
					'exception' => $e,
				)
			);

			return $this->create_error_result( 'exception', $e->getMessage(), $product_data );
		}
	}

	/**
	 * Import a batch of products.
	 *
	 * @param array $products_data Array of mapped product data.
	 * @param array $source_data_batch Array of original source data for reference.
	 * @return array Batch import results.
	 */
	public function import_batch( array $products_data, array $source_data_batch = array() ): array {
		$results     = array();
		$batch_stats = array(
			'successful' => 0,
			'failed'     => 0,
			'skipped'    => 0,
		);

		$total_count = count( $products_data );

		foreach ( $products_data as $index => $product_data ) {
			$source_data  = $source_data_batch[ $index ] ?? array();
			$product_name = $product_data['name'] ?? 'Unknown Product';

			$result = $this->import_product( $product_data, $source_data );

			$results[] = $result;

			if ( 'success' === $result['status'] ) {
				if ( 'skipped' === $result['action'] ) {
					++$batch_stats['skipped'];
				} else {
					++$batch_stats['successful'];
				}
			} else {
				++$batch_stats['failed'];
			}

			if ( $this->progress_callback ) {
				call_user_func( $this->progress_callback, $index + 1, $total_count, $product_name, $result );
			}
		}

		return array(
			'results' => $results,
			'stats'   => $batch_stats,
		);
	}

	/**
	 * Get current import statistics.
	 *
	 * @return array Import statistics.
	 */
	public function get_import_stats(): array {
		return $this->import_stats;
	}

	/**
	 * Reset import statistics.
	 */
	public function reset_stats(): void {
		$this->import_stats = array(
			'products_created'   => 0,
			'products_updated'   => 0,
			'products_skipped'   => 0,
			'images_processed'   => 0,
			'errors_encountered' => 0,
		);
	}

	/**
	 * Get default import options.
	 *
	 * @return array Default options.
	 */
	private function get_default_options(): array {
		return array(
			'skip_existing'           => false,
			'update_existing'         => true,
			'import_images'           => true,
			'image_timeout'           => self::DEFAULT_IMAGE_TIMEOUT,
			'max_images_per_product'  => self::MAX_IMAGES_PER_PRODUCT,
			'skip_duplicate_images'   => false,
			'create_categories'       => true,
			'create_tags'             => true,
			'handle_variations'       => true,
			'assign_default_category' => false,
			'dry_run'                 => false,
		);
	}

	/**
	 * Validate product data before import.
	 *
	 * @param array $product_data Product data to validate.
	 * @return array Validation result.
	 */
	private function validate_product_data( array $product_data ): array {
		$required_fields = array( 'name' );
		$missing_fields  = array();

		foreach ( $required_fields as $field ) {
			if ( empty( $product_data[ $field ] ) ) {
				$missing_fields[] = $field;
			}
		}

		if ( ! empty( $missing_fields ) ) {
			return array(
				'valid'   => false,
				'message' => 'Missing required fields: ' . implode( ', ', $missing_fields ),
			);
		}

		return array( 'valid' => true );
	}

	/**
	 * Find existing product by various identifiers.
	 *
	 * @param array $product_data Mapped product data.
	 * @return int|null Existing product ID or null if not found.
	 */
	private function find_existing_product( array $product_data ): ?int {
		if ( ! empty( $product_data['original_product_id'] ) ) {
			$existing_posts = get_posts(
				array(
					'post_type'   => 'product',
					'post_status' => 'any', // Find regardless of status.
					'meta_key'    => '_original_product_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
					'meta_value'  => $product_data['original_product_id'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
					'fields'      => 'ids',
					'numberposts' => 1,
				)
			);

			if ( ! empty( $existing_posts ) ) {
				return (int) $existing_posts[0];
			}
		}

		if ( ! empty( $product_data['sku'] ) ) {
			$product_id = wc_get_product_id_by_sku( $product_data['sku'] );
			if ( $product_id ) {
				return $product_id;
			}
		}

		if ( ! empty( $product_data['slug'] ) ) {
			$post = get_page_by_path( $product_data['slug'], OBJECT, 'product' );
			if ( $post ) {
				return $post->ID;
			}
		}

		return null;
	}


	/**
	 * Determine product type from product data.
	 *
	 * @param array $product_data Product data.
	 * @return string Product type.
	 */
	private function determine_product_type( array $product_data ): string {
		if ( isset( $product_data['is_variable'] ) ) {
			return $product_data['is_variable'] ? 'variable' : 'simple';
		}

		if ( ! empty( $product_data['variations'] ) && count( $product_data['variations'] ) >= 1 ) {
			return 'variable';
		}

		if ( ! empty( $product_data['attributes'] ) ) {
			foreach ( $product_data['attributes'] as $attribute ) {
				if ( ! empty( $attribute['is_variation'] ) || ! empty( $attribute['variation'] ) ) {
					return 'variable';
				}
			}
		}

		return 'simple';
	}

	/**
	 * Get or create product object with proper type conversion handling.
	 *
	 * @param int|null $existing_product_id Existing product ID if updating.
	 * @param string   $required_type Required product type.
	 * @return WC_Product|null Product object or null on failure.
	 */
	private function get_or_create_product_object( ?int $existing_product_id, string $required_type ): ?WC_Product {
		if ( ! $existing_product_id ) {
			return $this->create_product_object( $required_type );
		}

		$existing_product = wc_get_product( $existing_product_id );
		if ( ! $existing_product ) {
			return $this->create_product_object( $required_type );
		}

		$current_type = $existing_product->get_type();
		if ( $current_type === $required_type ) {
			return $existing_product;
		}

		wc_get_logger()->info(
			"Converting product ID {$existing_product_id} from {$current_type} to {$required_type}",
			array( 'source' => 'wc-migrator' )
		);

		switch ( $required_type ) {
			case 'variable':
				return new WC_Product_Variable( $existing_product_id );
			case 'simple':
			default:
				return new WC_Product_Simple( $existing_product_id );
		}
	}

	/**
	 * Create appropriate product object based on type.
	 *
	 * @param string $product_type Product type.
	 * @return WC_Product|null Product object or null on failure.
	 */
	private function create_product_object( string $product_type ): ?WC_Product {
		switch ( $product_type ) {
			case 'variable':
				return new WC_Product_Variable();
			case 'simple':
			default:
				return new WC_Product_Simple();
		}
	}

	/**
	 * Set basic product properties common to all product types.
	 *
	 * @param WC_Product $product      Product object.
	 * @param array      $product_data Product data.
	 */
	private function set_basic_product_properties( WC_Product $product, array $product_data ): void {
		$product->set_name( $product_data['name'] );

		if ( ! empty( $product_data['slug'] ) ) {
			$product->set_slug( $product_data['slug'] );
		}

		if ( ! empty( $product_data['description'] ) ) {
			$product->set_description( $product_data['description'] );
		}

		if ( ! empty( $product_data['short_description'] ) ) {
			$product->set_short_description( $product_data['short_description'] );
		}

		if ( ! empty( $product_data['status'] ) ) {
			$product->set_status( $product_data['status'] );
		}

		if ( ! empty( $product_data['sku'] ) ) {
			$product->set_sku( $product_data['sku'] );
		}

		if ( isset( $product_data['catalog_visibility'] ) ) {
			$product->set_catalog_visibility( $product_data['catalog_visibility'] );
		}

		if ( ! empty( $product_data['date_created_gmt'] ) ) {
			$product->set_date_created( $product_data['date_created_gmt'] );
		}

		if ( ! empty( $product_data['weight'] ) ) {
			$product->set_weight( $product_data['weight'] );
		}

		if ( ! empty( $product_data['tax_status'] ) ) {
			$product->set_tax_status( $product_data['tax_status'] );
		}

		if ( ! empty( $product_data['metafields'] ) ) {
			foreach ( $product_data['metafields'] as $key => $value ) {
				if ( ! empty( $key ) ) {
					$product->add_meta_data( $key, $value, true );
				}
			}
		}

		if ( ! empty( $product_data['meta_data'] ) ) {
			foreach ( $product_data['meta_data'] as $meta ) {
				if ( ! empty( $meta['key'] ) ) {
					$product->add_meta_data( $meta['key'], $meta['value'] ?? '', true );
				}
			}
		}
	}

	/**
	 * Handle simple product specific data.
	 *
	 * @param WC_Product_Simple $product      Simple product object.
	 * @param array             $product_data Product data.
	 */
	private function handle_simple_product( WC_Product_Simple $product, array $product_data ): void {
		if ( ! empty( $product_data['regular_price'] ) ) {
			$product->set_regular_price( $product_data['regular_price'] );
			$product->set_price( $product_data['regular_price'] );
		}

		if ( ! empty( $product_data['sale_price'] ) ) {
			$product->set_sale_price( $product_data['sale_price'] );
			$product->set_price( $product_data['sale_price'] );
		}

		if ( ! empty( $product_data['sku'] ) ) {
			add_filter( 'wc_product_has_unique_sku', '__return_false', 999 );
			$product->set_sku( $product_data['sku'] );
			remove_filter( 'wc_product_has_unique_sku', '__return_false', 999 );
		}

		if ( isset( $product_data['manage_stock'] ) ) {
			$product->set_manage_stock( $product_data['manage_stock'] );
		}

		if ( ! empty( $product_data['stock_quantity'] ) ) {
			$product->set_stock_quantity( (int) $product_data['stock_quantity'] );
		}

		if ( ! empty( $product_data['stock_status'] ) ) {
			$product->set_stock_status( $product_data['stock_status'] );
		}

		if ( array_key_exists( 'cost_of_goods', $product_data ) ) {
			$cogs_is_enabled = FeaturesUtil::feature_is_enabled( 'cost_of_goods_sold' );
			if ( $cogs_is_enabled ) {
				$product->set_cogs_value( (float) $product_data['cost_of_goods'] );
			} else {
				$this->set_cogs_value_direct( $product, (float) $product_data['cost_of_goods'] );
			}
		}
	}

	/**
	 * Handle variable product specific data.
	 *
	 * @param WC_Product_Variable $product      Variable product object.
	 * @param array               $product_data Product data.
	 */
	private function handle_variable_product( WC_Product_Variable $product, array $product_data ): void {
		$product->set_sku( '' );
		$product->set_regular_price( '' );
		$product->set_sale_price( '' );
		$product->set_manage_stock( false );
		$product->set_weight( '' );
		$product->set_stock_quantity( null );

		if ( ! empty( $product_data['attributes'] ) ) {
			$this->setup_attributes( $product, $product_data['attributes'] );
		}

		$product_id = $product->save();

		if ( ! empty( $product_data['variations'] ) && $this->import_options['handle_variations'] ) {
			$this->sync_variations( $product, $product_data['variations'] );
		}
	}

	/**
	 * Set product attributes.
	 *
	 * @param WC_Product $product    Product object.
	 * @param array      $attributes Attributes data.
	 */
	private function set_product_attributes( WC_Product $product, array $attributes ): void {
		$product_attributes = array();

		foreach ( $attributes as $attribute_data ) {
			if ( empty( $attribute_data['name'] ) ) {
				continue;
			}

			$attribute = new \WC_Product_Attribute();
			$attribute->set_name( $attribute_data['name'] );
			$attribute->set_options( $attribute_data['options'] ?? array() );
			$attribute->set_variation( $attribute_data['is_variation'] ?? $attribute_data['variation'] ?? false );
			$attribute->set_visible( $attribute_data['is_visible'] ?? $attribute_data['visible'] ?? true );

			$product_attributes[] = $attribute;
		}

		$product->set_attributes( $product_attributes );
	}

	/**
	 * Sets up product attributes for variable products with global taxonomy creation.
	 *
	 * @param WC_Product_Variable $product The variable product object.
	 * @param array               $attributes_data              Standardized attribute data from mapper.
	 */
	private function setup_attributes( WC_Product_Variable $product, array $attributes_data ): void {
		$woo_attributes                  = array();
		$this->current_attribute_mapping = array();

		foreach ( $attributes_data as $attribute_info ) {
			$attr_name    = $attribute_info['name'] ?? null;
			$attr_options = $attribute_info['options'] ?? array();
			if ( empty( $attr_name ) || empty( $attr_options ) ) {
				continue;
			}

			$taxonomy_slug = sanitize_title( $attr_name );
			$taxonomy_name = 'pa_' . $taxonomy_slug;
			$attribute_id  = 0;

			if ( ! taxonomy_exists( $taxonomy_name ) ) {
				$attribute_id = wc_create_attribute(
					array(
						'name'         => $attr_name,
						'slug'         => $taxonomy_slug,
						'type'         => 'select',
						'order_by'     => 'menu_order',
						'has_archives' => false,
					)
				);
				if ( is_wp_error( $attribute_id ) ) {
					wc_get_logger()->warning( "Failed to create attribute '{$attr_name}': " . $attribute_id->get_error_message(), array( 'source' => 'wc-migrator' ) );
					continue;
				}

				register_taxonomy(
					$taxonomy_name,
					/**
					 * Filters the object types associated with the attribute taxonomy.
					 *
					 * @since 10.2.0
					 * @param array $object_types Array of object types.
					 */
					apply_filters( 'woocommerce_taxonomy_objects_' . $taxonomy_name, array( 'product' ) ),
					/**
					 * Filters the arguments for registering the attribute taxonomy.
					 *
					 * @since 10.2.0
					 * @param array $args Array of taxonomy registration arguments.
					 */
					apply_filters(
						'woocommerce_taxonomy_args_' . $taxonomy_name,
						array(
							'labels'       => array(
								'name' => $attr_name,
							),
							'hierarchical' => false,
							'show_ui'      => false,
							'show_in_rest' => true,
							'query_var'    => true,
							'rewrite'      => false,
							'public'       => false,
						)
					)
				);
			} else {
				$attribute_id = wc_attribute_taxonomy_id_by_name( $taxonomy_name );
			}

			$term_ids   = array();
			$term_slugs = array();
			foreach ( $attr_options as $value ) {
				$term_slug = sanitize_title( $value );
				$term      = get_term_by( 'slug', $term_slug, $taxonomy_name );
				if ( ! $term ) {
					$term_result = wp_insert_term( $value, $taxonomy_name, array( 'slug' => $term_slug ) );
					if ( is_wp_error( $term_result ) ) {
						wc_get_logger()->warning( "Failed to insert term '{$value}' (slug: {$term_slug}) into {$taxonomy_name}: " . $term_result->get_error_message(), array( 'source' => 'wc-migrator' ) );
						continue;
					}
					$term_ids[]   = $term_result['term_id'];
					$term_slugs[] = $term_slug;
				} else {
					$term_ids[]   = $term->term_id;
					$term_slugs[] = $term->slug;
				}
			}

			$woo_attribute = new \WC_Product_Attribute();
			$woo_attribute->set_name( $taxonomy_name );
			$woo_attribute->set_id( $attribute_id );
			$woo_attribute->set_options( $term_ids );
			$woo_attribute->set_position( $attribute_info['position'] ?? 0 );
			$woo_attribute->set_visible( $attribute_info['is_visible'] ?? true );
			$woo_attribute->set_variation( $attribute_info['is_variation'] ?? true );
			$woo_attributes[] = $woo_attribute;

			$this->current_attribute_mapping[ $attr_name ] = $taxonomy_name;
		}

		$product->set_attributes( $woo_attributes );
	}

	/**
	 * Creates or updates product variations with proper mapping and lookup.
	 *
	 * @param WC_Product_Variable $product The parent variable product.
	 * @param array               $variations_data Standardized variation data from mapper.
	 */
	private function sync_variations( WC_Product_Variable $product, array $variations_data ): void {
		$parent_product_id       = $product->get_id();
		$parent_original_id      = $product->get_meta( '_original_product_id' );
		$processed_variation_ids = array();

		$variation_count = count( $variations_data );
		wc_get_logger()->debug( "Syncing {$variation_count} variations for product ID {$parent_product_id}", array( 'source' => 'wc-migrator' ) );

		$attribute_taxonomy_map = $this->current_attribute_mapping;

		// Build fallback mapping from product attributes if current mapping is empty.
		if ( empty( $attribute_taxonomy_map ) ) {
			$product_attributes = $product->get_attributes();
			foreach ( $product_attributes as $taxonomy => $attribute_obj ) {
				if ( $attribute_obj->get_variation() ) {
					$attribute_label = wc_attribute_label( $taxonomy, $product );
					// Store mapping with both original case and lowercase for case-insensitive lookup.
					$attribute_taxonomy_map[ $attribute_label ]               = $taxonomy;
					$attribute_taxonomy_map[ strtolower( $attribute_label ) ] = $taxonomy;
				}
			}
		}

		foreach ( $variations_data as $var_data ) {
			$original_variant_id = $var_data['original_id'] ?? null;
			if ( ! $original_variant_id ) {
				wc_get_logger()->warning( 'Skipping variation: Missing original ID.', array( 'source' => 'wc-migrator' ) );
				continue;
			}

			$variation_id = null;
			$variation    = null;

			if ( isset( $this->migration_data['variations_mapping'][ $original_variant_id ] ) ) {
				$_variation_id = $this->migration_data['variations_mapping'][ $original_variant_id ];
				$_variation    = wc_get_product( $_variation_id );
				if ( $_variation instanceof WC_Product_Variation && $_variation->get_parent_id() === $parent_product_id ) {
					$variation    = $_variation;
					$variation_id = $_variation_id;
				} else {
					unset( $this->migration_data['variations_mapping'][ $original_variant_id ] );
				}
			}

			if ( ! $variation ) {
				$query_args = array(
					'post_parent' => $parent_product_id,
					'post_type'   => 'product_variation',
					'numberposts' => 1,
					'post_status' => 'any',
					'meta_key'    => '_original_variant_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
					'meta_value'  => $original_variant_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
					'fields'      => 'ids',
				);

				$found_ids = get_posts( $query_args );
				if ( ! empty( $found_ids ) ) {
					$variation_id = $found_ids[0];
					$variation    = wc_get_product( $variation_id );
					if ( ! ( $variation instanceof WC_Product_Variation ) ) {
						wc_get_logger()->warning( "Found post ID {$variation_id} for original variant {$original_variant_id}, but it's not a WC_Product_Variation.", array( 'source' => 'wc-migrator' ) );
						$variation    = null;
						$variation_id = null;
					}
				}
			}

			if ( ! $variation ) {
				$variation = new WC_Product_Variation();
				$variation->set_parent_id( $parent_product_id );
			}

			$variation->set_status( 'publish' );
			$variation->set_menu_order( $var_data['menu_order'] ?? 0 );

			$variation->set_regular_price( $var_data['regular_price'] ?? '' );
			$variation->set_sale_price( $var_data['sale_price'] ?? '' );

			if ( ! empty( $var_data['sku'] ) ) {
				add_filter( 'wc_product_has_unique_sku', '__return_false', 999 );
				$variation->set_sku( $var_data['sku'] );
				remove_filter( 'wc_product_has_unique_sku', '__return_false', 999 );
			}

			$variation->set_manage_stock( $var_data['manage_stock'] ?? false );
			$variation->set_stock_quantity( $var_data['stock_quantity'] ?? null );
			$variation->set_stock_status( $var_data['stock_status'] ?? 'instock' );

			$variation->set_weight( $var_data['weight'] ?? '' );

			if ( ! empty( $var_data['tax_status'] ) ) {
				$variation->set_tax_status( $var_data['tax_status'] );
			}

			$image_original_id = $var_data['image_original_id'] ?? null;
			if ( $image_original_id && isset( $this->migration_data['images_mapping'][ $image_original_id ] ) ) {
				$variation->set_image_id( $this->migration_data['images_mapping'][ $image_original_id ] );
			} else {
				$variation->set_image_id( '' );
			}

			$wc_variation_attributes = array();
			if ( ! empty( $var_data['attributes'] ) && is_array( $var_data['attributes'] ) ) {
				foreach ( $var_data['attributes'] as $attr_name => $attr_value ) {
					if ( isset( $attribute_taxonomy_map[ $attr_name ] ) ) {
						$taxonomy                  = $attribute_taxonomy_map[ $attr_name ];
						$term_slug                 = sanitize_title( $attr_value );
						$normalized_attribute_name = wc_variation_attribute_name( $taxonomy );

						$wc_variation_attributes[ $normalized_attribute_name ] = $term_slug;
					} else {
						wc_get_logger()->warning( "Attribute taxonomy mapping not found for option '{$attr_name}' while processing variation {$original_variant_id}.", array( 'source' => 'wc-migrator' ) );
					}
				}
			}
			$variation->set_attributes( $wc_variation_attributes );

			$variation->update_meta_data( '_original_variant_id', $original_variant_id );
			if ( $parent_original_id ) {
				$variation->update_meta_data( '_original_product_id', $parent_original_id );
			}

			$saved_variation_id = $variation->save();
			if ( $saved_variation_id ) {
				$processed_variation_ids[] = $saved_variation_id;
				$this->migration_data['variations_mapping'][ $original_variant_id ] = $saved_variation_id;
				if ( ! empty( $var_data['cost_of_goods'] ) ) {
					update_post_meta( $saved_variation_id, '_cogs_total_value', (float) $var_data['cost_of_goods'] );
				}
			} else {
				wc_get_logger()->error( "Failed to save variation for original variant {$original_variant_id}", array( 'source' => 'wc-migrator' ) );
			}
		}

		WC_Product_Variable::sync( $parent_product_id );

		$processed_count = count( $processed_variation_ids );
		wc_get_logger()->debug( "Successfully synced {$processed_count}/{$variation_count} variations for product ID {$parent_product_id}", array( 'source' => 'wc-migrator' ) );
	}

	/**
	 * Create product variations (legacy method - keeping for backward compatibility).
	 *
	 * @param int   $parent_id  Parent product ID.
	 * @param array $variations Variations data.
	 */
	private function create_product_variations( int $parent_id, array $variations ): void {
		$product = wc_get_product( $parent_id );
		if ( $product instanceof WC_Product_Variable ) {
			$this->sync_variations( $product, $variations );
		}
	}

	/**
	 * Handle post-save operations like metadata and migration tracking.
	 *
	 * @param int   $product_id   Product ID.
	 * @param array $product_data Product data.
	 */
	private function handle_post_save_operations( int $product_id, array $product_data ): void {

		if ( ! empty( $product_data['original_product_id'] ) ) {
			update_post_meta( $product_id, '_original_product_id', $product_data['original_product_id'] );
		}

		if ( ! empty( $product_data['original_url'] ) ) {
			update_post_meta( $product_id, '_original_url', $product_data['original_url'] );
		}

		update_post_meta( $product_id, '_migration_data', $this->migration_data );

		if ( ! empty( $product_data['metafields'] ) ) {
			$this->update_seo_meta( $product_id, $product_data['metafields'], $product_data );
		}
	}

	/**
	 * Set product taxonomies (categories, tags, brand) before product save.
	 *
	 * @param WC_Product $product The product object.
	 * @param array      $product_data Standardized data containing taxonomies.
	 */
	private function set_product_taxonomies( WC_Product $product, array $product_data ): void {
		$product_id = $product->get_id();
		if ( ! $product_id ) {
			$product_id = $product->save();
			if ( ! $product_id ) {
				wc_get_logger()->warning( 'Could not save product to set taxonomies.', array( 'source' => 'wc-migrator' ) );
				return;
			}
		}

		$taxonomies_to_set = array();

		if ( isset( $product_data['categories'] ) && is_array( $product_data['categories'] ) && $this->import_options['create_categories'] ) {
			$term_ids = $this->get_or_create_terms( $product_data['categories'], 'product_cat' );
			if ( ! empty( $term_ids ) ) {
				$taxonomies_to_set['product_cat'] = $term_ids;
			} elseif ( $this->import_options['assign_default_category'] ) {
				$default_cat_id = get_option( 'default_product_cat' );
				if ( $default_cat_id ) {
					$taxonomies_to_set['product_cat'] = array( $default_cat_id );
					wc_get_logger()->info( "Assigned default category (ID: {$default_cat_id}) to product with no categories", array( 'source' => 'wc-migrator' ) );
				}
			} else {
				wc_get_logger()->debug( 'Product has no categories and assign_default_category is disabled', array( 'source' => 'wc-migrator' ) );
			}
		}

		if ( isset( $product_data['tags'] ) && is_array( $product_data['tags'] ) && $this->import_options['create_tags'] ) {
			$term_ids = $this->get_or_create_terms( $product_data['tags'], 'product_tag' );
			if ( ! empty( $term_ids ) ) {
				$taxonomies_to_set['product_tag'] = $term_ids;
			}
		}

		if ( ! empty( $product_data['brand']['name'] ) && taxonomy_exists( 'product_brand' ) ) {
			$brand_data = array( $product_data['brand'] );
			$term_ids   = $this->get_or_create_terms( $brand_data, 'product_brand' );
			if ( ! empty( $term_ids ) ) {
				$taxonomies_to_set['product_brand'] = $term_ids;
			}
		}

		foreach ( $taxonomies_to_set as $taxonomy => $ids ) {
			wp_set_object_terms( $product_id, $ids, $taxonomy, false );
		}
	}

	/**
	 * Helper to get or create term IDs for a given taxonomy.
	 *
	 * @param array  $terms_data Array of ['name' => ..., 'slug' => ...].
	 * @param string $taxonomy Taxonomy slug.
	 * @return array Array of term IDs.
	 */
	private function get_or_create_terms( array $terms_data, string $taxonomy ): array {
		$term_ids = array();
		foreach ( $terms_data as $term_info ) {
			$term_name = $term_info['name'] ?? null;
			$term_slug = $term_info['slug'] ?? sanitize_title( $term_name );

			if ( empty( $term_name ) || empty( $term_slug ) ) {
				continue;
			}

			$term = get_term_by( 'slug', $term_slug, $taxonomy );

			if ( ! $term ) {
				$term_result = wp_insert_term( $term_name, $taxonomy, array( 'slug' => $term_slug ) );
				if ( is_wp_error( $term_result ) ) {
					wc_get_logger()->warning( "Failed to insert term '{$term_name}' (slug: {$term_slug}) into {$taxonomy}: " . $term_result->get_error_message(), array( 'source' => 'wc-migrator' ) );
					continue;
				}
				$term_ids[] = $term_result['term_id'];
			} else {
				$term_ids[] = $term->term_id;
			}
		}
		return array_unique( $term_ids );
	}

	/**
	 * Handle product images using product object methods.
	 *
	 * @param WC_Product $product The product object.
	 * @param array      $images_data Standardized image data from mapper.
	 */
	private function handle_product_images( WC_Product $product, array $images_data ): void {
		if ( empty( $images_data ) ) {
			return;
		}

		$gallery_ids     = array();
		$featured_id     = null;
		$product_id      = $product->get_id();
		$processed_count = 0;

		foreach ( $images_data as $index => $image ) {
			if ( $processed_count >= $this->import_options['max_images_per_product'] ) {
				break;
			}

			$original_id = $image['original_id'] ?? null;
			$image_url   = $image['src'] ?? null;
			$image_alt   = $image['alt'] ?? '';
			$is_featured = $image['is_featured'] ?? ( 0 === $index );

			if ( empty( $original_id ) || empty( $image_url ) ) {
				wc_get_logger()->warning( 'Skipping image: Missing original ID or URL.', array( 'source' => 'wc-migrator' ) );
				continue;
			}

			if ( isset( $this->migration_data['images_mapping'][ $original_id ] ) && wp_attachment_is_image( $this->migration_data['images_mapping'][ $original_id ] ) ) {
				$attachment_id = $this->migration_data['images_mapping'][ $original_id ];
			} else {
				if ( ! $product_id ) {
					$product_id = $product->save();
					if ( ! $product_id ) {
						wc_get_logger()->warning( "Skipping image upload {$original_id}: Could not get product ID before sideloading.", array( 'source' => 'wc-migrator' ) );
						continue;
					}
				}

				$start_time    = microtime( true );
				$image_desc    = $image_alt ? $image_alt : $product->get_name();
				$attachment_id = $this->import_image( $image_url, $image_alt, $product_id );
				$duration      = microtime( true ) - $start_time;

				if ( is_wp_error( $attachment_id ) ) {
					wc_get_logger()->error( "Error uploading {$image_url}: " . $attachment_id->get_error_message() . " (Duration: {$duration}s)", array( 'source' => 'wc-migrator' ) );
					continue;
				}

				if ( ! $attachment_id ) {
					wc_get_logger()->warning( "Image upload failed for {$image_url} (Duration: {$duration}s)", array( 'source' => 'wc-migrator' ) );
					continue;
				}

				$this->migration_data['images_mapping'][ $original_id ] = $attachment_id;

				if ( $image_alt ) {
					update_post_meta( $attachment_id, '_wp_attachment_image_alt', $image_alt );
				}
			}

			if ( $is_featured ) {
				$featured_id = $attachment_id;
			} else {
				$gallery_ids[] = $attachment_id;
			}

			++$processed_count;
			++$this->import_stats['images_processed'];
		}

		if ( $featured_id ) {
			$product->set_image_id( $featured_id );
		}
		if ( ! empty( $gallery_ids ) ) {
			$product->set_gallery_image_ids( array_unique( $gallery_ids ) );
		}
	}

	/**
	 * Import image from URL with mapping optimization.
	 *
	 * @param string      $image_url   Image URL.
	 * @param string      $alt_text    Alt text for the image.
	 * @param string|null $original_id Original platform image ID.
	 * @param int         $product_id  Product ID for sideloading.
	 * @return int|null Attachment ID or null on failure.
	 */
	private function import_image_with_mapping( string $image_url, string $alt_text = '', ?string $original_id = null, int $product_id = 0 ): ?int {
		if ( $original_id && isset( $this->migration_data['images_mapping'][ $original_id ] ) ) {
			$attachment_id = $this->migration_data['images_mapping'][ $original_id ];
			if ( wp_attachment_is_image( $attachment_id ) ) {
				return $attachment_id;
			} else {
				unset( $this->migration_data['images_mapping'][ $original_id ] );
			}
		}

		$start_time    = microtime( true );
		$attachment_id = $this->import_image( $image_url, $alt_text, $product_id );
		$duration      = microtime( true ) - $start_time;

		if ( $attachment_id && $original_id ) {
			$this->migration_data['images_mapping'][ $original_id ] = $attachment_id;
		}

		if ( $attachment_id ) {
			$message = sprintf( 'Image uploaded successfully in %.2fs: %s -> %d', $duration, $image_url, $attachment_id );

			if ( $this->import_options['verbose'] ?? false ) {
				\WP_CLI::log( $message );
			}

			wc_get_logger()->info( $message, array( 'source' => 'wc-migrator-images' ) );
		} else {
			$message = sprintf( 'Image upload failed in %.2fs: %s', $duration, $image_url );

			if ( $this->import_options['verbose'] ?? false ) {
				\WP_CLI::warning( $message );
			}

			wc_get_logger()->error( $message, array( 'source' => 'wc-migrator-images' ) );
		}

		return $attachment_id;
	}

	/**
	 * Import image from URL.
	 *
	 * @param string $image_url Image URL.
	 * @param string $alt_text  Alt text for the image.
	 * @param int    $product_id Product ID for sideloading.
	 * @return int|null Attachment ID or null on failure.
	 */
	private function import_image( string $image_url, string $alt_text = '', int $product_id = 0 ): ?int {
		if ( $this->import_options['dry_run'] ) {
			return null;
		}

		if ( ! $this->import_options['skip_duplicate_images'] ) {
			$existing_attachment = $this->get_attachment_by_url( $image_url );
			if ( $existing_attachment ) {
				return $existing_attachment;
			}
		}

		require_once ABSPATH . 'wp-admin/includes/media.php';
		require_once ABSPATH . 'wp-admin/includes/file.php';
		require_once ABSPATH . 'wp-admin/includes/image.php';

		add_filter( 'http_request_timeout', array( $this, 'set_image_download_timeout' ) );
		add_filter( 'http_request_args', array( $this, 'optimize_http_request_args' ) );
		add_filter( 'image_sideload_extensions', array( $this, 'add_avif_support_to_sideload' ) );
		try {
			$attachment_id = media_sideload_image( $image_url, $product_id, null, 'id' );

			if ( is_wp_error( $attachment_id ) ) {
				$message = sprintf( 'Image import failed for URL %s: %s', $image_url, $attachment_id->get_error_message() );

				if ( $this->import_options['verbose'] ?? false ) {
					\WP_CLI::warning( $message );
				}
				wc_get_logger()->error( $message, array( 'source' => 'wc-migrator-images' ) );
				return null;
			}

			if ( $alt_text ) {
				update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt_text );
			}

			return $attachment_id;
		} finally {
			remove_filter( 'http_request_timeout', array( $this, 'set_image_download_timeout' ) );
			remove_filter( 'http_request_args', array( $this, 'optimize_http_request_args' ) );
			remove_filter( 'image_sideload_extensions', array( $this, 'add_avif_support_to_sideload' ) );
		}
	}

	/**
	 * Set HTTP timeout for image downloads.
	 *
	 * @return int Modified timeout.
	 */
	public function set_image_download_timeout(): int {
		return $this->import_options['image_timeout'];
	}

	/**
	 * Optimize HTTP request arguments for faster image downloads.
	 *
	 * @param array $args HTTP request arguments.
	 * @return array Optimized arguments.
	 */
	public function optimize_http_request_args( array $args ): array {
		$args['redirection'] = 3;
		$args['timeout']     = $this->import_options['image_timeout'] ?? 30;

		return $args;
	}

	/**
	 * Add AVIF support to image sideload extensions.
	 *
	 * @param array $allowed_extensions Array of allowed file extensions.
	 * @return array Modified array with AVIF support.
	 */
	public function add_avif_support_to_sideload( array $allowed_extensions ): array {
		if ( ! in_array( 'avif', $allowed_extensions, true ) ) {
			$allowed_extensions[] = 'avif';
		}
		return $allowed_extensions;
	}

	/**
	 * Get existing attachment by URL.
	 *
	 * @param string $image_url Image URL.
	 * @return int|null Attachment ID or null if not found.
	 */
	private function get_attachment_by_url( string $image_url ): ?int {
		global $wpdb;

		$basename      = wp_basename( $image_url );
		$attachment_id = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT post_id FROM $wpdb->postmeta WHERE meta_key = '_wp_attached_file' AND meta_value LIKE %s",
				'%' . $wpdb->esc_like( $basename )
			)
		);
		return $attachment_id ? (int) $attachment_id : null;
	}

	/**
	 * Create success result array.
	 *
	 * @param string $action     Action performed (created, updated, skipped).
	 * @param int    $product_id Product ID.
	 * @param string $message    Success message.
	 * @return array Success result.
	 */
	private function create_success_result( string $action, int $product_id, string $message ): array {
		return array(
			'status'     => 'success',
			'action'     => $action,
			'product_id' => $product_id,
			'message'    => $message,
		);
	}

	/**
	 * Updates SEO meta fields if Yoast SEO is active.
	 *
	 * @param int   $product_id  The product ID.
	 * @param array $metafields  Key-value array of metafields from standardized data.
	 * @param array $product_data Full product data for fallbacks.
	 */
	private function update_seo_meta( int $product_id, array $metafields, array $product_data ): void {
		if ( ! defined( 'WPSEO_VERSION' ) ) {
			return;
		}

		$seo_title       = $metafields['global_title_tag'] ?? null;
		$seo_description = $metafields['global_description_tag'] ?? null;

		$final_seo_title       = $seo_title ? $seo_title : ( $product_data['name'] ?? '' );
		$fallback_desc         = $product_data['description'] ? $product_data['description'] : ( $product_data['short_description'] ?? '' );
		$final_seo_description = $seo_description ? $seo_description : wp_strip_all_tags( $fallback_desc );

		$current_title = get_post_meta( $product_id, '_yoast_wpseo_title', true );
		if ( $current_title !== $final_seo_title && ! empty( $final_seo_title ) ) {
			update_post_meta( $product_id, '_yoast_wpseo_title', $final_seo_title );
		}

		$current_desc = get_post_meta( $product_id, '_yoast_wpseo_metadesc', true );
		if ( $current_desc !== $final_seo_description && ! empty( $final_seo_description ) ) {
			$truncated_desc = mb_substr( $final_seo_description, 0, 160 );
			update_post_meta( $product_id, '_yoast_wpseo_metadesc', $truncated_desc );
		}
	}

	/**
	 * Set COGS value directly using meta data.
	 *
	 * @param WC_Product $product The product object.
	 * @param float      $cogs_value The COGS value to set.
	 */
	private function set_cogs_value_direct( WC_Product $product, float $cogs_value ): void {
		$product->update_meta_data( '_cogs_total_value', $cogs_value );
	}

	/**
	 * Create error result array.
	 *
	 * @param string $error_code   Error code.
	 * @param string $message      Error message.
	 * @param array  $product_data Product data that failed.
	 * @return array Error result.
	 */
	private function create_error_result( string $error_code, string $message, array $product_data ): array {
		return array(
			'status'       => 'error',
			'error_code'   => $error_code,
			'message'      => $message,
			'product_data' => $product_data,
		);
	}
}
PK     [1] .  .  %  CLI/Migrator/Core/MigratorTracker.phpnu         <?php
/**
 * Migrator Tracker
 *
 * @package Automattic\WooCommerce\Internal\CLI\Migrator\Core
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\CLI\Migrator\Core;

defined( 'ABSPATH' ) || exit;

/**
 * MigratorTracker class.
 *
 * Implements subscriber pattern to track comprehensive migration analytics
 * for integration with WC_Tracker telemetry system.
 *
 * @internal This class is part of the CLI Migrator feature and should not be used directly.
 */
class MigratorTracker {

	/**
	 * Option name for storing migration analytics.
	 */
	private const OPTION_NAME = 'wc_migrator_analytics';

	/**
	 * Current migration session data.
	 *
	 * @var array
	 */
	private array $current_session = array();

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->init_hooks();
	}

	/**
	 * Initialize WordPress hooks.
	 */
	private function init_hooks(): void {
		add_action( 'wc_migrator_session_started', array( $this, 'on_session_started' ), 10, 2 );
		add_action( 'wc_migrator_batch_processed', array( $this, 'on_batch_processed' ), 10, 3 );
		add_action( 'wc_migrator_session_completed', array( $this, 'on_session_completed' ), 10, 2 );
	}

	/**
	 * Handle migration session start.
	 *
	 * @param string $platform Platform identifier (e.g., 'shopify').
	 * @param array  $metadata Session metadata.
	 */
	public function on_session_started( string $platform, array $metadata ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
		$this->current_session = array(
			'platform'            => $platform,
			'started_at'          => time(),
			'products_total'      => 0,
			'products_attempted'  => 0,
			'products_successful' => 0,
			'products_failed'     => 0,
			'products_skipped'    => 0,
			'product_types'       => array(),
			'total_time'          => 0,
			'is_dry_run'          => $metadata['is_dry_run'] ?? false,
		);
	}

	/**
	 * Handle batch processing completion.
	 *
	 * @param array $batch_results Results from the batch import.
	 * @param array $source_data   Source platform data for the batch.
	 * @param array $mapped_data   Mapped WooCommerce data for the batch.
	 */
	public function on_batch_processed( array $batch_results, array $source_data, array $mapped_data ): void {
		if ( empty( $this->current_session ) ) {
			return;
		}

		// Track detailed statistics for better telemetry accuracy.
		$batch_stats                                   = $batch_results['stats'] ?? array();
		$this->current_session['products_attempted']  += count( $mapped_data );
		$this->current_session['products_successful'] += $batch_stats['successful'] ?? 0;
		$this->current_session['products_failed']     += $batch_stats['failed'] ?? 0;
		$this->current_session['products_skipped']    += $batch_stats['skipped'] ?? 0;

		$this->track_product_types( $mapped_data, $batch_results );
	}

	/**
	 * Handle migration session completion.
	 *
	 * @param string $platform    Platform identifier.
	 * @param array  $final_stats Final migration statistics.
	 */
	public function on_session_completed( string $platform, array $final_stats ): void {
		if ( empty( $this->current_session ) ) {
			// Log warning for debugging - session completed without active session.
			if ( function_exists( 'wc_get_logger' ) ) {
				wc_get_logger()->warning(
					'Migration session completed event fired without active session.',
					array( 'source' => 'migrator_tracker' )
				);
			}
			return;
		}

		// Use consistent time() calls to avoid any timezone issues.
		$completion_time                       = time();
		$this->current_session['total_time']   = $completion_time - $this->current_session['started_at'];
		$this->current_session['completed_at'] = $completion_time;

		$this->current_session['products_total'] = $final_stats['total_found'] ?? $this->current_session['products_attempted'];

		$this->save_session_data();

		$this->current_session = array();
	}

	/**
	 * Track product types from mapped data and import results.
	 *
	 * Only count product types for successfully imported products to ensure
	 * telemetry accuracy.
	 *
	 * @param array $mapped_data   Array of mapped product data.
	 * @param array $batch_results Results from the batch import.
	 */
	private function track_product_types( array $mapped_data, array $batch_results ): void {
		$successful_results = array_filter(
			$batch_results['results'] ?? array(),
			function ( $result ) {
				return 'success' === ( $result['status'] ?? '' ) && 'skipped' !== ( $result['action'] ?? '' );
			}
		);

		// Only track types for successfully imported products.
		foreach ( $successful_results as $index => $result ) {
			if ( ! isset( $mapped_data[ $index ] ) ) {
				continue;
			}

			$product = $mapped_data[ $index ];
			$type    = $product['type'] ?? 'simple';

			if ( ! isset( $this->current_session['product_types'][ $type ] ) ) {
				$this->current_session['product_types'][ $type ] = 0;
			}

			++$this->current_session['product_types'][ $type ];
		}
	}

	/**
	 * Save current session data to persistent storage.
	 */
	private function save_session_data(): void {
		$analytics = $this->get_stored_analytics();
		$platform  = $this->current_session['platform'];

		if ( ! isset( $analytics['platforms'][ $platform ] ) ) {
			$analytics['platforms'][ $platform ] = array(
				'total_products_attempted'  => 0,
				'total_products_successful' => 0,
				'total_products_failed'     => 0,
				'total_products_skipped'    => 0,
				'total_sessions'            => 0,
				'total_time'                => 0,
				'product_types'             => array(),
				'last_migration'            => null,
				'dry_run_sessions'          => 0,
			);
		}

		$platform_data = &$analytics['platforms'][ $platform ];

		$products_attempted  = $this->current_session['products_attempted'] ?? 0;
		$products_successful = $this->current_session['products_successful'] ?? 0;
		$products_failed     = $this->current_session['products_failed'] ?? 0;
		$products_skipped    = $this->current_session['products_skipped'] ?? 0;
		$total_time          = $this->current_session['total_time'] ?? 0;
		$completed_at        = $this->current_session['completed_at'] ?? time();
		$product_types       = $this->current_session['product_types'] ?? array();
		$is_dry_run          = $this->current_session['is_dry_run'] ?? false;

		// Update platform statistics.
		if ( ! $is_dry_run ) {
			$platform_data['total_products_attempted']  += $products_attempted;
			$platform_data['total_products_successful'] += $products_successful;
			$platform_data['total_products_failed']     += $products_failed;
			$platform_data['total_products_skipped']    += $products_skipped;
			$platform_data['last_migration']             = $completed_at;
		} else {
			++$platform_data['dry_run_sessions'];
		}

		++$platform_data['total_sessions'];
		$platform_data['total_time'] += $total_time;

		foreach ( $product_types as $type => $count ) {
			if ( ! isset( $platform_data['product_types'][ $type ] ) ) {
				$platform_data['product_types'][ $type ] = 0;
			}
			$platform_data['product_types'][ $type ] += $count;
		}

		if ( ! isset( $analytics['totals'] ) || ! is_array( $analytics['totals'] ) ) {
			$analytics['totals'] = array();
		}

		// Only update global totals for non-dry-run sessions.
		if ( ! $is_dry_run ) {
			$analytics['totals']['products_attempted']  = ( $analytics['totals']['products_attempted'] ?? 0 ) + $products_attempted;
			$analytics['totals']['products_successful'] = ( $analytics['totals']['products_successful'] ?? 0 ) + $products_successful;
			$analytics['totals']['products_failed']     = ( $analytics['totals']['products_failed'] ?? 0 ) + $products_failed;
			$analytics['totals']['products_skipped']    = ( $analytics['totals']['products_skipped'] ?? 0 ) + $products_skipped;
		}

		$analytics['totals']['total_sessions']       = ( $analytics['totals']['total_sessions'] ?? 0 ) + 1;
		$analytics['totals']['total_migration_time'] = ( $analytics['totals']['total_migration_time'] ?? 0 ) + $total_time;
		$analytics['totals']['dry_run_sessions']     = ( $analytics['totals']['dry_run_sessions'] ?? 0 ) + ( $is_dry_run ? 1 : 0 );

		$this->save_analytics( $analytics );
	}

	/**
	 * Get comprehensive migration data for WC_Tracker integration.
	 *
	 * @return array Formatted data for telemetry reporting.
	 */
	public function get_data(): array {
		$analytics = $this->get_stored_analytics();

		$totals = $analytics['totals'] ?? array();

		$data = array(
			'products_attempted'       => $totals['products_attempted'] ?? 0,
			'products_successful'      => $totals['products_successful'] ?? 0,
			'products_failed'          => $totals['products_failed'] ?? 0,
			'products_skipped'         => $totals['products_skipped'] ?? 0,
			'total_migration_sessions' => $totals['total_sessions'] ?? 0,
			'total_migration_time'     => $totals['total_migration_time'] ?? 0,
			'dry_run_sessions'         => $totals['dry_run_sessions'] ?? 0,
			'platforms_used'           => array_keys( $analytics['platforms'] ?? array() ),
			'platform_breakdown'       => array(),
			'success_rate'             => $this->calculate_success_rate( $totals ),
		);

		$platforms = $analytics['platforms'] ?? array();
		foreach ( $platforms as $platform => $platform_data ) {
			$data['platform_breakdown'][ $platform ] = array(
				'products_attempted'  => $platform_data['total_products_attempted'] ?? 0,
				'products_successful' => $platform_data['total_products_successful'] ?? 0,
				'products_failed'     => $platform_data['total_products_failed'] ?? 0,
				'products_skipped'    => $platform_data['total_products_skipped'] ?? 0,
				'sessions_count'      => $platform_data['total_sessions'] ?? 0,
				'dry_run_sessions'    => $platform_data['dry_run_sessions'] ?? 0,
				'total_time'          => $platform_data['total_time'] ?? 0,
				'product_types'       => $platform_data['product_types'] ?? array(),
				'last_migration'      => $platform_data['last_migration'] ?? null,
				'success_rate'        => $this->calculate_success_rate( $platform_data ),
			);
		}

		return $data;
	}

	/**
	 * Calculate success rate as a percentage.
	 *
	 * @param array $stats Statistics array containing attempted and successful counts.
	 * @return float Success rate as a percentage (0-100).
	 */
	private function calculate_success_rate( array $stats ): float {
		$attempted  = $stats['total_products_attempted'] ?? $stats['products_attempted'] ?? 0;
		$successful = $stats['total_products_successful'] ?? $stats['products_successful'] ?? 0;

		if ( 0 === $attempted ) {
			return 0.0;
		}

		return round( ( $successful / $attempted ) * 100, 2 );
	}

	/**
	 * Get stored analytics data with defaults.
	 *
	 * @return array Analytics data structure.
	 */
	private function get_stored_analytics(): array {
		$defaults = array(
			'totals'    => array(
				'products_attempted'   => 0,
				'products_successful'  => 0,
				'products_failed'      => 0,
				'products_skipped'     => 0,
				'total_sessions'       => 0,
				'total_migration_time' => 0,
				'dry_run_sessions'     => 0,
			),
			'platforms' => array(),
		);

		$stored = get_option( self::OPTION_NAME, array() );
		return wp_parse_args( $stored, $defaults );
	}

	/**
	 * Save analytics data to WordPress options.
	 *
	 * @param array $analytics Analytics data to save.
	 */
	private function save_analytics( array $analytics ): void {
		if ( false === get_option( self::OPTION_NAME ) ) {
			add_option( self::OPTION_NAME, $analytics, '', 'no' );
		} else {
			update_option( self::OPTION_NAME, $analytics, 'no' );
		}
	}

	/**
	 * Clear all stored analytics data.
	 * Useful for development/testing or user privacy requests.
	 */
	public function clear_data(): void {
		delete_option( self::OPTION_NAME );
		$this->current_session = array();
	}
}
PK     [1]s      CLI/Migrator/Runner.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\CLI\Migrator;

use Automattic\WooCommerce\Internal\CLI\Migrator\Commands\ProductsCommand;
use Automattic\WooCommerce\Internal\CLI\Migrator\Commands\ResetCommand;
use Automattic\WooCommerce\Internal\CLI\Migrator\Commands\SetupCommand;
use Automattic\WooCommerce\Internal\CLI\Migrator\Commands\ListCommand;
use Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Shopify\ShopifyPlatform;
use WP_CLI;
use WC_Product_Factory;

/**
 * The main runner for the migrator.
 */
final class Runner {

	/**
	 * Register the commands for the migrator.
	 *
	 * @return void
	 */
	public static function register_commands(): void {
		// Initialize built-in platforms.
		self::init_platforms();

		$container = wc_get_container();

		WP_CLI::add_command(
			'wc migrate products',
			$container->get( ProductsCommand::class ),
			array(
				'shortdesc' => 'Migrate products from a source platform to WooCommerce.',
				'longdesc'  => 'Migrate products from a source platform to WooCommerce. The migrator will fetch products from the source platform, map them to the WooCommerce product schema, and then import them into WooCommerce.',
			)
		);

		WP_CLI::add_command(
			'wc migrate reset',
			$container->get( ResetCommand::class ),
			array(
				'shortdesc' => 'Resets (deletes) the credentials for a given platform.',
			)
		);

		WP_CLI::add_command(
			'wc migrate setup',
			$container->get( SetupCommand::class ),
			array(
				'shortdesc' => 'Interactively sets up the credentials for a given platform.',
			)
		);

		WP_CLI::add_command(
			'wc migrate list',
			$container->get( ListCommand::class ),
			array(
				'shortdesc' => 'Lists all registered migration platforms.',
			)
		);
	}

	/**
	 * Initialize built-in migration platforms.
	 *
	 * @return void
	 */
	private static function init_platforms(): void {
		ShopifyPlatform::init();
	}
}
PK     [1]$}J  }J  "  CLI/Migrator/Lib/ImportSession.phpnu         <?php

/**
 * !! Do not apply Woo-specific changes to this class !!
 *
 * This class is a part of the WordPress/php-toolkit project and is currently
 * duplicated between WordPress/php-toolkit and woocommerce/woocommerce:
 * * https://github.com/WordPress/php-toolkit/blob/trunk/components/DataLiberation/Importer/ImportSession.php
 * * https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/Internal/CLI/Migrator/Lib/ImportSession.php
 *
 * Apply all changes in both projects until Woo consumes php-toolkit as a
 * composer dependency. Generic changes belong to this class. Anything
 * Woo-specific should be implemented as an extension point.
 *
 * MODIFICATION: Made this class standalone by replacing external StreamImporter 
 * and AttachmentDownloaderEvent dependencies with internal constants to eliminate 
 * external imports and make the class fully self-contained.
 */

namespace Automattic\WooCommerce\Internal\CLI\Migrator\Lib;

use WP_Query;

use function get_all_post_meta_flat;
use function is_wp_error;

/**
 * Manages import session data in the WordPress database.
 *
 * Each import session is stored as a post of type 'import_session'.
 * Progress, stage, and other metadata are stored as post meta.
 */
class ImportSession {
	const POST_TYPE = 'import_session';

	// Import stage constants - replaces StreamImporter dependencies
	const STAGE_INITIAL = 'initial';
	const STAGE_FINISHED = 'finished';
	
	// Import stages in processing order
	const STAGES_IN_ORDER = array(
		self::STAGE_INITIAL,
		'indexing',
		'preparing',
		'importing',
		'finalizing',
		self::STAGE_FINISHED,
	);

	// Event type constants - replaces AttachmentDownloaderEvent dependencies  
	const EVENT_SUCCESS = 'success';
	const EVENT_ALREADY_EXISTS = 'already_exists';
	const EVENT_FAILURE = 'failure';

	/**
	 * @TODO: Make it extendable
	 * @TODO: Reuse the same entities list as WP_Stream_Importer
	 */
	const PROGRESS_ENTITIES = array(
		'site_option',
		'user',
		'category',
		'tag',
		'term',
		'post',
		'post_meta',
		'comment',
		'comment_meta',
	);

	const FRONTLOAD_STATUS_AWAITING_DOWNLOAD = 'awaiting_download';
	const FRONTLOAD_STATUS_IGNORED = 'ignored';
	const FRONTLOAD_STATUS_ERROR = 'error';
	const FRONTLOAD_STATUS_SUCCEEDED = 'succeeded';
	private $post_id;
	private $cached_stage;

	/**
	 * Creates a new import session.
	 *
	 * @param  array  $args  {
	 *
	 * @type string $data_source The data source (e.g. 'wxr_file', 'wxr_url', 'markdown_zip')
	 * @type string $source_url Optional. URL of the source file for remote imports
	 * @type int $attachment_id Optional. ID of the uploaded file attachment
	 * @type string $file_name Optional. Original name of the uploaded file
	 * }
	 * @return ImportSession The created ImportSession instance.
	 * @throws \Exception If the arguments are invalid.
	 */
	public static function create( $args ) {
		// Validate the required arguments for each data source.
		// @TODO: Leave it up to filters to make it extendable.
		switch ( $args['data_source'] ) {
			case 'wxr_file':
				if ( empty( $args['file_name'] ) ) {
					throw new \Exception( 'File name is required for WXR file imports' );
				}
				break;
			case 'wxr_url':
				if ( empty( $args['source_url'] ) ) {
					throw new \Exception( 'Source URL is required for remote imports' );
				}
				break;
			case 'markdown_zip':
				if ( empty( $args['file_name'] ) ) {
					throw new \Exception( 'File name is required for Markdown ZIP imports' );
				}
				break;
			case 'local_directory':
				if ( empty( $args['file_name'] ) ) {
					throw new \Exception( 'Directory path is required for local directory imports' );
				}
				break;
		}

		$post_id = wp_insert_post(
			array(
				'post_type'   => self::POST_TYPE,
				'post_status' => 'publish',
				'post_title'  => sprintf(
					'Import from %s - %s',
					$args['data_source'],
					$args['file_name'] ?? $args['source_url'] ?? 'Unknown source'
				),
				'meta_input'  => array(
					'data_source'   => $args['data_source'],
					'started_at'    => time(),
					'file_name'     => $args['file_name'] ?? null,
					'source_url'    => $args['source_url'] ?? null,
					'attachment_id' => $args['attachment_id'] ?? null,
				),
			),
			true
		);
		if ( is_wp_error( $post_id ) ) {
			throw new \Exception( 'Error creating an import session: ' . $post_id->get_error_message() );
		}

		if ( ! empty( $args['attachment_id'] ) ) {
			wp_update_post(
				array(
					'ID'          => $post_id,
					'post_parent' => $args['attachment_id'],
				)
			);
		}

		return new self( $post_id );
	}

	/**
	 * Gets an existing import session by ID.
	 *
	 * @param  int  $post_id  The import session post ID
	 *
	 * @return WP_Import_Model|null The import model instance or null if not found
	 */
	public static function by_id( $post_id ) {
		$post = get_post( $post_id );
		if ( ! $post || $post->post_type !== self::POST_TYPE ) {
			return false;
		}

		return new self( $post_id );
	}

	/**
	 * Gets the most recent active import session.
	 *
	 * @return WP_Import_Session|null The most recent import or null if none found
	 */
	public static function get_active() {
		$posts = get_posts(
			array(
				'post_type'      => self::POST_TYPE,
				'post_status'    => array( 'publish' ),
				'posts_per_page' => 1,
				'orderby'        => 'date',
				'order'          => 'DESC',
				'meta_query'     => array(
					// @TODO: This somehow makes $post empty.
					// array(
					// 'key' => 'current_stage',
					// 'value' => WP_Stream_Importer::STAGE_FINISHED,
					// 'compare' => '!='
					// )
				),
			)
		);

		if ( empty( $posts ) ) {
			return false;
		}

		return new self( $posts[0]->ID );
	}

	public function __construct( $post_id ) {
		$this->post_id = $post_id;
	}

	/**
	 * Gets the import session ID.
	 *
	 * @return int The post ID
	 */
	public function get_id() {
		return $this->post_id;
	}

	public function get_metadata() {
		$cursor = $this->get_reentrancy_cursor();

		return array(
			'post_id'       => $this->post_id,
			'cursor'        => $cursor ? $cursor : null,
			'data_source'   => get_post_meta( $this->post_id, 'data_source', true ),
			'source_url'    => get_post_meta( $this->post_id, 'source_url', true ),
			'attachment_id' => get_post_meta( $this->post_id, 'attachment_id', true ),
		);
	}

	public function get_data_source() {
		return get_post_meta( $this->post_id, 'data_source', true );
	}

	public function get_human_readable_file_reference() {
		switch ( $this->get_data_source() ) {
			case 'wxr_file':
			case 'markdown_zip':
				return get_post_meta( $this->post_id, 'file_name', true );
			case 'wxr_url':
				return get_post_meta( $this->post_id, 'source_url', true );
		}

		return '';
	}

	public function archive() {
		wp_update_post(
			array(
				'ID'          => $this->post_id,
				'post_status' => 'archived',
			)
		);
	}

	/**
	 * Gets the current progress information.
	 *
	 * @return array The progress data
	 */
	public function count_imported_entities() {
		$progress = array();
		foreach ( self::PROGRESS_ENTITIES as $entity ) {
			$progress[] = array(
				'label'    => $entity,
				'imported' => (int) get_post_meta( $this->post_id, 'imported_' . $entity, true ),
				'total'    => (int) get_post_meta( $this->post_id, 'total_' . $entity, true ),
			);
		}

		return $progress;
	}

	public function count_all_imported_entities() {
		$counts = $this->count_imported_entities();

		return array_sum( array_column( $counts, 'imported' ) );
	}

	public function count_all_total_entities() {
		$counts = $this->count_imported_entities();

		return array_sum( array_column( $counts, 'total' ) );
	}

	public function count_remaining_entities() {
		$counts = $this->count_imported_entities();

		return array_sum( array_column( $counts, 'total' ) ) - array_sum( array_column( $counts, 'imported' ) );
	}

	/**
	 * Cache of imported entity counts to avoid repeated database queries
	 *
	 * @var array
	 */
	private $cached_imported_counts = array();

	/**
	 * Updates the progress information.
	 *
	 * @param  array  $newly_imported_entities  The new progress data with keys: posts, comments, terms, attachments, users
	 */
	public function bump_imported_entities_counts( $newly_imported_entities ) {
		foreach ( $newly_imported_entities as $field => $count ) {
			if ( ! in_array( $field, static::PROGRESS_ENTITIES, true ) ) {
				_doing_it_wrong(
					__METHOD__,
					'Cannot bump imported entities count for unknown entity type: ' . $field,
					'1.0.0'
				);
				continue;
			}

			// Get current count from cache or database
			if ( ! isset( $this->cached_imported_counts[ $field ] ) ) {
				$this->cached_imported_counts[ $field ] = (int) get_post_meta( $this->post_id, 'imported_' . $field, true );
			}

			// Add new count to total
			$new_count = $this->cached_imported_counts[ $field ] + $count;

			// Update database and cache
			update_post_meta( $this->post_id, 'imported_' . $field, $new_count );
			$this->cached_imported_counts[ $field ] = $new_count;
			/*
			@TODO run an atomic query instead:
			$sql = $wpdb->prepare(
				"INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value)
				VALUES (%d, %s, %d)
				ON DUPLICATE KEY UPDATE meta_value = meta_value + %d",
				$this->post_id,
				'imported_' . $field,
				$count,
				$count
			);
			$wpdb->query($sql);
			*/
		}
	}

	public function count_awaiting_frontloading_stubs() {
		global $wpdb;

		return (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM $wpdb->posts
				 WHERE post_type = 'frontloading_stub'
				 AND post_parent = %d
				 AND post_status = %s",
				$this->post_id,
				self::FRONTLOAD_STATUS_AWAITING_DOWNLOAD
			)
		);
	}

	public function count_unfinished_frontloading_stubs() {
		global $wpdb;

		return (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM $wpdb->posts
				 WHERE post_type = 'frontloading_stub'
				 AND post_parent = %d
				 AND post_status != %s
				 AND post_status != %s",
				$this->post_id,
				self::FRONTLOAD_STATUS_SUCCEEDED,
				self::FRONTLOAD_STATUS_IGNORED
			)
		);
	}

	public function mark_frontloading_errors_as_ignored() {
		global $wpdb;
		$wpdb->update(
			$wpdb->posts,
			array( 'post_status' => self::FRONTLOAD_STATUS_IGNORED ),
			array(
				'post_type' => 'frontloading_stub',
				// 'post_status !=' => self::FRONTLOAD_STATUS_SUCCEEDED,
			)
		);
	}

	public function get_frontloading_stubs( $options = array() ) {
		$query = new WP_Query(
			array(
				'post_type'      => 'frontloading_stub',
				'post_status'    => 'any',
				'post_parent'    => $this->post_id,
				'posts_per_page' => $options['per_page'] ?? 25,
				'paged'          => $options['page'] ?? 1,
				'orderby'        => array(
					'post_status' => array(
						self::FRONTLOAD_STATUS_ERROR             => 0,
						self::FRONTLOAD_STATUS_AWAITING_DOWNLOAD => 1,
						'any'                                    => 2,
					),
					'ID'          => 'ASC',
				),
			)
		);

		if ( ! $query->have_posts() ) {
			return array();
		}

		$posts = $query->posts;
		$ids   = array_map(
			function ( $post ) {
				return $post->ID;
			},
			$posts
		);
		update_meta_cache( 'post', $ids );
		foreach ( $posts as $post ) {
			$post->meta = get_all_post_meta_flat( $post->ID );
		}

		return $posts;
	}

	public function get_total_number_of_entities() {
		$totals = array();
		foreach ( static::PROGRESS_ENTITIES as $field ) {
			$totals[ $field ] = (int) get_post_meta( $this->post_id, 'total_' . $field, true );
		}
		$totals['download'] = $this->get_total_number_of_assets();

		return $totals;
	}

	public function get_total_number_of_assets() {
		global $wpdb;

		return (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM $wpdb->posts
			WHERE post_type = 'frontloading_stub'
			AND post_parent = %d",
				$this->post_id
			)
		);
	}

	public function get_frontloading_stub( $url ) {
		global $wpdb;
		$id = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT p.ID FROM $wpdb->posts p
				 INNER JOIN $wpdb->postmeta pm ON p.ID = pm.post_id
				 WHERE p.post_type = 'frontloading_stub'
				 AND p.post_parent = %d
				 AND pm.meta_key = 'current_url'
				 AND pm.meta_value = %s
				 LIMIT 1",
				$this->post_id,
				$url
			)
		);

		return get_post( $id );
	}

	/**
	 * Creates placeholder attachments for the assets to be downloaded in the
	 * frontloading stage.
	 */
	public function create_frontloading_stubs( $urls ) {
		global $wpdb;

		foreach ( $urls as $url => $_ ) {
			/**
			 * Check if placeholder with this URL already exists
			 * There's a race condition here – another insert may happen
			 * between the check and the insert.
			 *
			 * @TODO: Explore solutions. A custom table with a UNIQUE constraint
			 * may or may not be an option, depending on the performance impact
			 * on 100GB+ VIP databases.
			 */
			$exists = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT ID FROM $wpdb->posts
				WHERE post_type = 'frontloading_stub'
				AND post_parent = %d
				AND guid = %s
				LIMIT 1",
					$this->post_id,
					$url
				)
			);

			if ( $exists ) {
				continue;
			}

			$post_data        = array(
				'post_type'   => 'frontloading_stub',
				'post_parent' => $this->post_id,
				'post_title'  => basename( $url ),
				'post_status' => self::FRONTLOAD_STATUS_AWAITING_DOWNLOAD,
				'guid'        => $url,
				'meta_input'  => array(
					'original_url' => $url,
					'current_url'  => $url,
					'attempts'     => 0,
					'last_error'   => null,
					'target_path'  => '',
				),
			);
			$insertion_result = wp_insert_post( $post_data );
			if ( is_wp_error( $insertion_result ) ) {
				throw new \Exception( 'Failed to insert frontloading placeholder' );
			}
		}
	}

	/**
	 * Sets the total number of entities to import for each type.
	 *
	 * @param  array  $totals  The total number of entities for each type
	 */
	private $cached_totals = array();

	public function bump_total_number_of_entities( $newly_indexed_entities ) {
		foreach ( $newly_indexed_entities as $field => $count ) {
			if ( ! in_array( $field, static::PROGRESS_ENTITIES, true ) ) {
				_doing_it_wrong(
					__METHOD__,
					'Cannot set total number of entities for unknown entity type: ' . $field,
					'1.0.0'
				);
				continue;
			}

			// Get current total from cache or database
			if ( ! isset( $this->cached_totals[ $field ] ) ) {
				$this->cached_totals[ $field ] = (int) get_post_meta( $this->post_id, 'total_' . $field, true );
			}

			// Add new count to total
			$new_total = $this->cached_totals[ $field ] + $count;

			// Update database and cache
			update_post_meta( $this->post_id, 'total_' . $field, $new_total );
			$this->cached_totals[ $field ] = $new_total;
		}
	}

	/**
	 * Saves an array of [$url => ['received' => $downloaded_bytes, 'total' => $total_bytes | null]]
	 * of the currently fetched files. The list is ephemeral and changes as we stream the data. There
	 * will never be more than $concurrency_limit files in the list at any given time.
	 */
	public function bump_frontloading_progress( $frontloading_progress, $events = array() ) {
		update_post_meta( $this->post_id, 'frontloading_progress', $frontloading_progress );

		foreach ( $events as $event ) {
			$url         = $event->resource_id;
			$placeholder = $this->get_frontloading_stub( $url );
			if ( ! $placeholder ) {
				_doing_it_wrong(
					__METHOD__,
					'Frontloading placeholder post not found for URL: ' . $url,
					'1.0.0'
				);
				continue;
			}

			update_post_meta( $placeholder->ID, 'last_error', $event->error );

			$attempts     = get_post_meta( $placeholder->ID, 'attempts', true );
			$new_attempts = $attempts;
			$new_status   = $placeholder->post_status;
			switch ( $event->type ) {
				case self::EVENT_SUCCESS:
					$new_status   = self::FRONTLOAD_STATUS_SUCCEEDED;
					$new_attempts = $attempts + 1;
					break;
				case self::EVENT_ALREADY_EXISTS:
					$new_status = self::FRONTLOAD_STATUS_SUCCEEDED;
					break;
				case self::EVENT_FAILURE:
					$new_status   = self::FRONTLOAD_STATUS_ERROR;
					$new_attempts = $attempts + 1;
					break;
			}

			if ( $new_attempts !== $attempts ) {
				update_post_meta( $placeholder->ID, 'attempts', $new_attempts );
			}

			if ( $new_status !== $placeholder->post_status ) {
				wp_update_post(
					array(
						'ID'          => $placeholder->ID,
						'post_status' => $new_status,
					)
				);
			}
		}
	}

	public function get_frontloading_progress() {
		$meta = get_post_meta( $this->post_id, 'frontloading_progress', true );

		return $meta ? $meta : array();
	}

	public function is_stage_completed( $stage ) {
		$current_stage       = $this->get_stage();
		$stage_index         = array_search( $stage, self::STAGES_IN_ORDER, true );
		$current_stage_index = array_search( $current_stage, self::STAGES_IN_ORDER, true );

		return $current_stage_index > $stage_index;
	}

	/**
	 * Gets the current import stage.
	 *
	 * @return string The current stage
	 */
	public function get_stage() {
		if ( ! isset( $this->cached_stage ) ) {
			$meta               = get_post_meta( $this->post_id, 'current_stage', true );
			$this->cached_stage = $meta ? $meta : self::STAGE_INITIAL;
		}

		return $this->cached_stage;
	}

	/**
	 * Updates the current import stage.
	 *
	 * @param  string  $stage  The new stage
	 */
	public function set_stage( $stage ) {
		if ( $stage === $this->get_stage() ) {
			return;
		}
		if ( self::STAGE_FINISHED === $stage ) {
			update_post_meta( $this->post_id, 'finished_at', time() );
		}
		update_post_meta( $this->post_id, 'current_stage', $stage );
		$this->cached_stage = $stage;
	}

	public function get_started_at() {
		return get_post_meta( $this->post_id, 'started_at', true );
	}

	public function get_finished_at() {
		return get_post_meta( $this->post_id, 'finished_at', true );
	}

	public function is_finished() {
		return ! empty( get_post_meta( $this->post_id, 'finished_at', true ) );
	}

	/**
	 * Gets the importer cursor for resuming imports.
	 *
	 * @return string|null The cursor data
	 */
	public function get_reentrancy_cursor() {
		return get_post_meta( $this->post_id, 'importer_cursor', true );
	}

	/**
	 * Updates the importer cursor.
	 *
	 * @param  string  $cursor  The new cursor data
	 */
	public function set_reentrancy_cursor( $cursor ) {
		// WordPress, sadly, removes single slashes from the meta value and
		// requires an addslashes() call to preserve them.
		update_post_meta( $this->post_id, 'importer_cursor', addslashes( $cursor ) );
	}

	/**
	 * Save the original command arguments for session resumption.
	 *
	 * @param array $args The original command arguments
	 */
	public function set_original_arguments( array $args ) {
		update_post_meta( $this->post_id, 'original_arguments', $args );
	}

	/**
	 * Get the original command arguments for session resumption.
	 *
	 * @return array|null The original arguments or null if not found
	 */
	public function get_original_arguments() {
		$args = get_post_meta( $this->post_id, 'original_arguments', true );
		return ( is_array( $args ) && ! empty( $args ) ) ? $args : null;
	}
}
PK     [1]|      0  CLI/Migrator/Platforms/Shopify/ShopifyClient.phpnu         <?php
/**
 * Shopify Client
 *
 * @package Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Shopify
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Shopify;

/**
 * Handles communication with the Shopify REST API.
 */
class ShopifyClient {

	/**
	 * Platform credentials.
	 *
	 * @var array
	 */
	private array $credentials;

	/**
	 * Constructor.
	 *
	 * @param array $credentials Platform credentials array.
	 */
	public function __construct( array $credentials ) {
		$this->credentials = $credentials;
	}

	/**
	 * Makes a request to the Shopify REST API.
	 *
	 * @param string $path         The API path (e.g., '/products/count.json').
	 * @param array  $query_params Optional query parameters.
	 * @param string $method       HTTP method (GET, POST, PUT, DELETE).
	 * @param array  $body         Request body for POST/PUT.
	 * @return object|\WP_Error Decoded JSON response object or WP_Error on failure.
	 */
	public function rest_request( string $path, array $query_params = array(), string $method = 'GET', array $body = array() ) {
		$credentials = $this->get_credentials();
		if ( is_wp_error( $credentials ) ) {
			return $credentials;
		}

		$rest_endpoint = $this->build_rest_url( $credentials['domain'], $path, $query_params );
		$request_args  = $this->build_request_args( $credentials['access_token'], $method, $body );

		$response = wp_remote_request( $rest_endpoint, $request_args );

		return $this->process_response( $response, $path );
	}

	/**
	 * Makes a request to the Shopify GraphQL API.
	 *
	 * @param string $query     The GraphQL query string.
	 * @param array  $variables The variables for the query.
	 * @return object|\WP_Error Decoded JSON response data or WP_Error on failure.
	 */
	public function graphql_request( string $query, array $variables = array() ) {
		$credentials = $this->get_credentials();
		if ( is_wp_error( $credentials ) ) {
			return $credentials;
		}

		$graphql_endpoint = $this->build_graphql_url( $credentials['domain'] );
		$request_args     = $this->build_graphql_request_args( $credentials['access_token'], $query, $variables );

		$response = wp_remote_request( $graphql_endpoint, $request_args );

		return $this->process_graphql_response( $response );
	}

	/**
	 * Get Shopify API credentials.
	 *
	 * @return array|\WP_Error Array with 'domain' and 'access_token' keys, or WP_Error on failure.
	 */
	private function get_credentials() {
		if ( empty( $this->credentials['shop_url'] ) || empty( $this->credentials['access_token'] ) ) {
			return new \WP_Error(
				'api_error',
				'Shopify API credentials (shop_url, access_token) are not configured. Please run: wp wc migrate setup'
			);
		}

		// Map the stored credential keys to the expected format.
		return array(
			'domain'       => $this->credentials['shop_url'],
			'access_token' => $this->credentials['access_token'],
		);
	}

	/**
	 * Build the REST API URL.
	 *
	 * @param string $domain       The Shopify domain.
	 * @param string $path         The API path.
	 * @param array  $query_params Query parameters.
	 * @return string The complete API URL.
	 */
	private function build_rest_url( string $domain, string $path, array $query_params ): string {
		// Ensure the domain has the protocol.
		if ( ! preg_match( '~^https?://~i', $domain ) ) {
			$domain = 'https://' . $domain;
		}

		$shop_url = untrailingslashit( $domain );
		// Use the latest stable API version.
		$api_version   = '2025-04';
		$rest_endpoint = "{$shop_url}/admin/api/{$api_version}{$path}";

		if ( ! empty( $query_params ) ) {
			$rest_endpoint = add_query_arg( $query_params, $rest_endpoint );
		}

		return $rest_endpoint;
	}

	/**
	 * Build the request arguments.
	 *
	 * @param string $access_token The Shopify access token.
	 * @param string $method       HTTP method.
	 * @param array  $body         Request body.
	 * @return array Request arguments for wp_remote_request.
	 */
	private function build_request_args( string $access_token, string $method, array $body ): array {
		$request_args = array(
			'method'  => $method,
			'headers' => array(
				'Content-Type'           => 'application/json',
				'X-Shopify-Access-Token' => $access_token,
			),
			'timeout' => 60,
		);

		if ( ! empty( $body ) && ( 'POST' === $method || 'PUT' === $method ) ) {
			$request_args['body'] = wp_json_encode( $body );
		}

		return $request_args;
	}

	/**
	 * Process the API response.
	 *
	 * @param array|WP_Error $response The HTTP response.
	 * @param string         $path     The API path for error reporting.
	 * @return object|\WP_Error Decoded response or WP_Error.
	 */
	private function process_response( $response, string $path ) {
		if ( is_wp_error( $response ) ) {
			return new \WP_Error( 'api_error', 'REST request failed: ' . $response->get_error_message() );
		}

		$response_code = wp_remote_retrieve_response_code( $response );
		$response_body = wp_remote_retrieve_body( $response );

		if ( $response_code >= 300 ) {
			$error_details = json_decode( $response_body );
			$error_message = isset( $error_details->errors ) ? wp_json_encode( $error_details->errors ) : $response_body;
			return new \WP_Error(
				'api_error',
				"REST request to {$path} failed with status code {$response_code}: " . $error_message
			);
		}

		$data = json_decode( $response_body );

		if ( json_last_error() !== JSON_ERROR_NONE ) {
			return new \WP_Error( 'api_error', 'Failed to decode REST JSON response: ' . json_last_error_msg() );
		}

		return $data;
	}

	/**
	 * Build the GraphQL API URL.
	 *
	 * @param string $domain The Shopify domain.
	 * @return string The complete GraphQL API URL.
	 */
	private function build_graphql_url( string $domain ): string {
		// Ensure the domain has the protocol.
		if ( ! preg_match( '~^https?://~i', $domain ) ) {
			$domain = 'https://' . $domain;
		}

		$shop_url = untrailingslashit( $domain );
		// Use the same API version as REST.
		$api_version = '2025-04';
		return "{$shop_url}/admin/api/{$api_version}/graphql.json";
	}

	/**
	 * Build the request arguments for GraphQL requests.
	 *
	 * @param string $access_token The Shopify access token.
	 * @param string $query        The GraphQL query.
	 * @param array  $variables    The GraphQL variables.
	 * @return array Request arguments for wp_remote_request.
	 *
	 * @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
	 */
	private function build_graphql_request_args( string $access_token, string $query, array $variables ): array {
		$request_body = compact( 'query', 'variables' );

		return array(
			'method'  => 'POST',
			'headers' => array(
				'Content-Type'           => 'application/json',
				'X-Shopify-Access-Token' => $access_token,
			),
			'body'    => wp_json_encode( $request_body ),
			'timeout' => 60,
		);
	}

	/**
	 * Process the GraphQL API response.
	 *
	 * @param array|\WP_Error $response The HTTP response.
	 * @return object|\WP_Error Decoded response data or WP_Error.
	 */
	private function process_graphql_response( $response ) {
		if ( is_wp_error( $response ) ) {
			return new \WP_Error( 'api_error', 'GraphQL request failed: ' . $response->get_error_message() );
		}

		$response_code = wp_remote_retrieve_response_code( $response );
		$response_body = wp_remote_retrieve_body( $response );

		if ( $response_code >= 300 ) {
			$error_details = json_decode( $response_body );
			$error_message = isset( $error_details->errors ) ? wp_json_encode( $error_details->errors ) : $response_body;
			return new \WP_Error(
				'api_error',
				"GraphQL request failed with status code {$response_code}: " . $error_message
			);
		}

		$data = json_decode( $response_body );

		if ( json_last_error() !== JSON_ERROR_NONE ) {
			return new \WP_Error( 'api_error', 'Failed to decode GraphQL JSON response: ' . json_last_error_msg() );
		}

		// Check for GraphQL-specific errors.
		if ( ! empty( $data->errors ) ) {
			return new \WP_Error( 'graphql_error', 'GraphQL API returned errors: ' . wp_json_encode( $data->errors ) );
		}

		if ( empty( $data->data ) ) {
			return new \WP_Error( 'api_error', 'GraphQL response missing "data" field.' );
		}

		return $data->data;
	}
}
PK     [1]N[?m  m  0  CLI/Migrator/Platforms/Shopify/ShopifyMapper.phpnu         <?php
/**
 * Shopify Mapper
 *
 * @package Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Shopify
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Shopify;

use Automattic\WooCommerce\Internal\CLI\Migrator\Interfaces\PlatformMapperInterface;

defined( 'ABSPATH' ) || exit;

/**
 * ShopifyMapper class.
 *
 * This class is responsible for transforming raw Shopify product data
 * into a standardized format suitable for the WooCommerce Importer.
 * Maps comprehensive product data including variants, images, taxonomies,
 * and metadata from Shopify's GraphQL API response format.
 *
 * @internal This class is part of the CLI Migrator feature and should not be used directly.
 */
class ShopifyMapper implements PlatformMapperInterface {

	/**
	 * Shopify weight unit to standard unit mapping.
	 *
	 * @var array
	 */
	private const WEIGHT_UNIT_MAP = array(
		'GRAMS'     => 'g',
		'KILOGRAMS' => 'kg',
		'POUNDS'    => 'lb',
		'OUNCES'    => 'oz',
	);

	/**
	 * Weight conversion factors between units.
	 * Structure: [from_unit][to_unit] = factor
	 *
	 * @var array
	 */
	private const WEIGHT_CONVERSION_FACTORS = array(
		'kg' => array(
			'kg' => 1,
			'g'  => 1000,
			'lb' => 2.20462,
			'oz' => 35.274,
		),
		'g'  => array(
			'kg' => 0.001,
			'g'  => 1,
			'lb' => 0.00220462,
			'oz' => 0.035274,
		),
		'lb' => array(
			'kg' => 0.453592,
			'g'  => 453.592,
			'lb' => 1,
			'oz' => 16,
		),
		'oz' => array(
			'kg' => 0.0283495,
			'g'  => 28.3495,
			'lb' => 0.0625,
			'oz' => 1,
		),
	);

	/**
	 * Fields to process during mapping.
	 *
	 * @var array
	 */
	private $fields_to_process = array();

	/**
	 * Constructor.
	 *
	 * @param array $args Optional arguments including 'fields' array for selective processing.
	 */
	public function __construct( array $args = array() ) {
		$this->fields_to_process = $args['fields'] ?? $this->get_default_product_fields();
	}

	/**
	 * Maps raw Shopify product data to a standardized array format.
	 *
	 * @param object $shopify_product The raw Shopify product node from GraphQL.
	 * @return array Standardized data array for WooCommerce_Product_Importer.
	 */
	public function map_product_data( object $shopify_product ): array {
		$is_variable = $this->is_variable_product( $shopify_product );

		$wc_data = $this->map_basic_product_fields( $shopify_product, $is_variable );

		// Map simple product data (for non-variable products).
		if ( ! $is_variable ) {
			$simple_data = $this->map_simple_product_data( $shopify_product );
			$wc_data     = array_merge( $wc_data, $simple_data );
		}

		// Map product images.
		$wc_data['images'] = $this->map_product_images( $shopify_product );

		// Map metafields and SEO data.
		$wc_data['metafields'] = $this->map_metafields( $shopify_product );

		// Map variable product data (attributes and variations).
		$variable_data = $this->map_variable_product_data( $shopify_product, $is_variable );
		$wc_data       = array_merge( $wc_data, $variable_data );

		return $wc_data;
	}

	/**
	 * Checks if a product is a variable product.
	 *
	 * @param object $shopify_product The Shopify product data.
	 * @return bool True if the product is a variable product, false otherwise.
	 */
	private function is_variable_product( object $shopify_product ): bool {
		return isset( $shopify_product->variants->edges ) && count( $shopify_product->variants->edges ) > 1;
	}

	/**
	 * Converts the Shopify product status into WooCommerce product status.
	 *
	 * @param object $shopify_product The Shopify product data.
	 * @return string The WooCommerce product status.
	 */
	private function get_woo_product_status( object $shopify_product ): string {
		$woo_product_status = 'draft';
		if ( 'ACTIVE' === $shopify_product->status ) {
			$woo_product_status = 'publish';
		}
		return $woo_product_status;
	}

	/**
	 * Maps enhanced publication status fields from Shopify.
	 *
	 * @param object $shopify_product The Shopify product data.
	 * @return array Enhanced status data.
	 */
	private function map_enhanced_status( object $shopify_product ): array {
		$status_data = array();

		// Publication date.
		if ( property_exists( $shopify_product, 'publishedAt' ) && $shopify_product->publishedAt ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
			$status_data['date_published_gmt'] = $shopify_product->publishedAt; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
		}

		// Available for sale flag.
		if ( property_exists( $shopify_product, 'availableForSale' ) ) {
			$status_data['available_for_sale'] = $shopify_product->availableForSale; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
		}

		return $status_data;
	}

	/**
	 * Maps product classification fields from Shopify.
	 *
	 * @param object $shopify_product The Shopify product data.
	 * @return array Product classification data.
	 */
	private function map_product_classification( object $shopify_product ): array {
		$classification = array();

		// Product type - check both camelCase and snake_case for compatibility.
		$product_type = null;
		if ( property_exists( $shopify_product, 'productType' ) && $shopify_product->productType ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
			$product_type = $shopify_product->productType; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
		} elseif ( property_exists( $shopify_product, 'product_type' ) && $shopify_product->product_type ) {
			$product_type = $shopify_product->product_type;
		}

		if ( $product_type ) {
			$classification['product_type'] = array(
				'name' => $product_type,
				'slug' => sanitize_title( $product_type ),
			);
		}

		// Standard category.
		if ( property_exists( $shopify_product, 'category' ) && is_object( $shopify_product->category ) ) {
			$classification['standard_category'] = array(
				'name' => $shopify_product->category->name ?? '',
				'slug' => sanitize_title( $shopify_product->category->name ?? '' ),
			);
		}

		// Gift card detection - check both camelCase and snake_case for compatibility.
		if ( property_exists( $shopify_product, 'isGiftCard' ) ) {
			$classification['is_gift_card'] = $shopify_product->isGiftCard; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
		} elseif ( property_exists( $shopify_product, 'is_gift_card' ) ) {
			$classification['is_gift_card'] = $shopify_product->is_gift_card;
		}

		if ( property_exists( $shopify_product, 'requiresSellingPlan' ) ) {
			$classification['requires_subscription'] = $shopify_product->requiresSellingPlan; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
		} elseif ( property_exists( $shopify_product, 'requires_selling_plan' ) ) {
			$classification['requires_subscription'] = $shopify_product->requires_selling_plan;
		}

		return $classification;
	}

	/**
	 * Maps SEO fields from Shopify product data.
	 *
	 * @param object $shopify_product The Shopify product data.
	 * @return array SEO metafields data.
	 */
	private function map_seo_fields( object $shopify_product ): array {
		$seo_data = array();

		if ( property_exists( $shopify_product, 'seo' ) && is_object( $shopify_product->seo ) ) {
			if ( ! empty( $shopify_product->seo->title ) ) {
				$seo_data['global_title_tag'] = $shopify_product->seo->title;
			}
			if ( ! empty( $shopify_product->seo->description ) ) {
				$seo_data['global_description_tag'] = $shopify_product->seo->description;
			}
		}

		return $seo_data;
	}

	/**
	 * Gets mapped WooCommerce product categories from Shopify collections.
	 *
	 * @param object $shopify_product The Shopify product data.
	 * @return array Mapped category data.
	 */
	private function get_mapped_categories( object $shopify_product ): array {
		$categories = array();
		if ( ! property_exists( $shopify_product, 'collections' ) || empty( $shopify_product->collections->edges ) ) {
			return $categories;
		}

		foreach ( $shopify_product->collections->edges as $collection_edge ) {
			$collection_node = $collection_edge->node;
			$categories[]    = array(
				'name' => wc_clean( $collection_node->title ),
				'slug' => sanitize_title( $collection_node->handle ),
			);
		}

		return $categories;
	}

	/**
	 * Gets mapped WooCommerce product tags from Shopify tags.
	 *
	 * @param object $shopify_product The Shopify product data.
	 * @return array Mapped tag data.
	 */
	private function get_mapped_tags( object $shopify_product ): array {
		$tags = array();
		if ( empty( $shopify_product->tags ) ) {
			return $tags;
		}

		foreach ( $shopify_product->tags as $tag ) {
			$trimmed_tag = trim( $tag );
			if ( ! empty( $trimmed_tag ) ) {
				$tags[] = array(
					'name' => wc_clean( $trimmed_tag ),
					'slug' => sanitize_title( $trimmed_tag ),
				);
			}
		}
		return $tags;
	}

	/**
	 * Converts weight based on Shopify weight unit to store's weight unit.
	 *
	 * @param float|null  $weight      The weight value from Shopify.
	 * @param string|null $weight_unit The weight unit from Shopify.
	 * @return float|null The converted weight, or null if input is invalid/zero.
	 */
	private function get_converted_weight( $weight, $weight_unit ): ?float {
		if ( null === $weight || null === $weight_unit || (float) $weight <= 0 ) {
			return null;
		}

		$shopify_unit_key = self::WEIGHT_UNIT_MAP[ $weight_unit ] ?? null;

		if ( ! $shopify_unit_key ) {
			return (float) $weight;
		}

		$store_weight_unit = get_option( 'woocommerce_weight_unit' );

		if ( 'lbs' === $store_weight_unit ) {
			$store_weight_unit = 'lb';
		}

		if ( $shopify_unit_key === $store_weight_unit ) {
			return (float) $weight;
		}

		// Use wc_get_weight for conversion if possible.
		if ( function_exists( 'wc_get_weight' ) ) {
			$converted = wc_get_weight( (float) $weight, $store_weight_unit, $shopify_unit_key );
			return is_numeric( $converted ) ? (float) $converted : null;
		}

		// Fallback manual conversion using class constants.
		if ( ! isset( self::WEIGHT_CONVERSION_FACTORS[ $shopify_unit_key ][ $store_weight_unit ] ) ) {
			return (float) $weight;
		}

		return (float) $weight * self::WEIGHT_CONVERSION_FACTORS[ $shopify_unit_key ][ $store_weight_unit ];
	}


	/**
	 * Checks if a specific field should be processed based on constructor args.
	 *
	 * @param string $field_key The field key.
	 * @return bool True if the field should be processed.
	 */
	private function should_process( string $field_key ): bool {
		if ( empty( $this->fields_to_process ) ) {
			return true;
		}
		return in_array( $field_key, $this->fields_to_process, true );
	}

	/**
	 * Maps basic product fields from Shopify to WooCommerce format.
	 *
	 * @param object $shopify_product The Shopify product data.
	 * @param bool   $is_variable     Whether this is a variable product.
	 * @return array Basic product field mappings.
	 */
	private function map_basic_product_fields( object $shopify_product, bool $is_variable ): array {
		$basic_data = array();

		$basic_data['is_variable']         = $is_variable;
		$basic_data['original_product_id'] = ! empty( $shopify_product->id ) ? basename( $shopify_product->id ) : null;

		// Basic Product Fields.
		$basic_data['name']              = wc_clean( $shopify_product->title );
		$basic_data['slug']              = sanitize_title( $shopify_product->handle );
		$basic_data['description']       = wp_kses_post( $shopify_product->descriptionHtml ?? '' ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
		$basic_data['short_description'] = wp_kses_post( $shopify_product->descriptionPlainSummary ?? '' ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
		$basic_data['status']            = $this->get_woo_product_status( $shopify_product );
		$basic_data['date_created_gmt']  = $shopify_product->createdAt; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.

		// Enhanced date handling.
		if ( property_exists( $shopify_product, 'updatedAt' ) ) {
			$basic_data['date_modified_gmt'] = $shopify_product->updatedAt; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
		}

		// Catalog Visibility & Original URL.
		$basic_data['catalog_visibility'] = 'visible';
		$basic_data['original_url']       = null;
		if ( property_exists( $shopify_product, 'onlineStoreUrl' ) ) {
			if ( null === $shopify_product->onlineStoreUrl ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
				$basic_data['catalog_visibility'] = 'hidden';
			} else {
				$basic_data['original_url'] = $shopify_product->onlineStoreUrl; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
			}
		}

		$enhanced_status = $this->map_enhanced_status( $shopify_product );
		$basic_data      = array_merge( $basic_data, $enhanced_status );

		// Taxonomies.
		$basic_data['categories'] = $this->get_mapped_categories( $shopify_product );
		$basic_data['tags']       = $this->get_mapped_tags( $shopify_product );

		// Enhanced product classification.
		$classification = $this->map_product_classification( $shopify_product );
		$basic_data     = array_merge( $basic_data, $classification );

		// Brand (Vendor).
		$brand_name          = $shopify_product->vendor ?? null;
		$basic_data['brand'] = $brand_name ? array(
			'name' => wc_clean( $brand_name ),
			'slug' => sanitize_title( $brand_name ),
		) : null;

		return $basic_data;
	}

	/**
	 * Maps simple product data (price, SKU, stock, weight) from Shopify variant.
	 *
	 * @param object $shopify_product The Shopify product data.
	 * @return array Simple product data mappings.
	 */
	private function map_simple_product_data( object $shopify_product ): array {
		$simple_data = array();

		if ( ! empty( $shopify_product->variants->edges ) ) {
			$variant_node = $shopify_product->variants->edges[0]->node;

			if ( $this->should_process( 'price' ) ) {
				if ( $variant_node->compareAtPrice && $variant_node->compareAtPrice > $variant_node->price ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
					$simple_data['sale_price']    = $variant_node->price;
					$simple_data['regular_price'] = $variant_node->compareAtPrice; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
				} else {
					$simple_data['sale_price']    = null;
					$simple_data['regular_price'] = $variant_node->price;
				}
			}

			if ( $this->should_process( 'sku' ) ) {
				$simple_data['sku'] = wc_clean( $variant_node->sku );
			}

			if ( $this->should_process( 'stock' ) ) {
				$manage_stock                  = property_exists( $variant_node, 'inventoryItem' ) && $variant_node->inventoryItem->tracked; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
				$simple_data['manage_stock']   = $manage_stock;
				$stock_quantity                = $variant_node->inventoryQuantity ?? 0; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
				$allow_oversell                = $manage_stock && 'CONTINUE' === $variant_node->inventoryPolicy; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
				$simple_data['stock_status']   = ( $stock_quantity > 0 || $allow_oversell ) ? 'instock' : 'outofstock';
				$simple_data['stock_quantity'] = $stock_quantity;
			}

			if ( $this->should_process( 'weight' ) ) {
				$weight_data = null;
				if ( property_exists( $variant_node, 'inventoryItem' ) && is_object( $variant_node->inventoryItem ) && // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
					property_exists( $variant_node->inventoryItem, 'measurement' ) && is_object( $variant_node->inventoryItem->measurement ) && // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
					property_exists( $variant_node->inventoryItem->measurement, 'weight' ) && is_object( $variant_node->inventoryItem->measurement->weight ) // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
				) {
					$weight_data = $variant_node->inventoryItem->measurement->weight; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
				}
				$weight                = $weight_data ? $weight_data->value : null;
				$weight_unit           = $weight_data ? $weight_data->unit : null;
				$simple_data['weight'] = $this->get_converted_weight( $weight, $weight_unit );
			}

			if ( property_exists( $variant_node, 'inventoryItem' ) && is_object( $variant_node->inventoryItem ) && // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
				property_exists( $variant_node->inventoryItem, 'unitCost' ) && is_object( $variant_node->inventoryItem->unitCost ) // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
			) {
				$simple_data['cost_of_goods'] = $variant_node->inventoryItem->unitCost->amount; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
			}

			if ( property_exists( $variant_node, 'taxable' ) ) {
				$simple_data['tax_status'] = $variant_node->taxable ? 'taxable' : 'none';
			}

			$simple_data['original_variant_id'] = ! empty( $variant_node->id ) ? basename( $variant_node->id ) : null;

		} else {
			$simple_data['sku']            = null;
			$simple_data['regular_price']  = null;
			$simple_data['sale_price']     = null;
			$simple_data['stock_quantity'] = null;
			$simple_data['manage_stock']   = false;
			$simple_data['stock_status']   = 'instock';
			$simple_data['weight']         = null;

			if ( property_exists( $shopify_product, 'taxable' ) ) {
				$simple_data['tax_status'] = $shopify_product->taxable ? 'taxable' : 'none';
			}

			$simple_data['original_variant_id'] = null;
		}

		return $simple_data;
	}

	/**
	 * Maps variable product data (attributes and variations) from Shopify.
	 *
	 * @param object $shopify_product The Shopify product data.
	 * @param bool   $is_variable     Whether this is a variable product.
	 * @return array Variable product data mappings.
	 */
	private function map_variable_product_data( object $shopify_product, bool $is_variable ): array {
		$variable_data = array();

		// Attributes (Variable Only).
		$variable_data['attributes'] = array();
		if ( $is_variable && property_exists( $shopify_product, 'options' ) && ! empty( $shopify_product->options ) ) {
			foreach ( $shopify_product->options as $option ) {
				$variable_data['attributes'][] = array(
					'name'         => wc_clean( $option->name ),
					'options'      => array_map( 'wc_clean', $option->values ),
					'position'     => $option->position,
					'is_visible'   => true,
					'is_variation' => true,
				);
			}
		}

		// Variations (Variable Only).
		$variable_data['variations'] = array();
		if ( $is_variable && property_exists( $shopify_product, 'variants' ) && ! empty( $shopify_product->variants->edges ) ) {
			foreach ( $shopify_product->variants->edges as $variant_edge ) {
				$variant_node                  = $variant_edge->node;
				$variation_data                = array();
				$variation_data['original_id'] = ! empty( $variant_node->id ) ? basename( $variant_node->id ) : null;

				if ( $this->should_process( 'price' ) ) {
					if ( $variant_node->compareAtPrice && (float) $variant_node->compareAtPrice > (float) $variant_node->price ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
						$variation_data['regular_price'] = $variant_node->compareAtPrice; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
						$variation_data['sale_price']    = $variant_node->price;
					} else {
						$variation_data['regular_price'] = $variant_node->price;
						$variation_data['sale_price']    = null;
					}
				}

				if ( $this->should_process( 'sku' ) ) {
					$variation_data['sku'] = wc_clean( $variant_node->sku ?? '' );
				}

				if ( $this->should_process( 'stock' ) ) {
					$manage_stock                     = property_exists( $variant_node, 'inventoryItem' ) && $variant_node->inventoryItem->tracked; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
					$variation_data['manage_stock']   = $manage_stock;
					$stock_quantity                   = $variant_node->inventoryQuantity ?? 0; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
					$allow_oversell                   = $manage_stock && 'CONTINUE' === $variant_node->inventoryPolicy; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
					$variation_data['stock_status']   = ( $stock_quantity > 0 || $allow_oversell ) ? 'instock' : 'outofstock';
					$variation_data['stock_quantity'] = $stock_quantity;
				}

				if ( $this->should_process( 'weight' ) ) {
					$weight_data = null;
					if ( property_exists( $variant_node, 'inventoryItem' ) && is_object( $variant_node->inventoryItem ) && // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
						property_exists( $variant_node->inventoryItem, 'measurement' ) && is_object( $variant_node->inventoryItem->measurement ) && // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
						property_exists( $variant_node->inventoryItem->measurement, 'weight' ) && is_object( $variant_node->inventoryItem->measurement->weight ) // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
					) {
						$weight_data = $variant_node->inventoryItem->measurement->weight; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
					}
					$weight                   = $weight_data ? $weight_data->value : null;
					$weight_unit              = $weight_data ? $weight_data->unit : null;
					$variation_data['weight'] = $this->get_converted_weight( $weight, $weight_unit );
				}

				if ( property_exists( $variant_node, 'inventoryItem' ) && is_object( $variant_node->inventoryItem ) && // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
					property_exists( $variant_node->inventoryItem, 'unitCost' ) && is_object( $variant_node->inventoryItem->unitCost ) // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
				) {
					$variation_data['cost_of_goods'] = $variant_node->inventoryItem->unitCost->amount; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
				}

				if ( property_exists( $variant_node, 'taxable' ) ) {
					$variation_data['tax_status'] = $variant_node->taxable ? 'taxable' : 'none';
				}

				if ( $this->should_process( 'attributes' ) ) {
					$variation_data['attributes'] = array();
					if ( ! empty( $variant_node->selectedOptions ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
						foreach ( $variant_node->selectedOptions as $selectedOption ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase,WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase -- GraphQL uses camelCase.
							$variation_data['attributes'][ wc_clean( $selectedOption->name ) ] = wc_clean( $selectedOption->value ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase -- GraphQL uses camelCase.
						}
					}
				}

				if ( $this->should_process( 'images' ) ) {
					$variation_data['image_original_id'] = null;
					if ( ! empty( $variant_node->media->edges ) ) {
						$variant_media_node = $variant_node->media->edges[0]->node ?? null;
						if ( $variant_media_node && property_exists( $variant_media_node, 'image' ) && is_object( $variant_media_node->image ) && ! empty( $variant_media_node->id ) ) {
							$variation_data['image_original_id'] = $variant_media_node->id;
						}
					}
				}

				// Menu Order / Position.
				$variation_data['menu_order'] = $variant_node->position;

				$variable_data['variations'][] = $variation_data;
			}
		}

		return $variable_data;
	}

	/**
	 * Maps product images from Shopify media data.
	 *
	 * @param object $shopify_product The Shopify product data.
	 * @return array Product images data.
	 */
	private function map_product_images( object $shopify_product ): array {
		$images_data       = array();
		$featured_media_id = null;

		if ( ! empty( $shopify_product->featuredMedia ) && is_object( $shopify_product->featuredMedia ) && ! empty( $shopify_product->featuredMedia->id ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
			$featured_media_id = $shopify_product->featuredMedia->id; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
		}

		if ( ! empty( $shopify_product->media->edges ) ) {
			foreach ( $shopify_product->media->edges as $media_edge ) {
				$media_node = $media_edge->node;
				if ( property_exists( $media_node, 'image' ) && is_object( $media_node->image ) && ! empty( $media_node->id ) && ! empty( $media_node->image->url ) ) {
					$images_data[] = array(
						'original_id' => $media_node->id,
						'src'         => $media_node->image->url,
						'alt'         => $media_node->image->altText ?? null,
						'is_featured' => ( $media_node->id === $featured_media_id ),
					);
				}
			}
		}

		return $images_data;
	}

	/**
	 * Maps metafields and SEO data from Shopify product.
	 *
	 * @param object $shopify_product The Shopify product data.
	 * @return array Metafields data.
	 */
	private function map_metafields( object $shopify_product ): array {
		$metafields_data = array();

		if ( property_exists( $shopify_product, 'metafields' ) && ! empty( $shopify_product->metafields->edges ) ) {
			foreach ( $shopify_product->metafields->edges as $edge ) {
				$field_node              = $edge->node;
				$key                     = sprintf( '%s_%s', $field_node->namespace, $field_node->key );
				$metafields_data[ $key ] = $field_node->value;
			}
		}

		// Enhanced SEO mapping.
		$seo_data        = $this->map_seo_fields( $shopify_product );
		$metafields_data = array_merge( $metafields_data, $seo_data );

		return $metafields_data;
	}

	/**
	 * Gets the default product fields to process if not specified.
	 *
	 * @return array Default fields.
	 */
	private function get_default_product_fields(): array {
		return array(
			'title',
			'slug',
			'description',
			'short_description',
			'status',
			'date_created',
			'catalog_visibility',
			'category',
			'tag',
			'price',
			'sku',
			'stock',
			'weight',
			'brand',
			'images',
			'seo',
			'attributes',
		);
	}
}
PK     [1]=~:  :  2  CLI/Migrator/Platforms/Shopify/ShopifyPlatform.phpnu         <?php
/**
 * Shopify Platform Registration
 *
 * @package Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Shopify
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Shopify;

defined( 'ABSPATH' ) || exit;

/**
 * ShopifyPlatform class.
 *
 * This class handles the registration of the Shopify platform with the
 * WooCommerce Migrator's platform registry system.
 */
class ShopifyPlatform {

	/**
	 * Initializes the Shopify platform registration.
	 *
	 * @internal
	 */
	final public static function init(): void {
		add_filter( 'woocommerce_migrator_platforms', array( self::class, 'register_platform' ) );
	}

	/**
	 * Registers the Shopify platform with the migrator system.
	 *
	 * @param array $platforms Array of registered platforms.
	 *
	 * @return array Updated array of platforms including Shopify.
	 */
	public static function register_platform( array $platforms ): array {
		$platforms['shopify'] = array(
			'name'        => 'Shopify',
			'description' => 'Import products and data from Shopify stores',
			'fetcher'     => ShopifyFetcher::class,
			'mapper'      => ShopifyMapper::class,
			'credentials' => array(
				'shop_url'     => 'Enter shop URL (e.g., mystore.myshopify.com):',
				'access_token' => 'Enter access token:',
			),
		);

		return $platforms;
	}
}
PK     [1]܉f$  $  1  CLI/Migrator/Platforms/Shopify/ShopifyFetcher.phpnu         <?php
/**
 * Shopify Fetcher
 *
 * @package Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Shopify
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Shopify;

use Automattic\WooCommerce\Internal\CLI\Migrator\Interfaces\PlatformFetcherInterface;

defined( 'ABSPATH' ) || exit;

/**
 * ShopifyFetcher class.
 *
 * This class is responsible for fetching data from the Shopify platform.
 * Uses ShopifyClient for REST API communication and will be extended with
 * GraphQL API logic in future PRs.
 */
class ShopifyFetcher implements PlatformFetcherInterface {

	/**
	 * Comprehensive GraphQL query for fetching Shopify products.
	 *
	 * This query fetches all necessary product data including variants, images,
	 * collections, and metadata for migration to WooCommerce.
	 */
	const SHOPIFY_PRODUCT_QUERY = <<<'GRAPHQL'
	query GetShopifyProducts(
		$first: Int!,
		$after: String,
		$query: String,
		$variantsFirst: Int = 100
	) {
		products(first: $first, after: $after, query: $query) {
			edges {
				cursor
				node {
					id
					title
					handle
					descriptionHtml
					status
					createdAt
					vendor
					tags
					onlineStoreUrl
					options(first: 10) {
						id
						name
						position
						values
					}
					featuredMedia {
						... on MediaImage {
							id
							image {
								url
								altText
							}
						}
					}
					media(first: 50) {
						edges {
							node {
								... on MediaImage {
									id
									image {
										url
										altText
									}
								}
							}
						}
					}
					variants(first: $variantsFirst) {
						edges {
							node {
								id
								product { id }
								price
								compareAtPrice
								sku
								taxable
								inventoryPolicy
								inventoryQuantity
								position
								inventoryItem {
									tracked
									unitCost {
										amount
										currencyCode
									}
									measurement {
										weight {
											value
											unit
										}
									}
								}
								media(first: 1) {
									edges {
										node {
											... on MediaImage {
												id
												image {
													url
													altText
												}
											}
										}
									}
								}
								selectedOptions {
									name
									value
								}
							}
						}
					}
					collections(first: 20) {
						edges {
							node {
								id
								handle
								title
							}
						}
					}
					metafields(first: 20, namespace: "global") {
						edges {
							node {
								namespace
								key
								value
							}
						}
					}
				}
			}
			pageInfo {
				hasNextPage
			}
		}
	}
	GRAPHQL;

	/**
	 * The Shopify client instance.
	 *
	 * @var ShopifyClient
	 */
	private $shopify_client;

	/**
	 * Platform credentials.
	 *
	 * @var array
	 */
	private array $credentials;

	/**
	 * Constructor.
	 *
	 * @param array $credentials Platform credentials array.
	 */
	public function __construct( array $credentials ) {
		$this->credentials    = $credentials;
		$this->shopify_client = new ShopifyClient( $credentials );
	}

	/**
	 * Fetches a batch of products from the Shopify GraphQL API.
	 *
	 * @param array $args Arguments for fetching. Supported keys:
	 *                    - 'limit': Max number of items per batch (default: 50).
	 *                    - 'after_cursor': Cursor for pagination (optional).
	 *                    - 'query_filter': GraphQL query filter string (optional).
	 *                    - 'variants_per_product': Max variants per product (default: 100).
	 *
	 * @return array An array containing:
	 *               'items'       => array Raw product edges fetched from Shopify.
	 *               'cursor'      => ?string The cursor for the next page, or null if no more pages.
	 *               'has_next_page' => bool Indicates if there are more pages to fetch.
	 */
	public function fetch_batch( array $args ): array {
		$variables = $this->build_graphql_variables( $args );

		$response_data = $this->shopify_client->graphql_request( self::SHOPIFY_PRODUCT_QUERY, $variables );

		if ( is_wp_error( $response_data ) ) {
			\WP_CLI::warning( 'Failed to fetch products via GraphQL: ' . $response_data->get_error_message() );
			return array(
				'items'         => array(),
				'cursor'        => null,
				'has_next_page' => false,
			);
		}

		if ( ! isset( $response_data->products->edges ) ) {
			\WP_CLI::warning( 'Invalid GraphQL response structure - missing products.edges field.' );
			return array(
				'items'         => array(),
				'cursor'        => null,
				'has_next_page' => false,
			);
		}

		$items       = $response_data->products->edges;
		$page_info   = $response_data->products->pageInfo ?? null;
		$last_cursor = null;

		if ( ! empty( $items ) ) {
			$last_edge   = end( $items );
			$last_cursor = $last_edge->cursor ?? null;
		}

		return array(
			'items'         => $items,
			'cursor'        => $last_cursor,
			// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL response property
			'has_next_page' => $page_info ? $page_info->hasNextPage : false,
		);
	}

	/**
	 * Build GraphQL variables from fetch arguments.
	 *
	 * @param array $args The fetch arguments.
	 * @return array The GraphQL variables.
	 */
	private function build_graphql_variables( array $args ): array {
		$variables = array(
			'first'         => $args['limit'] ?? 50,
			'after'         => $args['after_cursor'] ?? null,
			'query'         => $this->build_graphql_query_string( $args ),
			'variantsFirst' => $args['variants_per_product'] ?? 100,
		);

		// Remove null values to avoid GraphQL issues.
		return array_filter(
			$variables,
			function ( $value ) {
				return null !== $value && '' !== $value;
			}
		);
	}

	/**
	 * Build GraphQL query string from filter arguments.
	 *
	 * @param array $args Filter arguments.
	 * @return string GraphQL query string.
	 */
	private function build_graphql_query_string( array $args ): string {
		$query_parts = array();

		if ( isset( $args['status'] ) ) {
			$query_parts[] = 'status:' . strtoupper( $args['status'] );
		}

		if ( isset( $args['product_type'] ) ) {
			$query_parts[] = 'product_type:"' . $args['product_type'] . '"';
		}

		if ( isset( $args['vendor'] ) ) {
			$query_parts[] = 'vendor:"' . $args['vendor'] . '"';
		}

		if ( isset( $args['handle'] ) ) {
			$query_parts[] = 'handle:' . $args['handle'];
		}

		if ( isset( $args['created_after'] ) ) {
			$query_parts[] = 'created_at:>=' . $args['created_after'];
		}

		if ( isset( $args['created_before'] ) ) {
			$query_parts[] = 'created_at:<=' . $args['created_before'];
		}

		if ( isset( $args['ids'] ) ) {
			$ids = is_array( $args['ids'] ) ? $args['ids'] : explode( ',', $args['ids'] );
			$ids = array_filter( array_map( 'trim', $ids ) );
			if ( ! empty( $ids ) ) {
				$formatted_ids = array_map(
					function ( $id ) {
						return 'gid://shopify/Product/' . $id;
					},
					$ids
				);
				$query_parts[] = 'id:(' . implode( ' OR ', $formatted_ids ) . ')';
			}
		}

		return implode( ' AND ', $query_parts );
	}

	/**
	 * Fetches the total count of products from the Shopify REST API.
	 *
	 * @param array $args Arguments for filtering the count (e.g., status, date range).
	 *
	 * @return int The total count, or 0 on failure.
	 */
	public function fetch_total_count( array $args ): int {
		// Handle special case: if specific IDs are provided, count them directly.
		if ( isset( $args['ids'] ) ) {
			\WP_CLI::debug( 'Calculating total count based on provided product IDs.' );
			$ids = is_array( $args['ids'] ) ? $args['ids'] : explode( ',', $args['ids'] );
			return count( array_filter( $ids ) );
		}

		$rest_api_path = '/products/count.json';
		$query_params  = $this->build_count_query_params( $args );

		$response = $this->shopify_client->rest_request( $rest_api_path, $query_params );

		if ( is_wp_error( $response ) ) {
			\WP_CLI::warning( 'Could not fetch total product count from Shopify REST API: ' . $response->get_error_message() );
			return 0;
		}

		if ( ! isset( $response->count ) ) {
			\WP_CLI::warning( 'Unexpected response format from Shopify count API - missing count field.' );
			return 0;
		}

		return (int) $response->count;
	}

	/**
	 * Build query parameters for the count API request.
	 *
	 * @param array $args Filter arguments.
	 * @return array Query parameters for the REST API.
	 */
	private function build_count_query_params( array $args ): array {
		$query_params = array();

		// Map standard filter args to Shopify REST count query params.
		if ( isset( $args['status'] ) ) {
			$query_params['status'] = strtolower( $args['status'] ); // REST uses lowercase.
		}

		if ( isset( $args['created_at_min'] ) ) {
			$query_params['created_at_min'] = $args['created_at_min'];
		}

		if ( isset( $args['created_at_max'] ) ) {
			$query_params['created_at_max'] = $args['created_at_max'];
		}

		if ( isset( $args['updated_at_min'] ) ) {
			$query_params['updated_at_min'] = $args['updated_at_min'];
		}

		if ( isset( $args['updated_at_max'] ) ) {
			$query_params['updated_at_max'] = $args['updated_at_max'];
		}

		if ( isset( $args['vendor'] ) ) {
			$query_params['vendor'] = $args['vendor'];
		}

		if ( isset( $args['product_type'] ) ) {
			$query_params['product_type'] = $args['product_type'];
		}

		return $query_params;
	}
}
PK     [1]M    &  CLI/Migrator/Commands/ResetCommand.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\CLI\Migrator\Commands;

use Automattic\WooCommerce\Internal\CLI\Migrator\Core\CredentialManager;
use Automattic\WooCommerce\Internal\CLI\Migrator\Core\PlatformRegistry;
use WP_CLI;

/**
 * The command for resetting platform credentials.
 */
class ResetCommand {

	/**
	 * The credential manager.
	 *
	 * @var CredentialManager
	 */
	private CredentialManager $credential_manager;

	/**
	 * The platform registry.
	 *
	 * @var PlatformRegistry
	 */
	private PlatformRegistry $platform_registry;

	/**
	 * Initialize the command with its dependencies.
	 *
	 * @param CredentialManager $credential_manager The credential manager.
	 * @param PlatformRegistry  $platform_registry  The platform registry.
	 *
	 * @internal
	 */
	final public function init( CredentialManager $credential_manager, PlatformRegistry $platform_registry ): void {
		$this->credential_manager = $credential_manager;
		$this->platform_registry  = $platform_registry;
	}

	/**
	 * Resets (deletes) the credentials for a given platform.
	 *
	 * ## OPTIONS
	 *
	 * [--platform=<platform>]
	 * : The platform to reset credentials for. Defaults to 'shopify'.
	 *
	 * ## EXAMPLES
	 *
	 *     wp wc migrate reset
	 *
	 * @param array $args       Positional arguments.
	 * @param array $assoc_args Associative arguments.
	 */
	public function __invoke( array $args, array $assoc_args ) {
		// Resolve and validate the platform.
		$platform              = $this->platform_registry->resolve_platform( $assoc_args );
		$platform_display_name = $this->platform_registry->get_platform_display_name( $platform );

		if ( ! $this->credential_manager->has_credentials( $platform ) ) {
			WP_CLI::warning( "No credentials found for '{$platform_display_name}' to reset." );
			return;
		}

		$this->credential_manager->delete_credentials( $platform );

		WP_CLI::success( "Credentials for the '{$platform_display_name}' platform have been cleared." );
	}
}
PK     [1]Vi_    )  CLI/Migrator/Commands/ProductsCommand.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\CLI\Migrator\Commands;

use Automattic\WooCommerce\Internal\CLI\Migrator\Core\CredentialManager;
use Automattic\WooCommerce\Internal\CLI\Migrator\Core\PlatformRegistry;
use Automattic\WooCommerce\Internal\CLI\Migrator\Core\ProductsController;
use WP_CLI;

/**
 * The products command.
 */
final class ProductsCommand {

	/**
	 * The credential manager.
	 *
	 * @var CredentialManager
	 */
	private CredentialManager $credential_manager;

	/**
	 * The platform registry.
	 *
	 * @var PlatformRegistry
	 */
	private PlatformRegistry $platform_registry;

	/**
	 * The products controller.
	 *
	 * @var ProductsController
	 */
	private ProductsController $products_controller;

	/**
	 * Initialize the command with its dependencies.
	 *
	 * @param CredentialManager  $credential_manager The credential manager.
	 * @param PlatformRegistry   $platform_registry  The platform registry.
	 * @param ProductsController $products_controller The products controller.
	 *
	 * @internal
	 */
	final public function init( CredentialManager $credential_manager, PlatformRegistry $platform_registry, ProductsController $products_controller ): void { // phpcs:ignore Generic.CodeAnalysis.UnnecessaryFinalModifier.Found -- Required by WooCommerce injection method rules
		$this->credential_manager  = $credential_manager;
		$this->platform_registry   = $platform_registry;
		$this->products_controller = $products_controller;
	}
	/**
	 * The main execution logic for the command.
	 *
	 * [--platform=<platform>]
	 * : The platform to migrate products from.
	 * ---
	 * default: shopify
	 * ---
	 *
	 * [--count]
	 * : Only fetch and display the total product count.
	 *
	 * [--limit=<limit>]
	 * : Maximum number of products to migrate.
	 *
	 * [--status=<status>]
	 * : Filter products by status (active, archived, draft).
	 *
	 * [--product-type=<product-type>]
	 * : Filter products by type (for Shopify: any product type name, or 'single'/'variable' for WooCommerce equivalents).
	 *
	 * [--vendor=<vendor>]
	 * : Filter products by vendor name.
	 *
	 * [--ids=<ids>]
	 * : Comma-separated list of product IDs to migrate.
	 *
	 * [--batch-size=<size>]
	 * : Number of products to process per batch (default: 20, max: 250).
	 *
	 * [--fields=<fields>]
	 * : Comma-separated list of fields to migrate.
	 *
	 * [--exclude-fields=<fields>]
	 * : Comma-separated list of fields to exclude from migration.
	 *
	 * [--resume]
	 * : Resume from previous migration session without prompting.
	 *
	 * [--skip-existing]
	 * : Skip products that already exist in WooCommerce.
	 *
	 * [--dry-run]
	 * : Perform a dry run without creating products.
	 *
	 * [--verbose]
	 * : Show detailed progress information including warnings and errors.
	 *
	 * [--assign-default-category]
	 * : Assign WooCommerce default category to products that have no categories.
	 *
	 * ## EXAMPLES
	 *
	 *     wp wc migrate products --count
	 *     wp wc migrate products --count --status=active
	 *     wp wc migrate products --count --product-type="T-Shirt"
	 *     wp wc migrate products --count --vendor="My Brand"
	 *     wp wc migrate products --limit=100 --batch-size=25
	 *     wp wc migrate products --product-type="single" --status=active --limit=50
	 *     wp wc migrate products --ids="123,456,789"
	 *     wp wc migrate products --fields=name,price,sku --resume
	 *     wp wc migrate products --verbose --limit=50
	 *     wp wc migrate products --assign-default-category --limit=100
	 *
	 * @param array $args       The positional arguments.
	 * @param array $assoc_args The associative arguments.
	 *
	 * @return void
	 */
	public function __invoke( array $args, array $assoc_args ): void {
		// Resolve and validate the platform.
		$platform              = $this->platform_registry->resolve_platform( $assoc_args );
		$platform_display_name = $this->platform_registry->get_platform_display_name( $platform );

		if ( ! $this->credential_manager->has_credentials( $platform ) ) {
			WP_CLI::log( "Credentials for '{$platform_display_name}' not found. Let's set them up." );

			// Get platform-specific credential fields and set them up.
			$required_fields = $this->platform_registry->get_platform_credential_fields( $platform );
			if ( empty( $required_fields ) ) {
				WP_CLI::error( "The platform '{$platform_display_name}' does not have configured credential fields." );
				return;
			}

			$this->credential_manager->setup_credentials( $platform, $required_fields );
			WP_CLI::success( 'Credentials saved successfully. Please run the command again to begin the migration.' );
			return;
		}

		// Handle count request if specified.
		if ( isset( $assoc_args['count'] ) ) {
			$this->handle_count_request( $platform, $platform_display_name, $assoc_args );
			return;
		}

		// Delegate actual migration logic to ProductsController with resolved platform.
		$this->products_controller->migrate_products( $assoc_args, $platform );
	}

	/**
	 * Handle the count request.
	 *
	 * @param string $platform             The platform name.
	 * @param string $platform_display_name The platform display name.
	 * @param array  $assoc_args           The associative arguments.
	 */
	private function handle_count_request( string $platform, string $platform_display_name, array $assoc_args ): void {
		WP_CLI::log( "Fetching product count from {$platform_display_name}..." );

		$fetcher = $this->platform_registry->get_fetcher( $platform );
		if ( ! $fetcher ) {
			WP_CLI::error( "Could not get fetcher for platform '{$platform_display_name}'" );
			return;
		}

		// Build filter arguments.
		$filter_args = array();
		if ( isset( $assoc_args['status'] ) ) {
			$filter_args['status'] = $assoc_args['status'];
		}
		if ( isset( $assoc_args['product-type'] ) ) {
			$filter_args['product_type'] = $assoc_args['product-type'];
		}
		if ( isset( $assoc_args['vendor'] ) ) {
			$filter_args['vendor'] = $assoc_args['vendor'];
		}
		if ( isset( $assoc_args['ids'] ) ) {
			$filter_args['ids'] = $assoc_args['ids'];
		}

		$count = $fetcher->fetch_total_count( $filter_args );

		if ( 0 === $count ) {
			WP_CLI::log( 'No products found or unable to fetch count.' );
		} else {
			$filters = array();
			if ( isset( $assoc_args['status'] ) ) {
				$filters[] = "status '{$assoc_args['status']}'";
			}
			if ( isset( $assoc_args['product-type'] ) ) {
				$filters[] = "type '{$assoc_args['product-type']}'";
			}
			if ( isset( $assoc_args['vendor'] ) ) {
				$filters[] = "vendor '{$assoc_args['vendor']}'";
			}
			if ( isset( $assoc_args['ids'] ) ) {
				$filters[] = "IDs '{$assoc_args['ids']}'";
			}

			$filter_description = empty( $filters ) ? '' : ' with ' . implode( ', ', $filters );
			WP_CLI::success( "Found {$count} products{$filter_description} on {$platform_display_name}." );
		}
	}
}
PK     [1]    %  CLI/Migrator/Commands/ListCommand.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\CLI\Migrator\Commands;

use Automattic\WooCommerce\Internal\CLI\Migrator\Core\PlatformRegistry;
use WP_CLI;

/**
 * Lists all registered migration platforms.
 */
class ListCommand {

	/**
	 * The platform registry.
	 *
	 * @var PlatformRegistry
	 */
	private PlatformRegistry $platform_registry;

	/**
	 * Initialize the command with its dependencies.
	 *
	 * @param PlatformRegistry $platform_registry The platform registry.
	 *
	 * @internal
	 */
	final public function init( PlatformRegistry $platform_registry ): void {
		$this->platform_registry = $platform_registry;
	}

	/**
	 * Lists all registered migration platforms.
	 *
	 * ## EXAMPLES
	 *
	 *     $ wp wc migrate list
	 *
	 * @param array $args       The positional arguments (unused).
	 * @param array $assoc_args The associative arguments (unused).
	 *
	 * @return void
	 */
	public function __invoke( array $args, array $assoc_args ): void {
		// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
		unset( $args, $assoc_args );

		$platforms = $this->platform_registry->get_platforms();

		if ( empty( $platforms ) ) {
			WP_CLI::line( 'No migration platforms are registered.' );
			return;
		}

		$formatted_items = array();
		$platform_count  = count( $platforms );
		$current_index   = 0;

		foreach ( $platforms as $id => $details ) {
			$formatted_items[] = array(
				'id'      => $id,
				'name'    => $details['name'] ?? '',
				'fetcher' => $details['fetcher'] ?? '',
				'mapper'  => $details['mapper'] ?? '',
			);

			// Add separator row between platforms (but not after the last one).
			++$current_index;
			if ( $current_index < $platform_count ) {
				$formatted_items[] = array(
					'id'      => str_repeat( '-', 20 ),
					'name'    => str_repeat( '-', 25 ),
					'fetcher' => str_repeat( '-', 30 ),
					'mapper'  => str_repeat( '-', 30 ),
				);
			}
		}

		WP_CLI\Utils\format_items(
			'table',
			$formatted_items,
			array( 'id', 'name', 'fetcher', 'mapper' )
		);
	}
}
PK     [1]q1  1  &  CLI/Migrator/Commands/SetupCommand.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\CLI\Migrator\Commands;

use Automattic\WooCommerce\Internal\CLI\Migrator\Core\CredentialManager;
use Automattic\WooCommerce\Internal\CLI\Migrator\Core\PlatformRegistry;
use WP_CLI;

/**
 * The command for interactively setting up platform credentials.
 */
class SetupCommand {

	/**
	 * The credential manager.
	 *
	 * @var CredentialManager
	 */
	private CredentialManager $credential_manager;

	/**
	 * The platform registry.
	 *
	 * @var PlatformRegistry
	 */
	private PlatformRegistry $platform_registry;

	/**
	 * Initialize the command with its dependencies.
	 *
	 * @param CredentialManager $credential_manager The credential manager.
	 * @param PlatformRegistry  $platform_registry  The platform registry.
	 *
	 * @internal
	 */
	final public function init( CredentialManager $credential_manager, PlatformRegistry $platform_registry ): void {
		$this->credential_manager = $credential_manager;
		$this->platform_registry  = $platform_registry;
	}

	/**
	 * Sets up the credentials for a given platform.
	 *
	 * ## OPTIONS
	 *
	 * [--platform=<platform>]
	 * : The platform to set up credentials for. Defaults to 'shopify'.
	 *
	 * ## EXAMPLES
	 *
	 *     wp wc migrate setup
	 *
	 * @param array $args       Positional arguments.
	 * @param array $assoc_args Associative arguments.
	 */
	public function __invoke( array $args, array $assoc_args ) {
		// Resolve and validate the platform.
		$platform              = $this->platform_registry->resolve_platform( $assoc_args );
		$platform_display_name = $this->platform_registry->get_platform_display_name( $platform );

		// Get platform-specific credential fields and set them up.
		$required_fields = $this->platform_registry->get_platform_credential_fields( $platform );
		if ( empty( $required_fields ) ) {
			WP_CLI::error( "The platform '{$platform_display_name}' does not have configured credential fields." );
		}

		$this->credential_manager->setup_credentials( $platform, $required_fields );
		WP_CLI::success( 'Credentials saved successfully.' );
	}
}
PK     [1]f{      WCCom/ConnectionHelper.phpnu         <?php
/**
 * Helpers for managing connection to WooCommerce.com.
 */

namespace Automattic\WooCommerce\Internal\WCCom;

defined( 'ABSPATH' ) || exit;

/**
 * Class WCConnectionHelper.
 *
 * Helpers for managing connection to WooCommerce.com.
 */
final class ConnectionHelper {
	/**
	 * Check if WooCommerce.com account is connected.
	 *
	 * @since 4.4.0
	 * @return bool Whether account is connected.
	 */
	public static function is_connected() {
		$helper_options    = get_option( 'woocommerce_helper_data', array() );
		if ( is_array( $helper_options ) && array_key_exists( 'auth', $helper_options ) && ! empty( $helper_options['auth'] ) ) {
			return true;
		}
		return false;
	}
}
PK     [1]G)    '  Fulfillments/FulfillmentsController.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments;

use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;

/**
 * Class FulfillmentsController
 *
 * Base controller for fulfillments management.
 */
class FulfillmentsController {
	/**
	 * Provides the list of classes that this controller provides.
	 *
	 * @var string[]
	 */
	private $provides = array(
		FulfillmentsManager::class,
		FulfillmentsRenderer::class,
		FulfillmentsSettings::class,
		OrderFulfillmentsRestController::class,
	);

	/**
	 * Initialize the controller.
	 *
	 * @return void
	 */
	public function register() {
		add_action( 'init', array( $this, 'initialize_fulfillments' ), 10, 0 );
	}

	/**
	 * Initialize the fulfillments controller.
	 */
	public function initialize_fulfillments() {
		$container           = wc_get_container();
		$features_controller = $container->get( FeaturesController::class );

		// If fulfillments feature is not enabled, do not add the DB tables, and don't register the controller.
		if ( ! $features_controller->feature_is_enabled( 'fulfillments' ) ) {
			return;
		}

		// Create the database tables if they do not exist.
		$this->maybe_create_db_tables();

		// Register the classes that this controller provides.
		foreach ( $this->provides as $class ) {
			$class = $container->get( $class );
			if ( method_exists( $class, 'register' ) ) {
				$class->register();
			}
		}
	}

	/**
	 * Create the database tables if they do not exist.
	 *
	 * @return void
	 */
	private function maybe_create_db_tables(): void {
		global $wpdb;

		if ( get_option( 'woocommerce_fulfillments_db_tables_created', false ) ) {
			// The tables already exist, no need to create them again.
			return;
		}

		// Drop the tables if they exist, to ensure a clean slate.
		// If one table exists and the other does not, it will be an issue.
		$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}wc_order_fulfillments" );
		$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}wc_order_fulfillment_meta" );

		// Bulk delete order fulfillment status meta from legacy and HPOS order tables.
		$this->bulk_delete_order_fulfillment_status_meta();

		$collate       = '';
		$container     = wc_get_container();
		$database_util = $container->get( DatabaseUtil::class );

		$max_index_length = $database_util->get_max_index_length();
		if ( $wpdb->has_cap( 'collation' ) ) {
			$collate = $wpdb->get_charset_collate();
		}

		$schema = "CREATE TABLE {$wpdb->prefix}wc_order_fulfillments (
			fulfillment_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			entity_type varchar(255) NOT NULL,
			entity_id bigint(20) unsigned NOT NULL,
			status varchar(255) NOT NULL,
			is_fulfilled tinyint(1) NOT NULL DEFAULT 0,
			date_updated datetime NOT NULL,
			date_deleted datetime NULL,
			PRIMARY KEY (fulfillment_id),
			KEY entity_type_id (entity_type({$max_index_length}), entity_id)
		) $collate;
		CREATE TABLE {$wpdb->prefix}wc_order_fulfillment_meta (
			meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			fulfillment_id bigint(20) unsigned NOT NULL,
			meta_key varchar(255) NULL,
			meta_value longtext NULL,
			date_updated datetime NOT NULL,
			date_deleted datetime NULL,
			PRIMARY KEY (meta_id),
			KEY meta_key (meta_key({$max_index_length})),
			KEY fulfillment_id (fulfillment_id)
		) $collate;";

		$database_util->dbdelta( $schema );

		// Update the option to indicate that the tables have been created.
		update_option( 'woocommerce_fulfillments_db_tables_created', true );
	}

	/**
	 * Bulk delete fulfillment status meta for specific order IDs, or all orders if no order ID specified.
	 *
	 * This method deletes the fulfillment status meta for the specified order IDs from both the legacy postmeta table
	 * and the HPOS meta table.
	 *
	 * @param array<int> $order_ids Array of order IDs to delete fulfillment status meta for.
	 */
	private function bulk_delete_order_fulfillment_status_meta( $order_ids = array() ): void {
		$this->delete_legacy_order_fulfillment_meta( $order_ids );
		$this->delete_hpos_order_fulfillment_meta( $order_ids );
	}

	/**
	 * Delete fulfillment status meta from legacy postmeta table.
	 *
	 * @param array<int> $order_ids Array of order IDs to delete fulfillment status meta for.
	 */
	private function delete_legacy_order_fulfillment_meta( $order_ids = array() ) {
		global $wpdb;

		if ( ! empty( $order_ids ) ) {
			$order_params = array_merge( array( '_fulfillment_status' ), $order_ids );
			$wpdb->query(
				$wpdb->prepare(
					"DELETE pm FROM {$wpdb->postmeta} pm
					INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID
					WHERE p.post_type = 'shop_order'
					AND pm.meta_key = %s
					AND pm.post_id IN (" . implode( ',', array_fill( 0, count( $order_ids ), '%d' ) ) . ')',
					...$order_params
				)
			);
		} else {
			$wpdb->query(
				$wpdb->prepare(
					"DELETE pm FROM {$wpdb->postmeta} pm
					INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID
					WHERE p.post_type = 'shop_order'
					AND pm.meta_key = %s",
					'_fulfillment_status'
				)
			);
		}
	}

	/**
	 * Delete fulfillment status meta from HPOS meta table.
	 *
	 * @param array<int> $order_ids Array of order IDs to delete fulfillment status meta for.
	 */
	private function delete_hpos_order_fulfillment_meta( $order_ids = array() ): void {
		global $wpdb;

		// Check if HPOS meta table exists.
		$table_name = $wpdb->prefix . 'wc_orders_meta';
		if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ) !== $table_name ) {
			return;
		}

		if ( ! empty( $order_ids ) ) {
			$order_params = array_merge( array( '_fulfillment_status' ), $order_ids );
			$wpdb->query(
				$wpdb->prepare(
					"DELETE FROM {$wpdb->prefix}wc_orders_meta
					WHERE meta_key = %s
					AND order_id IN (" . implode( ',', array_fill( 0, count( $order_ids ), '%d' ) ) . ')',
					...$order_params
				)
			);
		} else {
			$wpdb->query(
				$wpdb->prepare(
					"DELETE FROM {$wpdb->prefix}wc_orders_meta
					WHERE meta_key = %s",
					'_fulfillment_status'
				)
			);
		}
	}
}
PK     [1]O2=  =  $  Fulfillments/FulfillmentsManager.phpnu         <?php
/**
 * WooCommerce Fulfillment Hooks
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Fulfillments;

use Automattic\WooCommerce\Internal\DataStores\Fulfillments\FulfillmentsDataStore;
use Automattic\WooCommerce\Internal\Fulfillments\Providers\AbstractShippingProvider;
use WC_Order;
use WC_Order_Refund;

/**
 * FulfillmentsManager class.
 *
 * This class is responsible for adding hooks related to fulfillments in WooCommerce.
 *
 * @since 10.1.0
 * @package WooCommerce\Internal\Fulfillments
 */
class FulfillmentsManager {
	/**
	 * This method registers the hooks related to fulfillments.
	 */
	public function register() {
		add_filter( 'woocommerce_fulfillment_shipping_providers', array( $this, 'get_initial_shipping_providers' ), 10, 1 );
		add_filter( 'woocommerce_fulfillment_translate_meta_key', array( $this, 'translate_fulfillment_meta_key' ), 10, 1 );
		add_filter( 'woocommerce_fulfillment_parse_tracking_number', array( $this, 'try_parse_tracking_number' ), 10, 3 );

		$this->init_fulfillment_status_hooks();
		$this->init_refund_hooks();
	}

	/**
	 * Hook fulfillment status events.
	 *
	 * This method hooks into the fulfillment status events to update the order fulfillment status
	 * when a fulfillment is created, updated, or deleted.
	 */
	private function init_fulfillment_status_hooks() {
		// Update order fulfillment status when a fulfillment is created, updated, or deleted.
		add_action( 'woocommerce_fulfillment_after_create', array( $this, 'update_order_fulfillment_status_on_fulfillment_update' ), 10, 1 );
		add_action( 'woocommerce_fulfillment_after_update', array( $this, 'update_order_fulfillment_status_on_fulfillment_update' ), 10, 1 );
		add_action( 'woocommerce_fulfillment_after_delete', array( $this, 'update_order_fulfillment_status_on_fulfillment_update' ), 10, 1 );
	}

	/**
	 * Initialize refund-related hooks.
	 *
	 * This method initializes the hooks related to refunds, such as updating fulfillments after a refund is created
	 */
	private function init_refund_hooks() {
		add_action( 'woocommerce_refund_created', array( $this, 'update_fulfillments_after_refund' ), 10, 1 );
		add_action( 'woocommerce_delete_order_refund', array( $this, 'update_fulfillment_status_after_refund_deleted' ), 10, 1 );
	}

	/**
	 * Translate fulfillment meta keys.
	 *
	 * @param string $meta_key The meta key to translate.
	 * @return string Translated meta key.
	 */
	public function translate_fulfillment_meta_key( $meta_key ) {
		/**
		 * Filter to translate fulfillment meta keys.
		 *
		 * This filter allows us to translate fulfillment meta keys
		 * to make them more user-friendly in the admin interface and emails.
		 *
		 * @since 10.1.0
		 */
		$meta_key_translations = apply_filters(
			'woocommerce_fulfillment_meta_key_translations',
			array(
				'fulfillment_status' => __( 'Fulfillment Status', 'woocommerce' ),
				'shipment_tracking'  => __( 'Shipment Tracking', 'woocommerce' ),
				'shipment_provider'  => __( 'Shipment Provider', 'woocommerce' ),
			)
		);
		return isset( $meta_key_translations[ $meta_key ] ) ? $meta_key_translations[ $meta_key ] : $meta_key;
	}

	/**
	 * Get initial shipping providers.
	 *
	 * This method provides the initial shipping providers that feeds the `woocommerce_fulfillment_shipping_providers` filter,
	 * which is used to populate the list of available shipping providers on the fulfillment UI.
	 *
	 * @param array $shipping_providers The current list of shipping providers.
	 *
	 * @return array The modified list of shipping providers.
	 */
	public function get_initial_shipping_providers( $shipping_providers ) {
		if ( ! is_array( $shipping_providers ) ) {
			$shipping_providers = array();
		}

		$shipping_providers = array_merge(
			$shipping_providers,
			include __DIR__ . '/ShippingProviders.php'
		);

		ksort( $shipping_providers );

		return $shipping_providers;
	}

	/**
	 * Update order fulfillment status after a fulfillment is created, updated, or deleted.
	 *
	 * @param Fulfillment $data The fulfillment data.
	 */
	public function update_order_fulfillment_status_on_fulfillment_update( Fulfillment $data ) {
		if ( ! $data instanceof Fulfillment ) {
			return;
		}

		$order = $data->get_order();
		if ( ! $order instanceof \WC_Order ) {
			return;
		}

		/**
		 * Get the FulfillmentsDataStore instance.
		 *
		 * @var FulfillmentsDataStore $fulfillments_data_store
		 */
		$fulfillments_data_store = wc_get_container()->get( FulfillmentsDataStore::class );
		// Read all fulfillments for the order.
		$fulfillments = $fulfillments_data_store->read_fulfillments( \WC_Order::class, (string) $order->get_id() );

		$this->update_fulfillment_status( $order, $fulfillments );
	}

	/**
	 * Update fulfillment status after a refund is deleted.
	 *
	 * This method updates the fulfillment status after a refund is deleted to ensure that the fulfillment status
	 * and items are correctly adjusted based on the refund deletion.
	 *
	 * @param int $refund_id The ID of the refund being deleted.
	 *
	 * @return void
	 */
	public function update_fulfillment_status_after_refund_deleted( int $refund_id ): void {
		$refund = wc_get_order( $refund_id );
		if ( ! $refund instanceof \WC_Order ) {
			return; // If the refund is not a valid order, do nothing.
		}

		$order_id = $refund->get_parent_id();
		if ( ! $order_id ) {
			return; // If the refund does not have a parent order, do nothing.
		}

		$order = wc_get_order( $order_id );
		if ( ! $order instanceof \WC_Order ) {
			return; // If the order is not valid, do nothing.
		}

		$fulfillments_data_store = wc_get_container()->get( FulfillmentsDataStore::class );
		$fulfillments            = $fulfillments_data_store->read_fulfillments( \WC_Order::class, (string) $order_id );

		$this->update_fulfillment_status( $order, $fulfillments );
	}

	/**
	 * Update fulfillments after a refund is created.
	 *
	 * @param int $refund_id The ID of the refund created.
	 *
	 * @return void
	 */
	public function update_fulfillments_after_refund( int $refund_id ): void {
		// Get the order object.
		$refund = $refund_id ? wc_get_order( $refund_id ) : null;
		if ( ! $refund instanceof WC_Order_Refund ) {
			return; // If the order is not valid, do nothing.
		}

		$order_id = $refund->get_parent_id();
		if ( ! $order_id ) {
			return; // If the refund does not have a parent order, do nothing.
		}
		$order = wc_get_order( $order_id );
		if ( ! $order instanceof \WC_Order ) {
			return; // If the order is not valid, do nothing.
		}

		// If there are no refunded items, we can skip the fulfillment update.
		$items_refunded = FulfillmentUtils::get_refunded_items( $order );
		if ( empty( $items_refunded ) ) {
			return; // No items were refunded, so no need to update fulfillments.
		}

		// Get the fulfillments data store and read all fulfillments for the order.
		$fulfillments_data_store = wc_get_container()->get( FulfillmentsDataStore::class );
		$fulfillments            = $fulfillments_data_store->read_fulfillments( \WC_Order::class, (string) $order_id );
		if ( empty( $fulfillments ) ) {
			return; // No fulfillments found for the order.
		}

		// Get all refunded items from the order.
		$pending_items_without_refunds = FulfillmentUtils::get_pending_items( $order, $fulfillments, false );
		$pending_items_without_refunds = array_map(
			function ( $item ) {
				return array(
					'item_id' => $item['item_id'],
					'qty'     => $item['qty'],
				);
			},
			$pending_items_without_refunds
		);

		// Check if the refunded items can be removed from pending items.
		foreach ( $items_refunded as $item_id => &$refunded_qty ) {
			$pending_item_record = array_filter(
				$pending_items_without_refunds,
				function ( $item ) use ( $item_id ) {
					return isset( $item['item_id'] ) && $item['item_id'] === $item_id;
				}
			);
			if ( ! empty( $pending_item_record ) ) {
				$pending_item_record = reset( $pending_item_record );
				if ( isset( $pending_item_record['qty'] ) && $pending_item_record['qty'] > 0 ) {
					// If the pending item quantity is greater than the refunded quantity, reduce it.
					$refunded_qty -= $pending_item_record['qty'];
				}
			}
		}

		// If all refunded items can be removed from pending items, we can skip the fulfillment update.
		$items_need_removal_from_fulfillments = array_filter(
			$items_refunded,
			function ( $actual_qty ) {
				return $actual_qty > 0;
			}
		);

		if ( empty( $items_need_removal_from_fulfillments ) ) {
			return;
		}

		// Now we need to adjust the fulfillments based on the refunded items.
		// Loop through each fulfillment and adjust the items based on the refunded quantities.
		// We will remove items from fulfillments if they are fully refunded, or reduce their quantity if partially refunded.
		// If a fulfillment has no items left after adjustment, we will delete it.
		// If a fulfillment has items left, we will update the fulfillment with the new items.
		foreach ( $fulfillments as $fulfillment ) {
			if ( ! $fulfillment instanceof Fulfillment ) {
				continue; // Skip if the fulfillment is not an instance of Fulfillment.
			}

			if ( $fulfillment->get_is_fulfilled() ) {
				continue; // Skip if the fulfillment is already fulfilled. We don't remove items from fulfilled fulfillments.
			}

			// Get the items from the fulfillment.
			$items = $fulfillment->get_items();
			if ( empty( $items ) ) {
				continue; // Skip if there are no items in the fulfillment.
			}

			// Adjust the items based on the refund.
			$new_items = array();
			foreach ( $items as $item ) {
				if ( isset( $item['qty'] ) && isset( $item['item_id'] ) && isset( $items_need_removal_from_fulfillments[ $item['item_id'] ] ) ) {
					if ( $items_need_removal_from_fulfillments[ $item['item_id'] ] <= $item['qty'] ) {
						// If the refunded quantity is less than or equal to the item quantity, reduce the item quantity.
						$item['qty'] -= $items_need_removal_from_fulfillments[ $item['item_id'] ];
						$items_need_removal_from_fulfillments[ $item['item_id'] ] = 0; // Set refunded quantity to zero after adjustment.
					} else {
						// If the refunded quantity is greater than the item quantity, set the item quantity to zero.
						$item['qty'] = 0;
						$items_need_removal_from_fulfillments[ $item['item_id'] ] -= $item['qty']; // Reduce the refunded quantity.
					}
					$new_items[] = $item; // Add the adjusted item to the new items array.
				} else {
					$new_items[] = $item; // If the item is not in the refunded items, keep it as is.
				}
			}

			$new_items = array_filter(
				$new_items,
				function ( $item ) {
					return isset( $item['qty'] ) && $item['qty'] > 0; // Only keep items with a positive quantity.
				}
			);

			if ( empty( $new_items ) ) {
				// If no items remain after adjustment, delete the fulfillment.
				$fulfillment->delete();
			} else {
				// Update the fulfillment items with the new items.
				$fulfillment->set_items( $new_items );
				$fulfillment->save();
			}
		}

		$this->update_fulfillment_status( $order, $fulfillments );
	}

	/**
	 * Update the fulfillment status for the order.
	 *
	 * @param \WC_Order $order The order object.
	 * @param array     $fulfillments The fulfillments data store.
	 *
	 * This method updates the fulfillment status for the order based on the fulfillments data store.
	 */
	private function update_fulfillment_status( $order, $fulfillments = array() ) {
		$last_status = FulfillmentUtils::calculate_order_fulfillment_status( $order, $fulfillments );
		if ( 'no_fulfillments' === $last_status ) {
			$order->delete_meta_data( '_fulfillment_status' );
		} else {
			// Update the fulfillment status meta data.
			$order->update_meta_data( '_fulfillment_status', $last_status );
		}

		$order->save();
	}

	/**
	 * Try to parse the tracking number with additional parameters.
	 *
	 * @param string $tracking_number The tracking number.
	 * @param string $shipping_from The country code from which the shipment is sent.
	 * @param string $shipping_to The country code to which the shipment is sent.
	 *
	 * @return array An array containing the provider as key, and the parsing results.
	 */
	public function try_parse_tracking_number( string $tracking_number, string $shipping_from, string $shipping_to ): array {
		// Validate the tracking number format and length.
		if ( ! is_string( $tracking_number ) || empty( $tracking_number ) || strlen( $tracking_number ) > 50 ) {
			$tracking_number = is_string( $tracking_number ) && ! empty( $tracking_number ) ? substr( $tracking_number, 0, 50 ) : '';
			return array(
				'tracking_number'   => $tracking_number,
				'shipping_provider' => '',
				'tracking_url'      => '',
			);
		}

		// Normalize the tracking number to uppercase.
		$tracking_number = strtoupper( $tracking_number );
		$tracking_number = preg_replace( '/[^A-Z0-9]/', '', $tracking_number ); // Remove non-alphanumeric characters.

		$shipping_providers = FulfillmentUtils::get_shipping_providers();
		$results            = array();
		foreach ( $shipping_providers as $provider ) {
			if ( class_exists( $provider ) && is_subclass_of( $provider, AbstractShippingProvider::class ) ) {
				try {
					/**
					 * Instantiate the shipping provider class.
					 *
					 * @var AbstractShippingProvider $provider_instance
					 */
					$provider_instance = wc_get_container()->get( $provider );
				} catch ( \Throwable $e ) {
					$logger = wc_get_logger();
					$logger->error(
						sprintf(
							'Error instantiating shipping provider class %s: %s',
							$provider,
							$e->getMessage()
						),
						array( 'source' => 'woocommerce-fulfillments' )
					);
					continue; // Skip if the provider class cannot be instantiated.
				}
			} else {
				continue; // Skip if the provider class does not exist or is not a valid shipping provider.
			}

			$parsing_result = $provider_instance->try_parse_tracking_number( $tracking_number, $shipping_from, $shipping_to );
			if ( ! is_null( $parsing_result ) ) {
				$results[ $provider_instance->get_key() ] = $parsing_result;
			}
		}

		if ( 1 === count( $results ) ) {
			$result  = reset( $results );
			$key     = key( $results );
			$results = array(
				'tracking_number'   => $tracking_number,
				'shipping_provider' => $key,
				'tracking_url'      => $result['url'] ?? '',
			);
		} elseif ( 1 < count( $results ) ) {
			// If multiple providers could parse the tracking number, find the one with the highest ambiguity score.
			$possibilities            = $results;
			$results                  = $this->get_best_parsing_result( $results, $tracking_number );
			$results['possibilities'] = $possibilities; // Include all possibilities for reference.
		}

		return $results;
	}

	/**
	 * Get the best parsing result from multiple results.
	 *
	 * This method finds the provider with the highest ambiguity score from the results.
	 *
	 * @param array  $results The results from multiple providers.
	 * @param string $tracking_number The tracking number being parsed.
	 *
	 * @return array The best parsing result.
	 */
	private function get_best_parsing_result( array $results, string $tracking_number ): array {
		$best_result   = null;
		$best_provider = '';
		$best_score    = 0;
		foreach ( $results as $provider_key => $result ) {
			if ( ! isset( $result['ambiguity_score'] ) || ! is_numeric( $result['ambiguity_score'] ) ) {
				continue; // Skip if ambiguity score is not set or not numeric.
			}

			if ( is_null( $best_result ) || $result['ambiguity_score'] > $best_score ) {
				$best_result   = $result;
				$best_provider = $provider_key;
				$best_score    = $result['ambiguity_score'];
			}
		}
		return is_null( $best_result ) ? array() : array(
			'tracking_number'   => $tracking_number,
			'shipping_provider' => $best_provider,
			'tracking_url'      => $best_result['url'],
		);
	}
}
PK     [1];      Fulfillments/Fulfillment.phpnu         <?php
/**
 * WooCommerce order fulfillments.
 *
 * The WooCommerce order fulfillments class gets contains fulfillment related properties and methods.
 *
 * @package WooCommerce\Classes
 * @version 9.9.0
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Fulfillments;

use Automattic\WooCommerce\Internal\DataStores\Fulfillments\FulfillmentsDataStore;
use WC_Meta_Data;

defined( 'ABSPATH' ) || exit;

/**
 * WC Order Fulfillment Class
 *
 * @since 10.1.0
 */
class Fulfillment extends \WC_Data {
	/**
	 * Fulfillment constructor. Loads fulfillment data.
	 *
	 * @param array|string|Fulfillment $data Fulfillment data.
	 */
	public function __construct( $data = '' ) {
		parent::__construct( $data );

		if ( $data instanceof Fulfillment ) {
			$this->set_id( absint( $data->get_id() ) );
		} elseif ( is_numeric( $data ) ) {
			$this->set_id( absint( $data ) );
		} elseif ( is_array( $data ) && isset( $data['id'] ) ) {
			$this->set_id( absint( $data['id'] ) );
		} elseif ( is_string( $data ) && ! empty( $data ) ) {
			$this->set_id( absint( $data ) );
		} elseif ( is_object( $data ) && isset( $data->id ) ) {
			$this->set_id( absint( $data->id ) );
		} else {
			$this->set_object_read( true );
		}

		// Load the items array.
		$this->data_store = wc_get_container()->get( FulfillmentsDataStore::class );
		if ( $this->get_id() > 0 ) {
			$this->data_store->read( $this );
		}
	}

	/**
	 * Get the fulfillment ID.
	 *
	 * @return int Fulfillment ID.
	 */
	public function get_id(): int {
		return $this->data['id'] ?? 0;
	}

	/**
	 * Set the fulfillment ID.
	 *
	 * @param int $id Fulfillment ID.
	 */
	public function set_id( $id ): void {
		$this->data['id'] = is_numeric( $id ) ? absint( $id ) : 0;
		parent::set_id( $this->data['id'] );
	}

	/**
	 * Get the entity type.
	 *
	 * @return string|null Entity type.
	 */
	public function get_entity_type(): ?string {
		return $this->data['entity_type'] ?? null;
	}

	/**
	 * Set the entity type.
	 *
	 * @param class-string|null $entity_type Entity type.
	 */
	public function set_entity_type( ?string $entity_type ): void {
		$this->data['entity_type'] = $entity_type;
	}

	/**
	 * Get the entity ID.
	 *
	 * @return string|null Entity ID.
	 */
	public function get_entity_id(): ?string {
		return $this->data['entity_id'] ?? null;
	}

	/**
	 * Set the entity ID.
	 *
	 * @param string|null $entity_id Entity ID.
	 */
	public function set_entity_id( ?string $entity_id ): void {
		$this->data['entity_id'] = $entity_id;
	}

	/**
	 * Set fulfillment status.
	 *
	 * @param string|null $status Fulfillment status.
	 *
	 * @return void
	 *
	 * @throws \InvalidArgumentException If the status is invalid.
	 */
	public function set_status( ?string $status ): void {
		$statuses = FulfillmentUtils::get_fulfillment_statuses();
		if ( ! isset( $statuses[ $status ] ) ) {
			// Change the status to an existing one if the provided status is not valid.
			$status = $this->get_is_fulfilled() ? 'fulfilled' : 'unfulfilled';
		}
		// Set the fulfillment status.
		$this->set_is_fulfilled( $statuses[ $status ]['is_fulfilled'] ?? false );
		// Set the status in the data array.
		$this->data['status'] = $status;
	}

	/**
	 * Get the fulfillment status.
	 *
	 * @return string|null Fulfillment status.
	 */
	public function get_status(): ?string {
		return $this->data['status'] ?? null;
	}

	/**
	 * Set if the fulfillment is fulfilled. This is an internal method which is bound to the fulfillment status.
	 *
	 * @param bool $is_fulfilled Whether the fulfillment is fulfilled.
	 *
	 *  @return void
	 */
	private function set_is_fulfilled( bool $is_fulfilled ): void {
		$this->data['is_fulfilled'] = $is_fulfilled;
	}

	/**
	 * Get if the fulfillment is fulfilled.
	 *
	 * @return bool Whether the fulfillment is fulfilled.
	 */
	public function get_is_fulfilled(): bool {
		return $this->data['is_fulfilled'] ?? false;
	}

	/**
	 * Check if the fulfillment is locked.
	 *
	 * @return bool Whether the fulfillment is locked.
	 */
	public function is_locked(): bool {
		return boolval( $this->get_meta( '_is_locked' ) );
	}

	/**
	 * Get the lock message.
	 *
	 * @return string Lock message.
	 */
	public function get_lock_message(): string {
		return $this->get_meta( '_lock_message' ) ?? '';
	}

	/**
	 * Set the lock status and message.
	 *
	 * @param bool   $locked  Whether the fulfillment is locked.
	 * @param string $message Optional. The lock message.
	 *                        Defaults to an empty string.
	 *
	 * @return void
	 */
	public function set_locked( bool $locked, string $message = '' ): void {
		$this->update_meta_data( '_is_locked', $locked );
		if ( $locked ) {
			$this->update_meta_data( '_lock_message', $message );
		} else {
			$this->delete_meta_data( '_lock_message' );
		}
	}

	/**
	 * Get the date updated.
	 *
	 * @return string|null Date updated.
	 */
	public function get_date_updated(): ?string {
		return $this->data['date_updated'] ?? null;
	}

	/**
	 * Set the date updated.
	 *
	 * @param string|null $date_updated Date updated.
	 */
	public function set_date_updated( ?string $date_updated ): void {
		$this->data['date_updated'] = $date_updated;
	}

	/**
	 * Get the date the fulfillment was fulfilled.
	 */
	public function get_date_fulfilled(): ?string {
		return $this->meta_exists( '_date_fulfilled' ) ? $this->get_meta( '_date_fulfilled', true ) : null;
	}

	/**
	 * Set the date the fulfillment was fulfilled.
	 *
	 * @param string $date_fulfilled Date fulfilled.
	 */
	public function set_date_fulfilled( string $date_fulfilled ): void {
		$this->add_meta_data( '_date_fulfilled', $date_fulfilled, true );
	}

	/**
	 * Get the date deleted.
	 *
	 * @return string|null Date deleted.
	 */
	public function get_date_deleted(): ?string {
		return $this->data['date_deleted'] ?? null;
	}

	/**
	 * Set the date deleted.
	 *
	 * @param string|null $date_deleted Date deleted.
	 * @return void
	 */
	public function set_date_deleted( ?string $date_deleted ): void {
		$this->data['date_deleted'] = $date_deleted;
	}

	/**
	 * Get the fulfillment items.
	 *
	 * @return array Fulfillment items.
	 */
	public function get_items(): array {
		$items = $this->get_meta( '_items' );
		return $items ? $items : array();
	}

	/**
	 * Set the fulfillment items.
	 *
	 * @param array $items Fulfillment items.
	 */
	public function set_items( array $items ): void {
		$this->update_meta_data( '_items', array_values( $items ) );
	}

	/**
	 * Get the order associated with this fulfillment.
	 *
	 * This method retrieves the order based on the entity type and entity ID.
	 * If the entity type is `WC_Order`, it returns the order object.
	 *
	 * @return \WC_Order|null The order object or null if not found.
	 */
	public function get_order(): ?\WC_Order {
		$entity_type = $this->get_entity_type();
		$entity_id   = $this->get_entity_id();

		if ( ! $entity_type || ! $entity_id ) {
			return null;
		}

		if ( \WC_Order::class === $entity_type ) {
			$order = wc_get_order( (int) $entity_id );
			if ( $order instanceof \WC_Order ) {
				return $order;
			}
		}

		return null;
	}

	/**
	 * Returns all data for this object as an associative array.
	 *
	 * @return array
	 */
	public function get_raw_data() {
		return array_merge( array( 'id' => $this->get_id() ), $this->data, array( 'meta_data' => $this->get_raw_meta_data() ) );
	}

	/**
	 * Returns the meta data as array for this object.
	 *
	 * @return array
	 */
	public function get_raw_meta_data() {
		return array_map( fn( WC_Meta_Data $meta ) => (array) $meta->get_data(), $this->get_meta_data() );
	}
}
PK     [1]x    %  Fulfillments/FulfillmentsSettings.phpnu         <?php

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments;

use Automattic\WooCommerce\Internal\DataStores\Fulfillments\FulfillmentsDataStore;
use WC_Order;

/**
 * FulfillmentsSettings class.
 */
class FulfillmentsSettings {

	/**
	 * Registers the hooks related to fulfillments settings.
	 */
	public function register() {
		add_filter( 'admin_init', array( $this, 'init_settings_auto_fulfill' ) );
		add_action( 'woocommerce_order_status_processing', array( $this, 'auto_fulfill_items_on_processing' ), 10, 2 );
		add_action( 'woocommerce_order_status_completed', array( $this, 'auto_fulfill_items_on_completed' ), 10, 2 );
	}

	/**
	 * Initialize settings for auto-fulfill options.
	 */
	public function init_settings_auto_fulfill() {
		add_filter( 'woocommerce_get_settings_products', array( $this, 'add_auto_fulfill_settings' ), 10, 2 );
	}

	/**
	 * Add auto-fulfill settings to the WooCommerce settings.
	 *
	 * @param array       $settings The existing settings.
	 * @param string|null $current_section The current section being viewed.
	 *
	 * @return array Modified settings with auto-fulfill options added.
	 */
	public function add_auto_fulfill_settings( array $settings, $current_section ): array {
		if ( ! empty( $current_section ) ) {
			return $settings;
		}

		$insertion_index = null;

		// Find the index of the sectionend for 'Shop pages'.
		foreach ( $settings as $index => $setting ) {
			if (
			isset( $setting['type'], $setting['id'] ) &&
			'sectionend' === $setting['type'] &&
			'catalog_options' === $setting['id'] // Woo core's ID for Shop pages section.
			) {
				$insertion_index = $index + 1; // Insert after the sectionend.
				break;
			}
		}

		if ( is_null( $insertion_index ) ) {
			return $settings; // fallback if not found.
		}

		$auto_fulfill_settings = array(
			array(
				'title' => 'Auto-fulfill items',
				'desc'  => '',
				'type'  => 'title',
				'id'    => 'auto_fulfill_options',
			),
			array(
				'title'         => 'Virtual and downloadable items',
				'desc'          => 'Automatically mark downloadable items as fulfilled when the order is created.',
				'id'            => 'auto_fulfill_downloadable',
				'type'          => 'checkbox',
				'checkboxgroup' => 'start',
				'default'       => 'yes',
			),
			array(
				'title'         => 'Auto-fulfill items',
				'desc'          => 'Automatically mark virtual (non-downloadable) items as fulfilled when the order is created.',
				'id'            => 'auto_fulfill_virtual',
				'type'          => 'checkbox',
				'checkboxgroup' => 'end',
				'default'       => 'no',
			),
			array(
				'type' => 'sectionend',
				'id'   => 'auto_fulfill_options',
			),
		);

		array_splice( $settings, $insertion_index, 0, $auto_fulfill_settings );

		return $settings;
	}

	/**
	 * Automatically fulfill items in the order on the processing state.
	 *
	 * @param int      $order_id The ID of the order being created.
	 * @param WC_Order $order The order object.
	 */
	public function auto_fulfill_items_on_processing( int $order_id, $order ): void {
		$order = $order instanceof WC_Order ? $order : wc_get_order( $order_id );

		if ( ! $order || empty( $order->get_items() ) ) {
			return;
		}
		$auto_fulfill_downloadable = 'yes' === get_option( 'auto_fulfill_downloadable', 'yes' );
		$auto_fulfill_virtual      = 'yes' === get_option( 'auto_fulfill_virtual', 'no' );

		/**
		 * Filter to get the list of the item, or variant ID's that should be auto-fulfilled.
		 *
		 * @since 10.1.0
		 *
		 * @param array $auto_fulfill_items List of product or variant ID's to auto-fulfill.
		 * @param \WC_Order $order The order object.
		 *
		 * @return array Filtered list of product or variant ID's to auto-fulfill
		 */
		$auto_fulfill_product_ids = apply_filters( 'woocommerce_fulfillments_auto_fulfill_products', array(), $order );
		$auto_fulfill_items       = array();

		foreach ( $order->get_items() as $item ) {
			/**
			 * Get the product associated with the item.
			 *
			 * @var \WC_Order_Item_Product $item
			 * @var \WC_Product $product
			 */
			$product = $item->get_product();
			if ( ! $product ) {
				continue;
			}

			if ( ( $product->is_downloadable() && $auto_fulfill_downloadable )
				|| ( $product->is_virtual() && $auto_fulfill_virtual )
				|| in_array( $product->get_id(), $auto_fulfill_product_ids, true ) ) {
				$auto_fulfill_items[] = $item;
			}
		}

		if ( ! empty( $auto_fulfill_items ) ) {
			$fulfillment = new Fulfillment();
			$fulfillment->set_entity_type( WC_Order::class );
			$fulfillment->set_entity_id( (string) $order_id );
			$fulfillment->set_status( 'fulfilled' );
			$fulfillment->set_items(
				array_map(
					function ( $item ) {
						return array(
							'item_id' => $item->get_id(),
							'qty'     => $item->get_quantity(),
						);
					},
					$auto_fulfill_items
				)
			);
			$fulfillment->save();
		}

		$order->update_meta_data( '_auto_fulfill_processed', true );
	}

	/**
	 * Automatically fulfill items in the order for orders that skip the processing state.
	 *
	 * @param int      $order_id The ID of the order being created.
	 * @param WC_Order $order The order object.
	 */
	public function auto_fulfill_items_on_completed( int $order_id, $order ): void {
		$order = $order instanceof WC_Order ? $order : wc_get_order( $order_id );
		if ( ! $order || empty( $order->get_items() ) ) {
			return;
		}

		// If auto-fulfill already processed, skip.
		if ( $order->get_meta( '_auto_fulfill_processed', true ) ) {
			return;
		}

		// If fulfillments already exist, skip auto-fulfillment.
		$fulfillments = wc_get_container()->get( FulfillmentsDataStore::class )->read_fulfillments( \WC_Order::class, (string) $order_id );
		if ( ! empty( $fulfillments ) ) {
			return;
		}

		// Auto-fulfill items.
		$this->auto_fulfill_items_on_processing( $order_id, $order );
	}
}
PK     [1]B[  [  !  Fulfillments/FulfillmentUtils.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments;

use Automattic\WooCommerce\Internal\Fulfillments\Providers\AbstractShippingProvider;
use WC_Order;

/**
 * Class FulfillmentUtils
 *
 * Utility class for handling order fulfillments.
 */
class FulfillmentUtils {

	/**
	 * Get pending items for an order.
	 *
	 * @param WC_Order $order The order object.
	 * @param array    $fulfillments An array of fulfillments to check.
	 * @param bool     $without_refunds Whether to exclude refunded items from the pending items.
	 *
	 * @return array An array of pending items.
	 */
	public static function get_pending_items( WC_Order $order, $fulfillments, $without_refunds = true ): array {
		$items_in_fulfillments = self::get_all_items_of_fulfillments( $fulfillments );
		$order_items           = array_map(
			function ( $item ) use ( $order, $without_refunds ) {
				// Refunded item quantities are saved as negative values in the order.
				return array(
					'item_id' => $item->get_id(),
					'item'    => $item,
					'qty'     => $item->get_quantity() + ( $without_refunds ? $order->get_qty_refunded_for_item( $item->get_id() ) : 0 ),
				);
			},
			$order->get_items() ?? array()
		);

		// If there are items in fulfillments, subtract their quantities from the order items.
		if ( ! empty( $items_in_fulfillments ) ) {
			foreach ( $order_items as $item_id => &$item ) {
				if ( isset( $items_in_fulfillments[ $item_id ] ) ) {
					$item['qty'] = $item['qty'] - $items_in_fulfillments[ $item_id ];
				}
			}
		}

		return array_filter(
			$order_items,
			function ( $item ) {
				return $item['qty'] > 0; // Only return items with a positive quantity.
			}
		);
	}

	/**
	 * Get refunded items for an order.
	 *
	 * @param WC_Order $order The order object.
	 *
	 * @return array An array of refunded items with their IDs and quantities.
	 */
	public static function get_refunded_items( WC_Order $order ): array {
		$items_refunded = array();
		foreach ( $order->get_items() as $item ) {
			$items_refunded[ $item->get_id() ] = -1 * $order->get_qty_refunded_for_item( $item->get_id() );
		}
		return array_filter(
			$items_refunded,
			function ( $qty ) {
				return $qty > 0; // Only include items that have been refunded.
			}
		);
	}

	/**
	 * Get order items for a fulfillment.
	 *
	 * @param WC_Order    $order The order object.
	 * @param Fulfillment $fulfillment The fulfillment object.
	 *
	 * @return array An array of order items.
	 */
	public static function get_fulfillment_items( WC_Order $order, Fulfillment $fulfillment ): array {
		$fulfillment_items = array_combine(
			array_column( $fulfillment->get_items(), 'item_id' ),
			array_column( $fulfillment->get_items(), 'qty' )
		);

		$order_items = array_map(
			function ( $item ) use ( $order ) {
				return array(
					'item_id' => $item->get_id(),
					'item'    => $item,
					'qty'     => $item->get_quantity() - $order->get_qty_refunded_for_item( $item ),
				);
			},
			$order->get_items()
		);

		return array_map(
			function ( $item ) use ( $fulfillment_items ) {
				$item['qty'] = $fulfillment_items[ $item['item_id'] ];
				return $item;
			},
			array_filter(
				$order_items,
				function ( $item ) use ( $fulfillment_items ) {
					return isset( $fulfillment_items[ $item['item_id'] ] );
				}
			)
		);
	}

	/**
	 * Check if an order has pending items.
	 *
	 * @param WC_Order $order The order object.
	 * @param array    $fulfillments An array of fulfillments to check.
	 *
	 * @return bool True if there are pending items, false otherwise.
	 */
	public static function has_pending_items( WC_Order $order, array $fulfillments ): bool {
		$pending_items = self::get_pending_items( $order, $fulfillments );
		return ! empty( $pending_items );
	}

	/**
	 * Get the fulfillment status of the entity. This runs like a computed property, where
	 * it checks the fulfillment status of each fulfillment attached to the order,
	 * and computes the overall fulfillment status of the order.
	 *
	 * @param WC_Order $order The order object.
	 * @param array    $fulfillments An array of fulfillments to check.
	 *
	 * @return string The fulfillment status.
	 */
	public static function calculate_order_fulfillment_status( WC_Order $order, $fulfillments = array() ): string {
		$has_fulfillments = ! empty( $fulfillments );
		if ( $has_fulfillments ) {
			$pending_items = self::get_pending_items( $order, $fulfillments );

			$all_fulfilled  = true;
			$some_fulfilled = false;

			foreach ( $fulfillments as $fulfillment ) {
				if ( ! $fulfillment->get_is_fulfilled() ) {
					$all_fulfilled = false;
				} else {
					$some_fulfilled = true;
				}
			}

			if ( $all_fulfilled && empty( $pending_items ) ) {
				$status = 'fulfilled';
			} elseif ( $some_fulfilled ) {
				$status = 'partially_fulfilled';
			} else {
				$status = 'unfulfilled';
			}
		} else {
			$status = 'no_fulfillments';
		}

		/**
		 * This filter allows plugins to modify the fulfillment status of an order.
		 *
		 * @since 10.1.0
		 *
		 * @param string $status The default fulfillment status.
		 * @param WC_Order $order The order object.
		 * @param array $fulfillments An array of fulfillments for the order.
		 */
		return apply_filters(
			'woocommerce_fulfillment_calculate_order_fulfillment_status',
			$status,
			$order,
			$fulfillments
		);
	}

	/**
	 * Get all items from the fulfillments.
	 *
	 * @param array $fulfillments An array of fulfillments.
	 *
	 * @return array An associative array of item IDs and their quantities.
	 */
	public static function get_all_items_of_fulfillments( array $fulfillments ): array {
		$items = array();
		foreach ( $fulfillments as $fulfillment ) {
			$fulfillment_items = $fulfillment->get_items();
			foreach ( $fulfillment_items as $item ) {
				if ( ! isset( $items[ $item['item_id'] ] ) ) {
					$items[ $item['item_id'] ] = 0; // Initialize if not set.
				}
				// Sum the quantities for each item.
				$items[ $item['item_id'] ] += $item['qty'];
			}
		}
		return $items;
	}

	/**
	 * Get the HTML for the fulfillment tracking number.
	 *
	 * @param Fulfillment $fulfillment The fulfillment object.
	 *
	 * @return string The HTML for the tracking number.
	 */
	public static function get_tracking_info_html( Fulfillment $fulfillment ): string {
		$tracking_html   = '';
		$tracking_url    = $fulfillment->get_meta( '_tracking_url', true );
		$tracking_number = $fulfillment->get_meta( '_tracking_number', true );
		if ( ! empty( $tracking_url ) && ! empty( $tracking_number ) ) {
			$tracking_html .= '<a href="' . esc_url( $tracking_url ) . '" target="_blank" rel="noopener noreferrer">';
			$tracking_html .= esc_html( $tracking_number );
			$tracking_html .= '</a>';
		} elseif ( ! empty( $tracking_number ) ) {
			$tracking_html .= esc_html( $tracking_number );
		} else {
			$tracking_html .= '<span class="no-tracking">' . esc_html__( 'No tracking number available', 'woocommerce' ) . '</span>';
		}
		return $tracking_html;
	}

	/**
	 * Get the fulfillment status of an order.
	 *
	 * @param WC_Order $order The order object.
	 * @return string The fulfillment status.
	 */
	public static function get_order_fulfillment_status( WC_Order $order ): string {
		if ( ! $order instanceof WC_Order ) {
			return 'no_fulfillments';
		}

		return $order->meta_exists( '_fulfillment_status' ) ? $order->get_meta( '_fulfillment_status', true ) : 'no_fulfillments';
	}

	/**
	 * Get the fulfillment status text for an order.
	 *
	 * @param WC_Order $order The order object.
	 *
	 * @return string The fulfillment status text.
	 */
	public static function get_order_fulfillment_status_text( WC_Order $order ): string {
		// Ensure the order is a valid WC_Order object.
		if ( ! $order instanceof WC_Order ) {
			return '';
		}

		// Check if the order meta exists for fulfillment status.
		$fulfillment_status      = self::get_order_fulfillment_status( $order );
		$fulfillment_status_text = '';
		switch ( $fulfillment_status ) {
			case 'fulfilled':
				$fulfillment_status_text = ' ' . __( 'It has been <mark class="fulfillment-status">Fulfilled</mark>.', 'woocommerce' );
				break;
			case 'partially_fulfilled':
				$fulfillment_status_text = ' ' . __( 'It has been <mark class="fulfillment-status">Partially fulfilled</mark>.', 'woocommerce' );
				break;
			case 'unfulfilled':
				$fulfillment_status_text = ' ' . __( 'It is currently <mark class="fulfillment-status">Unfulfilled</mark>.', 'woocommerce' );
				break;
			case 'no_fulfillments':
				$fulfillment_status_text = ' ' . __( 'It has <mark class="fulfillment-status">no fulfillments</mark> yet.', 'woocommerce' );
				break;
		}

		/**
		 * This filter allows plugins to modify the fulfillment status text for an order for their custom fulfillment statuses.
		 *
		 * @since 10.1.0
		 *
		 * @param string $fulfillment_status_text The default fulfillment status text.
		 * @param string $fulfillment_status The fulfillment status of the order.
		 * @param WC_Order $order The order object.
		 */
		return apply_filters(
			'woocommerce_fulfillment_order_fulfillment_status_text',
			$fulfillment_status_text,
			$fulfillment_status,
			$order
		);
	}

	/**
	 * Get the meta query for the order fulfillment status.
	 *
	 * @param array|string $statuses The fulfillment statuses, or single status.
	 * @return array The meta query.
	 */
	public static function get_order_fulfillment_status_meta_query( $statuses ): array {
		if ( is_string( $statuses ) ) {
			$statuses = array( $statuses );
		}

		$valid_statuses = array_filter( $statuses, array( self::class, 'is_valid_order_fulfillment_status' ) );
		if ( empty( $valid_statuses ) ) {
			return array();
		}

		if ( in_array( 'no_fulfillments', $valid_statuses, true ) ) {
			return array(
				'relation' => 'OR',
				array(
					'key'     => '_fulfillment_status',
					'value'   => $valid_statuses,
					'compare' => 'IN',
				),
				array(
					'key'     => '_fulfillment_status',
					'compare' => 'NOT EXISTS',
				),
			);
		}

		return array(
			'key'     => '_fulfillment_status',
			'value'   => $valid_statuses,
			'compare' => 'IN',
		);
	}

	/**
	 * Check if the given fulfillment status is valid.
	 *
	 * @param string|null $status The fulfillment status to check.
	 *
	 * @return bool True if the status is valid, false otherwise.
	 */
	public static function is_valid_order_fulfillment_status( ?string $status ): bool {
		if ( is_null( $status ) ) {
			return false;
		}
		$order_fulfillment_statuses = self::get_order_fulfillment_statuses();
		return in_array( $status, array_keys( $order_fulfillment_statuses ), true );
	}

	/**
	 * Check if the given fulfillment status is valid.
	 *
	 * @param string|null $status The fulfillment status to check.
	 *
	 * @return bool True if the status is valid, false otherwise.
	 */
	public static function is_valid_fulfillment_status( ?string $status ): bool {
		if ( is_null( $status ) ) {
			return false;
		}
		$fulfillment_statuses = self::get_fulfillment_statuses();
		return in_array( $status, array_keys( $fulfillment_statuses ), true );
	}

	/**
	 * Get the order fulfillment statuses.
	 *
	 * This method provides the order fulfillment statuses that can be used
	 * in the WooCommerce Fulfillments system. It can be filtered using the
	 * `woocommerce_fulfillment_order_fulfillment_statuses` filter.
	 *
	 * @return array An associative array of order fulfillment statuses.
	 */
	public static function get_order_fulfillment_statuses(): array {
		/**
		 * This filter allows plugins to modify the list of order fulfillment statuses.
		 * It can be used to add, remove, or change the order fulfillment statuses available in the
		 * WooCommerce Fulfillments system.
		 *
		 * @since 10.1.0
		 *
		 * @param array $order_fulfillment_statuses The default list of order fulfillment statuses.
		 */
		return apply_filters(
			'woocommerce_fulfillment_order_fulfillment_statuses',
			self::get_default_order_fulfillment_statuses()
		);
	}

	/**
	 * Get the fulfillment statuses.
	 *
	 * This method provides the fulfillment statuses that can be used
	 * in the WooCommerce Fulfillments system. It can be filtered using the
	 * `woocommerce_fulfillment_fulfillment_statuses` filter.
	 *
	 * @return array An associative array of fulfillment statuses.
	 */
	public static function get_fulfillment_statuses(): array {
		/**
		 * This filter allows plugins to modify the list of fulfillment statuses.
		 * It can be used to add, remove, or change the fulfillment statuses available in the
		 * WooCommerce Fulfillments system.
		 *
		 * @since 10.1.0
		 *
		 * @param array $fulfillment_statuses The default list of fulfillment statuses.
		 */
		return apply_filters(
			'woocommerce_fulfillment_fulfillment_statuses',
			self::get_default_fulfillment_statuses()
		);
	}

	/**
	 * Get the shipping providers.
	 *
	 * This method retrieves the shipping providers registered in the WooCommerce Fulfillments system.
	 * It can be filtered using the `woocommerce_fulfillment_shipping_providers` filter.
	 *
	 * @return array An associative array of shipping providers with their details.
	 */
	public static function get_shipping_providers(): array {
		/**
		 * This filter allows plugins to modify the list of shipping providers.
		 * It can be used to add, remove, or change the shipping providers available in the
		 * WooCommerce Fulfillments system.
		 *
		 * @since 10.1.0
		 *
		 * @param array $shipping_providers The default list of shipping providers.
		 */
		return apply_filters(
			'woocommerce_fulfillment_shipping_providers',
			array()
		);
	}

	/**
	 * Get the shipping providers as an array of JS objects, for use in the fulfillment UI.
	 *
	 * @return array An associative array of shipping providers with their details.
	 */
	public static function get_shipping_providers_object(): array {
		$shipping_providers = self::get_shipping_providers();
		if ( ! is_array( $shipping_providers ) ) {
			return array();
		}
		$shipping_providers_object = array();
		foreach ( $shipping_providers as $shipping_provider ) {
			if ( is_string( $shipping_provider )
			&& class_exists( $shipping_provider )
			&& is_subclass_of( $shipping_provider, AbstractShippingProvider::class )
			) {
				try {
					// Instantiate the shipping provider class.
					$shipping_provider_instance = wc_get_container()->get( $shipping_provider );
				} catch ( \Throwable $e ) {
					continue; // Skip if instantiation fails.
				}
				$shipping_providers_object[ $shipping_provider_instance->get_key() ] = array(
					'label' => $shipping_provider_instance->get_name(),
					'icon'  => $shipping_provider_instance->get_icon(),
					'value' => $shipping_provider_instance->get_key(),
					'url'   => $shipping_provider_instance->get_tracking_url( '__PLACEHOLDER__' ),
				);
			}
			if ( is_object( $shipping_provider ) && $shipping_provider instanceof AbstractShippingProvider ) {
				$shipping_providers_object[ $shipping_provider->get_key() ] = array(
					'label' => $shipping_provider->get_name(),
					'icon'  => $shipping_provider->get_icon(),
					'value' => $shipping_provider->get_key(),
					'url'   => $shipping_provider->get_tracking_url( '__PLACEHOLDER__' ),
				);
			}
		}

		return $shipping_providers_object;
	}

	/**
	 * Get the default order fulfillment statuses.
	 *
	 * This method provides the default order fulfillment statuses that can be used
	 * in the WooCommerce Fulfillments system. It can be filtered using the
	 * `woocommerce_fulfillment_order_fulfillment_statuses` filter.
	 *
	 * @return array An associative array of default order fulfillment statuses.
	 */
	protected static function get_default_order_fulfillment_statuses(): array {
		return array(
			'fulfilled'           => array(
				'label'            => __( 'Fulfilled', 'woocommerce' ),
				'background_color' => '#C6E1C6',
				'text_color'       => '#13550F',
			),
			'partially_fulfilled' => array(
				'label'            => __( 'Partially fulfilled', 'woocommerce' ),
				'background_color' => '#C8D7E1',
				'text_color'       => '#003D66',
			),
			'unfulfilled'         => array(
				'label'            => __( 'Unfulfilled', 'woocommerce' ),
				'background_color' => '#FBE5E5',
				'text_color'       => '#CC1818',
			),
			'no_fulfillments'     => array(
				'label'            => __( 'No fulfillments', 'woocommerce' ),
				'background_color' => '#F0F0F0',
				'text_color'       => '#2F2F2F',
			),
		);
	}

	/**
	 * Get the default fulfillment statuses.
	 *
	 * This method provides the default fulfillment statuses that can be used
	 * in the WooCommerce Fulfillments system. It can be filtered using the
	 * `woocommerce_fulfillment_fulfillment_statuses` filter.
	 *
	 * @return array An associative array of default fulfillment statuses.
	 */
	protected static function get_default_fulfillment_statuses(): array {
		return array(
			'fulfilled'   => array(
				'label'            => __( 'Fulfilled', 'woocommerce' ),
				'is_fulfilled'     => true,
				'background_color' => '#C6E1C6',
				'text_color'       => '#13550F',
			),
			'unfulfilled' => array(
				'label'            => __( 'Unfulfilled', 'woocommerce' ),
				'is_fulfilled'     => false,
				'background_color' => '#FBE5E5',
				'text_color'       => '#CC1818',
			),
		);
	}

	/**
	 * Calculate the S10 check digit for UPU tracking numbers.
	 *
	 * @param string $tracking_number The tracking number without the check digit.
	 *
	 * @return bool True if the check digit is valid, false otherwise.
	 */
	public static function check_s10_upu_format( string $tracking_number ): bool {
		if ( preg_match( '/^[A-Z]{2}\d{9}[A-Z]{2}$/', $tracking_number ) ) {
			// The tracking number is in the UPU S10 format.
			$tracking_number = substr( $tracking_number, 2, -2 );
		} elseif ( ! preg_match( '/^\d{9}$/', $tracking_number ) ) {
			// Ensure the tracking number is exactly 9 digits.
			return false;
		}

		// Define the weights for the S10 check digit calculation.
		$weights = array( 8, 6, 4, 2, 3, 5, 9, 7 );
		$sum     = 0;

		// Calculate the weighted sum of the digits.
		for ( $i = 0; $i < 8; $i++ ) {
			$sum += $weights[ $i ] * (int) $tracking_number[ $i ];
		}

		// Calculate the check digit.
		$check_digit = 11 - ( $sum % 11 );
		if ( 10 === $check_digit ) {
			$check_digit = 0;
		} elseif ( 11 === $check_digit ) {
			$check_digit = 5;
		}

		// Validate the check digit against the last digit of the tracking number.
		return (int) $tracking_number[8] === $check_digit;
	}

	/**
	 * Validate UPS 1Z tracking number using Mod 10 check digit.
	 *
	 * @param string $tracking_number The UPS 1Z tracking number.
	 * @return bool True if valid, false otherwise.
	 */
	public static function validate_ups_1z_check_digit( string $tracking_number ): bool {
		if ( ! preg_match( '/^1Z[0-9A-Z]{15,16}$/', $tracking_number ) ) {
			return false;
		}

		// Extract the trackable part (remove 1Z prefix).
		$trackable   = substr( $tracking_number, 2 );
		$check_digit = (int) substr( $trackable, -1 );
		$trackable   = substr( $trackable, 0, -1 );

		$sum          = 0;
		$odd_position = true;

		// Process each character from right to left.
		for ( $i = strlen( $trackable ) - 1; $i >= 0; $i-- ) {
			$char  = $trackable[ $i ];
			$value = is_numeric( $char ) ? (int) $char : ord( $char ) - 55; // A=10, B=11, etc.

			if ( $odd_position ) {
				$value *= 2;
				if ( $value > 9 ) {
					$value = (int) ( $value / 10 ) + ( $value % 10 );
				}
			}

			$sum         += $value;
			$odd_position = ! $odd_position;
		}

		$calculated_check = ( 10 - ( $sum % 10 ) ) % 10;
		return $calculated_check === $check_digit;
	}

	/**
	 * Validate Mod 7 check digit for numeric tracking numbers.
	 *
	 * @param string $tracking_number The numeric tracking number.
	 * @return bool True if valid, false otherwise.
	 */
	public static function validate_mod7_check_digit( string $tracking_number ): bool {
		if ( ! preg_match( '/^\d+$/', $tracking_number ) || strlen( $tracking_number ) < 2 ) {
			return false;
		}

		$check_digit  = (int) substr( $tracking_number, -1 );
		$number       = substr( $tracking_number, 0, -1 );
		$sum          = 0;
		$weights      = array( 3, 1, 3, 1, 3, 1, 3 ); // Mod 7 weights.
		$weight_index = 0;
		// Process each digit from right to left.
		for ( $i = strlen( $number ) - 1; $i >= 0; $i-- ) {
			$digit = (int) $number[ $i ];
			$sum  += $digit * $weights[ $weight_index % count( $weights ) ];
			++$weight_index;
		}
		$calculated_check = $sum % 7;
		if ( 0 === $calculated_check ) {
			$calculated_check = 7; // If the sum is a multiple of 7, the check digit is 7.
		}
		return $calculated_check === $check_digit;
	}

	/**
	 * Validate Mod 10 check digit for numeric tracking numbers.
	 *
	 * @param string $tracking_number The numeric tracking number.
	 * @return bool True if valid, false otherwise.
	 */
	public static function validate_mod10_check_digit( string $tracking_number ): bool {
		if ( ! preg_match( '/^\d+$/', $tracking_number ) || strlen( $tracking_number ) < 2 ) {
			return false;
		}

		$check_digit = (int) substr( $tracking_number, -1 );
		$number      = substr( $tracking_number, 0, -1 );

		$sum          = 0;
		$odd_position = true;

		// Process each digit from right to left.
		for ( $i = strlen( $number ) - 1; $i >= 0; $i-- ) {
			$digit = (int) $number[ $i ];

			if ( $odd_position ) {
				$digit *= 2;
				if ( $digit > 9 ) {
					$digit = (int) ( $digit / 10 ) + ( $digit % 10 );
				}
			}

			$sum         += $digit;
			$odd_position = ! $odd_position;
		}

		$calculated_check = ( 10 - ( $sum % 10 ) ) % 10;
		return $calculated_check === $check_digit;
	}

	/**
	 * Validate Mod 11 check digit for tracking numbers (used by DHL).
	 *
	 * @param string $tracking_number The tracking number.
	 * @return bool True if valid, false otherwise.
	 */
	public static function validate_mod11_check_digit( string $tracking_number ): bool {
		if ( ! preg_match( '/^\d+$/', $tracking_number ) || strlen( $tracking_number ) < 2 ) {
			return false;
		}

		$check_digit = (int) substr( $tracking_number, -1 );
		$number      = substr( $tracking_number, 0, -1 );

		$weights      = array( 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 );
		$sum          = 0;
		$weight_index = 0;

		// Process each digit from right to left.
		for ( $i = strlen( $number ) - 1; $i >= 0; $i-- ) {
			$digit = (int) $number[ $i ];
			$sum  += $digit * $weights[ $weight_index % count( $weights ) ];
			++$weight_index;
		}

		$calculated_check = 11 - ( $sum % 11 );
		if ( 10 === $calculated_check ) {
			$calculated_check = 0;
		} elseif ( 11 === $calculated_check ) {
			$calculated_check = 5;
		}

		return $calculated_check === $check_digit;
	}

	/**
	 * Validate FedEx check digit for 12/14-digit tracking numbers.
	 *
	 * @param string $tracking_number The FedEx tracking number.
	 * @return bool True if valid, false otherwise.
	 */
	public static function validate_fedex_check_digit( string $tracking_number ): bool {
		if ( ! preg_match( '/^\d{12}$/', $tracking_number ) ) {
			return false;
		}
		$digits           = str_split( substr( $tracking_number, 0, 11 ) );
		$multipliers      = array( 3, 1, 7 );
		$sum              = 0;
		$multiplier_index = 0;
		for ( $i = 10; $i >= 0; $i-- ) {
			$sum             += $digits[ $i ] * $multipliers[ $multiplier_index ];
			$multiplier_index = ( ++$multiplier_index ) % 3;
		}
		$check = $sum % 11;
		if ( 10 === $check ) {
			$check = 0;
		}
		return intval( $tracking_number[11] ) === $check;
	}
}
PK     [1]}&p  p  0  Fulfillments/OrderFulfillmentsRestController.phpnu         <?php
/**
 * FulfillmentsAPISchema class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Fulfillments;

use Automattic\WooCommerce\Internal\Admin\Settings\Exceptions\ApiException;
use Automattic\WooCommerce\Internal\RestApiControllerBase;
use Automattic\WooCommerce\Internal\DataStores\Fulfillments\FulfillmentsDataStore;
use WC_Order;
use WP_Http;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

/**
 * OrderFulfillmentsRestController class file.
 *
 * !> Note: This REST controller is only created for `WC_Order` type of entities, that allow
 * !> managing fulfillments only for admins. Regular users can only view their fulfillments.
 * !>
 * !> If you are using another entity type for your fulfillments, you should create a new controller.
 *
 * @package Automattic\WooCommerce\Internal\Fulfillments
 */
class OrderFulfillmentsRestController extends RestApiControllerBase {
	/**
	 * Endpoint namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'wc/v3';

	/**
	 * REST API base.
	 *
	 * @var string
	 */
	protected $rest_base = '/orders/(?P<order_id>[\d]+)/fulfillments';

	/**
	 * Get the WooCommerce REST API namespace for the class.
	 *
	 * @return string
	 */
	protected function get_rest_api_namespace(): string {
		return 'order_fulfillments';
	}

	/**
	 * Register the routes for fulfillments.
	 */
	public function register_routes() {
		// Register the route for getting and setting order fulfillments.
		register_rest_route(
			$this->route_namespace,
			$this->rest_base,
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'get_fulfillments' ),
					'permission_callback' => fn( $request ) => $this->check_permission_for_fulfillments( $request ),
					'args'                => $this->get_args_for_get_fulfillments(),
					'schema'              => $this->get_schema_for_get_fulfillments(),
				),
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'create_fulfillment' ),
					'permission_callback' => fn( $request ) => $this->check_permission_for_fulfillments( $request ),
					'args'                => $this->get_args_for_create_fulfillment(),
					'schema'              => $this->get_schema_for_create_fulfillment(),
				),
			),
		);

		// Register the route for getting a specific fulfillment.
		register_rest_route(
			$this->route_namespace,
			$this->rest_base . '/(?P<fulfillment_id>[\d]+)',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'get_fulfillment' ),
					'permission_callback' => fn( $request ) => $this->check_permission_for_fulfillments( $request ),
					'args'                => $this->get_args_for_get_fulfillment(),
					'schema'              => $this->get_schema_for_get_fulfillment(),
				),
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'update_fulfillment' ),
					'permission_callback' => fn( $request ) => $this->check_permission_for_fulfillments( $request ),
					'args'                => $this->get_args_for_update_fulfillment(),
					'schema'              => $this->get_schema_for_update_fulfillment(),
				),
				array(
					'methods'             => \WP_REST_Server::DELETABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'delete_fulfillment' ),
					'permission_callback' => fn( $request ) => $this->check_permission_for_fulfillments( $request ),
					'args'                => $this->get_args_for_delete_fulfillment(),
					'schema'              => $this->get_schema_for_delete_fulfillment(),
				),
			),
		);

		// Register the route for fulfillment metadata.
		register_rest_route(
			$this->route_namespace,
			$this->rest_base . '/(?P<fulfillment_id>[\d]+)/metadata',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'get_fulfillment_meta' ),
					'permission_callback' => fn( $request ) => $this->check_permission_for_fulfillments( $request ),
					'args'                => $this->get_args_for_get_fulfillment_meta(),
					'schema'              => $this->get_schema_for_get_fulfillment_meta(),
				),
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'update_fulfillment_meta' ),
					'permission_callback' => fn( $request ) => $this->check_permission_for_fulfillments( $request ),
					'args'                => $this->get_args_for_update_fulfillment_meta(),
					'schema'              => $this->get_schema_for_update_fulfillment_meta(),
				),
				array(
					'methods'             => \WP_REST_Server::DELETABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'delete_fulfillment_meta' ),
					'permission_callback' => fn( $request ) => $this->check_permission_for_fulfillments( $request ),
					'args'                => $this->get_args_for_delete_fulfillment_meta(),
					'schema'              => $this->get_schema_for_delete_fulfillment_meta(),
				),
			),
		);

		// Register the route for tracking number lookup.
		register_rest_route(
			$this->route_namespace,
			$this->rest_base . '/lookup',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'get_tracking_number_details' ),
					'permission_callback' => fn( $request ) => $this->check_permission_for_fulfillments( $request ),
					'args'                => $this->get_args_for_get_tracking_number_details(),
					'schema'              => $this->get_schema_for_get_tracking_number_details(),
				),
			),
		);
	}

	/**
	 * Permission check for REST API endpoints, given the request method.
	 * For all fulfillments methods that have an order_id, we need to be sure the user has permission to view the order.
	 * For all other methods, we check if the user is logged in as admin and has the required capability.
	 *
	 * @param WP_REST_Request $request The request for which the permission is checked.
	 * @return bool|\WP_Error True if the current user has the capability, otherwise an "Unauthorized" error or False if no error is available for the request method.
	 *
	 * @throws \WP_Error If the URL contains an order, but the order does not exist.
	 */
	protected function check_permission_for_fulfillments( WP_REST_Request $request ) {
		// Fetch the order first if there's an order_id in the request.
		$order = null;
		if ( $request->has_param( 'order_id' ) ) {
			$order_id = (int) $request->get_param( 'order_id' );
			$order    = wc_get_order( $order_id );

			if ( ! $order ) {
				return new \WP_Error(
					'woocommerce_rest_order_invalid_id',
					esc_html__( 'Invalid order ID.', 'woocommerce' ),
					array( 'status' => esc_attr( WP_Http::NOT_FOUND ) )
				);
			}
		}

		// Check if the user is logged in as admin, and has the required capability.
		// Admins who can manage WooCommerce can view all fulfillments.
		if ( current_user_can( 'manage_woocommerce' ) ) { // phpcs:ignore WordPress.WP.Capabilities.Unknown
			return true;
		}

		// Check if the order exists, and if the current user is the owner of the order, and the request is a read request.
		// We allow this because we need to render the order fulfillments on the customer's order details and order tracking pages.
		// But they will be only able to view them, not edit.
		if ( get_current_user_id() === $order->get_customer_id() && WP_REST_Server::READABLE === $request->get_method() ) {
			return true;
		}

		// Return an error related to the request method.
		$error_information = $this->get_authentication_error_by_method( $request->get_method() );

		if ( is_null( $error_information ) ) {
			return false;
		}

		return new \WP_Error(
			$error_information['code'],
			$error_information['message'],
			array( 'status' => rest_authorization_required_code() )
		);
	}

	/**
	 * Get the fulfillments for the order.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_REST_Response The fulfillments for the order, or an error if the request fails.
	 */
	public function get_fulfillments( WP_REST_Request $request ): WP_REST_Response {
		$order_id     = (int) $request->get_param( 'order_id' );
		$fulfillments = array();

		// Fetch fulfillments for the order.
		try {
			$datastore    = wc_get_container()->get( FulfillmentsDataStore::class );
			$fulfillments = $datastore->read_fulfillments( WC_Order::class, "$order_id" );
		} catch ( \Exception $e ) {
			return $this->prepare_error_response(
				$e->getCode(),
				$e->getMessage(),
				WP_Http::BAD_REQUEST
			);
		}

		// Return the fulfillments.
		return new WP_REST_Response(
			array_map(
				function ( $fulfillment ) {
					return $fulfillment->get_raw_data(); },
				$fulfillments
			),
			WP_Http::OK
		);
	}

	/**
	 * Create a new fulfillment with the given data for the order.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_REST_Response The created fulfillment, or an error if the request fails.
	 */
	public function create_fulfillment( WP_REST_Request $request ) {
		$order_id        = (int) $request->get_param( 'order_id' );
		$notify_customer = (bool) $request->get_param( 'notify_customer' );
		// Create a new fulfillment.
		try {
			$fulfillment = new Fulfillment();
			$fulfillment->set_props( $request->get_json_params() );
			$fulfillment->set_meta_data( $request->get_json_params()['meta_data'] );
			$fulfillment->set_entity_type( WC_Order::class );
			$fulfillment->set_entity_id( "$order_id" );

			$fulfillment->save();

			if ( $fulfillment->get_is_fulfilled() && $notify_customer ) {
				/**
				 * Trigger the fulfillment created notification on creating a fulfilled fulfillment.
				 *
				 * @since 10.1.0
				 */
				do_action( 'woocommerce_fulfillment_created_notification', $order_id, $fulfillment, wc_get_order( $order_id ) );
			}
		} catch ( ApiException $ex ) {
			return $this->prepare_error_response(
				$ex->getErrorCode(),
				$ex->getMessage(),
				WP_Http::BAD_REQUEST
			);

		} catch ( \Exception $e ) {
			return $this->prepare_error_response(
				$e->getCode(),
				$e->getMessage(),
				WP_Http::BAD_REQUEST
			);
		}

		return new WP_REST_Response( $fulfillment->get_raw_data(), WP_Http::CREATED );
	}

	/**
	 * Get a specific fulfillment for the order.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_REST_Response The fulfillment for the order, or an error if the request fails.
	 *
	 * @throws \Exception If the fulfillment is not found or is deleted.
	 */
	public function get_fulfillment( WP_REST_Request $request ): WP_REST_Response {
		$order_id       = (int) $request->get_param( 'order_id' );
		$fulfillment_id = (int) $request->get_param( 'fulfillment_id' );

		// Fetch the fulfillment for the order.
		try {
			$fulfillment = new Fulfillment( $fulfillment_id );
			$this->validate_fulfillment( $fulfillment, $fulfillment_id, $order_id );
			if ( $fulfillment->get_date_deleted() ) {
				throw new \Exception(
					esc_html__( 'Fulfillment not found.', 'woocommerce' ),
					WP_Http::NOT_FOUND
				);
			}
		} catch ( \Exception $e ) {
			return $this->prepare_error_response(
				$e->getCode(),
				$e->getMessage(),
				WP_Http::BAD_REQUEST
			);
		}

		return new WP_REST_Response(
			$fulfillment->get_raw_data(),
			WP_Http::OK
		);
	}

	/**
	 * Update a specific fulfillment for the order.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_REST_Response The updated fulfillment, or an error if the request fails.
	 */
	public function update_fulfillment( WP_REST_Request $request ): WP_REST_Response {
		$order_id        = (int) $request->get_param( 'order_id' );
		$fulfillment_id  = (int) $request->get_param( 'fulfillment_id' );
		$notify_customer = (bool) $request->get_param( 'notify_customer' );

		// Update the fulfillment for the order.
		try {
			$fulfillment    = new Fulfillment( $fulfillment_id );
			$previous_state = $fulfillment->get_is_fulfilled();
			$this->validate_fulfillment( $fulfillment, $fulfillment_id, $order_id );

			$fulfillment->set_props( $request->get_json_params() );
			$next_state = $fulfillment->get_is_fulfilled();

			if ( isset( $request->get_json_params()['meta_data'] ) && is_array( $request->get_json_params()['meta_data'] ) ) {
				// Update the meta data keys that exist in the request.
				foreach ( $request->get_json_params()['meta_data'] as $meta ) {
					$fulfillment->update_meta_data( $meta['key'], $meta['value'], $meta['id'] ?? 0 );
				}

				// Remove the meta data keys that don't exist in the request, by matching their keys.
				$existing_meta_data = $fulfillment->get_meta_data();
				foreach ( $existing_meta_data as $meta ) {
					if ( ! in_array( $meta->key, array_column( $request->get_json_params()['meta_data'], 'key' ), true ) ) {
						$fulfillment->delete_meta_data( $meta->key );
					}
				}
			}
			$fulfillment->save();
			$fulfillment->save_meta_data();

			if ( $notify_customer ) {
				if ( ! $previous_state && $next_state ) {
					/**
					 * Trigger the fulfillment created notification on fulfilling a fulfillment.
					 *
					 * @since 10.1.0
					 */
					do_action( 'woocommerce_fulfillment_created_notification', $order_id, $fulfillment, wc_get_order( $order_id ) );
				} elseif ( $next_state ) {
					/**
					 * Trigger the fulfillment updated notification on updating a fulfillment.
					 *
					 * @since 10.1.0
					 */
					do_action( 'woocommerce_fulfillment_updated_notification', $order_id, $fulfillment, wc_get_order( $order_id ) );
				}
			}
		} catch ( ApiException $ex ) {
			return $this->prepare_error_response(
				$ex->getErrorCode(),
				$ex->getMessage(),
				WP_Http::BAD_REQUEST
			);
		} catch ( \Exception $e ) {
			return $this->prepare_error_response(
				$e->getCode(),
				$e->getMessage(),
				WP_Http::BAD_REQUEST
			);
		}

		return new WP_REST_Response(
			$fulfillment->get_raw_data(),
			WP_Http::OK
		);
	}

	/**
	 * Delete a specific fulfillment for the order.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_REST_Response The deleted fulfillment, or an error if the request fails.
	 */
	public function delete_fulfillment( WP_REST_Request $request ) {
		$order_id        = (int) $request->get_param( 'order_id' );
		$fulfillment_id  = (int) $request->get_param( 'fulfillment_id' );
		$notify_customer = (bool) $request->get_param( 'notify_customer' );

		// Delete the fulfillment for the order.
		try {
			$fulfillment = new Fulfillment( $fulfillment_id );
			$this->validate_fulfillment( $fulfillment, $fulfillment_id, $order_id );
			$fulfillment->delete();
		} catch ( ApiException $ex ) {
			return $this->prepare_error_response(
				$ex->getErrorCode(),
				$ex->getMessage(),
				WP_Http::BAD_REQUEST
			);
		} catch ( \Exception $e ) {
			return $this->prepare_error_response(
				$e->getCode(),
				$e->getMessage(),
				WP_Http::BAD_REQUEST
			);
		}

		if ( $fulfillment->get_is_fulfilled() && $notify_customer ) {
			/**
			 * Trigger the fulfillment deleted notification.
			 *
			 * @since 10.1.0
			 */
			do_action( 'woocommerce_fulfillment_deleted_notification', $order_id, $fulfillment, wc_get_order( $order_id ) );
		}
		return new WP_REST_Response(
			array(
				'message' => __( 'Fulfillment deleted successfully.', 'woocommerce' ),
			),
			WP_Http::OK
		);
	}

	/**
	 * Get the metadata for a specific fulfillment.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_REST_Response The metadata for the fulfillment, or an error if the request fails.
	 */
	public function get_fulfillment_meta( WP_REST_Request $request ): WP_REST_Response {
		$order_id       = (int) $request->get_param( 'order_id' );
		$fulfillment_id = (int) $request->get_param( 'fulfillment_id' );

		// Fetch the metadata for the fulfillment.
		try {
			$fulfillment = new Fulfillment( $fulfillment_id );
			$this->validate_fulfillment( $fulfillment, $fulfillment_id, $order_id );
		} catch ( \Exception $e ) {
			return $this->prepare_error_response(
				$e->getCode(),
				$e->getMessage(),
				WP_Http::BAD_REQUEST
			);
		}

		return new WP_REST_Response(
			$fulfillment->get_raw_meta_data(),
			WP_Http::OK
		);
	}

	/**
	 * Update the metadata for a specific fulfillment.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_REST_Response The updated metadata for the fulfillment, or an error if the request fails.
	 */
	public function update_fulfillment_meta( WP_REST_Request $request ): WP_REST_Response {
		$order_id       = (int) $request->get_param( 'order_id' );
		$fulfillment_id = (int) $request->get_param( 'fulfillment_id' );

		// Update the metadata for the fulfillment.
		try {
			$fulfillment = new Fulfillment( $fulfillment_id );
			$this->validate_fulfillment( $fulfillment, $fulfillment_id, $order_id );

			// Update the meta data keys that exist in the request.
			foreach ( $request->get_json_params()['meta_data'] as $meta ) {
				$fulfillment->update_meta_data( $meta['key'], $meta['value'], $meta['id'] ?? 0 );
			}

			// Remove the meta data keys that don't exist in the request, by matching their keys.
			$existing_meta_data = $fulfillment->get_meta_data();
			foreach ( $existing_meta_data as $meta ) {
				if ( ! in_array( $meta->key, array_column( $request->get_json_params()['meta_data'], 'key' ), true ) ) {
					$fulfillment->delete_meta_data( $meta->key );
				}
			}
			$fulfillment->save();
		} catch ( ApiException $ex ) {
			return $this->prepare_error_response(
				$ex->getErrorCode(),
				$ex->getMessage(),
				WP_Http::BAD_REQUEST
			);
		} catch ( \Exception $e ) {
			return $this->prepare_error_response(
				$e->getCode(),
				$e->getMessage(),
				WP_Http::BAD_REQUEST
			);
		}

		return new WP_REST_Response(
			$fulfillment->get_raw_meta_data(),
			WP_Http::OK
		);
	}

	/**
	 * Delete the metadata for a specific fulfillment.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_REST_Response The deleted metadata for the fulfillment, or an error if the request fails.
	 */
	public function delete_fulfillment_meta( WP_REST_Request $request ) {
		$order_id       = (int) $request->get_param( 'order_id' );
		$fulfillment_id = (int) $request->get_param( 'fulfillment_id' );

		// Delete the metadata for the fulfillment.
		try {
			$fulfillment = new Fulfillment( $fulfillment_id );
			$this->validate_fulfillment( $fulfillment, $fulfillment_id, $order_id );

			$meta_key = sanitize_text_field( wp_unslash( (string) $request->get_param( 'meta_key' ) ) );
			$fulfillment->delete_meta_data( $meta_key );
			$fulfillment->save();
		} catch ( ApiException $ex ) {
			return $this->prepare_error_response(
				$ex->getErrorCode(),
				$ex->getMessage(),
				WP_Http::BAD_REQUEST
			);
		} catch ( \Exception $e ) {
			return $this->prepare_error_response(
				$e->getCode(),
				$e->getMessage(),
				WP_Http::BAD_REQUEST
			);
		}

		return new WP_REST_Response(
			$fulfillment->get_raw_meta_data(),
			WP_Http::OK
		);
	}

	/**
	 * Get the tracking number details for a given tracking number, if possible.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_REST_Response The tracking number details, or an error if the request fails.
	 */
	public function get_tracking_number_details( WP_REST_Request $request ) {
		$order_id        = (int) $request->get_param( 'order_id' );
		$tracking_number = sanitize_text_field( $request->get_param( 'tracking_number' ) );

		if ( empty( $tracking_number ) ) {
			return $this->prepare_error_response(
				'woocommerce_rest_tracking_number_missing',
				__( 'Tracking number is required.', 'woocommerce' ),
				array( 'status' => WP_Http::BAD_REQUEST )
			);
		}

		if ( ! $order_id ) {
			return $this->prepare_error_response(
				'woocommerce_rest_order_id_missing',
				__( 'Order ID is required.', 'woocommerce' ),
				array( 'status' => WP_Http::BAD_REQUEST )
			);
		}

		$order = wc_get_order( $order_id );
		if ( ! $order || ! $order instanceof WC_Order ) {
			return $this->prepare_error_response(
				'woocommerce_rest_order_invalid_id',
				__( 'Invalid order ID.', 'woocommerce' ),
				array( 'status' => WP_Http::NOT_FOUND )
			);
		}

		/**
		 * Parse the tracking number with additional parameters.
		 *
		 * @since 10.1.0
		 */
		$tracking_number_parse_result = apply_filters(
			'woocommerce_fulfillment_parse_tracking_number',
			$tracking_number,
			WC()->countries->get_base_country(),
			$order->get_shipping_country(),
		);

		return new WP_REST_Response( $tracking_number_parse_result, WP_Http::OK );
	}

	/**
	 * Get the arguments for the get order fulfillments endpoint.
	 *
	 * @return array
	 */
	private function get_args_for_get_fulfillments(): array {
		return array(
			'order_id' => array(
				'description' => __( 'Unique identifier for the order.', 'woocommerce' ),
				'type'        => 'integer',
				'required'    => true,
				'context'     => array( 'view', 'edit' ),
			),
		);
	}

	/**
	 * Get the schema for the get order fulfillments endpoint.
	 *
	 * @return array
	 */
	private function get_schema_for_get_fulfillments(): array {
		$schema          = $this->get_base_schema();
		$schema['title'] = __( 'Get fulfillments response.', 'woocommerce' );
		$schema['type']  = 'array';
		$schema['items'] = array(
			'type'       => 'object',
			'properties' => $this->get_read_schema_for_fulfillment(),
		);
		return $schema;
	}

	/**
	 * Get the arguments for the create fulfillment endpoint.
	 *
	 * @return array
	 */
	private function get_args_for_create_fulfillment(): array {
		return $this->get_write_args_for_fulfillment( true );
	}

	/**
	 * Get the schema for the create fulfillment endpoint.
	 *
	 * @return array
	 */
	private function get_schema_for_create_fulfillment(): array {
		$schema               = $this->get_base_schema();
		$schema['title']      = __( 'Create fulfillment response.', 'woocommerce' );
		$schema['properties'] = $this->get_read_schema_for_fulfillment();
		return $schema;
	}

	/**
	 * Get the arguments for the get fulfillment endpoint.
	 *
	 * @return array
	 */
	private function get_args_for_get_fulfillment(): array {
		return array(
			'order_id'       => array(
				'description' => __( 'Unique identifier for the order.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'required'    => true,
			),
			'fulfillment_id' => array(
				'description' => __( 'Unique identifier for the fulfillment.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'required'    => true,
			),
		);
	}

	/**
	 * Get the schema for the get fulfillment endpoint.
	 *
	 * @return array
	 */
	private function get_schema_for_get_fulfillment(): array {
		$schema               = $this->get_base_schema();
		$schema['title']      = __( 'Get fulfillment response.', 'woocommerce' );
		$schema['properties'] = $this->get_read_schema_for_fulfillment();

		return $schema;
	}

	/**
	 * Get the arguments for the update fulfillment endpoint.
	 *
	 * @return array
	 */
	private function get_args_for_update_fulfillment(): array {
		return $this->get_write_args_for_fulfillment( false );
	}

	/**
	 * Get the schema for the update fulfillment endpoint.
	 *
	 * @return array
	 */
	private function get_schema_for_update_fulfillment(): array {
		$schema               = $this->get_base_schema();
		$schema['title']      = __( 'Update fulfillment response.', 'woocommerce' );
		$schema['type']       = 'object';
		$schema['properties'] = $this->get_read_schema_for_fulfillment();

		return $schema;
	}

	/**
	 * Get the arguments for the delete fulfillment endpoint.
	 *
	 * @return array
	 */
	private function get_args_for_delete_fulfillment(): array {
		return array(
			'order_id'        => array(
				'description' => __( 'Unique identifier for the order.', 'woocommerce' ),
				'type'        => 'integer',
				'required'    => true,
				'context'     => array( 'view', 'edit' ),
			),
			'fulfillment_id'  => array(
				'description' => __( 'Unique identifier for the fulfillment.', 'woocommerce' ),
				'type'        => 'integer',
				'required'    => true,
				'context'     => array( 'view', 'edit' ),
			),
			'notify_customer' => array(
				'description' => __( 'Whether to notify the customer about the fulfillment update.', 'woocommerce' ),
				'type'        => 'boolean',
				'default'     => false,
				'required'    => false,
				'context'     => array( 'view', 'edit' ),
			),
		);
	}

	/**
	 * Get the schema for the delete fulfillment endpoint.
	 *
	 * @return array
	 */
	private function get_schema_for_delete_fulfillment(): array {
		$schema               = $this->get_base_schema();
		$schema['title']      = __( 'Delete fulfillment response.', 'woocommerce' );
		$schema['properties'] = array(
			'message' => array(
				'description' => __( 'The response message.', 'woocommerce' ),
				'type'        => 'string',
				'required'    => true,
			),
		);

		return $schema;
	}

	/**
	 * Get the arguments for the get fulfillment meta endpoint.
	 *
	 * @return array
	 */
	private function get_args_for_get_fulfillment_meta(): array {
		return array(
			'order_id'       => array(
				'description' => __( 'Unique identifier for the order.', 'woocommerce' ),
				'type'        => 'integer',
				'required'    => true,
				'context'     => array( 'view', 'edit' ),
			),
			'fulfillment_id' => array(
				'description' => __( 'Unique identifier for the fulfillment.', 'woocommerce' ),
				'type'        => 'integer',
				'required'    => true,
				'context'     => array( 'view', 'edit' ),
			),
		);
	}

	/**
	 * Get the schema for the get fulfillment meta endpoint.
	 *
	 * @return array
	 */
	private function get_schema_for_get_fulfillment_meta(): array {
		$schema          = $this->get_base_schema();
		$schema['title'] = __( 'Get fulfillment meta data response.', 'woocommerce' );
		$schema['type']  = 'array';
		$schema['items'] = array(
			'description' => __( 'The meta data object.', 'woocommerce' ),
			'type'        => 'object',
			'properties'  => $this->get_schema_for_meta_data(),
		);

		return $schema;
	}

	/**
	 * Get the arguments for the update fulfillment meta endpoint.
	 *
	 * @return array
	 */
	private function get_args_for_update_fulfillment_meta(): array {
		return array(
			'order_id'       => array(
				'description' => __( 'Unique identifier for the order.', 'woocommerce' ),
				'type'        => 'integer',
				'required'    => true,
				'context'     => array( 'view', 'edit' ),
			),
			'fulfillment_id' => array(
				'description' => __( 'Unique identifier for the fulfillment.', 'woocommerce' ),
				'type'        => 'integer',
				'required'    => true,
				'context'     => array( 'view', 'edit' ),
			),
			'meta_data'      => array(
				'description' => __( 'The meta data array.', 'woocommerce' ),
				'type'        => 'array',
				'required'    => true,
				'items'       => array(
					'description' => __( 'The meta data object.', 'woocommerce' ),
					'type'        => 'object',
					'properties'  => $this->get_schema_for_meta_data(),
				),
			),
		);
	}

	/**
	 * Get the schema for the update fulfillment meta endpoint.
	 *
	 * @return array
	 */
	private function get_schema_for_update_fulfillment_meta(): array {
		$schema          = $this->get_base_schema();
		$schema['title'] = __( 'Update fulfillment meta data response.', 'woocommerce' );
		$schema['type']  = 'array';
		$schema['items'] = array(
			'description' => __( 'The meta data object.', 'woocommerce' ),
			'type'        => 'object',
			'properties'  => $this->get_schema_for_meta_data(),
		);

		return $schema;
	}

	/**
	 * Get the arguments for the delete fulfillment meta endpoint.
	 *
	 * @return array
	 */
	private function get_args_for_delete_fulfillment_meta(): array {
		return array(
			'order_id'       => array(
				'description' => __( 'Unique identifier for the order.', 'woocommerce' ),
				'type'        => 'integer',
				'required'    => true,
				'context'     => array( 'view', 'edit' ),
			),
			'fulfillment_id' => array(
				'description' => __( 'Unique identifier for the fulfillment.', 'woocommerce' ),
				'type'        => 'integer',
				'required'    => true,
				'context'     => array( 'view', 'edit' ),
			),
			'meta_key'       => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
				'description' => __( 'The meta key to delete.', 'woocommerce' ),
				'type'        => 'string',
				'required'    => true,
			),
		);
	}

	/**
	 * Get the schema for the delete fulfillment meta endpoint.
	 *
	 * @return array
	 */
	private function get_schema_for_delete_fulfillment_meta(): array {
		$schema          = $this->get_base_schema();
		$schema['title'] = __( 'Delete fulfillment meta data response.', 'woocommerce' );
		$schema['type']  = 'array';
		$schema['items'] = array(
			'description' => __( 'The meta data object.', 'woocommerce' ),
			'type'        => 'object',
			'properties'  => $this->get_schema_for_meta_data(),
		);

		return $schema;
	}

	/**
	 * Get the arguments for the get tracking number details endpoint.
	 *
	 * @return array
	 */
	private function get_args_for_get_tracking_number_details(): array {
		return array(
			'order_id'        => array(
				'description' => __( 'Unique identifier for the order.', 'woocommerce' ),
				'type'        => 'integer',
				'required'    => true,
				'context'     => array( 'view', 'edit' ),
			),
			'tracking_number' => array(
				'description' => __( 'The tracking number to look up.', 'woocommerce' ),
				'type'        => 'string',
				'required'    => true,
				'context'     => array( 'view', 'edit' ),
			),
		);
	}

	/**
	 * Get the schema for the get tracking number details endpoint.
	 *
	 * @return array
	 */
	private function get_schema_for_get_tracking_number_details(): array {
		$schema               = $this->get_base_schema();
		$schema['title']      = __( 'The tracking number details response.', 'woocommerce' );
		$schema['properties'] = array(
			'tracking_number'   => array(
				'description' => __( 'The tracking number.', 'woocommerce' ),
				'type'        => 'string',
				'required'    => true,
			),
			'shipping_provider' => array(
				'description' => __( 'The shipping provider.', 'woocommerce' ),
				'type'        => 'string',
				'required'    => true,
			),
			'tracking_url'      => array(
				'description' => __( 'The tracking URL.', 'woocommerce' ),
				'type'        => 'string',
				'required'    => true,
			),
			'possibilities'     => array(
				'description' => __( 'Ambiguous shipping providers list.', 'woocommerce' ),
				'type'        => 'array',
				'required'    => false,
				'items'       => array(
					'type' => 'string',
				),
			),
		);
		return $schema;
	}

	/**
	 * Get the base schema for the fulfillment with a read context.
	 *
	 * @return array
	 */
	private function get_read_schema_for_fulfillment() {
		return array(
			'id'           => array(
				'description' => __( 'Unique identifier for the fulfillment.', 'woocommerce' ),
				'type'        => 'integer',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
			'entity_type'  => array(
				'description' => __( 'The type of entity for which the fulfillment is created.', 'woocommerce' ),
				'type'        => 'string',
				'required'    => true,
				'context'     => array( 'view', 'edit' ),
			),
			'entity_id'    => array(
				'description' => __( 'Unique identifier for the entity.', 'woocommerce' ),
				'type'        => 'string',
				'required'    => true,
				'context'     => array( 'view', 'edit' ),
			),
			'status'       => array(
				'description' => __( 'The status of the fulfillment.', 'woocommerce' ),
				'type'        => 'string',
				'default'     => 'unfulfilled',
				'required'    => true,
				'context'     => array( 'view', 'edit' ),
			),
			'is_fulfilled' => array(
				'description' => __( 'Whether the fulfillment is fulfilled.', 'woocommerce' ),
				'type'        => 'boolean',
				'default'     => false,
				'required'    => true,
				'context'     => array( 'view', 'edit' ),
			),
			'date_updated' => array(
				'description' => __( 'The date the fulfillment was last updated.', 'woocommerce' ),
				'type'        => 'string',
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'required'    => true,
			),
			'date_deleted' => array(
				'description' => __( 'The date the fulfillment was deleted.', 'woocommerce' ),
				'anyOf'       => array(
					array(
						'type' => 'string',
					),
					array(
						'type' => 'null',
					),
				),
				'default'     => null,
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'required'    => true,
			),
			'meta_data'    => array(
				'description' => __( 'Meta data for the fulfillment.', 'woocommerce' ),
				'type'        => 'array',
				'required'    => true,
				'items'       => $this->get_schema_for_meta_data(),
			),
		);
	}

	/**
	 * Get the base args for the fulfillment with a write context.
	 *
	 * @param bool $is_create Whether the args list is for a create request.
	 *
	 * @return array
	 */
	private function get_write_args_for_fulfillment( bool $is_create = false ) {
		return array_merge(
			! $is_create ? array(
				'fulfillment_id' => array(
					'description' => __( 'Unique identifier for the fulfillment.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
			) : array(),
			array(
				'status'          => array(
					'description' => __( 'The status of the fulfillment.', 'woocommerce' ),
					'type'        => 'string',
					'default'     => 'unfulfilled',
					'required'    => false,
					'context'     => array( 'view', 'edit' ),
				),
				'is_fulfilled'    => array(
					'description' => __( 'Whether the fulfillment is fulfilled.', 'woocommerce' ),
					'type'        => 'boolean',
					'default'     => false,
					'required'    => false,
					'context'     => array( 'view', 'edit' ),
				),
				'meta_data'       => array(
					'description' => __( 'Meta data for the fulfillment.', 'woocommerce' ),
					'type'        => 'array',
					'required'    => true,
					'schema'      => $this->get_schema_for_meta_data(),
				),
				'notify_customer' => array(
					'description' => __( 'Whether to notify the customer about the fulfillment update.', 'woocommerce' ),
					'type'        => 'boolean',
					'default'     => false,
					'required'    => false,
					'context'     => array( 'view', 'edit' ),
				),
			)
		);
	}

	/**
	 * Get the schema for the meta data.
	 *
	 * @return array
	 */
	private function get_schema_for_meta_data(): array {
		return array(
			'type'       => 'object',
			'properties' => array(
				'id'    => array(
					'description' => __( 'The unique identifier for the meta data. Set `0` for new records.', 'woocommerce' ),
					'type'        => 'integer',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'key'   => array(
					'description' => __( 'The key of the meta data.', 'woocommerce' ),
					'type'        => 'string',
					'required'    => true,
					'context'     => array( 'view', 'edit' ),
				),
				'value' => array(
					'description' => __( 'The value of the meta data.', 'woocommerce' ),
					'type'        => 'string',
					'required'    => true,
					'context'     => array( 'view', 'edit' ),
				),
			),
			'required'   => true,
			'context'    => array( 'view', 'edit' ),
			'readonly'   => true,
		);
	}

	/**
	 * Prepare an error response.
	 *
	 * @param string $code The error code.
	 * @param string $message The error message.
	 * @param int    $status The HTTP status code.
	 *
	 * @return WP_REST_Response The error response.
	 */
	private function prepare_error_response( $code, $message, $status ): WP_REST_Response {
		return new WP_REST_Response(
			array(
				'code'    => $code,
				'message' => $message,
				'data'    => array( 'status' => $status ),
			),
			$status
		);
	}

	/**
	 * Validate the fulfillment.
	 *
	 * @param Fulfillment $fulfillment The fulfillment object.
	 * @param int         $fulfillment_id The fulfillment ID.
	 * @param int         $order_id The order ID.
	 *
	 * @throws \Exception If the fulfillment ID is invalid.
	 */
	private function validate_fulfillment( Fulfillment $fulfillment, int $fulfillment_id, int $order_id ) {
		if ( $fulfillment->get_id() !== $fulfillment_id || $fulfillment->get_entity_type() !== WC_Order::class || $fulfillment->get_entity_id() !== "$order_id" ) {
			throw new \Exception( esc_html__( 'Invalid fulfillment ID.', 'woocommerce' ) );
		}
	}
}
PK     [1]SRE{    7  Fulfillments/Providers/DeutschePostShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Deutsche Post Shipping Provider class.
 */
class DeutschePostShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'deutsche-post';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Deutsche Post';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/deutsche-post.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.deutschepost.de/sendung/simpleQuery.html?piececode=' . $tracking_number;
	}
}
PK     [1]~#e    4  Fulfillments/Providers/StarTrackShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * StarTrack Shipping Provider class.
 */
class StarTrackShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'startrack';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'StarTrack';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/startrack.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.startrack.com.au/track/' . $tracking_number;
	}
}
PK     [1]v      8  Fulfillments/Providers/AustraliaPostShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

use Automattic\WooCommerce\Internal\Fulfillments\FulfillmentUtils;

/**
 * Australia Post Shipping Provider class.
 *
 * Provides Australia Post tracking number validation, supported countries, and tracking URL generation.
 */
class AustraliaPostShippingProvider extends AbstractShippingProvider {

	/**
	 * Australia Post tracking number patterns with enhanced service detection.
	 *
	 * @var array<string, array{patterns: array<int, string>, confidence: int}>
	 */
	private const TRACKING_PATTERNS = array(
		'AU' => array( // Australia.
			'patterns'   => array(
				// International UPU S10 format with validation.
				'/^[A-Z]{2}\d{9}AU$/',       // XX#########AU.
				'/^[A-Z]{2}\d{7}AU$/',       // Alternative international format: XX#######AU.

				// Domestic numeric tracking formats.
				'/^\d{13}$/',                // 13-digit domestic tracking.
				'/^\d{12}$/',                // 12-digit domestic tracking.
				'/^\d{11}$/',                // 11-digit domestic tracking.

				// Standard alphanumeric formats.
				'/^[A-Z]{2}\d{8}[A-Z]{2}$/', // Standard format: XX########XX.
				'/^[A-Z]{1}\d{10}[A-Z]{1}$/', // Domestic format: X##########X.

				// Service-specific patterns.
				'/^[A-Z]{4}\d{8}$/',         // Express Post format: XXXX########.
				'/^EP\d{10}$/',              // Express Post specific.
				'/^ST\d{10}$/',              // StarTrack (freight).
				'/^MB\d{10}$/',              // MyPost Business.
				'/^PO\d{10}$/',              // Post Office Box.

				// MyPost Digital formats.
				'/^MP\d{10,12}$/',           // MyPost tracking.
				'/^DG\d{10,12}$/',           // Digital tracking.

				// Parcel numeric formats.
				'/^7\d{15}$/',               // 16-digit format starting with 7.
				'/^3\d{15}$/',               // 16-digit format starting with 3.
				'/^8\d{15}$/',               // 16-digit format starting with 8.

				// eParcel formats.
				'/^[A-Z]{3}\d{8,12}$/',      // Three-letter prefix.
				'/^33\d?[A-Z]{2}\d{18,20}$/', // StarTrack eParcel.
				'/^AP\d{10,13}$/',           // Australia Post eParcel.

				// Legacy and alternative formats.
				'/^[0-9]{10}[A-Z]{2}$/',     // 10 digits + 2 letters.
				'/^[A-Z]{1}\d{8}[A-Z]{3}$/', // Alternative format.
			),
			'confidence' => 90,
		),
	);

	/**
	 * Get the unique key for this shipping provider.
	 *
	 * @return string Unique key.
	 */
	public function get_key(): string {
		return 'australia-post';
	}

	/**
	 * Get the name of this shipping provider.
	 *
	 * @return string Name of the shipping provider.
	 */
	public function get_name(): string {
		return 'Australia Post';
	}

	/**
	 * Get the icon URL for this shipping provider.
	 *
	 * @return string URL of the shipping provider icon.
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/australia-post.png';
	}

	/**
	 * Get the countries this shipping provider can ship from.
	 *
	 * @return array List of country codes.
	 */
	public function get_shipping_from_countries(): array {
		return array_keys( self::TRACKING_PATTERNS );
	}

	/**
	 * Get the countries this shipping provider can ship to.
	 *
	 * Australia Post ships internationally, so we return a comprehensive list.
	 *
	 * @return array List of country codes.
	 */
	public function get_shipping_to_countries(): array {
		return array( 'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AO', 'AR', 'AS', 'AT', 'AU', 'AW', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BM', 'BN', 'BO', 'BR', 'BS', 'BT', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL', 'CM', 'CN', 'CO', 'CR', 'CU', 'CV', 'CW', 'CY', 'CZ', 'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ', 'EC', 'EE', 'EG', 'ER', 'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', 'FO', 'FR', 'GA', 'GB', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GL', 'GM', 'GN', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GW', 'GY', 'HK', 'HN', 'HR', 'HT', 'HU', 'ID', 'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR', 'IS', 'IT', 'JE', 'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI', 'KP', 'KR', 'KW', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LI', 'LK', 'LR', 'LS', 'LT', 'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO', 'NP', 'NR', 'NU', 'NZ', 'OM', 'PA', 'PE', 'PF', 'PG', 'PH', 'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY', 'QA', 'RE', 'RO', 'RS', 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH', 'SI', 'SK', 'SL', 'SM', 'SN', 'SO', 'SR', 'SS', 'ST', 'SV', 'SX', 'SY', 'SZ', 'TC', 'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL', 'TM', 'TN', 'TO', 'TR', 'TT', 'TV', 'TW', 'TZ', 'UA', 'UG', 'UM', 'US', 'UY', 'UZ', 'VA', 'VC', 'VE', 'VG', 'VI', 'VN', 'VU', 'WF', 'WS', 'YE', 'YT', 'ZA', 'ZM', 'ZW' );
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number to generate the URL for.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://auspost.com.au/mypost/track/details/' . rawurlencode( $tracking_number );
	}

	/**
	 * Validate tracking number against country-specific patterns.
	 *
	 * @param string $tracking_number The tracking number to validate.
	 * @param string $country_code The country code for the shipment.
	 * @return bool True if valid, false otherwise.
	 */
	private function validate_country_pattern( string $tracking_number, string $country_code ): bool {
		if ( ! isset( self::TRACKING_PATTERNS[ $country_code ] ) ) {
			return false;
		}

		foreach ( self::TRACKING_PATTERNS[ $country_code ]['patterns'] as $pattern ) {
			if ( preg_match( $pattern, $tracking_number ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Try to parse an Australia Post tracking number.
	 *
	 * @param string $tracking_number The tracking number to parse.
	 * @param string $shipping_from The country code of the shipping origin.
	 * @param string $shipping_to The country code of the shipping destination.
	 * @return array|null An array with 'url' and 'ambiguity_score' if valid, null otherwise.
	 */
	public function try_parse_tracking_number(
		string $tracking_number,
		string $shipping_from,
		string $shipping_to
	): ?array {
		if ( empty( $tracking_number ) || empty( $shipping_from ) || empty( $shipping_to ) ) {
			return null;
		}

		$normalized = strtoupper( preg_replace( '/\s+/', '', $tracking_number ) );
		if ( empty( $normalized ) ) {
			return null;
		}

		$shipping_from = strtoupper( $shipping_from );
		$shipping_to   = strtoupper( $shipping_to );

		// Australia Post ships only from Australia.
		if ( 'AU' !== $shipping_from ) {
			return null;
		}

		if ( $this->validate_country_pattern( $normalized, $shipping_from ) ) {
			$confidence = self::TRACKING_PATTERNS[ $shipping_from ]['confidence'];

			// Check digit validation for numeric formats.
			if ( preg_match( '/^\d{11,13}$/', $normalized ) ) {
				if ( FulfillmentUtils::validate_mod10_check_digit( $normalized ) ) {
					$confidence = min( 98, $confidence + 8 );
				}
			}

			// UPU S10 validation for international formats.
			if ( preg_match( '/^[A-Z]{2}\d{7,9}AU$/', $normalized ) ) {
				if ( FulfillmentUtils::check_s10_upu_format( $normalized ) ) {
					$confidence = min( 98, $confidence + 8 );
				}
			}

			// Service-specific confidence boosts.
			if ( preg_match( '/^(EP|ST|MB)\d+/', $normalized ) ) {
				$confidence = min( 95, $confidence + 5 );
			}

			// Boost confidence for domestic shipments.
			if ( 'AU' === $shipping_to ) {
				$confidence = min( 98, $confidence + 5 );
			}

			// Boost confidence for Asia-Pacific destinations.
			$apac_destinations = array( 'NZ', 'SG', 'HK', 'JP', 'KR', 'TH', 'MY', 'ID', 'PH', 'VN', 'IN' );
			if ( in_array( $shipping_to, $apac_destinations, true ) ) {
				$confidence = min( 95, $confidence + 3 );
			}

			// Boost confidence for common destinations.
			$common_destinations = array( 'US', 'GB', 'CA', 'DE', 'FR' );
			if ( in_array( $shipping_to, $common_destinations, true ) ) {
				$confidence = min( 93, $confidence + 2 );
			}

			return array(
				'url'             => $this->get_tracking_url( $normalized ),
				'ambiguity_score' => $confidence,
			);
		}

		return null;
	}
}
PK     [1]2    2  Fulfillments/Providers/HayPostShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * HayPost Shipping Provider class.
 */
class HayPostShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'haypost';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'HayPost';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/haypost.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.haypost.am/en/track/' . $tracking_number;
	}
}
PK     [1]    9  Fulfillments/Providers/NewZealandPostShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * New Zealand Post Shipping Provider class.
 */
class NewZealandPostShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'new-zealand-post';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'New Zealand Post';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/new-zealand-post.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.nzpost.co.nz/tools/tracking?track=' . $tracking_number;
	}
}
PK     [1]Y`
  
  7  Fulfillments/Providers/YurticiKargoShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Yurtici Kargo Shipping Provider class.
 */
class YurticiKargoShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'yurtici-kargo';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Yurtiçi Kargo';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/yurtici-kargo.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.yurticikargo.com/Tracking/Detail/' . $tracking_number;
	}
}
PK     [1]r    /  Fulfillments/Providers/SeurShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * SEUR Shipping Provider class.
 */
class SeurShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'seur';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'SEUR';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/seur.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.seur.com/seguimiento/' . $tracking_number;
	}
}
PK     [1]ٿ>    5  Fulfillments/Providers/NovaPoshtaShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Nova Poshta Shipping Provider class.
 */
class NovaPoshtaShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'nova-poshta';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Nova Poshta';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/nova-poshta.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://novaposhta.ua/en/tracking/' . $tracking_number;
	}
}
PK     [1]}	-  -  ;  Fulfillments/Providers/LaPosteColissimoShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * La Poste / Colissimo Shipping Provider class.
 */
class LaPosteColissimoShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'la-poste-colissimo';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'La Poste / Colissimo';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/la-poste-colissimo.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.laposte.fr/outils/suivre-vos-envois?code=' . $tracking_number;
	}
}
PK     [1]&o
    8  Fulfillments/Providers/PostaMoldoveiShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Posta Moldovei Shipping Provider class.
 */
class PostaMoldoveiShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'posta-moldovei';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Poșta Moldovei';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/posta-moldovei.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.posta.md/track/' . $tracking_number;
	}
}
PK     [1]<g    .  Fulfillments/Providers/SDAShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * SDA Shipping Provider class.
 */
class SDAShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'sda';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'SDA';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/sda.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.sda.it/track/' . $tracking_number;
	}
}
PK     [1]z"d    4  Fulfillments/Providers/MaltaPostShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * MaltaPost Shipping Provider class.
 */
class MaltaPostShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'maltapost';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'MaltaPost';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/maltapost.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.maltapost.com/track/' . $tracking_number;
	}
}
PK     [1]vn    3  Fulfillments/Providers/HelthjemShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Helthjem Shipping Provider class.
 */
class HelthjemShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'helthjem';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Helthjem';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/helthjem.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.helthjem.no/sporing/' . $tracking_number;
	}
}
PK     [1]]A
    :  Fulfillments/Providers/MakedonskaPostaShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Makedonska Posta Shipping Provider class.
 */
class MakedonskaPostaShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'makedonska-posta';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Makedonska Pošta';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/makedonska-posta.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.posta.gov.mk/track/' . $tracking_number;
	}
}
PK     [1]    4  Fulfillments/Providers/SwissPostShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Swiss Post Shipping Provider class.
 */
class SwissPostShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'swiss-post';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Swiss Post';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/swiss-post.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.post.ch/en/parcel-tracking?itemId=' . $tracking_number;
	}
}
PK     [1]Zn    0  Fulfillments/Providers/BpostShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Bpost Shipping Provider class.
 */
class BpostShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'bpost';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'bpost';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/bpost.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.bpost.be/en/track-and-trace?itemId=' . $tracking_number;
	}
}
PK     [1]y+)    3  Fulfillments/Providers/AbstractShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Abstract class for shipping providers.
 *
 * This class defines the basic structure and methods that all shipping providers must implement.
 */
abstract class AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	abstract public function get_key(): string;

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	abstract public function get_name(): string;

	/**
	 * Get the path of the icon of the shipping provider.
	 *
	 * @return string
	 */
	abstract public function get_icon(): string;

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	abstract public function get_tracking_url( string $tracking_number ): string;

	/**
	 * Get the countries from which the shipping provider can ship.
	 *
	 * @return array An array of country codes.
	 */
	public function get_shipping_from_countries(): array {
		return array();
	}

	/**
	 * Get the countries to which the shipping provider can ship.
	 *
	 * @return array An array of country codes.
	 */
	public function get_shipping_to_countries(): array {
		return array();
	}

	/**
	 * Check if the shipping provider can ship from a specific country.
	 *
	 * @param string $country_code The country code to check.
	 * @return bool True if the provider can ship from the country, false otherwise.
	 */
	public function can_ship_from( string $country_code ): bool {
		return in_array( $country_code, $this->get_shipping_from_countries(), true );
	}

	/**
	 * Check if the shipping provider can ship to a specific country.
	 *
	 * @param string $country_code The country code to check.
	 * @return bool True if the provider can ship to the country, false otherwise.
	 */
	public function can_ship_to( string $country_code ): bool {
		return in_array( $country_code, $this->get_shipping_to_countries(), true );
	}

	/**
	 * Check if the shipping provider can ship from a specific country to another.
	 *
	 * @param string $shipping_from The country code from which the shipment is sent.
	 * @param string $shipping_to The country code to which the shipment is sent.
	 * @return bool True if the provider can ship from the source to the destination, false otherwise.
	 */
	public function can_ship_from_to( string $shipping_from, string $shipping_to ): bool {
		return $this->can_ship_from( $shipping_from ) && $this->can_ship_to( $shipping_to );
	}

	/**
	 * Get the tracking URL for a given tracking number with additional parameters.
	 *
	 * @param string $tracking_number The tracking number.
	 * @param string $shipping_from The country code from which the shipment is sent.
	 * @param string $shipping_to The country code to which the shipment is sent.
	 *
	 * @return array|null The tracking URL with ambiguity score, or null if parsing fails.
	 *
	 * phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter
	 */
	public function try_parse_tracking_number( string $tracking_number, string $shipping_from, string $shipping_to ): ?array {
		return null; // Default implementation returns null, subclasses should override this method.
	}
}
PK     [1]    9  Fulfillments/Providers/PostLuxembourgShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * POST Luxembourg Shipping Provider class.
 */
class PostLuxembourgShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'post-luxembourg';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'POST Luxembourg';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/post-luxembourg.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.post.lu/en/track/' . $tracking_number;
	}
}
PK     [1]j    5  Fulfillments/Providers/CyprusPostShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * CyprusPost Shipping Provider class.
 */
class CyprusPostShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'cyprus-post';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Cyprus Post';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/cyprus-post.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.cypruspost.post/en/track/' . $tracking_number;
	}
}
PK     [1]tP    5  Fulfillments/Providers/CeskaPostaShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Česká pošta Shipping Provider class.
 */
class CeskaPostaShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'ceska-posta';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Česká pošta';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/ceska-posta.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.postaonline.cz/trackandtrace/' . $tracking_number;
	}
}
PK     [1]؝    6  Fulfillments/Providers/ParcelForceShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Parcelforce Shipping Provider class.
 */
class ParcelForceShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'parcelforce';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Parcelforce';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/parcelforce.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.parcelforce.com/track-trace/' . $tracking_number;
	}
}
PK     [1]\[    6  Fulfillments/Providers/RussianPostShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Russian Post Shipping Provider class.
 */
class RussianPostShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'russian-post';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Russian Post';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/russian-post.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.pochta.ru/tracking/' . $tracking_number;
	}
}
PK     [1]]m    5  Fulfillments/Providers/ZasilkovnaShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Zasilkovna Shipping Provider class.
 */
class ZasilkovnaShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'zasilkovna';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Zásilkovna';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/zasilkovna.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.zasilkovna.cz/track/' . $tracking_number;
	}
}
PK     [1]7    7  Fulfillments/Providers/PocztaPolskaShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Poczta Polska Shipping Provider class.
 */
class PocztaPolskaShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'poczta-polska';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Poczta Polska';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/poczta-polska.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://emonitoring.poczta-polska.pl/track/' . $tracking_number;
	}
}
PK     [1]K{+  +  A  Fulfillments/Providers/LiechtensteinischePostShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Liechtensteinische Post Shipping Provider class.
 */
class LiechtensteinischePostShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'liechtensteinische-post';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Liechtensteinische Post';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/liechtensteinische-post.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.post.li/en/track/' . $tracking_number;
	}
}
PK     [1]j+    :  Fulfillments/Providers/SpeeDeeDeliveryShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Spee-Dee Delivery Shipping Provider class.
 */
class SpeeDeeDeliveryShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'spee-dee-delivery';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Spee-Dee Delivery';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/spee-dee-delivery.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.speedeedelivery.com/track/' . $tracking_number;
	}
}
PK     [1]S    3  Fulfillments/Providers/PostNordShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * PostNord Shipping Provider class.
 */
class PostNordShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'postnord';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'PostNord';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/postnord.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.postnord.se/track-and-trace/' . $tracking_number;
	}
}
PK     [1]m+    5  Fulfillments/Providers/FanCourierShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Fan Courier Shipping Provider class.
 */
class FanCourierShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'fan-courier';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Fan Courier';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/fan-courier.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.fancourier.ro/urmarire/' . $tracking_number;
	}
}
PK     [1]5    :  Fulfillments/Providers/AmazonLogisticsShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Amazon Logistics Shipping Provider implementation.
 *
 * Handles Amazon Logistics tracking number detection and validation.
 */
class AmazonLogisticsShippingProvider extends AbstractShippingProvider {
	/**
	 * Countries where Amazon Logistics operates.
	 *
	 * @var array<string>
	 */
	private array $operating_countries = array( 'US', 'CA', 'GB', 'DE', 'FR', 'BE', 'NL', 'IT', 'IN', 'MX', 'JP', 'AU', 'ES', 'CN', 'HK', 'SG', 'GG', 'JE', 'IM', 'GI', 'AT', 'CH', 'PL', 'SE', 'DK', 'NO', 'FI', 'IE', 'PT', 'CZ', 'HU', 'RO', 'BG', 'HR', 'SK', 'SI', 'EE', 'LV', 'LT', 'CY', 'MT', 'LU', 'GR', 'BR', 'TR', 'AE', 'SA', 'EG', 'KW', 'IL', 'ZA', 'KR', 'TW', 'TH', 'MY', 'ID', 'PH', 'VN', 'NZ' );

	/**
	 * Gets the unique provider key.
	 *
	 * @return string The provider key 'amazon-logistics'.
	 */
	public function get_key(): string {
		return 'amazon-logistics';
	}

	/**
	 * Gets the display name of the provider.
	 *
	 * @return string The provider name 'Amazon Logistics'.
	 */
	public function get_name(): string {
		return 'Amazon Logistics';
	}

	/**
	 * Gets the path to the provider's icon.
	 *
	 * @return string URL to the Amazon Logistics logo image.
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/amazon-logistics.png';
	}

	/**
	 * Gets the list of origin countries supported by Amazon Logistics.
	 *
	 * @return array<string> Array of country codes.
	 */
	public function get_shipping_from_countries(): array {
		return $this->operating_countries;
	}

	/**
	 * Gets the list of destination countries supported by Amazon Logistics.
	 *
	 * @return array<string> Array of country codes.
	 */
	public function get_shipping_to_countries(): array {
		return $this->operating_countries;
	}

	/**
	 * Checks if Amazon Logistics can ship between two countries.
	 *
	 * @param string $shipping_from Origin country code.
	 * @param string $shipping_to Destination country code.
	 * @return bool True if shipping route is supported.
	 */
	public function can_ship_from_to( string $shipping_from, string $shipping_to ): bool {
		return in_array( $shipping_from, $this->operating_countries, true ) &&
			in_array( $shipping_to, $this->operating_countries, true );
	}

	/**
	 * Generates the tracking URL for an Amazon Logistics tracking number.
	 *
	 * @param string $tracking_number The tracking number to generate URL for.
	 * @return string The complete tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.amazon.com/progress-tracker/package/ref=ppx_yo_dt_b_track_package_o0?_=' .
			strtoupper( rawurlencode( $tracking_number ) );
	}

	/**
	 * Validates and parses an Amazon Logistics tracking number.
	 *
	 * @param string $tracking_number The tracking number to validate.
	 * @param string $shipping_from Origin country code.
	 * @param string $shipping_to Destination country code.
	 * @return array|null Array with tracking URL and score, or null if invalid.
	 */
	public function try_parse_tracking_number(
		string $tracking_number,
		string $shipping_from,
		string $shipping_to
	): ?array {
		if ( empty( $tracking_number ) || ! $this->can_ship_from_to( $shipping_from, $shipping_to ) ) {
			return null;
		}

		$tracking_number = strtoupper( preg_replace( '/\s+/', '', $tracking_number ) );

		// Amazon Logistics tracking number patterns with region/service differentiation.
		$patterns = array(
			// North America patterns.
			'/^TBA\d{12}$/'       => fn() => 'US' === $shipping_from ? 100 : 95, // US standard format.
			'/^TBC\d{12}$/'       => fn() => 'CA' === $shipping_from ? 100 : 90, // Canada standard format.
			'/^TBM\d{12}$/'       => fn() => 'MX' === $shipping_from ? 100 : 85, // Mexico standard format.

			// European patterns.
			'/^CC\d{12}$/'        => fn() => in_array( $shipping_from, array( 'FR', 'BE', 'NL', 'DE' ), true ) ? 95 : 80, // Continental Europe.
			'/^GBA\d{12}$/'       => fn() => 'GB' === $shipping_from ? 100 : 85, // United Kingdom.
			'/^UK\d{10}$/'        => fn() => 'GB' === $shipping_from ? 100 : 85, // United Kingdom.
			'/^W[A-Z]\d{9}GB$/'   => fn() => 'GB' === $shipping_from ? 99 : 85, // Amazon UK specific pattern.
			'/^[A-Z]{2}\d{9}GB$/' => fn() => 'GB' === $shipping_from ? 92 : 75, // United Kingdom.
			'/^AM\d{12}$/'        => fn() => in_array( $shipping_from, array( 'DE', 'FR', 'IT', 'ES' ), true ) ? 95 : 80, // Amazon Europe.
			'/^D\d{13}$/'         => fn() => 'DE' === $shipping_from ? 95 : 75, // Germany specific.

			// Asia-Pacific patterns.
			'/^RB\d{12}$/'        => fn() => in_array( $shipping_from, array( 'CN', 'HK' ), true ) ? 95 : 75, // China/Hong Kong.
			'/^ZZ\d{12}$/'        => fn() => 'AU' === $shipping_from ? 100 : 80, // Australia.
			'/^ZX\d{12}$/'        => fn() => 'IN' === $shipping_from ? 100 : 85, // India.
			'/^JP\d{12}$/'        => fn() => 'JP' === $shipping_from ? 100 : 85, // Japan.
			'/^SG\d{12}$/'        => fn() => 'SG' === $shipping_from ? 100 : 85, // Singapore.

			// Amazon Fresh/Whole Foods.
			'/^AF\d{12}$/'        => fn() => 'US' === $shipping_from ? 98 : 80, // Amazon Fresh US.
			'/^WF\d{12}$/'        => fn() => 'US' === $shipping_from ? 98 : 80, // Whole Foods US.

			// Amazon Business.
			'/^AB\d{12}$/'        => fn() => in_array( $shipping_from, array( 'US', 'GB', 'DE', 'FR' ), true ) ? 95 : 80, // Amazon Business.

			// Legacy and alternative formats.
			'/^TB[A-Z]\d{11}$/'   => fn() => in_array( $shipping_from, array( 'US', 'CA', 'MX' ), true ) ? 90 : 70, // Variable third character.
			'/^AZ\d{12}$/'        => fn() => in_array( $shipping_from, array( 'US', 'GB', 'DE' ), true ) ? 88 : 75, // Alternative format.

			// Amazon Pantry/Subscribe & Save.
			'/^AP\d{12}$/'        => fn() => 'US' === $shipping_from ? 90 : 75, // Pantry US.
			'/^SS\d{12}$/'        => fn() => 'US' === $shipping_from ? 90 : 75, // Subscribe & Save US.

			// Fallback: 15-20 character Amazon codes (future-proof, low confidence).
			'/^[A-Z0-9]{15,20}$/' => fn() => 60,
		);

		foreach ( $patterns as $pattern => $score_callback ) {
			if ( preg_match( $pattern, $tracking_number ) ) {
				return array(
					'url'             => $this->get_tracking_url( $tracking_number ),
					'ambiguity_score' => $score_callback(),
				);
			}
		}

		return null;
	}
}
PK     [1]N=    7  Fulfillments/Providers/UrgentCargusShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Urgent Cargus Shipping Provider class.
 */
class UrgentCargusShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'urgent-cargus';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Urgent Cargus';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/urgent-cargus.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.urgentcargus.ro/urmarire/' . $tracking_number;
	}
}
PK     [1]j    7  Fulfillments/Providers/MondialRelayShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Mondial Relay Shipping Provider class.
 */
class MondialRelayShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'mondial-relay';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Mondial Relay';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/mondial-relay.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.mondialrelay.fr/suivi-colis/' . $tracking_number;
	}
}
PK     [1]GXhZ    5  Fulfillments/Providers/ChronopostShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Chronopost Shipping Provider class.
 */
class ChronopostShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'chronopost';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Chronopost';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/chronopost.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.chronopost.fr/en/track/' . $tracking_number;
	}
}
PK     [1]9    2  Fulfillments/Providers/CorreosShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Correos Shipping Provider class.
 */
class CorreosShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'correos';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Correos';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/correos.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.correos.es/ss/Satellite/site/pagina-tracking/info?idioma=en_GB&numeroEnvio=' . $tracking_number;
	}
}
PK     [1]~    5  Fulfillments/Providers/ACSCourierShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * ACSCourier Shipping Provider class.
 */
class ACSCourierShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'acs-courier';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'ACS Courier';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/acs-courier.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.acscourier.com/track?tracking_number=' . $tracking_number;
	}
}
PK     [1]LH    .  Fulfillments/Providers/MRWShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * MRW Shipping Provider class.
 */
class MRWShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'mrw';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'MRW';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/mrw.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.mrw.es/seguimiento/' . $tracking_number;
	}
}
PK     [1]h%  %  9  Fulfillments/Providers/BulgarianPostsShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * BulgarianPosts Shipping Provider class.
 */
class BulgarianPostsShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'bulgarian-posts';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Bulgarian Posts';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/bulgarian-posts.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.bulgarianposts.bg/en/track-and-trace?trackingNumber=' . $tracking_number;
	}
}
PK     [1]ƻ    6  Fulfillments/Providers/PostaRomanaShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Posta Romana Shipping Provider class.
 */
class PostaRomanaShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'posta-romana';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Poșta Română';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/posta-romana.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.posta-romana.ro/urmărire-colet/' . $tracking_number;
	}
}
PK     [1]8Y    3  Fulfillments/Providers/AzerpostShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Azerpost Shipping Provider class.
 */
class AzerpostShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'azerpost';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Azerpost';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/azerpost.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.azerpost.az/track/' . $tracking_number;
	}
}
PK     [1]:/
    1  Fulfillments/Providers/AnPostShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * AnPost Shipping Provider class.
 */
class AnPostShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'an-post';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'An Post';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/an-post.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.anpost.com/Track/Track?item=' . $tracking_number;
	}
}
PK     [1]vY    4  Fulfillments/Providers/PurolatorShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Purolator Shipping Provider class.
 */
class PurolatorShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'purolator';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Purolator';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/purolator.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.purolator.com/en/shipping/tracking/' . $tracking_number;
	}
}
PK     [1]V4    0  Fulfillments/Providers/FedExShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

use Automattic\WooCommerce\Internal\Fulfillments\FulfillmentUtils;

/**
 * FedEx Shipping Provider implementation.
 *
 * Handles FedEx tracking number detection and validation for all FedEx services.
 */
class FedExShippingProvider extends AbstractShippingProvider {
	/**
	 * List of countries where FedEx has significant operations.
	 *
	 * @var array<string>
	 */
	private array $supported_countries = array( 'US', 'CA', 'GB', 'DE', 'FR', 'AU', 'JP', 'MX', 'CN', 'IN', 'IT', 'ES', 'NL', 'BE', 'CH', 'AT', 'BR', 'SG' );

	/**
	 * Gets the unique provider key.
	 *
	 * @return string The provider key 'fedex'.
	 */
	public function get_key(): string {
		return 'fedex';
	}

	/**
	 * Gets the display name of the provider.
	 *
	 * @return string The provider name 'FedEx'.
	 */
	public function get_name(): string {
		return 'FedEx';
	}

	/**
	 * Gets the path to the provider's icon.
	 *
	 * @return string URL to the FedEx logo image.
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/fedex.png';
	}

	/**
	 * Generates the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number to generate URL for.
	 * @return string The complete tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.fedex.com/fedextrack/?tracknumbers=' . rawurlencode( $tracking_number );
	}

	/**
	 * Gets the list of origin countries supported by FedEx.
	 *
	 * @return array<string> Array of country codes.
	 */
	public function get_shipping_from_countries(): array {
		return $this->supported_countries;
	}

	/**
	 * Gets the list of destination countries supported by FedEx.
	 *
	 * @return array<string> Array of country codes.
	 */
	public function get_shipping_to_countries(): array {
		return $this->supported_countries;
	}

	/**
	 * Checks if FedEx can ship between two countries.
	 *
	 * @param string $shipping_from Origin country code.
	 * @param string $shipping_to Destination country code.
	 * @return bool True if shipping route is supported.
	 */
	public function can_ship_from_to( string $shipping_from, string $shipping_to ): bool {
		return in_array( $shipping_from, $this->supported_countries, true ) &&
			in_array( $shipping_to, $this->supported_countries, true );
	}

	/**
	 * Validates and parses a FedEx tracking number.
	 *
	 * @param string $tracking_number The tracking number to validate.
	 * @param string $shipping_from Origin country code.
	 * @param string $shipping_to Destination country code.
	 * @return array|null Array with tracking URL and score, or null if invalid.
	 */
	public function try_parse_tracking_number( string $tracking_number, string $shipping_from, string $shipping_to ): ?array {
		if ( empty( $tracking_number ) || ! $this->can_ship_from_to( $shipping_from, $shipping_to ) ) {
			return null;
		}

		$tracking_number  = strtoupper( preg_replace( '/\s+/', '', $tracking_number ) ); // Remove spaces and uppercase for consistency.
		$is_north_america = in_array( $shipping_from, array( 'US', 'CA' ), true ); // North America flag for scoring.
		$is_us_domestic   = 'US' === $shipping_from && 'US' === $shipping_to; // US domestic flag for scoring.

		// FedEx tracking number patterns with enhanced validation and comments.
		$patterns = array(
			// FedEx Door Tag: DT + 12 digits (US/CA only).
			'/^DT\d{12}$/'       => $is_north_america ? 90 : 0,

			// FedEx Custom Critical: 0 or 1 followed by 13-23 digits (very rare, highest confidence).
			'/^0[01]\d{13,23}$/' => 98,

			// FedEx SmartPost: 023 + 17 digits (US only, SmartPost).
			'/^023\d{17}$/'      => 97,

			// FedEx SmartPost: 58 + 17-19 digits (older SmartPost).
			'/^58\d{17,19}$/'    => 96,

			// FedEx Express: 12 digits (most common, with check digit validation).
			'/^\d{12}$/'         => function () use ( $tracking_number, $is_north_america, $is_us_domestic ) {
				if ( FulfillmentUtils::validate_fedex_check_digit( $tracking_number ) ) {
					return $is_north_america || $is_us_domestic ? 98 : 85; // High confidence if check digit valid.
				}
				return $is_north_america ? ( $is_us_domestic ? 98 : 85 ) : 70; // Lower if check digit invalid.
			},

			// FedEx Express: 15 digits (less common, with check digit validation).
			'/^\d{15}$/'         => function () use ( $tracking_number, $is_north_america ) {
				if ( FulfillmentUtils::validate_fedex_check_digit( $tracking_number ) ) {
					return $is_north_america ? 96 : 80; // High confidence if check digit valid.
				}
				return $is_north_america ? 80 : 65; // Lower if check digit invalid.
			},

			// FedEx Express: 14 digits (with check digit validation).
			'/^\d{14}$/'         => function () use ( $tracking_number, $is_north_america ) {
				if ( FulfillmentUtils::validate_fedex_check_digit( $tracking_number ) ) {
					return $is_north_america ? 95 : 78; // High confidence if check digit valid.
				}
				return $is_north_america ? 78 : 60; // Lower if check digit invalid.
			},

			// FedEx Express: 34 digits (rare, international bulk shipments).
			'/^\d{34}$/'         => 90,

			// FedEx Ground: 96 + 18-20 digits (US/CA only).
			'/^96\d{18,20}$/'    => $is_north_america ? 95 : 60,

			// FedEx Ground: 7 + 11-20 digits (US/CA only, legacy).
			'/^7\d{11,20}$/'     => $is_north_america ? 90 : 75,

			// FedEx Freight: 97 + 13-23 digits (Freight/LTL).
			'/^97\d{13,23}$/'    => 93,

			// FedEx Express International: 3 + 10-14 digits (Europe/Asia).
			'/^3\d{10,14}$/'     => 92,

			// FedEx International Priority: 8 + 8-14 digits (Europe/Asia).
			'/^8\d{8,14}$/'      => function () use ( $shipping_from ) {
				return in_array( $shipping_from, array( 'GB', 'DE', 'FR', 'IT', 'ES', 'NL' ), true ) ? 93 : 75;
			},

			// FedEx Express Next Flight Out: NFO + 10-15 digits.
			'/^NFO\d{10,15}$/'   => 92,

			// FedEx SameDay: SD + 10-15 digits.
			'/^SD\d{10,15}$/'    => 90,

			// Fallback: 20 digit numeric (used by some international and legacy services).
			'/^\d{20}$/'         => 70,

			// Fallback: 22 digit numeric (rare, legacy).
			'/^\d{22}$/'         => 65,
		);

		foreach ( $patterns as $pattern => $base_score ) {
			if ( preg_match( $pattern, $tracking_number ) ) {
				$score = is_callable( $base_score ) ? $base_score() : $base_score;
				if ( $score > 0 ) {
					return array(
						'url'             => $this->get_tracking_url( $tracking_number ),
						'ambiguity_score' => $score,
					);
				}
			}
		}

		return null; // No matching pattern found.
	}
}
PK     [1]M	    7  Fulfillments/Providers/BartoliniBRTShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Bartolini (BRT) Shipping Provider class.
 */
class BartoliniBRTShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'bartolini-brt';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Bartolini (BRT)';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/bartolini-brt.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.brt.it/track?trackingNumber=' . $tracking_number;
	}
}
PK     [1]|    /  Fulfillments/Providers/CDEKShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * CDEK Shipping Provider class.
 */
class CDEKShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'cdek';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'CDEK';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/cdek.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.cdek.ru/track.html?trackingNumber=' . $tracking_number;
	}
}
PK     [1]!4    ;  Fulfillments/Providers/PostenNorgeBringShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Posten Norge / Bring Shipping Provider class.
 */
class PostenNorgeBringShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'posten-norge-bring';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Posten Norge / Bring';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/posten-norge-bring.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.posten.no/sporing/' . $tracking_number;
	}
}
PK     [1]5'_    <  Fulfillments/Providers/GenikiTaxydromikiShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Geniki Taxydromiki Shipping Provider class.
 */
class GenikiTaxydromikiShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'geniki-taxydromiki';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Geniki Taxydromiki';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/geniki-taxydromiki.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.taxydromiki.com.gr/track/' . $tracking_number;
	}
}
PK     [1]M2PӨ    .  Fulfillments/Providers/UPSShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

use Automattic\WooCommerce\Internal\Fulfillments\FulfillmentUtils;

/**
 * UPS Shipping Provider class.
 */
class UPSShippingProvider extends AbstractShippingProvider {
	/**
	 * Countries that support international UPS shipping.
	 *
	 * @var array
	 */
	private array $international_shipping_countries = array( 'AF', 'AX', 'AL', 'DZ', 'AS', 'AD', 'AO', 'AI', 'AQ', 'AG', 'AM', 'AW', 'AU', 'AT', 'AZ', 'BS', 'BH', 'BD', 'BB', 'BY', 'BE', 'BZ', 'BJ', 'BM', 'BT', 'BO', 'BQ', 'BA', 'BW', 'BV', 'BR', 'IO', 'BN', 'BG', 'BF', 'BI', 'CV', 'KH', 'CM', 'CA', 'KY', 'CF', 'TD', 'CL', 'CN', 'CX', 'CC', 'CO', 'KM', 'CD', 'CG', 'CK', 'CR', 'CI', 'HR', 'CU', 'CW', 'CY', 'CZ', 'DK', 'DJ', 'DM', 'DO', 'EC', 'EG', 'SV', 'GQ', 'ER', 'EE', 'SZ', 'ET', 'FK', 'FO', 'FJ', 'FI', 'FR', 'GF', 'PF', 'TF', 'GA', 'GM', 'GE', 'DE', 'GH', 'GI', 'GR', 'GL', 'GD', 'GP', 'GU', 'GT', 'GG', 'GN', 'GW', 'GY', 'HT', 'HM', 'VA', 'HN', 'HK', 'HU', 'IS', 'IN', 'ID', 'IR', 'IQ', 'IE', 'IM', 'IL', 'IT', 'JM', 'JP', 'JE', 'JO', 'KZ', 'KE', 'KI', 'KP', 'KR', 'KW', 'KG', 'LA', 'LV', 'LB', 'LS', 'LR', 'LY', 'LI', 'LT', 'LU', 'MO', 'MG', 'MW', 'MY', 'MV', 'ML', 'MT', 'MH', 'MQ', 'MR', 'MU', 'YT', 'MX', 'FM', 'MD', 'MC', 'MN', 'ME', 'MS', 'MA', 'MZ', 'MM', 'NA', 'NR', 'NP', 'NL', 'NC', 'NZ', 'NI', 'NE', 'NG', 'NU', 'NF', 'MK', 'MP', 'NO', 'OM', 'PK', 'PW', 'PS', 'PA', 'PG', 'PY', 'PE', 'PH', 'PN', 'PL', 'PT', 'PR', 'QA', 'RE', 'RO', 'RU', 'RW', 'BL', 'SH', 'KN', 'LC', 'MF', 'PM', 'VC', 'WS', 'SM', 'ST', 'SA', 'SN', 'RS', 'SC', 'SL', 'SG', 'SX', 'SK', 'SI', 'SB', 'SO', 'ZA', 'GS', 'SS', 'ES', 'LK', 'SD', 'SR', 'SJ', 'SE', 'CH', 'SY', 'TW', 'TJ', 'TZ', 'TH', 'TL', 'TG', 'TK', 'TO', 'TT', 'TN', 'TR', 'TM', 'TC', 'TV', 'UG', 'UA', 'AE', 'GB', 'US', 'UM', 'UY', 'UZ', 'VU', 'VE', 'VN', 'VG', 'VI', 'WF', 'EH', 'YE', 'ZM', 'ZW' );

	/**
	 * Countries that support domestic UPS shipping.
	 *
	 * @var array
	 */
	private array $domestic_shipping_countries = array( 'US', 'CA', 'MX', 'BR', 'AR', 'CL', 'CO', 'PE', 'CR', 'PR', 'DE', 'GB', 'FR', 'IT', 'ES', 'NL', 'BE', 'PL', 'SE', 'DK', 'AT', 'CH', 'PT', 'IE', 'CZ', 'HU', 'FI', 'NO', 'CN', 'HK', 'IN', 'JP', 'KR', 'SG', 'MY', 'TH', 'VN', 'PH', 'AU', 'NZ', 'AE', 'SA', 'ZA', 'TR', 'IL', 'KE', 'NG' );

	/**
	 * Countries that support UPS domestic shipping but use international tracking formats.
	 *
	 * @var array
	 */
	private array $domestic_but_international_tracking = array( 'IN', 'ZA', 'VN', 'NG', 'PR', 'HK', 'MO', 'CN', 'BR', 'KE' );

	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'ups';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'UPS';
	}

	/**
	 * Get the path of the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/ups.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.ups.com/track?tracknum=' . rawurlencode( $tracking_number );
	}

	/**
	 * Get the countries from which this provider can ship.
	 *
	 * @return array An array of country codes.
	 */
	public function get_shipping_from_countries(): array {
		return $this->international_shipping_countries;
	}

	/**
	 * Get the countries to which this provider can ship.
	 *
	 * @return array An array of country codes.
	 */
	public function get_shipping_to_countries(): array {
		return $this->international_shipping_countries;
	}

	/**
	 * Check if this provider can ship from a specific country.
	 *
	 * @param string $shipping_from The country code from which the shipment is sent.
	 * @param string $shipping_to The country code to which the shipment is sent.
	 *
	 * @return bool True if this provider can ship from the country, false otherwise.
	 */
	public function can_ship_from_to( string $shipping_from, string $shipping_to ): bool {
		if ( $shipping_from === $shipping_to ) {
			return in_array( $shipping_from, $this->domestic_shipping_countries, true ) ||
				in_array( $shipping_from, $this->domestic_but_international_tracking, true );
		} else {
			return in_array( $shipping_from, $this->international_shipping_countries, true ) &&
				in_array( $shipping_to, $this->international_shipping_countries, true );
		}
	}

	/**
	 * Try to parse the tracking number with additional parameters.
	 *
	 * @param string $tracking_number The tracking number.
	 * @param string $shipping_from The country code from which the shipment is sent.
	 * @param string $shipping_to The country code to which the shipment is sent.
	 *
	 * @return array|null The tracking URL with ambiguity score, or null if parsing fails.
	 */
	public function try_parse_tracking_number( string $tracking_number, string $shipping_from, string $shipping_to ): ?array {
		if ( empty( $tracking_number ) || empty( $shipping_from ) || empty( $shipping_to ) || ! $this->can_ship_from_to( $shipping_from, $shipping_to ) ) {
			return null;
		}

		$tracking_number      = strtoupper( $tracking_number );
		$is_domestic_shipping = $shipping_from === $shipping_to;

		// UPS tracking number patterns (ordered by confidence).
		$patterns = array(
			// 1Z format (standard UPS) - 18 chars, check digit validation.
			'/^1Z[0-9A-Z]{16}$/'        => function () use ( $tracking_number ) {
				return FulfillmentUtils::validate_ups_1z_check_digit( $tracking_number ) ? 100 : 95;
			},

			// Numeric only: 12 digits (common for UPS Air/Ground, with mod10 check digit).
			'/^\d{12}$/'                => function () use ( $tracking_number ) {
				return FulfillmentUtils::validate_mod10_check_digit( $tracking_number ) ? 90 : 80;
			},

			// Numeric only: 9 or 10 digits (legacy/freight).
			'/^\d{10}$/'                => 75,
			'/^\d{9}$/'                 => 70,

			// T, H, or V prefix + 10 digits (special international/freight).
			'/^[THV]\d{10}$/'           => 85,

			// UPS InfoNotice (J + 10 digits).
			'/^J\d{10}$/'               => 80,

			// UPS Mail Innovations Parcel ID (MI + 6 digits + up to 22 alphanum).
			'/^MI\d{6}[A-Z0-9]{6,22}$/' => 80,

			// USPS Delivery Confirmation (Mail Innovations, 22–34 digits).
			'/^9\d{21,33}$/'            => function () use ( $shipping_from ) {
				return in_array( $shipping_from, array( 'US', 'CA' ), true ) ? 85 : 70;
			},

			// UPU S10 format (international, e.g. 'AA123456789CC').
			'/^[A-Z]{2}\d{9}[A-Z]{2}$/' => function () use ( $shipping_from ) {
				return in_array( $shipping_from, $this->domestic_but_international_tracking, true ) ? 80 : 65;
			},

			// Long mail format (22 digits).
			'/^\d{22}$/'                => 60,
		);

		$match           = false;
		$ambiguity_score = 0;

		foreach ( $patterns as $pattern => $score ) {
			if ( preg_match( $pattern, $tracking_number ) ) {
				$match           = true;
				$ambiguity_score = is_callable( $score ) ? $score() : $score;
				break;
			}
		}

		// Boost score for domestic-but-international-tracking countries.
		if ( $match && $is_domestic_shipping && in_array( $shipping_from, $this->domestic_but_international_tracking, true ) ) {
			$ambiguity_score += 5;
		}

		return $match ? array(
			'url'             => $this->get_tracking_url( $tracking_number ),
			'ambiguity_score' => $ambiguity_score,
		) : null;
	}
}
PK     [1]-    4  Fulfillments/Providers/BelpochtaShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Belpochta Shipping Provider class.
 */
class BelpochtaShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'belpochta';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Belpochta';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/belpochta.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.belpost.by/track/' . $tracking_number;
	}
}
PK     [1]{C    2  Fulfillments/Providers/EimskipShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Eimskip Shipping Provider class.
 */
class EimskipShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'eimskip';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Eimskip';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/eimskip.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.eimskip.is/track/' . $tracking_number;
	}
}
PK     [1]    0  Fulfillments/Providers/EcontShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Econt Shipping Provider class.
 */
class EcontShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'econt';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Econt';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/econt.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.econt.com/en/track/' . $tracking_number;
	}
}
PK     [1]_    .  Fulfillments/Providers/CTTShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * CTT Shipping Provider class.
 */
class CTTShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'ctt';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'CTT';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/ctt.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.ctt.pt/feapl_2/app/open/objectTrackingSearch.do?objectCode=' . $tracking_number;
	}
}
PK     [1])    1  Fulfillments/Providers/OmnivaShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Omniva Shipping Provider class.
 */
class OmnivaShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'omniva';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Omniva';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/omniva.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.omniva.ee/track/' . $tracking_number;
	}
}
PK     [1]JrT"  T"  5  Fulfillments/Providers/CanadaPostShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

use Automattic\WooCommerce\Internal\Fulfillments\FulfillmentUtils;

/**
 * Canada Post Shipping Provider class.
 *
 * Provides Canada Post tracking number validation, supported countries, and tracking URL generation.
 */
class CanadaPostShippingProvider extends AbstractShippingProvider {

	/**
	 * Canada Post tracking number patterns with service differentiation.
	 *
	 * @var array<string, array{patterns: array<int, string>, confidence: int}>
	 */
	private const TRACKING_PATTERNS = array(
		'CA' => array( // Canada.
			'patterns'   => array(
				// UPU S10 international format (outbound).
				'/^[A-Z]{2}\d{9}CA$/',         // Standard format: XX#########CA.
				// UPU S10 international (inbound/other countries, fallback).
				'/^[A-Z]{2}\d{9}[A-Z]{2}$/',   // Any S10/UPU code.
				// Domestic numeric formats.
				'/^\d{16}$/',                  // 16-digit domestic tracking.
				'/^\d{15}$/',                  // 15-digit legacy/partner/returns.
				'/^\d{14}$/',                  // 14-digit legacy/partner/returns.
				'/^\d{13}$/',                  // 13-digit domestic (most common).
				'/^\d{12}$/',                  // 12-digit domestic.
				'/^\d{10}$/',                  // 10-digit legacy.
				'/^\d{9}$/',                   // 9-digit legacy.
				'/^\d{8}$/',                   // 8-digit calling card/legacy.
				// Service-specific patterns.
				'/^XP\d{9}CA$/',               // Xpresspost International.
				'/^EX\d{9}CA$/',               // Express International.
				'/^PR\d{9}CA$/',               // Priority.
				'/^RG\d{9}CA$/',               // Regular (deprecated, legacy).
				'/^RM\d{9}CA$/',               // Registered Mail.
				'/^CM\d{9}CA$/',               // Certified Mail.
				'/^[A-Z]{2}\d{7}[A-Z]{2}$/',   // International format: XX#######XX.
				'/^[A-Z]{1}\d{9}[A-Z]{1}$/',   // Domestic formats: X#########X.
				'/^FD\d{10,12}$/',             // FlexDelivery.
				'/^PO\d{10,12}$/',             // Post Office Box service.
				'/^CP\d{10,14}$/',             // Canada Post business.
				'/^SM\d{10,14}$/',             // Small packet.
				// Legacy and alternative formats.
				'/^[0-9]{13}[A-Z]{1}$/',       // 13 digits + 1 letter.
			),
			'confidence' => 92,
		),
	);

	/**
	 * Get the unique key for this shipping provider.
	 *
	 * @return string Unique key.
	 */
	public function get_key(): string {
		return 'canada-post';
	}

	/**
	 * Get the name of this shipping provider.
	 *
	 * @return string Name of the shipping provider.
	 */
	public function get_name(): string {
		return 'Canada Post';
	}

	/**
	 * Get the icon URL for this shipping provider.
	 *
	 * @return string URL of the shipping provider icon.
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/canada-post.png';
	}

	/**
	 * Get the countries this shipping provider can ship from.
	 *
	 * @return array List of country codes.
	 */
	public function get_shipping_from_countries(): array {
		return array_keys( self::TRACKING_PATTERNS );
	}

	/**
	 * Get the countries this shipping provider can ship to.
	 *
	 * Canada Post ships internationally, so we return a comprehensive list.
	 *
	 * @return array List of country codes.
	 */
	public function get_shipping_to_countries(): array {
		return array( 'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AO', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AW', 'AX', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BQ', 'BR', 'BS', 'BT', 'BV', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL', 'CM', 'CN', 'CO', 'CR', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ', 'EC', 'EE', 'EG', 'EH', 'ER', 'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', 'FO', 'FR', 'GA', 'GB', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GL', 'GM', 'GN', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GW', 'GY', 'HK', 'HM', 'HN', 'HR', 'HT', 'HU', 'ID', 'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR', 'IS', 'IT', 'JE', 'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI', 'KM', 'KN', 'KP', 'KR', 'KW', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LI', 'LK', 'LR', 'LS', 'LT', 'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO', 'NP', 'NR', 'NU', 'NZ', 'OM', 'PA', 'PE', 'PF', 'PG', 'PH', 'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY', 'QA', 'RE', 'RO', 'RS', 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SR', 'SS', 'ST', 'SV', 'SX', 'SY', 'SZ', 'TC', 'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL', 'TM', 'TN', 'TO', 'TR', 'TT', 'TV', 'TW', 'TZ', 'UA', 'UG', 'UM', 'US', 'UY', 'UZ', 'VA', 'VC', 'VE', 'VG', 'VI', 'VN', 'VU', 'WF', 'WS', 'YE', 'YT', 'ZA', 'ZM', 'ZW' );
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number to generate the URL for.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.canadapost-postescanada.ca/track-reperage/en#/search?searchFor=' . rawurlencode( $tracking_number );
	}

	/**
	 * Validate tracking number against country-specific patterns.
	 *
	 * @param string $tracking_number The tracking number to validate.
	 * @param string $country_code The country code for the shipment.
	 * @return bool True if valid, false otherwise.
	 */
	private function validate_country_pattern( string $tracking_number, string $country_code ): bool {
		if ( ! isset( self::TRACKING_PATTERNS[ $country_code ] ) ) {
			return false;
		}

		foreach ( self::TRACKING_PATTERNS[ $country_code ]['patterns'] as $pattern ) {
			if ( preg_match( $pattern, $tracking_number ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Try to parse a Canada Post tracking number.
	 *
	 * @param string $tracking_number The tracking number to parse.
	 * @param string $shipping_from The country code of the shipping origin.
	 * @param string $shipping_to The country code of the shipping destination.
	 * @return array|null An array with 'url' and 'ambiguity_score' if valid, null otherwise.
	 */
	public function try_parse_tracking_number(
		string $tracking_number,
		string $shipping_from,
		string $shipping_to
	): ?array {
		if ( empty( $tracking_number ) || empty( $shipping_from ) || empty( $shipping_to ) ) {
			return null;
		}

		$normalized = strtoupper( preg_replace( '/\s+/', '', $tracking_number ) ); // Normalize input.
		if ( empty( $normalized ) ) {
			return null;
		}

		$shipping_from = strtoupper( $shipping_from );
		$shipping_to   = strtoupper( $shipping_to );

		// Check if shipping from Canada.
		if ( 'CA' !== $shipping_from ) {
			return null;
		}

		// Check country-specific patterns with enhanced validation.
		if ( $this->validate_country_pattern( $normalized, $shipping_from ) ) {
			$confidence = self::TRACKING_PATTERNS[ $shipping_from ]['confidence'];

			// Apply UPU S10 validation for international formats.
			if ( preg_match( '/^[A-Z]{2}\d{9}CA$/', $normalized ) ) {
				if ( FulfillmentUtils::check_s10_upu_format( $normalized ) ) {
					$confidence = min( 98, $confidence + 6 ); // Strong boost for valid UPU.
				}
			} elseif ( preg_match( '/^[A-Z]{2}\d{9}[A-Z]{2}$/', $normalized ) ) {
				// Apply S10/UPU fallback with lower confidence.
				if ( FulfillmentUtils::check_s10_upu_format( $normalized ) ) {
					$confidence = min( 94, $confidence + 2 ); // Lower boost for inbound S10.
				}
			}

			// Apply check digit validation for numeric formats.
			if ( preg_match( '/^\d{12,16}$/', $normalized ) ) {
				if ( FulfillmentUtils::validate_mod10_check_digit( $normalized ) ) {
					$confidence = min( 96, $confidence + 4 ); // Boost for valid check digit.
				}
			}

			// Service-specific confidence boosts.
			if ( preg_match( '/^(XP|EX|PR)\d+/', $normalized ) ) {
				$confidence = min( 96, $confidence + 4 ); // Express/Priority services.
			} elseif ( preg_match( '/^(RM|CM)\d+/', $normalized ) ) {
				$confidence = min( 95, $confidence + 3 ); // Registered/Certified.
			} elseif ( preg_match( '/^(FD|PO|CP|SM)\d+/', $normalized ) ) {
				$confidence = min( 94, $confidence + 2 ); // Special services.
			}

			// Boost confidence for domestic shipments.
			if ( 'CA' === $shipping_to ) {
				$confidence = min( 98, $confidence + 3 );
			}

			// Boost for North American destinations.
			if ( in_array( $shipping_to, array( 'US', 'MX' ), true ) ) {
				$confidence = min( 95, $confidence + 2 );
			}

			return array(
				'url'             => $this->get_tracking_url( $normalized ),
				'ambiguity_score' => $confidence,
			);
		}

		return null;
	}
}
PK     [1]s"/    2  Fulfillments/Providers/FastwayShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Fastway Shipping Provider class.
 */
class FastwayShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'fastway';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Fastway';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/fastway.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.fastway.ie/track/' . $tracking_number;
	}
}
PK     [1]h    /  Fulfillments/Providers/ELTAShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * ELTA Shipping Provider class.
 */
class ELTAShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'elta';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'ELTA';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/elta.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://elta.gr/en/track/' . $tracking_number;
	}
}
PK     [1](~0  0  .  Fulfillments/Providers/DPDShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * DPD Shipping Provider class.
 *
 * Provides DPD tracking number validation, supported countries, and tracking URL generation.
 */
class DPDShippingProvider extends AbstractShippingProvider {

	/**
	 * DPD tracking number patterns by country with service differentiation.
	 *
	 * @var array<string, array{patterns: array<int, string>, confidence: int, services?: array<string, int>}>
	 */
	private const TRACKING_PATTERNS = array(
		'DE' => array( // Germany.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^\d{12}$/',
				'/^02\d{12}$/', // DPD Classic.
				'/^05\d{12}$/', // DPD Express.
				'/^09\d{12}$/', // DPD Predict.
				'/^[A-Z]{2}\d{9}[A-Z]{2}$/', // S10/UPU international.
				'/^\d{24}$/', // 24-digit fallback.
			),
			'confidence' => 80,
			'services'   => array(
				'classic' => 80,
				'express' => 85,
				'predict' => 85,
				's10'     => 90,
			),
		),
		'GB' => array( // United Kingdom.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^[A-Z]{2}\d{9}GB$/',
				'/^03\d{12}$/', // DPD Next Day.
				'/^06\d{12}$/', // DPD Express.
				'/^1[56]\d{12}$/', // Predict/Return.
				'/^[A-Z]{2}\d{9}[A-Z]{2}$/', // S10/UPU international.
				'/^\d{24}$/', // 24-digit fallback.
			),
			'confidence' => 90,
			'services'   => array(
				'next_day' => 88,
				'express'  => 88,
				's10'      => 90,
			),
		),
		'FR' => array( // France.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^\d{12}$/',
				'/^02\d{12}$/', // DPD Relais.
				'/^04\d{12}$/', // DPD Predict.
				'/^[A-Z]{2}\d{9}[A-Z]{2}$/', // S10/UPU international.
				'/^\d{24}$/', // 24-digit fallback.
			),
			'confidence' => 78,
			'services'   => array(
				'relais'  => 82,
				'predict' => 82,
				's10'     => 90,
			),
		),
		'NL' => array( // Netherlands.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^\d{12}$/',
				'/^03\d{12}$/', // DPD Classic.
				'/^07\d{12}$/', // DPD Express.
				'/^[A-Z]{2}\d{9}[A-Z]{2}$/', // S10/UPU international.
				'/^\d{24}$/', // 24-digit fallback.
			),
			'confidence' => 78,
			'services'   => array(
				'classic' => 82,
				'express' => 85,
				's10'     => 90,
			),
		),
		'BE' => array( // Belgium.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^\d{12}$/',
				'/^03\d{12}$/', // DPD Classic.
				'/^08\d{12}$/', // DPD Express.
				'/^[A-Z]{2}\d{9}[A-Z]{2}$/', // S10/UPU international.
				'/^\d{24}$/', // 24-digit fallback.
			),
			'confidence' => 78,
			'services'   => array(
				'classic' => 82,
				'express' => 85,
				's10'     => 90,
			),
		),
		'PL' => array( // Poland.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^[A-Z]{2}\d{10}$/',
				'/^[A-Z]{2}\d{9}[A-Z]{2}$/', // S10/UPU international.
				'/^\d{24}$/', // 24-digit fallback.
			),
			'confidence' => 90,
		),
		'IE' => array( // Ireland.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^[A-Z]{2}\d{9}IE$/',
			),
			'confidence' => 85,
		),
		'AT' => array( // Austria.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^\d{12}$/',
			),
			'confidence' => 75, // Reduced: generic patterns.
		),
		'CH' => array( // Switzerland.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^[A-Z]{2}\d{9}CH$/',
			),
			'confidence' => 85,
		),
		'ES' => array( // Spain.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^[A-Z]{2}\d{10}$/',
			),
			'confidence' => 85,
		),
		'IT' => array( // Italy.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^[A-Z]{2}\d{10}$/',
			),
			'confidence' => 85,
		),
		'LU' => array( // Luxembourg.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^\d{12}$/',
			),
			'confidence' => 75, // Reduced: generic patterns.
		),
		'CZ' => array( // Czech Republic.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^[A-Z]{2}\d{10}$/',
			),
			'confidence' => 90,
		),
		'SK' => array( // Slovakia.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^[A-Z]{2}\d{10}$/',
			),
			'confidence' => 90,
		),
		'HU' => array( // Hungary.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^[A-Z]{2}\d{10}$/',
			),
			'confidence' => 90,
		),
		'SI' => array( // Slovenia.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^[A-Z]{2}\d{10}$/',
			),
			'confidence' => 80,
		),
		'HR' => array( // Croatia.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^[A-Z]{2}\d{10}$/',
			),
			'confidence' => 80,
		),
		'RO' => array( // Romania.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^[A-Z]{2}\d{10}$/',
			),
			'confidence' => 75,
		),
		'BG' => array( // Bulgaria.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^[A-Z]{2}\d{10}$/',
			),
			'confidence' => 70,
		),
		'LT' => array( // Lithuania.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^\d{12}$/',
			),
			'confidence' => 70, // Reduced: generic patterns, limited DPD presence.
		),
		'LV' => array( // Latvia.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^\d{12}$/',
			),
			'confidence' => 70, // Reduced: generic patterns, limited DPD presence.
		),
		'EE' => array( // Estonia.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^\d{12}$/',
			),
			'confidence' => 70, // Reduced: generic patterns, limited DPD presence.
		),
		'FI' => array( // Finland.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^\d{12}$/',
			),
			'confidence' => 65, // Reduced: partnership-based, not direct DPD.
		),
		'DK' => array( // Denmark.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^\d{12}$/',
			),
			'confidence' => 65, // Reduced: partnership-based, not direct DPD.
		),
		'SE' => array( // Sweden.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^\d{12}$/',
			),
			'confidence' => 65, // Reduced: partnership-based, not direct DPD.
		),
		'NO' => array( // Norway.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^\d{12}$/',
			),
			'confidence' => 60, // Reduced: limited DPD presence.
		),
		'GR' => array( // Greece.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^[A-Z]{2}\d{10}$/',
			),
			'confidence' => 85,
		),
		'PT' => array( // Portugal.
			'patterns'   => array(
				'/^\d{14}$/',
				'/^[A-Z]{2}\d{10}$/',
			),
			'confidence' => 85,
		),
	);

	/**
	 * International shipment pattern (28 digits)
	 */
	private const INTERNATIONAL_PATTERN = '/^\d{28}$/';

	/**
	 * S10/UPU international pattern.
	 */
	private const S10_PATTERN = '/^[A-Z]{2}\d{9}[A-Z]{2}$/';

	/**
	 * Get the unique key for this shipping provider.
	 *
	 * @return string Unique key.
	 */
	public function get_key(): string {
		return 'dpd';
	}

	/**
	 * Get the name of this shipping provider.
	 *
	 * @return string Name of the shipping provider.
	 */
	public function get_name(): string {
		return 'DPD';
	}

	/**
	 * Get the icon URL for this shipping provider.
	 *
	 * @return string URL of the shipping provider icon.
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/dpd.png';
	}

	/**
	 * Get the description of this shipping provider.
	 *
	 * @return array Description of the shipping provider.
	 */
	public function get_shipping_from_countries(): array {
		return array_keys( self::TRACKING_PATTERNS );
	}

	/**
	 * Get the countries this shipping provider can ship to.
	 *
	 * DPD typically ships within Europe, so we return the same countries as shipping from.
	 *
	 * @return array List of country codes.
	 */
	public function get_shipping_to_countries(): array {
		return $this->get_shipping_from_countries();
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number to generate the URL for.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.dpd.com/tracking/' . rawurlencode( $tracking_number );
	}

	/**
	 * Validate tracking number against country-specific patterns and determine service type.
	 *
	 * @param string $tracking_number The tracking number to validate.
	 * @param string $country_code The country code for the shipment.
	 * @return array|bool Array with service info if valid, false otherwise.
	 */
	private function validate_country_pattern( string $tracking_number, string $country_code ) {
		if ( ! isset( self::TRACKING_PATTERNS[ $country_code ] ) ) {
			return false;
		}

		$country_data     = self::TRACKING_PATTERNS[ $country_code ];
		$detected_service = null;
		$confidence_boost = 0;

		// Check service-specific patterns first.
		if ( isset( $country_data['services'] ) ) {
			if ( preg_match( '/^02\d{12}$/', $tracking_number ) ) {
				$detected_service = 'classic';
				$confidence_boost = $country_data['services']['classic'] ?? 0;
			} elseif ( preg_match( '/^0[34578]\d{12}$/', $tracking_number ) ) {
				$detected_service = 'express';
				$confidence_boost = $country_data['services']['express'] ?? 0;
			} elseif ( preg_match( '/^0[49]\d{12}$/', $tracking_number ) ) {
				$detected_service = 'predict';
				$confidence_boost = $country_data['services']['predict'] ?? 0;
			} elseif ( preg_match( '/^03\d{12}$/', $tracking_number ) && 'GB' === $country_code ) {
				$detected_service = 'next_day';
				$confidence_boost = $country_data['services']['next_day'] ?? 0;
			} elseif ( preg_match( '/^02\d{12}$/', $tracking_number ) && 'FR' === $country_code ) {
				$detected_service = 'relais';
				$confidence_boost = $country_data['services']['relais'] ?? 0;
			} elseif ( preg_match( self::S10_PATTERN, $tracking_number ) ) {
				$detected_service = 's10';
				$confidence_boost = $country_data['services']['s10'] ?? 90;
			}
		}

		// Check all patterns.
		foreach ( $country_data['patterns'] as $pattern ) {
			if ( preg_match( $pattern, $tracking_number ) ) {
				return array(
					'valid'            => true,
					'service'          => $detected_service,
					'confidence_boost' => $confidence_boost,
				);
			}
		}

		return false;
	}

	/**
	 * Try to parse a DPD tracking number.
	 *
	 * @param string $tracking_number The tracking number to parse.
	 * @param string $shipping_from The country code of the shipping origin.
	 * @param string $shipping_to The country code of the shipping destination.
	 * @return array|null An array with 'url' and 'ambiguity_score' if valid, null otherwise.
	 */
	public function try_parse_tracking_number(
		string $tracking_number,
		string $shipping_from,
		string $shipping_to
	): ?array {
		if ( empty( $tracking_number ) || empty( $shipping_from ) || empty( $shipping_to ) ) {
			return null;
		}

		$normalized = strtoupper( preg_replace( '/\s+/', '', $tracking_number ) );
		if ( empty( $normalized ) ) {
			return null;
		}

		$shipping_from = strtoupper( $shipping_from );
		$shipping_to   = strtoupper( $shipping_to );

		// 1. Check international 28-digit format first.
		if ( preg_match( self::INTERNATIONAL_PATTERN, $normalized ) ) {
			if ( in_array( $shipping_from, $this->get_shipping_from_countries(), true ) &&
				in_array( $shipping_to, $this->get_shipping_to_countries(), true ) ) {
				return array(
					'url'             => $this->get_tracking_url( $normalized ),
					'ambiguity_score' => 95,
				);
			}
			return null;
		}

		// 2. Check S10/UPU format (international DPD).
		if ( preg_match( self::S10_PATTERN, $normalized ) ) {
			return array(
				'url'             => $this->get_tracking_url( $normalized ),
				'ambiguity_score' => 90,
			);
		}

		// 3. Check country-specific patterns.
		$validation_result = $this->validate_country_pattern( $normalized, $shipping_from );
		if ( $validation_result && is_array( $validation_result ) ) {
			$confidence = self::TRACKING_PATTERNS[ $shipping_from ]['confidence'];

			// Apply service-specific confidence boost.
			if ( $validation_result['confidence_boost'] > 0 ) {
				$confidence = min( 95, $validation_result['confidence_boost'] );
			}

			// Boost confidence for intra-DPD shipments.
			if ( in_array( $shipping_to, $this->get_shipping_to_countries(), true ) ) {
				$confidence = min( 98, $confidence + 3 );
			}

			// Additional boost for express services.
			if ( 'express' === $validation_result['service'] ) {
				$confidence = min( 98, $confidence + 2 );
			}

			return array(
				'url'             => $this->get_tracking_url( $normalized ),
				'ambiguity_score' => $confidence,
			);
		}

		// 4. Fallback: 12–24 digit numeric.
		if ( preg_match( '/^\d{12,24}$/', $normalized ) ) {
			return array(
				'url'             => $this->get_tracking_url( $normalized ),
				'ambiguity_score' => 60,
			);
		}

		return null;
	}
}
PK     [1]&I-    9  Fulfillments/Providers/PosteSanMarinoShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Poste San Marino Shipping Provider class.
 */
class PosteSanMarinoShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'poste-san-marino';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Poste San Marino';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/poste-san-marino.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.poste.sm/it/servizi/ricerca-spedizioni/' . $tracking_number;
	}
}
PK     [1]\[  [  5  Fulfillments/Providers/EvriHermesShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Evri (Hermes) Shipping Provider class.
 *
 * Provides Evri tracking number validation, supported countries, and tracking URL generation.
 */
class EvriHermesShippingProvider extends AbstractShippingProvider {

	/**
	 * Main Evri/Hermes tracking number patterns.
	 */
	private const MAIN_PATTERNS = array(
		'/^\d{16}$/',                              // 16-digit numeric (official Evri/Hermes format).
		'/^[A-Z]{1,2}\d{14,15}$/',                 // H, E, HM, EV, HH, MH + 14-15 digits (legacy/retail).
		'/^MH\d{16}$/',                            // MH + 16 digits (Hermes Germany legacy)[3].
		'/^(?:[A-Z]\d{2}[A-Z0-9]{13}|\d{16})$/',   // Newer Evri format.
	);

	/**
	 * Calling card pattern.
	 */
	private const CALLING_CARD_PATTERN = '/^\d{8}$/'; // 8-digit calling card number[1][5].

	/**
	 * Legacy and fallback patterns.
	 */
	private const LEGACY_PATTERNS = array(
		'/^\d{13,15}$/',              // 13-15 digit numeric (rare, legacy).
	);

	/**
	 * Get the unique key for this shipping provider.
	 *
	 * @return string Unique key.
	 */
	public function get_key(): string {
		return 'evri-hermes';
	}

	/**
	 * Get the name of this shipping provider.
	 *
	 * @return string Name of the shipping provider.
	 */
	public function get_name(): string {
		return 'Evri (Hermes)';
	}

	/**
	 * Get the icon URL for this shipping provider.
	 *
	 * @return string URL of the shipping provider icon.
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/evri-hermes.png';
	}

	/**
	 * Get the countries this shipping provider can ship from.
	 *
	 * @return array List of country codes.
	 */
	public function get_shipping_from_countries(): array {
		// Evri (formerly Hermes UK) primarily operates from the UK only.
		return array( 'GB' );
	}

	/**
	 * Get the countries this shipping provider can ship to.
	 *
	 * @return array List of country codes.
	 */
	public function get_shipping_to_countries(): array {
		// Evri ships from UK to these exact destinations as listed on their website dropdown.
		// This list is based on the actual options in their destination choice select.
		return array( 'GB', 'AL', 'DZ', 'AS', 'AD', 'AO', 'AI', 'AG', 'AR', 'AM', 'AW', 'AU', 'AT', 'AZ', 'PT', 'BS', 'BH', 'ES', 'BD', 'BB', 'BE', 'BZ', 'BJ', 'BM', 'BT', 'BO', 'BQ', 'BA', 'BA', 'BW', 'BR', 'VG', 'BN', 'BG', 'BF', 'BI', 'KH', 'CM', 'CA', 'ES', 'CV', 'KY', 'CF', 'TD', 'JE', 'CL', 'CN', 'CO', 'KM', 'CG', 'CK', 'CR', 'GR', 'HR', 'CW', 'CY', 'CZ', 'CD', 'DK', 'DJ', 'DM', 'DO', 'TL', 'EC', 'EG', 'SV', 'GQ', 'ER', 'EE', 'SZ', 'ET', 'FK', 'FO', 'FJ', 'FI', 'FR', 'GF', 'PF', 'GA', 'GM', 'GE', 'DE', 'GI', 'GR', 'GL', 'GD', 'GP', 'GU', 'GT', 'GG', 'GN', 'GW', 'GY', 'HT', 'HN', 'HK', 'HU', 'ES', 'IS', 'IN', 'ID', 'IQ', 'IE', 'IL', 'IT', 'JM', 'JP', 'JE', 'JO', 'KZ', 'KE', 'KI', 'KW', 'LA', 'LV', 'LB', 'LS', 'LR', 'LY', 'LI', 'LT', 'LU', 'MO', 'MG', 'ES', 'MW', 'MY', 'MV', 'ML', 'MT', 'MH', 'MQ', 'MR', 'MU', 'YT', 'ES', 'MX', 'FM', 'MD', 'MC', 'MN', 'ME', 'MS', 'MA', 'MZ', 'NA', 'NR', 'NP', 'NL', 'AN', 'NC', 'NZ', 'NI', 'NE', 'MK', 'GB', 'NO', 'OM', 'PK', 'PW', 'PS', 'PA', 'PG', 'PY', 'PE', 'PH', 'PL', 'PT', 'PR', 'QA', 'RE', 'RO', 'RW', 'MP', 'WS', 'SM', 'SA', 'SN', 'RS', 'SC', 'SL', 'SG', 'SK', 'SI', 'SB', 'KR', 'ES', 'LK', 'BL', 'BQ', 'KN', 'LC', 'SX', 'VC', 'SR', 'SE', 'CH', 'TW', 'TJ', 'TZ', 'TH', 'TG', 'TO', 'TT', 'TN', 'TR', 'TM', 'TC', 'TV', 'UG', 'GB', 'UA', 'AE', 'UY', 'US', 'UZ', 'VU', 'VA', 'VN', 'VI', 'WF', 'YE', 'ZM', 'ZW' );
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number to generate the URL for.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.evri.com/track/' . rawurlencode( $tracking_number );
	}

	/**
	 * Try to parse an Evri tracking number.
	 *
	 * @param string $tracking_number The tracking number to parse.
	 * @param string $shipping_from The country code of the shipping origin.
	 * @param string $shipping_to The country code of the shipping destination.
	 * @return array|null An array with 'url' and 'ambiguity_score' if valid, null otherwise.
	 */
	public function try_parse_tracking_number(
		string $tracking_number,
		string $shipping_from,
		string $shipping_to
	): ?array {
		if ( empty( $tracking_number ) || empty( $shipping_from ) || empty( $shipping_to ) ) {
			return null;
		}

		// Check if this provider can handle this shipping route.
		if ( ! $this->can_ship_from_to( $shipping_from, $shipping_to ) ) {
			return null;
		}

		$normalized = strtoupper( preg_replace( '/\s+/', '', $tracking_number ) );
		if ( empty( $normalized ) ) {
			return null;
		}

		// 1. Check for main 16-digit and legacy Evri/Hermes formats.
		foreach ( self::MAIN_PATTERNS as $pattern ) {
			if ( preg_match( $pattern, $normalized ) ) {
				$confidence = 90;
				// Boost for UK shipments.
				if ( 'GB' === $shipping_from ) {
					$confidence = min( 98, $confidence + 2 );
				}
				return array(
					'url'             => $this->get_tracking_url( $normalized ),
					'ambiguity_score' => $confidence,
				);
			}
		}

		// 2. Check for 8-digit calling card number.
		if ( preg_match( self::CALLING_CARD_PATTERN, $normalized ) ) {
			return array(
				'url'             => $this->get_tracking_url( $normalized ),
				'ambiguity_score' => 80,
			);
		}

		// 3. Check for legacy/fallback patterns (lower confidence).
		foreach ( self::LEGACY_PATTERNS as $pattern ) {
			if ( preg_match( $pattern, $normalized ) ) {
				$confidence = 75;
				// Boost for UK shipments.
				if ( 'GB' === $shipping_from ) {
					$confidence = min( 95, $confidence + 15 );
				}
				return array(
					'url'             => $this->get_tracking_url( $normalized ),
					'ambiguity_score' => $confidence,
				);
			}
		}

		return null;
	}
}
PK     [1]e_J    8  Fulfillments/Providers/PosteItalianeShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Poste Italiane Shipping Provider class.
 */
class PosteItalianeShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'poste-italiane';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Poste Italiane';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/poste-italiane.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.poste.it/track/' . $tracking_number;
	}
}
PK     [1]g(    9  Fulfillments/Providers/SlovenskaPostaShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Slovenska Posta Shipping Provider class.
 */
class SlovenskaPostaShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'slovenska-posta';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Slovenská pošta';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/slovenska-posta.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.posta.sk/track/' . $tracking_number;
	}
}
PK     [1]S    2  Fulfillments/Providers/KazpostShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Kazpost Shipping Provider class.
 */
class KazpostShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'kazpost';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Kazpost';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/kazpost.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.kazpost.kz/track/' . $tracking_number;
	}
}
PK     [1]̌    >  Fulfillments/Providers/OsterreichischePostShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Osterreichische Post Shipping Provider class.
 */
class OsterreichischePostShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'osterreichische-post';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Österreichische Post';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/osterreichische-post.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.post.at/en/track/' . $tracking_number;
	}
}
PK     [1]Sه    .  Fulfillments/Providers/MPLShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * MPL Shipping Provider class.
 */
class MPLShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'mpl';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'MPL';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/mpl.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.mpl.com.mt/track/' . $tracking_number;
	}
}
PK     [1]J݈    /  Fulfillments/Providers/USPSShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

use Automattic\WooCommerce\Internal\Fulfillments\FulfillmentUtils;

/**
 * USPS Shipping Provider implementation.
 *
 * Handles USPS tracking number detection and validation for both domestic and international shipments.
 */
class USPSShippingProvider extends AbstractShippingProvider {
	/**
	 * List of countries/territories where USPS offers domestic service.
	 *
	 * @var array<string>
	 */
	private array $domestic_countries = array( 'US', 'PR', 'GU', 'AS', 'VI', 'MP', 'FM', 'MH', 'PW' );

	/**
	 * Gets the unique provider key.
	 *
	 * @return string The provider key 'usps'.
	 */
	public function get_key(): string {
		return 'usps';
	}

	/**
	 * Gets the display name of the provider.
	 *
	 * @return string The provider name 'USPS'.
	 */
	public function get_name(): string {
		return 'USPS';
	}

	/**
	 * Gets the path to the provider's icon.
	 *
	 * @return string URL to the USPS logo image.
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/usps.png';
	}

	/**
	 * Generates the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number to generate URL for.
	 * @return string The complete tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://tools.usps.com/go/TrackConfirmAction?tLabels=' . rawurlencode( $tracking_number );
	}

	/**
	 * Gets the list of origin countries supported by USPS.
	 *
	 * @return array<string> Array of country codes (only 'US').
	 */
	public function get_shipping_from_countries(): array {
		return array( 'US' ); // USPS only ships from the United States.
	}

	/**
	 * Gets the list of destination countries supported by USPS.
	 *
	 * @return array<string> Array of country codes including domestic and international.
	 */
	public function get_shipping_to_countries(): array {
		return array_merge(
			$this->domestic_countries,
			explode( ' ', 'AD AE AF AG AI AL AM AO AR AT AU AW AZ BA BB BD BE BF BG BH BI BJ BM BN BO BR BS BT BW BY BZ CA CD CF CG CH CI CL CM CN CO CR CU CV CY CZ DE DJ DK DM DO DZ EC EE EG ER ES ET FI FJ FR GA GB GD GE GH GI GM GN GQ GR GT GW GY HK HN HR HT HU ID IE IL IN IQ IR IS IT JM JO JP KE KG KH KI KM KN KP KR KW KZ LA LB LC LK LR LS LT LU LV LY MA MC MD ME MG MK ML MM MN MO MR MT MU MV MW MX MY MZ NA NE NG NI NL NO NP NZ OM PA PE PG PH PK PL PT PY QA RO RS RU RW SA SB SC SD SE SG SI SK SL SM SN SO SR ST SV SY SZ TD TG TH TJ TL TM TN TO TR TT TV TW TZ UA UG UK UY UZ VC VE VN VU WS YE ZA ZM ZW' )
		);
	}

	/**
	 * Checks if USPS can ship from and to the specified countries.
	 *
	 * @param string $shipping_from Origin country code.
	 * @param string $shipping_to Destination country code.
	 * @return bool
	 */
	public function can_ship_from_to( string $shipping_from, string $shipping_to ): bool {
		return in_array( $shipping_from, $this->get_shipping_from_countries(), true )
			&& in_array( $shipping_to, $this->get_shipping_to_countries(), true );
	}

	/**
	 * Attempts to parse and validate a USPS tracking number.
	 *
	 * @param string $tracking_number The tracking number to validate.
	 * @param string $shipping_from Origin country code.
	 * @param string $shipping_to Destination country code.
	 * @return array|null Array with tracking URL and score, or null if invalid.
	 */
	public function try_parse_tracking_number( string $tracking_number, string $shipping_from, string $shipping_to ): ?array {
		if ( empty( $tracking_number ) || ! $this->can_ship_from_to( $shipping_from, $shipping_to ) ) {
			return null;
		}

		// Remove spaces and uppercase for consistency.
		$tracking_number = strtoupper( preg_replace( '/\s+/', '', $tracking_number ) );
		$is_domestic     = in_array( $shipping_to, $this->domestic_countries, true );

		// USPS tracking number patterns (ordered by confidence).
		$patterns = array(
			// 22-digit, 20-digit, and 26-34 digit numeric (domestic and third-party)
			'/^(94|93|92|95|96|94|94|94|94)\d{18,22}$/' => function () use ( $tracking_number ) {
				// Most common domestic, check digit validation.
				return FulfillmentUtils::validate_mod10_check_digit( $tracking_number ) ? 100 : 95;
			},

			// S10/UPU international (2 letters, 9 digits, 2 letters, e.g., EC123456789US).
			'/^[A-Z]{2}\d{9}[A-Z]{2}$/'                 => function () use ( $tracking_number ) {
				return FulfillmentUtils::check_s10_upu_format( $tracking_number ) ? 98 : 90;
			},

			// Global Express Guaranteed (10 or 11 digits, starts with 82).
			'/^82\d{8,9}$/'                             => 95,

			// 26-34 digit numeric (Parcel Pool, third-party, starts with 420)
			'/^420\d{23,31}$/'                          => 90,

			// 20-22 digit numeric (fallback, domestic)
			'/^\d{20,22}$/'                             => 80,

			// 9x... (fallback, 22-34 digits, numeric)
			'/^9\d{21,33}$/'                            => 75,

			// Legacy/Express/other.
			'/^91\d{18,20}$/'                           => function () use ( $tracking_number ) {
				return FulfillmentUtils::validate_mod10_check_digit( $tracking_number ) ? 90 : 80;
			},
			'/^030[67]\d{16,20}$/'                      => function () use ( $tracking_number ) {
				return FulfillmentUtils::validate_mod10_check_digit( $tracking_number ) ? 88 : 80;
			},
		);

		foreach ( $patterns as $pattern => $score ) {
			if ( preg_match( $pattern, $tracking_number ) ) {
				$ambiguity_score = is_callable( $score ) ? $score() : $score;
				return array(
					'url'             => $this->get_tracking_url( $tracking_number ),
					'ambiguity_score' => $ambiguity_score,
				);
			}
		}

		// Fallback: Accept any 13-char S10/UPU format ending with "US".
		if ( preg_match( '/^[A-Z]{2}\d{9}US$/', $tracking_number ) ) {
			return array(
				'url'             => $this->get_tracking_url( $tracking_number ),
				'ambiguity_score' => 80,
			);
		}

		// Fallback: Accept any 20-34 digit numeric string (very low confidence).
		if ( preg_match( '/^\d{20,34}$/', $tracking_number ) ) {
			return array(
				'url'             => $this->get_tracking_url( $tracking_number ),
				'ambiguity_score' => 60,
			);
		}

		return null; // No matching pattern found.
	}
}
PK     [1]	      6  Fulfillments/Providers/MatkahuoltoShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Matkahuolto Shipping Provider class.
 */
class MatkahuoltoShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'matkahuolto';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Matkahuolto';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/matkahuolto.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.matkahuolto.fi/fi/asiakaspalvelu/rahtien-seuranta?trackingNumber=' . $tracking_number;
	}
}
PK     [1]SD'    /  Fulfillments/Providers/TollShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Toll Shipping Provider class.
 */
class TollShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'toll';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Toll';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/toll.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.tollgroup.com/track/' . $tracking_number;
	}
}
PK     [1]#    4  Fulfillments/Providers/UkrposhtaShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Ukrposhta Shipping Provider class.
 */
class UkrposhtaShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'ukrposhta';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Ukrposhta';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/ukrposhta.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.ukrposhta.ua/track/' . $tracking_number;
	}
}
PK     [1]ϥ    8  Fulfillments/Providers/HrvatskaPostaShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Hrvatska Posta Shipping Provider class.
 */
class HrvatskaPostaShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'hrvatska-posta';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Hrvatska Pošta';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/hrvatska-posta.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.posta.hr/track/' . $tracking_number;
	}
}
PK     [1]{(h    1  Fulfillments/Providers/InPostShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * InPost Shipping Provider class.
 */
class InPostShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'inpost';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'InPost';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/inpost.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://inpost.pl/sledzenie-przesylek/' . $tracking_number;
	}
}
PK     [1]u    .  Fulfillments/Providers/GLSShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * GLS Shipping Provider class.
 */
class GLSShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'gls';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'GLS';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/gls.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://gls-group.eu/EU/en/parcel-tracking/' . $tracking_number;
	}
}
PK     [1]V&p$  $  4  Fulfillments/Providers/RoyalMailShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

use Automattic\WooCommerce\Internal\Fulfillments\FulfillmentUtils;

/**
 * Royal Mail Shipping Provider class.
 *
 * Provides Royal Mail tracking number validation, supported countries, and tracking URL generation.
 */
class RoyalMailShippingProvider extends AbstractShippingProvider {

	/**
	 * Royal Mail tracking number patterns with enhanced service detection.
	 *
	 * @var array<string, array{patterns: array<int, string>, confidence: int}>
	 */
	private const TRACKING_PATTERNS = array(
		'GB' => array( // United Kingdom.
			'patterns'   => array(
				// UPU S10 international formats.
				'/^[A-Z]{2}\d{9}GB$/',        // International format: XX#########GB.
				'/^[A-Z]{2}\d{7}GB$/',        // Alternative international format: XX#######GB.

				// Domestic tracking formats.
				'/^[A-Z]{1}\d{9}[A-Z]{1}$/',  // Domestic format: X#########X.
				'/^[A-Z]{2}\d{8}[A-Z]{2}$/',  // Standard format: XX########XX.
				'/^[A-Z]{2}\d{6}[A-Z]{2}$/',  // Compact format: XX######XX.

				// Service-specific patterns.
				'/^[A-Z]{4}\d{10}$/',         // Special delivery format: XXXX##########.
				'/^SD\d{8,12}$/',             // Signed For service.
				'/^SF\d{8,12}$/',             // Special Delivery.
				'/^RM\d{8,12}$/',             // Royal Mail standard.

				// Digital tracking formats.
				'/^\d{16}$/',                 // 16-digit returns label or digital.
				'/^\d{14}$/',                 // 14-digit returns label.
				'/^\d{13}$/',                 // 13-digit domestic/international tracking.
				'/^\d{12}$/',                 // 12-digit domestic tracking.
				'/^\d{11}$/',                 // 11-digit domestic tracking.
				'/^\d{10}$/',                 // 10-digit legacy Parcelforce/RM.
				'/^\d{9}$/',                  // 9-digit legacy Parcelforce/RM.

				// Parcelforce (Royal Mail Group).
				'/^PF\d{8,12}$/',             // Parcelforce Express.
				'/^[A-Z]{2}\d{8}PF$/',        // Parcelforce International.
				'/^\d{13}$/',                 // Parcelforce Worldwide numeric.

				// International tracked services.
				'/^IT\d{9}GB$/',              // International Tracked.
				'/^IE\d{9}GB$/',              // International Economy.
				'/^IS\d{9}GB$/',              // International Standard.

				// Business services.
				'/^BF\d{8,12}$/',             // Business services.
				'/^[A-Z]{3}\d{8,12}$/',       // Three-letter business codes.

				// Legacy formats.
				'/^[A-Z]{1}\d{8}[A-Z]{2}$/',  // Legacy format: X########XX.
				'/^[0-9]{9}[A-Z]{3}$/',       // 9 digits + 3 letters.
			),
			'confidence' => 80,
		),
	);

	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'royal-mail';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Royal Mail';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/royal-mail.png';
	}

	/**
	 * Get the countries this shipping provider can ship from.
	 *
	 * @return array List of country codes.
	 */
	public function get_shipping_from_countries(): array {
		return array_keys( self::TRACKING_PATTERNS );
	}

	/**
	 * Get the countries this shipping provider can ship to.
	 *
	 * Royal Mail ships internationally, so we return a comprehensive list.
	 *
	 * @return array List of country codes.
	 */
	public function get_shipping_to_countries(): array {
		return array( 'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AO', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AW', 'AX', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BQ', 'BR', 'BS', 'BT', 'BV', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL', 'CM', 'CN', 'CO', 'CR', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ', 'EC', 'EE', 'EG', 'EH', 'ER', 'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', 'FO', 'FR', 'GA', 'GB', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GL', 'GM', 'GN', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GW', 'GY', 'HK', 'HM', 'HN', 'HR', 'HT', 'HU', 'ID', 'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR', 'IS', 'IT', 'JE', 'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI', 'KM', 'KN', 'KP', 'KR', 'KW', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LI', 'LK', 'LR', 'LS', 'LT', 'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO', 'NP', 'NR', 'NU', 'NZ', 'OM', 'PA', 'PE', 'PF', 'PG', 'PH', 'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY', 'QA', 'RE', 'RO', 'RS', 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SR', 'SS', 'ST', 'SV', 'SX', 'SY', 'SZ', 'TC', 'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL', 'TM', 'TN', 'TO', 'TR', 'TT', 'TV', 'TW', 'TZ', 'UA', 'UG', 'UM', 'US', 'UY', 'UZ', 'VA', 'VC', 'VE', 'VG', 'VI', 'VN', 'VU', 'WF', 'WS', 'YE', 'YT', 'ZA', 'ZM', 'ZW' );
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.royalmail.com/track-your-item#/tracking-results/' . rawurlencode( $tracking_number );
	}

	/**
	 * Validate tracking number against country-specific patterns.
	 *
	 * @param string $tracking_number The tracking number to validate.
	 * @param string $country_code The country code for the shipment.
	 * @return bool True if valid, false otherwise.
	 */
	private function validate_country_pattern( string $tracking_number, string $country_code ): bool {
		if ( ! isset( self::TRACKING_PATTERNS[ $country_code ] ) ) {
			return false;
		}

		foreach ( self::TRACKING_PATTERNS[ $country_code ]['patterns'] as $pattern ) {
			if ( preg_match( $pattern, $tracking_number ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Try to parse a Royal Mail tracking number.
	 *
	 * @param string $tracking_number The tracking number to parse.
	 * @param string $shipping_from The country code of the shipping origin.
	 * @param string $shipping_to The country code of the shipping destination.
	 * @return array|null An array with 'url' and 'ambiguity_score' if valid, null otherwise.
	 */
	public function try_parse_tracking_number(
		string $tracking_number,
		string $shipping_from,
		string $shipping_to
	): ?array {
		if ( empty( $tracking_number ) || empty( $shipping_from ) || empty( $shipping_to ) ) {
			return null;
		}

		$normalized = strtoupper( preg_replace( '/\s+/', '', $tracking_number ) ); // Normalize input.
		if ( empty( $normalized ) ) {
			return null;
		}

		$shipping_from = strtoupper( $shipping_from );
		$shipping_to   = strtoupper( $shipping_to );

		// Check if shipping from UK.
		if ( 'GB' !== $shipping_from ) {
			return null;
		}

		// Check country-specific patterns with enhanced validation.
		if ( $this->validate_country_pattern( $normalized, $shipping_from ) ) {
			$confidence = self::TRACKING_PATTERNS[ $shipping_from ]['confidence'];

			// Apply UPU S10 validation for international formats.
			if ( preg_match( '/^[A-Z]{2}\d{7,9}GB$/', $normalized ) ) {
				if ( FulfillmentUtils::check_s10_upu_format( $normalized ) ) {
					$confidence = min( 98, $confidence + 8 ); // Strong boost for valid UPU.
				}
			}

			// Apply check digit validation for numeric formats.
			if ( preg_match( '/^\d{11,16}$/', $normalized ) ) {
				if ( FulfillmentUtils::validate_mod10_check_digit( $normalized ) ) {
					$confidence = min( 95, $confidence + 5 ); // Boost for valid check digit.
				}
			}

			// Service-specific confidence boosts.
			if ( preg_match( '/^(SD|SF)\d+/', $normalized ) ) {
				$confidence = min( 96, $confidence + 6 ); // Special Delivery/Signed For.
			} elseif ( preg_match( '/^PF\d+/', $normalized ) ) {
				$confidence = min( 94, $confidence + 4 ); // Parcelforce.
			} elseif ( preg_match( '/^(IT|IE|IS)\d+GB$/', $normalized ) ) {
				$confidence = min( 95, $confidence + 5 ); // International tracked services.
			} elseif ( preg_match( '/^(RM|BF)\d+/', $normalized ) ) {
				$confidence = min( 92, $confidence + 3 ); // Standard Royal Mail/Business.
			}

			// Boost confidence for domestic shipments.
			if ( 'GB' === $shipping_to ) {
				$confidence = min( 95, $confidence + 8 );
			}

			// Boost confidence for common destinations (Europe).
			$european_destinations = array( 'FR', 'DE', 'ES', 'IT', 'NL', 'BE', 'IE', 'AT', 'CH', 'PT', 'DK', 'SE', 'NO' );
			if ( in_array( $shipping_to, $european_destinations, true ) ) {
				$confidence = min( 95, $confidence + 3 );
			}

			// Boost for other common destinations.
			$common_destinations = array( 'US', 'CA', 'AU', 'NZ', 'JP', 'SG', 'HK' );
			if ( in_array( $shipping_to, $common_destinations, true ) ) {
				$confidence = min( 93, $confidence + 2 );
			}

			return array(
				'url'             => $this->get_tracking_url( $normalized ),
				'ambiguity_score' => $confidence,
			);
		}

		return null;
	}
}
PK     [1] y    1  Fulfillments/Providers/PostNLShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * PostNL Shipping Provider class.
 */
class PostNLShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'postnl';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'PostNL';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/postnl.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.postnl.nl/track-en-trace/' . $tracking_number;
	}
}
PK     [1]?XG    6  Fulfillments/Providers/MagyarPostaShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Magyar Posta Shipping Provider class.
 */
class MagyarPostaShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'magyar-posta';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Magyar Posta';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/magyar-posta.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://posta.hu/track/' . $tracking_number;
	}
}
PK     [1]!c:  :  8  Fulfillments/Providers/IslandsposturShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Islandspostur Shipping Provider class.
 */
class IslandsposturShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'islandspostur';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Íslandspóstur';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/islandspostur.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.islandspostur.is/umsoknir-og-umsoknir/umsoknir/umsoknir-um-sendingar/sendingar/' . $tracking_number;
	}
}
PK     [1]s@  @  .  Fulfillments/Providers/DHLShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

use Automattic\WooCommerce\Internal\Fulfillments\FulfillmentUtils;

/**
 * DHL Shipping Provider implementation.
 *
 * Handles DHL tracking number detection and validation for all DHL services.
 */
class DHLShippingProvider extends AbstractShippingProvider {
	/**
	 * List of countries where DHL has significant operations.
	 *
	 * @var array<string>
	 */
	private array $major_operation_countries = array( 'DE', 'US', 'CA', 'GB', 'SG', 'JP', 'HK', 'NL', 'FR', 'IT', 'AU', 'CN', 'IN', 'ES', 'BE', 'CH', 'AT', 'SE', 'DK', 'NO', 'PL', 'CZ', 'FI', 'IE', 'PT', 'GR', 'HU', 'RO', 'BG', 'HR', 'SK', 'SI', 'LT', 'LV', 'EE', 'CY', 'MT', 'LU' );

	/**
	 * Gets the unique provider key.
	 *
	 * @return string The provider key 'dhl'.
	 */
	public function get_key(): string {
		return 'dhl';
	}

	/**
	 * Gets the display name of the provider.
	 *
	 * @return string The provider name 'DHL'.
	 */
	public function get_name(): string {
		return 'DHL';
	}

	/**
	 * Gets the path to the provider's icon.
	 *
	 * @return string URL to the DHL logo image.
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/dhl.png';
	}

	/**
	 * Generates the appropriate tracking URL based on DHL service type.
	 *
	 * @param string $tracking_number The tracking number to generate URL for.
	 * @return string The complete tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		$tracking_number = strtoupper( $tracking_number ); // Uppercase for consistency.

		// DHL Global Mail and eCommerce prefixes.
		if ( preg_match( '/^(GM|LX|RX|CN|SG|MY|HK|AU|TH|420)/', $tracking_number ) ) {
			return 'https://webtrack.dhlglobalmail.com/?trackingnumber=' . rawurlencode( $tracking_number );
		}

		// DHL Paket Germany (3S...).
		if ( preg_match( '/^3S[A-Z0-9]{8,12}$/', $tracking_number ) ) {
			return 'https://www.dhl.de/en/privatkunden/dhl-sendungsverfolgung.html?piececode=' . rawurlencode( $tracking_number );
		}

		// Standard DHL Express tracking.
		return 'https://www.dhl.com/en/express/tracking.html?AWB=' . rawurlencode( $tracking_number );
	}

	/**
	 * Gets the list of origin countries supported by DHL.
	 *
	 * @return array<string> Array of country codes.
	 */
	public function get_shipping_from_countries(): array {
		return $this->major_operation_countries;
	}

	/**
	 * Gets the list of destination countries supported by DHL.
	 *
	 * @return array<string> Array of country codes.
	 */
	public function get_shipping_to_countries(): array {
		return array_keys( wc()->countries->get_countries() );
	}

	/**
	 * Checks if DHL can ship between two countries.
	 *
	 * @param string $shipping_from Origin country code.
	 * @param string $shipping_to Destination country code.
	 * @return bool True if shipping route is supported.
	 */
	public function can_ship_from_to( string $shipping_from, string $shipping_to ): bool {
		return in_array( $shipping_from, $this->get_shipping_from_countries(), true ) &&
			in_array( $shipping_to, $this->get_shipping_to_countries(), true );
	}

	/**
	 * Validates and parses a DHL tracking number.
	 *
	 * @param string $tracking_number The tracking number to validate.
	 * @param string $shipping_from Origin country code.
	 * @param string $shipping_to Destination country code.
	 * @return array|null Array with tracking URL and score, or null if invalid.
	 */
	public function try_parse_tracking_number( string $tracking_number, string $shipping_from, string $shipping_to ): ?array {
		if ( empty( $tracking_number ) || ! $this->can_ship_from_to( $shipping_from, $shipping_to ) ) {
			return null;
		}

		$tracking_number  = strtoupper( preg_replace( '/\s+/', '', $tracking_number ) ); // Remove spaces and uppercase for consistency.
		$is_major_country = in_array( $shipping_from, $this->major_operation_countries, true ); // Major operation region flag.

		// DHL tracking number patterns with enhanced validation and comments.
		$patterns = array(
			// DHL Express Air Waybill: 10 or 11 digits, with check digit validation.
			'/^\d{10}$/'                                 => function () use ( $tracking_number ) {
				return FulfillmentUtils::validate_mod11_check_digit( $tracking_number ) ? 98 : 90;
			},
			'/^\d{11}$/'                                 => function () use ( $tracking_number ) {
				return FulfillmentUtils::validate_mod11_check_digit( $tracking_number ) ? 98 : 90;
			},

			// DHL Express JJD and JVGL formats.
			'/^JJD\d{10}$/'                              => 98,
			'/^JVGL\d{10}$/'                             => 98,

			// DHL Paket Germany: 12, 14, or 20 digits.
			// Only match 12/14-digit numeric for DHL if both from and to are DE (Germany).
			'/^\d{12}$/'                                 => function () use ( $shipping_from, $shipping_to ) {
				return ( 'DE' === $shipping_from && 'DE' === $shipping_to ) ? 92 : 60;
			},
			'/^\d{14}$/'                                 => function () use ( $shipping_from, $shipping_to ) {
				return ( 'DE' === $shipping_from && 'DE' === $shipping_to ) ? 92 : 60;
			},
			'/^\d{20}$/'                                 => 90,

			// DHL Paket Germany: 3S + 8–12 alphanumeric.
			'/^3S[A-Z0-9]{8,12}$/'                       => 95,

			// DHL eCommerce North America: GM + 16–20 digits.
			'/^GM\d{16,20}$/'                            => function () use ( $shipping_from ) {
				return in_array( $shipping_from, array( 'US', 'CA' ), true ) ? 95 : 80;
			},

			// DHL eCommerce Asia-Pacific: LX, RX, CN, SG, MY, HK, AU, TH + 9 digits + 2 letters.
			'/^(LX|RX|CN|SG|MY|HK|AU|TH)\d{9}[A-Z]{2}$/' => 92,

			// DHL eCommerce US consolidator: 420 + 27–31 digits.
			'/^420\d{23,31}$/'                           => 90,

			// DHL Global Forwarding: 7, 8, or 9 digits (numeric only).
			'/^\d{7,9}$/'                                => 88,

			// DHL Global Forwarding: 1 digit + 2 letters + 4–6 digits.
			'/^\d[A-Z]{2}\d{4,6}$/'                      => 90,

			// DHL Global Forwarding: 3–4 letters + 4–8 digits.
			'/^[A-Z]{3,4}\d{4,8}$/'                      => 88,

			// DHL Same Day: DSD + 8–12 digits.
			'/^DSD\d{8,12}$/'                            => 92,

			// DHL Piece Numbers: JD + 11 digits.
			'/^JD\d{11}$/'                               => 90,

			// DHL Supply Chain: DSC + 10–15 digits.
			'/^DSC\d{10,15}$/'                           => 85,

			// S10/UPU format: 2 letters + 9 digits + 2 letters (used for DHL eCommerce and Packet International).
			'/^[A-Z]{2}\d{9}[A-Z]{2}$/'                  => function () use ( $tracking_number ) {
				return FulfillmentUtils::check_s10_upu_format( $tracking_number ) ? 88 : 75;
			},

			// Fallback: 22 digit numeric (legacy/rare).
			'/^\d{22}$/'                                 => 70,
		);

		foreach ( $patterns as $pattern => $base_score ) {
			if ( preg_match( $pattern, $tracking_number ) ) {
				$score = is_callable( $base_score ) ? $base_score() : $base_score;
				if ( $score > 0 ) {
					return array(
						'url'             => $this->get_tracking_url( $tracking_number ),
						'ambiguity_score' => $score,
					);
				}
			}
		}

		return null;
	}
}
PK     [1]-3	  	  4  Fulfillments/Providers/ArasKargoShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Aras Kargo Shipping Provider class.
 */
class ArasKargoShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'aras-kargo';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Aras Kargo';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/aras-kargo.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.araskargo.com.tr/Tracking/Detail?trackingNumber=' . $tracking_number;
	}
}
PK     [1]5    8  Fulfillments/Providers/LatvijasPastsShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Latvijas Pasts Shipping Provider class.
 */
class LatvijasPastsShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'latvijas-pasts';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Latvijas Pasts';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/latvijas-pasts.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.pasts.lv/en/track/' . $tracking_number;
	}
}
PK     [1]i_    :  Fulfillments/Providers/LasershipOntracShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Lasership/OnTrac Shipping Provider class.
 */
class LasershipOntracShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'lasership-ontrac';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'LaserShip/OnTrac';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/lasership-ontrac.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.lasership.com/track/' . $tracking_number;
	}
}
PK     [1]Va    1  Fulfillments/Providers/ItellaShippingProvider.phpnu         <?php declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Fulfillments\Providers;

/**
 * Itella Shipping Provider class.
 */
class ItellaShippingProvider extends AbstractShippingProvider {
	/**
	 * Get the key of the shipping provider.
	 *
	 * @return string
	 */
	public function get_key(): string {
		return 'itella';
	}

	/**
	 * Get the name of the shipping provider.
	 *
	 * @return string
	 */
	public function get_name(): string {
		return 'Smartposti (Itella)';
	}

	/**
	 * Get the icon of the shipping provider.
	 *
	 * @return string
	 */
	public function get_icon(): string {
		return esc_url( WC()->plugin_url() ) . '/assets/images/shipping_providers/itella.png';
	}

	/**
	 * Get the tracking URL for a given tracking number.
	 *
	 * @param string $tracking_number The tracking number.
	 * @return string The tracking URL.
	 */
	public function get_tracking_url( string $tracking_number ): string {
		return 'https://www.smartposti.fi/track/' . $tracking_number;
	}
}
PK     [1];A  A  %  Fulfillments/FulfillmentException.phpnu         <?php declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\Fulfillments;

use Automattic\WooCommerce\Internal\Admin\Settings\Exceptions\ApiException;

/**
 * FulfillmentException class.
 * This exception is thrown when there is an issue with fulfillment operations,
 * such as creating, updating, or deleting fulfillments.
 */
class FulfillmentException extends ApiException {
	/**
	 * Setup exception.
	 *
	 * @param string $message          User-friendly translated error message, e.g. 'Fulfillment creation failed'.
	 * @param int    $http_status_code Optional. Proper HTTP status code to respond with.
	 *                                 Defaults to 400 (Bad request).
	 * @param array  $additional_data  Optional. Extra data (key value pairs) to expose in the error response.
	 *                                 Defaults to empty array.
	 */
	public function __construct( string $message, int $http_status_code = 400, array $additional_data = array() ) {
		parent::__construct( 'woocommerce_fulfillment_error', $message, $http_status_code, $additional_data );
	}
}
PK     [1]I!4P  4P  %  Fulfillments/FulfillmentsRenderer.phpnu         <?php
/**
 * WooCommerce order fulfillments renderer script.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Fulfillments;

use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
use Automattic\WooCommerce\Internal\DataStores\Fulfillments\FulfillmentsDataStore;
use Automattic\WooCommerce\Utilities\OrderUtil;
use WC_Order;

/**
 * FulfillmentsRenderer class.
 */
class FulfillmentsRenderer {

	/**
	 * Fulfillments cache, that holds the fulfillments for each order to eliminate
	 * fetching fulfillment records of an order on each column render.
	 *
	 * @var array
	 */
	private array $fulfillments_cache = array();

	/**
	 * Registers the hooks related to fulfillments.
	 */
	public function register() {
		if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
			// Hook into column definitions and add the new fulfillment columns.
			add_filter( 'manage_woocommerce_page_wc-orders_columns', array( $this, 'add_fulfillment_columns' ) );
			// Hook into the column rendering and render the new fulfillment columns.
			add_action( 'manage_woocommerce_page_wc-orders_custom_column', array( $this, 'render_fulfillment_column_row_data' ), 10, 2 );
		} else {
			// For legacy orders table, hook into column definitions and add the new fulfillment columns.
			add_filter( 'manage_edit-shop_order_columns', array( $this, 'add_fulfillment_columns' ) );
			// Hook into the column rendering and render the new fulfillment columns.
			add_action( 'manage_shop_order_posts_custom_column', array( $this, 'render_fulfillment_column_row_data_legacy' ), 25, 1 );
		}
		// Hook into the admin footer to add the fulfillment drawer slot, which the React component will mount on.
		add_action( 'admin_footer', array( $this, 'render_fulfillment_drawer_slot' ) );
		// Hook into the admin enqueue scripts to load the fulfillment drawer component.
		add_action( 'admin_enqueue_scripts', array( $this, 'load_components' ) );
		// Hook into the order details page to render the fulfillment badges.
		add_action( 'woocommerce_admin_order_data_header_right', array( $this, 'render_order_details_badges' ) );
		// Hook into the order details before order table to render the fulfillment customer details.
		add_action( 'woocommerce_order_details_before_order_table', array( $this, 'render_fulfillment_customer_details' ) );
		// Initialize the renderer for bulk actions.
		add_action( 'admin_init', array( $this, 'init_admin_hooks' ) );
		// Hook into the order status text to append the fulfillment status.
		add_filter( 'woocommerce_order_details_status', array( $this, 'render_fulfillment_status_text' ), 10, 2 );
		add_filter( 'woocommerce_order_tracking_status', array( $this, 'render_fulfillment_status_text' ), 10, 2 );
	}

	/**
	 * Initialize the hooks that should run after `admin_init` hook.
	 */
	public function init_admin_hooks() {
		if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
			// For custom orders table, we need to add the bulk actions to the custom orders table.
			add_filter( 'bulk_actions-woocommerce_page_wc-orders', array( $this, 'define_fulfillment_bulk_actions' ) );
			add_filter( 'handle_bulk_actions-woocommerce_page_wc-orders', array( $this, 'handle_fulfillment_bulk_actions' ), 10, 3 );
			// For custom orders table, we need to filter the query to include fulfillment status.
			add_action( 'woocommerce_order_list_table_restrict_manage_orders', array( $this, 'render_fulfillment_filters' ) );
			add_filter( 'woocommerce_order_query_args', array( $this, 'filter_orders_list_table_query' ), 10, 1 );
		} else {
			// For legacy orders table, we need to add the bulk actions to the legacy orders table.
			add_filter( 'bulk_actions-edit-shop_order', array( $this, 'define_fulfillment_bulk_actions' ) );
			add_filter( 'handle_bulk_actions-edit-shop_order', array( $this, 'handle_fulfillment_bulk_actions' ), 10, 3 );
			// For legacy orders table, we need to filter the query to include fulfillment status.
			add_action( 'restrict_manage_posts', array( $this, 'render_fulfillment_filters_legacy' ) );
			add_action( 'pre_get_posts', array( $this, 'filter_legacy_orders_list_query' ) );
		}
	}

	/**
	 * Add the fulfillment related columns to the orders table, after the order_status column.
	 *
	 * @param array $columns The columns in the orders page.
	 * @return array The modified columns.
	 */
	public function add_fulfillment_columns( $columns ) {
		$new_columns = array();
		foreach ( $columns as $column_name => $column_info ) {
			$new_columns[ $column_name ] = $column_info;
			if ( 'order_status' === $column_name ) {
				$new_columns[ $column_name ]       = 'Order Status';
				$new_columns['fulfillment_status'] = __( 'Fulfillment Status', 'woocommerce' );
				$new_columns['shipment_tracking']  = __( 'Shipment Tracking', 'woocommerce' );
				$new_columns['shipment_provider']  = __( 'Shipment Provider', 'woocommerce' );
			}
		}
		return $new_columns;
	}

	/**
	 * Render the fulfillment column row data for legacy order list support.
	 *
	 * @param string $column_name The name of the column.
	 */
	public function render_fulfillment_column_row_data_legacy( string $column_name ) {
		global $the_order;
		// This method is kept for legacy support, but the main rendering logic is now in render_fulfillment_column_row_data.
		return $this->render_fulfillment_column_row_data( $column_name, $the_order );
	}

	/**
	 * Render the fulfillment status column.
	 *
	 * @param string   $column_name The name of the column.
	 * @param WC_Order $order The order object.
	 */
	public function render_fulfillment_column_row_data( string $column_name, WC_Order $order ) {
		$fulfillments = $this->maybe_read_fulfillments( $order );

		// Render the column data based on the column name.
		switch ( $column_name ) {
			case 'fulfillment_status':
				$this->render_order_fulfillment_status_column_row_data( $order );
				break;
			case 'shipment_tracking':
				$this->render_shipment_tracking_column_row_data( $order, $fulfillments );
				break;
			case 'shipment_provider':
				$this->render_shipment_provider_column_row_data( $order, $fulfillments );
				break;
		}
	}

	/**
	 * Render the fulfillment status column row data.
	 *
	 * @param WC_Order $order The order object.
	 */
	private function render_order_fulfillment_status_column_row_data( WC_Order $order ) {
		$order_fulfillment_status = FulfillmentUtils::get_order_fulfillment_status( $order );

		echo "<div class='fulfillment-status-wrapper'>";
		$this->render_order_fulfillment_status_badge( $order, $order_fulfillment_status );
		echo '</div>';
	}

	/**
	 * Render the fulfillment status badge.
	 *
	 * @param WC_Order $order The order object.
	 * @param string   $order_fulfillment_status The fulfillment status of the order.
	 */
	private function render_order_fulfillment_status_badge( $order, string $order_fulfillment_status ) {
		$status_props = FulfillmentUtils::get_order_fulfillment_statuses()[ $order_fulfillment_status ];
		if ( ! $status_props ) {
			$status_props = array(
				'label'            => __( 'Unknown', 'woocommerce' ),
				'background_color' => '#f0f0f0',
				'text_color'       => '#000',
			);
		}

		echo '<mark class="fulfillment-status" style="background-color:' . esc_attr( $status_props['background_color'] ) . '; color: ' . esc_attr( $status_props['text_color'] ) . '"><span>' . esc_html( $status_props['label'] ) . '</span></mark>';
		echo "<a href='#' class='fulfillments-trigger' data-order-id='" . esc_attr( $order->get_id() ) . "' title='" . esc_attr__( 'View Fulfillments', 'woocommerce' ) . "'>
			<svg width='16' height='16' viewBox='0 0 12 14' xmlns='http://www.w3.org/2000/svg'>
				<path d='M11.8333 2.83301L9.33329 0.333008L2.24996 7.41634L1.41663 10.7497L4.74996 9.91634L11.8333 2.83301ZM5.99996 12.4163H0.166626V13.6663H5.99996V12.4163Z' />
			</svg>
		</a>";
	}

	/**
	 * Render the shipment provider column row data.
	 *
	 * @param WC_Order $order The order object.
	 * @param array    $fulfillments The fulfillments.
	 */
	private function render_shipment_provider_column_row_data( WC_Order $order, array $fulfillments ) {
		$providers = array();
		foreach ( $fulfillments as $fulfillment ) {
			$providers[] = $fulfillment->get_meta( '_shipment_provider' ) ?? null;
		}

		$providers = array_filter(
			$providers,
			function ( $provider ) {
				return ! empty( $provider );
			}
		);

		if ( count( $providers ) > 1 ) {
			echo '<span>' . esc_html__( 'Multiple providers', 'woocommerce' ) . '</span>';
		} elseif ( 1 === count( $providers ) ) {
			echo '<span>' . esc_html( array_shift( $providers ) ) . '</span>';
		} else {
			echo '<span>--</span>';
		}
	}

	/**
	 * Render the shipment tracking column row data.
	 *
	 * @param WC_Order $order The order object.
	 * @param array    $fulfillments The fulfillments.
	 */
	private function render_shipment_tracking_column_row_data( WC_Order $order, array $fulfillments ) {
		$tracking = array();
		foreach ( $fulfillments as $fulfillment ) {
			$tracking[] = $fulfillment->get_meta( '_tracking_number' ) ?? null;
		}

		$tracking = array_filter(
			$tracking,
			function ( $provider ) {
				return ! empty( $provider );
			}
		);

		if ( count( $tracking ) > 1 ) {
			echo '<span>' . esc_html__( 'Multiple trackings', 'woocommerce' ) . '</span>';
		} elseif ( 1 === count( $tracking ) ) {
			echo '<span>' . esc_html( array_shift( $tracking ) ) . '</span>';
		} else {
			echo '<span>--</span>';
		}
	}

	/**
	 * Render the fulfillment drawer.
	 */
	public function render_fulfillment_drawer_slot() {
		if ( ! $this->should_render_fulfillment_drawer() ) {
			return;
		}
		?>
		<div id="wc_order_fulfillments_panel_container"></div>
		<?php
	}

	/**
	 * Define bulk actions for fulfillments.
	 *
	 * @param array $actions Existing actions.
	 * @return array
	 */
	public function define_fulfillment_bulk_actions( $actions ) {
		$actions['fulfill'] = __( 'Mark as fulfilled', 'woocommerce' );

		return $actions;
	}

	/**
	 * Handle bulk actions for fulfillments.
	 *
	 * @param string $redirect_to The redirect URL.
	 * @param string $action The action being performed.
	 * @param array  $post_ids The post IDs being acted upon.
	 * @return string
	 */
	public function handle_fulfillment_bulk_actions( $redirect_to, $action, $post_ids ) {
		if ( 'fulfill' === $action ) {
			foreach ( $post_ids as $post_id ) {
				$order = wc_get_order( $post_id );
				if ( ! $order ) {
					continue;
				}

				$fulfillments = $this->maybe_read_fulfillments( $order );

				// Fulfill all existing fulfillments.
				foreach ( $fulfillments as $fulfillment ) {
					$fulfillment->set_status( 'fulfilled' );
					$fulfillment->save();
				}

				// Create a fulfillment for the order, containing all remaining items in the order.
				$remaining_items = array_map(
					function ( $item ) {
						return array(
							'item_id' => $item['item_id'],
							'qty'     => $item['qty'],
						);
					},
					FulfillmentUtils::get_pending_items( $order, $fulfillments )
				);

				if ( 0 < count( $remaining_items ) ) {
					$fulfillment = new Fulfillment();
					$fulfillment->set_entity_type( WC_Order::class );
					$fulfillment->set_entity_id( (string) $order->get_id() );
					$fulfillment->set_status( 'fulfilled' );
					$fulfillment->set_items( $remaining_items );
					$fulfillment->save();
				}
			}
			$redirect_to = add_query_arg( array( 'bulk_action' => $action ), $redirect_to );
		}
		return $redirect_to;
	}

	/**
	 * Render the fulfillment status text in the order details page and the order tracking page.
	 *
	 * @param string   $order_status The order status text.
	 * @param WC_Order $order The order object.
	 *
	 * @return string The fulfillment status appended order status text.
	 */
	public function render_fulfillment_status_text( string $order_status, WC_Order $order ): string {
		$fulfillments       = $this->maybe_read_fulfillments( $order );
		$fulfillment_status = FulfillmentUtils::get_order_fulfillment_status_text( $order, $fulfillments );
		return sprintf( '%s %s', $order_status, $fulfillment_status );
	}

	/**
	 * Render the fulfillment customer details in the order details page.
	 *
	 * @param WC_Order $order The order object.
	 */
	public function render_fulfillment_customer_details( WC_Order $order ) {
		$fulfillments = $this->maybe_read_fulfillments( $order );

		if ( ! empty( $fulfillments ) ) {
			?>
<section class="woocommerce-order-details">
	<table class="woocommerce-table woocommerce-table--order-details shop_table order_details">
		<thead>
			<?php
			foreach ( $fulfillments as $index => $fulfillment ) {
				if ( ! $fulfillment->get_is_fulfilled() ) {
					continue;
				}
				?>
			<tr>
				<th class="woocommerce-table__shipment-info shipment-info" style="font-weight: normal;">
				<?php
				printf(
					/* translators: %1$s is the shipment index, %2$s is the shipment date */
					wp_kses( __( '<b>Shipment %1$s</b> was shipped on <b>%2$s</b>', 'woocommerce' ), 'b' ),
					intval( $index ) + 1,
					esc_html(
						gmdate(
							'F j, Y',
							strtotime(
								$fulfillment->get_date_fulfilled() // Get the fulfilled date.
								?? $fulfillment->get_date_updated() // Fallback to the updated date if fulfilled date is not set.
							)
						)
					)
				);
				?>
				</th>
				<th class="woocommerce-table__shipment-tracking shipment-tracking" style="font-weight: normal;">
					<?php echo wp_kses( FulfillmentUtils::get_tracking_info_html( $fulfillment ), 'a' ); ?>
				</th>
			</tr>
				<?php
			}
			?>
		</thead>
	</table>
</section>
			<?php
		}
	}

	/**
	 * Render the fulfillment badges in the order details page.
	 *
	 * @param WC_Order $order The order object.
	 */
	public function render_order_details_badges( WC_Order $order ) {
		echo '<div class="wc-order-fulfillment-badges">';

		// Get the fulfillment status for the order.
		$fulfillments             = $this->maybe_read_fulfillments( $order );
		$order_fulfillment_status = FulfillmentUtils::calculate_order_fulfillment_status( $order, $fulfillments );

		// Render order status badge.
		$order_status = $order->get_status();
		echo '<mark class="order-status status-' . esc_attr( $order_status ) . '"><span>' . esc_html( wc_get_order_status_name( $order_status ) ) . '</span></mark>';

		// Render fulfillment status badge.
		$this->render_order_fulfillment_status_badge( $order, $order_fulfillment_status );
		echo '</div>';
	}

	/**
	 * Loads the fulfillments scripts and styles.
	 */
	public function load_components() {
		if ( ! $this->should_render_fulfillment_drawer() ) {
			return;
		}

		$this->register_fulfillments_assets();
		$this->load_fulfillments_js_settings();
	}

	/**
	 * Register the fulfillment assets.
	 */
	protected function register_fulfillments_assets() {
		WCAdminAssets::register_style( 'fulfillments', 'style', array( 'wp-components' ) );
		WCAdminAssets::register_script( 'wp-admin-scripts', 'fulfillments', true );
	}

	/**
	 * Load the fulfillments JS settings.
	 *
	 * @return void
	 */
	protected function load_fulfillments_js_settings() {
		$fulfillment_settings = array(
			'providers'                  => FulfillmentUtils::get_shipping_providers_object(),
			'currency_symbols'           => get_woocommerce_currency_symbols(),
			'fulfillment_statuses'       => FulfillmentUtils::get_fulfillment_statuses(),
			'order_fulfillment_statuses' => FulfillmentUtils::get_order_fulfillment_statuses(),
		);

		wp_localize_script( 'wc-admin-fulfillments', 'wcFulfillmentSettings', $fulfillment_settings );
	}

	/**
	 * Render the fulfillment filters in the orders table.
	 */
	public function render_fulfillment_filters() {
		if ( ! self::should_render_fulfillment_drawer() ) {
			return;
		}
		?>
		<?php
		// This is a read-only filter on the admin orders table, so nonce verification is not required.
		// phpcs:ignore WordPress.Security.NonceVerification ?>
			<?php $selected_status = isset( $_GET['fulfillment_status'] ) ? sanitize_text_field( wp_unslash( $_GET['fulfillment_status'] ) ) : ''; ?>
		<select id="fulfillment-status-filter" name="fulfillment_status">
			<option value="" <?php selected( $selected_status, '' ); ?>><?php esc_html_e( 'Filter by fulfillment', 'woocommerce' ); ?></option>
				<?php foreach ( FulfillmentUtils::get_order_fulfillment_statuses() as $status => $props ) : ?>
				<option value="<?php echo esc_attr( $status ); ?>" <?php selected( $selected_status, $status ); ?>>
					<?php echo esc_html( $props['label'] ?? '' ); ?>
				</option>
			<?php endforeach; ?>
		</select>
			<?php
	}

	/**
	 * Render the fulfillment filters in the legacy orders table.
	 */
	public function render_fulfillment_filters_legacy() {
		global $typenow;

		if ( 'shop_order' !== $typenow ) {
			return;
		}

		$this->render_fulfillment_filters();
	}

	/**
	 * Apply the fulfillment status filter to the orders list.
	 *
	 * @param array $args The query arguments for the orders list.
	 * @return array The modified query arguments.
	 */
	public function filter_orders_list_table_query( $args ) {
		// This is a read-only filter on the admin orders table, so nonce verification is not required.
		// phpcs:ignore WordPress.Security.NonceVerification
		if ( isset( $_GET['fulfillment_status'] ) && ! empty( $_GET['fulfillment_status'] ) ) {
			// phpcs:ignore WordPress.Security.NonceVerification
			$fulfillment_status = sanitize_text_field( wp_unslash( $_GET['fulfillment_status'] ) );

			// Ensure the fulfillment status is one of the allowed values.
			if ( FulfillmentUtils::is_valid_order_fulfillment_status( $fulfillment_status ) ) {
				$meta_query = FulfillmentUtils::get_order_fulfillment_status_meta_query( $fulfillment_status );
				if ( ! empty( $meta_query ) ) {
					if ( ! isset( $args['meta_query'] ) ) {
						$args['meta_query'] = array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
					}
					$args['meta_query'][] = $meta_query;
				}
			}
		}

		return $args;
	}

	/**
	 * Filter the legacy orders list query to include fulfillment status.
	 *
	 * @param \WP_Query $query The WP_Query object.
	 */
	public function filter_legacy_orders_list_query( $query ) {
		if (
		is_admin()
		&& $query->is_main_query()
		&& $query->get( 'post_type' ) === 'shop_order'
		&& isset( $_GET['fulfillment_status'] ) && ! empty( $_GET['fulfillment_status'] ) // phpcs:ignore WordPress.Security.NonceVerification
		) {
			$status = sanitize_text_field( wp_unslash( $_GET['fulfillment_status'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
			// Ensure the fulfillment status is one of the allowed values.
			if ( FulfillmentUtils::is_valid_order_fulfillment_status( $status ) ) {
				$query->set(
					'meta_query',
					'no_fulfillments' === $status ?
					array(
						'relation' => 'OR',
						array(
							'key'     => '_fulfillment_status',
							'compare' => 'NOT EXISTS',
						),
					) :
					array(
						array(
							'key'     => '_fulfillment_status',
							'value'   => $status,
							'compare' => '=',
						),
					)
				);
			}
		}
	}

	/**
	 * Check if the fulfillment drawer should be rendered (admin only).
	 *
	 * @return bool True if the fulfillment drawer should be rendered, false otherwise.
	 */
	protected function should_render_fulfillment_drawer(): bool {
		if ( ! is_admin() ) {
			return false;
		}

		if ( ! function_exists( 'get_current_screen' ) ) {
			return false;
		}

		$current_screen = get_current_screen();
		if ( ! $current_screen || ! $current_screen->id ) {
			return false;
		}

		return 'woocommerce_page_wc-orders' === $current_screen->id // HPOS screen.
		|| 'edit-shop_order' === $current_screen->id // Legacy screen.
		|| 'shop_order' === $current_screen->id; // Order details screen (legacy).
	}

	/**
	 * Fetches the fulfillments for the given order, caching them to avoid multiple fetches.
	 *
	 * @param WC_Order $order The order object.
	 *
	 * @return array The fulfillments for the order.
	 */
	private function maybe_read_fulfillments( WC_Order $order ): array {
		// Check if we've already fetched the fulfillments for this order.
		if ( isset( $this->fulfillments_cache[ $order->get_id() ] ) ) {
			return $this->fulfillments_cache[ $order->get_id() ];
		}

		// If not, fetch them and cache them.
		$data_store                                   = wc_get_container()->get( FulfillmentsDataStore::class );
		$fulfillments                                 = $data_store->read_fulfillments( WC_Order::class, '' . $order->get_id() );
		$this->fulfillments_cache[ $order->get_id() ] = $fulfillments;

		return $fulfillments;
	}
}
PK     [1]uW  W  "  Fulfillments/ShippingProviders.phpnu         <?php declare(strict_types=1);

use Automattic\WooCommerce\Internal\Fulfillments\Providers as ShippingProviders;

return array(
	'acs-courier'             => ShippingProviders\ACSCourierShippingProvider::class,
	'amazon-logistics'        => ShippingProviders\AmazonLogisticsShippingProvider::class,
	'an-post'                 => ShippingProviders\AnPostShippingProvider::class,
	'aras-kargo'              => ShippingProviders\ArasKargoShippingProvider::class,
	'australia-post'          => ShippingProviders\AustraliaPostShippingProvider::class,
	'azerpost'                => ShippingProviders\AzerpostShippingProvider::class,
	'bartolini-brt'           => ShippingProviders\BartoliniBRTShippingProvider::class,
	'belpochta'               => ShippingProviders\BelpochtaShippingProvider::class,
	'bpost'                   => ShippingProviders\BpostShippingProvider::class,
	'bulgarian-posts'         => ShippingProviders\BulgarianPostsShippingProvider::class,
	'canada-post'             => ShippingProviders\CanadaPostShippingProvider::class,
	'cdek'                    => ShippingProviders\CDEKShippingProvider::class,
	'ceska-posta'             => ShippingProviders\CeskaPostaShippingProvider::class,
	'chronopost'              => ShippingProviders\ChronopostShippingProvider::class,
	'correos'                 => ShippingProviders\CorreosShippingProvider::class,
	'ctt'                     => ShippingProviders\CTTShippingProvider::class,
	'cyprus-post'             => ShippingProviders\CyprusPostShippingProvider::class,
	'deutsche-post'           => ShippingProviders\DeutschePostShippingProvider::class,
	'dhl'                     => ShippingProviders\DHLShippingProvider::class,
	'dpd'                     => ShippingProviders\DPDShippingProvider::class,
	'econt'                   => ShippingProviders\EcontShippingProvider::class,
	'eimskip'                 => ShippingProviders\EimskipShippingProvider::class,
	'elta'                    => ShippingProviders\ELTAShippingProvider::class,
	'evri-hermes'             => ShippingProviders\EvriHermesShippingProvider::class,
	'fan-courier'             => ShippingProviders\FanCourierShippingProvider::class,
	'fastway'                 => ShippingProviders\FastwayShippingProvider::class,
	'fedex'                   => ShippingProviders\FedExShippingProvider::class,
	'geniki-taxydromiki'      => ShippingProviders\GenikiTaxydromikiShippingProvider::class,
	'gls'                     => ShippingProviders\GLSShippingProvider::class,
	'haypost'                 => ShippingProviders\HayPostShippingProvider::class,
	'helthjem'                => ShippingProviders\HelthjemShippingProvider::class,
	'hrvatska-posta'          => ShippingProviders\HrvatskaPostaShippingProvider::class,
	'inpost'                  => ShippingProviders\InPostShippingProvider::class,
	'islandspostur'           => ShippingProviders\IslandsposturShippingProvider::class,
	'itella'                  => ShippingProviders\ItellaShippingProvider::class,
	'kazpost'                 => ShippingProviders\KazpostShippingProvider::class,
	'la-poste-colissimo'      => ShippingProviders\LaPosteColissimoShippingProvider::class,
	'lasership-ontrac'        => ShippingProviders\LasershipOntracShippingProvider::class,
	'latvijas-pasts'          => ShippingProviders\LatvijasPastsShippingProvider::class,
	'liechtensteinische-post' => ShippingProviders\LiechtensteinischePostShippingProvider::class,
	'magyar-posta'            => ShippingProviders\MagyarPostaShippingProvider::class,
	'makedonska-posta'        => ShippingProviders\MakedonskaPostaShippingProvider::class,
	'maltapost'               => ShippingProviders\MaltaPostShippingProvider::class,
	'matkahuolto'             => ShippingProviders\MatkahuoltoShippingProvider::class,
	'mondial-relay'           => ShippingProviders\MondialRelayShippingProvider::class,
	'mpl'                     => ShippingProviders\MPLShippingProvider::class,
	'mrw'                     => ShippingProviders\MRWShippingProvider::class,
	'new-zealand-post'        => ShippingProviders\NewZealandPostShippingProvider::class,
	'nova-poshta'             => ShippingProviders\NovaPoshtaShippingProvider::class,
	'omniva'                  => ShippingProviders\OmnivaShippingProvider::class,
	'osterreichische-post'    => ShippingProviders\OsterreichischePostShippingProvider::class,
	'parcelforce'             => ShippingProviders\ParcelForceShippingProvider::class,
	'poczta-polska'           => ShippingProviders\PocztaPolskaShippingProvider::class,
	'post-luxembourg'         => ShippingProviders\PostLuxembourgShippingProvider::class,
	'posta-moldovei'          => ShippingProviders\PostaMoldoveiShippingProvider::class,
	'posta-romana'            => ShippingProviders\PostaRomanaShippingProvider::class,
	'poste-italiane'          => ShippingProviders\PosteItalianeShippingProvider::class,
	'poste-san-marino'        => ShippingProviders\PosteSanMarinoShippingProvider::class,
	'posten-norge-bring'      => ShippingProviders\PostenNorgeBringShippingProvider::class,
	'postnl'                  => ShippingProviders\PostNLShippingProvider::class,
	'postnord'                => ShippingProviders\PostNordShippingProvider::class,
	'purolator'               => ShippingProviders\PurolatorShippingProvider::class,
	'royal-mail'              => ShippingProviders\RoyalMailShippingProvider::class,
	'russian-post'            => ShippingProviders\RussianPostShippingProvider::class,
	'sda'                     => ShippingProviders\SDAShippingProvider::class,
	'seur'                    => ShippingProviders\SeurShippingProvider::class,
	'slovenska-posta'         => ShippingProviders\SlovenskaPostaShippingProvider::class,
	'spee-dee-delivery'       => ShippingProviders\SpeeDeeDeliveryShippingProvider::class,
	'startrack'               => ShippingProviders\StarTrackShippingProvider::class,
	'swiss-post'              => ShippingProviders\SwissPostShippingProvider::class,
	'toll'                    => ShippingProviders\TollShippingProvider::class,
	'ukrposhta'               => ShippingProviders\UkrposhtaShippingProvider::class,
	'ups'                     => ShippingProviders\UPSShippingProvider::class,
	'usps'                    => ShippingProviders\USPSShippingProvider::class,
	'urgent-cargus'           => ShippingProviders\UrgentCargusShippingProvider::class,
	'yurtici-kargo'           => ShippingProviders\YurticiKargoShippingProvider::class,
	'zasilkovna'              => ShippingProviders\ZasilkovnaShippingProvider::class,
);
PK     [1]Z$Id  d  *  MCP/Transport/WooCommerceRestTransport.phpnu         <?php
/**
 * WooCommerce MCP REST Transport with API validation.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\MCP\Transport;

use WP\MCP\Transport\HttpTransport;
use WP\MCP\Transport\Infrastructure\McpTransportContext;
use WP_REST_Request;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * WooCommerce MCP REST Transport class.
 *
 * Extends the base HttpTransport with standalone WooCommerce REST API key authentication.
 * Uses X-MCP-API-Key header with consumer_key:consumer_secret format.
 */
class WooCommerceRestTransport extends HttpTransport {

	/**
	 * Current MCP user's API key permissions.
	 *
	 * @var string|null
	 */
	private static $current_mcp_permissions = null;

	/**
	 * Constructor.
	 *
	 * @param McpTransportContext $context The transport context.
	 */
	public function __construct( McpTransportContext $context ) {
		parent::__construct( $context );

		// This filter is documented in the check_ability_permission method.
		add_filter( 'woocommerce_check_rest_ability_permissions_for_method', array( $this, 'check_ability_permission' ), 10, 3 );
	}

	/**
	 * Validate request using WooCommerce REST API authentication.
	 *
	 * @param WP_REST_Request|null $request The REST request object.
	 * @return bool|\WP_Error True if allowed, WP_Error if not.
	 */
	public function check_permission( $request = null ) {
		return $this->validate_request( $request );
	}

	/**
	 * Validate the MCP request using standalone authentication.
	 *
	 * @param \WP_REST_Request $request The REST request object.
	 * @return bool|\WP_Error True if allowed, WP_Error if not.
	 */
	public function validate_request( \WP_REST_Request $request ) {
		// Require TLS by default; allow explicit opt-in for non-SSL (e.g., local dev).
		/**
		 * Filter to allow insecure transport for MCP requests.
		 *
		 * @since 10.3.0
		 * @param bool             $allowed Whether to allow insecure transport.
		 * @param \WP_REST_Request $request The REST request object.
		 */
		if ( ! is_ssl() && ! apply_filters( 'woocommerce_mcp_allow_insecure_transport', false, $request ) ) {
			return new \WP_Error(
				'insecure_transport',
				__( 'HTTPS is required for MCP requests.', 'woocommerce' ),
				array( 'status' => 403 )
			);
		}

		// Get X-MCP-API-Key header.
		$api_key = $request->get_header( 'X-MCP-API-Key' );

		if ( empty( $api_key ) ) {
			return new \WP_Error(
				'missing_api_key',
				__( 'X-MCP-API-Key header required. Format: consumer_key:consumer_secret', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}

		if ( strpos( $api_key, ':' ) === false ) {
			return new \WP_Error(
				'invalid_api_key',
				__( 'X-MCP-API-Key must be in format consumer_key:consumer_secret', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}

		list( $consumer_key, $consumer_secret ) = explode( ':', $api_key, 2 );

		// Use our standalone authentication method.
		$result = $this->authenticate( $consumer_key, $consumer_secret );

		if ( is_wp_error( $result ) ) {
			return $result;
		}

		return true;
	}

	/**
	 * Authenticate user using consumer key and secret.
	 *
	 * @param string $consumer_key    Consumer key.
	 * @param string $consumer_secret Consumer secret.
	 * @return int|\WP_Error User ID on success, WP_Error on failure.
	 */
	private function authenticate( $consumer_key, $consumer_secret ) {
		global $wpdb;

		// Hash the consumer key as WooCommerce does.
		$hashed_consumer_key = wc_api_hash( trim( (string) $consumer_key ) );

		// Query the WooCommerce API keys table directly.
		$user_data = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT key_id, user_id, permissions, consumer_key, consumer_secret, nonces
				FROM {$wpdb->prefix}woocommerce_api_keys
				WHERE consumer_key = %s",
				$hashed_consumer_key
			)
		);

		// Check if user data was found.
		if ( empty( $user_data ) ) {
			return new \WP_Error(
				'authentication_failed',
				__( 'Authentication failed.', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}

		// Validate consumer secret using hash_equals for timing attack protection.
		if ( ! hash_equals( $user_data->consumer_secret, trim( (string) $consumer_secret ) ) ) {
			return new \WP_Error(
				'authentication_failed',
				__( 'Authentication failed.', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}

		// Store permissions for tool-level checking.
		self::$current_mcp_permissions = $user_data->permissions;

		// Ensure the user exists before switching context.
		$user = get_user_by( 'id', (int) $user_data->user_id );
		if ( ! $user ) {
			return new \WP_Error(
				'mcp_user_not_found',
				__( 'The user associated with this API key no longer exists.', 'woocommerce' ),
				array( 'status' => 401 )
			);
		}
		wp_set_current_user( $user->ID );

		return $user->ID;
	}

	/**
	 * Get the current MCP user's API key permissions.
	 *
	 * @return string|null The permissions (read, write, read_write) or null if no MCP context.
	 */
	public static function get_current_user_permissions(): ?string {
		return self::$current_mcp_permissions;
	}

	/**
	 * Check REST ability permissions for HTTP method.
	 *
	 * @param bool   $allowed    Whether the operation is allowed. Default false.
	 * @param string $method     HTTP method (GET, POST, PUT, DELETE).
	 * @param object $controller REST controller instance.
	 * @return bool Whether permission is granted.
	 */
	public function check_ability_permission( $allowed, $method, $controller ) {
		// Only check permissions if we have MCP context.
		$permissions = self::get_current_user_permissions();
		if ( null === $permissions ) {
			return $allowed;
		}

		// Check permissions based on method.
		switch ( $method ) {
			case 'HEAD':
			case 'GET':
				return ( 'read' === $permissions || 'read_write' === $permissions );
			case 'POST':
			case 'PUT':
			case 'PATCH':
			case 'DELETE':
				return ( 'write' === $permissions || 'read_write' === $permissions );
			case 'OPTIONS':
				return true;
			default:
				return false;
		}
	}
}
PK     [1]SO      MCP/MCPAdapterProvider.phpnu         <?php
/**
 * MCP Adapter Provider class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\MCP;

use Automattic\WooCommerce\Utilities\FeaturesUtil;
use Automattic\WooCommerce\Internal\Abilities\AbilitiesRegistry;
use Automattic\WooCommerce\Internal\MCP\Transport\WooCommerceRestTransport;

defined( 'ABSPATH' ) || exit;

/**
 * MCP Adapter Provider class for WooCommerce.
 *
 * Manages MCP (Model Context Protocol) adapter initialization and server configuration.
 * Abilities should be registered separately using the WordPress Abilities API.
 */
class MCPAdapterProvider {

	/**
	 * MCP server namespace.
	 *
	 * @var string
	 */
	const MCP_NAMESPACE = 'woocommerce';

	/**
	 * MCP server route.
	 *
	 * @var string
	 */
	const MCP_ROUTE = 'mcp';

	/**
	 * Whether MCP adapter is initialized.
	 *
	 * @var bool
	 */
	private bool $initialized = false;

	/**
	 * Constructor.
	 */
	public function __construct() {
		/*
		 * Hook into rest_api_init with priority 10 to initialize only on REST API requests.
		 * MCP adapter registers on rest_api_init with priority 20000, so we initialize earlier.
		 * This prevents unnecessary MCP initialization on favicon, cron, or admin requests.
		 */
		add_action( 'rest_api_init', array( $this, 'maybe_initialize' ), 10 );
	}

	/**
	 * Check feature flag and initialize MCP adapter if enabled.
	 */
	public function maybe_initialize(): void {
		// Check if MCP integration feature is enabled.
		if ( ! FeaturesUtil::feature_is_enabled( 'mcp_integration' ) ) {
			return;
		}

		// Prevent double initialization.
		if ( $this->initialized ) {
			return;
		}

		$this->initialize_mcp_adapter();
		$this->register_hooks();
		$this->initialized = true;
	}

	/**
	 * Initialize the MCP adapter.
	 */
	private function initialize_mcp_adapter(): void {
		// Check if MCP adapter class exists (should be autoloaded by WooCommerce's composer).
		if ( ! class_exists( 'WP\MCP\Core\McpAdapter' ) ) {
			if ( function_exists( 'wc_get_logger' ) ) {
				wc_get_logger()->warning(
					'MCP adapter class not found. Skipping MCP initialization.',
					array( 'source' => 'woocommerce-mcp' )
				);
			}
			return;
		}

		// Initialize the MCP adapter instance - this triggers the rest_api_init hook registration.
		\WP\MCP\Core\McpAdapter::instance();
	}

	/**
	 * Register WordPress hooks for MCP adapter.
	 */
	private function register_hooks(): void {
		// Initialize MCP server when MCP adapter is ready.
		add_action( 'mcp_adapter_init', array( $this, 'initialize_mcp_server' ) );
	}

	/**
	 * Initialize MCP server.
	 *
	 * @param object $adapter MCP adapter instance.
	 */
	public function initialize_mcp_server( $adapter ): void {
		// Get filtered abilities for MCP server.
		$abilities_ids = $this->get_woocommerce_mcp_abilities();

		// Bail if no abilities are available.
		if ( empty( $abilities_ids ) ) {
			return;
		}

		/*
		 * Temporarily disable MCP validation during server creation.
		 * Workaround for validator bug with union types (e.g., ["integer", "null"]).
		 * This will be removed once the mcp-adapter validator bug is fixed.
		 *
		 * @see https://github.com/WordPress/mcp-adapter/issues/47
		 */
		add_filter( 'mcp_validation_enabled', array( __CLASS__, 'disable_mcp_validation' ), 999 );

		try {
			// Create MCP server.
			$adapter->create_server(
				'woocommerce-mcp',
				self::MCP_NAMESPACE,
				self::MCP_ROUTE,
				__( 'WooCommerce MCP Server', 'woocommerce' ),
				__( 'AI-accessible WooCommerce operations via MCP', 'woocommerce' ),
				'1.0.0',
				array( WooCommerceRestTransport::class ),
				\WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler::class,
				\WP\MCP\Infrastructure\Observability\NullMcpObservabilityHandler::class,
				$abilities_ids,
			);
		} catch ( \Throwable $e ) {
			if ( function_exists( 'wc_get_logger' ) ) {
				wc_get_logger()->error(
					'MCP server initialization failed: ' . $e->getMessage(),
					array( 'source' => 'woocommerce-mcp' )
				);
			}
		} finally {
			// Re-enable MCP validation immediately after server creation.
			remove_filter( 'mcp_validation_enabled', array( __CLASS__, 'disable_mcp_validation' ), 999 );
		}
	}

	/**
	 * Get WooCommerce abilities for MCP server.
	 *
	 * Filters abilities to include only those with 'woocommerce/' namespace by default,
	 * with a filter to allow inclusion of abilities from other namespaces.
	 *
	 * @return array Array of ability IDs for MCP server.
	 */
	private function get_woocommerce_mcp_abilities(): array {
		// Get all abilities from the registry.
		$abilities_registry = wc_get_container()->get( AbilitiesRegistry::class );
		$all_abilities_ids  = $abilities_registry->get_abilities_ids();

		// Filter abilities based on namespace and custom filter.
		$mcp_abilities = array_filter(
			$all_abilities_ids,
			function ( $ability_id ) {
				// Include WooCommerce abilities by default.
				$include = str_starts_with( $ability_id, 'woocommerce/' );

				// Allow filter to override inclusion decision.
				/**
				 * Filter to override MCP ability inclusion decision.
				 *
				 * @since 10.3.0
				 * @param bool   $include    Whether to include the ability.
				 * @param string $ability_id The ability ID.
				 */
				return apply_filters( 'woocommerce_mcp_include_ability', $include, $ability_id );
			}
		);

		// Re-index array.
		return array_values( $mcp_abilities );
	}

	/**
	 * Temporarily disable MCP validation.
	 *
	 * Used as a callback for the mcp_validation_enabled filter to work around
	 * validator bugs with union types.
	 *
	 * @return bool Always returns false to disable validation.
	 */
	public static function disable_mcp_validation(): bool {
		return false;
	}

	/**
	 * Check if MCP adapter is initialized.
	 *
	 * @return bool Whether MCP adapter is initialized.
	 */
	public function is_initialized(): bool {
		return $this->initialized;
	}

	/**
	 * Check if the current request is for the MCP endpoint.
	 *
	 * @return bool True if this is an MCP endpoint request.
	 */
	public static function is_mcp_request(): bool {
		// Check if this is a REST request.
		if ( ! defined( 'REST_REQUEST' ) || ! REST_REQUEST ) {
			return false;
		}

		// Get the request URI.
		$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';

		// Build the MCP endpoint path dynamically from constants.
		$mcp_endpoint = '/' . self::MCP_NAMESPACE . '/' . self::MCP_ROUTE;

		// Check if the request is for the MCP endpoint.
		return false !== strpos( $request_uri, $mcp_endpoint );
	}
}
PK     [1]&e$O  $O    ProductFilters/QueryClauses.phpnu         <?php
/**
 * QueryClauses class file.
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFilters;

use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
use Automattic\WooCommerce\Internal\ProductFilters\Interfaces\QueryClausesGenerator;
use Automattic\WooCommerce\Internal\ProductFilters\Interfaces\MainQueryClausesGenerator;
use Automattic\WooCommerce\Internal\ProductFilters\CacheController;
use WC_Tax;
use WC_Cache_Helper;

defined( 'ABSPATH' ) || exit;

/**
 * Class for filter clauses.
 *
 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
 */
class QueryClauses implements QueryClausesGenerator, MainQueryClausesGenerator {
	/**
	 * Hold the filter params.
	 *
	 * @var Params
	 */
	private $params;

	/**
	 * Initialize the query clauses.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 * @param Params $params The filter params.
	 * @return void
	 */
	final public function init( Params $params ): void {
		$this->params = $params;
	}

	/**
	 * Add conditional query clauses based on the filter params in query vars.
	 *
	 * There isn't a clause for rating filter because we use tax_query for it
	 * (product_visibility).
	 *
	 * @param array     $args     Query args.
	 * @param \WP_Query $wp_query WP_Query object.
	 * @return array
	 */
	public function add_query_clauses( array $args, \WP_Query $wp_query ): array {
		if ( $wp_query->get( 'filter_stock_status' ) ) {
			$stock_statuses = trim( $wp_query->get( 'filter_stock_status' ) );
			$stock_statuses = explode( ',', $stock_statuses );

			$args = $this->add_stock_clauses( $args, $stock_statuses );
		}

		if ( $wp_query->get( 'min_price' ) || $wp_query->get( 'max_price' ) ) {
			$price_range = array(
				'min_price' => $wp_query->get( 'min_price' ),
				'max_price' => $wp_query->get( 'max_price' ),
			);
			$price_range = array_filter( $price_range );
			$args        = $this->add_price_clauses( $args, $price_range );
		}

		$args = $this->add_attribute_clauses(
			$args,
			$this->get_chosen_attributes( $wp_query->query_vars )
		);

		$args = $this->add_taxonomy_clauses(
			$args,
			$this->get_chosen_taxonomies( $wp_query->query_vars )
		);

		return $args;
	}

	/**
	 * Add query clauses for main query.
	 * WooCommerce handles attribute, price, and rating filters in the main query.
	 * This method is used to add stock status and taxonomy filters to the main query.
	 *
	 * @param array     $args     Query args.
	 * @param \WP_Query $wp_query WP_Query object.
	 * @return array
	 */
	public function add_query_clauses_for_main_query( array $args, \WP_Query $wp_query ): array {
		if (
			! $wp_query->is_main_query() ||
			'product_query' !== $wp_query->get( 'wc_query' )
		) {
			return $args;
		}

		if ( $wp_query->get( 'filter_stock_status' ) ) {
			$stock_statuses = trim( $wp_query->get( 'filter_stock_status' ) );
			$stock_statuses = explode( ',', $stock_statuses );
			$stock_statuses = array_filter( $stock_statuses );

			$args = $this->add_stock_clauses( $args, $stock_statuses );
		}

		$args = $this->add_taxonomy_clauses(
			$args,
			$this->get_chosen_taxonomies( $wp_query->query_vars )
		);

		return $args;
	}

	/**
	 * Add query clauses for stock filter.
	 *
	 * @param array $args           Query args.
	 * @param array $stock_statuses Stock statuses to be queried.
	 * @return array
	 */
	public function add_stock_clauses( array $args, array $stock_statuses ): array {
		$stock_statuses = array_filter( $stock_statuses );

		if ( empty( $stock_statuses ) ) {
			return $args;
		}

		$filtered_stock_statuses = array_intersect(
			array_map( 'esc_sql', $stock_statuses ),
			array_keys( wc_get_product_stock_status_options() )
		);

		if ( ! empty( $filtered_stock_statuses ) ) {
			$args['join']   = $this->append_product_sorting_table_join( $args['join'] );
			$args['where'] .= ' AND wc_product_meta_lookup.stock_status IN ("' . implode( '","', $filtered_stock_statuses ) . '")';
		}

		if ( ! empty( $stock_statuses ) && empty( $filtered_stock_statuses ) ) {
			$args['where'] .= ' AND 1=0';
		}

		return $args;
	}

	/**
	 * Add query clauses for price filter.
	 *
	 * @param array $args        Query args.
	 * @param array $price_range {
	 *     Price range array.
	 *
	 *     @type int|string $min_price Optional. Min price.
	 *     @type int|string $max_price Optional. Max Price.
	 * }
	 * @return array
	 */
	public function add_price_clauses( array $args, array $price_range ): array {
		if ( ! isset( $price_range['min_price'] ) && ! isset( $price_range['max_price'] ) ) {
			return $args;
		}

		global $wpdb;

		$adjust_for_taxes = $this->should_adjust_price_filters_for_displayed_taxes();
		$args['join']     = $this->append_product_sorting_table_join( $args['join'] );

		if ( isset( $price_range['min_price'] ) ) {
			$min_price_filter = intval( $price_range['min_price'] );

			if ( $adjust_for_taxes ) {
				$args['where'] .= $this->get_price_filter_query_for_displayed_taxes( $min_price_filter, 'max_price', '>=' );
			} else {
				$args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.max_price >= %f ', $min_price_filter );
			}
		}

		if ( isset( $price_range['max_price'] ) ) {
			$max_price_filter = intval( $price_range['max_price'] );

			if ( $adjust_for_taxes ) {
				$args['where'] .= $this->get_price_filter_query_for_displayed_taxes( $max_price_filter, 'min_price', '<=' );
			} else {
				$args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.min_price <= %f ', $max_price_filter );
			}
		}

		return $args;
	}

	/**
	 * Add query clauses for filtering products by attributes.
	 *
	 * @param array $args              Query args.
	 * @param array $chosen_attributes {
	 *     Chosen attributes array.
	 *
	 *     @type array {$taxonomy: Attribute taxonomy name} {
	 *         @type string[] $terms      Chosen terms' slug.
	 *         @type string   $query_type Query type. Accepts 'and' or 'or'.
	 *     }
	 * }
	 *
	 * @return array
	 */
	public function add_attribute_clauses( array $args, array $chosen_attributes ): array {
		if ( empty( $chosen_attributes ) ) {
			return $args;
		}

		global $wpdb;

		// The extra derived table ("SELECT product_or_parent_id FROM") is needed for performance
		// (causes the filtering subquery to be executed only once).
		$clause_root = " {$wpdb->posts}.ID IN ( SELECT product_or_parent_id FROM (";
		if ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ) {
			$in_stock_clause = ' AND in_stock = 1';
		} else {
			$in_stock_clause = '';
		}

		$attribute_ids_for_and_filtering = array();
		$clauses                         = array();

		// Get all terms for all attribute taxonomies in one query for better performance.
		$all_terms_slugs = array();
		foreach ( $chosen_attributes as $data ) {
			if ( ! empty( $data['terms'] ) && is_array( $data['terms'] ) ) {
				$all_terms_slugs = array_merge( $all_terms_slugs, $data['terms'] );
			}
		}

		$all_terms = get_terms(
			array(
				'taxonomy'   => array_keys( $chosen_attributes ),
				'slug'       => $all_terms_slugs,
				'hide_empty' => false,
			)
		);

		if ( is_wp_error( $all_terms ) ) {
			return $args;
		}

		// Group terms by taxonomy for easier processing.
		$terms_by_taxonomy = array();
		foreach ( $all_terms as $term ) {
			$terms_by_taxonomy[ $term->taxonomy ][] = $term;
		}

		foreach ( $chosen_attributes as $taxonomy => $data ) {
			$current_attribute_terms    = $terms_by_taxonomy[ $taxonomy ] ?? array();
			$term_ids_by_slug           = wp_list_pluck( $current_attribute_terms, 'term_id', 'slug' );
			$term_ids_to_filter_by      = array_values( array_intersect_key( $term_ids_by_slug, array_flip( $data['terms'] ) ) );
			$term_ids_to_filter_by      = array_map( 'absint', $term_ids_to_filter_by );
			$term_ids_to_filter_by_list = '(' . join( ',', $term_ids_to_filter_by ) . ')';
			$is_and_query               = 'and' === strtolower( $data['query_type'] );

			$count = count( $term_ids_to_filter_by );

			if ( 0 !== $count ) {
				if ( $is_and_query && $count > 1 ) {
					$attribute_ids_for_and_filtering = array_merge( $attribute_ids_for_and_filtering, $term_ids_to_filter_by );
				} else {
					$clauses[] = "
							{$clause_root}
							SELECT product_or_parent_id
							FROM {$this->get_lookup_table_name()} lt
							WHERE term_id in {$term_ids_to_filter_by_list}
							{$in_stock_clause}
						)";
				}
			}
		}

		if ( ! empty( $attribute_ids_for_and_filtering ) ) {
			$count                      = count( $attribute_ids_for_and_filtering );
			$term_ids_to_filter_by_list = '(' . join( ',', $attribute_ids_for_and_filtering ) . ')';
			$clauses[]                  = "
				{$clause_root}
				SELECT product_or_parent_id
				FROM {$this->get_lookup_table_name()} lt
				WHERE is_variation_attribute=0
				{$in_stock_clause}
				AND term_id in {$term_ids_to_filter_by_list}
				GROUP BY product_id
				HAVING COUNT(product_id)={$count}
				UNION
				SELECT product_or_parent_id
				FROM {$this->get_lookup_table_name()} lt
				WHERE is_variation_attribute=1
				{$in_stock_clause}
				AND term_id in {$term_ids_to_filter_by_list}
			)";
		}

		if ( ! empty( $clauses ) ) {
			// "temp" is needed because the extra derived tables require an alias.
			$args['where'] .= ' AND (' . join( ' temp ) AND ', $clauses ) . ' temp ))';
		} elseif ( ! empty( $chosen_attributes ) ) {
			$args['where'] .= ' AND 1=0';
		}

		return $args;
	}

	/**
	 * Add query clauses for taxonomy filter (e.g., product_cat, product_tag).
	 *
	 * @param array $args           Query args.
	 * @param array $chosen_taxonomies {
	 *     Chosen taxonomies array.
	 *
	 *     @type array {$taxonomy: Taxonomy name} {
	 *         @type string[] $terms Chosen terms' slug.
	 *     }
	 * }
	 * @return array
	 */
	public function add_taxonomy_clauses( array $args, array $chosen_taxonomies ): array {
		if ( empty( $chosen_taxonomies ) ) {
			return $args;
		}

		global $wpdb;

		$tax_queries = array();

		$all_terms = get_terms(
			array(
				'taxonomy'   => array_keys( $chosen_taxonomies ),
				'slug'       => array_merge( ...array_values( $chosen_taxonomies ) ),
				'hide_empty' => false,
			)
		);

		if ( is_wp_error( $all_terms ) ) {
			/**
			 * No error logging needed here because:
			 * 1. Taxonomy existence is already validated in the initial get_terms() call above
			 * 2. get_terms() only returns WP_Error for invalid taxonomy or rare DB connection issues
			 * 3. If the taxonomy was invalid, we would have failed earlier and never reached this code
			 * 4. Database errors would likely affect the entire request, not just this call
			 */
			return $args;
		}

		$term_ids_by_taxonomy = array();

		foreach ( $all_terms as $term ) {
			$term_ids_by_taxonomy[ $term->taxonomy ][] = $term->term_id;
		}

		foreach ( $term_ids_by_taxonomy as $taxonomy => $term_ids ) {
			if ( empty( $term_ids ) ) {
				continue;
			}

			if ( is_taxonomy_hierarchical( $taxonomy ) ) {
				$expanded_term_ids = $term_ids;

				foreach ( $term_ids as $term_id ) {
					$cache_key = WC_Cache_Helper::get_cache_prefix( CacheController::CACHE_GROUP ) . 'child_terms_' . $taxonomy . '_' . $term_id;
					$children  = wp_cache_get( $cache_key );

					if ( false === $children ) {
						$children = get_terms(
							array(
								'taxonomy'   => $taxonomy,
								'child_of'   => $term_id,
								'fields'     => 'ids',
								'hide_empty' => false,
							)
						);

						if ( ! is_wp_error( $children ) ) {
							wp_cache_set( $cache_key, $children, '', HOUR_IN_SECONDS );
						} else {
							$children = array();
						}
					}

					$expanded_term_ids = array_merge( $expanded_term_ids, $children );
				}

				$term_ids = array_unique( $expanded_term_ids );
			}

			$term_ids_list = '(' . implode( ',', array_map( 'absint', $term_ids ) ) . ')';

			/*
			 * Use EXISTS subquery for taxonomy filtering for several key benefits:
			 *
			 * 1. Performance: EXISTS stops execution as soon as the first matching row is found,
			 *    making it faster than JOIN approaches that need to process all matches.
			 *
			 * 2. No duplicate rows: Unlike JOINs, EXISTS doesn't create duplicate rows when
			 *    a product has multiple matching terms, eliminating the need for DISTINCT.
			 *
			 * 3. Clean boolean logic: We only care IF a product has the terms, not HOW MANY
			 *    or which specific ones, making EXISTS semantically correct.
			 *
			 * 4. Efficient combination: Multiple taxonomy filters can be combined with AND
			 *    without complex GROUP BY logic or performance degradation.
			 */
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
			$tax_queries[] = $wpdb->prepare(
				"EXISTS (
					SELECT 1 FROM {$wpdb->term_relationships} tr
					INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
					WHERE tr.object_id = {$wpdb->posts}.ID
					AND tt.taxonomy = %s
					AND tt.term_id IN {$term_ids_list}
				)",
				$taxonomy
			);
		}

		if ( ! empty( $tax_queries ) ) {
			$args['where'] .= ' AND (' . implode( ' AND ', $tax_queries ) . ')';
		} else {
			$args['where'] .= ' AND 1=0';
		}

		return $args;
	}

	/**
	 * Join wc_product_meta_lookup to posts if not already joined.
	 *
	 * @param string $sql SQL join.
	 * @return string
	 */
	private function append_product_sorting_table_join( string $sql ): string {
		global $wpdb;

		if ( ! strstr( $sql, 'wc_product_meta_lookup' ) ) {
			$sql .= " LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON $wpdb->posts.ID = wc_product_meta_lookup.product_id ";
		}
		return $sql;
	}

	/**
	 * If price filters need adjustment to work with displayed taxes, this returns true.
	 *
	 * This logic is used when prices are stored in the database differently to how they are being displayed, with regards
	 * to taxes.
	 *
	 * @return boolean
	 */
	private function should_adjust_price_filters_for_displayed_taxes(): bool {
		$display  = get_option( 'woocommerce_tax_display_shop' );
		$database = wc_prices_include_tax() ? 'incl' : 'excl';

		return $display !== $database;
	}

	/**
	 * Get query for price filters when dealing with displayed taxes.
	 *
	 * @param float  $price_filter Price filter to apply.
	 * @param string $column Price being filtered (min or max).
	 * @param string $operator Comparison operator for column. Accepts '>=' or '<='.
	 * @return string Constructed query.
	 */
	private function get_price_filter_query_for_displayed_taxes( float $price_filter, string $column = 'min_price', string $operator = '>=' ): string {
		global $wpdb;

		if ( ! in_array( $operator, array( '>=', '<=' ), true ) ) {
			return '';
		}

		// Select only used tax classes to avoid unwanted calculations.
		$cache_key           = WC_Cache_Helper::get_cache_prefix( 'filter_clauses' ) . 'tax_classes';
		$product_tax_classes = wp_cache_get( $cache_key );

		if ( ! $product_tax_classes ) {
			$product_tax_classes = $wpdb->get_col( "SELECT DISTINCT tax_class FROM {$wpdb->wc_product_meta_lookup};" );
			wp_cache_set( $cache_key, $product_tax_classes );
		}

		if ( empty( $product_tax_classes ) ) {
			return '';
		}

		$or_queries = array();

		// We need to adjust the filter for each possible tax class and combine the queries into one.
		foreach ( $product_tax_classes as $tax_class ) {
			$adjusted_price_filter = $this->adjust_price_filter_for_tax_class( $price_filter, $tax_class );
			$or_queries[]          = $wpdb->prepare(
				'( wc_product_meta_lookup.tax_class = %s AND wc_product_meta_lookup.`' . esc_sql( $column ) . '` ' . esc_sql( $operator ) . ' %f )',
				$tax_class,
				$adjusted_price_filter
			);
		}

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
		return $wpdb->prepare(
			' AND (
				wc_product_meta_lookup.tax_status = "taxable" AND ( 0=1 OR ' . implode( ' OR ', $or_queries ) . ')
				OR ( wc_product_meta_lookup.tax_status != "taxable" AND wc_product_meta_lookup.`' . esc_sql( $column ) . '` ' . esc_sql( $operator ) . ' %f )
			) ',
			$price_filter
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
	}

	/**
	 * Adjusts a price filter based on a tax class and whether or not the amount includes or excludes taxes.
	 *
	 * This calculation logic is based on `wc_get_price_excluding_tax` and `wc_get_price_including_tax` in core.
	 *
	 * @param float  $price_filter Price filter amount as entered.
	 * @param string $tax_class Tax class for adjustment.
	 * @return float
	 */
	private function adjust_price_filter_for_tax_class( float $price_filter, string $tax_class ): float {
		$tax_display    = get_option( 'woocommerce_tax_display_shop' );
		$tax_rates      = WC_Tax::get_rates( $tax_class );
		$base_tax_rates = WC_Tax::get_base_tax_rates( $tax_class );

		// If prices are shown incl. tax, we want to remove the taxes from the filter amount to match prices stored excl. tax.
		if ( 'incl' === $tax_display ) {
			/**
			 * Filters if taxes should be removed from locations outside the store base location.
			 *
			 * The woocommerce_adjust_non_base_location_prices filter can stop base taxes being taken off when dealing
			 * with out of base locations. e.g. If a product costs 10 including tax, all users will pay 10
			 * regardless of location and taxes.
			 *
			 * @since 2.6.0
			 *
			 * @internal Matches filter name in WooCommerce core.
			 *
			 * @param boolean $adjust_non_base_location_prices True by default.
			 * @return boolean
			 */
			$taxes = apply_filters( 'woocommerce_adjust_non_base_location_prices', true ) ? WC_Tax::calc_tax( $price_filter, $base_tax_rates, true ) : WC_Tax::calc_tax( $price_filter, $tax_rates, true );
			return $price_filter - array_sum( $taxes );
		}

		// If prices are shown excl. tax, add taxes to match the prices stored in the DB.
		$taxes = WC_Tax::calc_tax( $price_filter, $tax_rates, false );

		return $price_filter + array_sum( $taxes );
	}

	/**
	 * Get an array of attributes and terms selected from query arguments.
	 *
	 * @param array $query_vars The WP_Query arguments.
	 * @return array
	 */
	private function get_chosen_attributes( array $query_vars ): array {
		$chosen_attributes = array();

		if ( empty( $query_vars ) ) {
			return $chosen_attributes;
		}

		foreach ( $query_vars as $key => $value ) {
			if ( 0 === strpos( $key, 'filter_' ) ) {
				$attribute    = wc_sanitize_taxonomy_name( str_replace( 'filter_', '', $key ) );
				$taxonomy     = wc_attribute_taxonomy_name( $attribute );
				$filter_terms = ! empty( $value ) ? explode( ',', wc_clean( wp_unslash( $value ) ) ) : array();

				if ( empty( $filter_terms ) || ! taxonomy_exists( $taxonomy ) || ! wc_attribute_taxonomy_id_by_name( $attribute ) ) {
					continue;
				}

				$query_type                                   = ! empty( $query_vars[ 'query_type_' . $attribute ] ) && in_array( $query_vars[ 'query_type_' . $attribute ], array( 'and', 'or' ), true ) ? wc_clean( wp_unslash( $query_vars[ 'query_type_' . $attribute ] ) ) : '';
				$chosen_attributes[ $taxonomy ]['terms']      = array_map( 'sanitize_title', $filter_terms ); // Ensures correct encoding.
				$chosen_attributes[ $taxonomy ]['query_type'] = $query_type ? $query_type : 'and';
			}
		}

		return $chosen_attributes;
	}

	/**
	 * Get an array of taxonomies and terms selected from query arguments.
	 *
	 * @param array $query_vars The WP_Query arguments.
	 * @return array
	 */
	private function get_chosen_taxonomies( array $query_vars ): array {
		$chosen_taxonomies = array();

		if ( empty( $query_vars ) ) {
			return $chosen_taxonomies;
		}

		foreach ( $this->params->get_param( 'taxonomy' ) as $taxonomy => $param ) {
			if ( isset( $query_vars[ $param ] ) && ! empty( trim( $query_vars[ $param ] ) ) ) {
				$chosen_taxonomies[ $taxonomy ] = array_filter( array_map( 'sanitize_title', explode( ',', $query_vars[ $param ] ) ) );
			}
		}

		return $chosen_taxonomies;
	}

	/**
	 * Get attribute lookup table name.
	 *
	 * @return string
	 */
	private function get_lookup_table_name(): string {
		return wc_get_container()->get( LookupDataStore::class )->get_lookup_table_name();
	}
}
PK     [1]26uh  h  %  ProductFilters/FilterDataProvider.phpnu         <?php
/**
 * Provider class file.
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFilters;

use Automattic\WooCommerce\Internal\ProductFilters\Interfaces\QueryClausesGenerator;
use Automattic\WooCommerce\Internal\ProductFilters\TaxonomyHierarchyData;

defined( 'ABSPATH' ) || exit;

/**
 * Provider class.
 *
 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
 */
class FilterDataProvider {
	/**
	 * Hold initialized providers.
	 *
	 * @var array Product filter data providers.
	 */
	private $providers = array();

	/**
	 * Instance of TaxonomyHierarchyData.
	 *
	 * @var TaxonomyHierarchyData
	 */
	private $taxonomy_hierarchy_data;

	/**
	 * Initialize dependencies.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 *
	 * @param TaxonomyHierarchyData $taxonomy_hierarchy_data Instance of TaxonomyHierarchyData.
	 *
	 * @return void
	 */
	final public function init( TaxonomyHierarchyData $taxonomy_hierarchy_data ): void {
		$this->taxonomy_hierarchy_data = $taxonomy_hierarchy_data;
	}

	/**
	 * Get the data provider with desired query clauses generator.
	 *
	 * @param QueryClausesGenerator $query_clauses_generator The query clauses generator instance.
	 */
	public function with( QueryClausesGenerator $query_clauses_generator ) {
		$class_name = get_class( $query_clauses_generator );

		if ( ! isset( $this->providers[ $class_name ] ) ) {
			$this->providers[ $class_name ] = new FilterData( $query_clauses_generator, $this->taxonomy_hierarchy_data );
		}

		return $this->providers[ $class_name ];
	}
}
PK     [1]kt
  
    ProductFilters/Params.phpnu         <?php

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFilters;

use Automattic\WooCommerce\Internal\ProductFilters\Interfaces\FilterUrlParam;

defined( 'ABSPATH' ) || exit;

/**
 * Single source of truth for managing all filter params.
 *
 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
 */
class Params implements FilterUrlParam {
	/**
	 * Hold the filter params.
	 *
	 * @var array
	 */
	private static $params = array();

	/**
	 * Get the param keys.
	 *
	 * @return array
	 */
	public function get_param_keys(): array {
		if ( empty( self::$params ) ) {
			$this->init_params();
		}

		$keys = array();
		foreach ( self::$params as $taxonomy => $params ) {
			$keys = array_merge( $keys, array_values( $params ) );
			if ( 'attribute' === $taxonomy ) {
				$query_type_params = array_map(
					function ( $param ) {
						return 'query_type_' . $param;
					},
					array_keys( $params )
				);
				$keys              = array_merge( $keys, $query_type_params );
			}
		}

		return $keys;
	}

	/**
	 * Get the param.
	 *
	 * @param string $type The type of param to get.
	 * @return array
	 */
	public function get_param( string $type ): array {
		if ( empty( self::$params ) ) {
			$this->init_params();
		}

		return self::$params[ $type ] ?? array();
	}

	/**
	 * Initialize the params.
	 *
	 * @return void
	 */
	private function init_params(): void {
		self::$params = array(
			'price'     => array(
				'min_price',
				'max_price',
			),
			'rating'    => array(
				'rating_filter',
			),
			'status'    => array(
				'filter_stock_status',
			),
			'attribute' => $this->get_attribute_params(),
			'taxonomy'  => $this->get_taxonomy_params(),
		);
	}

	/**
	 * Get the attribute params.
	 *
	 * @return array
	 */
	private function get_attribute_params(): array {
		$params = array();
		foreach ( wc_get_attribute_taxonomies() as $attribute ) {
			$params[ $attribute->attribute_name ] = "filter_$attribute->attribute_name";
		}

		return $params;
	}

	/**
	 * Get the taxonomy params.
	 *
	 * @return array
	 */
	private function get_taxonomy_params(): array {
		$public_product_taxonomies = get_taxonomies(
			array(
				'public'  => true,
				'show_ui' => true,
			),
			'objects'
		);

		// We have control over built-in taxonomies, so we can use prettier names.
		$map = array(
			'product_cat'   => 'categories',
			'product_tag'   => 'tags',
			'product_brand' => 'brands',
		);

		$params = array();

		foreach ( $public_product_taxonomies as $taxonomy ) {
			if ( is_array( $taxonomy->object_type ) && in_array( 'product', $taxonomy->object_type, true ) ) {
				$params[ $taxonomy->name ] = $map[ $taxonomy->name ] ?? "filter_$taxonomy->name";
			}
		}

		return $params;
	}
}
PK     [1]LmK  K    ProductFilters/FilterData.phpnu         <?php

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFilters;

use Automattic\WooCommerce\Internal\ProductFilters\Interfaces\QueryClausesGenerator;
use Automattic\WooCommerce\Internal\ProductFilters\TaxonomyHierarchyData;
use WC_Cache_Helper;

defined( 'ABSPATH' ) || exit;

/**
 * Class for filter counts.
 *
 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
 */
class FilterData {
	/**
	 * Instance of QueryClauses.
	 *
	 * @var QueryClausesGenerator
	 */
	private $query_clauses;

	/**
	 * Instance of TaxonomyHierarchyData.
	 *
	 * @var TaxonomyHierarchyData
	 */
	private $taxonomy_hierarchy_data;

	/**
	 * Constructor.
	 *
	 * @param QueryClausesGenerator $query_clauses Instance of QueryClausesGenerator.
	 * @param TaxonomyHierarchyData $taxonomy_hierarchy_data Instance of TaxonomyHierarchyData.
	 */
	public function __construct( QueryClausesGenerator $query_clauses, TaxonomyHierarchyData $taxonomy_hierarchy_data ) {
		$this->query_clauses           = $query_clauses;
		$this->taxonomy_hierarchy_data = $taxonomy_hierarchy_data;
	}

	/**
	 * Get price data for current products.
	 *
	 * @param array $query_vars The WP_Query arguments.
	 * @return object
	 */
	public function get_filtered_price( array $query_vars ) {
		/**
		 * Allows offloading the filter data to external services like Elasticsearch.
		 *
		 * @hook woocommerce_pre_product_filter_data
		 *
		 * @since 9.9.0
		 *
		 * @param array  $results      The results for current query.
		 * @param string $filter_type  The type of filter. Accepts price|stock|rating|attribute.
		 * @param array  $query_vars   The query arguments to calculate the filter data.
		 * @param array  $extra        Some filter types require extra arguments for calculation, like attribute.
		 * @return array The filtered results or null to continue with default processing.
		 */
		$pre_filter_counts = apply_filters( 'woocommerce_pre_product_filter_data', null, 'price', $query_vars, array() );

		if ( is_array( $pre_filter_counts ) ) {
			return $pre_filter_counts;
		}

		$transient_key = $this->get_transient_key( $query_vars, 'price' );
		$cached_data   = $this->get_cache( $transient_key );

		if ( ! empty( $cached_data ) ) {
			return $cached_data;
		}

		$results     = array();
		$product_ids = $this->get_cached_product_ids( $query_vars );

		if ( $product_ids ) {
			global $wpdb;

			$price_filter_sql = "
			SELECT min( min_price ) as min_price, MAX( max_price ) as max_price
			FROM {$wpdb->wc_product_meta_lookup}
			WHERE product_id IN ( {$product_ids} )
			";

			/**
			* We can't use $wpdb->prepare() here because using %s with
			* $wpdb->prepare() for a subquery won't work as it will escape the SQL
			* query.
			* We're using the query as is, same as Core does.
			*/
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
			$results = (array) $wpdb->get_row( $price_filter_sql );
		}

		/**
		 * Filters the product filter data before it is returned.
		 *
		 * @hook woocommerce_product_filter_data
		 * @since 9.9.0
		 *
		 * @param array  $results      The results for current query.
		 * @param string $filter_type  The type of filter. Accepts price|stock|rating|attribute.
		 * @param array  $query_vars   The query arguments to calculate the filter data.
		 * @param array  $extra        Some filter types require extra arguments for calculation, like attribute.
		 * @return array The filtered results
		 */
		$results = apply_filters( 'woocommerce_product_filter_data', $results, 'price', $query_vars, array() );

		$this->set_cache( $transient_key, $results );

		return $results;
	}

	/**
	 * Get stock status counts for the current products.
	 *
	 * @param array $query_vars The WP_Query arguments.
	 * @param array $statuses   Array of stock status values to count.
	 * @return array status=>count pairs.
	 */
	public function get_stock_status_counts( array $query_vars, array $statuses ) {
		/**
		 * Filter the data. @see get_filtered_price() for full documentation.
		 */
		$pre_filter_counts = apply_filters( 'woocommerce_pre_product_filter_data', null, 'stock', $query_vars, array() ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment

		if ( is_array( $pre_filter_counts ) ) {
			return $pre_filter_counts;
		}

		$transient_key = $this->get_transient_key( $query_vars, 'stock' );
		$cached_data   = $this->get_cache( $transient_key );

		if ( ! empty( $cached_data ) ) {
			return $cached_data;
		}

		$results     = array();
		$product_ids = $this->get_cached_product_ids( $query_vars );

		if ( $product_ids ) {
			global $wpdb;

			foreach ( $statuses as $status ) {
				$stock_status_count_sql = "
					SELECT COUNT( DISTINCT posts.ID ) as status_count
					FROM {$wpdb->posts} as posts
					INNER JOIN {$wpdb->postmeta} as postmeta ON posts.ID = postmeta.post_id
					AND postmeta.meta_key = '_stock_status'
					AND postmeta.meta_value = '" . esc_sql( $status ) . "'
					WHERE posts.ID IN ( {$product_ids} )
				";

				/**
				* We can't use $wpdb->prepare() here because using %s with
				* $wpdb->prepare() for a subquery won't work as it will escape the
				* SQL query.
				* We're using the query as is, same as Core does.
				*/
				// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				$result             = $wpdb->get_row( $stock_status_count_sql );
				$results[ $status ] = $result->status_count;
			}
		}

		/**
		 * Filter the results. @see get_filtered_price() for full documentation.
		 */
		$results = apply_filters( 'woocommerce_product_filter_data', $results, 'stock', $query_vars, array() ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment

		$this->set_cache( $transient_key, $results );

		return $results;
	}

	/**
	 * Get rating counts for the current products.
	 *
	 * @param array $query_vars The WP_Query arguments.
	 * @return array rating=>count pairs.
	 */
	public function get_rating_counts( array $query_vars ) {
		/**
		 * Filter the data. @see get_filtered_price() for full documentation.
		 */
		$pre_filter_counts = apply_filters( 'woocommerce_pre_product_filter_data', null, 'rating', $query_vars, array() ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment

		if ( is_array( $pre_filter_counts ) ) {
			return $pre_filter_counts;
		}

		$transient_key = $this->get_transient_key( $query_vars, 'rating' );
		$cached_data   = $this->get_cache( $transient_key );

		if ( ! empty( $cached_data ) ) {
			return $cached_data;
		}

		$results     = array();
		$product_ids = $this->get_cached_product_ids( $query_vars );

		if ( $product_ids ) {
			global $wpdb;

			$rating_count_sql = "
				SELECT COUNT( DISTINCT product_id ) as product_count, ROUND( average_rating, 0 ) as rounded_average_rating
				FROM {$wpdb->wc_product_meta_lookup}
				WHERE product_id IN ( {$product_ids} )
				AND average_rating > 0
				GROUP BY rounded_average_rating
				ORDER BY rounded_average_rating DESC
			";

			/**
			* We can't use $wpdb->prepare() here because using %s with
			* $wpdb->prepare() for a subquery won't work as it will escape the
			* SQL query.
			* We're using the query as is, same as Core does.
			*/
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
			$results = $wpdb->get_results( $rating_count_sql );
			$results = array_map( 'absint', wp_list_pluck( $results, 'product_count', 'rounded_average_rating' ) );
		}

		/**
		 * Filter the results. @see get_filtered_price() for full documentation.
		 */
		$results = apply_filters( 'woocommerce_product_filter_data', $results, 'rating', $query_vars, array() ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment

		$this->set_cache( $transient_key, $results );

		return $results;
	}

	/**
	 * Get attribute counts for the current products.
	 *
	 * @param array  $query_vars         The WP_Query arguments.
	 * @param string $attribute_to_count Attribute taxonomy name.
	 * @return array termId=>count pairs.
	 */
	public function get_attribute_counts( array $query_vars, string $attribute_to_count ) {
		/**
		 * Filter the data. @see get_filtered_price() for full documentation.
		 */
		$pre_filter_counts = apply_filters( 'woocommerce_pre_product_filter_data', null, 'attribute', $query_vars, array( 'taxonomy' => $attribute_to_count ) ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment

		if ( is_array( $pre_filter_counts ) ) {
			return $pre_filter_counts;
		}

		$transient_key = $this->get_transient_key( $query_vars, 'attribute', array( 'taxonomy' => $attribute_to_count ) );
		$cached_data   = $this->get_cache( $transient_key );

		if ( ! empty( $cached_data ) ) {
			return $cached_data;
		}

		$results     = array();
		$product_ids = $this->get_cached_product_ids( $query_vars );

		if ( $product_ids ) {
			global $wpdb;

			$taxonomy_escaped    = esc_sql( wc_sanitize_taxonomy_name( $attribute_to_count ) );
			$attribute_count_sql = "
				SELECT COUNT( DISTINCT posts.ID ) as term_count, terms.term_id as term_count_id
				FROM {$wpdb->posts} AS posts
				INNER JOIN {$wpdb->term_relationships} AS term_relationships ON posts.ID = term_relationships.object_id
				INNER JOIN {$wpdb->term_taxonomy} AS term_taxonomy USING( term_taxonomy_id )
				INNER JOIN {$wpdb->terms} AS terms USING( term_id )
				WHERE posts.ID IN ( {$product_ids} )
				AND term_taxonomy.taxonomy = '{$taxonomy_escaped}'
				GROUP BY terms.term_id
			";

			/**
			 * We can't use $wpdb->prepare() here because using %s with
			 * $wpdb->prepare() for a subquery won't work as it will escape the
			 * SQL query.
			 * We're using the query as is, same as Core does.
			 */
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
			$results = $wpdb->get_results( $attribute_count_sql );
			$results = array_map( 'absint', wp_list_pluck( $results, 'term_count', 'term_count_id' ) );
		}

		/**
		 * Filter the results. @see get_filtered_price() for full documentation.
		 *
		 * @since 9.9.0
		 */
		$results = apply_filters( 'woocommerce_product_filter_data', $results, 'attribute', $query_vars, array( 'taxonomy' => $attribute_to_count ) );

		$this->set_cache( $transient_key, $results );

		return $results;
	}

	/**
	 * Get taxonomy counts for the current products.
	 *
	 * @param array  $query_vars The WP_Query arguments.
	 * @param string $taxonomy_to_count   Taxonomy name.
	 * @return array termId=>count pairs.
	 */
	public function get_taxonomy_counts( array $query_vars, string $taxonomy_to_count ) {
		/**
		 * Filter the data. @see get_filtered_price() for full documentation.
		 *
		 * @since 9.9.0
		 */
		$pre_filter_counts = apply_filters( 'woocommerce_pre_product_filter_data', null, 'taxonomy', $query_vars, array( 'taxonomy' => $taxonomy_to_count ) );

		if ( is_array( $pre_filter_counts ) ) {
			return $pre_filter_counts;
		}

		$transient_key = $this->get_transient_key( $query_vars, 'taxonomy', array( 'taxonomy' => $taxonomy_to_count ) );
		$cached_data   = $this->get_cache( $transient_key );

		if ( ! empty( $cached_data ) ) {
			return $cached_data;
		}

		$results     = array();
		$product_ids = $this->get_cached_product_ids( $query_vars );

		if ( $product_ids ) {
			global $wpdb;

			$taxonomy_escaped = esc_sql( wc_sanitize_taxonomy_name( $taxonomy_to_count ) );

			if ( is_taxonomy_hierarchical( $taxonomy_to_count ) ) {
				$results = $this->get_hierarchical_taxonomy_counts( $product_ids, $taxonomy_to_count );
			} else {
				$taxonomy_count_sql = "
					SELECT COUNT( DISTINCT term_relationships.object_id ) as term_count, term_taxonomy.term_taxonomy_id as term_count_id
					FROM {$wpdb->term_relationships} AS term_relationships
					INNER JOIN {$wpdb->term_taxonomy} AS term_taxonomy USING( term_taxonomy_id )
					WHERE term_relationships.object_id IN ( {$product_ids} )
					AND term_taxonomy.taxonomy = '{$taxonomy_escaped}'
					GROUP BY term_taxonomy.term_taxonomy_id
				";

				/**
				 * We can't use $wpdb->prepare() here because using %s with
				 * $wpdb->prepare() for a subquery won't work as it will escape the
				 * SQL query.
				 * We're using the query as is, same as Core does.
				 */
				// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				$base_results = $wpdb->get_results( $taxonomy_count_sql );
				$results      = array_map( 'absint', wp_list_pluck( $base_results, 'term_count', 'term_count_id' ) );
			}
		}

		/**
		 * Filter the results. @see get_filtered_price() for full documentation.
		 *
		 * @since 9.9.0
		 */
		$results = apply_filters( 'woocommerce_product_filter_data', $results, 'taxonomy', $query_vars, array( 'taxonomy' => $taxonomy_to_count ) );

		$this->set_cache( $transient_key, $results );

		return $results;
	}

	/**
	 * Get hierarchical taxonomy counts using optimized hierarchy data.
	 *
	 * @param string $product_ids   Comma-separated list of product IDs.
	 * @param string $taxonomy_name Original taxonomy name for hierarchy methods.
	 * @return array Array of term_id => count pairs.
	 */
	private function get_hierarchical_taxonomy_counts( string $product_ids, string $taxonomy_name ) {
		global $wpdb;

		// Step 1: Get all terms that have products in the filtered set (1 query).
		$taxonomy_escaped = esc_sql( wc_sanitize_taxonomy_name( $taxonomy_name ) );
		$base_terms_sql   = "
			SELECT DISTINCT tt.term_id, tt.term_taxonomy_id
			FROM {$wpdb->term_relationships} tr
			INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
			WHERE tr.object_id IN ( {$product_ids} )
			AND tt.taxonomy = '{$taxonomy_escaped}'
		";

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		$base_terms = $wpdb->get_results( $base_terms_sql );

		if ( empty( $base_terms ) ) {
			return array();
		}

		// Step 2: Build hierarchy relationships using TaxonomyHierarchyData.
		$hierarchy_counts = array();
		$processed_terms  = array();

		// Process each base term and its ancestors.
		foreach ( $base_terms as $term ) {
			$term_id = (int) $term->term_id;

			// Count for the term itself and all its descendants.
			if ( ! isset( $hierarchy_counts[ $term_id ] ) ) {
				$descendants                  = $this->taxonomy_hierarchy_data->get_descendants( $term_id, $taxonomy_name );
				$descendants[]                = $term_id; // Include the term itself.
				$hierarchy_counts[ $term_id ] = $descendants;
			}

			// Get ancestors using hierarchy data.
			$ancestors = $this->taxonomy_hierarchy_data->get_ancestors( $term_id, $taxonomy_name );
			foreach ( $ancestors as $ancestor_id ) {
				if ( in_array( $ancestor_id, $processed_terms, true ) ) {
					continue;
				}

				$descendants   = $this->taxonomy_hierarchy_data->get_descendants( $ancestor_id, $taxonomy_name );
				$descendants[] = $ancestor_id; // Include the ancestor term itself.

				$hierarchy_counts[ $ancestor_id ] = $descendants;
				$processed_terms[]                = $ancestor_id;
			}
		}

		if ( empty( $hierarchy_counts ) ) {
			return array();
		}

		// Step 3: Execute batch counting using a single query with CASE statements.
		$count_cases = array();
		foreach ( $hierarchy_counts as $term_id => $term_ids ) {
			$term_ids_str  = implode( ',', array_map( 'absint', $term_ids ) );
			$count_cases[] = "COUNT(DISTINCT CASE WHEN tt.term_id IN ({$term_ids_str}) THEN tr.object_id END) as count_{$term_id}";
		}

		$batch_count_sql = '
			SELECT ' . implode( ', ', $count_cases ) . "
			FROM {$wpdb->term_relationships} tr
			INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
			WHERE tr.object_id IN ( {$product_ids} )
			AND tt.taxonomy = '{$taxonomy_escaped}'
		";

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		$count_result = $wpdb->get_row( $batch_count_sql, ARRAY_A );

		if ( empty( $count_result ) ) {
			return array();
		}

		// Parse results back to term_id => count format.
		$final_counts = array();
		foreach ( $hierarchy_counts as $term_id => $term_ids ) {
			$count_key = "count_{$term_id}";
			if ( isset( $count_result[ $count_key ] ) && $count_result[ $count_key ] > 0 ) {
				$final_counts[ $term_id ] = absint( $count_result[ $count_key ] );
			}
		}

		return $final_counts;
	}

	/**
	 * Get filter data transient key.
	 *
	 * @param array  $query_vars   The query arguments to calculate the filter data.
	 * @param string $filter_type The type of filter. Accepts price|stock|rating|attribute.
	 * @param array  $extra        Some filter types require extra arguments for calculation, like attribute.
	 */
	private function get_transient_key( $query_vars, $filter_type, $extra = array() ) {
		return sprintf(
			'wc_%s_%s',
			CacheController::CACHE_GROUP,
			md5(
				wp_json_encode(
					array(
						'query_vars'  => $query_vars,
						'extra'       => $extra,
						'filter_type' => $filter_type,
					)
				)
			)
		);
	}

	/**
	 * Get cached filter data.
	 *
	 * @param string $key Transient key.
	 */
	private function get_cache( $key ) {
		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			return null;
		}

		$cache             = get_transient( $key );
		$transient_version = WC_Cache_Helper::get_transient_version( CacheController::CACHE_GROUP );

		if ( empty( $cache['version'] ) ||
			! is_array( $cache['value'] ) ||
			empty( $cache['value'] ) ||
			$transient_version !== $cache['version']
		) {
			return null;
		}

		return $cache['value'];
	}

	/**
	 * Set the cache with transient version to invalidate all at once when needed.
	 *
	 * @param string $key   Transient key.
	 * @param mix    $value Value to set.
	 *
	 * @return bool True if the cache was set, false otherwise.
	 */
	private function set_cache( $key, $value ) {
		if ( ! is_array( $value ) ) {
			return false;
		}

		$transient_version = WC_Cache_Helper::get_transient_version( CacheController::CACHE_GROUP );
		$transient_value   = array(
			'version' => $transient_version,
			'value'   => $value,
		);

		$result = set_transient( $key, $transient_value, DAY_IN_SECONDS );

		return $result;
	}

	/**
	 * Get cached product IDs from query vars.
	 *
	 * Executes a WP_Query with the given query vars and returns a comma-separated string of product IDs.
	 * Results are cached to avoid repeated database queries.
	 *
	 * @param array $query_vars The WP_Query arguments.
	 * @return string Comma-separated list of product IDs.
	 */
	private function get_cached_product_ids( array $query_vars ) {
		$cache_key = WC_Cache_Helper::get_cache_prefix( CacheController::CACHE_GROUP ) . md5( wp_json_encode( $query_vars ) );
		$cache     = wp_cache_get( $cache_key );

		if ( $cache ) {
			return $cache;
		}

		add_filter( 'posts_clauses', array( $this->query_clauses, 'add_query_clauses' ), 10, 2 );
		add_filter( 'posts_pre_query', '__return_empty_array' );

		$query_vars['no_found_rows']  = true;
		$query_vars['posts_per_page'] = -1;
		$query_vars['fields']         = 'ids';
		$query                        = new \WP_Query();

		$query->query( $query_vars );

		remove_filter( 'posts_clauses', array( $this->query_clauses, 'add_query_clauses' ), 10 );
		remove_filter( 'posts_pre_query', '__return_empty_array' );

		global $wpdb;

		// The query is already prepared by WP_Query.
		$results = $wpdb->get_results( $query->request, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

		if ( ! $results ) {
			$results = array();
		}

		$results = implode( ',', array_column( $results, 'ID' ) );

		wp_cache_set( $cache_key, $results );

		return $results;
	}
}
PK     [1]0M@  @  (  ProductFilters/TaxonomyHierarchyData.phpnu         <?php

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFilters;

defined( 'ABSPATH' ) || exit;

/**
 * Class for managing taxonomy hierarchy data with performance optimization.
 *
 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
 */
class TaxonomyHierarchyData {

	/**
	 * Cache group for taxonomy hierarchy data.
	 */
	private const CACHE_GROUP = 'wc_taxonomy_hierarchy';

	/**
	 * In-memory cache for hierarchy maps.
	 *
	 * @var array
	 */
	private $hierarchy_data = array();

	/**
	 * Get optimized hierarchy map for a taxonomy.
	 *
	 * @param string $taxonomy The taxonomy name.
	 * @return array Hierarchy map structure optimized for the taxonomy size.
	 */
	public function get_hierarchy_map( string $taxonomy ): array {
		if ( ! is_taxonomy_hierarchical( $taxonomy ) ) {
			return array();
		}

		// Check in-memory cache first.
		if ( isset( $this->hierarchy_data[ $taxonomy ] ) ) {
			return $this->hierarchy_data[ $taxonomy ];
		}

		// Check option cache.
		$cache_key  = self::CACHE_GROUP . '_' . $taxonomy;
		$cached_map = null;

		if ( ! ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) {
			$cached_map = get_option( $cache_key );
		}

		if ( ! empty( $cached_map ) && $this->validate_cache( $cached_map ) ) {
			// Cache in memory and return.
			$this->hierarchy_data[ $taxonomy ] = $cached_map;
			return $cached_map;
		}

		// Build the complete hierarchy map with all descendants pre-computed.
		$map = $this->build_full_hierarchy_map( $taxonomy );

		// Cache the map in options and memory.
		if ( ! ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) {
			update_option( $cache_key, $map, false );
		}

		$this->hierarchy_data[ $taxonomy ] = $map;

		return $map;
	}

	/**
	 * Get all descendants for a term.
	 *
	 * @param int    $term_id  The term ID.
	 * @param string $taxonomy The taxonomy name.
	 * @return array Array of all descendant term IDs.
	 */
	public function get_descendants( int $term_id, string $taxonomy ): array {
		$map = $this->get_hierarchy_map( $taxonomy );
		return $map['descendants'][ $term_id ] ?? array();
	}

	/**
	 * Get ancestor chain for batch processing.
	 *
	 * @param int    $term_id  The term ID.
	 * @param string $taxonomy The taxonomy name.
	 * @return array Array of ancestor term IDs (bottom-up).
	 */
	public function get_ancestors( int $term_id, string $taxonomy ): array {
		$map = $this->get_hierarchy_map( $taxonomy );
		return $map['ancestors'][ $term_id ] ?? array();
	}

	/**
	 * Clear hierarchy cache for a taxonomy.
	 *
	 * @param string $taxonomy The taxonomy name.
	 */
	public function clear_cache( string $taxonomy ): void {
		// Clear in-memory cache for this taxonomy.
		unset( $this->hierarchy_data[ $taxonomy ] );

		// Clear only the specific taxonomy's option cache.
		$cache_key = self::CACHE_GROUP . '_' . $taxonomy;
		delete_option( $cache_key );
	}

	/**
	 * Check if the cache is valid.
	 *
	 * @param array $data Cache data.
	 *
	 * @return boolean
	 */
	private function validate_cache( $data ) {
		return is_array( $data ) &&
			array_key_exists( 'descendants', $data ) &&
			array_key_exists( 'ancestors', $data ) &&
			array_key_exists( 'tree', $data );
	}

	/**
	 * Build hierarchy map for FilterData and ProductFilterTaxonomy.
	 *
	 * Pre-computes descendants and ancestor chains for maximum query speed.
	 *
	 * @param string $taxonomy The taxonomy name.
	 * @return array Complete hierarchy map with descendants and ancestor chains.
	 */
	private function build_full_hierarchy_map( string $taxonomy ): array {
		$terms = get_terms(
			array(
				'taxonomy'   => $taxonomy,
				'hide_empty' => false,
				'orderby'    => 'name',
				'order'      => 'ASC',
			)
		);

		if ( is_wp_error( $terms ) || empty( $terms ) ) {
			return array();
		}

		$map = array(
			'descendants' => array(), // term_id => [descendant_ids].
			'ancestors'   => array(), // term_id => [ancestor_ids].
			'tree'        => array(),
		);

		// Build core lookups and temporary structures.
		$temp_children = array();
		$temp_parents  = array();
		$temp_terms    = array();

		foreach ( $terms as $term ) {
			$term_id   = $term->term_id;
			$parent_id = $term->parent;

			$temp_parents[ $term_id ] = $parent_id;

			if ( ! isset( $temp_children[ $parent_id ] ) ) {
				$temp_children[ $parent_id ] = array();
			}

			$temp_children[ $parent_id ][] = $term_id;

			$temp_terms[ $term_id ] = array(
				'slug'    => $term->slug,
				'name'    => $term->name,
				'parent'  => $parent_id,
				'term_id' => $term->term_id,
			);
		}

		// Pre-compute descendants and ancestors.
		foreach ( array_keys( $temp_parents ) as $term_id ) {
			$map['descendants'][ $term_id ] = $this->compute_descendants( $term_id, $temp_children );
			$map['ancestors'][ $term_id ]   = $this->compute_ancestors( $term_id, $temp_parents );
		}

		foreach ( $temp_children[0] as $term_id ) {
			$this->build_term_tree( $map['tree'], $term_id, $temp_children, $temp_terms );
		}

		return $map;
	}

	/**
	 * Recursively build hierarchical term tree with depth and parent.
	 *
	 * @param array $tree       Reference to tree array being built.
	 * @param int   $term_id    Current term ID.
	 * @param array $children   Children relationships map (parent_id => [child_ids]).
	 * @param array $temp_terms Term data indexed by term_id.
	 * @param int   $depth      Current depth level in hierarchy.
	 */
	private function build_term_tree( &$tree, $term_id, $children, $temp_terms, $depth = 0 ) {
		$tree[ $term_id ]          = $temp_terms[ $term_id ];
		$tree[ $term_id ]['depth'] = $depth;

		if ( ! empty( $children[ $term_id ] ) ) {
			foreach ( $children[ $term_id ] as $child_id ) {
				$this->build_term_tree( $tree[ $term_id ]['children'], $child_id, $children, $temp_terms, $depth + 1 );
			}
		}
	}

	/**
	 * Compute all descendants of a term.
	 *
	 * @param int   $term_id  The term ID.
	 * @param array $children Children relationships map.
	 * @return array Array of descendant term IDs.
	 */
	private function compute_descendants( int $term_id, array $children ): array {
		$descendants = array();

		if ( ! isset( $children[ $term_id ] ) ) {
			return $descendants;
		}

		foreach ( $children[ $term_id ] as $child_id ) {
			$descendants[] = $child_id;
			$descendants   = array_merge( $descendants, $this->compute_descendants( $child_id, $children ) );
		}

		return array_unique( $descendants );
	}

	/**
	 * Compute ancestor chain for a term.
	 *
	 * @param int   $term_id The term ID.
	 * @param array $parent_lookup Parent relationships.
	 * @return array Array of ancestor term IDs (bottom-up).
	 */
	private function compute_ancestors( int $term_id, array $parent_lookup ): array {
		$ancestors  = array();
		$current_id = $term_id;

		while ( isset( $parent_lookup[ $current_id ] ) && $parent_lookup[ $current_id ] > 0 ) {
			$parent_id   = $parent_lookup[ $current_id ];
			$ancestors[] = $parent_id;
			$current_id  = $parent_id;
		}

		return $ancestors;
	}
}
PK     [1])    "  ProductFilters/CacheController.phpnu         <?php
declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFilters;

use Automattic\WooCommerce\Internal\RegisterHooksInterface;
use Automattic\WooCommerce\Internal\ProductFilters\TaxonomyHierarchyData;
use WC_Cache_Helper;

defined( 'ABSPATH' ) || exit;

/**
 * Hooks into WooCommerce actions to register cache invalidation.
 *
 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
 */
class CacheController implements RegisterHooksInterface {
	const CACHE_GROUP = 'filter_data';

	/**
	 * Instance of TaxonomyHierarchyData.
	 *
	 * @var TaxonomyHierarchyData
	 */
	private $taxonomy_hierarchy_data;

	/**
	 * Initialize dependencies.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 * @param TaxonomyHierarchyData $taxonomy_hierarchy_data Instance of TaxonomyHierarchyData.
	 * @return void
	 */
	final public function init( TaxonomyHierarchyData $taxonomy_hierarchy_data ): void {
		$this->taxonomy_hierarchy_data = $taxonomy_hierarchy_data;
	}

	/**
	 * Hook into actions and filters.
	 */
	public function register() {
		if ( ! $this->need_cleanup() ) {
			return;
		}

		add_action( 'woocommerce_after_product_object_save', array( $this, 'invalidate_filter_data_cache' ) );
		add_action( 'woocommerce_delete_product_transients', array( $this, 'invalidate_filter_data_cache' ) );

		// Clear taxonomy hierarchy cache when terms change.
		add_action( 'created_term', array( $this, 'clear_taxonomy_hierarchy_cache' ), 10, 3 );
		add_action( 'edited_term', array( $this, 'clear_taxonomy_hierarchy_cache' ), 10, 3 );
		add_action( 'delete_term', array( $this, 'clear_taxonomy_hierarchy_cache' ), 10, 3 );
	}

	/**
	 * Invalidate all cache under filter data group.
	 */
	public function invalidate_filter_data_cache(): void {
		WC_Cache_Helper::get_transient_version( self::CACHE_GROUP, true );
		WC_Cache_Helper::invalidate_cache_group( self::CACHE_GROUP );
	}

	/**
	 * Clear taxonomy hierarchy cache when terms are created, updated, or deleted.
	 *
	 * @param int    $term_id          Term ID.
	 * @param int    $term_taxonomy_id Term taxonomy ID.
	 * @param string $taxonomy         Taxonomy slug.
	 */
	public function clear_taxonomy_hierarchy_cache( $term_id, $term_taxonomy_id, $taxonomy ) {
		// Only clear cache for hierarchical taxonomies.
		if ( is_taxonomy_hierarchical( $taxonomy ) ) {
			$this->taxonomy_hierarchy_data->clear_cache( $taxonomy );
		}
	}

	/**
	 * Delete all filter data transients.
	 */
	public function delete_filter_data_transients(): void {
		if ( ! $this->need_cleanup() ) {
			return;
		}

		global $wpdb;
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
				$wpdb->esc_like( '_transient_wc_filter_data_' ) . '%',
				$wpdb->esc_like( '_transient_timeout_wc_filter_data_' ) . '%'
			)
		);
	}

	/**
	 * Check if the filter data cache should be cleaned up.
	 * If the cache group is not set, it means that the store is not using
	 * the product filters and we don't need to register the hooks.
	 *
	 * @return bool
	 */
	public function need_cleanup() {
		return ! empty( get_transient( self::CACHE_GROUP . '-transient-version' ) );
	}
}
PK     [1]Q    ,  ProductFilters/Interfaces/FilterUrlParam.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\ProductFilters\Interfaces;

/**
 * Interface for filter URL parameters.
 *
 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
 */
interface FilterUrlParam {
	/**
	 * Get the param keys.
	 *
	 * @return array
	 */
	public function get_param_keys(): array;

	/**
	 * Get the param.
	 *
	 * @param string $type The type of param to get.
	 * @return array
	 */
	public function get_param( string $type ): array;
}
PK     [1]( 	    3  ProductFilters/Interfaces/QueryClausesGenerator.phpnu         <?php
/**
 * ClausesProviderInterface interface file.
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFilters\Interfaces;

defined( 'ABSPATH' ) || exit;

/**
 * QueryClausesGenerator interface.
 *
 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
 */
interface QueryClausesGenerator {

	/**
	 * Add conditional query clauses based on the filter params in query vars.
	 *
	 * @param array     $args     Query args.
	 * @param \WP_Query $wp_query WP_Query object.
	 * @return array
	 */
	public function add_query_clauses( array $args, \WP_Query $wp_query ): array;
}
PK     [1]D    7  ProductFilters/Interfaces/MainQueryClausesGenerator.phpnu         <?php
/**
 * MainQueryClausesGenerator interface file.
 */

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFilters\Interfaces;

defined( 'ABSPATH' ) || exit;

/**
 * MainQueryClausesGenerator interface.
 *
 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
 */
interface MainQueryClausesGenerator {

	/**
	 * Add conditional query clauses for main query based on the filter params in query vars.
	 *
	 * @param array     $args     Query args.
	 * @param \WP_Query $wp_query WP_Query object.
	 * @return array
	 */
	public function add_query_clauses_for_main_query( array $args, \WP_Query $wp_query ): array;
}
PK     [1]U׭    &  ProductFilters/MainQueryController.phpnu         <?php
declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\ProductFilters;

use Automattic\WooCommerce\Internal\RegisterHooksInterface;

defined( 'ABSPATH' ) || exit;
/**
 * Hooks into WordPress filters to handle product filters for the main query.
 *
 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
 */
class MainQueryController implements RegisterHooksInterface {

	/**
	 * Instance of QueryClauses.
	 *
	 * @var QueryClauses
	 */
	private $query_clauses;

	/**
	 * Hold the filter params.
	 *
	 * @var Params
	 */
	private $params;

	/**
	 * Initialize dependencies.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 * @param QueryClauses $query_clauses Instance of QueryClauses.
	 * @param Params       $params        Instance of Params.
	 *
	 * @return void
	 */
	final public function init( QueryClauses $query_clauses, Params $params ): void {
		$this->query_clauses = $query_clauses;
		$this->params        = $params;
	}

	/**
	 * Hook into actions and filters.
	 *
	 * @return void
	 */
	public function register(): void {
		add_filter( 'posts_clauses', array( $this->query_clauses, 'add_query_clauses_for_main_query' ), 10, 2 );
		add_filter( 'query_vars', array( $this, 'add_query_vars' ) );
	}

	/**
	 * Register custom query vars for our filters. Price, stock status, and attribute query vars are
	 * already registered at WC_Query.
	 *
	 * @param array $query_vars Query vars.
	 * @return array
	 */
	public function add_query_vars( array $query_vars ): array {
		return array_merge( $query_vars, $this->params->get_param_keys() );
	}
}
PK     [1]=ke  e    McStats.phpnu         <?php
/**
 * WooCommerce MC Stats package
 */

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal;

use Automattic\Jetpack\A8c_Mc_Stats;

/**
 * Class MC Stats, used to record internal usage stats for Automattic.
 *
 * This class is a wrapper around the Jetpack MC Stats package.
 * See https://github.com/Automattic/jetpack-a8c-mc-stats/tree/trunk for more details.
 */
class McStats extends A8c_Mc_Stats {

	/**
	 * Return the stats from a group in an array ready to be added as parameters in a query string
	 *
	 * Jetpack MC Stats package prefixes group names with "x_jetpack-" so we override this method to prefix group names with "x_woocommerce-".
	 *
	 * @param string $group_name The name of the group to retrieve.
	 * @return array Array with one item, where the key is the prefixed group and the value are all stats concatenated with a comma. If group not found, an empty array will be returned
	 */
	public function get_group_query_args( $group_name ) {
		$stats = $this->get_current_stats();
		if ( isset( $stats[ $group_name ] ) && ! empty( $stats[ $group_name ] ) ) {
			return array( "x_woocommerce-{$group_name}" => implode( ',', $stats[ $group_name ] ) );
		}
		return array();
	}

	/**
	 * Outputs the tracking pixels for the current stats and empty the stored stats from the object
	 *
	 * @return void
	 */
	public function do_stats() {
		if ( ! \WC_Site_Tracking::is_tracking_enabled() ) {
			return;
		}

		parent::do_stats();
	}

	/**
	 * Runs stats code for a one-off, server-side.
	 *
	 * @param string $url string The URL to be pinged. Should include `x_woocommerce-{$group}={$stats}` or whatever we want to store.
	 *
	 * @return bool If it worked.
	 */
	public function do_server_side_stat( $url ) {
		if ( ! \WC_Site_Tracking::is_tracking_enabled() ) {
			return false;
		}

		return parent::do_server_side_stat( $url );
	}

	/**
	 * Pings the stats server for the current stats and empty the stored stats from the object
	 *
	 * @return void
	 */
	public function do_server_side_stats() {
		if ( ! \WC_Site_Tracking::is_tracking_enabled() ) {
			return;
		}

		parent::do_server_side_stats();
	}
}
PK     [1]	+  +  /  CostOfGoodsSold/CogsAwareUnitTestSuiteTrait.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\CostOfGoodsSold;

/**
 * Trait with common functionality for unit tests related to the Cost of Goods Sold feature.
 */
trait CogsAwareUnitTestSuiteTrait {
	/**
	 * Enable the Cost of Goods Sold feature.
	 */
	private function enable_cogs_feature() {
		update_option( 'woocommerce_feature_cost_of_goods_sold_enabled', 'yes' );
	}

	/**
	 * Enable the Cost of Goods Sold feature.
	 */
	private function disable_cogs_feature() {
		delete_option( 'woocommerce_feature_cost_of_goods_sold_enabled' );
	}

	/**
	 * Sets the expectation for a "doing it wrong" being thrown.
	 *
	 * @param string $method_name The method name inside the error message.
	 */
	private function expect_doing_it_wrong_cogs_disabled( string $method_name ) {
		$this->register_legacy_proxy_function_mocks(
			array(
				'wc_doing_it_wrong' => function ( $function_name, $message ) {
					// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
					throw new \Exception( "Doing it wrong, function: '$function_name', message: '$message'" );
				},
			)
		);

		$this->expectExceptionMessage( "Doing it wrong, function: '{$method_name}', message: 'The Cost of Goods sold feature is disabled, thus the method called will do nothing and will return dummy data.'" );
	}
}
PK     [1]m<  <  "  CostOfGoodsSold/CogsAwareTrait.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\CostOfGoodsSold;

use Automattic\WooCommerce\Proxies\LegacyProxy;

/**
 * Trait with general Cost of Goods Sold related functionality shared by the entire codebase.
 */
trait CogsAwareTrait {

	/**
	 * Check if the Cost of Goods Sold feature is enabled.
	 *
	 * @param string|null $doing_it_wrong_function_name If not null, a "doing it wrong" error will be thrown with this function name if the deature is disabled.
	 *
	 * @return bool True if the feature is enabled.
	 */
	protected function cogs_is_enabled( ?string $doing_it_wrong_function_name = null ): bool {
		if ( wc_get_container()->get( CostOfGoodsSoldController::class )->feature_is_enabled() ) {
			return true;
		}

		if ( $doing_it_wrong_function_name ) {
			wc_get_container()->get( LegacyProxy::class )->call_function(
				'wc_doing_it_wrong',
				$doing_it_wrong_function_name,
				'The Cost of Goods sold feature is disabled, thus the method called will do nothing and will return dummy data.',
				'9.5.0'
			);
		}

		return false;
	}
}
PK     [1]zgp  p  -  CostOfGoodsSold/CostOfGoodsSoldController.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\CostOfGoodsSold;

use Automattic\WooCommerce\Enums\FeaturePluginCompatibility;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\RegisterHooksInterface;

/**
 * Main controller for the Cost of Goods Sold feature.
 */
class CostOfGoodsSoldController implements RegisterHooksInterface {

	/**
	 * The instance of FeaturesController to use.
	 *
	 * @var FeaturesController
	 */
	private FeaturesController $features_controller;

	/**
	 * Register hooks.
	 */
	public function register() {
		add_filter( 'woocommerce_debug_tools', array( $this, 'add_debug_tools_entry' ), 999, 1 );
	}

	/**
	 * Initialize the instance, runs when the instance is created by the dependency injection container.
	 *
	 * @internal
	 * @param FeaturesController $features_controller The instance of FeaturesController to use.
	 */
	final public function init( FeaturesController $features_controller ) {
		$this->features_controller = $features_controller;
	}

	/**
	 * Is the Cost of Goods Sold engine enabled?
	 *
	 * @return bool True if the engine is enabled, false otherwise.
	 */
	public function feature_is_enabled(): bool {
		return $this->features_controller->feature_is_enabled( 'cost_of_goods_sold' );
	}

	/**
	 * Add the feature information for the features settings page.
	 *
	 * @param FeaturesController $features_controller The instance of FeaturesController to use.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function add_feature_definition( $features_controller ) {
		$definition = array(
			'description'                  => __( 'Allows entering cost of goods sold information for products.', 'woocommerce' ),
			'is_experimental'              => false,
			'enabled_by_default'           => false,
			'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
		);

		$features_controller->add_feature_definition(
			'cost_of_goods_sold',
			__( 'Cost of Goods Sold', 'woocommerce' ),
			$definition
		);
	}

	/**
	 * Add the entry for "add/remove COGS value column to/from the product meta lookup table" to the WooCommerce admin tools.
	 *
	 * @internal Hook handler, not to be explicitly used from outside the class.
	 *
	 * @param array $tools_array Array to add the tool to.
	 * @return array Updated tools array.
	 */
	public function add_debug_tools_entry( array $tools_array ): array {
		// If the feature is disabled we show the tool for removing the column, but not for adding it.
		$column_exists = $this->product_meta_lookup_table_cogs_value_columns_exist();
		if ( ! $this->feature_is_enabled() && ! $column_exists ) {
			return $tools_array;
		}

		$tools_array['generate_cogs_value_meta_column'] = array(
			'name'     => $column_exists ?
				__( 'Remove COGS columns from the product meta lookup table', 'woocommerce' ) :
				__( 'Create COGS columns in the product meta lookup table', 'woocommerce' ),
			'button'   => $column_exists ?
				__( 'Remove columns', 'woocommerce' ) :
				__( 'Create columns', 'woocommerce' ),
			'desc'     =>
				$column_exists ?
				__( 'This tool will remove the Cost of Goods Sold (COGS) related columns from the product meta lookup table. COGS will continue working (if the feature is enabled) but some functionality will not be available.', 'woocommerce' ) :
				__( 'This tool will generate the necessary Cost of Goods Sold (COGS) related columns in the product meta lookup table, and populate them from existing product data.', 'woocommerce' ),
			'callback' =>
				$column_exists ? array( $this, 'remove_lookup_cogs_columns' ) : array( $this, 'generate_lookup_cogs_columns' ),
		);

		return $tools_array;
	}

	/**
	 * Handler for the "add COGS value column to the product meta lookup table" admin tool.
	 *
	 * @internal Tool callback, not to be explicitly used from outside the class.
	 */
	public function generate_lookup_cogs_columns() {
		global $wpdb;

		if ( $this->feature_is_enabled() && ! $this->product_meta_lookup_table_cogs_value_columns_exist() ) {
			$wpdb->query( "ALTER TABLE {$wpdb->prefix}wc_product_meta_lookup ADD COLUMN cogs_total_value DECIMAL(19,4)" );
			$wpdb->query(
				"UPDATE {$wpdb->prefix}wc_product_meta_lookup AS lookup
    			JOIN {$wpdb->prefix}postmeta AS pm ON lookup.product_id = pm.post_id
    			SET lookup.cogs_total_value = CAST(pm.meta_value AS DECIMAL(19, 4))
    			WHERE pm.meta_key = '_cogs_total_value';"
			);
		}
	}

	/**
	 * Handler for the "remove COGS value column to the product meta lookup table" admin tool.
	 *
	 * @internal Tool callback, not to be explicitly used from outside the class.
	 */
	public function remove_lookup_cogs_columns() {
		global $wpdb;

		if ( $this->product_meta_lookup_table_cogs_value_columns_exist() ) {
			$wpdb->query( "ALTER TABLE {$wpdb->prefix}wc_product_meta_lookup DROP COLUMN cogs_total_value" );
		}
	}

	/**
	 * Tells if the COGS value column exists in the product meta lookup table.
	 *
	 * @return bool True if the column exists, false otherwise.
	 */
	public function product_meta_lookup_table_cogs_value_columns_exist(): bool {
		global $wpdb;

		return (bool) $wpdb->get_var(
			$wpdb->prepare(
				"SHOW COLUMNS FROM {$wpdb->prefix}wc_product_meta_lookup LIKE %s",
				'cogs_total_value'
			)
		);
	}

	/**
	 * Get the tooltip text for the COGS value field in the product editor.
	 *
	 * @param bool $for_variable_products True to get the value for variable products, false for other types of products.
	 * @return string The string to use as tooltip (translated but not escaped).
	 */
	public function get_general_cost_edit_field_tooltip( bool $for_variable_products ) {
		return $for_variable_products ?
			__( 'Add the amount it costs you to buy or make this product. This will be applied as the default value for variations.', 'woocommerce' ) :
			__( 'Add the amount it costs you to buy or make this product.', 'woocommerce' );
	}
}
PK     [1]sN?  ?  0  CostOfGoodsSold/CogsAwareRestControllerTrait.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\CostOfGoodsSold;

/**
 * Trait with Cost of Goods Sold related functionality shared by the REST products and variations controllers.
 */
trait CogsAwareRestControllerTrait {

	use CogsAwareTrait;

	/**
	 * Add Cost of Goods Sold related information for a given product to the array of data that will become the REST response.
	 *
	 * @param array      $data Array of response data.
	 * @param WC_Product $product Product to get the information from.
	 */
	private function add_cogs_info_to_returned_product_data( array &$data, $product ): void {
		if ( ! $this->cogs_is_enabled() ) {
			return;
		}

		$data['cost_of_goods_sold'] = array(
			'values'      => array(
				array(
					'defined_value'   => $product->get_cogs_value(),
					'effective_value' => $product->get_cogs_effective_value(),
				),
			),
			'total_value' => $product->get_cogs_total_value(),
		);

		if ( $product instanceof \WC_Product_Variation ) {
			$data['cost_of_goods_sold']['defined_value_is_additive'] = $product->get_cogs_value_is_additive();
		}
	}

	/**
	 * Apply Cost of Goods Sold related information received in the request body to a product object.
	 *
	 * @param WP_Rest_Request $request Request data.
	 * @param WC_Product      $product The product to apply the data to.
	 */
	private function set_cogs_info_in_product_object( $request, $product ): void {
		$values = $request['cost_of_goods_sold']['values'] ?? null;
		if ( ! is_null( $values ) ) {
			$value = 0;
			foreach ( $values as $value_info ) {
				$value += (float) ( $value_info['defined_value'] ?? 0 );
			}

			$product->set_cogs_value( $value );
		}

		if ( $product instanceof \WC_Product_Variation ) {
			$is_additive = $request['cost_of_goods_sold']['defined_value_is_additive'] ?? null;
			if ( ! is_null( $is_additive ) ) {
				$product->set_cogs_value_is_additive( $is_additive );
			}
		}
	}

	/**
	 * Add Cost of Goods Sold related schema information to a given REST endpoint schema.
	 *
	 * @param array $schema The schema data set to add the information to.
	 * @param bool  $for_variations_controller True if the information is for an endpoint in the variations controller.
	 * @return array Updated schema information.
	 */
	private function add_cogs_related_product_schema( array $schema, bool $for_variations_controller ): array {
		$schema['properties']['cost_of_goods_sold'] = array(
			'description' => __( 'Cost of Goods Sold data.', 'woocommerce' ),
			'type'        => 'object',
			'context'     => array( 'view', 'edit' ),
			'properties'  => array(
				'values'                    => array(
					'description' => __( 'Cost of Goods Sold values for the product.', 'woocommerce' ),
					'type'        => 'array',
					'context'     => array( 'view', 'edit' ),
					'items'       => array(
						'type'       => 'object',
						'properties' => array(
							'defined_value'   => array(
								'description' => __( 'Defined cost value.', 'woocommerce' ),
								'type'        => 'number',
								'context'     => array( 'view', 'edit' ),
							),
							'effective_value' => array(
								'description' => __( 'Effective monetary cost value.', 'woocommerce' ),
								'type'        => 'number',
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
						),
					),

				),
				'defined_value_is_additive' => array(
					'description' => __( 'Applies to variations only. If true, the effective value is the base value from the parent product plus the defined value; if false, the defined value is the final effective value.', 'woocommerce' ),
					'type'        => 'boolean',
					'default'     => false,
					'context'     => array( 'view', 'edit' ),
				),
				'total_value'               => array(
					'description' => __( 'Total monetary value of the Cost of Goods Sold for the product (sum of all the effective values).', 'woocommerce' ),
					'type'        => 'number',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
			),
		);

		if ( $for_variations_controller ) {
			$schema['properties']['cost_of_goods_sold']['properties']['defined_value_is_additive']['description'] =
				__( 'If true, the effective value is the base value from the parent product plus the defined value; if false, the defined value is the final effective value.', 'woocommerce' );
		}

		return $schema;
	}
}
PK     [1]:7    +  BatchProcessing/BatchProcessorInterface.phpnu         <?php
/**
 * Interface for batch data processors. See the BatchProcessingController class for usage details.
 */

namespace Automattic\WooCommerce\Internal\BatchProcessing;

/**
 * Interface BatchProcessorInterface
 *
 * @package Automattic\WooCommerce\Internal\BatchProcessing
 */
interface BatchProcessorInterface {

	/**
	 * Get a user-friendly name for this processor.
	 *
	 * @return string Name of the processor.
	 */
	public function get_name() : string;

	/**
	 * Get a user-friendly description for this processor.
	 *
	 * @return string Description of what this processor does.
	 */
	public function get_description() : string;

	/**
	 * Get the total number of pending items that require processing.
	 * Once an item is successfully processed by 'process_batch' it shouldn't be included in this count.
	 *
	 * Note that the once the processor is enqueued the batch processor controller will keep
	 * invoking `get_next_batch_to_process` and `process_batch` repeatedly until this method returns zero.
	 *
	 * @return int Number of items pending processing.
	 */
	public function get_total_pending_count() : int;

	/**
	 * Returns the next batch of items that need to be processed.
	 *
	 * A batch item can be anything needed to identify the actual processing to be done,
	 * but whenever possible items should be numbers (e.g. database record ids)
	 * or at least strings, to ease troubleshooting and logging in case of problems.
	 *
	 * The size of the batch returned can be less than $size if there aren't that
	 * many items pending processing (and it can be zero if there isn't anything to process),
	 * but the size should always be consistent with what 'get_total_pending_count' returns
	 * (i.e. the size of the returned batch shouldn't be larger than the pending items count).
	 *
	 * @param int $size Maximum size of the batch to be returned.
	 *
	 * @return array Batch of items to process, containing $size or less items.
	 */
	public function get_next_batch_to_process( int $size ) : array;

	/**
	 * Process data for the supplied batch.
	 *
	 * This method should be prepared to receive items that don't actually need processing
	 * (because they have been processed before) and ignore them, but if at least
	 * one of the batch items that actually need processing can't be processed, an exception should be thrown.
	 *
	 * Once an item has been processed it shouldn't be counted in 'get_total_pending_count'
	 * nor included in 'get_next_batch_to_process' anymore (unless something happens that causes it
	 * to actually require further processing).
	 *
	 * @throw \Exception Something went wrong while processing the batch.
	 *
	 * @param array $batch Batch to process, as returned by 'get_next_batch_to_process'.
	 */
	public function process_batch( array $batch ): void;

	/**
	 * Default (preferred) batch size to pass to 'get_next_batch_to_process'.
	 * The controller will pass this size unless it's externally configured
	 * to use a different size.
	 *
	 * @return int Default batch size.
	 */
	public function get_default_batch_size() : int;
}
PK     [1]iW  W  -  BatchProcessing/BatchProcessingController.phpnu         <?php
/**
 * This class is a helper intended to handle data processings that need to happen in batches in a deferred way.
 * It abstracts away the nuances of (re)scheduling actions and dealing with errors.
 *
 * Usage:
 *
 * 1. Create a class that implements BatchProcessorInterface.
 *    The class must either be registered in the dependency injection container, or have a public parameterless constructor,
 *    or an instance must be provided via the 'woocommerce_get_batch_processor' filter.
 * 2. Whenever there's data to be processed invoke the 'enqueue_processor' method in this class,
 *    passing the class name of the processor.
 *
 * That's it, processing will be performed in batches inside scheduled actions; enqueued processors will only
 * be dequeued once they notify that no more items are left to process (or when `force_clear_all_processes` is invoked).
 * Failed batches will be retried after a while.
 *
 * There are also a few public methods to get the list of currently enqueued processors
 * and to check if a given processor is enqueued/actually scheduled.
 */

namespace Automattic\WooCommerce\Internal\BatchProcessing;

/**
 * Class BatchProcessingController
 *
 * @package Automattic\WooCommerce\Internal\BatchProcessing.
 */
class BatchProcessingController {
	/*
	 * Identifier of a "watchdog" action that will schedule a processing action
	 * for any processor that is enqueued but not yet scheduled
	 * (because it's been just enqueued or because it threw an error while processing a batch),
	 * that's one single action that reschedules itself continuously.
	 */
	const WATCHDOG_ACTION_NAME = 'wc_schedule_pending_batch_processes';

	/*
	 * Identifier of the action that will do the actual batch processing.
	 * There's one action per enqueued processor that will keep rescheduling itself
	 * as long as there are still pending items to process
	 * (except if there's an error that caused no items to be processed at all).
	 */
	const PROCESS_SINGLE_BATCH_ACTION_NAME = 'wc_run_batch_process';

	const ENQUEUED_PROCESSORS_OPTION_NAME = 'wc_pending_batch_processes';
	const ACTION_GROUP                    = 'wc_batch_processes';

	/**
	 * Maximum number of failures per processor before it gets dequeued.
	 */
	const FAILING_PROCESS_MAX_ATTEMPTS_DEFAULT = 5;

	/**
	 * Instance of WC_Logger class.
	 *
	 * @var \WC_Logger_Interface
	 */
	private $logger;

	/**
	 * BatchProcessingController constructor.
	 *
	 * Schedules the necessary actions to process batches.
	 */
	public function __construct() {
		add_action(
			self::WATCHDOG_ACTION_NAME,
			function () {
				$this->handle_watchdog_action();
			}
		);

		add_action(
			self::PROCESS_SINGLE_BATCH_ACTION_NAME,
			function ( $batch_process ) {
				$this->process_next_batch_for_single_processor( $batch_process );
			},
			10,
			2
		);

		add_action(
			'shutdown',
			function () {
				$this->remove_or_retry_failed_processors();
			}
		);

		$this->logger = wc_get_logger();
	}

	/**
	 * Enqueue a processor so that it will get batch processing requests from within scheduled actions.
	 *
	 * @param string $processor_class_name Fully qualified class name of the processor, must implement `BatchProcessorInterface`.
	 */
	public function enqueue_processor( string $processor_class_name ): void {
		$pending_updates = $this->get_enqueued_processors();
		if ( ! in_array( $processor_class_name, array_keys( $pending_updates ), true ) ) {
			$pending_updates[] = $processor_class_name;
			$this->set_enqueued_processors( $pending_updates );
		}
		$this->schedule_watchdog_action( false, true );
	}

	/**
	 * Schedule the watchdog action.
	 *
	 * @param bool $with_delay Whether to delay the action execution. Should be true when rescheduling, false when enqueueing.
	 * @param bool $unique     Whether to make the action unique.
	 */
	private function schedule_watchdog_action( bool $with_delay = false, bool $unique = false ): void {
		$time = time();
		if ( $with_delay ) {
			/**
			 * Modify the delay interval for the batch processor's watchdog events.
			 *
			 * @since 8.2.0
			 *
			 * @param int $delay Time, in seconds, before the watchdog process will run. Defaults to 3600 (1 hour).
			 */
			$time += apply_filters( 'woocommerce_batch_processor_watchdog_delay_seconds', HOUR_IN_SECONDS );
		}

		if ( ! as_has_scheduled_action( self::WATCHDOG_ACTION_NAME ) ) {
			as_schedule_single_action(
				$time,
				self::WATCHDOG_ACTION_NAME,
				array(),
				self::ACTION_GROUP,
				$unique
			);
		}
	}

	/**
	 * Schedule a processing action for all the processors that are enqueued but not scheduled
	 * (because they have just been enqueued, or because the processing for a batch failed).
	 */
	private function handle_watchdog_action(): void {
		$pending_processes = $this->get_enqueued_processors();
		if ( empty( $pending_processes ) ) {
			return;
		}
		foreach ( $pending_processes as $process_name ) {
			if ( ! $this->is_scheduled( $process_name ) ) {
				$this->schedule_batch_processing( $process_name );
			}
		}
		$this->schedule_watchdog_action( true );
	}

	/**
	 * Process a batch for a single processor, and handle any required rescheduling or state cleanup.
	 *
	 * @param string $processor_class_name Fully qualified class name of the processor.
	 *
	 * @throws \Exception If error occurred during batch processing.
	 */
	private function process_next_batch_for_single_processor( string $processor_class_name ): void {
		if ( ! $this->is_enqueued( $processor_class_name ) ) {
			return;
		}

		$batch_processor = $this->get_processor_instance( $processor_class_name );
		$error           = $this->process_next_batch_for_single_processor_core( $batch_processor );
		$still_pending   = count( $batch_processor->get_next_batch_to_process( 1 ) ) > 0;
		if ( ( $error instanceof \Exception ) ) {
			// The batch processing failed and no items were processed:
			// reschedule the processing with a delay, unless this is a repeatead failure.
			if ( $this->is_consistently_failing( $batch_processor ) ) {
				$this->log_consistent_failure( $batch_processor, $this->get_process_details( $batch_processor ) );
				$this->remove_processor( $processor_class_name );
			} else {
				$this->schedule_batch_processing( $processor_class_name, true );
			}

			throw $error;
		}
		if ( $still_pending ) {
			$this->schedule_batch_processing( $processor_class_name );
		} else {
			$this->dequeue_processor( $processor_class_name );
		}
	}

	/**
	 * Process a batch for a single processor, updating state and logging any error.
	 *
	 * @param BatchProcessorInterface $batch_processor Batch processor instance.
	 *
	 * @return null|\Exception Exception if error occurred, null otherwise.
	 */
	private function process_next_batch_for_single_processor_core( BatchProcessorInterface $batch_processor ): ?\Exception {
		$details    = $this->get_process_details( $batch_processor );
		$time_start = microtime( true );
		$batch      = $batch_processor->get_next_batch_to_process( $details['current_batch_size'] );
		if ( empty( $batch ) ) {
			return null;
		}
		try {
			$batch_processor->process_batch( $batch );
			$time_taken = microtime( true ) - $time_start;
			$this->update_processor_state( $batch_processor, $time_taken );
		} catch ( \Exception $exception ) {
			$time_taken = microtime( true ) - $time_start;
			$this->log_error( $exception, $batch_processor, $batch );
			$this->update_processor_state( $batch_processor, $time_taken, $exception );
			return $exception;
		}
		return null;
	}

	/**
	 * Get the current state for a given enqueued processor.
	 *
	 * @param BatchProcessorInterface $batch_processor Batch processor instance.
	 *
	 * @return array Current state for the processor, or a "blank" state if none exists yet.
	 */
	private function get_process_details( BatchProcessorInterface $batch_processor ): array {
		$defaults = array(
			'total_time_spent'    => 0,
			'current_batch_size'  => $batch_processor->get_default_batch_size(),
			'last_error'          => null,
			'recent_failures'     => 0,
			'batch_first_failure' => null,
			'batch_last_failure'  => null,
		);

		$process_details = get_option( $this->get_processor_state_option_name( $batch_processor ) );
		$process_details = wp_parse_args( is_array( $process_details ) ? $process_details : array(), $defaults );

		return $process_details;
	}

	/**
	 * Get the name of the option where we will be saving state for a given processor.
	 *
	 * @param BatchProcessorInterface|string $batch_processor Batch processor instance or class name.
	 *
	 * @return string Option name.
	 */
	private function get_processor_state_option_name( $batch_processor ): string {
		$class_name = is_a( $batch_processor, BatchProcessorInterface::class ) ? get_class( $batch_processor ) : $batch_processor;
		$class_md5  = md5( $class_name );
		// truncate the class name so we know that it will fit in the option name column along with md5 hash and prefix.
		$class_name = substr( $class_name, 0, 140 );
		return 'wc_batch_' . $class_name . '_' . $class_md5;
	}

	/**
	 * Update the state for a processor after a batch has completed processing.
	 *
	 * @param BatchProcessorInterface $batch_processor Batch processor instance.
	 * @param float                   $time_taken Time take by the batch to complete processing.
	 * @param \Exception|null         $last_error Exception object in processing the batch, if there was one.
	 */
	private function update_processor_state( BatchProcessorInterface $batch_processor, float $time_taken, ?\Exception $last_error = null ): void {
		$current_status                      = $this->get_process_details( $batch_processor );
		$current_status['total_time_spent'] += $time_taken;
		$current_status['last_error']        = null !== $last_error ? $last_error->getMessage() : null;

		if ( null !== $last_error ) {
			$current_status['recent_failures']    = ( $current_status['recent_failures'] ?? 0 ) + 1;
			$current_status['batch_last_failure'] = current_time( 'mysql' );

			if ( is_null( $current_status['batch_first_failure'] ) ) {
				$current_status['batch_first_failure'] = $current_status['batch_last_failure'];
			}
		} else {
			$current_status['recent_failures']     = 0;
			$current_status['batch_first_failure'] = null;
			$current_status['batch_last_failure']  = null;
		}

		update_option( $this->get_processor_state_option_name( $batch_processor ), $current_status, false );
	}

	/**
	 * Removes the option where we store state for a given processor.
	 *
	 * @since 9.1.0
	 *
	 * @param string $processor_class_name Fully qualified class name of the processor.
	 */
	private function clear_processor_state( string $processor_class_name ): void {
		delete_option( $this->get_processor_state_option_name( $processor_class_name ) );
	}

	/**
	 * Schedule a processing action for a single processor.
	 *
	 * @param string $processor_class_name Fully qualified class name of the processor.
	 * @param bool   $with_delay   Whether to schedule the action for immediate execution or for later.
	 */
	private function schedule_batch_processing( string $processor_class_name, bool $with_delay = false ): void {
		$time = $with_delay ? time() + MINUTE_IN_SECONDS : time();
		as_schedule_single_action( $time, self::PROCESS_SINGLE_BATCH_ACTION_NAME, array( $processor_class_name ) );
	}

	/**
	 * Check if a batch processing action is already scheduled for a given processor.
	 * Differs from `as_has_scheduled_action` in that this excludes actions in progress.
	 *
	 * @param string $processor_class_name Fully qualified class name of the batch processor.
	 *
	 * @return bool True if a batch processing action is already scheduled for the processor.
	 */
	public function is_scheduled( string $processor_class_name ): bool {
		return as_has_scheduled_action( self::PROCESS_SINGLE_BATCH_ACTION_NAME, array( $processor_class_name ) );
	}

	/**
	 * Get an instance of a processor given its class name.
	 *
	 * @param string $processor_class_name Full class name of the batch processor.
	 *
	 * @return BatchProcessorInterface Instance of batch processor for the given class.
	 * @throws \Exception If it's not possible to get an instance of the class.
	 */
	private function get_processor_instance( string $processor_class_name ): BatchProcessorInterface {

		$container = wc_get_container();
		$processor = $container->has( $processor_class_name ) ? $container->get( $processor_class_name ) : null;

		/**
		 * Filters the instance of a processor for a given class name.
		 *
		 * @param object|null $processor The processor instance given by the dependency injection container, or null if none was obtained.
		 * @param string $processor_class_name The full class name of the processor.
		 * @return BatchProcessorInterface|null The actual processor instance to use, or null if none could be retrieved.
		 *
		 * @since 6.8.0.
		 */
		$processor = apply_filters( 'woocommerce_get_batch_processor', $processor, $processor_class_name );
		if ( ! isset( $processor ) && class_exists( $processor_class_name ) ) {
			// This is a fallback for when the batch processor is not registered in the container.
			$processor = new $processor_class_name();
		}
		if ( ! is_a( $processor, BatchProcessorInterface::class ) ) {
			throw new \Exception( "Unable to initialize batch processor instance for $processor_class_name" );
		}
		return $processor;
	}

	/**
	 * Helper method to get list of all the enqueued processors.
	 *
	 * @return array List (of string) of the class names of the enqueued processors.
	 */
	public function get_enqueued_processors(): array {
		$enqueued_processors = get_option( self::ENQUEUED_PROCESSORS_OPTION_NAME, array() );

		if ( ! is_array( $enqueued_processors ) ) {
			$this->logger->error( 'Could not fetch list of processors. Clearing up queue.', array( 'source' => 'batch-processing' ) );
			delete_option( self::ENQUEUED_PROCESSORS_OPTION_NAME );
			$enqueued_processors = array();
		}

		return $enqueued_processors;
	}

	/**
	 * Dequeue a processor once it has no more items pending processing.
	 *
	 * @param string $processor_class_name Full processor class name.
	 */
	private function dequeue_processor( string $processor_class_name ): void {
		$pending_processes = $this->get_enqueued_processors();
		if ( in_array( $processor_class_name, $pending_processes, true ) ) {
			$this->clear_processor_state( $processor_class_name );
			$pending_processes = array_diff( $pending_processes, array( $processor_class_name ) );
			$this->set_enqueued_processors( $pending_processes );
		}
	}

	/**
	 * Helper method to set the enqueued processor class names.
	 *
	 * @param array $processors List of full processor class names.
	 */
	private function set_enqueued_processors( array $processors ): void {
		update_option( self::ENQUEUED_PROCESSORS_OPTION_NAME, $processors, false );
	}

	/**
	 * Check if a particular processor is enqueued.
	 *
	 * @param string $processor_class_name Fully qualified class name of the processor.
	 *
	 * @return bool True if the processor is enqueued.
	 */
	public function is_enqueued( string $processor_class_name ): bool {
		return in_array( $processor_class_name, $this->get_enqueued_processors(), true );
	}

	/**
	 * Dequeue and de-schedule a processor instance so that it won't be processed anymore.
	 *
	 * @param string $processor_class_name Fully qualified class name of the processor.
	 * @return bool True if the processor has been dequeued, false if the processor wasn't enqueued (so nothing has been done).
	 */
	public function remove_processor( string $processor_class_name ): bool {
		$enqueued_processors = $this->get_enqueued_processors();
		if ( ! in_array( $processor_class_name, $enqueued_processors, true ) ) {
			return false;
		}

		$enqueued_processors = array_diff( $enqueued_processors, array( $processor_class_name ) );
		if ( empty( $enqueued_processors ) ) {
			$this->force_clear_all_processes();
		} else {
			update_option( self::ENQUEUED_PROCESSORS_OPTION_NAME, $enqueued_processors, false );
			as_unschedule_all_actions( self::PROCESS_SINGLE_BATCH_ACTION_NAME, array( $processor_class_name ) );
			$this->clear_processor_state( $processor_class_name );
		}

		return true;
	}

	/**
	 * Dequeues and de-schedules all the processors.
	 */
	public function force_clear_all_processes(): void {
		as_unschedule_all_actions( self::PROCESS_SINGLE_BATCH_ACTION_NAME );
		as_unschedule_all_actions( self::WATCHDOG_ACTION_NAME );

		foreach ( $this->get_enqueued_processors() as $processor ) {
			$this->clear_processor_state( $processor );
		}

		update_option( self::ENQUEUED_PROCESSORS_OPTION_NAME, array(), false );
	}

	/**
	 * Log an error that happened while processing a batch.
	 *
	 * @param \Exception              $error Exception object to log.
	 * @param BatchProcessorInterface $batch_processor Batch processor instance.
	 * @param array                   $batch Batch that was being processed.
	 */
	protected function log_error( \Exception $error, BatchProcessorInterface $batch_processor, array $batch ): void {
		$error_message = "Error processing batch for {$batch_processor->get_name()}: {$error->getMessage()}";
		$error_context = array(
			'exception' => $error,
			'source'    => 'batch-processing',
		);

		// Log only first and last, as the entire batch may be too big.
		if ( count( $batch ) > 0 ) {
			$error_context = array_merge(
				$error_context,
				array(
					'batch_start' => $batch[0],
					'batch_end'   => end( $batch ),
				)
			);
		}

		/**
		 * Filters the error message for a batch processing.
		 *
		 * @param string $error_message The error message that will be logged.
		 * @param \Exception $error The exception that was thrown by the processor.
		 * @param BatchProcessorInterface $batch_processor The processor that threw the exception.
		 * @param array $batch The batch that was being processed.
		 * @param array $error_context Context to be passed to the logging function.
		 * @return string The actual error message that will be logged.
		 *
		 * @since 6.8.0
		 */
		$error_message = apply_filters( 'wc_batch_processing_log_message', $error_message, $error, $batch_processor, $batch, $error_context );

		$this->logger->error( $error_message, $error_context );
	}

	/**
	 * Determines whether a given processor is consistently failing based on how many recent consecutive failures it has had.
	 *
	 * @since 9.1.0
	 *
	 * @param BatchProcessorInterface $batch_processor The processor that we want to check.
	 * @return boolean TRUE if processor is consistently failing. FALSE otherwise.
	 */
	private function is_consistently_failing( BatchProcessorInterface $batch_processor ): bool {
		$process_details = $this->get_process_details( $batch_processor );
		$max_attempts    = absint(
			/**
			 * Controls the failure threshold for batch processors. That is, the number of times we'll attempt to
			 * process a batch that has resulted in a failure. Once above this threshold, the processor won't be
			 * re-scheduled and will be removed from the queue.
			 *
			 * @since 9.1.0
			 *
			 * @param int $failure_threshold Maximum number of times for the processor to try processing a given batch.
			 * @param BatchProcessorInterface $batch_processor The processor instance.
			 * @param array $process_details Array with batch processor state.
			 */
			apply_filters(
				'wc_batch_processing_max_attempts',
				self::FAILING_PROCESS_MAX_ATTEMPTS_DEFAULT,
				$batch_processor,
				$process_details
			)
		);

		return absint( $process_details['recent_failures'] ?? 0 ) >= max( $max_attempts, 1 );
	}

	/**
	 * Creates log entry with details about a batch processor that is consistently failing.
	 *
	 * @since 9.1.0
	 *
	 * @param BatchProcessorInterface $batch_processor The batch processor instance.
	 * @param array                   $process_details Failing process details.
	 */
	private function log_consistent_failure( BatchProcessorInterface $batch_processor, array $process_details ): void {
		$this->logger->error(
			"Batch processor {$batch_processor->get_name()} appears to be failing consistently: {$process_details['recent_failures']} unsuccessful attempt(s). No further attempts will be made.",
			array(
				'source'        => 'batch-processing',
				'failures'      => $process_details['recent_failures'],
				'first_failure' => $process_details['batch_first_failure'],
				'last_failure'  => $process_details['batch_last_failure'],
			)
		);
	}

	/**
	 * Hooked onto 'shutdown'. This cleanup routine checks enqueued processors and whether they are scheduled or not to
	 * either re-eschedule them or remove them from the queue.
	 * This prevents stale states where Action Scheduler won't schedule any more attempts but we still report the
	 * processor as enqueued.
	 *
	 * @since 9.1.0
	 */
	private function remove_or_retry_failed_processors(): void {
		if ( ! did_action( 'wp_loaded' ) ) {
			return;
		}

		$last_error = error_get_last();
		if ( ! is_null( $last_error ) && in_array( $last_error['type'], array( E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR ), true ) ) {
			return;
		}

		// The most efficient way to check for an existing action is to use `as_has_scheduled_action`, but in unusual
		// cases where another plugin has loaded a very old version of Action Scheduler, it may not be available to us.
		$has_scheduled_action = function_exists( 'as_has_scheduled_action') ? 'as_has_scheduled_action' : 'as_next_scheduled_action';

		if ( call_user_func( $has_scheduled_action, self::WATCHDOG_ACTION_NAME ) ) {
			return;
		}

		$enqueued_processors    = $this->get_enqueued_processors();
		$unscheduled_processors = array_diff( $enqueued_processors, array_filter( $enqueued_processors, array( $this, 'is_scheduled' ) ) );

		foreach ( $unscheduled_processors as $processor ) {
			try {
				$instance = $this->get_processor_instance( $processor );
			} catch ( \Exception $e ) {
				continue;
			}

			$exception = new \Exception( 'Processor is enqueued but not scheduled. Background job was probably killed or marked as failed. Reattempting execution.' );
			$this->update_processor_state( $instance, 0, $exception );
			$this->log_error( $exception, $instance, array() );

			if ( $this->is_consistently_failing( $instance ) ) {
				$this->log_consistent_failure( $instance, $this->get_process_details( $instance ) );
				$this->remove_processor( $processor );
			} else {
				$this->schedule_batch_processing( $processor, true );
			}
		}
	}
}
PK     [1]Բa      RegisterHooksInterface.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal;

/**
 * Interface RegisterHooksInterface
 *
 * The following must be added at the end of the 'init_hooks' method in the 'WooCommerce' class
 * for each class implementing this interface:
 * $container->get( <full class name>::class )->register();
 *
 * @since 8.5.0
 */
interface RegisterHooksInterface {

	/**
	 * Register this class instance to the appropriate hooks.
	 *
	 * @return void
	 */
	public function register();
}
PK     [1][    $  Admin/ProductForm/ComponentTrait.phpnu         <?php
/**
 * Product Form Traits
 */

namespace Automattic\WooCommerce\Internal\Admin\ProductForm;

defined( 'ABSPATH' ) || exit;

/**
 * ComponentTrait class.
 */
trait ComponentTrait {
	/**
	 * Component ID.
	 *
	 * @var string
	 */
	protected $id;

	/**
	 * Plugin ID.
	 *
	 * @var string
	 */
	protected $plugin_id;

	/**
	 * Product form component location.
	 *
	 * @var string
	 */
	protected $location;

	/**
	 * Product form component order.
	 *
	 * @var number
	 */
	protected $order;

	/**
	 * Return id.
	 *
	 * @return string
	 */
	public function get_id() {
		return $this->id;
	}

	/**
	 * Return plugin id.
	 *
	 * @return string
	 */
	public function get_plugin_id() {
		return $this->plugin_id;
	}
}
PK     [1]rH>  >  !  Admin/ProductForm/FormFactory.phpnu         <?php
/**
 * WooCommerce Product Form Factory
 *
 * @package Woocommerce ProductForm
 */

namespace Automattic\WooCommerce\Internal\Admin\ProductForm;

use WP_Error;

/**
 * Factory that contains logic for the WooCommerce Product Form.
 */
class FormFactory {
	/**
	 * Class instance.
	 *
	 * @var Form instance
	 */
	protected static $instance = null;

	/**
	 * Store form fields.
	 *
	 * @var array
	 */
	protected static $form_fields = array();

	/**
	 * Store form cards.
	 *
	 * @var array
	 */
	protected static $form_subsections = array();

	/**
	 * Store form sections.
	 *
	 * @var array
	 */
	protected static $form_sections = array();

	/**
	 * Store form tabs.
	 *
	 * @var array
	 */
	protected static $form_tabs = array();

	/**
	 * Get class instance.
	 */
	final public static function instance() {
		if ( ! static::$instance ) {
			static::$instance = new static();
		}
		return static::$instance;
	}

	/**
	 * Init.
	 */
	public function init() {    }

	/**
	 * Adds a field to the product form.
	 *
	 * @param string $id Field id.
	 * @param string $plugin_id Plugin id.
	 * @param array  $args Array containing the necessary arguments.
	 *     $args = array(
	 *       'type'            => (string) Field type. Required.
	 *       'section'         => (string) Field location. Required.
	 *       'order'           => (int) Field order.
	 *       'properties'      => (array) Field properties.
	 *       'name'            => (string) Field name.
	 *     ).
	 * @return Field|WP_Error New field or WP_Error.
	 */
	public static function add_field( $id, $plugin_id, $args ) {
		$new_field = self::create_item( 'field', 'Field', $id, $plugin_id, $args );
		if ( is_wp_error( $new_field ) ) {
			return $new_field;
		}
		self::$form_fields[ $id ] = $new_field;
		return $new_field;
	}

	/**
	 * Adds a Subsection to the product form.
	 *
	 * @param string $id Subsection id.
	 * @param string $plugin_id Plugin id.
	 * @param array  $args Array containing the necessary arguments.
	 * @return Subsection|WP_Error New subsection or WP_Error.
	 */
	public static function add_subsection( $id, $plugin_id, $args = array() ) {
		$new_subsection = self::create_item( 'subsection', 'Subsection', $id, $plugin_id, $args );
		if ( is_wp_error( $new_subsection ) ) {
			return $new_subsection;
		}
		self::$form_subsections[ $id ] = $new_subsection;
		return $new_subsection;
	}

	/**
	 * Adds a section to the product form.
	 *
	 * @param string $id Card id.
	 * @param string $plugin_id Plugin id.
	 * @param array  $args Array containing the necessary arguments.
	 * @return Section|WP_Error New section or WP_Error.
	 */
	public static function add_section( $id, $plugin_id, $args ) {
		$new_section = self::create_item( 'section', 'Section', $id, $plugin_id, $args );
		if ( is_wp_error( $new_section ) ) {
			return $new_section;
		}
		self::$form_sections[ $id ] = $new_section;
		return $new_section;
	}

	/**
	 * Adds a tab to the product form.
	 *
	 * @param string $id Card id.
	 * @param string $plugin_id Plugin id.
	 * @param array  $args Array containing the necessary arguments.
	 * @return Tab|WP_Error New section or WP_Error.
	 */
	public static function add_tab( $id, $plugin_id, $args ) {
		$new_tab = self::create_item( 'tab', 'Tab', $id, $plugin_id, $args );
		if ( is_wp_error( $new_tab ) ) {
			return $new_tab;
		}
		self::$form_tabs[ $id ] = $new_tab;
		return $new_tab;
	}

	/**
	 * Returns list of registered fields.
	 *
	 * @param array $sort_by key and order to sort by.
	 * @return array list of registered fields.
	 */
	public static function get_fields( $sort_by = array(
		'key'   => 'order',
		'order' => 'asc',
	) ) {
		return self::get_items( 'field', 'Field', $sort_by );
	}

	/**
	 * Returns list of registered cards.
	 *
	 * @param array $sort_by key and order to sort by.
	 * @return array list of registered cards.
	 */
	public static function get_subsections( $sort_by = array(
		'key'   => 'order',
		'order' => 'asc',
	) ) {
		return self::get_items( 'subsection', 'Subsection', $sort_by );
	}

	/**
	 * Returns list of registered sections.
	 *
	 * @param array $sort_by key and order to sort by.
	 * @return array list of registered sections.
	 */
	public static function get_sections( $sort_by = array(
		'key'   => 'order',
		'order' => 'asc',
	) ) {
		return self::get_items( 'section', 'Section', $sort_by );
	}

	/**
	 * Returns list of registered tabs.
	 *
	 * @param array $sort_by key and order to sort by.
	 * @return array list of registered tabs.
	 */
	public static function get_tabs( $sort_by = array(
		'key'   => 'order',
		'order' => 'asc',
	) ) {
		return self::get_items( 'tab', 'Tab', $sort_by );
	}

	/**
	 * Returns list of registered items.
	 *
	 * @param string $type Form component type.
	 * @return array List of registered items.
	 */
	private static function get_item_list( $type ) {
		$mapping = array(
			'field'      => self::$form_fields,
			'subsection' => self::$form_subsections,
			'section'    => self::$form_sections,
			'tab'        => self::$form_tabs,
		);
		if ( array_key_exists( $type, $mapping ) ) {
			return $mapping[ $type ];
		}
		return array();
	}

	/**
	 * Returns list of registered items.
	 *
	 * @param string       $type Form component type.
	 * @param class-string $class_name Class of component type.
	 * @param array        $sort_by key and order to sort by.
	 * @return array       list of registered items.
	 */
	private static function get_items( $type, $class_name, $sort_by = array(
		'key'   => 'order',
		'order' => 'asc',
	) ) {
		$item_list = self::get_item_list( $type );
		$class     = 'Automattic\\WooCommerce\\Internal\\Admin\\ProductForm\\' . $class_name;
		$items     = array_values( $item_list );
		if ( class_exists( $class ) && method_exists( $class, 'sort' ) ) {
			usort(
				$items,
				function ( $a, $b ) use ( $sort_by, $class ) {
					return $class::sort( $a, $b, $sort_by );
				}
			);
		}
		return $items;
	}

	/**
	 * Creates a new item.
	 *
	 * @param string       $type Form component type.
	 * @param class-string $class_name Class of component type.
	 * @param string       $id Item id.
	 * @param string       $plugin_id Plugin id.
	 * @param array        $args additional arguments for item.
	 * @return Field|Card|Section|Tab|WP_Error New product form item or WP_Error.
	 */
	private static function create_item( $type, $class_name, $id, $plugin_id, $args ) {
		$item_list = self::get_item_list( $type );
		$class     = 'Automattic\\WooCommerce\\Internal\\Admin\\ProductForm\\' . $class_name;
		if ( ! class_exists( $class ) ) {
			return new WP_Error(
				'wc_product_form_' . $type . '_missing_form_class',
				sprintf(
				/* translators: 1: missing class name. */
					esc_html__( '%1$s class does not exist.', 'woocommerce' ),
					$class
				)
			);
		}
		if ( isset( $item_list[ $id ] ) ) {
			return new WP_Error(
				'wc_product_form_' . $type . '_duplicate_field_id',
				sprintf(
				/* translators: 1: Item type 2: Duplicate registered item id. */
					esc_html__( 'You have attempted to register a duplicate form %1$s with WooCommerce Form: %2$s', 'woocommerce' ),
					$type,
					'`' . $id . '`'
				)
			);
		}

		$defaults = array(
			'order' => 20,
		);

		$item_arguments = wp_parse_args( $args, $defaults );

		try {
			return new $class( $id, $plugin_id, $item_arguments );
		} catch ( \Exception $e ) {
			return new WP_Error(
				'wc_product_form_' . $type . '_class_creation',
				$e->getMessage()
			);
		}
	}
}

PK     [1]^V      Admin/ProductForm/Tab.phpnu         <?php
/**
 * Handles product form tab related methods.
 */

namespace Automattic\WooCommerce\Internal\Admin\ProductForm;

/**
 * Field class.
 */
class Tab extends Component {

	/**
	 * Constructor
	 *
	 * @param string $id Field id.
	 * @param string $plugin_id Plugin id.
	 * @param array  $additional_args Array containing the necessary arguments.
	 *     $args = array(
	 *       'name'            => (string) Tab name. Required.
	 *       'title'         => (string) Tab title. Required.
	 *       'order'           => (int) Tab order.
	 *       'properties'      => (array) Tab properties.
	 *     ).
	 * @throws \Exception If there are missing arguments.
	 */
	public function __construct( $id, $plugin_id, $additional_args ) {
		parent::__construct( $id, $plugin_id, $additional_args );

		$this->required_arguments = array(
			'name',
			'title',
		);

		$missing_arguments = self::get_missing_arguments( $additional_args );
		if ( count( $missing_arguments ) > 0 ) {
			throw new \Exception(
				sprintf(
				/* translators: 1: Missing arguments list. */
					esc_html__( 'You are missing required arguments of WooCommerce ProductForm Tab: %1$s', 'woocommerce' ),
					join( ', ', $missing_arguments )
				)
			);
		}
	}

}
PK     [1]KMa      Admin/ProductForm/Component.phpnu         <?php
/**
 * Abstract class for product form components.
 */

namespace Automattic\WooCommerce\Internal\Admin\ProductForm;

/**
 * Component class.
 */
abstract class Component {
	/**
	 * Product Component traits.
	 */
	use ComponentTrait;

	/**
	 * Component additional arguments.
	 *
	 * @var array
	 */
	protected $additional_args;

	/**
	 * Constructor
	 *
	 * @param string $id Component id.
	 * @param string $plugin_id Plugin id.
	 * @param array  $additional_args Array containing additional arguments.
	 */
	public function __construct( $id, $plugin_id, $additional_args ) {
		$this->id              = $id;
		$this->plugin_id       = $plugin_id;
		$this->additional_args = $additional_args;
	}

	/**
	 * Component arguments.
	 *
	 * @return array
	 */
	public function get_additional_args() {
		return $this->additional_args;
	}

	/**
	 * Component arguments.
	 *
	 * @param string $key key of argument.
	 * @return mixed
	 */
	public function get_additional_argument( $key ) {
		return self::get_argument_from_path( $this->additional_args, $key );
	}

	/**
	 * Get the component as JSON.
	 *
	 * @return array
	 */
	public function get_json() {
		return array_merge(
			array(
				'id'        => $this->get_id(),
				'plugin_id' => $this->get_plugin_id(),
			),
			$this->get_additional_args()
		);
	}

	/**
	 * Sorting function for product form component.
	 *
	 * @param Component $a Component a.
	 * @param Component $b Component b.
	 * @param array     $sort_by key and order to sort by.
	 * @return int
	 */
	public static function sort( $a, $b, $sort_by = array() ) {
		$key   = $sort_by['key'];
		$a_val = $a->get_additional_argument( $key );
		$b_val = $b->get_additional_argument( $key );
		if ( 'asc' === $sort_by['order'] ) {
			return $a_val <=> $b_val;
		} else {
			return $b_val <=> $a_val;
		}
	}

	/**
	 * Gets argument by dot notation path.
	 *
	 * @param array  $arguments Arguments array.
	 * @param string $path Path for argument key.
	 * @param string $delimiter Path delimiter, default: '.'.
	 * @return mixed|null
	 */
	public static function get_argument_from_path( $arguments, $path, $delimiter = '.' ) {
		$path_keys = explode( $delimiter, $path );
		$num_keys  = false !== $path_keys ? count( $path_keys ) : 0;

		$val = $arguments;
		for ( $i = 0; $i < $num_keys; $i++ ) {
			$key = $path_keys[ $i ];
			if ( array_key_exists( $key, $val ) ) {
				$val = $val[ $key ];
			} else {
				$val = null;
				break;
			}
		}
		return $val;
	}

	/**
	 * Array of required arguments.
	 *
	 * @var array
	 */
	protected $required_arguments = array();

	/**
	 * Get missing arguments of args array.
	 *
	 * @param array $args field arguments.
	 * @return array
	 */
	public function get_missing_arguments( $args ) {
		return array_values(
			array_filter(
				$this->required_arguments,
				function( $arg_key ) use ( $args ) {
					return null === self::get_argument_from_path( $args, $arg_key );
				}
			)
		);
	}
}
PK     [1]ua      Admin/ProductForm/Section.phpnu         <?php
/**
 * Handles product form section related methods.
 */

namespace Automattic\WooCommerce\Internal\Admin\ProductForm;

/**
 * Section class.
 */
class Section extends Component {

	/**
	 * Constructor
	 *
	 * @param string $id Section id.
	 * @param string $plugin_id Plugin id.
	 * @param array  $additional_args Array containing additional arguments.
	 *     $args = array(
	 *       'order'       => (int) Section order.
	 *       'title'       => (string) Section description.
	 *       'description' => (string) Section description.
	 *     ).
	 * @throws \Exception If there are missing arguments.
	 */
	public function __construct( $id, $plugin_id, $additional_args ) {
		parent::__construct( $id, $plugin_id, $additional_args );
		$this->required_arguments = array(
			'title',
		);
		$missing_arguments        = self::get_missing_arguments( $additional_args );
		if ( count( $missing_arguments ) > 0 ) {
			throw new \Exception(
				sprintf(
				/* translators: 1: Missing arguments list. */
					esc_html__( 'You are missing required arguments of WooCommerce ProductForm Section: %1$s', 'woocommerce' ),
					join( ', ', $missing_arguments )
				)
			);
		}
	}
}
PK     [1]:i         Admin/ProductForm/Subsection.phpnu         <?php
/**
 * Handles product form SubSection related methods.
 */

namespace Automattic\WooCommerce\Internal\Admin\ProductForm;

/**
 * SubSection class.
 */
class Subsection extends Component {}
PK     [1](č      Admin/ProductForm/Field.phpnu         <?php
/**
 * Handles product form field related methods.
 */

namespace Automattic\WooCommerce\Internal\Admin\ProductForm;

/**
 * Field class.
 */
class Field extends Component {

	/**
	 * Constructor
	 *
	 * @param string $id Field id.
	 * @param string $plugin_id Plugin id.
	 * @param array  $additional_args Array containing the necessary arguments.
	 *     $args = array(
	 *       'type'            => (string) Field type. Required.
	 *       'section'         => (string) Field location. Required.
	 *       'order'           => (int) Field order.
	 *       'properties'      => (array) Field properties.
	 *     ).
	 * @throws \Exception If there are missing arguments.
	 */
	public function __construct( $id, $plugin_id, $additional_args ) {
		parent::__construct( $id, $plugin_id, $additional_args );
		$this->required_arguments = array(
			'type',
			'section',
			'properties.name',
			'properties.label',
		);

		$missing_arguments = self::get_missing_arguments( $additional_args );
		if ( count( $missing_arguments ) > 0 ) {
			throw new \Exception(
				sprintf(
				/* translators: 1: Missing arguments list. */
					esc_html__( 'You are missing required arguments of WooCommerce ProductForm Field: %1$s', 'woocommerce' ),
					join( ', ', $missing_arguments )
				)
			);
		}
	}
}
PK     [1]/^rPF  PF    Admin/WCAdminAssets.phpnu         <?php
/**
 * Register the scripts, and styles used within WooCommerce Admin.
 */

namespace Automattic\WooCommerce\Internal\Admin;

use _WP_Dependency;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Internal\Admin\Loader;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
/**
 * WCAdminAssets Class.
 */
class WCAdminAssets {

	/**
	 * Class instance.
	 *
	 * @var WCAdminAssets instance
	 */
	protected static $instance = null;

	/**
	 * An array of dependencies that have been preloaded (to avoid duplicates).
	 *
	 * @var array
	 */
	protected $preloaded_dependencies;


	/**
	 * Get class instance.
	 */
	public static function get_instance() {
		if ( ! self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Constructor.
	 * Hooks added here should be removed in `wc_admin_initialize` via the feature plugin.
	 */
	public function __construct() {
		Features::get_instance();
		add_action( 'admin_enqueue_scripts', array( $this, 'register_scripts' ) );

		add_action( 'admin_enqueue_scripts', array( $this, 'inject_wc_settings_dependencies' ), 14 );
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ), 15 );
	}

	/**
	 * Gets the path for the asset depending on file type.
	 *
	 * @param  string $ext File extension.
	 * @return string Folder path of asset.
	 */
	public static function get_path( $ext ) {
		return ( $ext === 'css' ) ? WC_ADMIN_DIST_CSS_FOLDER : WC_ADMIN_DIST_JS_FOLDER;
	}

	/**
	 * Determines if a minified JS file should be served.
	 *
	 * @param  boolean $script_debug Only serve unminified files if script debug is on.
	 * @return boolean If js asset should use minified version.
	 */
	public static function should_use_minified_js_file( $script_debug ) {
		// minified files are only shipped in non-core versions of wc-admin, return false if minified files are not available.
		if ( ! Features::exists( 'minified-js' ) ) {
			return false;
		}

		// Otherwise we will serve un-minified files if SCRIPT_DEBUG is on, or if anything truthy is passed in-lieu of SCRIPT_DEBUG.
		return ! $script_debug;
	}

	/**
	 * Gets the URL to an asset file.
	 *
	 * @param  string $file File name (without extension).
	 * @param  string $ext File extension.
	 * @return string URL to asset.
	 */
	public static function get_url( $file, $ext ) {
		$suffix = '';

		// Potentially enqueue minified JavaScript.
		if ( $ext === 'js' ) {
			$script_debug = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG;
			$suffix       = self::should_use_minified_js_file( $script_debug ) ? '.min' : '';
		}

		return plugins_url( self::get_path( $ext ) . $file . $suffix . '.' . $ext, WC_ADMIN_PLUGIN_FILE );
	}

	/**
	 * Gets the file modified time as a cache buster if we're in dev mode,
	 * or the asset version (file content hash) if exists, or the WooCommerce version.
	 *
	 * @param string      $ext File extension.
	 * @param string|null $asset_version Optional. The version from the asset file.
	 * @return string The cache buster value to use for the given file.
	 */
	public static function get_file_version( $ext, $asset_version = null ) {
		if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) {
			return filemtime( WC_ADMIN_ABSPATH . self::get_path( $ext ) );
		}

		if ( ! empty( $asset_version ) ) {
			return $asset_version;
		}

		return WC_VERSION;
	}

	/**
	 * Gets a script asset registry filename. The asset registry lists dependencies for the given script.
	 *
	 * @param  string $script_path_name Path to where the script asset registry is contained.
	 * @param  string $file File name (without extension).
	 * @return string complete asset filename.
	 *
	 * @throws \Exception Throws an exception when a readable asset registry file cannot be found.
	 */
	public static function get_script_asset_filename( $script_path_name, $file ) {
		$minification_supported = Features::exists( 'minified-js' );
		$script_min_filename    = $file . '.min.asset.php';
		$script_nonmin_filename = $file . '.asset.php';
		$script_asset_path      = WC_ADMIN_ABSPATH . WC_ADMIN_DIST_JS_FOLDER . $script_path_name . '/';

		// Check minification is supported first, to avoid multiple is_readable checks when minification is
		// not supported.
		if ( $minification_supported && is_readable( $script_asset_path . $script_min_filename ) ) {
			return $script_min_filename;
		} elseif ( is_readable( $script_asset_path . $script_nonmin_filename ) ) {
			return $script_nonmin_filename;
		} else {
			// could not find an asset file, throw an error.
			throw new \Exception( 'Could not find asset registry for ' . $script_path_name );
		}
	}

	/**
	 * Render a preload link tag for a dependency, optionally
	 * checked against a provided allowlist.
	 *
	 * See: https://macarthur.me/posts/preloading-javascript-in-wordpress
	 *
	 * @param WP_Dependency $dependency The WP_Dependency being preloaded.
	 * @param string        $type Dependency type - 'script' or 'style'.
	 * @param array         $allowlist Optional. List of allowed dependency handles.
	 */
	private function maybe_output_preload_link_tag( $dependency, $type, $allowlist = array() ) {
		if (
			(
				! empty( $allowlist ) &&
				! in_array( $dependency->handle, $allowlist, true )
			) ||
			( ! empty( $this->preloaded_dependencies[ $type ] ) &&
			in_array( $dependency->handle, $this->preloaded_dependencies[ $type ], true ) )
		) {
			return;
		}

		$this->preloaded_dependencies[ $type ][] = $dependency->handle;

		$source = $dependency->ver ? add_query_arg( 'ver', $dependency->ver, $dependency->src ) : $dependency->src;

		echo '<link rel="preload" href="', esc_url( $source ), '" as="', esc_attr( $type ), '" />', "\n";
	}

	/**
	 * Output a preload link tag for dependencies (and their sub dependencies)
	 * with an optional allowlist.
	 *
	 * See: https://macarthur.me/posts/preloading-javascript-in-wordpress
	 *
	 * @param string $type Dependency type - 'script' or 'style'.
	 * @param array  $allowlist Optional. List of allowed dependency handles.
	 */
	private function output_header_preload_tags_for_type( $type, $allowlist = array() ) {
		if ( $type === 'script' ) {
			$dependencies_of_type = wp_scripts();
		} elseif ( $type === 'style' ) {
			$dependencies_of_type = wp_styles();
		} else {
			return;
		}

		foreach ( $dependencies_of_type->queue as $dependency_handle ) {
			$dependency = $dependencies_of_type->query( $dependency_handle, 'registered' );

			if ( $dependency === false ) {
				continue;
			}

			// Preload the subdependencies first.
			foreach ( $dependency->deps as $sub_dependency_handle ) {
				$sub_dependency = $dependencies_of_type->query( $sub_dependency_handle, 'registered' );

				if ( $sub_dependency ) {
					$this->maybe_output_preload_link_tag( $sub_dependency, $type, $allowlist );
				}
			}

			$this->maybe_output_preload_link_tag( $dependency, $type, $allowlist );
		}
	}

	/**
	 * Output preload link tags for all enqueued stylesheets and scripts.
	 *
	 * See: https://macarthur.me/posts/preloading-javascript-in-wordpress
	 */
	private function output_header_preload_tags() {
		$wc_admin_scripts = array(
			WC_ADMIN_APP,
			'wc-components',
		);

		$wc_admin_styles = array(
			WC_ADMIN_APP,
			'wc-components',
			'wc-material-icons',
		);

		// Preload styles.
		$this->output_header_preload_tags_for_type( 'style', $wc_admin_styles );

		// Preload scripts.
		$this->output_header_preload_tags_for_type( 'script', $wc_admin_scripts );
	}

	/**
	 * Loads the required scripts on the correct pages.
	 */
	public function enqueue_assets() {
		if ( ! PageController::is_admin_or_embed_page() ) {
			return;
		}

		if ( ! PageController::is_modern_settings_page() ) {
			wp_enqueue_script( WC_ADMIN_APP );
			wp_enqueue_style( WC_ADMIN_APP );
		}

		wp_enqueue_style( 'wc-material-icons' );
		wp_enqueue_style( 'wc-onboarding' );

		if ( PageController::is_settings_page() ) {
			$this->register_script( 'wp-admin-scripts', 'settings-embed', true );
			$this->register_style( 'settings-embed', 'style', array( 'wp-components' ) );
		}

		// Preload our assets.
		$this->output_header_preload_tags();
	}

	/**
	 * Modify script dependencies based on various conditions to only load the necessary scripts.
	 *
	 * @param array  $dependencies Array of script dependencies.
	 * @param string $script Script name.
	 * @return array Modified dependencies.
	 */
	private function modify_script_dependencies( $dependencies, $script ) {
		switch ( $script ) {
			case WC_ADMIN_APP:
				// Remove wp-editor dependency if we're not on a customize store page since we don't use wp-editor in other pages.
				$is_customize_store_page = (
					PageController::is_admin_page() &&
					isset( $_GET['path'] ) && // phpcs:ignore WordPress.Security.NonceVerification.Recommended
					str_starts_with( wc_clean( wp_unslash( $_GET['path'] ) ), '/customize-store' ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
				);
				if ( ! $is_customize_store_page ) {
					$dependencies = array_diff( $dependencies, array( 'wp-editor' ) );
				}

				// Remove product editor dependency from WC_ADMIN_APP when feature is disabled.
				if ( ! FeaturesUtil::feature_is_enabled( 'product_block_editor' ) ) {
					$dependencies = array_diff( $dependencies, array( 'wc-product-editor' ) );
				}
				break;
			case 'wc-product-editor':
				// Remove wp-editor dependency if the product editor feature is disabled as we don't need it.
				$is_product_data_view_page = \Automattic\WooCommerce\Admin\Features\ProductDataViews\Init::is_product_data_view_page();
				if ( ! ( FeaturesUtil::feature_is_enabled( 'product_block_editor' ) || $is_product_data_view_page ) ) {
					$dependencies = array_diff( $dependencies, array( 'wp-editor' ) );
				}
				break;
		}
		return $dependencies;
	}

	/**
	 * Registers all the necessary scripts and styles to show the admin experience.
	 */
	public function register_scripts() {
		if ( ! function_exists( 'wp_set_script_translations' ) ) {
			return;
		}

		// Register the JS scripts.
		$scripts = array(
			'wc-admin-layout',
			'wc-explat',
			'wc-experimental',
			'wc-customer-effort-score',
			// NOTE: This should be removed when Gutenberg is updated and the notices package is removed from WooCommerce Admin.
			'wc-notices',
			'wc-number',
			'wc-tracks',
			'wc-date',
			'wc-components',
			WC_ADMIN_APP,
			'wc-csv',
			'wc-store-data',
			'wc-currency',
			'wc-navigation',
			'wc-block-templates',
			'wc-product-editor',
			'wc-settings-editor',
			'wc-remote-logging',
			'wc-sanitize',
		);

		$scripts_map = array(
			WC_ADMIN_APP    => PageController::is_embed_page() ? 'embed' : 'app',
			'wc-csv'        => 'csv-export',
			'wc-store-data' => 'data',
		);

		$translated_scripts = array(
			'wc-currency',
			'wc-date',
			'wc-components',
			'wc-customer-effort-score',
			'wc-experimental',
			'wc-navigation',
			'wc-product-editor',
			WC_ADMIN_APP,
		);

		foreach ( $scripts as $script ) {
			$script_path_name = isset( $scripts_map[ $script ] ) ? $scripts_map[ $script ] : str_replace( 'wc-', '', $script );

			try {
				$script_assets_filename = self::get_script_asset_filename( $script_path_name, 'index' );
				$script_assets          = require WC_ADMIN_ABSPATH . WC_ADMIN_DIST_JS_FOLDER . $script_path_name . '/' . $script_assets_filename;
				$script_version         = self::get_file_version( 'js', $script_assets['version'] );

				$script_dependencies = $this->modify_script_dependencies( $script_assets['dependencies'], $script, $script_path_name );

				wp_register_script(
					$script,
					self::get_url( $script_path_name . '/index', 'js' ),
					$script_dependencies,
					$script_version,
					true
				);

				if ( in_array( $script, $translated_scripts, true ) ) {
					wp_set_script_translations( $script, 'woocommerce' );
				}

				if ( WC_ADMIN_APP === $script ) {
					wp_localize_script(
						WC_ADMIN_APP,
						'wcAdminAssets',
						array(
							'path'    => plugins_url( self::get_path( 'js' ), WC_ADMIN_PLUGIN_FILE ),
							'version' => $script_version,
						)
					);
				}
			} catch ( \Exception $e ) {
				// Avoid crashing WordPress if an asset file could not be loaded.
				wc_caught_exception( $e, __CLASS__ . '::' . __FUNCTION__, $script_path_name );
			}
		}

		// Register the CSS styles.
		$styles = array(
			array(
				'handle' => 'wc-admin-layout',
			),
			array(
				'handle' => 'wc-components',
			),
			array(
				'handle' => 'wc-block-templates',
			),
			array(
				'handle' => 'wc-product-editor',
			),
			array(
				'handle' => 'wc-settings-editor',
			),
			array(
				'handle' => 'wc-customer-effort-score',
			),
			array(
				'handle' => 'wc-experimental',
			),
			array(
				'handle'       => WC_ADMIN_APP,
				'dependencies' => array( 'wc-components', 'wc-admin-layout', 'wc-customer-effort-score', 'wp-components', 'wc-experimental' ),
			),
			array(
				'handle' => 'wc-onboarding',
			),
		);

		$css_file_version = self::get_file_version( 'css' );
		foreach ( $styles as $style ) {
			$handle          = $style['handle'];
			$style_path_name = isset( $scripts_map[ $handle ] ) ? $scripts_map[ $handle ] : str_replace( 'wc-', '', $handle );

			try {
				$style_assets_filename = self::get_script_asset_filename( $style_path_name, 'style' );
				$style_assets          = require WC_ADMIN_ABSPATH . WC_ADMIN_DIST_JS_FOLDER . $style_path_name . '/' . $style_assets_filename;
				$version               = $style_assets['version'];
			} catch ( \Throwable $e ) {
				// Use the default version if the asset file could not be loaded.
				$version = $css_file_version;
			}

			$dependencies = isset( $style['dependencies'] ) ? $style['dependencies'] : array();
			wp_register_style(
				$handle,
				self::get_url( $style_path_name . '/style', 'css' ),
				$dependencies,
				self::get_file_version( 'css', $version ),
			);
			wp_style_add_data( $handle, 'rtl', 'replace' );
		}
	}

	/**
	 * Injects wp-shared-settings as a dependency if it's present.
	 */
	public function inject_wc_settings_dependencies() {
		$wp_scripts = wp_scripts();
		if ( wp_script_is( 'wc-settings', 'registered' ) ) {
			$handles_for_injection = array(
				'wc-admin-layout',
				'wc-csv',
				'wc-currency',
				'wc-customer-effort-score',
				'wc-navigation',
				// NOTE: This should be removed when Gutenberg is updated and
				// the notices package is removed from WooCommerce Admin.
				'wc-notices',
				'wc-number',
				'wc-date',
				'wc-components',
				'wc-tracks',
				'wc-block-templates',
				'wc-product-editor',
			);
			foreach ( $handles_for_injection as $handle ) {
				$script = $wp_scripts->query( $handle, 'registered' );
				if ( $script instanceof _WP_Dependency ) {
					$script->deps[] = 'wc-settings';
					$wp_scripts->add_data( $handle, 'group', 1 );
				}
			}
			foreach ( $wp_scripts->registered as $handle => $script ) {
				// scripts that are loaded in the footer has extra->group = 1.
				if ( array_intersect( $handles_for_injection, $script->deps ) && ! isset( $script->extra['group'] ) ) {
					// Append the script to footer.
					$wp_scripts->add_data( $handle, 'group', 1 );
					// Show a warning.
					$error_handle  = 'wc-settings-dep-in-header';
					$used_deps     = implode( ', ', array_intersect( $handles_for_injection, $script->deps ) );
					$error_message = "Scripts that have a dependency on [$used_deps] must be loaded in the footer, {$handle} was registered to load in the header, but has been switched to load in the footer instead. See https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5059";
					// phpcs:ignore WordPress.WP.EnqueuedResourceParameters.NotInFooter,WordPress.WP.EnqueuedResourceParameters.MissingVersion
					wp_register_script( $error_handle, '' );
					wp_enqueue_script( $error_handle );
					wp_add_inline_script(
						$error_handle,
						sprintf( 'console.warn( "%s" );', $error_message )
					);

				}
			}
		}
	}

	/**
	 * Loads a script
	 *
	 * @param string $script_path_name The script path name.
	 * @param string $script_name Filename of the script to load.
	 * @param bool   $need_translation Whether the script need translations.
	 * @param array  $dependencies Array of any extra dependencies. Note wc-admin and any application JS dependencies are automatically added by Dependency Extraction Webpack Plugin. Use this parameter to designate any extra dependencies.
	 */
	public static function register_script( $script_path_name, $script_name, $need_translation = false, $dependencies = array() ) {
		$script_assets_filename = self::get_script_asset_filename( $script_path_name, $script_name );
		$script_assets          = require WC_ADMIN_ABSPATH . WC_ADMIN_DIST_JS_FOLDER . $script_path_name . '/' . $script_assets_filename;

		wp_enqueue_script(
			'wc-admin-' . $script_name,
			self::get_url( $script_path_name . '/' . $script_name, 'js' ),
			array_merge( array( WC_ADMIN_APP ), $script_assets ['dependencies'], $dependencies ),
			self::get_file_version( 'js', $script_assets['version'] ),
			true
		);
		if ( $need_translation ) {
			wp_set_script_translations( 'wc-admin-' . $script_name, 'woocommerce' );
		}
	}

	/**
	 * Loads a style
	 *
	 * @param string $style_path_name The style path name.
	 * @param string $style_name Filename of the style to load.
	 * @param array  $dependencies Array of any extra dependencies.
	 */
	public static function register_style( $style_path_name, $style_name, $dependencies = array() ) {
		$style_assets_filename = self::get_script_asset_filename( $style_path_name, $style_name );
		$style_assets          = require WC_ADMIN_ABSPATH . WC_ADMIN_DIST_CSS_FOLDER . $style_path_name . '/' . $style_assets_filename;

		$handle = 'wc-admin-' . $style_name;
		wp_enqueue_style(
			$handle,
			self::get_url( $style_path_name . '/' . $style_name, 'css' ),
			$dependencies,
			self::get_file_version( 'css', $style_assets['version'] ),
		);
		wp_style_add_data( $handle, 'rtl', 'replace' );
	}
}
PK     [1]!    #  Admin/Agentic/AgenticController.phpnu         <?php
declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Admin\Agentic;

use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\RegisterHooksInterface;
use Automattic\WooCommerce\Utilities\FeaturesUtil;

/**
 * AgenticController class
 *
 * Main controller for Agentic Commerce Protocol features.
 * Manages initialization of webhooks and future settings for the Agentic feature.
 *
 * @since 10.3.0
 */
class AgenticController implements RegisterHooksInterface {
	/**
	 * Register this class instance to the appropriate hooks.
	 *
	 * @internal
	 */
	public function register() {
		// Don't register hooks during installation.
		if ( Constants::is_true( 'WC_INSTALLING' ) ) {
			return;
		}

		// We want to run on init for translations but before woocommerce_init so that
		// we can hook the new integration settings page. We should be able to simplify
		// this by just hooking here when we no longer need to check if the feature is enabled.
		add_action( 'before_woocommerce_init', array( $this, 'on_init' ) );
	}

	/**
	 * Hook into WordPress on init.
	 *
	 * @internal
	 */
	public function on_init() {
		// Bail if the feature is not enabled.
		if ( ! FeaturesUtil::feature_is_enabled( 'agentic_checkout' ) ) {
			return;
		}

		// Resolve webhook manager from container.
		wc_get_container()->get( AgenticWebhookManager::class )->register();

		// Register Agentic Commerce integration.
		add_filter( 'woocommerce_integrations', array( $this, 'add_agentic_commerce_integration' ) );
	}

	/**
	 * Add Agentic Commerce integration to WooCommerce integrations.
	 *
	 * @param array $integrations Existing integrations.
	 * @return array Modified integrations.
	 */
	public function add_agentic_commerce_integration( $integrations ): array {
		if ( ! is_array( $integrations ) ) {
			$integrations = array();
		}
		$integrations[] = AgenticCommerceIntegration::class;
		return $integrations;
	}
}
PK     [1]^A"q  q  ,  Admin/Agentic/AgenticCommerceIntegration.phpnu         <?php
declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Admin\Agentic;

/**
 * Agentic Commerce Integration class
 *
 * Registers the Agentic Commerce Protocol as a WooCommerce integration.
 * Manages settings for various AI agent providers (OpenAI, Anthropic, etc.)
 *
 * @since 10.4.0
 */
class AgenticCommerceIntegration extends \WC_Integration {

	/**
	 * Settings page instance.
	 *
	 * @var AgenticSettingsPage
	 */
	private $settings_page;

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->id                 = 'agentic_commerce';
		$this->method_title       = __( 'Agentic Commerce', 'woocommerce' );
		$this->method_description = __( 'Configure settings to allow AI agents to purchase from your store.', 'woocommerce' );

		// Initialize settings page helper.
		$this->settings_page = new AgenticSettingsPage();

		// Bind to the save action for the settings.
		add_action( 'woocommerce_update_options_integration_' . $this->id, array( $this, 'process_admin_options' ) );
	}

	/**
	 * Admin options output.
	 */
	public function admin_options() {
		$settings = $this->settings_page->get_settings( array(), $this->id );
		\WC_Admin_Settings::output_fields( $settings );
	}

	/**
	 * Process and save options.
	 */
	public function process_admin_options() {
		// Let AgenticSettingsPage handle saving.
		$this->settings_page->save_settings();
	}
}
PK     [1].A    .  Admin/Agentic/AgenticWebhookPayloadBuilder.phpnu         <?php
declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Admin\Agentic;

use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\StoreApi\Formatters\MoneyFormatter;
use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Enums\OrderMetaKey;
use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\OrderStatus as ACPOrderStatus;
use Automattic\WooCommerce\Internal\Agentic\Enums\Specs\RefundType;
use WC_Logger_Interface;
use WC_Order;
use WC_Order_Refund;

/**
 * AgenticWebhookPayloadBuilder class
 *
 * Builds webhook payloads for the Agentic Commerce Protocol following
 * the specification for order lifecycle events.
 *
 * @since 10.3.0
 */
class AgenticWebhookPayloadBuilder {
	/**
	 * Money formatter instance.
	 *
	 * @var MoneyFormatter
	 */
	private $money_formatter;

	/**
	 * Dependency initialization.
	 *
	 * @internal
	 */
	final public function init() {
		$this->money_formatter = new MoneyFormatter();
	}

	/**
	 * Build the webhook payload for an order event.
	 *
	 * @param string   $event Event type ('order_create' or 'order_update').
	 * @param WC_Order $order Order object.
	 * @return array Webhook payload.
	 */
	public function build_payload( string $event, WC_Order $order ): array {
		return array(
			'type' => $event,
			'data' => $this->build_order_data( $order ),
		);
	}

	/**
	 * Build the order data for the webhook payload.
	 *
	 * @param WC_Order $order Order object.
	 * @return array Order data.
	 */
	private function build_order_data( WC_Order $order ): array {
		return array(
			'type'                => 'order',
			'checkout_session_id' => $order->get_meta( OrderMetaKey::AGENTIC_CHECKOUT_SESSION_ID ),
			'permalink_url'       => $order->get_checkout_order_received_url(),
			'status'              => $this->map_order_status( $order->get_status() ),
			'refunds'             => $this->build_refunds_data( $order ),
		);
	}

	/**
	 * Map WooCommerce order status to ACP status.
	 *
	 * ACP statuses: created, manual_review, confirmed, canceled, shipped, fulfilled
	 *
	 * @param string $wc_status WooCommerce order status.
	 * @return string ACP status.
	 */
	private function map_order_status( string $wc_status ): string {
		$status_map = array(
			// WooCommerce status => ACP status.
			OrderStatus::PENDING    => ACPOrderStatus::CREATED,
			OrderStatus::PROCESSING => ACPOrderStatus::CONFIRMED,
			OrderStatus::ON_HOLD    => ACPOrderStatus::MANUAL_REVIEW,
			OrderStatus::COMPLETED  => ACPOrderStatus::FULFILLED,
			OrderStatus::CANCELLED  => ACPOrderStatus::CANCELED,
			OrderStatus::REFUNDED   => ACPOrderStatus::FULFILLED, // Refunded orders are still fulfilled.
			OrderStatus::FAILED     => ACPOrderStatus::CANCELED,
		);

		/**
		 * Filter the WooCommerce to ACP order status mapping.
		 *
		 * Allows extensions to map custom WooCommerce order statuses to ACP order statuses.
		 * The mapped status must be one of: created, manual_review, confirmed, canceled, shipped, fulfilled.
		 *
		 * @see Automattic\WooCommerce\Internal\Agentic\Enums\Specs\OrderStatus
		 *
		 * @since 10.3.0
		 *
		 * @param array  $status_map Associative array of WooCommerce status => ACP status.
		 * @param string $wc_status  The WooCommerce order status being mapped.
		 */
		$status_map = apply_filters( 'woocommerce_agentic_webhook_order_status_map', $status_map, $wc_status );

		// Get mapped status or default to 'created'.
		$mapped_status = isset( $status_map[ $wc_status ] ) ? $status_map[ $wc_status ] : ACPOrderStatus::CREATED;

		// Validate the mapped status is a valid ACP status.
		if ( ! ACPOrderStatus::is_valid( $mapped_status ) ) {
			// Log a warning for invalid status but continue with fallback.
			wc_get_logger()->warning(
				sprintf(
					'Invalid ACP order status "%s" returned by woocommerce_agentic_webhook_order_status_map filter for WooCommerce status "%s". Using "created" as fallback.',
					$mapped_status,
					$wc_status
				),
				array( 'source' => 'agentic-webhooks' )
			);
			return ACPOrderStatus::CREATED;
		}

		return $mapped_status;
	}

	/**
	 * Build refunds data for the order.
	 *
	 * @param WC_Order $order Order object.
	 * @return array Array of refunds.
	 */
	private function build_refunds_data( WC_Order $order ): array {
		return array_map(
			array( $this, 'build_single_refund_data' ),
			$order->get_refunds()
		);
	}

	/**
	 * Build data for a single refund.
	 *
	 * @param WC_Order_Refund $refund Refund object.
	 * @return array Refund data.
	 */
	private function build_single_refund_data( WC_Order_Refund $refund ): array {
		$refund_type = $this->determine_refund_type( $refund );
		$amount      = abs( (float) $refund->get_total() ); // Get absolute value as refunds are negative.

		// Convert amount to minor units using MoneyFormatter (respects store currency decimals).
		$amount_in_minor_units = (int) $this->money_formatter->format( $amount );

		return array(
			'type'   => $refund_type,
			'amount' => $amount_in_minor_units,
		);
	}

	/**
	 * Determine the refund type.
	 *
	 * @param WC_Order_Refund $refund Refund object.
	 * @return string Refund type ('store_credit' or 'original_payment').
	 */
	private function determine_refund_type( WC_Order_Refund $refund ): string {
		// Default to original payment method.
		$refund_type = RefundType::ORIGINAL_PAYMENT;

		/**
		 * Filter the refund type for Agentic webhooks.
		 *
		 * This allows extensions to specify when a refund is store credit.
		 * By default, all refunds are assumed to be original payment method.
		 *
		 * @since 10.4.0
		 * @param string          $refund_type The refund type ('store_credit' or 'original_payment').
		 * @param WC_Order_Refund $refund      The refund object.
		 */
		return apply_filters( 'woocommerce_agentic_webhook_refund_type', $refund_type, $refund );
	}
}
PK     [1]VOx%  x%  %  Admin/Agentic/AgenticSettingsPage.phpnu         <?php
declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Admin\Agentic;

/**
 * AgenticSettingsPage class
 *
 * Adds Agentic Commerce settings to WooCommerce > Settings > Integration.
 * Uses a provider-based system to allow multiple AI agent integrations.
 *
 * @since 10.4.0
 */
class AgenticSettingsPage {

	/**
	 * Registry option name.
	 */
	const REGISTRY_OPTION = 'woocommerce_agentic_agent_registry';

	/**
	 * Constructor.
	 */
	public function __construct() {
		// No hooks needed - used by AgenticCommerceIntegration class.
	}

	/**
	 * Get the agent registry with default values.
	 *
	 * @return array Agent registry.
	 */
	private function get_registry() {
		return get_option( self::REGISTRY_OPTION, array() );
	}

	/**
	 * Get registered providers.
	 *
	 * Each provider should return an array with:
	 * - id: string (unique identifier, e.g., 'openai')
	 * - name: string (display name, e.g., 'OpenAI')
	 * - description: string (optional description)
	 * - fields: array (settings fields configuration)
	 *
	 * @return array Array of registered providers.
	 */
	private function get_providers() {
		$registry = $this->get_registry();

		// Register built-in OpenAI provider.
		$providers = array(
			array(
				'id'          => 'openai',
				'name'        => __( 'ChatGPT', 'woocommerce' ),
				'description' => sprintf(
					/* translators: %s: URL to ChatGPT merchants application page */
					__( 'To get started, <a href="%s" target="_blank">apply to ChatGPT</a>. Once approved, ChatGPT will provide the credentials below.', 'woocommerce' ),
					'https://chatgpt.com/merchants'
				),
				'fields'      => $this->get_openai_fields(),
			),
		);

		/**
		 * Filter to register additional AI agent providers.
		 *
		 * Allows extensions to add their own AI agent provider settings.
		 * Each provider should return an array with id, name, description, and fields.
		 *
		 * @since 10.4.0
		 *
		 * @internal This filter is experimental and behind a non-visible feature flag. Backwards compatibility not guaranted.
		 *
		 * @param array $providers Array of provider configurations.
		 * @param array $registry  Current registry data.
		 */
		$providers = apply_filters( 'woocommerce_agentic_commerce_providers', $providers, $registry );

		// Validate provider structure.
		$validated = array();
		foreach ( $providers as $provider ) {
			if (
				! is_array( $provider )
				|| empty( $provider['id'] )
				|| empty( $provider['name'] )
				|| ! is_array( $provider['fields'] ?? null )
			) {
				continue;
			}

			// Sanitize text fields.
			$provider['id']   = sanitize_key( $provider['id'] );
			$provider['name'] = sanitize_text_field( $provider['name'] );
			if ( ! empty( $provider['description'] ) ) {
				$provider['description'] = wp_kses_post( $provider['description'] );
			}

			$validated[] = $provider;
		}

		return $validated;
	}

	/**
	 * Get general Agentic Commerce settings.
	 *
	 * @param array $config Current general configuration.
	 * @return array Settings fields.
	 */
	private function get_general_settings( $config ) {
		return array(
			array(
				'title' => __( 'Agentic commerce', 'woocommerce' ),
				'type'  => 'title',
				'desc'  => '',
				'id'    => 'agentic_commerce_general_settings',
			),
			array(
				'title'   => __( 'Enable product visibility', 'woocommerce' ),
				'desc'    => __( 'Allow products to be visible by default to the AI agents you integrate with. Can be overridden per product.', 'woocommerce' ),
				'id'      => 'woocommerce_agentic_enable_products_default',
				'type'    => 'checkbox',
				'default' => ( ! empty( $config['enable_products_default'] ) && 'yes' === $config['enable_products_default'] ) ? 'yes' : 'no',
			),
			array(
				'type' => 'sectionend',
				'id'   => 'agentic_commerce_general_settings',
			),
		);
	}

	/**
	 * Get store policies settings.
	 *
	 * @return array Settings fields.
	 */
	private function get_store_policies_settings() {
		// Get URLs from WooCommerce/WordPress settings.
		$terms_page_id   = wc_terms_and_conditions_page_id();
		$privacy_page_id = get_option( 'wp_page_for_privacy_policy' );

		$terms_url   = $terms_page_id ? get_permalink( $terms_page_id ) : '';
		$privacy_url = $privacy_page_id ? get_permalink( $privacy_page_id ) : '';

		// Build admin URLs for configuration links.
		$advanced_settings_url = admin_url( 'admin.php?page=wc-settings&tab=advanced' );
		$privacy_settings_url  = admin_url( 'options-privacy.php' );

		return array(
			array(
				'title' => __( 'Store policies', 'woocommerce' ),
				'type'  => 'title',
				'desc'  => '',
				'id'    => 'agentic_commerce_store_policies',
			),
			array(
				'title'             => __( 'Privacy Policy URL', 'woocommerce' ),
				'desc'              => sprintf(
					/* translators: %s: URL to WordPress privacy settings */
					__( 'Configure your Privacy Policy page in <a href="%s">Settings &gt; Privacy</a>.', 'woocommerce' ),
					esc_url( $privacy_settings_url )
				),
				'id'                => 'woocommerce_agentic_privacy_url_display',
				'type'              => 'text',
				'default'           => esc_url( $privacy_url ),
				'custom_attributes' => array(
					'disabled' => 'disabled',
					'readonly' => 'readonly',
				),
			),
			array(
				'title'             => __( 'Terms and Conditions URL', 'woocommerce' ),
				'desc'              => sprintf(
					/* translators: %s: URL to WooCommerce advanced settings */
					__( 'Configure your Terms and Conditions page in <a href="%s">WooCommerce &gt; Settings &gt; Advanced &gt; Page setup</a>.', 'woocommerce' ),
					esc_url( $advanced_settings_url )
				),
				'id'                => 'woocommerce_agentic_terms_url_display',
				'type'              => 'text',
				'default'           => esc_url( $terms_url ),
				'custom_attributes' => array(
					'disabled' => 'disabled',
					'readonly' => 'readonly',
				),
			),
			array(
				'type' => 'sectionend',
				'id'   => 'agentic_commerce_store_policies',
			),
		);
	}

	/**
	 * Get OpenAI provider fields.
	 *
	 * @return array Fields configuration.
	 */
	private function get_openai_fields() {
		return array(
			array(
				'title'   => __( 'Authorization Token', 'woocommerce' ),
				'desc'    => __( 'The bearer token that ChatGPT uses to authenticate checkout requests.', 'woocommerce' ),
				'id'      => 'woocommerce_agentic_openai_bearer_token',
				'type'    => 'password',
				'default' => '',
			),
		);
	}

	/**
	 * Get settings for Agentic Commerce integration.
	 *
	 * @param array  $settings Current settings.
	 * @param string $current_section Current section ID.
	 * @return array Settings array.
	 */
	public function get_settings( $settings, $current_section ) {
		if ( 'agentic_commerce' !== $current_section ) {
			return $settings;
		}

		$agentic_settings = array();
		$registry         = $this->get_registry();

		// Add general Agentic Commerce settings section.
		$agentic_settings = array_merge( $agentic_settings, $this->get_general_settings( $registry['general'] ?? array() ) );

		// Build settings for each provider.
		$providers = $this->get_providers();
		foreach ( $providers as $provider ) {
			// Provider section header.
			$agentic_settings[] = array(
				'title' => $provider['name'],
				'type'  => 'title',
				'desc'  => $provider['description'] ?? '',
				'id'    => 'agentic_commerce_' . $provider['id'] . '_settings',
			);

			// Add provider fields.
			foreach ( $provider['fields'] as $field ) {
				$agentic_settings[] = $field;
			}

			// Provider section end.
			$agentic_settings[] = array(
				'type' => 'sectionend',
				'id'   => 'agentic_commerce_' . $provider['id'] . '_settings',
			);
		}

		// Add store policies section.
		$agentic_settings = array_merge( $agentic_settings, $this->get_store_policies_settings() );

		return $agentic_settings;
	}

	/**
	 * Save settings to registry structure.
	 */
	public function save_settings() {
		check_admin_referer( 'woocommerce-settings' );

		$registry = $this->get_registry();

		// Update general settings.
		$registry['general'] = array(
			'enable_products_default' => isset( $_POST['woocommerce_agentic_enable_products_default'] ) && '1' === $_POST['woocommerce_agentic_enable_products_default']
				? 'yes'
				: 'no',
		);

		// Update OpenAI settings.
		$new_token = isset( $_POST['woocommerce_agentic_openai_bearer_token'] )
			? sanitize_text_field( wp_unslash( $_POST['woocommerce_agentic_openai_bearer_token'] ) )
			: '';

		// Only update if a new token was provided; otherwise keep existing.
		if ( ! empty( $new_token ) ) {
			$registry['openai']['bearer_token'] = wp_hash_password( $new_token );
		} elseif ( ! isset( $registry['openai']['bearer_token'] ) ) {
			$registry['openai']['bearer_token'] = '';
		}

		/**
		 * Filter registry before saving.
		 *
		 * Allows extensions to save their own agent provider settings.
		 * Extensions can access $_POST directly for their settings but MUST sanitize all input
		 * using appropriate WordPress sanitization functions (sanitize_text_field, esc_url_raw, etc.)
		 * and call wp_unslash() on POST data.
		 *
		 * @since 10.4.0
		 *
		 * @internal This filter is experimental and behind a non-visible feature flag. Backwards compatibility not guaranted.
		 *
		 * @param array $registry Registry data to save. Extensions should add their provider settings to this array.
		 */
		$registry = apply_filters( 'woocommerce_agentic_commerce_save_settings', $registry );

		// Save registry (don't autoload to prevent performance issues).
		update_option( self::REGISTRY_OPTION, $registry, false );
	}
}
PK     [1]ƯKb      '  Admin/Agentic/AgenticWebhookManager.phpnu         <?php
declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Admin\Agentic;

use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Internal\RegisterHooksInterface;
use Automattic\WooCommerce\StoreApi\Routes\V1\Agentic\Enums\OrderMetaKey;
use WC_Order;
use WC_Webhook;

/**
 * AgenticWebhookManager class
 *
 * Integrates Agentic Commerce Protocol webhooks with WooCommerce's native webhook system.
 * Defines custom action topics and handles filtering/transformation for ACP compliance.
 *
 * @since 10.3.0
 */
class AgenticWebhookManager implements RegisterHooksInterface {
	/**
	 * Action that will be triggered for webhooks.
	 *
	 * @var string
	 */
	const WEBHOOK_ACTION = 'woocommerce_agentic_order_changed';

	/**
	 * Topic that will be used for webhooks.
	 *
	 * @var string
	 */
	const WEBHOOK_TOPIC = 'action.' . self::WEBHOOK_ACTION;

	/**
	 * Meta key to store if the first event has been delivered.
	 *
	 * @var string
	 */
	const FIRST_EVENT_DELIVERED_META_KEY = '_acp_order_created_sent';

	/**
	 * Payload builder instance.
	 *
	 * @var AgenticWebhookPayloadBuilder
	 */
	private $payload_builder;

	/**
	 * Initializes dependencies and hooks.
	 *
	 * @internal
	 *
	 * @param AgenticWebhookPayloadBuilder $payload_builder Payload builder instance.
	 */
	final public function init( AgenticWebhookPayloadBuilder $payload_builder ) {
		$this->payload_builder = $payload_builder;
	}

	/**
	 * Initialize hooks for webhook integration.
	 *
	 *  @internal
	 */
	public function register() {

		add_filter( 'woocommerce_webhook_topics', array( $this, 'register_webhook_topic_names' ) );

		// Hook into order lifecycle events to fire our custom actions.
		add_action( 'woocommerce_new_order', array( $this, 'handle_order_created' ), 999, 2 ); // Hook late to give a chance for other plugins to modify.
		add_action( 'woocommerce_order_status_changed', array( $this, 'handle_order_status_changed' ), 10, 4 );
		add_action( 'woocommerce_order_refunded', array( $this, 'handle_order_refunded' ), 10, 1 );

		// Customize webhook payload for our topics.
		add_filter( 'woocommerce_webhook_payload', array( $this, 'customize_webhook_payload' ), 10, 4 );

		// Customize webhook HTTP arguments for our topics.
		add_filter( 'woocommerce_webhook_http_args', array( $this, 'customize_webhook_http_args' ), 10, 3 );

		// When the webhook is delivered (or not), mark the first event as delivered.
		add_action( 'woocommerce_webhook_delivery', array( $this, 'mark_first_event_delivered' ), 10, 5 );
	}

	/**
	 * Register webhook topic names for display in the UI.
	 *
	 * @param array $topics Existing topics.
	 * @return array Modified topics.
	 */
	public function register_webhook_topic_names( $topics ): array {
		$topics[ self::WEBHOOK_TOPIC ] = __( 'Agentic Commerce Protocol: Order created or updated', 'woocommerce' );
		return $topics;
	}

	/**
	 * Handle order creation.
	 *
	 * @param int      $order_id Order ID.
	 * @param WC_Order $order    Order object.
	 */
	public function handle_order_created( $order_id, $order ) {
		if ( ! $this->should_trigger_webhook( $order ) ) {
			return;
		}

		/**
		 * Fires when an Agentic order is updated or created.
		 *
		 * @since 10.3.0
		 *
		 * @param int      $order_id Order ID.
		 * @param WC_Order $order    Order object.
		 */
		do_action( self::WEBHOOK_ACTION, $order_id, $order );
	}

	/**
	 * Handle order status changes.
	 *
	 * @param int      $order_id   Order ID.
	 * @param string   $old_status Old status.
	 * @param string   $new_status New status.
	 * @param WC_Order $order      Order object.
	 */
	public function handle_order_status_changed( $order_id, $old_status, $new_status, $order ) {
		if ( ! $this->should_trigger_webhook( $order ) ) {
			return;
		}

		/**
		 * Fires when an Agentic order status changes.
		 *
		 * @since 10.3.0
		 *
		 * @param int      $order_id Order ID.
		 * @param WC_Order $order    Order object.
		 */
		do_action( self::WEBHOOK_ACTION, $order_id, $order );
	}

	/**
	 * Handle order refunds.
	 *
	 * @param int $order_id  Order ID.
	 */
	public function handle_order_refunded( $order_id ) {
		$order = wc_get_order( $order_id );
		if ( ! $order || ! $this->should_trigger_webhook( $order ) ) {
			return;
		}

		/**
		 * Fires when an Agentic order is refunded.
		 *
		 * @since 10.3.0
		 *
		 * @param int      $order_id Order ID.
		 * @param WC_Order $order    Order object.
		 */
		do_action( self::WEBHOOK_ACTION, $order_id, $order );
	}

	/**
	 * Check if webhook should be triggered for this order.
	 *
	 * @param WC_Order $order Order object.
	 * @return bool True if webhook should be triggered.
	 */
	private function should_trigger_webhook( $order ) {
		// Only trigger for orders with an Agentic checkout session ID.
		$checkout_session_id = $order->get_meta( OrderMetaKey::AGENTIC_CHECKOUT_SESSION_ID );
		if ( empty( $checkout_session_id ) ) {
			return false;
		}

		// Don't trigger for draft orders.
		if (
			in_array(
				$order->get_status(),
				array(
					OrderStatus::CHECKOUT_DRAFT,
					OrderStatus::DRAFT,
					OrderStatus::AUTO_DRAFT,
				),
				true
			)
		) {
			return false;
		}

		return true;
	}

	/**
	 * Customize webhook payload for Agentic topics.
	 *
	 * @param array  $payload        Original payload.
	 * @param string $resource_type  Resource type.
	 * @param int    $resource_id    Resource ID.
	 * @param int    $webhook_id     Webhook ID.
	 * @return array Modified payload.
	 */
	public function customize_webhook_payload( $payload, $resource_type, $resource_id, $webhook_id ) {
		$webhook = wc_get_webhook( $webhook_id );
		if ( ! $webhook ) {
			return $payload;
		}

		$topic = $webhook->get_topic();

		// Check if this is one of our Agentic topics.
		if ( self::WEBHOOK_TOPIC !== $topic ) {
			return $payload;
		}

		// Get the order.
		$order = wc_get_order( $resource_id );
		if ( ! $order ) {
			return $payload;
		}

		$is_first_event = 'sent' !== $order->get_meta( self::FIRST_EVENT_DELIVERED_META_KEY );
		$event          = $is_first_event ? 'order_create' : 'order_update';

		// Build ACP-compliant payload.
		return $this->payload_builder->build_payload( $event, $order );
	}

	/**
	 * Customize webhook HTTP arguments for Agentic topics.
	 *
	 * @param array $http_args  HTTP arguments.
	 * @param mixed $arg        First hook argument.
	 * @param int   $webhook_id Webhook ID.
	 * @return array Modified HTTP arguments.
	 */
	public function customize_webhook_http_args( $http_args, $arg, $webhook_id ) {
		$webhook = wc_get_webhook( $webhook_id );
		if ( ! $webhook ) {
			return $http_args;
		}

		$topic = $webhook->get_topic();

		// Check if this is one of our Agentic topics.
		if ( self::WEBHOOK_TOPIC !== $topic ) {
			return $http_args;
		}

		// Compute HMAC signature per ACP webhook spec using WooCommerce's built-in method.
		// The signature must be computed over the raw request body.
		if ( isset( $http_args['body'] ) && ! empty( $webhook->get_secret() ) ) {
			// Use WooCommerce's signature generation to ensure consistency.
			$signature = $webhook->generate_signature( $http_args['body'] );

			// Add Merchant-Signature header per ACP webhook specification.
			$http_args['headers']['Merchant-Signature'] = $signature;
		}

		return $http_args;
	}

	/**
	 * Mark first event as delivered on successful webhook delivery.
	 *
	 * @param array $http_args   HTTP request args.
	 * @param mixed $response    HTTP response.
	 * @param float $duration    Request duration.
	 * @param int   $arg         First argument to the action (order_id).
	 * @param int   $webhook_id  Webhook ID.
	 */
	public function mark_first_event_delivered( $http_args, $response, $duration, $arg, $webhook_id ) {
		// Only proceed for successful responses.
		if ( is_wp_error( $response ) ) {
			return;
		}
		$code = wp_remote_retrieve_response_code( $response );
		if ( $code < 200 || $code >= 300 ) {
			return;
		}

		// Verify this is our webhook topic.
		$webhook = wc_get_webhook( $webhook_id );
		if ( ! $webhook || self::WEBHOOK_TOPIC !== $webhook->get_topic() ) {
			return;
		}

		// $arg contains the order_id from do_action( self::WEBHOOK_ACTION, $order_id, $order ).
		$order = wc_get_order( $arg );
		if ( ! $order ) {
			return;
		}

		if ( 'sent' !== $order->get_meta( self::FIRST_EVENT_DELIVERED_META_KEY ) ) {
			$order->update_meta_data( self::FIRST_EVENT_DELIVERED_META_KEY, 'sent' );
			$order->save();
		}
	}
}
PK     [1]e    )  Admin/ShippingLabelBannerDisplayRules.phpnu         <?php
/**
 * WooCommerce Shipping Label Banner Display Rules.
 */

namespace Automattic\WooCommerce\Internal\Admin;

/**
 * Determines whether the Shipping Label Banner should be displayed
 */
class ShippingLabelBannerDisplayRules {

	/**
	 * Whether the site is connected to wordpress.com.
	 *
	 * @var bool
	 */
	private $dotcom_connected;

	/**
	 * Whether installed plugins are incompatible with the banner.
	 *
	 * @var bool
	 */
	private $no_incompatible_plugins_installed;

	/**
	 * Holds the installed WooCommerce Shipping & Tax version.
	 *
	 * @var string
	 */
	private $wcs_version;

	/**
	 * Supported countries by USPS, see: https://webpmt.usps.gov/pmt010.cfm
	 *
	 * @var array
	 */
	private $supported_countries = array( 'US', 'AS', 'PR', 'VI', 'GU', 'MP', 'UM', 'FM', 'MH' );

	/**
	 * Array of supported currency codes.
	 *
	 * @var array
	 */
	private $supported_currencies = array( 'USD' );


	/**
	 * Constructor.
	 *
	 * @param bool        $dotcom_connected Is site connected to wordpress.com?.
	 * @param string|null $wcs_version Installed WooCommerce Shipping version to check, null if not installed.
	 * @param bool        $incompatible_plugins_installed Are there any incompatible plugins installed?.
	 */
	public function __construct( $dotcom_connected, $wcs_version, $incompatible_plugins_installed ) {
		$this->dotcom_connected                  = $dotcom_connected;
		$this->wcs_version                       = $wcs_version;
		$this->no_incompatible_plugins_installed = ! $incompatible_plugins_installed;
	}

	/**
	 * Determines whether banner is eligible for display (does not include a/b logic).
	 */
	public function should_display_banner() {
		return $this->banner_not_dismissed() &&
			$this->dotcom_connected &&
			$this->no_incompatible_plugins_installed &&
			$this->order_has_shippable_products() &&
			$this->store_in_us_and_usd() &&
			$this->wcs_not_installed();
	}

	/**
	 * Checks if the banner was not dismissed by the user.
	 *
	 * @return bool
	 */
	private function banner_not_dismissed() {
		$dismissed_timestamp_ms = get_option( 'woocommerce_shipping_dismissed_timestamp' );

		if ( ! is_numeric( $dismissed_timestamp_ms ) ) {
			return true;
		}
		$dismissed_timestamp_ms = intval( $dismissed_timestamp_ms );
		$dismissed_timestamp    = intval( round( $dismissed_timestamp_ms / 1000 ) );
		$expired_timestamp      = $dismissed_timestamp + 24 * 60 * 60; // 24 hours from click time

		$dismissed_for_good = -1 === $dismissed_timestamp_ms;
		$dismissed_24h      = time() < $expired_timestamp;

		return ! $dismissed_for_good && ! $dismissed_24h;
	}

	/**
	 * Checks if there's a shippable product in the current order.
	 *
	 * @return bool
	 */
	private function order_has_shippable_products() {
		$order = wc_get_order();

		if ( ! $order ) {
			return false;
		}
		// At this point (no packaging data), only show if there's at least one existing and shippable product.
		foreach ( $order->get_items() as $item ) {
			if ( $item instanceof \WC_Order_Item_Product ) {
				$product = $item->get_product();

				if ( $product && $product->needs_shipping() ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Checks if the store is in the US and has its default currency set to USD.
	 *
	 * @return bool
	 */
	private function store_in_us_and_usd() {
		$base_currency = get_woocommerce_currency();
		$base_location = wc_get_base_location();

		return in_array( $base_currency, $this->supported_currencies, true ) && in_array( $base_location['country'], $this->supported_countries, true );
	}

	/**
	 * Checks if WooCommerce Shipping & Tax is not installed.
	 *
	 * @return bool
	 */
	private function wcs_not_installed() {
		return ! $this->wcs_version;
	}
}
PK     [1])}ξ    +  Admin/Emails/EmailListingRestController.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Emails;

use Automattic\WooCommerce\Internal\RestApiControllerBase;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmails;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
use WP_Error;
use WP_REST_Request;

/**
 * Controller for the REST endpoint for the new email listing page.
 */
class EmailListingRestController extends RestApiControllerBase {

	/**
	 * Email listing nonce.
	 *
	 * @var string
	 */
	const NONCE_KEY = 'email-listing-nonce';

	/**
	 * The root namespace for the JSON REST API endpoints.
	 *
	 * @var string
	 */
	protected string $route_namespace = 'wc-admin-email';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected string $rest_base = 'settings/email/listing';

	/**
	 * Email template generator instance.
	 *
	 * @var WCTransactionalEmailPostsGenerator
	 */
	private $email_template_generator;

	/**
	 * Get the WooCommerce REST API namespace for the class.
	 *
	 * @return string
	 */
	protected function get_rest_api_namespace(): string {
		return 'wc-admin-email-listing';
	}

	/**
	 * The constructor.
	 */
	public function __construct() {
		$this->email_template_generator = new WCTransactionalEmailPostsGenerator();
	}

	/**
	 * Perform the initialization.
	 */
	public function initialize_template_generator() {
		$this->email_template_generator->init_default_transactional_emails();
	}

	/**
	 * Register the REST API endpoints handled by this controller.
	 */
	public function register_routes() {
		$this->initialize_template_generator();

		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/recreate-email-post',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->recreate_email_post( $request ),
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => $this->get_args_for_recreate_email_post(),
					'schema'              => $this->get_schema_with_message(),
				),
			)
		);
	}

	/**
	 * Get the accepted arguments for the POST recreate-email-post request.
	 *
	 * @return array[]
	 */
	private function get_args_for_recreate_email_post() {
		return array(
			'email_id' => array(
				'description'       => __( 'The email ID to recreate the post for.', 'woocommerce' ),
				'type'              => 'string',
				'required'          => true,
				'validate_callback' => fn( $email_id ) => $this->validate_email_id( $email_id ),
				'sanitize_callback' => 'sanitize_text_field',
			),
		);
	}

	/**
	 * Get the schema for the POST recreate-email-post and save-transient requests.
	 *
	 * @return array[]
	 */
	private function get_schema_with_message() {
		return array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'email-listing-with-message',
			'type'       => 'object',
			'properties' => array(
				'message' => array(
					'description' => __( 'A message indicating that the action completed successfully.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'post_id' => array(
					'description' => __( 'The post ID of the generated email post.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
			),
		);
	}

	/**
	 * Validate the email ID.
	 *
	 * @param string $email_id The email ID to validate.
	 * @return bool|WP_Error True if the email ID is valid, otherwise a WP_Error object.
	 */
	private function validate_email_id( string $email_id ) {
		if ( ! in_array( $email_id, WCTransactionalEmails::get_transactional_emails(), true ) ) {
			return new \WP_Error(
				'woocommerce_rest_not_allowed_email_id',
				sprintf( 'The provided email ID "%s" is not allowed.', $email_id ),
				array( 'status' => 400 ),
			);
		}
		return true;
	}

	/**
	 * Permission check for REST API endpoint.
	 *
	 * @param WP_REST_Request $request The request for which the permission is checked.
	 * @return bool|WP_Error True if the current user has the capability, otherwise a WP_Error object.
	 */
	private function check_permissions( WP_REST_Request $request ) {
		$nonce = $request->get_param( 'nonce' );
		if ( ! wp_verify_nonce( $nonce, self::NONCE_KEY ) ) {
			return new WP_Error(
				'invalid_nonce',
				__( 'Invalid nonce.', 'woocommerce' ),
				array( 'status' => 403 ),
			);
		}
		return $this->check_permission( $request, 'manage_woocommerce' );
	}

	/**
	 * Handle the POST /settings/email/listing/recreate-email-post.
	 *
	 * @param WP_REST_Request $request The received request.
	 * @return array|WP_Error Request response or an error.
	 */
	public function recreate_email_post( WP_REST_Request $request ) {
		$email_id = $request->get_param( 'email_id' );

		$generated_post_id = '';

		try {
			$generated_post_id = $this->email_template_generator->generate_email_template_if_not_exists( $email_id );
		} catch ( \Exception $e ) {
			return new WP_Error(
				'woocommerce_rest_email_post_generation_failed',
				// translators: %s: Error message.
				sprintf( __( 'Error generating email post. Error: %s.', 'woocommerce' ), $e->getMessage() ),
				array( 'status' => 500 )
			);
		}

		if ( $generated_post_id ) {
			return array(
				// translators: %s: WooCommerce transactional email ID.
				'message' => sprintf( __( 'Email post generated for %s.', 'woocommerce' ), $email_id ),
				'post_id' => (string) $generated_post_id,
			);
		}
		return new WP_Error(
			'woocommerce_rest_email_post_generation_error',
			__( 'Error unable to generate email post.', 'woocommerce' ),
			array( 'status' => 500 )
		);
	}
}
PK     [1]Mш"  "    Admin/Homescreen.phpnu         <?php
/**
 * WooCommerce Homescreen.
 */

namespace Automattic\WooCommerce\Internal\Admin;

use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\Shipping;

/**
 * Contains backend logic for the homescreen feature.
 */
class Homescreen {
	/**
	 * Menu slug.
	 */
	const MENU_SLUG = 'wc-admin';

	/**
	 * Class instance.
	 *
	 * @var Homescreen instance
	 */
	protected static $instance = null;

	/**
	 * Get class instance.
	 */
	public static function get_instance() {
		if ( ! self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Hook into WooCommerce.
	 */
	public function __construct() {
		add_filter( 'woocommerce_admin_get_user_data_fields', array( $this, 'add_user_data_fields' ) );
		add_action( 'admin_menu', array( $this, 'register_page' ) );
		// In WC Core 5.1 $submenu manipulation occurs in admin_menu, not admin_head. See https://github.com/woocommerce/woocommerce/pull/29088.
		if ( version_compare( WC_VERSION, '5.1', '>=' ) ) {
			// priority is 20 to run after admin_menu hook for woocommerce runs, so that submenu is populated.
			add_action( 'admin_menu', array( $this, 'possibly_remove_woocommerce_menu' ) );
			add_action( 'admin_menu', array( $this, 'update_link_structure' ), 20 );
		} else {
			// priority is 20 to run after https://github.com/woocommerce/woocommerce/blob/a55ae325306fc2179149ba9b97e66f32f84fdd9c/includes/admin/class-wc-admin-menus.php#L165.
			add_action( 'admin_head', array( $this, 'update_link_structure' ), 20 );
		}

		add_filter( 'woocommerce_admin_preload_options', array( $this, 'preload_options' ) );

		if ( Features::is_enabled( 'shipping-smart-defaults' ) ) {
			add_filter(
				'woocommerce_admin_shared_settings',
				array( $this, 'maybe_set_default_shipping_options_on_home' ),
				9999
			);
		}
	}

	/**
	 * Set free shipping in the same country as the store default
	 * Flag rate in all other countries when any of the following conditions are true
	 *
	 * - The store sells physical products, has JP and WCS installed and connected, and is located in the US.
	 * - The store sells physical products, and is not located in US/Canada/Australia/UK (irrelevant if JP is installed or not).
	 * - The store sells physical products and is located in US, but JP and WCS are not installed.
	 *
	 * @param array $settings shared admin settings.
	 * @return array
	 */
	public function maybe_set_default_shipping_options_on_home( $settings ) {
		if ( ! function_exists( 'get_current_screen' ) ) {
			return $settings;
		}

		$current_screen = get_current_screen();

		// Abort if it's not the homescreen.
		if ( ! isset( $current_screen->id ) || 'woocommerce_page_wc-admin' !== $current_screen->id ) {
			return $settings;
		}

		// Abort if we already created the shipping options.
		$already_created = get_option( 'woocommerce_admin_created_default_shipping_zones' );
		if ( $already_created === 'yes' ) {
			return $settings;
		}

		$zone_count = count( \WC_Data_Store::load( 'shipping-zone' )->get_zones() );
		if ( $zone_count ) {
			update_option( 'woocommerce_admin_created_default_shipping_zones', 'yes' );
			update_option( 'woocommerce_admin_reviewed_default_shipping_zones', 'yes' );
			return $settings;
		}

		$user_skipped_obw           = $settings['onboarding']['profile']['skipped'] ?? false;
		$store_address              = $settings['preloadSettings']['general']['woocommerce_store_address'] ?? '';
		$product_types              = $settings['onboarding']['profile']['product_types'] ?? array();
		$user_has_set_store_country = $settings['onboarding']['profile']['is_store_country_set'] ?? false;

		// Do not proceed if user has not filled out their country in the onboarding profiler.
		if ( ! $user_has_set_store_country ) {
			return $settings;
		}

		// If user skipped the obw or has not completed the store_details
		// then we assume the user is going to sell physical products.
		if ( $user_skipped_obw || '' === $store_address ) {
			$product_types[] = 'physical';
		}

		if ( false === in_array( 'physical', $product_types, true ) ) {
			return $settings;
		}

		$country_code = wc_format_country_state_string( $settings['preloadSettings']['general']['woocommerce_default_country'] )['country'];
		$country_name = WC()->countries->get_countries()[ $country_code ] ?? null;

		$is_jetpack_installed = in_array( 'jetpack', $settings['plugins']['installedPlugins'] ?? array(), true );
		$is_wcs_installed     = in_array( 'woocommerce-services', $settings['plugins']['installedPlugins'] ?? array(), true );

		if (
			( 'US' === $country_code && $is_jetpack_installed )
			||
			( ! in_array( $country_code, array( 'CA', 'AU', 'NZ', 'SG', 'HK', 'GB', 'ES', 'IT', 'DE', 'FR', 'CL', 'AR', 'PE', 'BR', 'UY', 'GT', 'NL', 'AT', 'BE' ), true ) )
			||
			( 'US' === $country_code && false === $is_jetpack_installed && false === $is_wcs_installed )
		) {
			$zone = new \WC_Shipping_Zone();
			$zone->set_zone_name( $country_name );
			$zone->add_location( $country_code, 'country' );

			// Method creation has no default title, use the REST API to add a title.
			$instance_id = $zone->add_shipping_method( 'free_shipping' );
			$request     = new \WP_REST_Request( 'POST', '/wc/v2/shipping/zones/' . $zone->get_id() . '/methods/' . $instance_id );
			$request->set_body_params(
				array(
					'settings' => array(
						'title' => 'Free shipping',
					),
				)
			);
			rest_do_request( $request );

			update_option( 'woocommerce_admin_created_default_shipping_zones', 'yes' );
			Shipping::delete_zone_count_transient();
		}

		return $settings;
	}

	/**
	 * Adds fields so that we can store performance indicators, row settings, and chart type settings for users.
	 *
	 * @param array $user_data_fields User data fields.
	 * @return array
	 */
	public function add_user_data_fields( $user_data_fields ) {
		return array_merge(
			$user_data_fields,
			array(
				'homepage_layout',
				'homepage_stats',
				'task_list_tracked_started_tasks',
			)
		);
	}

	/**
	 * Registers home page.
	 */
	public function register_page() {
		// Register a top-level item for users who cannot view the core WooCommerce menu.
		if ( ! self::is_admin_user() ) {
			wc_admin_register_page(
				array(
					'id'         => 'woocommerce-home',
					'title'      => __( 'WooCommerce', 'woocommerce' ),
					'path'       => self::MENU_SLUG,
					'capability' => 'read',
				)
			);
			return;
		}

		wc_admin_register_page(
			array(
				'id'         => 'woocommerce-home',
				'title'      => __( 'Home', 'woocommerce' ),
				'parent'     => 'woocommerce',
				'path'       => self::MENU_SLUG,
				'order'      => 0,
				'capability' => 'read',
			)
		);
	}

	/**
	 * Check if the user can access the top-level WooCommerce item.
	 *
	 * @return bool
	 */
	public static function is_admin_user() {
		if ( ! class_exists( 'WC_Admin_Menus', false ) ) {
			include_once WC_ABSPATH . 'includes/admin/class-wc-admin-menus.php';
		}
		if ( method_exists( 'WC_Admin_Menus', 'can_view_woocommerce_menu_item' ) ) {
			return \WC_Admin_Menus::can_view_woocommerce_menu_item() || current_user_can( 'manage_woocommerce' );
		} else {
			// We leave this line for WC versions <= 6.2.
			return current_user_can( 'edit_others_shop_orders' ) || current_user_can( 'manage_woocommerce' );
		}
	}

	/**
	 * Possibly remove the WooCommerce menu item if it was purely used to access wc-admin pages.
	 */
	public function possibly_remove_woocommerce_menu() {
		global $menu;

		if ( self::is_admin_user() ) {
			return;
		}

		foreach ( $menu as $key => $menu_item ) {
			if ( self::MENU_SLUG !== $menu_item[2] || 'read' !== $menu_item[1] ) {
				continue;
			}

			unset( $menu[ $key ] );
		}
	}

	/**
	 * Update the WooCommerce menu structure to make our main dashboard/handler
	 * the top level link for 'WooCommerce'.
	 */
	public function update_link_structure() {
		global $submenu;
		// User does not have capabilities to see the submenu.
		if ( ! current_user_can( 'manage_woocommerce' ) || empty( $submenu['woocommerce'] ) ) {
			return;
		}

		$wc_admin_key = null;
		foreach ( $submenu['woocommerce'] as $submenu_key => $submenu_item ) {
			if ( self::MENU_SLUG === $submenu_item[2] ) {
				$wc_admin_key = $submenu_key;
				break;
			}
		}

		if ( ! $wc_admin_key ) {
			return;
		}

		$menu = $submenu['woocommerce'][ $wc_admin_key ];

		// Move menu item to top of array.
		unset( $submenu['woocommerce'][ $wc_admin_key ] );
		array_unshift( $submenu['woocommerce'], $menu );
	}

	/**
	 * Preload options to prime state of the application.
	 *
	 * @param array $options Array of options to preload.
	 * @return array
	 */
	public function preload_options( $options ) {
		$options[] = 'woocommerce_default_homepage_layout';
		$options[] = 'woocommerce_admin_install_timestamp';

		return $options;
	}
}
PK     [1]    &  Admin/Onboarding/OnboardingProfile.phpnu         <?php
/**
 * WooCommerce Onboarding Setup Wizard
 */

namespace Automattic\WooCommerce\Internal\Admin\Onboarding;

use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\WCAdminHelper;

/**
 * Contains backend logic for the onboarding profile and checklist feature.
 */
class OnboardingProfile {
	/**
	 * Profile data option name.
	 */
	const DATA_OPTION = 'woocommerce_onboarding_profile';

	/**
	 * Option for storing the onboarding profile progress.
	 */
	const PROGRESS_OPTION = 'woocommerce_onboarding_profile_progress';

	/**
	 * Add onboarding actions.
	 */
	public static function init() {
		add_action( 'update_option_' . self::DATA_OPTION, array( __CLASS__, 'trigger_complete' ), 10, 2 );
	}

	/**
	 * Trigger the woocommerce_onboarding_profile_completed action
	 *
	 * @param array $old_value Previous value.
	 * @param array $value Current value.
	 */
	public static function trigger_complete( $old_value, $value ) {
		if ( isset( $old_value['completed'] ) && $old_value['completed'] ) {
			return;
		}

		if ( ! isset( $value['completed'] ) || ! $value['completed'] ) {
			return;
		}

		/**
		 * Action hook fired when the onboarding profile (or onboarding wizard,
		 * or profiler) is completed.
		 *
		 * @since 1.5.0
		 */
		do_action( 'woocommerce_onboarding_profile_completed' );
	}

	/**
	 * Check if the profiler still needs to be completed.
	 *
	 * @return bool
	 */
	public static function needs_completion() {
		$onboarding_data = get_option( self::DATA_OPTION, array() );

		$is_completed = isset( $onboarding_data['completed'] ) && true === $onboarding_data['completed'];
		$is_skipped   = isset( $onboarding_data['skipped'] ) && true === $onboarding_data['skipped'];

		// @todo When merging to WooCommerce Core, we should set the `completed` flag to true during the upgrade progress.
		// https://github.com/woocommerce/woocommerce-admin/pull/2300#discussion_r287237498.
		return ! $is_completed && ! $is_skipped;
	}
}
PK     [1]    #  Admin/Onboarding/OnboardingSync.phpnu         <?php
/**
 * WooCommerce Onboarding
 */

namespace Automattic\WooCommerce\Internal\Admin\Onboarding;

use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;

/**
 * Contains backend logic for the onboarding profile and checklist feature.
 */
class OnboardingSync {
	/**
	 * Class instance.
	 *
	 * @var OnboardingSync instance
	 */
	private static $instance = null;

	/**
	 * Get class instance.
	 */
	final public static function instance() {
		if ( ! static::$instance ) {
			static::$instance = new static();
		}
		return static::$instance;
	}

	/**
	 * Init.
	 */
	public function init() {
		add_action( 'update_option_' . OnboardingProfile::DATA_OPTION, array( $this, 'send_profile_data_on_update' ), 10, 2 );
		add_action( 'woocommerce_helper_connected', array( $this, 'send_profile_data_on_connect' ) );

		if ( ! is_admin() ) {
			return;
		}

		add_action( 'current_screen', array( $this, 'redirect_wccom_install' ) );
	}

	/**
	 * Send profile data to WooCommerce.com.
	 */
	private function send_profile_data() {
		if ( 'yes' !== get_option( 'woocommerce_allow_tracking', 'no' ) ) {
			return;
		}

		if ( ! class_exists( '\WC_Helper_API' ) || ! method_exists( '\WC_Helper_API', 'put' ) ) {
			return;
		}

		if ( ! class_exists( '\WC_Helper_Options' ) ) {
			return;
		}

		$auth = \WC_Helper_Options::get( 'auth' );
		if ( empty( $auth['access_token'] ) || empty( $auth['access_token_secret'] ) ) {
			return false;
		}

		$profile       = get_option( OnboardingProfile::DATA_OPTION, array() );
		$base_location = wc_get_base_location();
		$defaults      = array(
			'plugins'             => 'skipped',
			'industry'            => array(),
			'product_types'       => array(),
			'product_count'       => '0',
			'selling_venues'      => 'no',
			'number_employees'    => '1',
			'revenue'             => 'none',
			'other_platform'      => 'none',
			'business_extensions' => array(),
			'theme'               => get_stylesheet(),
			'setup_client'        => false,
			'store_location'      => $base_location['country'],
			'default_currency'    => get_woocommerce_currency(),
		);

		// Prepare industries as an array of slugs if they are in array format.
		if ( isset( $profile['industry'] ) && is_array( $profile['industry'] ) ) {
			$industry_slugs = array();
			foreach ( $profile['industry'] as $industry ) {
				$industry_slugs[] = is_array( $industry ) ? $industry['slug'] : $industry;
			}
			$profile['industry'] = $industry_slugs;
		}
		$body = wp_parse_args( $profile, $defaults );

		\WC_Helper_API::put(
			'profile',
			array(
				'authenticated' => true,
				'body'          => wp_json_encode( $body ),
				'headers'       => array(
					'Content-Type' => 'application/json',
				),
			)
		);
	}

	/**
	 * Send profiler data on profiler change to completion.
	 *
	 * @param array $old_value Previous value.
	 * @param array $value Current value.
	 */
	public function send_profile_data_on_update( $old_value, $value ) {
		if ( ! isset( $value['completed'] ) || ! $value['completed'] ) {
			return;
		}

		$this->send_profile_data();
	}

	/**
	 * Send profiler data after a site is connected.
	 */
	public function send_profile_data_on_connect() {
		$profile = get_option( OnboardingProfile::DATA_OPTION, array() );
		if ( ! isset( $profile['completed'] ) || ! $profile['completed'] ) {
			return;
		}

		$this->send_profile_data();
	}

	/**
	 * Redirects the user to the task list if the task list is enabled and finishing a wccom checkout.
	 *
	 * @todo Once URL params are added to the redirect, we can check those instead of the referer.
	 */
	public function redirect_wccom_install() {
		$task_list = TaskLists::get_list( 'setup' );

		if (
			! $task_list ||
			$task_list->is_hidden() ||
			! isset( $_SERVER['HTTP_REFERER'] ) ||
			0 !== strpos( wp_unslash( $_SERVER['HTTP_REFERER'] ), 'https://woocommerce.com/checkout?utm_medium=product' ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		) {
			return;
		}

		wp_safe_redirect( wc_admin_url() );
	}
}
PK     [1]\  \  '  Admin/Onboarding/OnboardingProducts.phpnu         <?php
/**
 * WooCommerce Onboarding Products
 */

namespace Automattic\WooCommerce\Internal\Admin\Onboarding;

use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\Loader;
use Automattic\WooCommerce\Admin\PluginsHelper;

/**
 * Class for handling product types and data around product types.
 */
class OnboardingProducts {

	/**
	 * Name of product data transient.
	 *
	 * @var string
	 */
	const PRODUCT_DATA_TRANSIENT = 'wc_onboarding_product_data';

	/**
	 * Get a list of allowed product types for the onboarding wizard.
	 *
	 * @return array
	 */
	public static function get_allowed_product_types() {
		$products         = array(
			'physical'        => array(
				'label'   => __( 'Physical products', 'woocommerce' ),
				'default' => true,
			),
			'downloads'       => array(
				'label' => __( 'Downloads', 'woocommerce' ),
			),
			'subscriptions'   => array(
				'label' => __( 'Subscriptions', 'woocommerce' ),
			),
			'memberships'     => array(
				'label'   => __( 'Memberships', 'woocommerce' ),
				'product' => 958589,
			),
			'bookings'        => array(
				'label'   => __( 'Bookings', 'woocommerce' ),
				'product' => 390890,
			),
			'product-bundles' => array(
				'label'   => __( 'Bundles', 'woocommerce' ),
				'product' => 18716,
			),
			'product-add-ons' => array(
				'label'   => __( 'Customizable products', 'woocommerce' ),
				'product' => 18618,
			),
		);
		$base_location    = wc_get_base_location();
		$has_cbd_industry = false;
		if ( 'US' === $base_location['country'] ) {
			$profile = get_option( OnboardingProfile::DATA_OPTION, array() );
			if ( ! empty( $profile['industry'] ) ) {
				$has_cbd_industry = in_array( 'cbd-other-hemp-derived-products', array_column( $profile['industry'], 'slug' ), true );
			}
		}
		if ( ! Features::is_enabled( 'subscriptions' ) || 'US' !== $base_location['country'] || $has_cbd_industry ) {
			$products['subscriptions']['product'] = 27147;
		}

		return apply_filters( 'woocommerce_admin_onboarding_product_types', $products );
	}

	/**
	 * Get dynamic product data from API.
	 *
	 * @param array $product_types Array of product types.
	 * @return array
	 */
	public static function get_product_data( $product_types ) {
		$locale = get_user_locale();
		// Transient value is an array of product data keyed by locale.
		$transient_value      = get_transient( self::PRODUCT_DATA_TRANSIENT );
		$transient_value      = is_array( $transient_value ) ? $transient_value : array();
		$woocommerce_products = $transient_value[ $locale ] ?? false;

		if ( false === $woocommerce_products ) {
			$woocommerce_products = wp_remote_get(
				add_query_arg(
					array(
						'locale' => $locale,
					),
					'https://woocommerce.com/wp-json/wccom-extensions/1.0/search'
				),
				array(
					'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ),
				)
			);
			if ( is_wp_error( $woocommerce_products ) ) {
				return $product_types;
			}
			$transient_value[ $locale ] = $woocommerce_products;
			set_transient( self::PRODUCT_DATA_TRANSIENT, $transient_value, DAY_IN_SECONDS );
		}

		$data         = json_decode( $woocommerce_products['body'] );
		$products     = array();
		$product_data = array();

		// Map product data by ID.
		if ( isset( $data ) && isset( $data->products ) ) {
			foreach ( $data->products as $product_datum ) {
				if ( isset( $product_datum->id ) ) {
					$products[ $product_datum->id ] = $product_datum;
				}
			}
		}

		// Loop over product types and append data.
		foreach ( $product_types as $key => $product_type ) {
			$product_data[ $key ] = $product_types[ $key ];

			if ( isset( $product_type['product'] ) && isset( $products[ $product_type['product'] ] ) ) {
				$price        = html_entity_decode( $products[ $product_type['product'] ]->price );
				$yearly_price = (float) str_replace( '$', '', $price );

				$product_data[ $key ]['yearly_price'] = $yearly_price;
				$product_data[ $key ]['description']  = $products[ $product_type['product'] ]->excerpt;
				$product_data[ $key ]['more_url']     = $products[ $product_type['product'] ]->link;
				$product_data[ $key ]['slug']         = strtolower( preg_replace( '~[^\pL\d]+~u', '-', $products[ $product_type['product'] ]->slug ) );
			}
		}

		return $product_data;
	}

	/**
	 * Get the allowed product types with the polled data.
	 *
	 * @return array
	 */
	public static function get_product_types_with_data() {
		return self::get_product_data( self::get_allowed_product_types() );
	}

	/**
	 * Get relevant purchaseable products for the site.
	 *
	 * @return array
	 */
	public static function get_relevant_products() {
		$profiler_data = get_option( OnboardingProfile::DATA_OPTION, array() );
		$installed     = PluginsHelper::get_installed_plugin_slugs();
		$product_types = isset( $profiler_data['product_types'] ) ? $profiler_data['product_types'] : array();
		$product_data  = self::get_product_types_with_data();
		$purchaseable  = array();
		$remaining     = array();
		foreach ( $product_types as $type ) {
			if ( ! isset( $product_data[ $type ]['slug'] ) ) {
				continue;
			}

			$purchaseable[] = $product_data[ $type ];

			if ( ! in_array( $product_data[ $type ]['slug'], $installed, true ) ) {
				$remaining[] = $product_data[ $type ]['label'];
			}
		}

		return array(
			'purchaseable' => $purchaseable,
			'remaining'    => $remaining,
		);
	}
}
PK     [1]hȣ*  *  *  Admin/Onboarding/OnboardingSetupWizard.phpnu         <?php
/**
 * WooCommerce Onboarding Setup Wizard
 */

namespace Automattic\WooCommerce\Internal\Admin\Onboarding;

use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\WCAdminHelper;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
use Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions\Init;
use Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions\ProcessCoreProfilerPluginInstallOptions;

/**
 * Contains backend logic for the onboarding profile and checklist feature.
 */
class OnboardingSetupWizard {
	/**
	 * Class instance.
	 *
	 * @var OnboardingSetupWizard instance
	 */
	private static $instance = null;

	/**
	 * Get class instance.
	 */
	final public static function instance() {
		if ( ! static::$instance ) {
			static::$instance = new static();
		}
		return static::$instance;
	}

	/**
	 * Add onboarding actions.
	 */
	public function init() {
		// should be placed before is_admin() check as this hook is triggered in AJAX calls.
		add_action(
			'woocommerce_plugins_install_before',
			function ( $slug, $source ) {
				$this->install_options_for_core_profiler_plugin_install( $slug, $source );
			},
			10,
			2
		);

		if ( ! is_admin() ) {
			return;
		}

		// Old settings injection.
		// Run after Automattic\WooCommerce\Internal\Admin\Loader.
		add_filter( 'woocommerce_components_settings', array( $this, 'component_settings' ), 20 );
		// New settings injection.
		add_filter( 'woocommerce_admin_shared_settings', array( $this, 'component_settings' ), 20 );
		add_filter( 'woocommerce_admin_preload_settings', array( $this, 'preload_settings' ) );
		add_filter( 'admin_body_class', array( $this, 'add_loading_classes' ) );
		add_action( 'admin_init', array( $this, 'do_admin_redirects' ) );
		add_action( 'current_screen', array( $this, 'redirect_to_profiler' ) );
		add_filter( 'woocommerce_show_admin_notice', array( $this, 'remove_old_install_notice' ), 10, 2 );
		add_filter( 'admin_viewport_meta', array( $this, 'set_viewport_meta_tag' ) );
	}

	/**
	 * Test whether the context of execution comes from async action scheduler.
	 * Note: this is a polyfill for wc_is_running_from_async_action_scheduler()
	 *       which was introduced in WC 4.0.
	 *
	 * @return bool
	 */
	private function is_running_from_async_action_scheduler() {
		if ( function_exists( '\wc_is_running_from_async_action_scheduler' ) ) {
			return \wc_is_running_from_async_action_scheduler();
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		return isset( $_REQUEST['action'] ) && 'as_async_request_queue_runner' === $_REQUEST['action'];
	}

	/**
	 * Handle redirects to setup/welcome page after install and updates.
	 *
	 * For setup wizard, transient must be present, the user must have access rights, and we must ignore the network/bulk plugin updaters.
	 */
	public function do_admin_redirects() {
		// Don't run this fn from Action Scheduler requests, as it would clear _wc_activation_redirect transient.
		// That means OBW would never be shown.
		if ( $this->is_running_from_async_action_scheduler() ) {
			return;
		}

		// Setup wizard redirect.
		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
		if ( get_transient( '_wc_activation_redirect' ) && apply_filters( 'woocommerce_enable_setup_wizard', true ) ) {
			$do_redirect        = true;
			$current_page       = isset( $_GET['page'] ) ? wc_clean( wp_unslash( $_GET['page'] ) ) : false; // phpcs:ignore WordPress.Security.NonceVerification
			$is_onboarding_path = ! isset( $_GET['path'] ) || '/setup-wizard' === wc_clean( wp_unslash( $_GET['page'] ) ); // phpcs:ignore WordPress.Security.NonceVerification

			// On these pages, or during these events, postpone the redirect.
			// phpcs:ignore WordPress.WP.Capabilities.Unknown
			if ( wp_doing_ajax() || is_network_admin() || ! current_user_can( 'manage_woocommerce' ) ) {
				$do_redirect = false;
			}

			// On these pages, or during these events, disable the redirect.
			if (
				( 'wc-admin' === $current_page && $is_onboarding_path ) ||
				// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
				apply_filters( 'woocommerce_prevent_automatic_wizard_redirect', false ) ||
				isset( $_GET['activate-multi'] ) // phpcs:ignore WordPress.Security.NonceVerification
			) {
				delete_transient( '_wc_activation_redirect' );
				$do_redirect = false;
			}

			if ( $do_redirect ) {
				delete_transient( '_wc_activation_redirect' );
				wp_safe_redirect( wc_admin_url() );
				exit;
			}
		}
	}

	/**
	 * Trigger the woocommerce_onboarding_profile_completed action
	 *
	 * @param array $old_value Previous value.
	 * @param array $value Current value.
	 */
	public function trigger_profile_completed_action( $old_value, $value ) {
		if ( isset( $old_value['completed'] ) && $old_value['completed'] ) {
			return;
		}

		if ( ! isset( $value['completed'] ) || ! $value['completed'] ) {
			return;
		}

		/**
		 * Action hook fired when the onboarding profile (or onboarding wizard,
		 * or profiler) is completed.
		 *
		 * @since 1.5.0
		 */
		do_action( 'woocommerce_onboarding_profile_completed' );
	}

	/**
	 * Returns true if the profiler should be displayed (not completed and not skipped).
	 *
	 * @return bool
	 */
	private function should_show() {
		if ( $this->is_setup_wizard() ) {
			return true;
		}

		return OnboardingProfile::needs_completion();
	}

	/**
	 * Redirect to the profiler on homepage if completion is needed.
	 */
	public function redirect_to_profiler() {
		if ( ! $this->is_homepage() || ! OnboardingProfile::needs_completion() ) {
			return;
		}

		wp_safe_redirect( wc_admin_url( '&path=/setup-wizard' ) );
		exit;
	}

	/**
	 * Check if the current page is the profile wizard.
	 *
	 * @return bool
	 */
	private function is_setup_wizard() {
		/* phpcs:disable WordPress.Security.NonceVerification */
		return isset( $_GET['page'] ) &&
			'wc-admin' === $_GET['page'] &&
			isset( $_GET['path'] ) &&
			'/setup-wizard' === $_GET['path'];
		/* phpcs: enable */
	}

	/**
	 * Check if the current page is the homepage.
	 *
	 * @return bool
	 */
	private function is_homepage() {
		/* phpcs:disable WordPress.Security.NonceVerification */
		return isset( $_GET['page'] ) &&
			'wc-admin' === $_GET['page'] &&
			! isset( $_GET['path'] );
		/* phpcs: enable */
	}

	/**
	 * Determine if the current page is one of the WC Admin pages.
	 *
	 * @return bool
	 */
	private function is_woocommerce_page() {
		$current_page = PageController::get_instance()->get_current_page();
		if ( ! $current_page || ! isset( $current_page['path'] ) ) {
			return false;
		}

		return 0 === strpos( $current_page['path'], 'wc-admin' );
	}

	/**
	 * Add profiler items to component settings.
	 *
	 * @param array $settings Component settings.
	 *
	 * @return array
	 */
	public function component_settings( $settings ) {
		$profile                = (array) get_option( OnboardingProfile::DATA_OPTION, array() );
		$settings['onboarding'] = array(
			'profile' => $profile,
		);

		// Only fetch if the onboarding wizard OR the task list is incomplete or currently shown
		// or the current page is one of the WooCommerce Admin pages.
		if (
			( ! $this->should_show() && ! count( TaskLists::get_visible() )
		    // phpcs:ignore Generic.CodeAnalysis.RequireExplicitBooleanOperatorPrecedence.MissingParentheses
			||
			! $this->is_woocommerce_page()
		)
		) {
			return $settings;
		}

		include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-options.php';
		$wccom_auth                 = \WC_Helper_Options::get( 'auth' );
		$profile['wccom_connected'] = empty( $wccom_auth['access_token'] ) ? false : true;

		$settings['onboarding']['currencySymbols'] = get_woocommerce_currency_symbols();
		$settings['onboarding']['euCountries']     = WC()->countries->get_european_union_countries();
		$settings['onboarding']['localeInfo']      = include WC()->plugin_path() . '/i18n/locale-info.php';
		$settings['onboarding']['profile']         = $profile;

		if ( $this->is_setup_wizard() ) {
			$settings['onboarding']['pageCount']    = (int) ( wp_count_posts( 'page' ) )->publish;
			$settings['onboarding']['postCount']    = (int) ( wp_count_posts( 'post' ) )->publish;
			$settings['onboarding']['isBlockTheme'] = wp_is_block_theme();
		}

		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
		return apply_filters( 'woocommerce_admin_onboarding_preloaded_data', $settings );
	}

	/**
	 * Preload WC setting options to prime state of the application.
	 *
	 * @param array $options Array of options to preload.
	 * @return array
	 */
	public function preload_settings( $options ) {
		$options[] = 'general';

		return $options;
	}

	/**
	 * Set the admin full screen class when loading to prevent flashes of unstyled content.
	 *
	 * @param bool $classes Body classes.
	 * @return array
	 */
	public function add_loading_classes( $classes ) {
		/* phpcs:disable WordPress.Security.NonceVerification */
		if ( $this->is_setup_wizard() ) {
			$classes .= ' woocommerce-admin-full-screen';
		}
		/* phpcs: enable */

		return $classes;
	}

	/**
	 * Remove the install notice that prompts the user to visit the old onboarding setup wizard.
	 *
	 * @param bool   $show Show or hide the notice.
	 * @param string $notice The slug of the notice.
	 * @return bool
	 */
	public function remove_old_install_notice( $show, $notice ) {
		if ( 'install' === $notice ) {
			return false;
		}

		return $show;
	}

	/**
	 * Set the viewport meta tag for the setup wizard.
	 *
	 * @param string $viewport_meta Viewport meta content value.
	 * @return string Viewport meta content value.
	 *
	 * @since 9.0.0
	 */
	public function set_viewport_meta_tag( $viewport_meta ) {
		if ( ! $this->is_setup_wizard() ) {
			return $viewport_meta;
		}

		return 'width=device-width, initial-scale=1.0, maximum-scale=1.0';
	}

	/**
	 * Install options for core profiler plugin install.
	 *
	 * When a plugin is installed from the core profiler, this method is called to process the install options.
	 *
	 * Install options are a list of options that are set for the plugin being installed.
	 *
	 * @param string $slug Plugin slug.
	 * @param string $source Source of the plugin install.
	 *
	 * @return void|null
	 */
	public function install_options_for_core_profiler_plugin_install( $slug, $source ) {
		// Only proceed if the plugin install was initiated from the core profiler.
		if ( 'core-profiler' !== $source ) {
			return;
		}

		// Retrieve the core profiler spec.
		$specs = array_filter( Init::get_specs(), fn( $spec ) => 'obw/core-profiler' === $spec->key );

		if ( ! $specs ) {
			return null;
		}

		$install_options = new ProcessCoreProfilerPluginInstallOptions( current( $specs )->plugins, $slug, wc_get_logger() );
		$install_options->process_install_options();
	}
}
PK     [1]O    )  Admin/Onboarding/OnboardingIndustries.phpnu         <?php
/**
 * WooCommerce Onboarding Industries
 */

namespace Automattic\WooCommerce\Internal\Admin\Onboarding;

/**
 * Logic around onboarding industries.
 */
class OnboardingIndustries {
	/**
	 * Init.
	 */
	public static function init() {
		add_filter( 'woocommerce_admin_onboarding_preloaded_data', array( __CLASS__, 'preload_data' ) );
	}

	/**
	 * Get a list of allowed industries for the onboarding wizard.
	 *
	 * @return array
	 */
	public static function get_allowed_industries() {
		/* With "use_description" we turn the description input on. With "description_label" we set the input label */
		return apply_filters(
			'woocommerce_admin_onboarding_industries',
			array(
				'fashion-apparel-accessories'     => array(
					'label'             => __( 'Fashion, apparel, and accessories', 'woocommerce' ),
					'use_description'   => false,
					'description_label' => '',
				),
				'health-beauty'                   => array(
					'label'             => __( 'Health and beauty', 'woocommerce' ),
					'use_description'   => false,
					'description_label' => '',
				),
				'electronics-computers'           => array(
					'label'             => __( 'Electronics and computers', 'woocommerce' ),
					'use_description'   => false,
					'description_label' => '',
				),
				'food-drink'                      => array(
					'label'             => __( 'Food and drink', 'woocommerce' ),
					'use_description'   => false,
					'description_label' => '',
				),
				'home-furniture-garden'           => array(
					'label'             => __( 'Home, furniture, and garden', 'woocommerce' ),
					'use_description'   => false,
					'description_label' => '',
				),
				'cbd-other-hemp-derived-products' => array(
					'label'             => __( 'CBD and other hemp-derived products', 'woocommerce' ),
					'use_description'   => false,
					'description_label' => '',
				),
				'education-and-learning'          => array(
					'label'             => __( 'Education and learning', 'woocommerce' ),
					'use_description'   => false,
					'description_label' => '',
				),
				'sports-and-recreation'           => array(
					'label'             => __( 'Sports and recreation', 'woocommerce' ),
					'use_description'   => false,
					'description_label' => '',
				),
				'arts-and-crafts'                 => array(
					'label'             => __( 'Arts and crafts', 'woocommerce' ),
					'use_description'   => false,
					'description_label' => '',
				),
				'other'                           => array(
					'label'             => __( 'Other', 'woocommerce' ),
					'use_description'   => true,
					'description_label' => __( 'Description', 'woocommerce' ),
				),
			)
		);
	}

	/**
	 * Add preloaded data to onboarding.
	 *
	 * @param array $settings Component settings.
	 * @return array
	 */
	public static function preload_data( $settings ) {
		$settings['onboarding']['industries'] = self::get_allowed_industries();
		return $settings;
	}
}
PK     [1]	t  t  %  Admin/Onboarding/OnboardingHelper.phpnu         <?php
/**
 * WooCommerce Onboarding Helper
 */

namespace Automattic\WooCommerce\Internal\Admin\Onboarding;

use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;

/**
 * Contains backend logic for the onboarding profile and checklist feature.
 */
class OnboardingHelper {

	/**
	 * Class instance.
	 *
	 * @var OnboardingHelper instance
	 */
	private static $instance = null;

	/**
	 * Get class instance.
	 */
	final public static function instance() {
		if ( ! static::$instance ) {
			static::$instance = new static();
		}
		return static::$instance;
	}

	/**
	 * Init.
	 */
	public function init() {
		if ( ! is_admin() ) {
			return;
		}

		add_action( 'current_screen', array( $this, 'add_help_tab' ), 60 );
		add_action( 'current_screen', array( $this, 'reset_task_list' ) );
		add_action( 'current_screen', array( $this, 'reset_extended_task_list' ) );
	}

	/**
	 * Update the help tab setup link to reset the onboarding profiler.
	 */
	public function add_help_tab() {
		if ( ! function_exists( 'wc_get_screen_ids' ) ) {
			return;
		}

		$screen = get_current_screen();

		if ( ! $screen || ! in_array( $screen->id, wc_get_screen_ids(), true ) ) {
			return;
		}

		// Remove the old help tab if it exists.
		$help_tabs = $screen->get_help_tabs();
		foreach ( $help_tabs as $help_tab ) {
			if ( 'woocommerce_onboard_tab' !== $help_tab['id'] ) {
				continue;
			}

			$screen->remove_help_tab( 'woocommerce_onboard_tab' );
		}

		// Add the new help tab.
		$help_tab = array(
			'title' => __( 'Setup wizard', 'woocommerce' ),
			'id'    => 'woocommerce_onboard_tab',
		);

		$setup_list    = TaskLists::get_list( 'setup' );
		$extended_list = TaskLists::get_list( 'extended' );

		if ( $setup_list ) {
			$help_tab['content'] = '<h2>' . __( 'WooCommerce Onboarding', 'woocommerce' ) . '</h2>';

			$help_tab['content'] .= '<h3>' . __( 'Profile Setup Wizard', 'woocommerce' ) . '</h3>';
			$help_tab['content'] .= '<p>' . __( 'If you need to access the setup wizard again, please click on the button below.', 'woocommerce' ) . '</p>' .
				'<p><a href="' . wc_admin_url( '&path=/setup-wizard' ) . '" class="button button-primary">' . __( 'Setup wizard', 'woocommerce' ) . '</a></p>';

			if ( ! $setup_list->is_complete() ) {
				$help_tab['content'] .= '<h3>' . __( 'Task List', 'woocommerce' ) . '</h3>';
				$help_tab['content'] .= '<p>' . __( 'If you need to enable or disable the task lists, please click on the button below.', 'woocommerce' ) . '</p>' .
				( $setup_list->is_hidden()
				? '<p><a href="' . wc_admin_url( '&reset_task_list=1' ) . '" class="button button-primary">' . __( 'Enable', 'woocommerce' ) . '</a></p>'
				: '<p><a href="' . wc_admin_url( '&reset_task_list=0' ) . '" class="button button-primary">' . __( 'Disable', 'woocommerce' ) . '</a></p>'
				);
			}
		}

		if ( $extended_list ) {
			$help_tab['content'] .= '<h3>' . __( 'Extended task List', 'woocommerce' ) . '</h3>';
			$help_tab['content'] .= '<p>' . __( 'If you need to enable or disable the extended task lists, please click on the button below.', 'woocommerce' ) . '</p>' .
			( $extended_list->is_hidden()
				? '<p><a href="' . wc_admin_url( '&reset_extended_task_list=1' ) . '" class="button button-primary">' . __( 'Enable', 'woocommerce' ) . '</a></p>'
				: '<p><a href="' . wc_admin_url( '&reset_extended_task_list=0' ) . '" class="button button-primary">' . __( 'Disable', 'woocommerce' ) . '</a></p>'
			);
		}

		$screen->add_help_tab( $help_tab );
	}

	/**
	 * Reset the onboarding task list and redirect to the dashboard.
	 */
	public function reset_task_list() {
		if (
			! PageController::is_admin_page() ||
			! isset( $_GET['reset_task_list'] ) // phpcs:ignore CSRF ok.
		) {
			return;
		}

		$task_list = TaskLists::get_list( 'setup' );

		if ( ! $task_list ) {
			return;
		}
		$show   = 1 === absint( $_GET['reset_task_list'] ); // phpcs:ignore CSRF ok.
		$update = $show ? $task_list->unhide() : $task_list->hide(); // phpcs:ignore CSRF ok.

		if ( $update ) {
			wc_admin_record_tracks_event(
				'tasklist_toggled',
				array(
					'status' => $show ? 'enabled' : 'disabled',
				)
			);
		}

		wp_safe_redirect( wc_admin_url() );
		exit;
	}

	/**
	 * Reset the extended task list and redirect to the dashboard.
	 */
	public function reset_extended_task_list() {
		if (
			! PageController::is_admin_page() ||
			! isset( $_GET['reset_extended_task_list'] ) // phpcs:ignore CSRF ok.
		) {
			return;
		}

		$task_list = TaskLists::get_list( 'extended' );

		if ( ! $task_list ) {
			return;
		}
		$show   = 1 === absint( $_GET['reset_extended_task_list'] ); // phpcs:ignore CSRF ok.
		$update = $show ? $task_list->unhide() : $task_list->hide(); // phpcs:ignore CSRF ok.

		if ( $update ) {
			wc_admin_record_tracks_event(
				'extended_tasklist_toggled',
				array(
					'status' => $show ? 'disabled' : 'enabled',
				)
			);
		}

		wp_safe_redirect( wc_admin_url() );
		exit;
	}
}
PK     [1]i@&N    (  Admin/Onboarding/OnboardingMailchimp.phpnu         <?php
/**
 * WooCommerce Onboarding Mailchimp
 */

namespace Automattic\WooCommerce\Internal\Admin\Onboarding;

use Automattic\WooCommerce\Internal\Admin\Schedulers\MailchimpScheduler;

/**
 * Logic around updating Mailchimp during onboarding.
 */
class OnboardingMailchimp {
	/**
	 * Class instance.
	 *
	 * @var OnboardingMailchimp instance
	 */
	private static $instance = null;

	/**
	 * Get class instance.
	 */
	final public static function instance() {
		if ( ! static::$instance ) {
			static::$instance = new static();
		}
		return static::$instance;
	}

	/**
	 * Init.
	 */
	public function init() {
		add_action( 'woocommerce_onboarding_profile_data_updated', array( $this, 'on_profile_data_updated' ), 10, 2 );
	}

	/**
	 * Reset MailchimpScheduler if profile data is being updated with a new email.
	 *
	 * @param array $existing_data Existing option data.
	 * @param array $updating_data Updating option data.
	 */
	public function on_profile_data_updated( $existing_data, $updating_data ) {
		if (
			isset( $existing_data['store_email'] ) &&
			isset( $updating_data['store_email'] ) &&
			$existing_data['store_email'] !== $updating_data['store_email']
		) {
			MailchimpScheduler::reset();
		}
	}
}
PK     [1]2)\b  b    Admin/Onboarding/Onboarding.phpnu         <?php
/**
 * WooCommerce Onboarding
 */

namespace Automattic\WooCommerce\Internal\Admin\Onboarding;

/**
 * Initializes backend logic for the onboarding process.
 */
class Onboarding {
	/**
	 * Initialize onboarding functionality.
	 *
	 * @internal This method is for internal purposes only.
	 */
	final public static function init() {
		OnboardingHelper::instance()->init();
		OnboardingIndustries::init();
		OnboardingJetpack::instance()->init();
		OnboardingMailchimp::instance()->init();
		OnboardingProfile::init();
		OnboardingSetupWizard::instance()->init();
		OnboardingSync::instance()->init();
	}
}
PK     [1]4  4  &  Admin/Onboarding/OnboardingJetpack.phpnu         <?php
/**
 * WooCommerce Onboarding Jetpack
 */

namespace Automattic\WooCommerce\Internal\Admin\Onboarding;

/**
 * Contains logic around Jetpack setup during onboarding.
 */
class OnboardingJetpack {
	/**
	 * Class instance.
	 *
	 * @var OnboardingJetpack instance
	 */
	private static $instance = null;

	/**
	 * Get class instance.
	 */
	final public static function instance() {
		if ( ! static::$instance ) {
			static::$instance = new static();
		}
		return static::$instance;
	}

	/**
	 * Init.
	 */
	public function init() {
		add_action( 'woocommerce_admin_plugins_pre_activate', array( $this, 'activate_and_install_jetpack_ahead_of_wcpay' ) );
		add_action( 'woocommerce_admin_plugins_pre_install', array( $this, 'activate_and_install_jetpack_ahead_of_wcpay' ) );

		// Always hook into Jetpack connection even if outside of admin.
		add_action( 'jetpack_site_registered', array( $this, 'set_woocommerce_setup_jetpack_opted_in' ) );
	}

	/**
	 * Sets the woocommerce_setup_jetpack_opted_in to true when Jetpack connects to WPCOM.
	 */
	public function set_woocommerce_setup_jetpack_opted_in() {
		update_option( 'woocommerce_setup_jetpack_opted_in', true );
	}

	/**
	 * Ensure that Jetpack gets installed and activated ahead of WooCommerce Payments
	 * if both are being installed/activated at the same time.
	 *
	 * See: https://github.com/Automattic/woocommerce-payments/issues/1663
	 * See: https://github.com/Automattic/jetpack/issues/19624
	 *
	 * @param array $plugins A list of plugins to install or activate.
	 *
	 * @return array
	 */
	public function activate_and_install_jetpack_ahead_of_wcpay( $plugins ) {
		if ( in_array( 'jetpack', $plugins, true ) && in_array( 'woocommerce-payments', $plugins, true ) ) {
			array_unshift( $plugins, 'jetpack' );
			$plugins = array_unique( $plugins );
		}
		return $plugins;
	}

}
PK     [1] J      &  Admin/BlockTemplates/BlockTemplate.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;

use Automattic\WooCommerce\Admin\BlockTemplates\ContainerInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;

/**
 * Block template class.
 */
class BlockTemplate extends AbstractBlockTemplate {
	/**
	 * Get the template ID.
	 */
	public function get_id(): string {
		return 'woocommerce-block-template';
	}

	/**
	 * Add an inner block to this template.
	 *
	 * @param array $block_config The block data.
	 */
	public function add_block( array $block_config ): BlockInterface {
		$block = new Block( $block_config, $this->get_root_template(), $this );
		return $this->add_inner_block( $block );
	}
}
PK     [1]n      Admin/BlockTemplates/Block.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;

use Automattic\WooCommerce\Admin\BlockTemplates\BlockContainerInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;

/**
 * Generic block with container properties to be used in BlockTemplate.
 */
class Block extends AbstractBlock implements BlockContainerInterface {
	use BlockContainerTrait;

	/**
	 * Add an inner block to this block.
	 *
	 * @param array $block_config The block data.
	 */
	public function &add_block( array $block_config ): BlockInterface {
		$block = new Block( $block_config, $this->get_root_template(), $this );
		return $this->add_inner_block( $block );
	}
}
PK     [1]j7    .  Admin/BlockTemplates/AbstractBlockTemplate.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;

use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;

/**
 * Block template class.
 */
abstract class AbstractBlockTemplate implements BlockTemplateInterface {
	use BlockContainerTrait;

	/**
	 * Get the template ID.
	 */
	abstract public function get_id(): string;

	/**
	 * Get the template title.
	 */
	public function get_title(): string {
		return '';
	}

	/**
	 * Get the template description.
	 */
	public function get_description(): string {
		return '';
	}

	/**
	 * Get the template area.
	 */
	public function get_area(): string {
		return 'uncategorized';
	}

	/**
	 * The block cache.
	 *
	 * @var BlockInterface[]
	 */
	private $block_cache = [];

	/**
	 * Get a block by ID.
	 *
	 * @param string $block_id The block ID.
	 */
	public function get_block( string $block_id ): ?BlockInterface {
		return $this->block_cache[ $block_id ] ?? null;
	}

	/**
	 * Caches a block in the template. This is an internal method and should not be called directly
	 * except for from the BlockContainerTrait's add_inner_block() method.
	 *
	 * @param BlockInterface $block The block to cache.
	 *
	 * @throws \ValueError If a block with the specified ID already exists in the template.
	 * @throws \ValueError If the block template that the block belongs to is not this template.
	 *
	 * @ignore
	 */
	public function cache_block( BlockInterface &$block ) {
		$id = $block->get_id();

		if ( isset( $this->block_cache[ $id ] ) ) {
			throw new \ValueError( 'A block with the specified ID already exists in the template.' );
		}

		if ( $block->get_root_template() !== $this ) {
			throw new \ValueError( 'The block template that the block belongs to must be the same as this template.' );
		}

		$this->block_cache[ $id ] = $block;
	}

	/**
	 * Uncaches a block in the template. This is an internal method and should not be called directly
	 * except for from the BlockContainerTrait's remove_block() method.
	 *
	 * @param string $block_id The block ID.
	 *
	 * @ignore
	 */
	public function uncache_block( string $block_id ) {
		if ( isset( $this->block_cache[ $block_id ] ) ) {
			unset( $this->block_cache[ $block_id ] );
		}
	}

	/**
	 * Generate a block ID based on a base.
	 *
	 * @param string $id_base The base to use when generating an ID.
	 * @return string
	 */
	public function generate_block_id( string $id_base ): string {
		$instance_count = 0;

		do {
			$instance_count++;
			$block_id = $id_base . '-' . $instance_count;
		} while ( isset( $this->block_cache[ $block_id ] ) );

		return $block_id;
	}

	/**
	 * Get the root template.
	 */
	public function &get_root_template(): BlockTemplateInterface {
		return $this;
	}

	/**
	 * Get the inner blocks as a formatted template.
	 */
	public function get_formatted_template(): array {
		$inner_blocks = $this->get_inner_blocks_sorted_by_order();

		$inner_blocks_formatted_template = array_map(
			function( BlockInterface $block ) {
				return $block->get_formatted_template();
			},
			$inner_blocks
		);

		return $inner_blocks_formatted_template;
	}

	/**
	 * Get the template as JSON like array.
	 *
	 * @return array The JSON.
	 */
	public function to_json(): array {
		return array(
			'id'             => $this->get_id(),
			'title'          => $this->get_title(),
			'description'    => $this->get_description(),
			'area'           => $this->get_area(),
			'blockTemplates' => $this->get_formatted_template(),
		);
	}
}
PK     [1] j8  8  ,  Admin/BlockTemplates/BlockTemplateLogger.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;

use Automattic\WooCommerce\Admin\BlockTemplates\BlockContainerInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\ContainerInterface;

/**
 * Logger for block template modifications.
 */
class BlockTemplateLogger {
	const BLOCK_ADDED                            = 'block_added';
	const BLOCK_REMOVED                          = 'block_removed';
	const BLOCK_MODIFIED                         = 'block_modified';
	const BLOCK_ADDED_TO_DETACHED_CONTAINER      = 'block_added_to_detached_container';
	const HIDE_CONDITION_ADDED                   = 'hide_condition_added';
	const HIDE_CONDITION_REMOVED                 = 'hide_condition_removed';
	const HIDE_CONDITION_ADDED_TO_DETACHED_BLOCK = 'hide_condition_added_to_detached_block';
	const ERROR_AFTER_BLOCK_ADDED                = 'error_after_block_added';
	const ERROR_AFTER_BLOCK_REMOVED              = 'error_after_block_removed';

	const LOG_HASH_TRANSIENT_BASE_NAME = 'wc_block_template_events_log_hash_';

	/**
	 * Event types.
	 *
	 * @var array
	 */
	public static $event_types = array(
		self::BLOCK_ADDED                            => array(
			'level'   => \WC_Log_Levels::DEBUG,
			'message' => 'Block added to template.',
		),
		self::BLOCK_REMOVED                          => array(
			'level'   => \WC_Log_Levels::NOTICE,
			'message' => 'Block removed from template.',
		),
		self::BLOCK_MODIFIED                         => array(
			'level'   => \WC_Log_Levels::NOTICE,
			'message' => 'Block modified in template.',
		),
		self::BLOCK_ADDED_TO_DETACHED_CONTAINER      => array(
			'level'   => \WC_Log_Levels::WARNING,
			'message' => 'Block added to detached container. Block will not be included in the template, since the container will not be included in the template.',
		),
		self::HIDE_CONDITION_ADDED                   => array(
			'level'   => \WC_Log_Levels::NOTICE,
			'message' => 'Hide condition added to block.',
		),
		self::HIDE_CONDITION_REMOVED                 => array(
			'level'   => \WC_Log_Levels::NOTICE,
			'message' => 'Hide condition removed from block.',
		),
		self::HIDE_CONDITION_ADDED_TO_DETACHED_BLOCK => array(
			'level'   => \WC_Log_Levels::WARNING,
			'message' => 'Hide condition added to detached block. Block will not be included in the template, so the hide condition is not needed.',
		),
		self::ERROR_AFTER_BLOCK_ADDED                => array(
			'level'   => \WC_Log_Levels::WARNING,
			'message' => 'Error after block added to template.',
		),
		self::ERROR_AFTER_BLOCK_REMOVED              => array(
			'level'   => \WC_Log_Levels::WARNING,
			'message' => 'Error after block removed from template.',
		),
	);

	/**
	 * Singleton instance.
	 *
	 * @var BlockTemplateLogger
	 */
	protected static $instance = null;

	/**
	 * Logger instance.
	 *
	 * @var \WC_Logger
	 */
	protected $logger = null;

	/**
	 * All template events.
	 *
	 * @var array
	 */
	private $all_template_events = array();

	/**
	 * Templates.
	 *
	 * @var array
	 */
	private $templates = array();

	/**
	 * Threshold severity.
	 *
	 * @var int
	 */
	private $threshold_severity = null;

	/**
	 * Get the singleton instance.
	 */
	public static function get_instance(): BlockTemplateLogger {
		if ( ! self::$instance ) {
			self::$instance = new self();
		}

		return self::$instance;
	}

	/**
	 * Constructor.
	 */
	protected function __construct() {
		$this->logger = wc_get_logger();

		$threshold = get_option( 'woocommerce_block_template_logging_threshold', \WC_Log_Levels::WARNING );
		if ( ! \WC_Log_Levels::is_valid_level( $threshold ) ) {
			$threshold = \WC_Log_Levels::INFO;
		}

		$this->threshold_severity = \WC_Log_Levels::get_level_severity( $threshold );

		add_action(
			'woocommerce_block_template_after_add_block',
			function ( BlockInterface $block ) {
				$is_detached = method_exists( $block->get_parent(), 'is_detached' ) && $block->get_parent()->is_detached();

				$this->log(
					$is_detached
						? $this::BLOCK_ADDED_TO_DETACHED_CONTAINER
						: $this::BLOCK_ADDED,
					$block,
				);
			},
			0,
		);

		add_action(
			'woocommerce_block_template_after_remove_block',
			function ( BlockInterface $block ) {
				$this->log(
					$this::BLOCK_REMOVED,
					$block,
				);
			},
			0,
		);

		add_action(
			'woocommerce_block_template_after_add_hide_condition',
			function ( BlockInterface $block ) {
				$this->log(
					$block->is_detached()
						? $this::HIDE_CONDITION_ADDED_TO_DETACHED_BLOCK
						: $this::HIDE_CONDITION_ADDED,
					$block,
				);
			},
			0
		);

		add_action(
			'woocommerce_block_template_after_remove_hide_condition',
			function ( BlockInterface $block ) {
				$this->log(
					$this::HIDE_CONDITION_REMOVED,
					$block,
				);
			},
			0
		);

		add_action(
			'woocommerce_block_template_after_add_block_error',
			function ( BlockInterface $block, string $action, \Exception $exception ) {
				$this->log(
					$this::ERROR_AFTER_BLOCK_ADDED,
					$block,
					array(
						'action'    => $action,
						'exception' => $exception,
					),
				);
			},
			0,
			3
		);

		add_action(
			'woocommerce_block_template_after_remove_block_error',
			function ( BlockInterface $block, string $action, \Exception $exception ) {
				$this->log(
					$this::ERROR_AFTER_BLOCK_REMOVED,
					$block,
					array(
						'action'    => $action,
						'exception' => $exception,
					),
				);
			},
			0,
			3
		);
	}

	/**
	 * Get all template events for a given template as a JSON like array.
	 *
	 * @param string $template_id Template ID.
	 */
	public function template_events_to_json( string $template_id ): array {
		if ( ! isset( $this->all_template_events[ $template_id ] ) ) {
			return array();
		}

		$template_events = $this->all_template_events[ $template_id ];

		return $this->to_json( $template_events );
	}

	/**
	 * Get all template events as a JSON like array.
	 *
	 * @param array $template_events Template events.
	 *
	 * @return array The JSON.
	 */
	private function to_json( array $template_events ): array {
		$json = array();

		foreach ( $template_events as $template_event ) {
			$container = $template_event['container'];
			$block     = $template_event['block'];

			$json[] = array(
				'level'           => $template_event['level'],
				'event_type'      => $template_event['event_type'],
				'message'         => $template_event['message'],
				'container'       => $container instanceof BlockInterface
					? array(
						'id'   => $container->get_id(),
						'name' => $container->get_name(),
					)
					: null,
				'block'           => array(
					'id'   => $block->get_id(),
					'name' => $block->get_name(),
				),
				'additional_info' => $this->format_info( $template_event['additional_info'] ),
			);
		}

		return $json;
	}

	/**
	 * Log all template events for a given template to the log file.
	 *
	 * @param string $template_id Template ID.
	 */
	public function log_template_events_to_file( string $template_id ) {
		if ( ! isset( $this->all_template_events[ $template_id ] ) ) {
			return;
		}

		$template_events = $this->all_template_events[ $template_id ];

		$hash = $this->generate_template_events_hash( $template_events );

		if ( ! $this->has_template_events_changed( $template_id, $hash ) ) {
			// Nothing has changed since the last time this was logged,
			// so don't log it again.
			return;
		}

		$this->set_template_events_log_hash( $template_id, $hash );

		$template = $this->templates[ $template_id ];

		foreach ( $template_events as $template_event ) {
			$info = array_merge(
				array(
					'template'  => $template,
					'container' => $template_event['container'],
					'block'     => $template_event['block'],
				),
				$template_event['additional_info']
			);

			$message = $this->format_message( $template_event['message'], $info );

			$this->logger->log(
				$template_event['level'],
				$message,
				array( 'source' => 'block_template' )
			);
		}
	}

	/**
	 * Has the template events changed since the last time they were logged?
	 *
	 * @param string $template_id Template ID.
	 * @param string $events_hash Events hash.
	 */
	private function has_template_events_changed( string $template_id, string $events_hash ) {
		$previous_hash = get_transient( self::LOG_HASH_TRANSIENT_BASE_NAME . $template_id );

		return $previous_hash !== $events_hash;
	}

	/**
	 * Generate a hash for a given set of template events.
	 *
	 * @param array $template_events Template events.
	 */
	private function generate_template_events_hash( array $template_events ): string {
		return md5( wp_json_encode( $this->to_json( $template_events ) ) );
	}

	/**
	 * Set the template events hash for a given template.
	 *
	 * @param string $template_id Template ID.
	 * @param string $hash        Hash of template events.
	 */
	private function set_template_events_log_hash( string $template_id, string $hash ) {
		set_transient( self::LOG_HASH_TRANSIENT_BASE_NAME . $template_id, $hash );
	}

	/**
	 * Log an event.
	 *
	 * @param string         $event_type      Event type.
	 * @param BlockInterface $block           Block.
	 * @param array          $additional_info Additional info.
	 */
	private function log( string $event_type, BlockInterface $block, $additional_info = array() ) {
		if ( ! isset( self::$event_types[ $event_type ] ) ) {
			/* translators: 1: WC_Logger::log 2: level */
			wc_doing_it_wrong( __METHOD__, sprintf( __( '%1$s was called with an invalid event type "%2$s".', 'woocommerce' ), '<code>BlockTemplateLogger::log</code>', $event_type ), '8.4' );
		}

		$event_type_info = isset( self::$event_types[ $event_type ] )
			? array_merge(
				self::$event_types[ $event_type ],
				array(
					'event_type' => $event_type,
				)
			)
			: array(
				'level'      => \WC_Log_Levels::ERROR,
				'event_type' => $event_type,
				'message'    => 'Unknown error.',
			);

		if ( ! $this->should_handle( $event_type_info['level'] ) ) {
			return;
		}

		$template  = $block->get_root_template();
		$container = $block->get_parent();

		$this->add_template_event( $event_type_info, $template, $container, $block, $additional_info );
	}

	/**
	 * Should the logger handle a given level?
	 *
	 * @param int $level Level to check.
	 */
	private function should_handle( $level ) {
		return $this->threshold_severity <= \WC_Log_Levels::get_level_severity( $level );
	}

	/**
	 * Add a template event.
	 *
	 * @param array                  $event_type_info Event type info.
	 * @param BlockTemplateInterface $template        Template.
	 * @param ContainerInterface     $container       Container.
	 * @param BlockInterface         $block           Block.
	 * @param array                  $additional_info Additional info.
	 */
	private function add_template_event( array $event_type_info, BlockTemplateInterface $template, ContainerInterface $container, BlockInterface $block, array $additional_info = array() ) {
		$template_id = $template->get_id();

		if ( ! isset( $this->all_template_events[ $template_id ] ) ) {
			$this->all_template_events[ $template_id ] = array();
			$this->templates[ $template_id ]           = $template;
		}

		$template_events = &$this->all_template_events[ $template_id ];

		$template_events[] = array(
			'level'           => $event_type_info['level'],
			'event_type'      => $event_type_info['event_type'],
			'message'         => $event_type_info['message'],
			'container'       => $container,
			'block'           => $block,
			'additional_info' => $additional_info,
		);
	}

	/**
	 * Format a message for logging.
	 *
	 * @param string $message Message to log.
	 * @param array  $info    Additional info to log.
	 */
	private function format_message( string $message, array $info = array() ): string {
		$formatted_message = sprintf(
			"%s\n%s",
			$message,
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
			print_r( $this->format_info( $info ), true ),
		);

		return $formatted_message;
	}

	/**
	 * Format info for logging.
	 *
	 * @param array $info Info to log.
	 */
	private function format_info( array $info ): array {
		$formatted_info = $info;

		if ( isset( $info['exception'] ) && $info['exception'] instanceof \Exception ) {
			$formatted_info['exception'] = $this->format_exception( $info['exception'] );
		}

		if ( isset( $info['container'] ) ) {
			if ( $info['container'] instanceof BlockContainerInterface ) {
				$formatted_info['container'] = $this->format_block( $info['container'] );
			} elseif ( $info['container'] instanceof BlockTemplateInterface ) {
				$formatted_info['container'] = $this->format_template( $info['container'] );
			} elseif ( $info['container'] instanceof BlockInterface ) {
				$formatted_info['container'] = $this->format_block( $info['container'] );
			}
		}

		if ( isset( $info['block'] ) && $info['block'] instanceof BlockInterface ) {
			$formatted_info['block'] = $this->format_block( $info['block'] );
		}

		if ( isset( $info['template'] ) && $info['template'] instanceof BlockTemplateInterface ) {
			$formatted_info['template'] = $this->format_template( $info['template'] );
		}

		return $formatted_info;
	}

	/**
	 * Format an exception for logging.
	 *
	 * @param \Exception $exception Exception to format.
	 */
	private function format_exception( \Exception $exception ): array {
		return array(
			'message' => $exception->getMessage(),
			'source'  => "{$exception->getFile()}: {$exception->getLine()}",
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
			'trace'   => print_r( $this->format_exception_trace( $exception->getTrace() ), true ),
		);
	}

	/**
	 * Format an exception trace for logging.
	 *
	 * @param array $trace Exception trace to format.
	 */
	private function format_exception_trace( array $trace ): array {
		$formatted_trace = array();

		foreach ( $trace as $source ) {
			$formatted_trace[] = "{$source['file']}: {$source['line']}";
		}

		return $formatted_trace;
	}

	/**
	 * Format a block template for logging.
	 *
	 * @param BlockTemplateInterface $template Template to format.
	 */
	private function format_template( BlockTemplateInterface $template ): string {
		return "{$template->get_id()} (area: {$template->get_area()})";
	}

	/**
	 * Format a block for logging.
	 *
	 * @param BlockInterface $block Block to format.
	 */
	private function format_block( BlockInterface $block ): string {
		return "{$block->get_id()} (name: {$block->get_name()})";
	}
}
PK     [1]    4  Admin/BlockTemplates/BlockFormattedTemplateTrait.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;

/**
 * Trait for block formatted template.
 */
trait BlockFormattedTemplateTrait {
	/**
	 * Get the block configuration as a formatted template.
	 *
	 * @return array The block configuration as a formatted template.
	 */
	public function get_formatted_template(): array {
		$arr = array(
			$this->get_name(),
			array_merge(
				$this->get_attributes(),
				array(
					'_templateBlockId'    => $this->get_id(),
					'_templateBlockOrder' => $this->get_order(),
				),
				! empty( $this->get_hide_conditions() ) ? array(
					'_templateBlockHideConditions' => $this->get_formatted_hide_conditions(),
				) : array(),
				! empty( $this->get_disable_conditions() ) ? array(
					'_templateBlockDisableConditions' => $this->get_formatted_disable_conditions(),
				) : array(),
			),
		);

		return $arr;
	}

	/**
	 * Get the block hide conditions formatted for inclusion in a formatted template.
	 */
	private function get_formatted_hide_conditions(): array {
		return $this->format_conditions( $this->get_hide_conditions() );
	}

	/**
	 * Get the block disable conditions formatted for inclusion in a formatted template.
	 */
	private function get_formatted_disable_conditions(): array {
		return $this->format_conditions( $this->get_disable_conditions() );
	}

	/**
	 * Formats conditions in the expected format to include in the template.
	 *
	 * @param array $conditions The conditions to format.
	 */
	private function format_conditions( $conditions ): array {
		$formatted_expressions = array_map(
			function( $condition ) {
				return array(
					'expression' => $condition['expression'],
				);
			},
			array_values( $conditions )
		);

		return $formatted_expressions;
	}
}
PK     [1]rR(  R(  ,  Admin/BlockTemplates/BlockContainerTrait.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;

use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\ContainerInterface;

/**
 * Trait for block containers.
 */
trait BlockContainerTrait {
	use BlockFormattedTemplateTrait {
		get_formatted_template as get_block_formatted_template;
	}

	/**
	 * The inner blocks.
	 *
	 * @var BlockInterface[]
	 */
	private $inner_blocks = array();

	// phpcs doesn't take into account exceptions thrown by called methods.
	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

	/**
	 * Add a block to the block container.
	 *
	 * @param BlockInterface $block The block.
	 *
	 * @throws \ValueError If the block configuration is invalid.
	 * @throws \ValueError If a block with the specified ID already exists in the template.
	 * @throws \UnexpectedValueException If the block container is not the parent of the block.
	 * @throws \UnexpectedValueException If the block container's root template is not the same as the block's root template.
	 */
	protected function &add_inner_block( BlockInterface $block ): BlockInterface {
		if ( $block->get_parent() !== $this ) {
			throw new \UnexpectedValueException( 'The block container is not the parent of the block.' );
		}

		if ( $block->get_root_template() !== $this->get_root_template() ) {
			throw new \UnexpectedValueException( 'The block container\'s root template is not the same as the block\'s root template.' );
		}

		$is_detached = method_exists( $this, 'is_detached' ) && $this->is_detached();
		if ( ! $is_detached ) {
			$this->get_root_template()->cache_block( $block );
		}

		$this->inner_blocks[] = &$block;

		$this->do_after_add_block_action( $block );
		$this->do_after_add_specific_block_action( $block );

		return $block;
	}

	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber

	/**
	 * Checks if a block is a descendant of the block container.
	 *
	 * @param BlockInterface $block The block.
	 */
	private function is_block_descendant( BlockInterface $block ): bool {
		$parent = $block->get_parent();

		if ( $parent === $this ) {
			return true;
		}

		if ( ! $parent instanceof BlockInterface ) {
			return false;
		}

		return $this->is_block_descendant( $parent );
	}

	/**
	 * Get a block by ID.
	 *
	 * @param string $block_id The block ID.
	 */
	public function get_block( string $block_id ): ?BlockInterface {
		foreach ( $this->inner_blocks as $block ) {
			if ( $block->get_id() === $block_id ) {
				return $block;
			}
		}

		foreach ( $this->inner_blocks as $block ) {
			if ( $block instanceof ContainerInterface ) {
				$block = $block->get_block( $block_id );

				if ( $block ) {
					return $block;
				}
			}
		}

		return null;
	}

	/**
	 * Remove a block from the block container.
	 *
	 * @param string $block_id The block ID.
	 *
	 * @throws \UnexpectedValueException If the block container is not an ancestor of the block.
	 */
	public function remove_block( string $block_id ) {
		$root_template = $this->get_root_template();

		$block = $root_template->get_block( $block_id );

		if ( ! $block ) {
			return;
		}

		if ( ! $this->is_block_descendant( $block ) ) {
			throw new \UnexpectedValueException( 'The block container is not an ancestor of the block.' );
		}

		// If the block is a container, remove all of its blocks.
		if ( $block instanceof ContainerInterface ) {
			$block->remove_blocks();
		}

		$parent = $block->get_parent();
		$parent->remove_inner_block( $block );
	}

	/**
	 * Remove all blocks from the block container.
	 */
	public function remove_blocks() {
		array_map(
			function ( BlockInterface $block ) {
				$this->remove_block( $block->get_id() );
			},
			$this->inner_blocks
		);
	}

	/**
	 * Remove a block from the block container's inner blocks. This is an internal method and should not be called directly
	 * except for from the BlockContainerTrait's remove_block() method.
	 *
	 * @param BlockInterface $block The block.
	 */
	public function remove_inner_block( BlockInterface $block ) {
		// Remove block from root template's cache.
		$root_template = $this->get_root_template();
		$root_template->uncache_block( $block->get_id() );

		$this->inner_blocks = array_filter(
			$this->inner_blocks,
			function ( BlockInterface $inner_block ) use ( $block ) {
				return $inner_block !== $block;
			}
		);

		$this->do_after_remove_block_action( $block );
		$this->do_after_remove_specific_block_action( $block );
	}

	/**
	 * Get the inner blocks sorted by order.
	 */
	private function get_inner_blocks_sorted_by_order(): array {
		$sorted_inner_blocks = $this->inner_blocks;

		usort(
			$sorted_inner_blocks,
			function( BlockInterface $a, BlockInterface $b ) {
				return $a->get_order() <=> $b->get_order();
			}
		);

		return $sorted_inner_blocks;
	}

	/**
	 * Get the inner blocks as a formatted template.
	 */
	public function get_formatted_template(): array {
		$arr = $this->get_block_formatted_template();

		$inner_blocks = $this->get_inner_blocks_sorted_by_order();

		if ( ! empty( $inner_blocks ) ) {
			$arr[] = array_map(
				function( BlockInterface $block ) {
					return $block->get_formatted_template();
				},
				$inner_blocks
			);
		}

		return $arr;
	}

	/**
	 * Do the `woocommerce_block_template_after_add_block` action.
	 * Handle exceptions thrown by the action.
	 *
	 * @param BlockInterface $block The block.
	 */
	private function do_after_add_block_action( BlockInterface $block ) {
		try {
			/**
			 * Action called after a block is added to a block container.
			 *
			 * This action can be used to perform actions after a block is added to the block container,
			 * such as adding a dependent block.
			 *
			 * @param BlockInterface $block The block.
			 *
			 * @since 8.2.0
			 */
			do_action( 'woocommerce_block_template_after_add_block', $block );
		} catch ( \Exception $e ) {
			$this->do_after_add_block_error_action( $block, 'woocommerce_block_template_after_add_block', $e );
		}
	}

	/**
	 * Do the `woocommerce_block_template_area_{template_area}_after_add_block_{block_id}` action.
	 * Handle exceptions thrown by the action.
	 *
	 * @param BlockInterface $block The block.
	 */
	private function do_after_add_specific_block_action( BlockInterface $block ) {
		try {
			/**
			 * Action called after a specific block is added to a template with a specific area.
			 *
			 * This action can be used to perform actions after a specific block is added to a template with a specific area,
			 * such as adding a dependent block.
			 *
			 * @param BlockInterface $block The block.
			 *
			 * @since 8.2.0
			 */
			do_action( "woocommerce_block_template_area_{$this->get_root_template()->get_area()}_after_add_block_{$block->get_id()}", $block );
		} catch ( \Exception $e ) {
			$this->do_after_add_block_error_action( $block, "woocommerce_block_template_area_{$this->get_root_template()->get_area()}_after_add_block_{$block->get_id()}", $e );
		}
	}

	/**
	 * Do the `woocommerce_block_after_add_block_error` action.
	 *
	 * @param BlockInterface $block The block.
	 * @param string         $action The action that threw the exception.
	 * @param \Exception     $e The exception.
	 */
	private function do_after_add_block_error_action( BlockInterface $block, string $action, \Exception $e ) {
		/**
		 * Action called after an exception is thrown by a `woocommerce_block_template_after_add_block` action hook.
		 *
		 * @param BlockInterface $block The block.
		 * @param string         $action The action that threw the exception.
		 * @param \Exception     $exception The exception.
		 *
		 * @since 8.4.0
		 */
		do_action(
			'woocommerce_block_template_after_add_block_error',
			$block,
			$action,
			$e,
		);
	}

	/**
	 * Do the `woocommerce_block_template_after_remove_block` action.
	 * Handle exceptions thrown by the action.
	 *
	 * @param BlockInterface $block The block.
	 */
	private function do_after_remove_block_action( BlockInterface $block ) {
		try {
			/**
			 * Action called after a block is removed from a block container.
			 *
			 * This action can be used to perform actions after a block is removed from the block container,
			 * such as removing a dependent block.
			 *
			 * @param BlockInterface $block The block.
			 *
			 * @since 8.2.0
			 */
			do_action( 'woocommerce_block_template_after_remove_block', $block );
		} catch ( \Exception $e ) {
			$this->do_after_remove_block_error_action( $block, 'woocommerce_block_template_after_remove_block', $e );
		}
	}

	/**
	 * Do the `woocommerce_block_template_area_{template_area}_after_remove_block_{block_id}` action.
	 * Handle exceptions thrown by the action.
	 *
	 * @param BlockInterface $block The block.
	 */
	private function do_after_remove_specific_block_action( BlockInterface $block ) {
		try {
			/**
			 * Action called after a specific block is removed from a template with a specific area.
			 *
			 * This action can be used to perform actions after a specific block is removed from a template with a specific area,
			 * such as removing a dependent block.
			 *
			 * @param BlockInterface $block The block.
			 *
			 * @since 8.2.0
			 */
			do_action( "woocommerce_block_template_area_{$this->get_root_template()->get_area()}_after_remove_block_{$block->get_id()}", $block );
		} catch ( \Exception $e ) {
			$this->do_after_remove_block_error_action( $block, "woocommerce_block_template_area_{$this->get_root_template()->get_area()}_after_remove_block_{$block->get_id()}", $e );
		}
	}

	/**
	 * Do the `woocommerce_block_after_remove_block_error` action.
	 *
	 * @param BlockInterface $block The block.
	 * @param string         $action The action that threw the exception.
	 * @param \Exception     $e The exception.
	 */
	private function do_after_remove_block_error_action( BlockInterface $block, string $action, \Exception $e ) {
		/**
		 * Action called after an exception is thrown by a `woocommerce_block_template_after_remove_block` action hook.
		 *
		 * @param BlockInterface $block The block.
		 * @param string         $action The action that threw the exception.
		 * @param \Exception     $exception The exception.
		 *
		 * @since 8.4.0
		 */
		do_action(
			'woocommerce_block_template_after_remove_block_error',
			$block,
			$action,
			$e,
		);
	}
}
PK     [1]8#>#  #  &  Admin/BlockTemplates/AbstractBlock.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;

use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\ContainerInterface;

/**
 * Block configuration used to specify blocks in BlockTemplate.
 */
class AbstractBlock implements BlockInterface {
	use BlockFormattedTemplateTrait;

	/**
	 * The block name.
	 *
	 * @var string
	 */
	private $name;

	/**
	 * The block ID.
	 *
	 * @var string
	 */
	private $id;

	/**
	 * The block order.
	 *
	 * @var int
	 */
	private $order = 10000;

	/**
	 * The block attributes.
	 *
	 * @var array
	 */
	private $attributes = array();

	/**
	 * The block hide conditions.
	 *
	 * @var array
	 */
	private $hide_conditions = array();

	/**
	 * The block hide conditions counter.
	 *
	 * @var int
	 */
	private $hide_conditions_counter = 0;

	/**
	 * The block disable conditions.
	 *
	 * @var array
	 */
	private $disable_conditions = array();

	/**
	 * The block disable conditions counter.
	 *
	 * @var int
	 */
	private $disable_conditions_counter = 0;

	/**
	 * The block template that this block belongs to.
	 *
	 * @var BlockTemplate
	 */
	private $root_template;

	/**
	 * The parent container.
	 *
	 * @var ContainerInterface
	 */
	private $parent;

	/**
	 * Block constructor.
	 *
	 * @param array                        $config The block configuration.
	 * @param BlockTemplateInterface       $root_template The block template that this block belongs to.
	 * @param BlockContainerInterface|null $parent The parent block container.
	 *
	 * @throws \ValueError If the block configuration is invalid.
	 * @throws \ValueError If the parent block container does not belong to the same template as the block.
	 */
	public function __construct( array $config, BlockTemplateInterface &$root_template, ?ContainerInterface &$parent = null ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.parentFound
		$this->validate( $config, $root_template, $parent );

		$this->root_template = $root_template;
		$this->parent        = is_null( $parent ) ? $root_template : $parent;

		$this->name = $config[ self::NAME_KEY ];

		if ( ! isset( $config[ self::ID_KEY ] ) ) {
			$this->id = $this->root_template->generate_block_id( $this->get_name() );
		} else {
			$this->id = $config[ self::ID_KEY ];
		}

		if ( isset( $config[ self::ORDER_KEY ] ) ) {
			$this->order = $config[ self::ORDER_KEY ];
		}

		if ( isset( $config[ self::ATTRIBUTES_KEY ] ) ) {
			$this->attributes = $config[ self::ATTRIBUTES_KEY ];
		}

		if ( isset( $config[ self::HIDE_CONDITIONS_KEY ] ) ) {
			foreach ( $config[ self::HIDE_CONDITIONS_KEY ] as $hide_condition ) {
				$this->add_hide_condition( $hide_condition['expression'] );
			}
		}

		if ( isset( $config[ self::DISABLE_CONDITIONS_KEY ] ) ) {
			foreach ( $config[ self::DISABLE_CONDITIONS_KEY ] as $disable_condition ) {
				$this->add_disable_condition( $disable_condition['expression'] );
			}
		}
	}

	/**
	 * Validate block configuration.
	 *
	 * @param array                   $config The block configuration.
	 * @param BlockTemplateInterface  $root_template The block template that this block belongs to.
	 * @param ContainerInterface|null $parent The parent block container.
	 *
	 * @throws \ValueError If the block configuration is invalid.
	 * @throws \ValueError If the parent block container does not belong to the same template as the block.
	 */
	protected function validate( array $config, BlockTemplateInterface &$root_template, ?ContainerInterface &$parent = null ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.parentFound
		if ( isset( $parent ) && ( $parent->get_root_template() !== $root_template ) ) {
			throw new \ValueError( 'The parent block must belong to the same template as the block.' );
		}

		if ( ! isset( $config[ self::NAME_KEY ] ) || ! is_string( $config[ self::NAME_KEY ] ) ) {
			throw new \ValueError( 'The block name must be specified.' );
		}

		if ( isset( $config[ self::ORDER_KEY ] ) && ! is_int( $config[ self::ORDER_KEY ] ) ) {
			throw new \ValueError( 'The block order must be an integer.' );
		}

		if ( isset( $config[ self::ATTRIBUTES_KEY ] ) && ! is_array( $config[ self::ATTRIBUTES_KEY ] ) ) {
			throw new \ValueError( 'The block attributes must be an array.' );
		}
	}

	/**
	 * Get the block name.
	 */
	public function get_name(): string {
		return $this->name;
	}

	/**
	 * Get the block ID.
	 */
	public function get_id(): string {
		return $this->id;
	}

	/**
	 * Get the block order.
	 */
	public function get_order(): int {
		return $this->order;
	}

	/**
	 * Set the block order.
	 *
	 * @param int $order The block order.
	 */
	public function set_order( int $order ) {
		$this->order = $order;
	}

	/**
	 * Get the block attributes.
	 */
	public function get_attributes(): array {
		return $this->attributes;
	}

	/**
	 * Set the block attributes.
	 *
	 * @param array $attributes The block attributes.
	 */
	public function set_attributes( array $attributes ) {
		$this->attributes = $attributes;
	}

	/**
	 * Set a block attribute value without replacing the entire attributes object.
	 *
	 * @param string $key The attribute key.
	 * @param mixed  $value The attribute value.
	 */
	public function set_attribute( string $key, $value ) {
		$this->attributes[ $key ] = $value;
	}

	/**
	 * Get the template that this block belongs to.
	 */
	public function &get_root_template(): BlockTemplateInterface {
		return $this->root_template;
	}

	/**
	 * Get the parent block container.
	 */
	public function &get_parent(): ContainerInterface {
		return $this->parent;
	}

	/**
	 * Remove the block from its parent.
	 */
	public function remove() {
		$this->parent->remove_block( $this->id );
	}

	/**
	 * Check if the block is detached from its parent block container or the template it belongs to.
	 *
	 * @return bool True if the block is detached from its parent block container or the template it belongs to.
	 */
	public function is_detached(): bool {
		$is_in_parent        = $this->parent->get_block( $this->id ) === $this;
		$is_in_root_template = $this->get_root_template()->get_block( $this->id ) === $this;

		return ! ( $is_in_parent && $is_in_root_template );
	}

	/**
	 * Add a hide condition to the block.
	 *
	 * The hide condition is a JavaScript-like expression that will be evaluated on the client to determine if the block should be hidden.
	 * See [@woocommerce/expression-evaluation](https://github.com/woocommerce/woocommerce/blob/trunk/packages/js/expression-evaluation/README.md) for more details.
	 *
	 * @param string $expression An expression, which if true, will hide the block.
	 */
	public function add_hide_condition( string $expression ): string {
		$key = 'k' . $this->hide_conditions_counter;
		$this->hide_conditions_counter++;

		// Storing the expression in an array to allow for future expansion
		// (such as adding the plugin that added the condition).
		$this->hide_conditions[ $key ] = array(
			'expression' => $expression,
		);

		/**
		 * Action called after a hide condition is added to a block.
		 *
		 * @param BlockInterface $block The block.
		 *
		 * @since 8.4.0
		 */
		do_action( 'woocommerce_block_template_after_add_hide_condition', $this );

		return $key;
	}

	/**
	 * Remove a hide condition from the block.
	 *
	 * @param string $key The key of the hide condition to remove.
	 */
	public function remove_hide_condition( string $key ) {
		unset( $this->hide_conditions[ $key ] );

		/**
		 * Action called after a hide condition is removed from a block.
		 *
		 * @param BlockInterface $block The block.
		 *
		 * @since 8.4.0
		 */
		do_action( 'woocommerce_block_template_after_remove_hide_condition', $this );
	}

	/**
	 * Get the hide conditions of the block.
	 */
	public function get_hide_conditions(): array {
		return $this->hide_conditions;
	}

	/**
	 * Add a disable condition to the block.
	 *
	 * The disable condition is a JavaScript-like expression that will be evaluated on the client to determine if the block should be hidden.
	 * See [@woocommerce/expression-evaluation](https://github.com/woocommerce/woocommerce/blob/trunk/packages/js/expression-evaluation/README.md) for more details.
	 *
	 * @param string $expression An expression, which if true, will disable the block.
	 */
	public function add_disable_condition( string $expression ): string {
		$key = 'k' . $this->disable_conditions_counter;
		$this->disable_conditions_counter++;

		// Storing the expression in an array to allow for future expansion
		// (such as adding the plugin that added the condition).
		$this->disable_conditions[ $key ] = array(
			'expression' => $expression,
		);

		return $key;
	}

	/**
	 * Remove a disable condition from the block.
	 *
	 * @param string $key The key of the disable condition to remove.
	 */
	public function remove_disable_condition( string $key ) {
		unset( $this->disable_conditions[ $key ] );
	}

	/**
	 * Get the disable conditions of the block.
	 */
	public function get_disable_conditions(): array {
		return $this->disable_conditions;
	}
}
PK     [1]ym
  
    Admin/WCPayPromotion/Init.phpnu         <?php
/**
 * Handles WooPayments promotion.
 */

namespace Automattic\WooCommerce\Internal\Admin\WCPayPromotion;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\EvaluateSuggestion;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
use Automattic\WooCommerce\Admin\RemoteSpecs\RemoteSpecsEngine;
use Automattic\WooCommerce\Utilities\FeaturesUtil;

/**
 * WooPayments Promotion engine.
 *
 * @deprecated 9.9.0 The WooPayments promotion engine is deprecated and will be removed in a future version of WooCommerce.
 */
class Init extends RemoteSpecsEngine {

	/**
	 * Possibly registers the pre-install WooPayments promoted gateway.
	 *
	 * @param array $gateways List of gateway classes.
	 *
	 * @return array List of gateway classes.
	 */
	public static function possibly_register_pre_install_wc_pay_promotion_gateway( $gateways ) {
		if ( self::can_show_promotion() && ! WCPaymentGatewayPreInstallWCPayPromotion::is_dismissed() ) {
			$gateways[] = 'Automattic\WooCommerce\Internal\Admin\WCPayPromotion\WCPaymentGatewayPreInstallWCPayPromotion';
		}
		return $gateways;
	}

	/**
	 * Checks if promoted gateway can be registered.
	 *
	 * @return boolean If promoted gateway should be registered.
	 */
	public static function can_show_promotion() {
		// Don't show if WooPayments is enabled.
		if ( class_exists( '\WC_Payments' ) ) {
			return false;
		}

		// Don't show if there is no WooPayments promotion spec.
		$wc_pay_spec = self::get_wc_pay_promotion_spec();
		if ( ! $wc_pay_spec ) {
			return false;
		}

		return true;
	}

	/**
	 * By default, new payment gateways are put at the bottom of the list on the admin "Payments" settings screen.
	 * For visibility, we want WooPayments to be at the top of the list.
	 *
	 * @param array $ordering Existing ordering of the payment gateways.
	 *
	 * @return array Modified ordering.
	 */
	public static function set_gateway_top_of_list( $ordering ) {
		$ordering = (array) $ordering;
		$id       = WCPaymentGatewayPreInstallWCPayPromotion::GATEWAY_ID;
		// Only tweak the ordering if the list hasn't been reordered with WooPayments in it already.
		if ( ! isset( $ordering[ $id ] ) || ! is_numeric( $ordering[ $id ] ) ) {
			$is_empty        = empty( $ordering ) || ( count( $ordering ) === 1 && in_array( $ordering[0], array( false, '' ) ) );
			$ordering[ $id ] = $is_empty ? 0 : ( min( array_map( 'intval', $ordering ) ) - 1 );
		}

		return $ordering;
	}

	/**
	 * Get WooPayments promotion spec.
	 *
	 * @param boolean $fetch_from_remote Whether to fetch the spec from remote or not.
	 *
	 * @return object|false WooPayments promotion spec or false if there isn't one.
	 */
	public static function get_wc_pay_promotion_spec( $fetch_from_remote = true ) {
		$promotions            = $fetch_from_remote ? self::get_promotions() : self::get_cached_or_default_promotions();
		$wc_pay_promotion_spec = array_values(
			array_filter(
				$promotions,
				function ( $promotion ) {
					return isset( $promotion->plugins ) && in_array( 'woocommerce-payments', $promotion->plugins, true );
				}
			)
		);

		return current( $wc_pay_promotion_spec );
	}

	/**
	 * Go through the specs and run them.
	 *
	 * @return array List of promotions.
	 */
	public static function get_promotions() {
		$locale = get_user_locale();

		$specs           = self::get_specs();
		$results         = EvaluateSuggestion::evaluate_specs( $specs, array( 'source' => 'wc-wcpay-promotions' ) );
		$specs_to_return = $results['suggestions'];
		$specs_to_save   = null;

		if ( empty( $specs_to_return ) ) {
			// When specs are empty, replace it with defaults and save for 3 hours.
			$specs_to_save   = DefaultPromotions::get_all();
			$specs_to_return = EvaluateSuggestion::evaluate_specs( $specs_to_save )['suggestions'];
		} elseif ( count( $results['errors'] ) > 0 ) {
			// When specs are not empty but have errors, save for 3 hours.
			$specs_to_save = $specs;
		}

		if ( count( $results['errors'] ) > 0 ) {
			self::log_errors( $results['errors'] );
		}

		if ( $specs_to_save ) {
			WCPayPromotionDataSourcePoller::get_instance()->set_specs_transient( array( $locale => $specs_to_save ), 3 * HOUR_IN_SECONDS );
		}

		return $specs_to_return;
	}

	/**
	 * Gets either cached or default promotions.
	 *
	 * @return array
	 */
	public static function get_cached_or_default_promotions() {
		$specs = 'no' === get_option( 'woocommerce_show_marketplace_suggestions', 'yes' )
			? DefaultPromotions::get_all()
			: WCPayPromotionDataSourcePoller::get_instance()->get_cached_specs();

		if ( ! is_array( $specs ) || 0 === count( $specs ) ) {
			$specs = DefaultPromotions::get_all();
		}
		$results = EvaluateSuggestion::evaluate_specs( $specs, array( 'source' => 'wc-wcpay-promotions' ) );
		return $results['suggestions'];
	}

	/**
	 * Get merchant WooPay eligibility.
	 *
	 * @return boolean If merchant is eligible for WooPay.
	 */
	public static function is_woopay_eligible() {
		$wcpay_promotion = self::get_wc_pay_promotion_spec( false );

		return $wcpay_promotion && 'woocommerce_payments:woopay' === $wcpay_promotion->id;
	}

	/**
	 * Delete the specs transient.
	 */
	public static function delete_specs_transient() {
		WCPayPromotionDataSourcePoller::get_instance()->delete_specs_transient();
	}

	/**
	 * Get specs or fetch remotely if they don't exist.
	 *
	 * @return array List of specs.
	 */
	public static function get_specs() {
		if ( get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) === 'no' ) {
			return DefaultPromotions::get_all();
		}

		$specs = WCPayPromotionDataSourcePoller::get_instance()->get_specs_from_data_sources();
		// On empty remote specs, fallback to default ones.
		if ( ! is_array( $specs ) || 0 === count( $specs ) ) {
			$specs = DefaultPromotions::get_all();
		}

		return $specs;
	}

	/**
	 * Loads the payment method promotions scripts and styles.
	 */
	public static function load_payment_method_promotions() {
		WCAdminAssets::register_style( 'payment-method-promotions', 'style', array( 'wp-components' ) );
		WCAdminAssets::register_script( 'wp-admin-scripts', 'payment-method-promotions', true );
	}
}
PK     [1]4e}R\
  \
  *  Admin/WCPayPromotion/DefaultPromotions.phpnu         <?php
/**
 * Gets a list of fallback promotions if remote fetching is disabled.
 */

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\Admin\WCPayPromotion;

use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\DefaultPaymentGateways;

defined( 'ABSPATH' ) || exit;

/**
 * Default Promotions
 */
class DefaultPromotions {

	/**
	 * Get the specs.
	 *
	 * @return array Suggestion specs.
	 */
	public static function get_all(): array {
		return array(
			array(
				'id'         => 'woocommerce_payments:woopay',
				'title'      => __( 'WooPayments', 'woocommerce' ),
				'content'    => __( 'Payments made simple — including WooPay, a new express checkout feature.', 'woocommerce' ),
				'image'      => plugins_url( 'assets/images/onboarding/wcpay.svg', WC_PLUGIN_FILE ),
				'plugins'    => array( 'woocommerce-payments' ),
				'is_visible' => array(
					DefaultPaymentGateways::get_rules_for_cbd( false ),
					DefaultPaymentGateways::get_rules_for_countries( self::get_woopay_available_countries() ),
				),
				'sub_title'  => self::get_wcpay_payment_icons(),
			),
			array(
				'id'         => 'woocommerce_payments',
				'title'      => __( 'WooPayments', 'woocommerce' ),
				'content'    => __( 'Payments made simple, with no monthly fees – designed exclusively for WooCommerce stores. Accept credit cards, debit cards, and other popular payment methods.', 'woocommerce' ),
				'image'      => plugins_url( 'assets/images/onboarding/wcpay.svg', WC_PLUGIN_FILE ),
				'plugins'    => array( 'woocommerce-payments' ),
				'is_visible' => array(
					DefaultPaymentGateways::get_rules_for_cbd( false ),
					DefaultPaymentGateways::get_rules_for_countries( DefaultPaymentGateways::get_wcpay_countries() ),
				),
				'sub_title'  => self::get_wcpay_payment_icons(),
			),
		);
	}

	/**
	 * Get the list of WooPay available countries.
	 *
	 * @return array The list of WooPay available countries.
	 */
	private static function get_woopay_available_countries(): array {
		return array( 'US' );
	}

	/**
	 * Get the list of payment icons as HTML img tags.
	 *
	 * @return string Payment icons as HTML img tags.
	 */
	private static function get_wcpay_payment_icons(): string {
		$icons              = array(
			'visa',
			'mastercard',
			'amex',
			'googlepay',
			'applepay',
		);
		$convert_to_img_tag = function ( $icon ) {
			return sprintf(
				'<img class="wcpay-%s-icon wcpay-icon" src="%s" alt="%s">',
				$icon,
				plugins_url( "assets/images/payment-methods/$icon.svg", WC_PLUGIN_FILE ),
				ucfirst( $icon )
			);
		};

		return implode( '', array_map( $convert_to_img_tag, $icons ) );
	}
}
PK     [1]:8    A  Admin/WCPayPromotion/WCPaymentGatewayPreInstallWCPayPromotion.phpnu         <?php
/**
 * Class WCPaymentGatewayPreInstallWCPayPromotion
 *
 * @package WooCommerce\Admin
 */

namespace Automattic\WooCommerce\Internal\Admin\WCPayPromotion;

use Automattic\WooCommerce\Enums\PaymentGatewayFeature;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * A pseudo WCPay gateway class.
 *
 * @extends \WC_Payment_Gateway
 */
class WCPaymentGatewayPreInstallWCPayPromotion extends \WC_Payment_Gateway {

	const GATEWAY_ID = 'pre_install_woocommerce_payments_promotion';

	/**
	 * Constructor
	 */
	public function __construct() {
		$wc_pay_spec = Init::get_wc_pay_promotion_spec();
		if ( ! $wc_pay_spec ) {
			return;
		}
		$this->id           = static::GATEWAY_ID;
		$this->method_title = $wc_pay_spec->title;
		if ( property_exists( $wc_pay_spec, 'sub_title' ) ) {
			$this->title = sprintf( '<span class="gateway-subtitle" >%s</span>', $wc_pay_spec->sub_title );
		}
		$this->method_description = $wc_pay_spec->content;
		$this->has_fields         = false;

		// Set the promotion pseudo-gateway support features.
		// If the promotion spec provides the supports property, use it.
		if ( property_exists( $wc_pay_spec, 'supports' ) ) {
			$this->supports = $wc_pay_spec->supports;
		} else {
			// Otherwise, use the default supported features in line with WooPayments ones.
			// We include all features here, even if some of them are behind settings, since this is for info only.
			$this->supports = array(
				// Regular features.
				PaymentGatewayFeature::PRODUCTS,
				PaymentGatewayFeature::REFUNDS,
				// Subscriptions features.
				PaymentGatewayFeature::SUBSCRIPTIONS,
				PaymentGatewayFeature::MULTIPLE_SUBSCRIPTIONS,
				PaymentGatewayFeature::SUBSCRIPTION_CANCELLATION,
				PaymentGatewayFeature::SUBSCRIPTION_REACTIVATION,
				PaymentGatewayFeature::SUBSCRIPTION_SUSPENSION,
				PaymentGatewayFeature::SUBSCRIPTION_AMOUNT_CHANGES,
				PaymentGatewayFeature::SUBSCRIPTION_DATE_CHANGES,
				PaymentGatewayFeature::SUBSCRIPTION_PAYMENT_METHOD_CHANGE_ADMIN,
				PaymentGatewayFeature::SUBSCRIPTION_PAYMENT_METHOD_CHANGE_CUSTOMER,
				PaymentGatewayFeature::SUBSCRIPTION_PAYMENT_METHOD_CHANGE,
				// Saved cards features.
				PaymentGatewayFeature::TOKENIZATION,
				PaymentGatewayFeature::ADD_PAYMENT_METHOD,
			);
		}

		// Get setting values.
		$this->enabled = false;

		// Load the settings.
		$this->init_form_fields();
		$this->init_settings();
	}

	/**
	 * Initialise Gateway Settings Form Fields.
	 */
	public function init_form_fields() {
		$this->form_fields = array(
			'is_dismissed' => array(
				'title'   => __( 'Dismiss', 'woocommerce' ),
				'type'    => 'checkbox',
				'label'   => __( 'Dismiss the gateway', 'woocommerce' ),
				'default' => 'no',
			),
		);
	}

	/**
	 * Check if the promotional gateway has been dismissed.
	 *
	 * @return bool
	 */
	public static function is_dismissed() {
		$settings = get_option( 'woocommerce_' . self::GATEWAY_ID . '_settings', array() );
		return isset( $settings['is_dismissed'] ) && 'yes' === $settings['is_dismissed'];
	}
}
PK     [1]=#w  w  7  Admin/WCPayPromotion/WCPayPromotionDataSourcePoller.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Admin\WCPayPromotion;

use Automattic\WooCommerce\Admin\RemoteSpecs\DataSourcePoller;
use WC_Helper;

/**
 * Specs data source poller class for WooPayments Promotion.
 */
class WCPayPromotionDataSourcePoller extends DataSourcePoller {

	const ID = 'payment_method_promotion';

	/**
	 * Default data sources array.
	 *
	 * @deprecated since 9.5.0. Use get_data_sources() instead.
	 */
	const DATA_SOURCES = array();

	/**
	 * Class instance.
	 *
	 * @var WCPayPromotionDataSourcePoller instance
	 */
	protected static $instance = null;

	/**
	 * Get class instance.
	 */
	public static function get_instance() {
		if ( ! self::$instance ) {
			self::$instance = new self( self::ID, self::get_data_sources() );
		}
		return self::$instance;
	}

	/**
	 * Get data sources.
	 *
	 * @return array
	 */
	public static function get_data_sources() {
		$data_sources = array(
			WC_Helper::get_woocommerce_com_base_url() . 'wp-json/wccom/payment-gateway-suggestions/2.0/payment-method/promotions.json',
		);

		// Add country query param to data sources.
		$base_location             = wc_get_base_location();
		$data_sources_with_country = array_map(
			function ( $url ) use ( $base_location ) {
				return add_query_arg(
					'country',
					$base_location['country'],
					$url
				);
			},
			$data_sources
		);
		return $data_sources_with_country;
	}
}
PK     [1]#nD\	  	    Admin/CouponsMovedTrait.phpnu         <?php
/**
 * A Trait to help with managing the legacy coupon menu.
 */

namespace Automattic\WooCommerce\Internal\Admin;

use Automattic\WooCommerce\Admin\Features\Features;

/**
 * CouponsMovedTrait trait.
 */
trait CouponsMovedTrait {

	/**
	 * The GET query key for the legacy menu.
	 *
	 * @var string
	 */
	protected static $query_key = 'legacy_coupon_menu';

	/**
	 * The key for storing an option in the DB.
	 *
	 * @var string
	 */
	protected static $option_key = 'wc_admin_show_legacy_coupon_menu';

	/**
	 * Get the URL for the legacy coupon management.
	 *
	 * @return string The unescaped URL for the legacy coupon management page.
	 */
	protected static function get_legacy_coupon_url() {
		return self::get_coupon_url( [ self::$query_key => true ] );
	}

	/**
	 * Get the URL for the coupon management page.
	 *
	 * @param array $args Additional URL query arguments.
	 *
	 * @return string
	 */
	protected static function get_coupon_url( $args = [] ) {
		$args = array_merge(
			[
				'post_type' => 'shop_coupon',
			],
			$args
		);

		return add_query_arg( $args, admin_url( 'edit.php' ) );
	}

	/**
	 * Get the new URL for managing coupons.
	 *
	 * @param string $page The management page.
	 *
	 * @return string
	 */
	protected static function get_management_url( $page ) {
		$path = '';
		switch ( $page ) {
			case 'coupon':
			case 'coupons':
				return self::get_coupon_url();

			case 'marketing':
				$path = self::get_marketing_path();
				break;
		}

		return "wc-admin&path={$path}";
	}

	/**
	 * Get the WC Admin path for the marking page.
	 *
	 * @return string
	 */
	protected static function get_marketing_path() {
		return '/marketing/overview';
	}

	/**
	 * Whether we should display the legacy coupon menu item.
	 *
	 * @return bool
	 */
	protected static function should_display_legacy_menu() {
		/**
		 * Filter to determine whether to display the legacy coupon menu item.
		 *
		 * @since 10.5.0
		 *
		 * @param bool $display Whether the menu should be displayed or not.
		 * @return bool
		 */
		return apply_filters(
			'wc_admin_show_legacy_coupon_menu',
			! Features::is_enabled( 'navigation' )
		);
	}

	/**
	 * Set whether we should display the legacy coupon menu item.
	 *
	 * @deprecated 10.5.0 No longer in use.
	 *
	 * @param bool $display Whether the menu should be displayed or not.
	 */
	protected static function display_legacy_menu( $display = false ) {
		update_option( self::$option_key, $display ? 1 : 0 );
	}
}
PK     [1]B	  B	    Admin/SiteHealth.phpnu         <?php
/**
 * Customize Site Health recommendations for WooCommerce.
 */

namespace Automattic\WooCommerce\Internal\Admin;

defined( 'ABSPATH' ) || exit;

/**
 * SiteHealth class.
 */
class SiteHealth {
	/**
	 * Class instance.
	 *
	 * @var SiteHealth instance
	 */
	protected static $instance = null;

	/**
	 * Get class instance.
	 */
	public static function get_instance() {
		if ( ! self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Hook into WooCommerce.
	 */
	public function __construct() {
		add_filter( 'site_status_should_suggest_persistent_object_cache', array( $this, 'should_suggest_persistent_object_cache' ) );
	}

	/**
	 * Counts specific types of WooCommerce entities to determine if a persistent object cache would be beneficial.
	 *
	 * Note that if all measured WooCommerce entities are below their thresholds, this will return null so that the
	 * other normal WordPress checks will still be run.
	 *
	 * @param true|null $check A non-null value will short-circuit WP's normal tests for this.
	 *
	 * @return true|null True if the store would benefit from a persistent object cache. Otherwise null.
	 */
	public function should_suggest_persistent_object_cache( $check ) {
		// Skip this if some other filter has already determined yes.
		if ( true === $check ) {
			return $check;
		}

		$thresholds = array(
			'orders'   => 100,
			'products' => 100,
		);

		foreach ( $thresholds as $key => $threshold ) {
			try {
				switch ( $key ) {
					case 'orders':
						$orders_query   = new \WC_Order_Query(
							array(
								'status'   => 'any',
								'limit'    => 1,
								'paginate' => true,
								'return'   => 'ids',
							)
						);
						$orders_results = $orders_query->get_orders();
						if ( $orders_results->total >= $threshold ) {
							$check = true;
						}
						break;

					case 'products':
						$products_query   = new \WC_Product_Query(
							array(
								'status'   => 'any',
								'limit'    => 1,
								'paginate' => true,
								'return'   => 'ids',
							)
						);
						$products_results = $products_query->get_products();
						if ( $products_results->total >= $threshold ) {
							$check = true;
						}
						break;
				}
			} catch ( \Exception $exception ) {
				break;
			}

			if ( ! is_null( $check ) ) {
				break;
			}
		}

		return $check;
	}
}
PK     [1]l#/  #/    Admin/Analytics.phpnu         <?php
/**
 * WooCommerce Analytics.
 */

namespace Automattic\WooCommerce\Internal\Admin;

use Automattic\WooCommerce\Admin\API\Reports\Cache;
use Automattic\WooCommerce\Utilities\OrderUtil;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrderStatsDataStore;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;

/**
 * Contains backend logic for the Analytics feature.
 */
class Analytics {
	/**
	 * Option name used to toggle this feature.
	 */
	const TOGGLE_OPTION_NAME = 'woocommerce_analytics_enabled';
	/**
	 * Clear cache tool identifier.
	 */
	const CACHE_TOOL_ID = 'clear_woocommerce_analytics_cache';

	/**
	 * Class instance.
	 *
	 * @var Analytics instance
	 */
	protected static $instance = null;

	/**
	 * Determines if the feature has been toggled on or off.
	 *
	 * @var boolean
	 */
	protected static $is_updated = false;

	/**
	 * Get class instance.
	 */
	public static function get_instance() {
		if ( ! self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Hook into WooCommerce.
	 */
	public function __construct() {
		add_action( 'update_option_' . self::TOGGLE_OPTION_NAME, array( $this, 'reload_page_on_toggle' ), 10, 2 );
		add_action( 'woocommerce_settings_saved', array( $this, 'maybe_reload_page' ) );

		if ( ! Features::is_enabled( 'analytics' ) ) {
			return;
		}

		add_filter( 'woocommerce_component_settings_preload_endpoints', array( $this, 'add_preload_endpoints' ) );
		add_filter( 'woocommerce_admin_get_user_data_fields', array( $this, 'add_user_data_fields' ) );
		add_action( 'admin_menu', array( $this, 'register_pages' ) );
		add_filter( 'woocommerce_debug_tools', array( $this, 'register_cache_clear_tool' ) );
		add_filter( 'woocommerce_debug_tools', array( $this, 'register_regenerate_order_fulfillment_status_tool' ), 12 );
	}

	/**
	 * Add the feature toggle to the features settings.
	 *
	 * @deprecated 7.0 The WooCommerce Admin features are now handled by the WooCommerce features engine (see the FeaturesController class).
	 *
	 * @param array $features Feature sections.
	 * @return array
	 */
	public static function add_feature_toggle( $features ) {
		return $features;
	}

	/**
	 * Reloads the page when the option is toggled to make sure all Analytics features are loaded.
	 *
	 * @param string $old_value Old value.
	 * @param string $value     New value.
	 */
	public static function reload_page_on_toggle( $old_value, $value ) {
		if ( $old_value === $value ) {
			return;
		}

		self::$is_updated = true;
	}

	/**
	 * Reload the page if the setting has been updated.
	 */
	public static function maybe_reload_page() {
		if ( ! isset( $_SERVER['REQUEST_URI'] ) || ! self::$is_updated ) {
			return;
		}

		wp_safe_redirect( wp_unslash( $_SERVER['REQUEST_URI'] ) );
		exit();
	}

	/**
	 * Preload data from the countries endpoint.
	 *
	 * @param array $endpoints Array of preloaded endpoints.
	 * @return array
	 */
	public function add_preload_endpoints( $endpoints ) {
		$screen_id = ( function_exists( 'get_current_screen' ) && get_current_screen() ) ? get_current_screen()->id : '';

		// Only preload endpoints on wc-admin pages.
		if ( 'woocommerce_page_wc-admin' === $screen_id ) {
			$endpoints['performanceIndicators'] = '/wc-analytics/reports/performance-indicators/allowed';
			$endpoints['leaderboards']          = '/wc-analytics/leaderboards/allowed';
		}

		return $endpoints;
	}

	/**
	 * Adds fields so that we can store user preferences for the columns to display on a report.
	 *
	 * @param array $user_data_fields User data fields.
	 * @return array
	 */
	public function add_user_data_fields( $user_data_fields ) {
		return array_merge(
			$user_data_fields,
			array(
				'categories_report_columns',
				'coupons_report_columns',
				'customers_report_columns',
				'orders_report_columns',
				'products_report_columns',
				'revenue_report_columns',
				'taxes_report_columns',
				'variations_report_columns',
				'dashboard_sections',
				'dashboard_chart_type',
				'dashboard_chart_interval',
				'dashboard_leaderboard_rows',
				'order_attribution_install_banner_dismissed',
				'scheduled_updates_promotion_notice_dismissed',
			)
		);
	}

	/**
	 * Register the cache clearing tool on the WooCommerce > Status > Tools page.
	 *
	 * @param array $debug_tools Available debug tool registrations.
	 * @return array Filtered debug tool registrations.
	 */
	public function register_cache_clear_tool( $debug_tools ) {
		$settings_url = add_query_arg(
			array(
				'page' => 'wc-admin',
				'path' => '/analytics/settings',
			),
			get_admin_url( null, 'admin.php' )
		);

		$debug_tools[ self::CACHE_TOOL_ID ] = array(
			'name'     => __( 'Clear analytics cache', 'woocommerce' ),
			'button'   => __( 'Clear', 'woocommerce' ),
			'desc'     => sprintf(
				/* translators: 1: opening link tag, 2: closing tag */
				__( 'This tool will reset the cached values used in WooCommerce Analytics. If numbers still look off, try %1$sReimporting Historical Data%2$s.', 'woocommerce' ),
				'<a href="' . esc_url( $settings_url ) . '">',
				'</a>'
			),
			'callback' => array( $this, 'run_clear_cache_tool' ),
		);

		return $debug_tools;
	}

	/**
	 * Register the regenerate order fulfillment status tool on the WooCommerce > Status > Tools page.
	 *
	 * @param array $debug_tools Available debug tool registrations.
	 * @return array Filtered debug tool registrations.
	 */
	public function register_regenerate_order_fulfillment_status_tool( $debug_tools ) {
		// Check if the fulfillments feature is enabled.
		$container           = wc_get_container();
		$features_controller = $container->get( FeaturesController::class );

		if ( ! $features_controller->feature_is_enabled( 'fulfillments' ) ) {
			return $debug_tools;
		}

		// If the order fulfillment status has already been regenerated, don't register the tool again.
		if ( true === (bool) get_option( 'woocommerce_analytics_order_fulfillment_status_regenerated' ) ) {
			return $debug_tools;
		}

		$debug_tools['regenerate_order_fulfillment_status'] = array(
			'name'     => __( 'Regenerate order fulfillment status for Analytics', 'woocommerce' ),
			'button'   => __( 'Regenerate', 'woocommerce' ),
			'desc'     => __( 'This tool will regenerate the order fulfillment status for all orders and update the Analytics data using a direct SQL query.', 'woocommerce' ),
			'callback' => array( $this, 'run_regenerate_order_fulfillment_status_tool' ),
		);

		return $debug_tools;
	}

	/**
	 * Regenerate order fulfillment status directly using SQL.
	 *
	 * @return string Success message or error message.
	 */
	public function run_regenerate_order_fulfillment_status_tool() {
		global $wpdb;

		// Check if the column exists, create it if not.
		if ( ! OrderStatsDataStore::has_fulfillment_status_column() ) {
			$create_column_result = OrderStatsDataStore::add_fulfillment_status_column();

			if ( true !== $create_column_result ) {
				return sprintf(
					/* translators: %s: error message */
					__( 'Failed to create fulfillment status column: %s', 'woocommerce' ),
					$create_column_result
				);
			}
		}

		$order_stats_table = $wpdb->prefix . 'wc_order_stats';

		// If HPOS is enabled, use the wc_orders_meta table, else use wp_postmeta.
		if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
			$order_meta_table  = OrdersTableDataStore::get_meta_table_name();
			$order_meta_column = 'order_id';
		} else {
			$order_meta_table  = $wpdb->postmeta;
			$order_meta_column = 'post_id';
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$updated = $wpdb->query(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table and column names cannot be prepared.
				"UPDATE {$order_stats_table} os INNER JOIN {$order_meta_table} om ON os.order_id = om.{$order_meta_column}
				SET os.fulfillment_status = CASE
					WHEN om.meta_value = %s THEN NULL
					ELSE om.meta_value
				END
				WHERE om.meta_key = %s",
				'no_fulfillments',
				'_fulfillment_status'
			)
		);

		if ( false === $updated ) {
			return __( 'Failed to update order fulfillment status. Please check the database logs for errors.', 'woocommerce' );
		}

		// Mark as completed.
		update_option( 'woocommerce_analytics_order_fulfillment_status_regenerated', true, false );

		return sprintf(
			/* translators: %d: number of orders updated */
			__( 'Successfully updated fulfillment status for %d orders.', 'woocommerce' ),
			$updated
		);
	}

	/**
	 * Registers report pages.
	 */
	public function register_pages() {
		$report_pages = self::get_report_pages();
		foreach ( $report_pages as $report_page ) {
			if ( ! is_null( $report_page ) ) {
				wc_admin_register_page( $report_page );
			}
		}
	}

	/**
	 * Get report pages.
	 */
	public static function get_report_pages() {
		$overview_page = array(
			'id'       => 'woocommerce-analytics',
			'title'    => __( 'Analytics', 'woocommerce' ),
			'path'     => '/analytics/overview',
			'icon'     => 'dashicons-chart-bar',
			'position' => 57, // After WooCommerce & Product menu items.
		);

		$report_pages = array(
			$overview_page,
			array(
				'id'     => 'woocommerce-analytics-overview',
				'title'  => __( 'Overview', 'woocommerce' ),
				'parent' => 'woocommerce-analytics',
				'path'   => '/analytics/overview',
			),
			array(
				'id'     => 'woocommerce-analytics-products',
				'title'  => __( 'Products', 'woocommerce' ),
				'parent' => 'woocommerce-analytics',
				'path'   => '/analytics/products',
			),
			array(
				'id'     => 'woocommerce-analytics-revenue',
				'title'  => __( 'Revenue', 'woocommerce' ),
				'parent' => 'woocommerce-analytics',
				'path'   => '/analytics/revenue',
			),
			array(
				'id'     => 'woocommerce-analytics-orders',
				'title'  => __( 'Orders', 'woocommerce' ),
				'parent' => 'woocommerce-analytics',
				'path'   => '/analytics/orders',
			),
			array(
				'id'     => 'woocommerce-analytics-variations',
				'title'  => __( 'Variations', 'woocommerce' ),
				'parent' => 'woocommerce-analytics',
				'path'   => '/analytics/variations',
			),
			array(
				'id'     => 'woocommerce-analytics-categories',
				'title'  => __( 'Categories', 'woocommerce' ),
				'parent' => 'woocommerce-analytics',
				'path'   => '/analytics/categories',
			),
			array(
				'id'     => 'woocommerce-analytics-coupons',
				'title'  => __( 'Coupons', 'woocommerce' ),
				'parent' => 'woocommerce-analytics',
				'path'   => '/analytics/coupons',
			),
			array(
				'id'     => 'woocommerce-analytics-taxes',
				'title'  => __( 'Taxes', 'woocommerce' ),
				'parent' => 'woocommerce-analytics',
				'path'   => '/analytics/taxes',
			),
			array(
				'id'     => 'woocommerce-analytics-downloads',
				'title'  => __( 'Downloads', 'woocommerce' ),
				'parent' => 'woocommerce-analytics',
				'path'   => '/analytics/downloads',
			),
			'yes' === get_option( 'woocommerce_manage_stock' ) ? array(
				'id'     => 'woocommerce-analytics-stock',
				'title'  => __( 'Stock', 'woocommerce' ),
				'parent' => 'woocommerce-analytics',
				'path'   => '/analytics/stock',
			) : null,
			array(
				'id'     => 'woocommerce-analytics-customers',
				'title'  => __( 'Customers', 'woocommerce' ),
				'parent' => 'woocommerce',
				'path'   => '/customers',
			),
			array(
				'id'     => 'woocommerce-analytics-settings',
				'title'  => __( 'Settings', 'woocommerce' ),
				'parent' => 'woocommerce-analytics',
				'path'   => '/analytics/settings',
			),
		);

		/**
		 * The analytics report items used in the menu.
		 *
		 * @since 6.4.0
		 */
		return apply_filters( 'woocommerce_analytics_report_menu_items', $report_pages );
	}

	/**
	 * "Clear" analytics cache by invalidating it.
	 */
	public function run_clear_cache_tool() {
		Cache::invalidate();

		return __( 'Analytics cache cleared.', 'woocommerce' );
	}
}
PK     [1]&
  
  "  Admin/Marketing/MarketingSpecs.phpnu         <?php
/**
 * Marketing Specs Handler
 *
 * Fetches the specifications for the marketing feature from WooCommerce.com API.
 */

namespace Automattic\WooCommerce\Internal\Admin\Marketing;

/**
 * Marketing Specifications Class.
 *
 * @internal
 * @since x.x.x
 */
class MarketingSpecs {
	/**
	 * Name of knowledge base post transient.
	 *
	 * @var string
	 */
	const KNOWLEDGE_BASE_TRANSIENT = 'wc_marketing_knowledge_base';

	/**
	 * Load knowledge base posts from WooCommerce.com
	 *
	 * @param string|null $topic The topic of marketing knowledgebase to retrieve.
	 * @return array
	 */
	public function get_knowledge_base_posts( ?string $topic ): array {
		// Default to the marketing topic (if no topic is set on the kb component).
		if ( empty( $topic ) ) {
			$topic = 'marketing';
		}

		$kb_transient = self::KNOWLEDGE_BASE_TRANSIENT . '_' . strtolower( $topic );

		$posts = get_transient( $kb_transient );

		if ( false === $posts ) {
			$request_url = add_query_arg(
				array(
					'page'     => 1,
					'per_page' => 8,
					'_embed'   => 1,
				),
				'https://woocommerce.com/wp-json/wccom/marketing-knowledgebase/v1/posts/' . $topic
			);

			$request = wp_remote_get(
				$request_url,
				array(
					'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ),
				)
			);
			$posts   = array();

			if ( ! is_wp_error( $request ) && 200 === $request['response']['code'] ) {
				$raw_posts = json_decode( $request['body'], true );

				foreach ( $raw_posts as $raw_post ) {
					$post = array(
						'title'         => html_entity_decode( $raw_post['title']['rendered'] ),
						'date'          => $raw_post['date_gmt'],
						'link'          => $raw_post['link'],
						'author_name'   => isset( $raw_post['author_name'] ) ? html_entity_decode( $raw_post['author_name'] ) : '',
						'author_avatar' => isset( $raw_post['author_avatar_url'] ) ? $raw_post['author_avatar_url'] : '',
					);

					$featured_media = isset( $raw_post['_embedded']['wp:featuredmedia'] ) && is_array( $raw_post['_embedded']['wp:featuredmedia'] ) ? $raw_post['_embedded']['wp:featuredmedia'] : array();
					if ( count( $featured_media ) > 0 ) {
						$image         = current( $featured_media );
						$post['image'] = add_query_arg(
							array(
								'resize' => '650,340',
								'crop'   => 1,
							),
							$image['source_url']
						);
					}

					$posts[] = $post;
				}
			}

			set_transient(
				$kb_transient,
				$posts,
				// Expire transient in 15 minutes if remote get failed.
				empty( $posts ) ? 900 : DAY_IN_SECONDS
			);
		}

		return $posts;
	}
}
PK     [1]\O4p  p    Admin/Coupons.phpnu         <?php
/**
 * WooCommerce Marketing > Coupons.
 */

namespace Automattic\WooCommerce\Internal\Admin;

use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\PageController;

/**
 * Contains backend logic for the Coupons feature.
 */
class Coupons {

	use CouponsMovedTrait;

	/**
	 * Class instance.
	 *
	 * @var Coupons instance
	 */
	protected static $instance = null;

	/**
	 * Get class instance.
	 */
	public static function get_instance() {
		if ( ! self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Hook into WooCommerce.
	 */
	public function __construct() {
		if ( ! is_admin() ) {
			return;
		}

		// If the main marketing feature is disabled, don't modify coupon behavior.
		if ( ! Features::is_enabled( 'marketing' ) ) {
			return;
		}

		// Only support coupon modifications if coupons are enabled.
		if ( ! wc_coupons_enabled() ) {
			return;
		}

		add_action( 'admin_enqueue_scripts', array( $this, 'maybe_add_marketing_coupon_script' ) );
		add_action( 'woocommerce_register_post_type_shop_coupon', array( $this, 'move_coupons' ) );
		add_action( 'admin_head', array( $this, 'fix_coupon_menu_highlight' ), 99 );
		add_action( 'admin_menu', array( $this, 'maybe_add_coupon_menu_redirect' ) );
	}

	/**
	 * Maybe add menu item back in original spot to help people transition
	 */
	public function maybe_add_coupon_menu_redirect() {
		if ( ! $this->should_display_legacy_menu() ) {
			return;
		}

		add_submenu_page(
			'woocommerce',
			__( 'Coupons', 'woocommerce' ),
			__( 'Coupons', 'woocommerce' ),
			'manage_options',
			'coupons-moved',
			array( $this, 'coupon_menu_moved' )
		);
	}

	/**
	 * Call back for transition menu item
	 */
	public function coupon_menu_moved() {
		wp_safe_redirect( $this->get_legacy_coupon_url(), 301 );
		exit();
	}

	/**
	 * Modify registered post type shop_coupon
	 *
	 * @param array $args Array of post type parameters.
	 *
	 * @return array the filtered parameters.
	 */
	public function move_coupons( $args ) {
		$args['show_in_menu'] = current_user_can( 'manage_woocommerce' ) ? 'woocommerce-marketing' : true;
		return $args;
	}

	/**
	 * Undo WC modifications to $parent_file for 'shop_coupon'
	 */
	public function fix_coupon_menu_highlight() {
		global $parent_file, $post_type;

		if ( $post_type === 'shop_coupon' ) {
			$parent_file = 'woocommerce-marketing'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride
		}
	}

	/**
	 * Maybe add our wc-admin coupon scripts if viewing coupon pages
	 */
	public function maybe_add_marketing_coupon_script() {
		$curent_screen = PageController::get_instance()->get_current_page();
		if ( ! isset( $curent_screen['id'] ) || $curent_screen['id'] !== 'woocommerce-coupons' ) {
			return;
		}

		WCAdminAssets::register_style( 'marketing-coupons', 'style' );
		WCAdminAssets::register_script( 'wp-admin-scripts', 'marketing-coupons', true );
	}
}
PK     [1]0>^    -  Admin/EmailImprovements/EmailImprovements.phpnu         <?php
/**
 * Helper class to gradually enable email improvements to existing merchants.
 *
 * @since 9.9.0
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\EmailImprovements;

use Automattic\WooCommerce\Utilities\FeaturesUtil;
use WC_Tracker;

defined( 'ABSPATH' ) || exit;

/**
 * EmailImprovements Class.
 */
class EmailImprovements {

	/**
	 * Non-exhaustive list of email customizers.
	 *
	 * @var string[]
	 */
	private const EMAIL_CUSTOMIZERS = array(
		'aco-email-customizer-and-designer-for-woocommerce.php',
		'decorator.php',
		'email-customizer-for-woocommerce.php',
		'email-customizer-pro.php',
		'kadence-woocommerce-email-designer.php',
		'mailpoet.php',
		'wp-html-mail.php',
		'yaymail.php',
	);

	private const EMAIL_TEMPLATE_PARTS = array(
		'email-addresses.php',
		'email-customer-details.php',
		'email-downloads.php',
		'email-footer.php',
		'email-header.php',
		'email-mobile-messaging.php',
		'email-order-details.php',
		'email-order-items.php',
		'email-styles.php',
	);

	/**
	 * Hook into WordPress.
	 */
	public function __construct() {
		add_action( 'admin_init', array( __CLASS__, 'add_email_improvements_modal_to_url' ) );
	}

	/**
	 * Check if any core emails are being overridden by a template override.
	 *
	 * @return bool True if core emails are being overridden, false otherwise.
	 */
	public static function has_email_templates_overridden() {
		$all_template_overrides = WC_Tracker::get_all_template_overrides();
		$core_email_overrides   = self::get_core_email_overrides( $all_template_overrides );
		return count( $core_email_overrides ) > 0;
	}

	/**
	 * Check if any of the email customizers is enabled.
	 *
	 * @return bool True if any of the email customizers is enabled, false otherwise.
	 */
	public static function is_email_customizer_enabled() {
		$all_plugins    = WC_Tracker::get_all_plugins();
		$active_plugins = $all_plugins['active_plugins'];
		$plugin_slugs   = array_map(
			function ( $plugin_path ) {
				$parts = explode( '/', $plugin_path );
				return end( $parts );
			},
			array_keys( $active_plugins )
		);
		return count( array_intersect( self::EMAIL_CUSTOMIZERS, $plugin_slugs ) ) > 0;
	}

	/**
	 * Check if email improvements are enabled for existing stores.
	 *
	 * @return bool True if email improvements are enabled for existing stores, false otherwise.
	 */
	public static function is_email_improvements_enabled_for_existing_stores() {
		$is_feature_enabled             = FeaturesUtil::feature_is_enabled( 'email_improvements' );
		$is_enabled_for_existing_stores = 'yes' === get_option( 'woocommerce_email_improvements_existing_store_enabled' );
		return $is_feature_enabled && $is_enabled_for_existing_stores;
	}

	/**
	 * Check if email improvements should be enabled for existing stores.
	 * - The feature is not already enabled.
	 * - The feature was not manually disabled.
	 * - The email templates are not overridden.
	 * - The email customizer is not enabled.
	 *
	 * @return bool True if email improvements should be enabled for existing stores, false otherwise.
	 */
	public static function should_enable_email_improvements_for_existing_stores() {
		if ( FeaturesUtil::feature_is_enabled( 'email_improvements' ) ) {
			return false;
		}
		$manually_disabled_before = get_option( 'woocommerce_email_improvements_last_disabled_at' );
		if ( $manually_disabled_before ) {
			return false;
		}
		if ( self::has_email_templates_overridden() ) {
			return false;
		}

		if ( self::is_email_customizer_enabled() ) {
			return false;
		}
		// Temporarily paused roll-out to gather more feedback.
		return false;
	}

	/**
	 * Check if we should notice the merchant about email improvements.
	 *
	 * @return bool True if we should notice the merchant about email improvements, false otherwise.
	 */
	public static function should_notify_merchant_about_email_improvements() {
		return ! FeaturesUtil::feature_is_enabled( 'email_improvements' );
	}

	/**
	 * Add email improvements modal parameter to the URL when loading the WooCommerce Home page.
	 *
	 * @return void
	 */
	public static function add_email_improvements_modal_to_url() {
		// Check if we're on the WooCommerce Home page.
		if ( ! isset( $_GET['page'] ) || 'wc-admin' !== $_GET['page'] || isset( $_GET['path'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			return;
		}

		$dismissed_modal = get_option( 'woocommerce_admin_dismissed_email_improvements_modal' );
		if ( 'yes' !== $dismissed_modal && self::is_email_improvements_enabled_for_existing_stores() ) {
			update_option( 'woocommerce_admin_dismissed_email_improvements_modal', 'yes' );
			wp_safe_redirect( add_query_arg( 'emailImprovementsModal', 'enabled' ) );
			exit;
		}

		$dismissed_modal = get_option( 'woocommerce_admin_dismissed_try_email_improvements_modal' );
		if ( 'yes' !== $dismissed_modal && self::should_notify_merchant_about_email_improvements() ) {
			update_option( 'woocommerce_admin_dismissed_try_email_improvements_modal', 'yes' );
			wp_safe_redirect( add_query_arg( 'emailImprovementsModal', 'try' ) );
			exit;
		}
	}

	/**
	 * Get all core emails.
	 *
	 * @return array Core emails.
	 */
	public static function get_core_emails() {
		return array_filter(
			self::get_emails(),
			function ( $email ) {
				return strpos( get_class( $email ), 'WC_Email_' ) === 0 && is_string( $email->template_html );
			}
		);
	}

	/**
	 * Get all core email template overrides.
	 *
	 * @param array $template_overrides All template overrides.
	 * @return array Core email template overrides.
	 */
	public static function get_core_email_overrides( $template_overrides ) {
		$core_emails          = self::get_core_emails();
		$core_email_templates = array_map(
			function ( $email ) {
				return basename( $email->template_html );
			},
			$core_emails
		);
		$all_email_templates  = array_merge( $core_email_templates, self::EMAIL_TEMPLATE_PARTS );
		return array_intersect( $all_email_templates, $template_overrides );
	}

	/**
	 * Get all enabled email IDs.
	 *
	 * @return array Enabled email IDs.
	 */
	public static function get_enabled_emails() {
		$enabled_emails = array_filter(
			self::get_emails(),
			function ( $email ) {
				return $email->is_enabled() && ! $email->is_manual();
			}
		);
		return array_values( array_map( fn( $email ) => get_class( $email ), $enabled_emails ) );
	}

	/**
	 * Get all disabled email IDs.
	 *
	 * @return array Enabled email IDs.
	 */
	public static function get_disabled_emails() {
		$disabled_emails = array_filter(
			self::get_emails(),
			function ( $email ) {
				return ! $email->is_enabled() && ! $email->is_manual();
			}
		);
		return array_values( array_map( fn( $email ) => get_class( $email ), $disabled_emails ) );
	}

	/**
	 * Get all enabled or manual emails with Cc or Bcc.
	 *
	 * @return array Enabled or manual emails with Cc or Bcc.
	 */
	public static function get_enabled_or_manual_emails_with_cc_or_bcc() {
		$enabled_or_manual_emails = array_filter(
			self::get_emails(),
			function ( $email ) {
				return $email->is_enabled() || $email->is_manual();
			}
		);

		$email_ids_with_cc  = array();
		$email_ids_with_bcc = array();

		foreach ( $enabled_or_manual_emails as $email ) {
			if ( $email->get_cc_recipient() ) {
				$email_ids_with_cc[] = get_class( $email );
			}
			if ( $email->get_bcc_recipient() ) {
				$email_ids_with_bcc[] = get_class( $email );
			}
		}

		return array(
			'ccs'  => $email_ids_with_cc,
			'bccs' => $email_ids_with_bcc,
		);
	}

	/**
	 * A helper method to filter out non-WC_Email objects.
	 *
	 * @return \WC_Email[] All WC_Email objects.
	 */
	private static function get_emails() {
		$emails = WC()->mailer()->get_emails();
		return array_filter(
			$emails,
			fn( $email ) => is_object( $email ) && $email instanceof \WC_Email
		);
	}
}
PK     [1][q:  :    Admin/Settings.phpnu         <?php
/**
 * WooCommerce Settings.
 */

namespace Automattic\WooCommerce\Internal\Admin;

use Automattic\WooCommerce\Admin\API\Plugins;
use Automattic\WooCommerce\Admin\API\Reports\Orders\DataStore as OrdersDataStore;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
use Automattic\WooCommerce\Utilities\OrderUtil;
use WC_Marketplace_Suggestions;

/**
 * Contains logic in regards to WooCommerce Admin Settings.
 */
class Settings {

	/**
	 * Class instance.
	 *
	 * @var Settings instance
	 */
	protected static $instance = null;

	/**
	 * Get class instance.
	 */
	public static function get_instance() {
		if ( ! self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Hook into WooCommerce.
	 */
	public function __construct() {
		// Old settings injection.
		add_filter( 'woocommerce_components_settings', array( $this, 'add_component_settings' ) );
		// New settings injection.
		add_filter( 'woocommerce_admin_shared_settings', array( $this, 'add_component_settings' ) );
		add_filter( 'woocommerce_settings_groups', array( $this, 'add_settings_group' ) );
		add_filter( 'woocommerce_settings-wc_admin', array( $this, 'add_settings' ) );
	}

	/**
	 * Format order statuses by removing a leading 'wc-' if present.
	 *
	 * @param array $statuses Order statuses.
	 * @return array formatted statuses.
	 */
	public static function get_order_statuses( $statuses ) {
		$formatted_statuses = array();
		foreach ( $statuses as $key => $value ) {
			$formatted_key                        = preg_replace( '/^wc-/', '', $key );
			$formatted_statuses[ $formatted_key ] = $value;
		}
		return $formatted_statuses;
	}

	/**
	 * Get all order statuses present in analytics tables that aren't registered.
	 *
	 * @return array Unregistered order statuses.
	 */
	private function get_unregistered_order_statuses() {
		$registered_statuses   = wc_get_order_statuses();
		$all_synced_statuses   = OrdersDataStore::get_all_statuses();
		$unregistered_statuses = array_diff( $all_synced_statuses, array_keys( $registered_statuses ) );
		$formatted_status_keys = self::get_order_statuses( array_fill_keys( $unregistered_statuses, '' ) );
		$formatted_statuses    = array_keys( $formatted_status_keys );

		return array_combine( $formatted_statuses, $formatted_statuses );
	}

	/**
	 * Return an object defining the currency options for the site's current currency
	 *
	 * @return  array  Settings for the current currency {
	 *     Array of settings.
	 *
	 *     @type string $code       Currency code.
	 *     @type string $precision  Number of decimals.
	 *     @type string $symbol     Symbol for currency.
	 * }
	 */
	public static function get_currency_settings() {
		$code = get_woocommerce_currency();

		/**
		 * The wc_currency_settings hook
		 *
		 * @since 6.5.0
		 */
		return apply_filters(
			'wc_currency_settings',
			array(
				'code'              => $code,
				'precision'         => wc_get_price_decimals(),
				'symbol'            => html_entity_decode( get_woocommerce_currency_symbol( $code ) ),
				'symbolPosition'    => get_option( 'woocommerce_currency_pos' ),
				'decimalSeparator'  => wc_get_price_decimal_separator(),
				'thousandSeparator' => wc_get_price_thousand_separator(),
				'priceFormat'       => html_entity_decode( get_woocommerce_price_format() ),
			)
		);
	}

	/**
	 * Hooks extra necessary data into the component settings array already set in WooCommerce core.
	 *
	 * @param array $settings Array of component settings.
	 * @return array Array of component settings.
	 */
	public function add_component_settings( $settings ) {
		if ( ! is_admin() ) {
			return $settings;
		}

		if ( ! function_exists( 'wc_blocks_container' ) ) {
			global $wp_locale;
			// inject data not available via older versions of wc_blocks/woo.
			$settings['orderStatuses'] = self::get_order_statuses( wc_get_order_statuses() );
			$settings['stockStatuses'] = self::get_order_statuses( wc_get_product_stock_status_options() );
			$settings['currency']      = self::get_currency_settings();
			$settings['locale']        = array(
				'siteLocale'    => isset( $settings['siteLocale'] )
					? $settings['siteLocale']
					: get_locale(),
				'userLocale'    => isset( $settings['l10n']['userLocale'] )
					? $settings['l10n']['userLocale']
					: get_user_locale(),
				'weekdaysShort' => isset( $settings['l10n']['weekdaysShort'] )
					? $settings['l10n']['weekdaysShort']
					: array_values( $wp_locale->weekday_abbrev ),
			);
		}

		//phpcs:ignore
		$preload_data_endpoints = apply_filters( 'woocommerce_component_settings_preload_endpoints', array() );
		$preload_data_endpoints['jetpackStatus'] = '/jetpack/v4/connection';
		if ( ! empty( $preload_data_endpoints ) ) {
			$preload_data = array_reduce(
				array_values( $preload_data_endpoints ),
				'rest_preload_api_request'
			);
		}

		//phpcs:ignore
		$preload_options = apply_filters( 'woocommerce_admin_preload_options', array() );
		if ( ! empty( $preload_options ) ) {
			foreach ( $preload_options as $option ) {
				$settings['preloadOptions'][ $option ] = get_option( $option );
			}
		}

		//phpcs:ignore
		$preload_settings = apply_filters( 'woocommerce_admin_preload_settings', array() );
		if ( ! empty( $preload_settings ) ) {
			$setting_options = new \WC_REST_Setting_Options_V2_Controller();
			foreach ( $preload_settings as $group ) {
				$group_settings   = $setting_options->get_group_settings( $group );
				$preload_settings = array();
				foreach ( $group_settings as $option ) {
					if ( array_key_exists( 'id', $option ) && array_key_exists( 'value', $option ) ) {
						$preload_settings[ $option['id'] ] = $option['value'];
					}
				}
				$settings['preloadSettings'][ $group ] = $preload_settings;
			}
		}

		$settings['currentUserData']      = WCAdminUser::get_user_data();
		$settings['reviewsEnabled']       = get_option( 'woocommerce_enable_reviews' );
		$settings['manageStock']          = get_option( 'woocommerce_manage_stock' );
		$settings['commentModeration']    = get_option( 'comment_moderation' );
		$settings['notifyLowStockAmount'] = get_option( 'woocommerce_notify_low_stock_amount' );

		/**
		 * Deprecate wcAdminAssetUrl as we no longer need it after The Merge.
		 * Use wcAssetUrl instead.
		 *
		 * @deprecated 6.7.0
		 * @var string
		 */
		$settings['wcAdminAssetUrl'] = WC_ADMIN_IMAGES_FOLDER_URL;
		$settings['wcVersion']       = WC_VERSION;
		$settings['siteUrl']         = site_url();
		$settings['shopUrl']         = get_permalink( wc_get_page_id( 'shop' ) );
		$settings['homeUrl']         = home_url();
		$settings['dateFormat']      = get_option( 'date_format' );
		$settings['timeZone']        = wc_timezone_string();
		$settings['plugins']         = array(
			'installedPlugins' => PluginsHelper::get_installed_plugin_slugs(),
			'activePlugins'    => Plugins::get_active_plugins(),
		);

		// DO NOT use outside of core, these can be removed without deprecation.
		$settings['__experimentalFlags'] = array();

		// Plugins that depend on changing the translation work on the server but not the client -
		// WooCommerce Branding is an example of this - so pass through the translation of
		// 'WooCommerce' to wcSettings.
		$settings['woocommerceTranslation'] = __( 'WooCommerce', 'woocommerce' );

		if ( PageController::is_admin_page() && Features::is_enabled( 'analytics' ) ) {
			// We may have synced orders with a now-unregistered status.
			// E.g. an extension that added statuses is now inactive or removed.
			$settings['unregisteredOrderStatuses'] = $this->get_unregistered_order_statuses();
			$settings['usesNewFullRefundData']     = OrderUtil::uses_new_full_refund_data();
		}

		// The separator used for attributes found in Variation titles.
		//phpcs:ignore
		$settings['variationTitleAttributesSeparator'] = apply_filters( 'woocommerce_product_variation_title_attributes_separator', ' - ', new \WC_Product() );

		if ( ! empty( $preload_data_endpoints ) ) {
			$settings['dataEndpoints'] = isset( $settings['dataEndpoints'] )
				? $settings['dataEndpoints']
				: array();
			foreach ( $preload_data_endpoints as $key => $endpoint ) {
				// Handle error case: rest_do_request() doesn't guarantee success.
				if ( empty( $preload_data[ $endpoint ] ) ) {
					$settings['dataEndpoints'][ $key ] = array();
				} else {
					$settings['dataEndpoints'][ $key ] = $preload_data[ $endpoint ]['body'];
				}
			}
		}
		$settings = $this->get_custom_settings( $settings );
		if ( PageController::is_embed_page() ) {
			$settings['embedBreadcrumbs'] = wc_admin_get_breadcrumbs();
		}

		$settings['allowMarketplaceSuggestions']      = WC_Marketplace_Suggestions::allow_suggestions();
		$settings['connectNonce']                     = wp_create_nonce( 'connect' );
		$settings['wcpay_welcome_page_connect_nonce'] = wp_create_nonce( 'wcpay-connect' );
		$settings['email_preview_nonce']              = wp_create_nonce( 'email-preview-nonce' );
		$settings['email_listing_nonce']              = wp_create_nonce( 'email-listing-nonce' );
		$settings['wc_helper_nonces']                 = array(
			'refresh' => wp_create_nonce( 'refresh' ),
		);

		$settings['features'] = $this->get_features();

		$has_gutenberg     = is_plugin_active( 'gutenberg/gutenberg.php' );
		$gutenberg_version = '';
		if ( $has_gutenberg ) {
			if ( defined( 'GUTENBERG_VERSION' ) ) {
				$gutenberg_version = GUTENBERG_VERSION;
			}

			if ( ! $gutenberg_version ) {
				$gutenberg_data    = get_plugin_data( WP_PLUGIN_DIR . '/gutenberg/gutenberg.php' );
				$gutenberg_version = $gutenberg_data['Version'];
			}
		}
		$settings['gutenberg_version'] = $has_gutenberg ? $gutenberg_version : 0;

		return $settings;
	}

	/**
	 * Removes non-necessary feature properties for the client side.
	 *
	 * @return array
	 */
	public function get_features() {
		$features     = FeaturesUtil::get_features( true, true );
		$new_features = array();

		foreach ( array_keys( $features ) as $feature_id ) {
			$new_features[ $feature_id ] = array(
				'is_enabled'      => $features[ $feature_id ]['is_enabled'],
				'is_experimental' => $features[ $feature_id ]['is_experimental'] ?? false,
			);
		}

		return $new_features;
	}

	/**
	 * Register the admin settings for use in the WC REST API
	 *
	 * @param array $groups Array of setting groups.
	 * @return array
	 */
	public function add_settings_group( $groups ) {
		$groups[] = array(
			'id'          => 'wc_admin',
			'label'       => __( 'WooCommerce Admin', 'woocommerce' ),
			'description' => __( 'Settings for WooCommerce admin reporting.', 'woocommerce' ),
		);
		return $groups;
	}

	/**
	 * Add WC Admin specific settings
	 *
	 * @param array $settings Array of settings in wc admin group.
	 * @return array
	 */
	public function add_settings( $settings ) {
		$unregistered_statuses = $this->get_unregistered_order_statuses();
		$registered_statuses   = self::get_order_statuses( wc_get_order_statuses() );
		$all_statuses          = array_merge( $unregistered_statuses, $registered_statuses );

		$settings[] = array(
			'id'          => 'woocommerce_excluded_report_order_statuses',
			'option_key'  => 'woocommerce_excluded_report_order_statuses',
			'label'       => __( 'Excluded report order statuses', 'woocommerce' ),
			'description' => __( 'Statuses that should not be included when calculating report totals.', 'woocommerce' ),
			'default'     => array( 'pending', 'cancelled', 'failed' ),
			'type'        => 'multiselect',
			'options'     => $all_statuses,
		);
		$settings[] = array(
			'id'          => 'woocommerce_actionable_order_statuses',
			'option_key'  => 'woocommerce_actionable_order_statuses',
			'label'       => __( 'Actionable order statuses', 'woocommerce' ),
			'description' => __( 'Statuses that require extra action on behalf of the store admin.', 'woocommerce' ),
			'default'     => array( 'processing', 'on-hold' ),
			'type'        => 'multiselect',
			'options'     => $all_statuses,
		);
		$settings[] = array(
			'id'          => 'woocommerce_default_date_range',
			'option_key'  => 'woocommerce_default_date_range',
			'label'       => __( 'Default Date Range', 'woocommerce' ),
			'description' => __( 'Default Date Range', 'woocommerce' ),
			'default'     => 'period=month&compare=previous_year',
			'type'        => 'text',
		);
		$settings[] = array(
			'id'          => 'woocommerce_date_type',
			'option_key'  => 'woocommerce_date_type',
			'label'       => __( 'Date Type', 'woocommerce' ),
			'description' => __( 'Database date field considered for Revenue and Orders reports', 'woocommerce' ),
			'type'        => 'select',
			'options'     => array(
				'date_created'   => 'date_created',
				'date_paid'      => 'date_paid',
				'date_completed' => 'date_completed',
			),
		);

		if ( Features::is_enabled( 'analytics-scheduled-import' ) ) {
			$settings[] = array(
				'id'          => 'woocommerce_analytics_scheduled_import',
				'option_key'  => 'woocommerce_analytics_scheduled_import',
				'label'       => __( 'Updates', 'woocommerce' ),
				'description' => __( 'Controls how analytics data is imported from orders.', 'woocommerce' ),
				'type'        => 'radio',
				'default'     => null, // Default to null so we can know if it's a new site or an existing site. New sites will have the option set.
				'options'     => array(
					'yes' => __( 'Scheduled (recommended)', 'woocommerce' ),
					'no'  => __( 'Immediately', 'woocommerce' ),
				),
			);

			// Add hidden setting for the import interval to display in the client side.
			$import_interval = \Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler::get_import_interval();
			$import_interval = absint( $import_interval );
			// Format the import interval to a human-readable string.
			$import_interval_string = human_time_diff( 0, $import_interval );
			$settings[]             = array(
				'id'         => 'woocommerce_analytics_import_interval',
				'option_key' => 'woocommerce_analytics_import_interval',
				'type'       => 'hidden',
				'default'    => $import_interval_string,
			);
		}

		return $settings;
	}

	/**
	 * Gets custom settings used for WC Admin.
	 *
	 * @param array $settings Array of settings to merge into.
	 * @return array
	 */
	private function get_custom_settings( $settings ) {
		$wc_rest_settings_options_controller = new \WC_REST_Setting_Options_Controller();
		$wc_admin_group_settings             = $wc_rest_settings_options_controller->get_group_settings( 'wc_admin' );
		$settings['wcAdminSettings']         = array();

		foreach ( $wc_admin_group_settings as $setting ) {
			if ( ! empty( $setting['id'] ) ) {
				$settings['wcAdminSettings'][ $setting['id'] ] = $setting['value'];
			}
		}
		return $settings;
	}
}
PK     [1]A8f  f    Admin/SystemStatusReport.phpnu         <?php
/**
 * Add additional system status report sections.
 */

namespace Automattic\WooCommerce\Internal\Admin;

use Automattic\WooCommerce\Admin\Notes\Notes;
defined( 'ABSPATH' ) || exit;

/**
 * SystemStatusReport class.
 */
class SystemStatusReport {
	/**
	 * Class instance.
	 *
	 * @var SystemStatus instance
	 */
	protected static $instance = null;

	/**
	 * Get class instance.
	 */
	public static function get_instance() {
		if ( ! self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Hook into WooCommerce.
	 */
	public function __construct() {
		add_action( 'woocommerce_system_status_report', array( $this, 'system_status_report' ) );
	}

	/**
	 * Hooks extra necessary sections into the system status report template
	 */
	public function system_status_report() {
		?>
			<table class="wc_status_table widefat" cellspacing="0">
				<thead>
				<tr>
					<th colspan="5" data-export-label="Admin">
						<h2>
							<?php esc_html_e( 'Admin', 'woocommerce' ); ?><?php echo wc_help_tip( esc_html__( 'This section shows details of WC Admin.', 'woocommerce' ) ); ?>
						</h2>
					</th>
				</tr>
				</thead>
				<tbody>
					<?php
						$this->render_features();
						$this->render_daily_cron();
						$this->render_options();
						$this->render_notes();
						$this->render_onboarding_state();
					?>
				</tbody>
			</table>
		<?php
	}

	/**
	 * Render features rows.
	 */
	public function render_features() {
		/**
		 * Filter the admin feature configs.
		 *
		 * @since 6.5.0
		 */
		$features          = apply_filters( 'woocommerce_admin_get_feature_config', wc_admin_get_feature_config() );
		$enabled_features  = array_filter( $features );
		$disabled_features = array_filter(
			$features,
			function( $feature ) {
				return empty( $feature );
			}
		);

		?>
			<tr>
				<td data-export-label="Enabled Features">
					<?php esc_html_e( 'Enabled Features', 'woocommerce' ); ?>:
				</td>
				<td class="help"><?php echo wc_help_tip( esc_html__( 'Which features are enabled?', 'woocommerce' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped */ ?></td>
				<td>
					<?php
						echo esc_html( implode( ', ', array_keys( $enabled_features ) ) )
					?>
				</td>
			</tr>

			<tr>
				<td data-export-label="Disabled Features">
					<?php esc_html_e( 'Disabled Features', 'woocommerce' ); ?>:
				</td>
				<td class="help"><?php echo wc_help_tip( esc_html__( 'Which features are disabled?', 'woocommerce' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped */ ?></td>
				<td>
					<?php
						echo esc_html( implode( ', ', array_keys( $disabled_features ) ) )
					?>
				</td>
			</tr>
		<?php
	}


	/**
	 * Render daily cron row.
	 */
	public function render_daily_cron() {
		$next_daily_cron = wp_next_scheduled( 'wc_admin_daily' );
		?>
			<tr>
				<td data-export-label="Daily Cron">
					<?php esc_html_e( 'Daily Cron', 'woocommerce' ); ?>:
				</td>
				<td class="help"><?php echo wc_help_tip( esc_html__( 'Is the daily cron job active, when does it next run?', 'woocommerce' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped */ ?></td>
				<td>
					<?php
					if ( empty( $next_daily_cron ) ) {
						echo '<mark class="error"><span class="dashicons dashicons-warning"></span> ' . esc_html__( 'Not scheduled', 'woocommerce' ) . '</mark>';
					} else {
						echo '<mark class="yes"><span class="dashicons dashicons-yes"></span> Next scheduled: ' . esc_html( date_i18n( 'Y-m-d H:i:s P', $next_daily_cron ) ) . '</mark>';
					}
					?>
				</td>
			</tr>
		<?php
	}

	/**
	 * Render option row.
	 */
	public function render_options() {
		$woocommerce_admin_install_timestamp = get_option( 'woocommerce_admin_install_timestamp' );

		$all_options_expected = is_numeric( $woocommerce_admin_install_timestamp )
			&& 0 < (int) $woocommerce_admin_install_timestamp
			&& is_array( get_option( 'woocommerce_onboarding_profile', array() ) );

		?>
			<tr>
				<td data-export-label="Options">
					<?php esc_html_e( 'Options', 'woocommerce' ); ?>:
				</td>
				<td class="help"><?php echo wc_help_tip( esc_html__( 'Do the important options return expected values?', 'woocommerce' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped */ ?></td>
				<td>
					<?php
					if ( $all_options_expected ) {
						echo '<mark class="yes"><span class="dashicons dashicons-yes"></span></mark>';
					} else {
						echo '<mark class="error"><span class="dashicons dashicons-warning"></span> ' . esc_html__( 'Not all expected', 'woocommerce' ) . '</mark>';
					}
					?>
				</td>
			</tr>
		<?php
	}

	/**
	 * Render the notes row.
	 */
	public function render_notes() {
		$notes_count = Notes::get_notes_count();

		?>
			<tr>
				<td data-export-label="Notes">
					<?php esc_html_e( 'Notes', 'woocommerce' ); ?>:
				</td>
				<td class="help"><?php echo wc_help_tip( esc_html__( 'How many notes in the database?', 'woocommerce' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped */ ?></td>
				<td>
					<?php
						echo esc_html( $notes_count )
					?>
				</td>
			</tr>
		<?php
	}

	/**
	 * Render the onboarding state row.
	 */
	public function render_onboarding_state() {
		$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );
		$onboarding_state   = '-';

		if ( isset( $onboarding_profile['skipped'] ) && $onboarding_profile['skipped'] ) {
			$onboarding_state = 'skipped';
		}

		if ( isset( $onboarding_profile['completed'] ) && $onboarding_profile['completed'] ) {
			$onboarding_state = 'completed';
		}

		?>
			<tr>
				<td data-export-label="Onboarding">
					<?php esc_html_e( 'Onboarding', 'woocommerce' ); ?>:
				</td>
				<td class="help"><?php echo wc_help_tip( esc_html__( 'Was onboarding completed or skipped?', 'woocommerce' ) ); /* phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped */ ?></td>
				<td>
					<?php
						echo esc_html( $onboarding_state )
					?>
				</td>
			</tr>
		<?php
	}

}
PK     [1]^ڨ      Admin/WCAdminUser.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Admin;

/**
 * WCAdminUser Class.
 */
class WCAdminUser {

	/**
	 * Class instance.
	 *
	 * @var WCAdminUser instance
	 */
	protected static $instance = null;

	/**
	 * Constructor.
	 */
	public function __construct() {
		add_action( 'rest_api_init', array( $this, 'register_user_data' ) );
	}

	/**
	 * Get class instance.
	 *
	 * @return object Instance.
	 */
	public static function get_instance() {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Registers WooCommerce specific user data to the WordPress user API.
	 */
	public function register_user_data() {
		register_rest_field(
			'user',
			'is_super_admin',
			array(
				'get_callback' => function( $user ) {
					if ( ! isset( $user['id'] ) || 0 === $user['id'] ) {
						return false;
					}

					return is_super_admin( $user['id'] );
				},
				'schema'       => null,
			)
		);
		register_rest_field(
			'user',
			'woocommerce_meta',
			array(
				'get_callback'    => array( $this, 'get_user_data_values' ),
				'update_callback' => array( $this, 'update_user_data_values' ),
				'schema'          => null,
			)
		);
	}

	/**
	 * For all the registered user data fields (  Loader::get_user_data_fields ), fetch the data
	 * for returning via the REST API.
	 *
	 * @param WP_User $user Current user.
	 */
	public function get_user_data_values( $user ) {
		$values = array();
		foreach ( $this->get_user_data_fields() as $field ) {
			$values[ $field ] = self::get_user_data_field( $user['id'], $field );
		}
		return $values;
	}

	/**
	 * For all the registered user data fields ( Loader::get_user_data_fields ), update the data
	 * for the REST API.
	 *
	 * @param array   $values   The new values for the meta.
	 * @param WP_User $user     The current user.
	 * @param string  $field_id The field id for the user meta.
	 */
	public function update_user_data_values( $values, $user, $field_id ) {
		if ( empty( $values ) || ! is_array( $values ) || 'woocommerce_meta' !== $field_id ) {
			return;
		}
		$fields  = $this->get_user_data_fields();
		$updates = array();
		foreach ( $values as $field => $value ) {
			if ( in_array( $field, $fields, true ) ) {
				$updates[ $field ] = $value;
				self::update_user_data_field( $user->ID, $field, $value );
			}
		}
		return $updates;
	}

	/**
	 * We store some WooCommerce specific user meta attached to users endpoint,
	 * so that we can track certain preferences or values such as the inbox activity panel last open time.
	 * Additional fields can be added in the function below, and then used via wc-admin's currentUser data.
	 *
	 * @return array Fields to expose over the WP user endpoint.
	 */
	public function get_user_data_fields() {
		/**
		 * Filter user data fields exposed over the WordPress user endpoint.
		 *
		 * @since 4.0.0
		 * @param array $fields Array of fields to expose over the WP user endpoint.
		 */
		return apply_filters( 'woocommerce_admin_get_user_data_fields', array( 'variable_product_tour_shown' ) );
	}

	/**
	 * Helper to update user data fields.
	 *
	 * @param int    $user_id  User ID.
	 * @param string $field Field name.
	 * @param mixed  $value  Field value.
	 */
	public static function update_user_data_field( $user_id, $field, $value ) {
		update_user_meta( $user_id, 'woocommerce_admin_' . $field, $value );
	}

	/**
	 * Helper to retrieve user data fields.
	 *
	 * Migrates old key prefixes as well.
	 *
	 * @param int    $user_id  User ID.
	 * @param string $field Field name.
	 * @return mixed The user field value.
	 */
	public static function get_user_data_field( $user_id, $field ) {
		$meta_value = get_user_meta( $user_id, 'woocommerce_admin_' . $field, true );

		// Migrate old meta values (prefix changed from `wc_admin_` to `woocommerce_admin_`).
		if ( '' === $meta_value ) {
			$old_meta_value = get_user_meta( $user_id, 'wc_admin_' . $field, true );

			if ( '' !== $old_meta_value ) {
				self::update_user_data_field( $user_id, $field, $old_meta_value );
				delete_user_meta( $user_id, 'wc_admin_' . $field );

				$meta_value = $old_meta_value;
			}
		}

		return $meta_value;
	}

	/**
	 * Get the current user data.
	 *
	 * @return array User data.
	 */
	public static function get_user_data() {
		$user_controller = new \WP_REST_Users_Controller();
		$request         = new \WP_REST_Request();
		$request->set_query_params( array( 'context' => 'edit' ) );
		$user_response     = $user_controller->get_current_item( $request );
		$current_user_data = is_wp_error( $user_response ) ? (object) array() : $user_response->get_data();
		$current_user_data = self::filter_user_capabilities( $current_user_data );

		return $current_user_data;
	}

	/**
	 * Filter user capabilities to respect file modification restrictions.
	 *
	 * @param array $user_data User data.
	 * @return array Filtered user data.
	 */
	private static function filter_user_capabilities( $user_data ) {
		if ( ! is_array( $user_data ) || ! isset( $user_data['capabilities'] ) ) {
			return $user_data;
		}

		// If the user has install_plugins capability, check if file modifications are allowed.
		if ( isset( $user_data['capabilities']->install_plugins ) && $user_data['capabilities']->install_plugins ) {
			$user_data['capabilities']->install_plugins = wp_is_file_mod_allowed( 'woocommerce' );
		}

		return $user_data;
	}
}
PK     [1]/DQ  Q    Admin/WcPayWelcomePage.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin;

use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Internal\Admin\Suggestions\PaymentsExtensionSuggestionIncentives;
use Automattic\WooCommerce\Internal\Admin\Suggestions\PaymentsExtensionSuggestions;
use Automattic\WooCommerce\Utilities\FeaturesUtil;

/**
 * Class WCPayWelcomePage
 *
 * @deprecated 9.9.0 The WooPayments welcome page is deprecated and will be removed in a future version of WooCommerce.
 */
class WcPayWelcomePage {
	/**
	 * The incentive type for the WooPayments welcome page.
	 */
	const INCENTIVE_TYPE = 'welcome_page';

	/**
	 * The suggestion incentives instance.
	 *
	 * @var PaymentsExtensionSuggestionIncentives
	 */
	private PaymentsExtensionSuggestionIncentives $suggestion_incentives;

	/**
	 * Class instance.
	 *
	 * @var ?WcPayWelcomePage
	 */
	protected static ?WcPayWelcomePage $instance = null;

	/**
	 * Get class instance.
	 *
	 * @return ?WcPayWelcomePage
	 */
	public static function instance(): ?WcPayWelcomePage {
		self::$instance = is_null( self::$instance ) ? new self() : self::$instance;

		return self::$instance;
	}

	/**
	 * WCPayWelcomePage constructor.
	 */
	public function __construct() {
		$this->suggestion_incentives = wc_get_container()->get( PaymentsExtensionSuggestionIncentives::class );
	}

	/**
	 * Check if we have an incentive available to show.
	 *
	 * @param bool $skip_wcpay_active Whether to skip the check for the WooPayments plugin being active.
	 *
	 * @return bool Whether we have an incentive available to show.
	 */
	public function has_incentive( bool $skip_wcpay_active = false ): bool {
		// The WooPayments plugin must not be active.
		if ( ! $skip_wcpay_active && $this->is_wcpay_active() ) {
			return false;
		}

		// Suggestions not disabled via a setting.
		if ( get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) === 'no' ) {
			return false;
		}

		/**
		 * Filter allow marketplace suggestions.
		 *
		 * User can disable all suggestions via filter.
		 *
		 * @since 3.6.0
		 */
		if ( ! apply_filters( 'woocommerce_allow_marketplace_suggestions', true ) ) {
			return false;
		}

		$incentive = $this->get_incentive();
		if ( empty( $incentive ) ) {
			return false;
		}

		if ( $this->is_incentive_dismissed( $incentive ) ) {
			return false;
		}

		return $this->suggestion_incentives->is_incentive_visible(
			$incentive['id'],
			PaymentsExtensionSuggestions::WOOPAYMENTS,
			WC()->countries->get_base_country(),
			$skip_wcpay_active
		);
	}

	/**
	 * Get the WooPayments incentive details, if available.
	 *
	 * @return array|null The incentive details. Null if there is no incentive available.
	 */
	private function get_incentive(): ?array {
		return $this->suggestion_incentives->get_incentive(
			PaymentsExtensionSuggestions::WOOPAYMENTS,
			WC()->countries->get_base_country(),
			self::INCENTIVE_TYPE,
			true
		);
	}

	/**
	 * Check if the WooPayments plugin is active.
	 *
	 * @return boolean
	 */
	private function is_wcpay_active(): bool {
		return class_exists( '\WC_Payments' );
	}

	/**
	 * Check if the current incentive has been manually dismissed.
	 *
	 * @param array $incentive The incentive details.
	 *
	 * @return boolean
	 */
	private function is_incentive_dismissed( array $incentive ): bool {
		/*
		 * First, check the legacy option.
		 */
		$dismissed_incentives = get_option( 'wcpay_welcome_page_incentives_dismissed', array() );
		if ( ! empty( $dismissed_incentives ) ) {
			// Search the incentive ID in the dismissed incentives list.
			if ( in_array( $incentive['id'], $dismissed_incentives, true ) ) {
				return true;
			}
		}

		/*
		 * Second, use the new logic.
		 */
		return $this->suggestion_incentives->is_incentive_dismissed(
			$incentive['id'],
			PaymentsExtensionSuggestions::WOOPAYMENTS,
			'wc_payments_task'
		);
	}

	/**
	 * Get the slug of the active payments task.
	 *
	 * It can be either 'woocommerce-payments' or 'payments'.
	 *
	 * @return string Either 'woocommerce-payments' or 'payments'. Empty string if no task is found.
	 */
	private function get_active_payments_task_slug(): string {
		$setup_task_list    = TaskLists::get_list( 'setup' );
		$extended_task_list = TaskLists::get_list( 'extended' );

		// The task pages are not available if the task lists don't exist or are not visible.
		// Bail early if we have no task to work with.
		if (
			( empty( $setup_task_list ) || ! $setup_task_list->is_visible() ) &&
			( empty( $extended_task_list ) || ! $extended_task_list->is_visible() )
		) {
			return '';
		}

		// The Payments task in the setup task list.
		if ( ! empty( $setup_task_list ) && $setup_task_list->is_visible() ) {
			$payments_task = $setup_task_list->get_task( 'payments' );
			if ( ! empty( $payments_task ) && $payments_task->can_view() ) {
				return 'payments';
			}
		}

		// The Additional Payments task in the extended task list.
		if ( ! empty( $extended_task_list ) && $extended_task_list->is_visible() ) {
			$payments_task = $extended_task_list->get_task( 'payments' );
			if ( ! empty( $payments_task ) && $payments_task->can_view() ) {
				return 'payments';
			}
		}

		// The WooPayments task in the setup task list.
		if ( ! empty( $setup_task_list ) && $setup_task_list->is_visible() ) {
			$payments_task = $setup_task_list->get_task( 'woocommerce-payments' );
			if ( ! empty( $payments_task ) && $payments_task->can_view() ) {
				return 'woocommerce-payments';
			}
		}

		return '';
	}

	/**
	 * Get the WooCommerce setup task list Payments task instance.
	 *
	 * @return Task|null The Payments task instance. null if the task is not found.
	 */
	private function get_payments_task(): ?Task {
		$task_list = TaskLists::get_list( 'setup' );
		if ( empty( $task_list ) ) {
			return null;
		}

		$payments_task = $task_list->get_task( 'payments' );
		if ( empty( $payments_task ) ) {
			return null;
		}

		return $payments_task;
	}

	/**
	 * Determine if the WooCommerce setup task list Payments task is complete.
	 *
	 * @return bool True if the Payments task is complete, false otherwise.
	 */
	private function is_payments_task_complete(): bool {
		$payments_task = $this->get_payments_task();

		return ! empty( $payments_task ) && $payments_task->is_complete();
	}
}
PK     [1]ZOP  P    Admin/WCAdminSharedSettings.phpnu         <?php
/**
 * Manages the WC Admin settings that need to be pre-loaded.
 */

namespace Automattic\WooCommerce\Internal\Admin;

use Automattic\WooCommerce\Admin\PageController;

defined( 'ABSPATH' ) || exit;

/**
 * \Automattic\WooCommerce\Internal\Admin\WCAdminSharedSettings class.
 */
class WCAdminSharedSettings {
	/**
	 * Settings prefix used for the window.wcSettings object.
	 *
	 * @var string
	 */
	private $settings_prefix = 'admin';

	/**
	 * Class instance.
	 *
	 * @var WCAdminSharedSettings instance
	 */
	protected static $instance = null;

	/**
	 * Hook into WooCommerce Blocks.
	 */
	protected function __construct() {
		if ( did_action( 'woocommerce_blocks_loaded' ) ) {
			$this->on_woocommerce_blocks_loaded();
		} else {
			add_action( 'woocommerce_blocks_loaded', array( $this, 'on_woocommerce_blocks_loaded' ), 10 );
		}
	}

	/**
	 * Get class instance.
	 *
	 * @return object Instance.
	 */
	public static function get_instance() {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Adds settings to the Blocks AssetDataRegistry when woocommerce_blocks is loaded.
	 *
	 * @return void
	 */
	public function on_woocommerce_blocks_loaded() {
		// Ensure we only add admin settings on the admin.
		if ( ! is_admin() ) {
			return;
		}

		if ( class_exists( '\Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry' ) ) {
			\Automattic\WooCommerce\Blocks\Package::container()->get( \Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry::class )->add(
				$this->settings_prefix,
				function () {
					/**
					 * Filters the shared settings that are passed to the client.
					 *
					 * @since 6.4.0
					 */
					return apply_filters( 'woocommerce_admin_shared_settings', array() );
				}
			);

			add_action(
				'admin_enqueue_scripts',
				function () {
					if ( ! PageController::is_admin_or_embed_page() ) {
						return;
					}
					// Enqueue deprecation scripts (client/wp-admin-scripts/wcsettings-deprecation/index.js).
					WCAdminAssets::register_script( 'wp-admin-scripts', 'wcsettings-deprecation', true );
				}
			);
		}
	}
}
PK     [1]kh]L  L    Admin/Loader.phpnu         <?php
/**
 * Register the scripts, styles, and includes needed for pieces of the WooCommerce Admin experience.
 */

namespace Automattic\WooCommerce\Internal\Admin;

use Automattic\WooCommerce\Admin\API\Reports\Orders\DataStore as OrdersDataStore;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Internal\Admin\ProductReviews\Reviews;
use Automattic\WooCommerce\Internal\Admin\ProductReviews\ReviewsCommentsOverrides;

/**
 * Loader Class.
 */
class Loader {
	/**
	 * Class instance.
	 *
	 * @var Loader instance
	 */
	protected static $instance = null;

	/**
	 * An array of classes to load from the includes folder.
	 *
	 * @var array
	 */
	protected static $classes = array();

	/**
	 * WordPress capability required to use analytics features.
	 *
	 * @var string
	 */
	protected static $required_capability = null;

	/**
	 * An array of dependencies that have been preloaded (to avoid duplicates).
	 *
	 * @var array
	 */
	protected $preloaded_dependencies = array(
		'script' => array(),
		'style'  => array(),
	);

	/**
	 * Get class instance.
	 */
	public static function get_instance() {
		if ( ! self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Constructor.
	 * Hooks added here should be removed in `wc_admin_initialize` via the feature plugin.
	 */
	public function __construct() {
		Features::get_instance();
		WCAdminSharedSettings::get_instance();
		Translations::get_instance();
		WCAdminUser::get_instance();
		Settings::get_instance();
		SiteHealth::get_instance();
		SystemStatusReport::get_instance();

		wc_get_container()->get( Reviews::class );
		wc_get_container()->get( ReviewsCommentsOverrides::class );

		add_filter( 'admin_body_class', array( __CLASS__, 'add_admin_body_classes' ) );
		add_filter( 'admin_title', array( __CLASS__, 'update_admin_title' ) );
		add_action( 'in_admin_header', array( __CLASS__, 'embed_page_header' ) );
		add_action( 'admin_head', array( __CLASS__, 'remove_notices' ) );
		add_action( 'admin_head', array( __CLASS__, 'smart_app_banner' ) );
		add_action( 'admin_notices', array( __CLASS__, 'inject_before_notices' ), -9999 );
		add_action( 'admin_notices', array( __CLASS__, 'inject_after_notices' ), PHP_INT_MAX );

		// Added this hook to delete the field woocommerce_onboarding_homepage_post_id when deleting the homepage.
		add_action( 'trashed_post', array( __CLASS__, 'delete_homepage' ) );

		/*
		* Remove the emoji script as it always defaults to replacing emojis with Twemoji images.
		* Gutenberg has also disabled emojis. More on that here -> https://github.com/WordPress/gutenberg/pull/6151
		*/
		remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );

		add_action( 'load-themes.php', array( __CLASS__, 'add_appearance_theme_view_tracks_event' ) );
	}

	/**
	 * Returns breadcrumbs for the current page.
	 */
	private static function get_embed_breadcrumbs() {
		return wc_admin_get_breadcrumbs();
	}

	/**
	 * Outputs breadcrumbs via PHP for the initial load of an embedded page.
	 *
	 * @param array $section Section to create breadcrumb from.
	 */
	private static function output_heading( $section ) {
		echo esc_html( $section );
	}

	/**
	 * Set up a div for the header embed to render into.
	 * The initial contents here are meant as a place loader for when the PHP page initially loads.
	 */
	public static function embed_page_header() {
		if ( ! PageController::is_admin_page() && ! PageController::is_embed_page() ) {
			return;
		}

		if ( ! PageController::is_embed_page() ) {
			return;
		}

		if ( PageController::is_modern_settings_page() ) {
			return;
		}

		$sections = self::get_embed_breadcrumbs();
		$sections = is_array( $sections ) ? $sections : array( $sections );

		$page_title      = '';
		$pages_with_tabs = array(
			'admin.php?page=wc-settings',
			'admin.php?page=wc-reports',
			'admin.php?page=wc-status',
		);

		if (
			count( $sections ) > 2 &&
			is_array( $sections[1] ) &&
			in_array( $sections[1][0], $pages_with_tabs, true )
		) {
			$page_title = $sections[1][1];
		} else {
			$page_title = end( $sections );
		}
		?>
		<div id="woocommerce-embedded-root" class="is-embed-loading">
			<div class="woocommerce-layout">
				<div class="woocommerce-layout__header is-embed-loading">
					<h1 class="woocommerce-layout__header-heading">
						<?php self::output_heading( $page_title ); ?>
					</h1>
				</div>
			</div>
		</div>
		<?php
	}

	/**
	 * Adds body classes to the main wp-admin wrapper, allowing us to better target elements in specific scenarios.
	 *
	 * @param string $admin_body_class Body class to add.
	 */
	public static function add_admin_body_classes( $admin_body_class = '' ) {
		if ( ! PageController::is_admin_or_embed_page() || PageController::is_modern_settings_page() ) {
			return $admin_body_class;
		}

		$classes   = explode( ' ', trim( $admin_body_class ) );
		$classes[] = 'woocommerce-admin-page';
		if ( PageController::is_embed_page() ) {
			$classes[] = 'woocommerce-embed-page';
		}

		// Add page ID as a class.
		$page_id = PageController::get_instance()->get_current_screen_id();
		if ( $page_id ) {
			$classes[] = $page_id;
		}

		/**
		 * Some routes or features like onboarding hide the wp-admin navigation and masterbar.
		 * Setting `woocommerce_admin_is_loading` to true allows us to premeptively hide these
		 * elements while the JS app loads.
		 * This class needs to be removed by those feature components (like <ProfileWizard />).
		 *
		 * @param bool $is_loading If WooCommerce Admin is loading a fullscreen view.
		 * @since 6.5.0
		 */
		$is_loading = apply_filters( 'woocommerce_admin_is_loading', false );

		if ( PageController::is_admin_page() && $is_loading ) {
			$classes[] = 'woocommerce-admin-is-loading';
		}

		$admin_body_class = implode( ' ', array_unique( $classes ) );
		return " $admin_body_class ";
	}

	/**
	 * Adds an iOS "Smart App Banner" for display on iOS Safari.
	 * See https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/PromotingAppswithAppBanners/PromotingAppswithAppBanners.html
	 */
	public static function smart_app_banner() {
		$exclude_paths = array(
			'/customize-store',
			'/setup-wizard',
			'/launch-your-store',
		);

		/* phpcs:ignore */
		$path = $_GET['path'] ?? '';

		if ( PageController::is_admin_or_embed_page() && ! in_array( $path, $exclude_paths, true ) ) {
			echo "
				<meta name='apple-itunes-app' content='app-id=1389130815'>
			";
		}
	}


	/**
	 * Removes notices that should not be displayed on WC Admin pages.
	 */
	public static function remove_notices() {
		if ( ! PageController::is_admin_or_embed_page() ) {
			return;
		}

		// Hello Dolly.
		if ( function_exists( 'hello_dolly' ) ) {
			remove_action( 'admin_notices', 'hello_dolly' );
		}
	}

	/**
	 * Runs before admin notices action and hides them.
	 */
	public static function inject_before_notices() {
		if ( ! PageController::is_admin_or_embed_page() ) {
			return;
		}

		// The JITMs won't be shown in the Onboarding Wizard.
		$is_onboarding   = isset( $_GET['path'] ) && '/setup-wizard' === wc_clean( wp_unslash( $_GET['path'] ) ); // phpcs:ignore WordPress.Security.NonceVerification
		$maybe_hide_jitm = $is_onboarding ? '-hide' : '';

		echo '<div class="woocommerce-layout__jitm' . sanitize_html_class( $maybe_hide_jitm ) . '" id="jp-admin-notices"></div>';

		// Wrap the notices in a hidden div to prevent flickering before
		// they are moved elsewhere in the page by WordPress Core.
		echo '<div class="woocommerce-layout__notice-list-hide" id="wp__notice-list">';

		if ( PageController::is_admin_page() ) {
			// Capture all notices and hide them. WordPress Core looks for
			// `.wp-header-end` and appends notices after it if found.
			// https://github.com/WordPress/WordPress/blob/f6a37e7d39e2534d05b9e542045174498edfe536/wp-admin/js/common.js#L737 .
			echo '<div class="wp-header-end" id="woocommerce-layout__notice-catcher"></div>';
		}
	}

	/**
	 * Runs after admin notices and closes div.
	 */
	public static function inject_after_notices() {
		if ( ! PageController::is_admin_or_embed_page() ) {
			return;
		}

		// Close the hidden div used to prevent notices from flickering before
		// they are inserted elsewhere in the page.
		echo '</div>';
	}

	/**
	 * Edits Admin title based on section of wc-admin.
	 *
	 * @param string $admin_title Modifies admin title.
	 * @todo Can we do some URL rewriting so we can figure out which page they are on server side?
	 */
	public static function update_admin_title( $admin_title ) {
		if (
			! did_action( 'current_screen' ) ||
			! PageController::is_admin_page()
		) {
			return $admin_title;
		}

		$sections = self::get_embed_breadcrumbs();
		$pieces   = array();

		foreach ( $sections as $section ) {
			$pieces[] = is_array( $section ) ? $section[1] : $section;
		}

		$pieces = array_reverse( $pieces );
		$title  = implode( ' &lsaquo; ', $pieces );

		/* translators: %1$s: updated title, %2$s: blog info name */
		return sprintf( __( '%1$s &lsaquo; %2$s', 'woocommerce' ), $title, get_bloginfo( 'name' ) );
	}

	/**
	 * Set up a div for the app to render into.
	 */
	public static function page_wrapper() {
		?>
		<div class="wrap">
			<div id="root"></div>
		</div>
		<?php
	}

	/**
	 * Hooks extra necessary data into the component settings array already set in WooCommerce core.
	 *
	 * @param array $settings Array of component settings.
	 * @return array Array of component settings.
	 */
	public static function add_component_settings( $settings ) {
		if ( ! is_admin() ) {
			return $settings;
		}

		if ( ! function_exists( 'wc_blocks_container' ) ) {
			global $wp_locale;
			// inject data not available via older versions of wc_blocks/woo.
			$settings['orderStatuses'] = Settings::get_order_statuses( wc_get_order_statuses() );
			$settings['stockStatuses'] = Settings::get_order_statuses( wc_get_product_stock_status_options() );
			$settings['currency']      = Settings::get_currency_settings();
			$settings['locale']        = array(
				'siteLocale'    => isset( $settings['siteLocale'] )
					? $settings['siteLocale']
					: get_locale(),
				'userLocale'    => isset( $settings['l10n']['userLocale'] )
					? $settings['l10n']['userLocale']
					: get_user_locale(),
				'weekdaysShort' => isset( $settings['l10n']['weekdaysShort'] )
					? $settings['l10n']['weekdaysShort']
					: array_values( $wp_locale->weekday_abbrev ),
			);
		}

		/**
		 * The woocommerce_component_settings_preload_endpoints filter
		 *
		 * @since 6.5.0
		 */
		$preload_data_endpoints = apply_filters( 'woocommerce_component_settings_preload_endpoints', array() );

		$preload_data_endpoints['jetpackStatus'] = '/jetpack/v4/connection';
		if ( ! empty( $preload_data_endpoints ) ) {
			$preload_data = array_reduce(
				array_values( $preload_data_endpoints ),
				'rest_preload_api_request'
			);
		}

		/**
		 * The woocommerce_admin_preload_options filter
		 *
		 * @since 6.5.0
		 */
		$preload_options = apply_filters( 'woocommerce_admin_preload_options', array() );
		if ( ! empty( $preload_options ) ) {
			foreach ( $preload_options as $option ) {
				$settings['preloadOptions'][ $option ] = get_option( $option );
			}
		}

		/**
		 * The woocommerce_admin_preload_settings filter
		 *
		 * @since 6.5.0
		 */
		$preload_settings = apply_filters( 'woocommerce_admin_preload_settings', array() );
		if ( ! empty( $preload_settings ) ) {
			$setting_options = new \WC_REST_Setting_Options_V2_Controller();
			foreach ( $preload_settings as $group ) {
				$group_settings   = $setting_options->get_group_settings( $group );
				$preload_settings = array();
				foreach ( $group_settings as $option ) {
					if ( array_key_exists( 'id', $option ) && array_key_exists( 'value', $option ) ) {
						$preload_settings[ $option['id'] ] = $option['value'];
					}
				}
				$settings['preloadSettings'][ $group ] = $preload_settings;
			}
		}

		$user_controller = new \WP_REST_Users_Controller();
		$request         = new \WP_REST_Request();
		$request->set_query_params( array( 'context' => 'edit' ) );
		$user_response     = $user_controller->get_current_item( $request );
		$current_user_data = is_wp_error( $user_response ) ? (object) array() : $user_response->get_data();

		$settings['currentUserData']      = $current_user_data;
		$settings['reviewsEnabled']       = get_option( 'woocommerce_enable_reviews' );
		$settings['manageStock']          = get_option( 'woocommerce_manage_stock' );
		$settings['commentModeration']    = get_option( 'comment_moderation' );
		$settings['notifyLowStockAmount'] = get_option( 'woocommerce_notify_low_stock_amount' );
		// @todo On merge, once plugin images are added to core WooCommerce, `wcAdminAssetUrl` can be retired,
		// and `wcAssetUrl` can be used in its place throughout the codebase.
		$settings['wcAdminAssetUrl'] = WC_ADMIN_IMAGES_FOLDER_URL;
		$settings['wcVersion']       = WC_VERSION;
		$settings['siteUrl']         = site_url();
		$settings['shopUrl']         = get_permalink( wc_get_page_id( 'shop' ) );
		$settings['homeUrl']         = home_url();
		$settings['dateFormat']      = get_option( 'date_format' );
		$settings['timeZone']        = wc_timezone_string();
		$settings['plugins']         = array(
			'installedPlugins' => PluginsHelper::get_installed_plugin_slugs(),
			'activePlugins'    => Plugins::get_active_plugins(),
		);
		// Plugins that depend on changing the translation work on the server but not the client -
		// WooCommerce Branding is an example of this - so pass through the translation of
		// 'WooCommerce' to wcSettings.
		$settings['woocommerceTranslation'] = __( 'WooCommerce', 'woocommerce' );
		// We may have synced orders with a now-unregistered status.
		// E.g An extension that added statuses is now inactive or removed.
		$settings['unregisteredOrderStatuses'] = self::get_unregistered_order_statuses();
		// The separator used for attributes found in Variation titles.
		/* phpcs:ignore */
		$settings['variationTitleAttributesSeparator'] = apply_filters( 'woocommerce_product_variation_title_attributes_separator', ' - ', new \WC_Product() );

		if ( ! empty( $preload_data_endpoints ) ) {
			$settings['dataEndpoints'] = isset( $settings['dataEndpoints'] )
				? $settings['dataEndpoints']
				: array();
			foreach ( $preload_data_endpoints as $key => $endpoint ) {
				// Handle error case: rest_do_request() doesn't guarantee success.
				if ( empty( $preload_data[ $endpoint ] ) ) {
					$settings['dataEndpoints'][ $key ] = array();
				} else {
					$settings['dataEndpoints'][ $key ] = $preload_data[ $endpoint ]['body'];
				}
			}
		}
		$settings = self::get_custom_settings( $settings );
		if ( PageController::is_embed_page() ) {
			$settings['embedBreadcrumbs'] = self::get_embed_breadcrumbs();
		}

		$settings['allowMarketplaceSuggestions']      = WC_Marketplace_Suggestions::allow_suggestions();
		$settings['connectNonce']                     = wp_create_nonce( 'connect' );
		$settings['wcpay_welcome_page_connect_nonce'] = wp_create_nonce( 'wcpay-connect' );

		return $settings;
	}

	/**
	 * Format order statuses by removing a leading 'wc-' if present.
	 *
	 * @param array $statuses Order statuses.
	 * @return array formatted statuses.
	 *
	 * @deprecated migrate to \Automattic\WooCommerce\Internal\Admin\Settings instead.
	 */
	public static function get_order_statuses( $statuses ) {
		wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.9.0', '\Automattic\WooCommerce\Internal\Admin\Settings::get_order_statuses' );

		return Settings::get_order_statuses( $statuses );
	}

	/**
	 * Get all order statuses present in analytics tables that aren't registered.
	 *
	 * @return array Unregistered order statuses.
	 *
	 * @deprecated migrate to \Automattic\WooCommerce\Internal\Admin\Settings instead.
	 */
	public static function get_unregistered_order_statuses() {
		wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.9.0' );

		$registered_statuses   = wc_get_order_statuses();
		$all_synced_statuses   = OrdersDataStore::get_all_statuses();
		$unregistered_statuses = array_diff( $all_synced_statuses, array_keys( $registered_statuses ) );
		$formatted_status_keys = Settings::get_order_statuses( array_fill_keys( $unregistered_statuses, '' ) );
		$formatted_statuses    = array_keys( $formatted_status_keys );

		return array_combine( $formatted_statuses, $formatted_statuses );
	}

	/**
	 * Register the admin settings for use in the WC REST API
	 *
	 * @param array $groups Array of setting groups.
	 * @return array
	 *
	 * @deprecated migrate to \Automattic\WooCommerce\Internal\Admin\Settings instead.
	 */
	public static function add_settings_group( $groups ) {
		wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.9.0', '\Automattic\WooCommerce\Internal\Admin\Settings::add_settings_group' );

		return Settings::get_instance()->add_settings_group( $groups );
	}

	/**
	 * Add WC Admin specific settings
	 *
	 * @param array $settings Array of settings in wc admin group.
	 * @return array
	 *
	 * @deprecated migrate to \Automattic\WooCommerce\Internal\Admin\Settings instead.
	 */
	public static function add_settings( $settings ) {
		wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.9.0', '\Automattic\WooCommerce\Internal\Admin\Settings::add_settings' );

		return Settings::get_instance()->add_settings( $settings );
	}

	/**
	 * Gets custom settings used for WC Admin.
	 *
	 * @param array $settings Array of settings to merge into.
	 * @return array
	 *
	 * @deprecated migrate to \Automattic\WooCommerce\Internal\Admin\Settings instead.
	 */
	public static function get_custom_settings( $settings ) {
		wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.9.0' );

		$wc_rest_settings_options_controller = new \WC_REST_Setting_Options_Controller();
		$wc_admin_group_settings             = $wc_rest_settings_options_controller->get_group_settings( 'wc_admin' );
		$settings['wcAdminSettings']         = array();

		foreach ( $wc_admin_group_settings as $setting ) {
			if ( ! empty( $setting['id'] ) ) {
				$settings['wcAdminSettings'][ $setting['id'] ] = $setting['value'];
			}
		}
		return $settings;
	}

	/**
	 * Return an object defining the currency options for the site's current currency
	 *
	 * @return  array  Settings for the current currency {
	 *     Array of settings.
	 *
	 *     @type string $code       Currency code.
	 *     @type string $precision  Number of decimals.
	 *     @type string $symbol     Symbol for currency.
	 * }
	 *
	 * @deprecated migrate to \Automattic\WooCommerce\Internal\Admin\Settings instead.
	 */
	public static function get_currency_settings() {
		wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.9.0', '\Automattic\WooCommerce\Internal\Admin\Settings::get_currency_settings' );

		return Settings::get_currency_settings();
	}

	/**
	 * Delete woocommerce_onboarding_homepage_post_id field when the homepage is deleted
	 *
	 * @param int $post_id The deleted post id.
	 */
	public static function delete_homepage( $post_id ) {
		if ( 'page' !== get_post_type( $post_id ) ) {
			return;
		}
		$homepage_id = intval( get_option( 'woocommerce_onboarding_homepage_post_id', false ) );
		if ( $homepage_id === $post_id ) {
			delete_option( 'woocommerce_onboarding_homepage_post_id' );
		}
	}

	/**
	 * Adds the appearance_theme_view Tracks event.
	 */
	public static function add_appearance_theme_view_tracks_event() {
		wc_admin_record_tracks_event( 'appearance_theme_view', array() );
	}
}
PK     [1]    $  Admin/Schedulers/ImportInterface.phpnu         <?php
/**
 * Import related abstract functions.
 */

namespace Automattic\WooCommerce\Internal\Admin\Schedulers;

interface ImportInterface {
	/**
	 * Get items based on query and return IDs along with total available.
	 *
	 * @internal
	 * @param int      $limit Number of records to retrieve.
	 * @param int      $page  Page number.
	 * @param int|bool $days Number of days prior to current date to limit search results.
	 * @param bool     $skip_existing Skip already imported items.
	 */
	public static function get_items( $limit, $page, $days, $skip_existing );

	/**
	 * Get total number of items already imported.
	 *
	 * @internal
	 * @return null
	 */
	public static function get_total_imported();

}
PK     [1];׹-  -  '  Admin/Schedulers/CustomersScheduler.phpnu         <?php
/**
 * Customer syncing related functions and actions.
 */

namespace Automattic\WooCommerce\Internal\Admin\Schedulers;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;

/**
 * CustomersScheduler Class.
 */
class CustomersScheduler extends ImportScheduler {
	/**
	 * Slug to identify the scheduler.
	 *
	 * @var string
	 */
	public static $name = 'customers';

	/**
	 * Attach customer lookup update hooks.
	 *
	 * @internal
	 */
	public static function init() {
		CustomersDataStore::init();
		parent::init();
	}

	/**
	 * Add customer dependencies.
	 *
	 * @internal
	 * @return array
	 */
	public static function get_dependencies() {
		return array(
			'delete_batch_init' => OrdersScheduler::get_action( 'delete_batch_init' ),
		);
	}

	/**
	 * Get the customer IDs and total count that need to be synced.
	 *
	 * @internal
	 * @param int      $limit Number of records to retrieve.
	 * @param int      $page  Page number.
	 * @param int|bool $days Number of days prior to current date to limit search results.
	 * @param bool     $skip_existing Skip already imported customers.
	 */
	public static function get_items( $limit = 10, $page = 1, $days = false, $skip_existing = false ) {
		$customer_roles = apply_filters( 'woocommerce_analytics_import_customer_roles', array( 'customer' ) );
		$query_args     = array(
			'fields'   => 'ID',
			'orderby'  => 'ID',
			'order'    => 'ASC',
			'number'   => $limit,
			'paged'    => $page,
			'role__in' => $customer_roles,
		);

		if ( is_int( $days ) ) {
			$query_args['date_query'] = array(
				'after' => gmdate( 'Y-m-d 00:00:00', time() - ( DAY_IN_SECONDS * $days ) ),
			);
		}

		if ( $skip_existing ) {
			add_action( 'pre_user_query', array( __CLASS__, 'exclude_existing_customers_from_query' ) );
		}

		$customer_query = new \WP_User_Query( $query_args );

		remove_action( 'pre_user_query', array( __CLASS__, 'exclude_existing_customers_from_query' ) );

		return (object) array(
			'total' => $customer_query->get_total(),
			'ids'   => $customer_query->get_results(),
		);
	}

	/**
	 * Exclude users that already exist in our customer lookup table.
	 *
	 * Meant to be hooked into 'pre_user_query' action.
	 *
	 * @internal
	 * @param WP_User_Query $wp_user_query WP_User_Query to modify.
	 */
	public static function exclude_existing_customers_from_query( $wp_user_query ) {
		global $wpdb;

		$wp_user_query->query_where .= " AND NOT EXISTS (
			SELECT ID FROM {$wpdb->prefix}wc_customer_lookup
			WHERE {$wpdb->prefix}wc_customer_lookup.user_id = {$wpdb->users}.ID
		)";
	}

	/**
	 * Get total number of rows imported.
	 *
	 * @internal
	 * @return int
	 */
	public static function get_total_imported() {
		global $wpdb;
		return $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_customer_lookup" );
	}

	/**
	 * Imports a single customer.
	 *
	 * @internal
	 * @param int $user_id User ID.
	 * @return void
	 */
	public static function import( $user_id ) {
		CustomersDataStore::update_registered_customer( $user_id );
	}

	/**
	 * Delete a batch of customers.
	 *
	 * @internal
	 * @param int $batch_size Number of items to delete.
	 * @return void
	 */
	public static function delete( $batch_size ) {
		global $wpdb;

		$customer_ids = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT customer_id FROM {$wpdb->prefix}wc_customer_lookup ORDER BY customer_id ASC LIMIT %d",
				$batch_size
			)
		);

		foreach ( $customer_ids as $customer_id ) {
			CustomersDataStore::delete_customer( $customer_id );
		}
	}
}
PK     [1]Iav  v  '  Admin/Schedulers/MailchimpScheduler.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Admin\Schedulers;

/**
 * Class MailchimpScheduler
 *
 * @package Automattic\WooCommerce\Admin\Schedulers
 */
class MailchimpScheduler {

	const SUBSCRIBE_ENDPOINT     = 'https://woocommerce.com/wp-json/wccom/v1/subscribe';
	const SUBSCRIBE_ENDPOINT_DEV = 'https://woocommerce.test/wp-json/wccom/v1/subscribe';

	const SUBSCRIBED_OPTION_NAME             = 'woocommerce_onboarding_subscribed_to_mailchimp';
	const SUBSCRIBED_ERROR_COUNT_OPTION_NAME = 'woocommerce_onboarding_subscribed_to_mailchimp_error_count';
	const MAX_ERROR_THRESHOLD                = 3;

	const LOGGER_CONTEXT = 'mailchimp_scheduler';

	/**
	 * The logger instance.
	 *
	 * @var \WC_Logger_Interface|null
	 */
	private $logger;

	/**
	 * MailchimpScheduler constructor.
	 *
	 * @internal
	 * @param \WC_Logger_Interface|null $logger Logger instance.
	 */
	public function __construct( ?\WC_Logger_Interface $logger = null ) {
		if ( null === $logger ) {
			$logger = wc_get_logger();
		}
		$this->logger = $logger;
	}

	/**
	 * Attempt to subscribe store_email to MailChimp.
	 *
	 * @internal
	 */
	public function run() {
		// Abort if we've already subscribed to MailChimp.
		if ( 'yes' === get_option( self::SUBSCRIBED_OPTION_NAME ) ) {
			return false;
		}

		$profile_data = get_option( 'woocommerce_onboarding_profile' );
		if ( ! isset( $profile_data['is_agree_marketing'] ) || false === $profile_data['is_agree_marketing'] ) {
			return false;
		}

		// Abort if store_email doesn't exist.
		if ( ! isset( $profile_data['store_email'] ) ) {
			return false;
		}

		// Abort if failed requests reaches the threshold.
		if ( intval( get_option( self::SUBSCRIBED_ERROR_COUNT_OPTION_NAME, 0 ) ) >= self::MAX_ERROR_THRESHOLD ) {
			return false;
		}

		$country_code = WC()->countries->get_base_country();
		$state        = WC()->countries->get_base_state();

		$address = array(
			// Setting N/A for addr1, city, state, zipcode and country as they are
			// required fields. Setting '' doesn't work.
			'addr1'   => 'N/A',
			'addr2'   => '',
			'city'    => 'N/A',
			'state'   => $state ?? 'N/A',
			'zip'     => 'N/A',
			'country' => $country_code ?? 'N/A',
		);

		$response = $this->make_request( $profile_data['store_email'], $address );

		if ( is_wp_error( $response ) || ! isset( $response['body'] ) ) {
			$this->handle_request_error();
			return false;
		}

		$body = json_decode( $response['body'] );
		if ( isset( $body->success ) && true === $body->success ) {
			update_option( self::SUBSCRIBED_OPTION_NAME, 'yes' );
			return true;
		}

		$this->handle_request_error( $body );
		return false;
	}

	/**
	 * Make an HTTP request to the API.
	 *
	 * @internal
	 * @param string $store_email Email address to subscribe.
	 * @param array  $address     Store address.
	 *
	 * @return mixed
	 */
	public function make_request( $store_email, $address ) {
		if ( true === defined( 'WP_ENVIRONMENT_TYPE' ) && 'development' === constant( 'WP_ENVIRONMENT_TYPE' ) ) {
			$subscribe_endpoint = self::SUBSCRIBE_ENDPOINT_DEV;
		} else {
			$subscribe_endpoint = self::SUBSCRIBE_ENDPOINT;
		}

		return wp_remote_post(
			$subscribe_endpoint,
			array(
				'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ),
				'method'     => 'POST',
				'body'       => array(
					'email'   => $store_email,
					'address' => $address,
				),
			)
		);
	}

	/**
	 * Reset options.
	 *
	 * @internal
	 */
	public static function reset() {
		delete_option( self::SUBSCRIBED_OPTION_NAME );
		delete_option( self::SUBSCRIBED_ERROR_COUNT_OPTION_NAME );
	}

	/**
	 * Handle subscribe API error.
	 *
	 * @internal
	 * @param string $extra_msg  Extra message to log.
	 */
	private function handle_request_error( $extra_msg = null ) {
		// phpcs:ignore
		$msg = isset( $extra_msg ) ? 'Incorrect response from Mailchimp API with: ' . print_r( $extra_msg, true ) : 'Error getting a response from Mailchimp API.';

		$this->logger->error( $msg, array( 'source' => self::LOGGER_CONTEXT ) );

		$accumulated_error_count = intval( get_option( self::SUBSCRIBED_ERROR_COUNT_OPTION_NAME, 0 ) ) + 1;
		update_option( self::SUBSCRIBED_ERROR_COUNT_OPTION_NAME, $accumulated_error_count );
	}
}
PK     [1]h.  .  $  Admin/Schedulers/ImportScheduler.phpnu         <?php
/**
 * Import related functions and actions.
 */

namespace Automattic\WooCommerce\Internal\Admin\Schedulers;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
use Automattic\WooCommerce\Admin\Schedulers\SchedulerTraits;

/**
 * ImportScheduler class.
 */
abstract class ImportScheduler implements ImportInterface {
	/**
	 * Import stats option name.
	 */
	const IMPORT_STATS_OPTION = 'woocommerce_admin_import_stats';

	/**
	 * Scheduler traits.
	 */
	use SchedulerTraits {
		get_batch_sizes as get_scheduler_batch_sizes;
	}

	/**
	 * Returns true if an import is in progress.
	 *
	 * @internal
	 * @return bool
	 */
	public static function is_importing() {
		$pending_jobs = self::queue()->search(
			array(
				'status'   => 'pending',
				'per_page' => 1,
				'claimed'  => false,
				'search'   => 'import',
				'group'    => self::$group,
			)
		);
		if ( empty( $pending_jobs ) ) {
			$in_progress = self::queue()->search(
				array(
					'status'   => 'in-progress',
					'per_page' => 1,
					'search'   => 'import',
					'group'    => self::$group,
				)
			);
		}

		return ! empty( $pending_jobs ) || ! empty( $in_progress );
	}

	/**
	 * Get batch sizes.
	 *
	 * @internal
	 * @return array
	 */
	public static function get_batch_sizes() {
		return array_merge(
			self::get_scheduler_batch_sizes(),
			array(
				'delete' => 10,
				'import' => 25,
				'queue'  => 100,
			)
		);

	}

	/**
	 * Get all available scheduling actions.
	 * Used to determine action hook names and clear events.
	 *
	 * @internal
	 * @return array
	 */
	public static function get_scheduler_actions() {
		return array(
			'import_batch_init' => 'wc-admin_import_batch_init_' . static::$name,
			'import_batch'      => 'wc-admin_import_batch_' . static::$name,
			'delete_batch_init' => 'wc-admin_delete_batch_init_' . static::$name,
			'delete_batch'      => 'wc-admin_delete_batch_' . static::$name,
			'import'            => 'wc-admin_import_' . static::$name,
		);
	}

	/**
	 * Queue the imports into multiple batches.
	 *
	 * @internal
	 * @param integer|boolean $days Number of days to import.
	 * @param boolean         $skip_existing Skip existing records.
	 */
	public static function import_batch_init( $days, $skip_existing ) {
		$batch_size = static::get_batch_size( 'import' );
		$items      = static::get_items( 1, 1, $days, $skip_existing );

		if ( 0 === $items->total ) {
			return;
		}

		$num_batches = ceil( $items->total / $batch_size );

		self::queue_batches( 1, $num_batches, 'import_batch', array( $days, $skip_existing ) );
	}

	/**
	 * Imports a batch of items to update.
	 *
	 * @internal
	 * @param int      $batch_number Batch number to import (essentially a query page number).
	 * @param int|bool $days Number of days to import.
	 * @param bool     $skip_existing Skip existing records.
	 * @return void
	 */
	public static function import_batch( $batch_number, $days, $skip_existing ) {
		$batch_size = static::get_batch_size( 'import' );

		$properties = array(
			'batch_number' => $batch_number,
			'batch_size'   => $batch_size,
			'type'         => static::$name,
		);
		wc_admin_record_tracks_event( 'import_job_start', $properties );

		// When we are skipping already imported items, the table of items to import gets smaller in
		// every batch, so we want to always import the first page.
		$page  = $skip_existing ? 1 : $batch_number;
		$items = static::get_items( $batch_size, $page, $days, $skip_existing );

		foreach ( $items->ids as $id ) {
			static::import( $id );
		}

		$import_stats                              = get_option( self::IMPORT_STATS_OPTION, array() );
		$imported_count                            = absint( $import_stats[ static::$name ]['imported'] ) + count( $items->ids );
		$import_stats[ static::$name ]['imported'] = $imported_count;
		update_option( self::IMPORT_STATS_OPTION, $import_stats );

		$properties['imported_count'] = $imported_count;

		wc_admin_record_tracks_event( 'import_job_complete', $properties );
	}

	/**
	 * Queue item deletion in batches.
	 *
	 * @internal
	 */
	public static function delete_batch_init() {
		global $wpdb;
		$batch_size = static::get_batch_size( 'delete' );
		$count      = static::get_total_imported();

		if ( 0 === $count ) {
			return;
		}

		$num_batches = ceil( $count / $batch_size );

		self::queue_batches( 1, $num_batches, 'delete_batch' );
	}

	/**
	 * Delete a batch by passing the count to be deleted to the child delete method.
	 *
	 * @internal
	 * @return void
	 */
	public static function delete_batch() {
		wc_admin_record_tracks_event( 'delete_import_data_job_start', array( 'type' => static::$name ) );

		$batch_size = static::get_batch_size( 'delete' );
		static::delete( $batch_size );

		ReportsCache::invalidate();

		wc_admin_record_tracks_event( 'delete_import_data_job_complete', array( 'type' => static::$name ) );
	}
}
PK     [1]Sz]  ]  $  Admin/Schedulers/OrdersScheduler.phpnu         <?php
/**
 * Order syncing related functions and actions.
 */

namespace Automattic\WooCommerce\Internal\Admin\Schedulers;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
use Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore as CouponsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;
use Automattic\WooCommerce\Admin\API\Reports\Orders\DataStore as OrderDataStore;
use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrdersStatsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\Products\DataStore as ProductsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\Taxes\DataStore as TaxesDataStore;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Utilities\OrderUtil;
use Automattic\WooCommerce\Admin\Features\Features;

/**
 * OrdersScheduler Class.
 */
class OrdersScheduler extends ImportScheduler {
	/**
	 * Slug to identify the scheduler.
	 *
	 * @var string
	 */
	public static $name = 'orders';

	/**
	 * Option name for storing the last processed order modified date.
	 *
	 * This is used as a cursor to track progress through the orders table.
	 * We need both date and ID because multiple orders can have the same
	 * date_updated timestamp (e.g., bulk operations, imports). Without tracking
	 * the ID, we would endlessly reprocess orders at the same timestamp when
	 * the batch size is smaller than the number of orders at that timestamp.
	 *
	 * @var string
	 */
	const LAST_PROCESSED_ORDER_DATE_OPTION = 'woocommerce_admin_scheduler_last_processed_order_modified_date';

	/**
	 * Option name for storing the last processed order ID.
	 *
	 * Used in conjunction with LAST_PROCESSED_ORDER_DATE_OPTION to handle
	 * cases where multiple orders have the same date_updated timestamp.
	 * Query pattern: WHERE (date > last_date) OR (date = last_date AND id > last_id)
	 *
	 * @var string
	 */
	const LAST_PROCESSED_ORDER_ID_OPTION = 'woocommerce_admin_scheduler_last_processed_order_id';

	/**
	 * Option name for storing whether to enable scheduled order import.
	 *
	 * @var string
	 */
	const SCHEDULED_IMPORT_OPTION = 'woocommerce_analytics_scheduled_import';

	/**
	 * Default value for the scheduled import option.
	 *
	 * @var string
	 */
	const SCHEDULED_IMPORT_OPTION_DEFAULT_VALUE = 'no';

	/**
	 * Action name for the order batch import.
	 *
	 * @var string
	 */
	const PROCESS_PENDING_ORDERS_BATCH_ACTION = 'process_pending_batch';

	/**
	 * Attach order lookup update hooks.
	 *
	 * @internal
	 */
	public static function init() {
		// Activate WC_Order extension.
		\Automattic\WooCommerce\Admin\Overrides\Order::add_filters();
		\Automattic\WooCommerce\Admin\Overrides\OrderRefund::add_filters();

		if ( self::is_scheduled_import_enabled() ) {
			// Schedule recurring batch processor.
			add_action( 'action_scheduler_ensure_recurring_actions', array( __CLASS__, 'schedule_recurring_batch_processor' ) );
		} else {
			// Schedule import immediately on order create/update/delete.
			add_action( 'woocommerce_update_order', array( __CLASS__, 'possibly_schedule_import' ) );
			add_filter( 'woocommerce_create_order', array( __CLASS__, 'possibly_schedule_import' ) );
			add_action( 'woocommerce_refund_created', array( __CLASS__, 'possibly_schedule_import' ) );
			add_action( 'woocommerce_schedule_import', array( __CLASS__, 'possibly_schedule_import' ) );
		}

		if ( Features::is_enabled( 'analytics-scheduled-import' ) ) {
			// Watch for changes to the scheduled import option.
			add_action( 'add_option_' . self::SCHEDULED_IMPORT_OPTION, array( __CLASS__, 'handle_scheduled_import_option_added' ), 10, 2 );
			add_action( 'update_option_' . self::SCHEDULED_IMPORT_OPTION, array( __CLASS__, 'handle_scheduled_import_option_change' ), 10, 2 );
			add_action( 'delete_option', array( __CLASS__, 'handle_scheduled_import_option_before_delete' ), 10, 1 );
		}

		OrdersStatsDataStore::init();
		CouponsDataStore::init();
		ProductsDataStore::init();
		TaxesDataStore::init();
		OrderDataStore::init();

		parent::init();
	}

	/**
	 * Add customer dependencies.
	 *
	 * @internal
	 * @return array
	 */
	public static function get_dependencies() {
		return array(
			'import_batch_init' => \Automattic\WooCommerce\Internal\Admin\Schedulers\CustomersScheduler::get_action( 'import_batch_init' ),
		);
	}

	/**
	 * Get all available scheduling actions.
	 * Extends parent to add the new batch processor action.
	 *
	 * @internal
	 * @return array
	 */
	public static function get_scheduler_actions() {
		return array_merge(
			parent::get_scheduler_actions(),
			array(
				self::PROCESS_PENDING_ORDERS_BATCH_ACTION => 'wc-admin_process_pending_orders_batch',
			)
		);
	}

	/**
	 * Get batch sizes for OrdersScheduler actions.
	 *
	 * @internal
	 * @return array
	 */
	public static function get_batch_sizes() {
		return array_merge(
			parent::get_batch_sizes(),
			array(
				self::PROCESS_PENDING_ORDERS_BATCH_ACTION => 100,
			)
		);
	}

	/**
	 * Get the order/refund IDs and total count that need to be synced.
	 *
	 * @internal
	 * @param int      $limit Number of records to retrieve.
	 * @param int      $page  Page number.
	 * @param int|bool $days Number of days prior to current date to limit search results.
	 * @param bool     $skip_existing Skip already imported orders.
	 */
	public static function get_items( $limit = 10, $page = 1, $days = false, $skip_existing = false ) {
		if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
			return self::get_items_from_orders_table( $limit, $page, $days, $skip_existing );
		} else {
			return self::get_items_from_posts_table( $limit, $page, $days, $skip_existing );
		}
	}

	/**
	 * Helper method to ger order/refund IDS and total count that needs to be synced.
	 *
	 * @internal
	 * @param int      $limit Number of records to retrieve.
	 * @param int      $page  Page number.
	 * @param int|bool $days Number of days prior to current date to limit search results.
	 * @param bool     $skip_existing Skip already imported orders.
	 *
	 * @return object Total counts.
	 */
	private static function get_items_from_posts_table( $limit, $page, $days, $skip_existing ) {
		global $wpdb;
		$where_clause = '';
		$offset       = $page > 1 ? ( $page - 1 ) * $limit : 0;

		if ( is_int( $days ) ) {
			$days_ago      = gmdate( 'Y-m-d 00:00:00', time() - ( DAY_IN_SECONDS * $days ) );
			$where_clause .= " AND post_date_gmt >= '{$days_ago}'";
		}

		if ( $skip_existing ) {
			$where_clause .= " AND NOT EXISTS (
				SELECT 1 FROM {$wpdb->prefix}wc_order_stats
				WHERE {$wpdb->prefix}wc_order_stats.order_id = {$wpdb->posts}.ID
			)";
		}

		$count = $wpdb->get_var(
			"SELECT COUNT(*) FROM {$wpdb->posts}
			WHERE post_type IN ( 'shop_order', 'shop_order_refund' )
			AND post_status NOT IN ( 'wc-auto-draft', 'auto-draft', 'trash' )
			{$where_clause}" // phpcs:ignore unprepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared SQL ok.
		);

		$order_ids = absint( $count ) > 0 ? $wpdb->get_col(
			$wpdb->prepare(
				"SELECT ID FROM {$wpdb->posts}
				WHERE post_type IN ( 'shop_order', 'shop_order_refund' )
				AND post_status NOT IN ( 'wc-auto-draft', 'auto-draft', 'trash' )
				{$where_clause}
				ORDER BY post_date_gmt ASC
				LIMIT %d
				OFFSET %d",
				$limit,
				$offset
			)
		) : array(); // phpcs:ignore unprepared SQL ok.

		return (object) array(
			'total' => absint( $count ),
			'ids'   => $order_ids,
		);
	}

	/**
	 * Helper method to ger order/refund IDS and total count that needs to be synced from HPOS.
	 *
	 * @internal
	 * @param int      $limit Number of records to retrieve.
	 * @param int      $page  Page number.
	 * @param int|bool $days Number of days prior to current date to limit search results.
	 * @param bool     $skip_existing Skip already imported orders.
	 *
	 * @return object Total counts.
	 */
	private static function get_items_from_orders_table( $limit, $page, $days, $skip_existing ) {
		global $wpdb;
		$where_clause = '';
		$offset       = $page > 1 ? ( $page - 1 ) * $limit : 0;
		$order_table  = OrdersTableDataStore::get_orders_table_name();

		if ( is_int( $days ) ) {
			$days_ago      = gmdate( 'Y-m-d 00:00:00', time() - ( DAY_IN_SECONDS * $days ) );
			$where_clause .= " AND orders.date_created_gmt >= '{$days_ago}'";
		}

		if ( $skip_existing ) {
			$where_clause .= "AND NOT EXiSTS (
					SELECT 1 FROM {$wpdb->prefix}wc_order_stats
					WHERE {$wpdb->prefix}wc_order_stats.order_id = orders.id
					)
				";
		}

		$count = $wpdb->get_var(
			"
SELECT COUNT(*) FROM {$order_table} AS orders
WHERE type in ( 'shop_order', 'shop_order_refund' )
AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
{$where_clause}
"
		); // phpcs:ignore unprepared SQL ok.

		$order_ids = absint( $count ) > 0 ? $wpdb->get_col(
			$wpdb->prepare(
				"SELECT id FROM {$order_table} AS orders
				WHERE type IN ( 'shop_order', 'shop_order_refund' )
				AND status NOT IN ( 'wc-auto-draft', 'auto-draft', 'trash' )
				{$where_clause}
				ORDER BY date_created_gmt ASC
				LIMIT %d
				OFFSET %d",
				$limit,
				$offset
			)
		) : array(); // phpcs:ignore unprepared SQL ok.

		return (object) array(
			'total' => absint( $count ),
			'ids'   => $order_ids,
		);
	}

	/**
	 * Get total number of rows imported.
	 *
	 * @internal
	 */
	public static function get_total_imported() {
		global $wpdb;
		return $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_order_stats" );
	}

	/**
	 * Schedule this import if the post is an order or refund.
	 * Note: This method is only called when scheduled import is disabled
	 * (immediate mode). Otherwise, orders are processed in batches periodically.
	 *
	 * @param int $order_id Post ID.
	 *
	 * @internal
	 * @returns int The order id
	 */
	public static function possibly_schedule_import( $order_id ) {
		if ( self::is_scheduled_import_enabled() ) {
			return $order_id;
		}

		if ( ! OrderUtil::is_order( $order_id, array( 'shop_order' ) ) && 'woocommerce_refund_created' !== current_filter() && 'woocommerce_schedule_import' !== current_filter() ) {
			return $order_id;
		}

		self::schedule_action( 'import', array( $order_id ) );
		return $order_id;
	}

	/**
	 * Imports a single order or refund to update lookup tables for.
	 * If an error is encountered in one of the updates, a retry action is scheduled.
	 *
	 * @internal
	 * @param int $order_id Order or refund ID.
	 * @return void
	 */
	public static function import( $order_id ) {
		$order = wc_get_order( $order_id );

		// If the order isn't found for some reason, skip the sync.
		if ( ! $order ) {
			return;
		}

		$type = $order->get_type();

		// If the order isn't the right type, skip sync.
		if ( 'shop_order' !== $type && 'shop_order_refund' !== $type ) {
			return;
		}

		// If the order has no id or date created, skip sync.
		if ( ! $order->get_id() || ! $order->get_date_created() ) {
			return;
		}

		$results = array(
			OrdersStatsDataStore::sync_order( $order_id ),
			ProductsDataStore::sync_order_products( $order_id ),
			CouponsDataStore::sync_order_coupons( $order_id ),
			TaxesDataStore::sync_order_taxes( $order_id ),
			CustomersDataStore::sync_order_customer( $order_id ),
		);

		if ( 'shop_order' === $type ) {
			$order_refunds = $order->get_refunds();

			foreach ( $order_refunds as $refund ) {
				OrdersStatsDataStore::sync_order( $refund->get_id() );
			}
		}

		ReportsCache::invalidate();

		/**
		 * Fires after an order or refund has been imported into Analytics lookup tables
		 * and the reports cache has been invalidated.
		 *
		 * @since 10.3.0
		 * @param int $order_id Order or refund ID.
		 */
		do_action( 'woocommerce_order_scheduler_after_import_order', $order_id );
	}

	/**
	 * Schedule recurring batch processor for order imports.
	 *
	 * @internal
	 */
	public static function schedule_recurring_batch_processor() {
		$action_hook = self::get_action( self::PROCESS_PENDING_ORDERS_BATCH_ACTION );
		// The most efficient way to check for an existing action is to use `as_has_scheduled_action`, but in unusual
		// cases where another plugin has loaded a very old version of Action Scheduler, it may not be available to us.
		$has_scheduled_action = function_exists( 'as_has_scheduled_action' ) ? 'as_has_scheduled_action' : 'as_next_scheduled_action';
		if ( call_user_func( $has_scheduled_action, $action_hook ) ) {
			return;
		}

		$interval = self::get_import_interval();

		as_schedule_recurring_action( time(), $interval, $action_hook, array(), static::$group, true );
	}

	/**
	 * Handle changes to the scheduled import option.
	 *
	 * When switching from scheduled to immediate import,
	 * we need to run a final catchup batch to ensure no orders are missed.
	 *
	 * When switching from immediate to scheduled import,
	 * we need to reschedule the recurring batch processor.
	 *
	 * @internal
	 * @param mixed $old_value The old value of the option.
	 * @param mixed $new_value The new value of the option.
	 * @return void
	 */
	public static function handle_scheduled_import_option_change( $old_value, $new_value ) {
		// If switching from scheduled to immediate import.
		if ( 'yes' === $old_value && 'no' === $new_value ) {
			// Unschedule the recurring batch processor.
			$action_hook = self::get_action( self::PROCESS_PENDING_ORDERS_BATCH_ACTION );
			as_unschedule_all_actions( $action_hook, array(), static::$group );

			// Schedule an immediate catchup batch to process all orders up to now.
			// This ensures no orders are missed during the transition.
			self::schedule_action( self::PROCESS_PENDING_ORDERS_BATCH_ACTION, array( null, null ) );
		} elseif ( 'no' === $old_value && 'yes' === $new_value ) {
			// Switching from immediate to scheduled import.
			// Set the last processed order date to now with 1 minute buffer to ensure no orders are missed.
			update_option( self::LAST_PROCESSED_ORDER_DATE_OPTION, gmdate( 'Y-m-d H:i:s', time() - MINUTE_IN_SECONDS ) );
			update_option( self::LAST_PROCESSED_ORDER_ID_OPTION, 0 );

			// Schedule the recurring batch processor.
			self::schedule_recurring_batch_processor();
		}
	}

	/**
	 * Handle addition of the scheduled import option.
	 *
	 * @internal
	 * @param string $option_name The name of the option that was added.
	 * @param string $value The value of the option that was added.
	 *
	 * @return void
	 */
	public static function handle_scheduled_import_option_added( $option_name, $value ) {
		if ( self::SCHEDULED_IMPORT_OPTION !== $option_name ) {
			return;
		}

		self::handle_scheduled_import_option_change( self::SCHEDULED_IMPORT_OPTION_DEFAULT_VALUE, $value );
	}

	/**
	 * Handle deletion of the scheduled import option.
	 *
	 * @internal
	 * @param string $option_name The name of the option that was deleted.
	 *
	 * @return void
	 */
	public static function handle_scheduled_import_option_before_delete( $option_name ) {
		if ( self::SCHEDULED_IMPORT_OPTION !== $option_name ) {
			return;
		}

		self::handle_scheduled_import_option_change(
			get_option( self::SCHEDULED_IMPORT_OPTION, self::SCHEDULED_IMPORT_OPTION_DEFAULT_VALUE ),
			self::SCHEDULED_IMPORT_OPTION_DEFAULT_VALUE,
		);
	}

	/**
	 * Process pending orders in batch.
	 *
	 * This method queries for orders updated since the last cursor position
	 * (compound cursor: date + ID) and imports them into the analytics tables.
	 *
	 * @internal
	 * @param string|null $cursor_date Cursor date in 'Y-m-d H:i:s' format. Orders after this date will be processed.
	 * @param int|null    $cursor_id   Cursor order ID. Combined with $cursor_date to form compound cursor.
	 * @return void
	 */
	public static function process_pending_batch( $cursor_date = null, $cursor_id = null ) {
		$logger  = wc_get_logger();
		$context = array( 'source' => 'wc-analytics-order-import' );

		if ( self::is_importing() ) {
			// No need to process if an import is already in progress.
			$logger->info( 'Import is already in progress, skipping batch import.', $context );
			return;
		}

		// Load cursor position from options if not provided.
		// If the cursor date is not provided, use the last 24 hours as the default since `action_scheduler_ensure_recurring_actions` runs daily so 24 hours is enough.
		$default_cursor_date = gmdate( 'Y-m-d H:i:s', strtotime( '-24 hours' ) );
		$cursor_date         = $cursor_date ?? get_option( self::LAST_PROCESSED_ORDER_DATE_OPTION, $default_cursor_date );
		$cursor_id           = $cursor_id ?? (int) get_option( self::LAST_PROCESSED_ORDER_ID_OPTION, 0 );

		// Validate cursor date.
		if ( ! $cursor_date || ! strtotime( $cursor_date ) ) {
			$logger->error( 'Invalid cursor date: ' . $cursor_date, $context );
			$cursor_date = $default_cursor_date;
		}

		$batch_size = self::get_batch_size( self::PROCESS_PENDING_ORDERS_BATCH_ACTION );

		$logger->info(
			sprintf( 'Starting batch import. Cursor: %s (ID: %d), batch size: %d', $cursor_date, $cursor_id, $batch_size ),
			$context
		);

		$start_time = microtime( true );

		// Get orders updated since the cursor position.
		$orders = self::get_orders_since( $cursor_date, $cursor_id, $batch_size );

		if ( empty( $orders ) ) {
			$logger->info( 'No orders to process', $context );
			// Update the cursor position to the start time of the batch so that the next batch will start from that point.
			update_option( self::LAST_PROCESSED_ORDER_DATE_OPTION, gmdate( 'Y-m-d H:i:s', (int) $start_time ), false );
			update_option( self::LAST_PROCESSED_ORDER_ID_OPTION, 0, false );
			return;
		}

		$processed_count = 0;
		foreach ( $orders as $order ) {
			try {
				self::import( $order->id );
				++$processed_count;

				// Advance cursor after each successful import. Since orders are sorted by
				// date ASC, id ASC, we can simply overwrite with the current order's values.
				// If an error occurs, we break and save the last successful position.
				$cursor_date = $order->date_updated_gmt;
				$cursor_id   = $order->id;
			} catch ( \Exception $e ) {
				$logger->error(
					sprintf( 'Failed to import order %d: %s', $order->id, $e->getMessage() ),
					$context
				);
				break;
			}
		}

		// Save the updated cursor position.
		update_option( self::LAST_PROCESSED_ORDER_DATE_OPTION, $cursor_date, false );
		update_option( self::LAST_PROCESSED_ORDER_ID_OPTION, $cursor_id, false );

		$elapsed_time = microtime( true ) - $start_time;
		$logger->info(
			sprintf(
				'Batch import completed. Processed: %d orders in %.2f seconds. Cursor: %s (ID: %d)',
				$processed_count,
				$elapsed_time,
				$cursor_date,
				$cursor_id
			),
			$context
		);

		// If we got a full batch, there might be more orders to process.
		// Schedule immediate next batch.
		if ( $processed_count === $batch_size ) {
			$logger->info( 'Full batch processed, scheduling next batch', $context );
			self::schedule_action(
				'process_pending_batch',
				array( $cursor_date, $cursor_id )
			);
		}
	}

	/**
	 * Get the import interval.
	 *
	 * @internal
	 * @return int The import interval in seconds.
	 */
	public static function get_import_interval() {
		/**
		 * Filter the analytics import interval.
		 *
		 * @since 10.4.0
		 * @param int $interval The import interval in seconds. Default is 12 hours.
		 */
		return apply_filters( 'woocommerce_analytics_import_interval', 12 * HOUR_IN_SECONDS );
	}

	/**
	 * Get orders updated since the specified cursor position.
	 *
	 * Uses a compound cursor (date + ID) to handle cases where multiple orders
	 * have the same timestamp. This ensures we can paginate through orders reliably
	 * even when batch_size < number of orders at the same timestamp.
	 *
	 * @internal
	 * @param string $cursor_date Cursor date in 'Y-m-d H:i:s' format.
	 * @param int    $cursor_id   Cursor order ID.
	 * @param int    $limit       Number of orders to retrieve.
	 * @return array Array of objects with 'id' and 'date_updated_gmt' properties.
	 */
	private static function get_orders_since( $cursor_date, $cursor_id, $limit ) {
		if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
			return self::get_orders_since_from_orders_table( $cursor_date, $cursor_id, $limit );
		} else {
			return self::get_orders_since_from_posts_table( $cursor_date, $cursor_id, $limit );
		}
	}

	/**
	 * Get orders from HPOS orders table updated since the specified cursor position.
	 *
	 * Query logic uses a compound cursor (date, ID) to handle pagination when multiple
	 * orders share the same timestamp:
	 * - WHERE date > cursor_date: Get orders with newer timestamps
	 * - OR (date = cursor_date AND id > cursor_id): Continue processing same timestamp
	 *
	 * Example: With batch_size=100 and 1000 orders at '2024-01-01 10:00:00',
	 * this processes them across 10 batches without infinite loops or duplicates.
	 *
	 * @internal
	 * @param string $cursor_date Cursor date in 'Y-m-d H:i:s' format.
	 * @param int    $cursor_id   Cursor order ID.
	 * @param int    $limit       Number of orders to retrieve.
	 * @return array Array of objects with 'id' and 'date_updated_gmt' properties.
	 */
	private static function get_orders_since_from_orders_table( $cursor_date, $cursor_id, $limit ) {
		global $wpdb;
		$orders_table = OrdersTableDataStore::get_orders_table_name();

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		return $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, date_updated_gmt
				FROM {$orders_table}
				WHERE type IN ('shop_order', 'shop_order_refund')
				AND status NOT IN ('wc-auto-draft', 'auto-draft', 'trash')
				AND (
					date_updated_gmt > %s
					OR (date_updated_gmt = %s AND id > %d)
				)
				ORDER BY date_updated_gmt ASC, id ASC
				LIMIT %d",
				$cursor_date,
				$cursor_date,
				$cursor_id,
				$limit
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}

	/**
	 * Get orders from posts table updated since the specified cursor position.
	 *
	 * Uses the same compound cursor logic as get_orders_since_from_orders_table()
	 * but queries the posts table instead of the HPOS orders table.
	 *
	 * @internal
	 * @param string $cursor_date Cursor date in 'Y-m-d H:i:s' format.
	 * @param int    $cursor_id   Cursor order ID.
	 * @param int    $limit       Number of orders to retrieve.
	 * @return array Array of objects with 'id' and 'date_updated_gmt' properties.
	 */
	private static function get_orders_since_from_posts_table( $cursor_date, $cursor_id, $limit ) {
		global $wpdb;

		return $wpdb->get_results(
			$wpdb->prepare(
				"SELECT ID as id, post_modified_gmt as date_updated_gmt
				FROM {$wpdb->posts}
				WHERE post_type IN ('shop_order', 'shop_order_refund')
				AND post_status NOT IN ('wc-auto-draft', 'auto-draft', 'trash')
				AND (
					post_modified_gmt > %s
					OR (post_modified_gmt = %s AND ID > %d)
				)
				ORDER BY post_modified_gmt ASC, ID ASC
				LIMIT %d",
				$cursor_date,
				$cursor_date,
				$cursor_id,
				$limit
			)
		);
	}

	/**
	 * Delete a batch of orders.
	 *
	 * @internal
	 * @param int $batch_size Number of items to delete.
	 * @return void
	 */
	public static function delete( $batch_size ) {
		global $wpdb;

		$order_ids = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT order_id FROM {$wpdb->prefix}wc_order_stats ORDER BY order_id ASC LIMIT %d",
				$batch_size
			)
		);

		foreach ( $order_ids as $order_id ) {
			OrdersStatsDataStore::delete_order( $order_id );
		}
	}

	/**
	 * Check whether scheduled import is enabled.
	 *
	 * When the "analytics-scheduled-import" feature is disabled, only immediate
	 * import is supported (returns false). When enabled, checks the option value.
	 *
	 * @internal
	 * @return bool
	 */
	private static function is_scheduled_import_enabled(): bool {
		if ( ! Features::is_enabled( 'analytics-scheduled-import' ) ) {
			// If the feature is disabled, only immediate import is supported.
			return false;
		}

		return 'yes' === get_option( self::SCHEDULED_IMPORT_OPTION, self::SCHEDULED_IMPORT_OPTION_DEFAULT_VALUE );
	}
}
PK     [1]"h  h  "  Admin/Logging/LogHandlerFileV2.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Admin\Logging;

use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\{ File, FileController };
use WC_Log_Handler;

/**
 * LogHandlerFileV2 class.
 */
class LogHandlerFileV2 extends WC_Log_Handler {
	/**
	 * Instance of the FileController class.
	 *
	 * @var FileController
	 */
	private $file_controller;

	/**
	 * Instance of the Settings class.
	 *
	 * @var Settings
	 */
	private $settings;

	/**
	 * LogHandlerFileV2 class.
	 */
	public function __construct() {
		$this->file_controller = wc_get_container()->get( FileController::class );
		$this->settings        = wc_get_container()->get( Settings::class );
	}

	/**
	 * Handle a log entry.
	 *
	 * @param int    $timestamp Log timestamp.
	 * @param string $level     emergency|alert|critical|error|warning|notice|info|debug.
	 * @param string $message   Log message.
	 * @param array  $context   {
	 *     Optional. Additional information for log handlers. Any data can be added here, but there are some array
	 *     keys that have special behavior.
	 *
	 *     @type string $source    Determines which log file to write to. Must be at least 3 characters in length.
	 *     @type bool   $backtrace True to include a backtrace that shows where the logging function got called.
	 * }
	 *
	 * @return bool False if value was not handled and true if value was handled.
	 */
	public function handle( $timestamp, $level, $message, $context ) {
		$context = (array) $context;

		if ( isset( $context['source'] ) && is_string( $context['source'] ) && strlen( $context['source'] ) >= 3 ) {
			$source = sanitize_title( trim( $context['source'] ) );
		} else {
			$source = $this->determine_source();
		}

		$entry = static::format_entry( $timestamp, $level, $message, $context );

		$written = $this->file_controller->write_to_file( $source, $entry, $timestamp );

		if ( $written ) {
			$this->file_controller->invalidate_cache();
		}

		return $written;
	}

	/**
	 * Builds a log entry text from level, timestamp, and message.
	 *
	 * @param int    $timestamp Log timestamp.
	 * @param string $level     emergency|alert|critical|error|warning|notice|info|debug.
	 * @param string $message   Log message.
	 * @param array  $context   Additional information for log handlers.
	 *
	 * @return string Formatted log entry.
	 */
	protected static function format_entry( $timestamp, $level, $message, $context ) {
		$time_string  = static::format_time( $timestamp );
		$level_string = strtoupper( $level );

		if ( isset( $context['backtrace'] ) && true === filter_var( $context['backtrace'], FILTER_VALIDATE_BOOLEAN ) ) {
			$context['backtrace'] = static::get_backtrace();
		}

		$context_for_entry = $context;
		unset( $context_for_entry['source'] );

		if ( ! empty( $context_for_entry ) ) {
			$formatted_context = wp_json_encode( $context_for_entry, JSON_UNESCAPED_UNICODE );
			$message          .= stripslashes( " CONTEXT: $formatted_context" );
		}

		$entry = "$time_string $level_string $message";

		// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
		/** This filter is documented in includes/abstracts/abstract-wc-log-handler.php */
		return apply_filters(
			'woocommerce_format_log_entry',
			$entry,
			array(
				'timestamp' => $timestamp,
				'level'     => $level,
				'message'   => $message,
				'context'   => $context,
			)
		);
		// phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
	}

	/**
	 * Figures out a source string to use for a log entry based on where the log method was called from.
	 *
	 * @return string
	 */
	protected function determine_source(): string {
		$source_roots = array(
			'mu-plugin' => trailingslashit( Constants::get_constant( 'WPMU_PLUGIN_DIR' ) ),
			'plugin'    => trailingslashit( Constants::get_constant( 'WP_PLUGIN_DIR' ) ),
			'theme'     => trailingslashit( get_theme_root() ),
		);

		$source    = '';
		$backtrace = static::get_backtrace();

		foreach ( $backtrace as $frame ) {
			if ( ! isset( $frame['file'] ) ) {
				continue;
			}

			foreach ( $source_roots as $type => $path ) {
				if ( 0 === strpos( $frame['file'], $path ) ) {
					$relative_path = trim( substr( $frame['file'], strlen( $path ) ), DIRECTORY_SEPARATOR );

					if ( 'mu-plugin' === $type ) {
						$info = pathinfo( $relative_path );

						if ( '.' === $info['dirname'] ) {
							$source = "$type-" . $info['filename'];
						} else {
							$source = "$type-" . $info['dirname'];
						}

						break 2;
					}

					$segments = explode( DIRECTORY_SEPARATOR, $relative_path );
					if ( is_array( $segments ) ) {
						$source = "$type-" . reset( $segments );
					}

					break 2;
				}
			}
		}

		if ( ! $source ) {
			$source = 'log';
		}

		return sanitize_title( $source );
	}

	/**
	 * Delete all logs from a specific source.
	 *
	 * @param string $source The source of the log entries.
	 * @param bool   $quiet  Whether to suppress the deletion message.
	 *
	 * @return int The number of files that were deleted.
	 */
	public function clear( string $source, bool $quiet = false ): int {
		$source = File::sanitize_source( $source );

		$files = $this->file_controller->get_files(
			array(
				'source' => $source,
			)
		);

		if ( is_wp_error( $files ) || count( $files ) < 1 ) {
			return 0;
		}

		$file_ids = array_map(
			fn( $file ) => $file->get_file_id(),
			$files
		);

		$deleted = $this->file_controller->delete_files( $file_ids );

		if ( $deleted > 0 && ! $quiet ) {
			$this->handle(
				time(),
				'info',
				sprintf(
					esc_html(
						// translators: %1$s is a number of log files, %2$s is a slug-style name for a file.
						_n(
							'%1$s log file from source %2$s was deleted.',
							'%1$s log files from source %2$s were deleted.',
							$deleted,
							'woocommerce'
						)
					),
					number_format_i18n( $deleted ),
					sprintf(
						'<code>%s</code>',
						esc_html( $source )
					)
				),
				array(
					'source'    => 'wc_logger',
					'backtrace' => true,
				)
			);
		}

		return $deleted;
	}

	/**
	 * Delete all logs older than a specified timestamp.
	 *
	 * @param int $timestamp All files created before this timestamp will be deleted.
	 *
	 * @return int The number of files that were deleted.
	 */
	public function delete_logs_before_timestamp( int $timestamp = 0 ): int {
		if ( ! $timestamp ) {
			return 0;
		}

		$files = $this->file_controller->get_files(
			array(
				'date_filter' => 'created',
				'date_start'  => 1,
				'date_end'    => $timestamp,
			)
		);

		if ( is_wp_error( $files ) ) {
			return 0;
		}

		$files = array_filter(
			$files,
			function ( $file ) use ( $timestamp ) {
				/**
				 * Allows preventing an expired log file from being deleted.
				 *
				 * @param bool $delete    True to delete the file.
				 * @param File $file      The log file object.
				 * @param int  $timestamp The expiration threshold.
				 *
				 * @since 8.7.0
				 */
				$delete = apply_filters( 'woocommerce_logger_delete_expired_file', true, $file, $timestamp );

				return boolval( $delete );
			}
		);

		if ( count( $files ) < 1 ) {
			return 0;
		}

		$file_ids = array_map(
			fn( $file ) => $file->get_file_id(),
			$files
		);

		$deleted        = $this->file_controller->delete_files( $file_ids );
		$retention_days = $this->settings->get_retention_period();

		if ( $deleted > 0 ) {
			$this->handle(
				time(),
				'info',
				sprintf(
					esc_html(
						// translators: %s is a number of log files.
						_n(
							'%s expired log file was deleted.',
							'%s expired log files were deleted.',
							$deleted,
							'woocommerce'
						)
					),
					number_format_i18n( $deleted )
				),
				array(
					'source' => 'wc_logger',
				)
			);
		}

		return $deleted;
	}
}
PK     [1]8~{  {  (  Admin/Logging/FileV2/SearchListTable.phpnu         <?php
declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\Admin\Logging\FileV2;

use Automattic\WooCommerce\Internal\Admin\Logging\PageController;

use WP_List_Table;

/**
 * SearchListTable class.
 */
class SearchListTable extends WP_List_Table {
	/**
	 * The user option key for saving the preferred number of search results displayed per page.
	 *
	 * @const string
	 */
	public const PER_PAGE_USER_OPTION_KEY = 'woocommerce_logging_search_results_per_page';

	/**
	 * Instance of FileController.
	 *
	 * @var FileController
	 */
	private $file_controller;

	/**
	 * Instance of PageController.
	 *
	 * @var PageController
	 */
	private $page_controller;

	/**
	 * SearchListTable class.
	 *
	 * @param FileController $file_controller Instance of FileController.
	 * @param PageController $page_controller Instance of PageController.
	 */
	public function __construct( FileController $file_controller, PageController $page_controller ) {
		$this->file_controller = $file_controller;
		$this->page_controller = $page_controller;

		parent::__construct(
			array(
				'singular' => 'wc-logs-search-result',
				'plural'   => 'wc-logs-search-results',
				'ajax'     => false,
			)
		);
	}

	/**
	 * Render message when there are no items.
	 *
	 * @return void
	 */
	public function no_items(): void {
		esc_html_e( 'No search results.', 'woocommerce' );
	}

	/**
	 * Set up the column header info.
	 *
	 * @return void
	 */
	public function prepare_column_headers(): void {
		$this->_column_headers = array(
			$this->get_columns(),
			array(),
			array(),
			$this->get_primary_column(),
		);
	}

	/**
	 * Prepares the list of items for displaying.
	 *
	 * @return void
	 */
	public function prepare_items(): void {
		$per_page = $this->get_items_per_page(
			self::PER_PAGE_USER_OPTION_KEY,
			$this->get_per_page_default()
		);

		$args = array(
			'per_page' => $per_page,
			'offset'   => ( $this->get_pagenum() - 1 ) * $per_page,
		);

		$file_args = $this->page_controller->get_query_params(
			array( 'date_end', 'date_filter', 'date_start', 'order', 'orderby', 'search', 'source' )
		);
		$search    = $file_args['search'];
		unset( $file_args['search'] );

		$total_items = $this->file_controller->search_within_files( $search, $args, $file_args, true );
		if ( is_wp_error( $total_items ) ) {
			printf(
				'<div class="notice notice-warning"><p>%s</p></div>',
				esc_html( $total_items->get_error_message() )
			);

			return;
		}

		if ( $total_items >= $this->file_controller::SEARCH_MAX_RESULTS ) {
			printf(
				'<div class="notice notice-info"><p>%s</p></div>',
				sprintf(
					// translators: %s is a number.
					esc_html__( 'The number of search results has reached the limit of %s. Try refining your search.', 'woocommerce' ),
					esc_html( number_format_i18n( $this->file_controller::SEARCH_MAX_RESULTS ) )
				)
			);
		}

		$total_pages = ceil( $total_items / $per_page );
		$results     = $this->file_controller->search_within_files( $search, $args, $file_args );
		$this->items = $results;

		$this->set_pagination_args(
			array(
				'per_page'    => $per_page,
				'total_items' => $total_items,
				'total_pages' => $total_pages,
			)
		);
	}

	/**
	 * Gets a list of columns.
	 *
	 * @return array
	 */
	public function get_columns(): array {
		$columns = array(
			'file_id'     => esc_html__( 'File', 'woocommerce' ),
			'line_number' => esc_html__( 'Line #', 'woocommerce' ),
			'line'        => esc_html__( 'Matched Line', 'woocommerce' ),
		);

		return $columns;
	}

	/**
	 * Render the file_id column.
	 *
	 * @param array $item The current search result being rendered.
	 *
	 * @return string
	 */
	public function column_file_id( array $item ): string {
		// Add a word break after the rotation number, if it exists.
		$file_id = preg_replace( '/\.([0-9])+\-/', '.\1<wbr>-', $item['file_id'] );

		return wp_kses( $file_id, array( 'wbr' => array() ) );
	}

	/**
	 * Render the line_number column.
	 *
	 * @param array $item The current search result being rendered.
	 *
	 * @return string
	 */
	public function column_line_number( array $item ): string {
		$match_url = add_query_arg(
			array(
				'view'    => 'single_file',
				'file_id' => $item['file_id'],
			),
			$this->page_controller->get_logs_tab_url() . '#L' . absint( $item['line_number'] )
		);

		return sprintf(
			'<a href="%1$s">%2$s</a>',
			esc_url( $match_url ),
			sprintf(
				// translators: %s is a line number in a file.
				esc_html__( 'Line %s', 'woocommerce' ),
				number_format_i18n( absint( $item['line_number'] ) )
			)
		);
	}

	/**
	 * Render the line column.
	 *
	 * @param array $item The current search result being rendered.
	 *
	 * @return string
	 */
	public function column_line( array $item ): string {
		$params = $this->page_controller->get_query_params( array( 'search' ) );
		$line   = $item['line'];

		// Highlight matches within the line.
		$pattern = preg_quote( $params['search'], '/' );
		preg_match_all( "/$pattern/i", $line, $matches, PREG_OFFSET_CAPTURE );
		if ( is_array( $matches[0] ) && count( $matches[0] ) >= 1 ) {
			$length_change = 0;

			foreach ( $matches[0] as $match ) {
				$replace        = '<span class="search-match">' . $match[0] . '</span>';
				$offset         = $match[1] + $length_change;
				$orig_length    = strlen( $match[0] );
				$replace_length = strlen( $replace );

				$line = substr_replace( $line, $replace, $offset, $orig_length );

				$length_change += $replace_length - $orig_length;
			}
		}

		return wp_kses_post( $line );
	}

	/**
	 * Helper to get the default value for the per_page arg.
	 *
	 * @return int
	 */
	public function get_per_page_default(): int {
		return $this->file_controller::DEFAULTS_SEARCH_WITHIN_FILES['per_page'];
	}
}
PK     [1]t(d    &  Admin/Logging/FileV2/FileListTable.phpnu         <?php
declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\Admin\Logging\FileV2;

use Automattic\WooCommerce\Internal\Admin\Logging\PageController;

use WP_List_Table;

/**
 * FileListTable class.
 */
class FileListTable extends WP_List_Table {
	/**
	 * The user option key for saving the preferred number of files displayed per page.
	 *
	 * @const string
	 */
	public const PER_PAGE_USER_OPTION_KEY = 'woocommerce_logging_file_list_per_page';

	/**
	 * Instance of FileController.
	 *
	 * @var FileController
	 */
	private $file_controller;

	/**
	 * Instance of PageController.
	 *
	 * @var PageController
	 */
	private $page_controller;

	/**
	 * FileListTable class.
	 *
	 * @param FileController $file_controller Instance of FileController.
	 * @param PageController $page_controller Instance of PageController.
	 */
	public function __construct( FileController $file_controller, PageController $page_controller ) {
		$this->file_controller = $file_controller;
		$this->page_controller = $page_controller;

		parent::__construct(
			array(
				'singular' => 'log-file',
				'plural'   => 'log-files',
				'ajax'     => false,
			)
		);
	}

	/**
	 * Render message when there are no items.
	 *
	 * @return void
	 */
	public function no_items(): void {
		esc_html_e( 'No log files found.', 'woocommerce' );
	}

	/**
	 * Retrieves the list of bulk actions available for this table.
	 *
	 * @return array
	 */
	protected function get_bulk_actions(): array {
		return array(
			'export' => esc_html__( 'Download', 'woocommerce' ),
			'delete' => esc_html__( 'Delete permanently', 'woocommerce' ),
		);
	}

	/**
	 * Get the existing log sources for the filter dropdown.
	 *
	 * @return array
	 */
	protected function get_sources_list(): array {
		$sources = $this->file_controller->get_file_sources();
		if ( is_wp_error( $sources ) ) {
			return array();
		}

		sort( $sources );

		return $sources;
	}

	/**
	 * Displays extra controls between bulk actions and pagination.
	 *
	 * @param string $which The location of the tablenav being rendered. 'top' or 'bottom'.
	 *
	 * @return void
	 */
	protected function extra_tablenav( $which ): void {
		$all_sources = $this->get_sources_list();

		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended
		$current_source = File::sanitize_source( wp_unslash( $_GET['source'] ?? '' ) );

		?>
		<div class="alignleft actions">
			<?php if ( 'top' === $which ) : ?>
				<label for="filter-by-source" class="screen-reader-text"><?php esc_html_e( 'Filter by log source', 'woocommerce' ); ?></label>
				<select name="source" id="filter-by-source">
					<option<?php selected( $current_source, '' ); ?> value=""><?php esc_html_e( 'All sources', 'woocommerce' ); ?></option>
					<?php foreach ( $all_sources as $source ) : ?>
						<option<?php selected( $current_source, $source ); ?> value="<?php echo esc_attr( $source ); ?>">
							<?php echo esc_html( $source ); ?>
						</option>
					<?php endforeach; ?>
				</select>
				<?php
				submit_button(
					__( 'Filter', 'woocommerce' ),
					'',
					'filter_action',
					false,
					array(
						'id' => 'logs-filter-submit',
					)
				);
				?>
			<?php endif; ?>
		</div>
		<?php
	}

	/**
	 * Set up the column header info.
	 *
	 * @return void
	 */
	public function prepare_column_headers(): void {
		$this->_column_headers = array(
			$this->get_columns(),
			get_hidden_columns( $this->screen ),
			$this->get_sortable_columns(),
			$this->get_primary_column(),
		);
	}

	/**
	 * Prepares the list of items for displaying.
	 *
	 * @return void
	 */
	public function prepare_items(): void {
		$per_page = $this->get_items_per_page(
			self::PER_PAGE_USER_OPTION_KEY,
			$this->get_per_page_default()
		);

		$defaults  = array(
			'per_page' => $per_page,
			'offset'   => ( $this->get_pagenum() - 1 ) * $per_page,
		);
		$file_args = wp_parse_args(
			$this->page_controller->get_query_params( array( 'order', 'orderby', 'source' ) ),
			$defaults
		);

		$total_items = $this->file_controller->get_files( $file_args, true );
		if ( is_wp_error( $total_items ) ) {
			printf(
				'<div class="notice notice-warning"><p>%s</p></div>',
				esc_html( $total_items->get_error_message() )
			);

			return;
		}

		$total_pages = ceil( $total_items / $per_page );
		$items       = $this->file_controller->get_files( $file_args );

		$this->items = $items;

		$this->set_pagination_args(
			array(
				'per_page'    => $per_page,
				'total_items' => $total_items,
				'total_pages' => $total_pages,
			)
		);
	}

	/**
	 * Gets a list of columns.
	 *
	 * @return array
	 */
	public function get_columns(): array {
		$columns = array(
			'cb'       => '<input type="checkbox" />',
			'source'   => esc_html__( 'Source', 'woocommerce' ),
			'created'  => esc_html__( 'Date created', 'woocommerce' ),
			'modified' => esc_html__( 'Date modified', 'woocommerce' ),
			'size'     => esc_html__( 'File size', 'woocommerce' ),
		);

		return $columns;
	}

	/**
	 * Gets a list of sortable columns.
	 *
	 * @return array
	 */
	protected function get_sortable_columns(): array {
		$sortable = array(
			'source'   => array( 'source' ),
			'created'  => array( 'created' ),
			'modified' => array( 'modified', true ),
			'size'     => array( 'size' ),
		);

		return $sortable;
	}

	/**
	 * Render the checkbox column.
	 *
	 * @param File $item The current log file being rendered.
	 *
	 * @return string
	 */
	public function column_cb( $item ): string {
		ob_start();
		?>
		<input
			id="cb-select-<?php echo esc_attr( $item->get_file_id() ); ?>"
			type="checkbox"
			name="file_id[]"
			value="<?php echo esc_attr( $item->get_file_id() ); ?>"
		/>
		<label for="cb-select-<?php echo esc_attr( $item->get_file_id() ); ?>">
			<span class="screen-reader-text">
				<?php
				printf(
					// translators: 1. a date, 2. a slug-style name for a file.
					esc_html__( 'Select the %1$s log file for %2$s', 'woocommerce' ),
					esc_html( gmdate( get_option( 'date_format' ), $item->get_created_timestamp() ) ),
					esc_html( $item->get_source() )
				);
				?>
			</span>
		</label>
		<?php
		return ob_get_clean();
	}

	/**
	 * Render the source column.
	 *
	 * @param File $item The current log file being rendered.
	 *
	 * @return string
	 */
	public function column_source( $item ): string {
		$log_file        = $item->get_file_id();
		$single_file_url = add_query_arg(
			array(
				'view'    => 'single_file',
				'file_id' => $log_file,
			),
			$this->page_controller->get_logs_tab_url()
		);
		$rotation        = '';
		if ( ! is_null( $item->get_rotation() ) ) {
			$rotation = sprintf(
				' &ndash; <span class="post-state">%d</span>',
				$item->get_rotation()
			);
		}

		return sprintf(
			'<a class="row-title" href="%1$s">%2$s</a>%3$s',
			esc_url( $single_file_url ),
			esc_html( $item->get_source() ),
			$rotation
		);
	}

	/**
	 * Render the created column.
	 *
	 * @param File $item The current log file being rendered.
	 *
	 * @return string
	 */
	public function column_created( $item ): string {
		$timestamp = $item->get_created_timestamp();

		return gmdate( 'Y-m-d', $timestamp );
	}

	/**
	 * Render the modified column.
	 *
	 * @param File $item The current log file being rendered.
	 *
	 * @return string
	 */
	public function column_modified( $item ): string {
		$timestamp = $item->get_modified_timestamp();

		return gmdate( 'Y-m-d H:i:s', $timestamp );
	}

	/**
	 * Render the size column.
	 *
	 * @param File $item The current log file being rendered.
	 *
	 * @return string
	 */
	public function column_size( $item ): string {
		$size = $item->get_file_size();

		return size_format( $size );
	}

	/**
	 * Helper to get the default value for the per_page arg.
	 *
	 * @return int
	 */
	public function get_per_page_default(): int {
		return $this->file_controller::DEFAULTS_GET_FILES['per_page'];
	}
}
PK     [1]t+K  +K  '  Admin/Logging/FileV2/FileController.phpnu         <?php
declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\Admin\Logging\FileV2;

use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Admin\Logging\Settings;
use PclZip;
use WC_Cache_Helper;
use WP_Error;

/**
 * FileController class.
 */
class FileController {
	/**
	 * The maximum number of rotations for a file before they start getting overwritten.
	 *
	 * This number should not go above 10, or it will cause issues with the glob patterns.
	 *
	 * const int
	 */
	private const MAX_FILE_ROTATIONS = 10;

	/**
	 * Default values for arguments for the get_files method.
	 *
	 * @const array
	 */
	public const DEFAULTS_GET_FILES = array(
		'date_end'    => 0,
		'date_filter' => '',
		'date_start'  => 0,
		'offset'      => 0,
		'order'       => 'desc',
		'orderby'     => 'modified',
		'per_page'    => 20,
		'source'      => '',
	);

	/**
	 * Default values for arguments for the search_within_files method.
	 *
	 * @const array
	 */
	public const DEFAULTS_SEARCH_WITHIN_FILES = array(
		'offset'   => 0,
		'per_page' => 50,
	);

	/**
	 * The maximum number of files that can be searched at one time.
	 *
	 * @const int
	 */
	public const SEARCH_MAX_FILES = 100;

	/**
	 * The maximum number of search results that can be returned at one time.
	 *
	 * @const int
	 */
	public const SEARCH_MAX_RESULTS = 200;

	/**
	 * The cache group name to use for caching operations.
	 *
	 * @const string
	 */
	private const CACHE_GROUP = 'log-files';

	/**
	 * A cache key for storing and retrieving the results of the last logs search.
	 *
	 * @const string
	 */
	private const SEARCH_CACHE_KEY = 'logs_previous_search';

	/**
	 * Get the file size limit that determines when to rotate a file.
	 *
	 * @return int
	 */
	private function get_file_size_limit(): int {
		$default = 5 * MB_IN_BYTES;

		/**
		 * Filter the threshold size of a log file at which point it will get rotated.
		 *
		 * @since 3.4.0
		 *
		 * @param int $file_size_limit The file size limit in bytes.
		 */
		$file_size_limit = apply_filters( 'woocommerce_log_file_size_limit', $default );

		if ( ! is_int( $file_size_limit ) || $file_size_limit < 1 ) {
			return $default;
		}

		return $file_size_limit;
	}

	/**
	 * Write a log entry to the appropriate file, after rotating the file if necessary.
	 *
	 * @param string   $source The source property of the log entry, which determines which file to write to.
	 * @param string   $text   The contents of the log entry to add to a file.
	 * @param int|null $time   Optional. The time of the log entry as a Unix timestamp. Defaults to the current time.
	 *
	 * @return bool True if the contents were successfully written to the file.
	 */
	public function write_to_file( string $source, string $text, ?int $time = null ): bool {
		if ( is_null( $time ) ) {
			$time = time();
		}

		$file_id = File::generate_file_id( $source, null, $time );
		$file    = $this->get_file_by_id( $file_id );

		if ( $file instanceof File && $file->get_file_size() >= $this->get_file_size_limit() ) {
			$rotated = $this->rotate_file( $file->get_file_id() );

			if ( $rotated ) {
				$file = null;
			} else {
				return false;
			}
		}

		if ( ! $file instanceof File ) {
			$new_path = Settings::get_log_directory() . $this->generate_filename( $source, $time );
			$file     = new File( $new_path );
		}

		return $file->write( $text );
	}

	/**
	 * Generate the full name of a file based on source and date values.
	 *
	 * @param string $source The source property of a log entry, which determines the filename.
	 * @param int    $time   The time of the log entry as a Unix timestamp.
	 *
	 * @return string
	 */
	private function generate_filename( string $source, int $time ): string {
		$file_id = File::generate_file_id( $source, null, $time );
		$hash    = File::generate_hash( $file_id );

		return "$file_id-$hash.log";
	}

	/**
	 * Get all the rotations of a file and increment them, so that they overwrite the previous file with that rotation.
	 *
	 * @param string $file_id A file ID (file basename without the hash).
	 *
	 * @return bool True if the file and all its rotations were successfully rotated.
	 */
	private function rotate_file( $file_id ): bool {
		$rotations = $this->get_file_rotations( $file_id );

		if ( is_wp_error( $rotations ) || ! isset( $rotations['current'] ) ) {
			return false;
		}

		$max_rotation_marker = self::MAX_FILE_ROTATIONS - 1;

		// Don't rotate a file with the maximum rotation.
		unset( $rotations[ $max_rotation_marker ] );

		$results = array();
		// Rotate starting with oldest first and working backwards.
		for ( $i = $max_rotation_marker; $i >= 0; $i -- ) {
			if ( isset( $rotations[ $i ] ) ) {
				$results[] = $rotations[ $i ]->rotate();
			}
		}
		$results[] = $rotations['current']->rotate();

		return ! in_array( false, $results, true );
	}

	/**
	 * Get an array of log files.
	 *
	 * @param array $args      {
	 *     Optional. Arguments to filter and sort the files that are returned.
	 *
	 *     @type int    $date_end    The end of the date range to filter by, as a Unix timestamp.
	 *     @type string $date_filter Filter files by one of the date props. 'created' or 'modified'.
	 *     @type int    $date_start  The beginning of the date range to filter by, as a Unix timestamp.
	 *     @type int    $offset      Omit this number of files from the beginning of the list. Works with $per_page to do pagination.
	 *     @type string $order       The sort direction. 'asc' or 'desc'. Defaults to 'desc'.
	 *     @type string $orderby     The property to sort the list by. 'created', 'modified', 'source', 'size'. Defaults to 'modified'.
	 *     @type int    $per_page    The number of files to include in the list. Works with $offset to do pagination.
	 *     @type string $source      Only include files from this source.
	 * }
	 * @param bool  $count_only Optional. True to return a total count of the files.
	 *
	 * @return File[]|int|WP_Error
	 */
	public function get_files( array $args = array(), bool $count_only = false ) {
		$args = wp_parse_args( $args, self::DEFAULTS_GET_FILES );

		$pattern = $args['source'] . '*.log';
		$paths   = glob( Settings::get_log_directory() . $pattern );

		if ( false === $paths ) {
			return new WP_Error(
				'wc_log_directory_error',
				__( 'Could not access the log file directory.', 'woocommerce' )
			);
		}

		$files = $this->convert_paths_to_objects( $paths );

		if ( $args['date_filter'] && $args['date_start'] && $args['date_end'] ) {
			switch ( $args['date_filter'] ) {
				case 'created':
					$files = array_filter(
						$files,
						fn( $file ) => $file->get_created_timestamp() >= $args['date_start']
							&& $file->get_created_timestamp() <= $args['date_end']
					);
					break;
				case 'modified':
					$files = array_filter(
						$files,
						fn( $file ) => $file->get_modified_timestamp() >= $args['date_start']
							&& $file->get_modified_timestamp() <= $args['date_end']
					);
					break;
			}
		}

		if ( true === $count_only ) {
			return count( $files );
		}

		$multi_sorter = function( $sort_sets, $order_sets ) {
			$comparison = 0;

			while ( ! empty( $sort_sets ) ) {
				$set   = array_shift( $sort_sets );
				$order = array_shift( $order_sets );

				if ( 'desc' === $order ) {
					$comparison = $set[1] <=> $set[0];
				} else {
					$comparison = $set[0] <=> $set[1];
				}

				if ( 0 !== $comparison ) {
					break;
				}
			}

			return $comparison;
		};

		switch ( $args['orderby'] ) {
			case 'created':
				$sort_callback = function( $a, $b ) use ( $args, $multi_sorter ) {
					$sort_sets  = array(
						array( $a->get_created_timestamp(), $b->get_created_timestamp() ),
						array( $a->get_source(), $b->get_source() ),
						array( $a->get_rotation() || -1, $b->get_rotation() || -1 ),
					);
					$order_sets = array( $args['order'], 'asc', 'asc' );
					return $multi_sorter( $sort_sets, $order_sets );
				};
				break;
			case 'modified':
				$sort_callback = function( $a, $b ) use ( $args, $multi_sorter ) {
					$sort_sets  = array(
						array( $a->get_modified_timestamp(), $b->get_modified_timestamp() ),
						array( $a->get_source(), $b->get_source() ),
						array( $a->get_rotation() || -1, $b->get_rotation() || -1 ),
					);
					$order_sets = array( $args['order'], 'asc', 'asc' );
					return $multi_sorter( $sort_sets, $order_sets );
				};
				break;
			case 'source':
				$sort_callback = function( $a, $b ) use ( $args, $multi_sorter ) {
					$sort_sets  = array(
						array( $a->get_source(), $b->get_source() ),
						array( $a->get_created_timestamp(), $b->get_created_timestamp() ),
						array( $a->get_rotation() || -1, $b->get_rotation() || -1 ),
					);
					$order_sets = array( $args['order'], 'desc', 'asc' );
					return $multi_sorter( $sort_sets, $order_sets );
				};
				break;
			case 'size':
				$sort_callback = function( $a, $b ) use ( $args, $multi_sorter ) {
					$sort_sets  = array(
						array( $a->get_file_size(), $b->get_file_size() ),
						array( $a->get_source(), $b->get_source() ),
						array( $a->get_rotation() || -1, $b->get_rotation() || -1 ),
					);
					$order_sets = array( $args['order'], 'asc', 'asc' );
					return $multi_sorter( $sort_sets, $order_sets );
				};
				break;
		}

		usort( $files, $sort_callback );

		return array_slice( $files, $args['offset'], $args['per_page'] );
	}

	/**
	 * Get one or more File instances from an array of file IDs.
	 *
	 * @param array $file_ids An array of file IDs (file basename without the hash).
	 *
	 * @return File[]
	 */
	public function get_files_by_id( array $file_ids ): array {
		$log_directory = Settings::get_log_directory();
		$paths         = array();

		foreach ( $file_ids as $file_id ) {
			// Look for the standard filename format first, which includes a hash.
			$glob = glob( $log_directory . $file_id . '-*.log' );

			if ( ! $glob ) {
				$glob = glob( $log_directory . $file_id . '.log' );
			}

			if ( is_array( $glob ) ) {
				$paths = array_merge( $paths, $glob );
			}
		}

		$files = $this->convert_paths_to_objects( array_unique( $paths ) );

		return $files;
	}

	/**
	 * Get a File instance from a file ID.
	 *
	 * @param string $file_id A file ID (file basename without the hash).
	 *
	 * @return File|WP_Error
	 */
	public function get_file_by_id( string $file_id ) {
		$result = $this->get_files_by_id( array( $file_id ) );

		if ( count( $result ) < 1 ) {
			return new WP_Error(
				'wc_log_file_error',
				esc_html__( 'This file does not exist.', 'woocommerce' )
			);
		}

		if ( count( $result ) > 1 ) {
			return new WP_Error(
				'wc_log_file_error',
				esc_html__( 'Multiple files match this ID.', 'woocommerce' )
			);
		}

		return reset( $result );
	}

	/**
	 * Get File instances for a given file ID and all of its related rotations.
	 *
	 * @param string $file_id A file ID (file basename without the hash).
	 *
	 * @return File[]|WP_Error An associative array where the rotation integer of the file is the key, and a "current"
	 *                         key for the iteration of the file that hasn't been rotated (if it exists).
	 */
	public function get_file_rotations( string $file_id ) {
		$file = $this->get_file_by_id( $file_id );

		if ( is_wp_error( $file ) ) {
			return $file;
		}

		$current   = array();
		$rotations = array();

		$source  = $file->get_source();
		$created = 0;
		if ( $file->has_standard_filename() ) {
			$created = $file->get_created_timestamp();
		}

		if ( is_null( $file->get_rotation() ) ) {
			$current['current'] = $file;
		} else {
			$current_file_id = File::generate_file_id( $source, null, $created );
			$result          = $this->get_file_by_id( $current_file_id );
			if ( ! is_wp_error( $result ) ) {
				$current['current'] = $result;
			}
		}

		$rotations_pattern = sprintf(
			'.[%s]',
			implode(
				'',
				range( 0, self::MAX_FILE_ROTATIONS - 1 )
			)
		);

		$created_pattern = $created ? '-' . gmdate( 'Y-m-d', $created ) . '-' : '';

		$rotation_pattern = Settings::get_log_directory() . $source . $rotations_pattern . $created_pattern . '*.log';
		$rotation_paths   = glob( $rotation_pattern );
		$rotation_files   = $this->convert_paths_to_objects( $rotation_paths );
		foreach ( $rotation_files as $rotation_file ) {
			if ( $rotation_file->is_readable() ) {
				$rotations[ $rotation_file->get_rotation() ] = $rotation_file;
			}
		}

		ksort( $rotations );

		return array_merge( $current, $rotations );
	}

	/**
	 * Helper method to get an array of File instances.
	 *
	 * @param array $paths An array of absolute file paths.
	 *
	 * @return File[]
	 */
	private function convert_paths_to_objects( array $paths ): array {
		$files = array_map(
			function( $path ) {
				$file = new File( $path );
				return $file->is_readable() ? $file : null;
			},
			$paths
		);

		return array_filter( $files );
	}

	/**
	 * Get a list of sources for existing log files.
	 *
	 * @return array|WP_Error
	 */
	public function get_file_sources() {
		$paths = glob( Settings::get_log_directory() . '*.log' );
		if ( false === $paths ) {
			return new WP_Error(
				'wc_log_directory_error',
				__( 'Could not access the log file directory.', 'woocommerce' )
			);
		}

		$all_sources = array_map(
			function( $path ) {
				$file = new File( $path );
				return $file->is_readable() ? $file->get_source() : null;
			},
			$paths
		);

		return array_unique( array_filter( $all_sources ) );
	}

	/**
	 * Delete one or more files from the filesystem.
	 *
	 * @param array $file_ids An array of file IDs (file basename without the hash).
	 *
	 * @return int The number of files that were deleted.
	 */
	public function delete_files( array $file_ids ): int {
		$deleted = 0;

		$files = $this->get_files_by_id( $file_ids );
		foreach ( $files as $file ) {
			$result = $file->delete();

			if ( true === $result ) {
				$deleted ++;
			}
		}

		if ( $deleted > 0 ) {
			$this->invalidate_cache();
		}

		return $deleted;
	}

	/**
	 * Stream a single file to the browser without zipping it first.
	 *
	 * @param string $file_id A file ID (file basename without the hash).
	 *
	 * @return WP_Error|void Only returns something if there is an error.
	 */
	public function export_single_file( $file_id ) {
		$file = $this->get_file_by_id( $file_id );

		if ( is_wp_error( $file ) ) {
			return $file;
		}

		$file_name = $file->get_file_id() . '.log';
		$exporter  = new FileExporter( $file->get_path(), $file_name );

		return $exporter->emit_file();
	}

	/**
	 * Create a zip archive of log files and stream it to the browser.
	 *
	 * @param array $file_ids An array of file IDs (file basename without the hash).
	 *
	 * @return WP_Error|void Only returns something if there is an error.
	 */
	public function export_multiple_files( array $file_ids ) {
		$files = $this->get_files_by_id( $file_ids );

		if ( count( $files ) < 1 ) {
			return new WP_Error(
				'wc_logs_invalid_file',
				__( 'Could not access the specified files.', 'woocommerce' )
			);
		}

		$temp_dir = get_temp_dir();

		if ( ! is_dir( $temp_dir ) || ! wp_is_writable( $temp_dir ) ) {
			return new WP_Error(
				'wc_logs_invalid_directory',
				__( 'Could not write to the temp directory. Try downloading files one at a time instead.', 'woocommerce' )
			);
		}

		require_once ABSPATH . 'wp-admin/includes/class-pclzip.php';

		$path       = trailingslashit( $temp_dir ) . 'woocommerce_logs_' . gmdate( 'Y-m-d_H-i-s' ) . '.zip';
		$file_paths = array_map(
			fn( $file ) => $file->get_path(),
			$files
		);
		$archive    = new PclZip( $path );

		$archive->create( $file_paths, PCLZIP_OPT_REMOVE_ALL_PATH );

		$exporter = new FileExporter( $path );

		return $exporter->emit_file();
	}

	/**
	 * Search within a set of log files for a particular string.
	 *
	 * @param string $search     The string to search for.
	 * @param array  $args       Optional. Arguments for pagination of search results.
	 * @param array  $file_args  Optional. Arguments to filter and sort the files that are returned. See get_files().
	 * @param bool   $count_only Optional. True to return a total count of the matches.
	 *
	 * @return array|int|WP_Error When matches are found, each array item is an associative array that includes the
	 *                            file ID, line number, and the matched string with HTML markup around the matched parts.
	 */
	public function search_within_files( string $search, array $args = array(), array $file_args = array(), bool $count_only = false ) {
		if ( '' === $search ) {
			return $count_only ? 0 : array();
		}

		$search = esc_html( $search );

		$args = wp_parse_args( $args, self::DEFAULTS_SEARCH_WITHIN_FILES );

		$file_args = array_merge(
			$file_args,
			array(
				'offset'   => 0,
				'per_page' => self::SEARCH_MAX_FILES,
			)
		);

		$cache_key = WC_Cache_Helper::get_prefixed_key( self::SEARCH_CACHE_KEY, self::CACHE_GROUP );
		$query     = wp_json_encode( array( $search, $args, $file_args ) );
		$cache     = wp_cache_get( $cache_key );
		$is_cached = isset( $cache['query'], $cache['results'] ) && $query === $cache['query'];

		if ( true === $is_cached ) {
			$matched_lines = $cache['results'];
		} else {
			$files = $this->get_files( $file_args );
			if ( is_wp_error( $files ) ) {
				return $files;
			}

			// Max string size * SEARCH_MAX_RESULTS = ~1MB largest possible cache entry.
			$max_string_size = 5 * KB_IN_BYTES;

			$matched_lines = array();

			foreach ( $files as $file ) {
				$stream      = $file->get_stream();
				$line_number = 1;

				while ( ! feof( $stream ) ) {
					$line = fgets( $stream, $max_string_size );
					if ( ! is_string( $line ) ) {
						continue;
					}

					$sanitized_line = esc_html( trim( $line ) );
					if ( false !== stripos( $sanitized_line, $search ) ) {
						$matched_lines[] = array(
							'file_id'     => $file->get_file_id(),
							'line_number' => $line_number,
							'line'        => $sanitized_line,
						);
					}

					if ( count( $matched_lines ) >= self::SEARCH_MAX_RESULTS ) {
						$file->close_stream();
						break 2;
					}

					if ( false !== strstr( $line, PHP_EOL ) ) {
						$line_number ++;
					}
				}

				$file->close_stream();
			}

			$to_cache = array(
				'query'   => $query,
				'results' => $matched_lines,
			);
			wp_cache_set( $cache_key, $to_cache, self::CACHE_GROUP, DAY_IN_SECONDS );
		}

		if ( true === $count_only ) {
			return count( $matched_lines );
		}

		return array_slice( $matched_lines, $args['offset'], $args['per_page'] );
	}

	/**
	 * Calculate the size, in bytes, of the log directory.
	 *
	 * @return int
	 */
	public function get_log_directory_size(): int {
		$bytes = 0;
		$path  = realpath( Settings::get_log_directory( false ) );

		if ( wp_is_writable( $path ) ) {
			$iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $path, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CATCH_GET_CHILD );

			foreach ( $iterator as $file ) {
				$bytes += $file->getSize();
			}
		}

		return $bytes;
	}

	/**
	 * Invalidate the cache group related to log file data.
	 *
	 * @return bool True on successfully invalidating the cache.
	 */
	public function invalidate_cache(): bool {
		return WC_Cache_Helper::invalidate_cache_group( self::CACHE_GROUP );
	}
}
PK     [1]A|5  5    Admin/Logging/FileV2/File.phpnu         <?php
declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\Admin\Logging\FileV2;

use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
use Exception;

/**
 * File class.
 *
 * An object representation of a single log file.
 */
class File {
	/**
	 * The absolute path of the file.
	 *
	 * @var string
	 */
	protected $path;

	/**
	 * The source property of the file, derived from the filename.
	 *
	 * @var string
	 */
	protected $source = '';

	/**
	 * The 0-based increment of the file, if it has been rotated. Derived from the filename. Can only be 0-9.
	 *
	 * @var int|null
	 */
	protected $rotation;

	/**
	 * The date the file was created, as a Unix timestamp, derived from the filename.
	 *
	 * @var int
	 */
	protected $created = 0;

	/**
	 * The hash property of the file, derived from the filename.
	 *
	 * @var string
	 */
	protected $hash = '';

	/**
	 * The file's resource handle when it is open.
	 *
	 * @var resource
	 */
	protected $stream;

	/**
	 * Class File
	 *
	 * @param string $path The absolute path of the file.
	 */
	public function __construct( $path ) {
		$this->path = $path;
		$this->ingest_path();
	}

	/**
	 * Make sure open streams are closed.
	 */
	public function __destruct() {
		if ( is_resource( $this->stream ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fclose -- No suitable alternative.
			fclose( $this->stream );
		}
	}

	/**
	 * Parse a path to a log file to determine if it uses the standard filename structure and various properties.
	 *
	 * This makes assumptions about the structure of the log file's name. Using `-` to separate the name into segments,
	 *  if there are at least 5 segments, it assumes that the last segment is the hash, and the three segments before
	 *  that make up the date when the file was created in YYYY-MM-DD format. Any segments left after that are the
	 *  "source" that generated the log entries. If the filename doesn't have enough segments, it falls back to the
	 *  source and the hash both being the entire filename, and using the inode change time as the creation date.
	 *
	 *  Example:
	 *      my-custom-plugin.2-2025-01-01-a1b2c3d4e5f.log
	 *            |          |       |         |
	 *    'my-custom-plugin' | '2025-01-01'    |
	 *         (source)      |   (created)     |
	 *                      '2'          'a1b2c3d4e5f'
	 *                  (rotation)           (hash)
	 *
	 * @param string $path The full path of the log file.
	 *
	 * @return array {
	 *     @type string   $dirname   The directory structure containing the file. See pathinfo().
	 *     @type string   $basename  The filename with extension. See pathinfo().
	 *     @type string   $extension The file extension. See pathinfo().
	 *     @type string   $filename  The filename without extension. See pathinfo().
	 *     @type string   $source    The source of the log entries contained in the file.
	 *     @type int|null $rotation  The 0-based incremental rotation marker, if the file has been rotated.
	 *                               Should only be a single digit.
	 *     @type int      $created   The date the file was created, as a Unix timestamp.
	 *     @type string   $hash      The hash suffix of the filename that protects from direct access.
	 *     @type string   $file_id   The public ID of the log file (filename without the hash).
	 * }
	 */
	public static function parse_path( string $path ): array {
		$defaults = array(
			'dirname'   => '',
			'basename'  => '',
			'extension' => '',
			'filename'  => '',
			'source'    => '',
			'rotation'  => null,
			'created'   => 0,
			'hash'      => '',
			'file_id'   => '',
		);

		$parsed = array_merge( $defaults, pathinfo( $path ) );

		$segments  = explode( '-', $parsed['filename'] );
		$timestamp = strtotime( implode( '-', array_slice( $segments, -4, 3 ) ) );

		if ( count( $segments ) >= 5 && false !== $timestamp ) {
			$parsed['source']  = implode( '-', array_slice( $segments, 0, -4 ) );
			$parsed['created'] = $timestamp;
			$parsed['hash']    = array_slice( $segments, -1 )[0];
		} else {
			$parsed['source'] = implode( '-', $segments );
		}

		$rotation_marker = strrpos( $parsed['source'], '.', -1 );
		if ( false !== $rotation_marker ) {
			$rotation = substr( $parsed['source'], -1 );
			if ( is_numeric( $rotation ) ) {
				$parsed['rotation'] = intval( $rotation );
			}

			$parsed['source'] = substr( $parsed['source'], 0, $rotation_marker );
		}

		$parsed['file_id'] = static::generate_file_id(
			$parsed['source'],
			$parsed['rotation'],
			$parsed['created']
		);

		return $parsed;
	}

	/**
	 * Generate a public ID for a log file based on its properties.
	 *
	 * The file ID is the basename of the file without the hash part. It allows us to identify a file without revealing
	 * its full name in the filesystem, so that it's difficult to access the file directly with an HTTP request.
	 *
	 * @param string   $source   The source of the log entries contained in the file.
	 * @param int|null $rotation Optional. The 0-based incremental rotation marker, if the file has been rotated.
	 *                           Should only be a single digit.
	 * @param int      $created  Optional. The date the file was created, as a Unix timestamp.
	 *
	 * @return string
	 */
	public static function generate_file_id( string $source, ?int $rotation = null, int $created = 0 ): string {
		$file_id = static::sanitize_source( $source );

		if ( ! is_null( $rotation ) ) {
			$file_id .= '.' . $rotation;
		}

		if ( $created > 0 ) {
			$file_id .= '-' . gmdate( 'Y-m-d', $created );
		}

		return $file_id;
	}

	/**
	 * Generate a hash to use as the suffix on a log filename.
	 *
	 * @param string $file_id A file ID (file basename without the hash).
	 *
	 * @return string
	 */
	public static function generate_hash( string $file_id ): string {
		$key = Constants::get_constant( 'AUTH_SALT' ) ?? 'wc-logs';

		return hash_hmac( 'md5', $file_id, $key );
	}

	/**
	 * Sanitize the source property of a log file.
	 *
	 * @param string $source The source of the log entries contained in the file.
	 *
	 * @return string
	 */
	public static function sanitize_source( string $source ): string {
		return sanitize_file_name( $source );
	}

	/**
	 * Parse the log file path and assign various properties to this class instance.
	 *
	 * @return void
	 */
	protected function ingest_path(): void {
		$parsed_path    = static::parse_path( $this->path );
		$this->source   = $parsed_path['source'];
		$this->rotation = $parsed_path['rotation'];
		$this->created  = $parsed_path['created'];
		$this->hash     = $parsed_path['hash'];
	}

	/**
	 * Check if the filename structure is in the expected format.
	 *
	 * @see parse_path().
	 *
	 * @return bool
	 */
	public function has_standard_filename(): bool {
		return ! ! $this->get_hash();
	}

	/**
	 * Check if the file represented by the class instance is a file and is readable.
	 *
	 * @return bool
	 */
	public function is_readable(): bool {
		try {
			$filesystem  = FilesystemUtil::get_wp_filesystem();
			$is_readable = $filesystem->is_file( $this->path ) && $filesystem->is_readable( $this->path );
		} catch ( Exception $exception ) {
			return false;
		}

		return $is_readable;
	}

	/**
	 * Check if the file represented by the class instance is a file and is writable.
	 *
	 * @return bool
	 */
	public function is_writable(): bool {
		try {
			$filesystem  = FilesystemUtil::get_wp_filesystem();
			$is_writable = $filesystem->is_file( $this->path ) && $filesystem->is_writable( $this->path );
		} catch ( Exception $exception ) {
			return false;
		}

		return $is_writable;
	}

	/**
	 * Open a read-only stream for this file.
	 *
	 * @return resource|false
	 */
	public function get_stream() {
		if ( ! $this->is_readable() ) {
			return false;
		}

		if ( ! is_resource( $this->stream ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fopen -- No suitable alternative.
			$this->stream = fopen( $this->path, 'rb' );
		}

		return $this->stream;
	}

	/**
	 * Close the stream for this file.
	 *
	 * The stream will also close automatically when the class instance destructs, but this can be useful for
	 * avoiding having a large number of streams open simultaneously.
	 *
	 * @return bool
	 */
	public function close_stream(): bool {
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fclose -- No suitable alternative.
		return fclose( $this->stream );
	}

	/**
	 * Get the full absolute path of the file.
	 *
	 * @return string
	 */
	public function get_path(): string {
		return $this->path;
	}

	/**
	 * Get the name of the file, with extension, but without full path.
	 *
	 * @return string
	 */
	public function get_basename(): string {
		return basename( $this->path );
	}

	/**
	 * Get the file's source property.
	 *
	 * @return string
	 */
	public function get_source(): string {
		return $this->source;
	}

	/**
	 * Get the file's rotation property.
	 *
	 * @return int|null
	 */
	public function get_rotation(): ?int {
		return $this->rotation;
	}

	/**
	 * Get the file's hash property.
	 *
	 * @return string
	 */
	public function get_hash(): string {
		return $this->hash;
	}

	/**
	 * Get the file's public ID.
	 *
	 * @return string
	 */
	public function get_file_id(): string {
		$created = 0;
		if ( $this->has_standard_filename() ) {
			$created = $this->get_created_timestamp();
		}

		$file_id = static::generate_file_id(
			$this->get_source(),
			$this->get_rotation(),
			$created
		);

		return $file_id;
	}

	/**
	 * Get the file's created property.
	 *
	 * @return int
	 */
	public function get_created_timestamp(): int {
		if ( ! $this->created && $this->is_readable() ) {
			$this->created = filectime( $this->path );
		}

		return $this->created;
	}

	/**
	 * Get the time of the last modification of the file, as a Unix timestamp. Or false if the file isn't readable.
	 *
	 * @return int|false
	 */
	public function get_modified_timestamp() {
		try {
			$filesystem = FilesystemUtil::get_wp_filesystem();
			$timestamp  = $filesystem->mtime( $this->path );
		} catch ( Exception $exception ) {
			return false;
		}

		return $timestamp;
	}

	/**
	 * Get the size of the file in bytes. Or false if the file isn't readable.
	 *
	 * @return int|false
	 */
	public function get_file_size() {
		try {
			$filesystem = FilesystemUtil::get_wp_filesystem();

			if ( ! $filesystem->is_readable( $this->path ) ) {
				return false;
			}

			$size = $filesystem->size( $this->path );
		} catch ( Exception $exception ) {
			return false;
		}

		return $size;
	}

	/**
	 * Create and set permissions on the file.
	 *
	 * @return bool
	 */
	protected function create(): bool {
		try {
			$filesystem = FilesystemUtil::get_wp_filesystem();
			$created    = $filesystem->touch( $this->path );
			$modded     = $filesystem->chmod( $this->path );
		} catch ( Exception $exception ) {
			return false;
		}

		return $created && $modded;
	}

	/**
	 * Write content to the file, appending it to the end.
	 *
	 * @param string $text The content to add to the file.
	 *
	 * @return bool
	 */
	public function write( string $text ): bool {
		if ( '' === $text ) {
			return false;
		}

		if ( ! $this->is_writable() ) {
			$created = $this->create();

			if ( ! $created || ! $this->is_writable() ) {
				return false;
			}
		}

		// Ensure content ends with a line ending.
		$eol_pos = strrpos( $text, PHP_EOL );
		if ( false === $eol_pos || strlen( $text ) !== $eol_pos + 1 ) {
			$text .= PHP_EOL;
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fopen -- No suitable alternative.
		$resource = fopen( $this->path, 'ab' );

		mbstring_binary_safe_encoding();
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fwrite -- No suitable alternative.
		$bytes_written = fwrite( $resource, $text );
		reset_mbstring_encoding();

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fclose -- No suitable alternative.
		fclose( $resource );

		if ( strlen( $text ) !== $bytes_written ) {
			return false;
		}

		return true;
	}

	/**
	 * Rename this file with an incremented rotation number.
	 *
	 * @return bool True if the file was successfully rotated.
	 */
	public function rotate(): bool {
		if ( ! $this->is_writable() ) {
			return false;
		}

		$created = 0;
		if ( $this->has_standard_filename() ) {
			$created = $this->get_created_timestamp();
		}

		if ( is_null( $this->get_rotation() ) ) {
			$new_rotation = 0;
		} else {
			$new_rotation = $this->get_rotation() + 1;
		}

		$new_file_id = static::generate_file_id( $this->get_source(), $new_rotation, $created );

		$search  = array( $this->get_file_id() );
		$replace = array( $new_file_id );
		if ( $this->has_standard_filename() ) {
			$search[]  = $this->get_hash();
			$replace[] = static::generate_hash( $new_file_id );
		}

		$old_filename = $this->get_basename();
		$new_filename = str_replace( $search, $replace, $old_filename );
		$new_path     = str_replace( $old_filename, $new_filename, $this->path );

		try {
			$filesystem = FilesystemUtil::get_wp_filesystem();
			$moved      = $filesystem->move( $this->path, $new_path, true );
		} catch ( Exception $exception ) {
			return false;
		}

		if ( ! $moved ) {
			return false;
		}

		$this->path = $new_path;
		$this->ingest_path();

		return $this->is_readable();
	}

	/**
	 * Delete the file from the filesystem.
	 *
	 * @return bool True on success, false on failure.
	 */
	public function delete(): bool {
		try {
			$filesystem = FilesystemUtil::get_wp_filesystem();
			$deleted    = $filesystem->delete( $this->path, false, 'f' );
		} catch ( Exception $exception ) {
			return false;
		}

		return $deleted;
	}
}
PK     [1],]9  9  %  Admin/Logging/FileV2/FileExporter.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Logging\FileV2;

use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
use Exception;
use WP_Error;

/**
 * FileExport class.
 */
class FileExporter {
	/**
	 * The number of bytes per read while streaming the file.
	 *
	 * @const int
	 */
	private const CHUNK_SIZE = 4 * KB_IN_BYTES;

	/**
	 * The absolute path of the file.
	 *
	 * @var string
	 */
	private $path;

	/**
	 * A name of the file to send to the browser rather than the filename part of the path.
	 *
	 * @var string
	 */
	private $alternate_filename;

	/**
	 * Class FileExporter.
	 *
	 * @param string $path               The absolute path of the file.
	 * @param string $alternate_filename Optional. The name of the file to send to the browser rather than the filename
	 *                                   part of the path.
	 */
	public function __construct( string $path, string $alternate_filename = '' ) {
		$this->path               = $path;
		$this->alternate_filename = $alternate_filename;
	}

	/**
	 * Configure PHP and stream the file to the browser.
	 *
	 * @return WP_Error|void Only returns something if there is an error.
	 */
	public function emit_file() {
		try {
			$filesystem  = FilesystemUtil::get_wp_filesystem();
			$is_readable = $filesystem->is_file( $this->path ) && $filesystem->is_readable( $this->path );
		} catch ( Exception $exception ) {
			$is_readable = false;
		}

		if ( ! $is_readable ) {
			return new WP_Error(
				'wc_logs_invalid_file',
				__( 'Could not access file.', 'woocommerce' )
			);
		}

		// These configuration tweaks are copied from WC_CSV_Exporter::send_headers().
		// phpcs:disable WordPress.PHP.NoSilencedErrors.Discouraged
		if ( function_exists( 'gc_enable' ) ) {
			gc_enable(); // phpcs:ignore PHPCompatibility.FunctionUse.NewFunctions.gc_enableFound
		}
		if ( function_exists( 'apache_setenv' ) ) {
			@apache_setenv( 'no-gzip', '1' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_apache_setenv
		}
		@ini_set( 'zlib.output_compression', 'Off' ); // phpcs:ignore WordPress.PHP.IniSet.Risky
		@ini_set( 'output_buffering', 'Off' ); // phpcs:ignore WordPress.PHP.IniSet.Risky
		@ini_set( 'output_handler', '' ); // phpcs:ignore WordPress.PHP.IniSet.Risky
		ignore_user_abort( true );
		wc_set_time_limit();
		wc_nocache_headers();
		// phpcs:enable WordPress.PHP.NoSilencedErrors.Discouraged

		$this->send_headers();
		$this->send_contents();

		die;
	}

	/**
	 * Send HTTP headers at the beginning of a file.
	 *
	 * Modeled on WC_CSV_Exporter::send_headers().
	 *
	 * @return void
	 */
	private function send_headers(): void {
		header( 'Content-Type: text/plain; charset=utf-8' );
		header( 'Content-Disposition: attachment; filename=' . $this->get_filename() );
		header( 'Pragma: no-cache' );
		header( 'Expires: 0' );
	}

	/**
	 * Send the contents of the file.
	 *
	 * @return void
	 */
	private function send_contents(): void {
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- No suitable alternative.
		$stream = fopen( $this->path, 'rb' );

		while ( is_resource( $stream ) && ! feof( $stream ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- No suitable alternative.
			$chunk = fread( $stream, self::CHUNK_SIZE );

			if ( is_string( $chunk ) ) {
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Outputting to file.
				echo $chunk;
			}
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- No suitable alternative.
		fclose( $stream );
	}

	/**
	 * Get the name of the file that will be sent to the browser.
	 *
	 * @return string
	 */
	private function get_filename(): string {
		if ( $this->alternate_filename ) {
			return $this->alternate_filename;
		}

		return basename( $this->path );
	}
}
PK     [1]"-X  X     Admin/Logging/PageController.phpnu         <?php
declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\Admin\Logging;

use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Admin\Logging\{ LogHandlerFileV2, Settings };
use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\{ File, FileController, FileListTable, SearchListTable };
use WC_Admin_Status;
use WC_Log_Handler_File, WC_Log_Handler_DB;
use WC_Log_Levels;
use WP_List_Table;

/**
 * PageController class.
 */
class PageController {

	/**
	 * Instance of FileController.
	 *
	 * @var FileController
	 */
	private $file_controller;

	/**
	 * Instance of Settings.
	 *
	 * @var Settings
	 */
	private $settings;

	/**
	 * Instance of FileListTable or SearchListTable.
	 *
	 * @var FileListTable|SearchListTable
	 */
	private $list_table;

	/**
	 * Initialize dependencies.
	 *
	 * @internal
	 *
	 * @param FileController $file_controller Instance of FileController.
	 * @param Settings       $settings        Instance of Settings.
	 *
	 * @return void
	 */
	final public function init(
		FileController $file_controller,
		Settings $settings
	): void {
		$this->file_controller = $file_controller;
		$this->settings        = $settings;

		$this->init_hooks();
	}

	/**
	 * Add callbacks to hooks.
	 *
	 * @return void
	 */
	private function init_hooks(): void {
		add_action( 'load-woocommerce_page_wc-status', array( $this, 'maybe_do_logs_tab_action' ), 2 );

		add_action( 'wc_logs_load_tab', array( $this, 'setup_screen_options' ) );
		add_action( 'wc_logs_load_tab', array( $this, 'handle_list_table_bulk_actions' ) );
		add_action( 'wc_logs_load_tab', array( $this, 'notices' ) );
	}

	/**
	 * Determine if the current tab on the Status page is Logs, and if so, fire an action.
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function maybe_do_logs_tab_action(): void {
		$is_logs_tab = 'logs' === filter_input( INPUT_GET, 'tab' );

		if ( $is_logs_tab ) {
			$params = $this->get_query_params( array( 'view' ) );

			/**
			 * Action fires when the Logs tab starts loading.
			 *
			 * @param string $view The current view within the Logs tab.
			 *
			 * @since 8.6.0
			 */
			do_action( 'wc_logs_load_tab', $params['view'] );
		}
	}

	/**
	 * Notices to display on Logs screens.
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function notices() {
		if ( ! $this->settings->logging_is_enabled() ) {
			add_action(
				'admin_notices',
				function () {
					?>
					<div class="notice notice-warning">
						<p>
							<?php
							printf(
								// translators: %s is a URL to another admin screen.
								wp_kses_post( __( 'Logging is disabled. It can be enabled in <a href="%s">Logs Settings</a>.', 'woocommerce' ) ),
								esc_url( add_query_arg( 'view', 'settings', $this->get_logs_tab_url() ) )
							);
							?>
						</p>
					</div>
					<?php
				}
			);
		}
	}

	/**
	 * Get the canonical URL for the Logs tab of the Status admin page.
	 *
	 * @return string
	 */
	public function get_logs_tab_url(): string {
		return add_query_arg(
			array(
				'page' => 'wc-status',
				'tab'  => 'logs',
			),
			admin_url( 'admin.php' )
		);
	}

	/**
	 * Render the "Logs" tab, depending on the current default log handler.
	 *
	 * @return void
	 */
	public function render(): void {
		$handler = $this->settings->get_default_handler();
		$params  = $this->get_query_params( array( 'view' ) );

		$this->render_section_nav();

		if ( 'settings' === $params['view'] ) {
			$this->settings->render_form();

			return;
		}

		switch ( $handler ) {
			case LogHandlerFileV2::class:
				$this->render_filev2();
				return;
			case WC_Log_Handler_DB::class:
				WC_Admin_Status::status_logs_db();
				return;
			case WC_Log_Handler_File::class:
				WC_Admin_Status::status_logs_file();
				return;
		}

		/**
		 * Action fires only if there is not a built-in rendering method for the current default log handler.
		 *
		 * This is intended as a way for extensions to render log views for custom handlers.
		 *
		 * @param string $handler
		 *
		 * @since 8.6.0
		 */
		do_action( 'wc_logs_render_page', $handler );
	}

	/**
	 * Render navigation to switch between logs browsing and settings.
	 *
	 * @return void
	 */
	private function render_section_nav(): void {
		$params       = $this->get_query_params( array( 'view' ) );
		$browse_url   = $this->get_logs_tab_url();
		$settings_url = add_query_arg( 'view', 'settings', $this->get_logs_tab_url() );

		?>
		<ul class="subsubsub">
			<li>
				<?php
				printf(
					'<a href="%1$s"%2$s>%3$s</a>',
					esc_url( $browse_url ),
					'settings' !== $params['view'] ? ' class="current"' : '',
					esc_html__( 'Browse', 'woocommerce' )
				);
				?>
				|
			</li>
			<li>
				<?php
				printf(
					'<a href="%1$s"%2$s>%3$s</a>',
					esc_url( $settings_url ),
					'settings' === $params['view'] ? ' class="current"' : '',
					esc_html__( 'Settings', 'woocommerce' )
				);
				?>
			</li>
		</ul>
		<br class="clear">
		<?php
	}

	/**
	 * Render the views for the FileV2 log handler.
	 *
	 * @return void
	 */
	private function render_filev2(): void {
		$params = $this->get_query_params( array( 'view' ) );

		switch ( $params['view'] ) {
			case 'list_files':
			default:
				$this->render_list_files_view();
				break;
			case 'search_results':
				$this->render_search_results_view();
				break;
			case 'single_file':
				$this->render_single_file_view();
				break;
		}
	}

	/**
	 * Render the file list view.
	 *
	 * @return void
	 */
	private function render_list_files_view(): void {
		$params     = $this->get_query_params( array( 'order', 'orderby', 'source', 'view' ) );
		$defaults   = $this->get_query_param_defaults();
		$list_table = $this->get_list_table( $params['view'] );

		$list_table->prepare_items();

		?>
		<header id="logs-header" class="wc-logs-header">
			<h2>
				<?php esc_html_e( 'Browse log files', 'woocommerce' ); ?>
			</h2>
			<?php $this->render_search_field(); ?>
		</header>
		<form id="logs-list-table-form" method="get">
			<input type="hidden" name="page" value="wc-status" />
			<input type="hidden" name="tab" value="logs" />
			<?php foreach ( $params as $key => $value ) : ?>
				<?php if ( $value !== $defaults[ $key ] ) : ?>
					<input
						type="hidden"
						name="<?php echo esc_attr( $key ); ?>"
						value="<?php echo esc_attr( $value ); ?>"
					/>
				<?php endif; ?>
			<?php endforeach; ?>
			<?php $list_table->display(); ?>
		</form>
		<?php
	}

	/**
	 * Render the single file view.
	 *
	 * @return void
	 */
	private function render_single_file_view(): void {
		$params = $this->get_query_params( array( 'file_id', 'view' ) );
		$file   = $this->file_controller->get_file_by_id( $params['file_id'] );

		if ( is_wp_error( $file ) ) {
			?>
			<div class="notice notice-error notice-inline">
				<?php echo wp_kses_post( wpautop( $file->get_error_message() ) ); ?>
				<?php
				printf(
					'<p><a href="%1$s">%2$s</a></p>',
					esc_url( $this->get_logs_tab_url() ),
					esc_html__( 'Return to the file list.', 'woocommerce' )
				);
				?>
			</div>
			<?php

			return;
		}

		$rotations         = $this->file_controller->get_file_rotations( $file->get_file_id() );
		$rotation_url_base = add_query_arg( 'view', 'single_file', $this->get_logs_tab_url() );

		$download_url           = add_query_arg(
			array(
				'action'  => 'export',
				'file_id' => array( $file->get_file_id() ),
			),
			wp_nonce_url( $this->get_logs_tab_url(), 'bulk-log-files' )
		);
		$delete_url             = add_query_arg(
			array(
				'action'  => 'delete',
				'file_id' => array( $file->get_file_id() ),
			),
			wp_nonce_url( $this->get_logs_tab_url(), 'bulk-log-files' )
		);
		$delete_confirmation_js = sprintf(
			"return window.confirm( '%s' )",
			esc_js( __( 'Delete this log file permanently?', 'woocommerce' ) )
		);

		$stream      = $file->get_stream();
		$line_number = 1;

		?>
		<header id="logs-header" class="wc-logs-header">
			<h2>
				<?php
				printf(
					// translators: %s is the name of a log file.
					esc_html__( 'Viewing log file %s', 'woocommerce' ),
					sprintf(
						'<span class="file-id">%s</span>',
						esc_html( $file->get_file_id() )
					)
				);
				?>
			</h2>
			<?php if ( count( $rotations ) > 1 ) : ?>
				<nav class="wc-logs-single-file-rotations">
					<h3><?php esc_html_e( 'File rotations:', 'woocommerce' ); ?></h3>
					<ul class="wc-logs-rotation-links">
						<?php if ( isset( $rotations['current'] ) ) : ?>
							<?php
							printf(
								'<li><a href="%1$s" class="button button-small button-%2$s">%3$s</a></li>',
								esc_url( add_query_arg( 'file_id', $rotations['current']->get_file_id(), $rotation_url_base ) ),
								$file->get_file_id() === $rotations['current']->get_file_id() ? 'primary' : 'secondary',
								esc_html__( 'Current', 'woocommerce' )
							);
							unset( $rotations['current'] );
							?>
						<?php endif; ?>
						<?php foreach ( $rotations as $rotation ) : ?>
							<?php
							printf(
								'<li><a href="%1$s" class="button button-small button-%2$s">%3$s</a></li>',
								esc_url( add_query_arg( 'file_id', $rotation->get_file_id(), $rotation_url_base ) ),
								$file->get_file_id() === $rotation->get_file_id() ? 'primary' : 'secondary',
								absint( $rotation->get_rotation() )
							);
							?>
						<?php endforeach; ?>
					</ul>
				</nav>
			<?php endif; ?>
			<div class="wc-logs-single-file-actions">
				<?php
				// Download button.
				printf(
					'<a href="%1$s" class="button button-secondary">%2$s</a>',
					esc_url( $download_url ),
					esc_html__( 'Download', 'woocommerce' )
				);
				?>
				<?php
				// Delete button.
				printf(
					'<a href="%1$s" class="button button-secondary" onclick="%2$s">%3$s</a>',
					esc_url( $delete_url ),
					esc_attr( $delete_confirmation_js ),
					esc_html__( 'Delete permanently', 'woocommerce' )
				);
				?>
			</div>
		</header>
		<section id="logs-entries" class="wc-logs-entries">
			<?php while ( ! feof( $stream ) ) : ?>
				<?php
				$line = fgets( $stream );
				if ( is_string( $line ) ) {
					// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- format_line does the escaping.
					echo $this->format_line( $line, $line_number );
					++$line_number;
				}
				?>
			<?php endwhile; ?>
		</section>
		<script>
			// Clear the line number hash and highlight with a click.
			document.documentElement.addEventListener( 'click', ( event ) => {
				if ( window.location.hash && ! event.target.classList.contains( 'line-anchor' ) ) {
					let scrollPos = document.documentElement.scrollTop;
					window.location.hash = '';
					document.documentElement.scrollTop = scrollPos;
					history.replaceState( null, '', window.location.pathname + window.location.search );
				}
			} );
		</script>
		<?php
	}

	/**
	 * Render the search results view.
	 *
	 * @return void
	 */
	private function render_search_results_view(): void {
		$params     = $this->get_query_params( array( 'view' ) );
		$list_table = $this->get_list_table( $params['view'] );

		$list_table->prepare_items();

		?>
		<header id="logs-header" class="wc-logs-header">
			<h2><?php esc_html_e( 'Search results', 'woocommerce' ); ?></h2>
			<?php $this->render_search_field(); ?>
		</header>
		<?php $list_table->display(); ?>
		<?php
	}

	/**
	 * Get the default values for URL query params for FileV2 views.
	 *
	 * @return string[]
	 */
	public function get_query_param_defaults(): array {
		return array(
			'file_id' => '',
			'order'   => $this->file_controller::DEFAULTS_GET_FILES['order'],
			'orderby' => $this->file_controller::DEFAULTS_GET_FILES['orderby'],
			'search'  => '',
			'source'  => $this->file_controller::DEFAULTS_GET_FILES['source'],
			'view'    => 'list_files',
		);
	}

	/**
	 * Get and validate URL query params for FileV2 views.
	 *
	 * @param array $param_keys Optional. The names of the params you want to get.
	 *
	 * @return array
	 */
	public function get_query_params( array $param_keys = array() ): array {
		$defaults = $this->get_query_param_defaults();
		$params   = filter_input_array(
			INPUT_GET,
			array(
				'file_id' => array(
					'filter'  => FILTER_CALLBACK,
					'options' => function ( $file_id ) {
						return sanitize_file_name( wp_unslash( $file_id ) );
					},
				),
				'order'   => array(
					'filter'  => FILTER_VALIDATE_REGEXP,
					'options' => array(
						'regexp'  => '/^(asc|desc)$/i',
						'default' => $defaults['order'],
					),
				),
				'orderby' => array(
					'filter'  => FILTER_VALIDATE_REGEXP,
					'options' => array(
						'regexp'  => '/^(created|modified|source|size)$/',
						'default' => $defaults['orderby'],
					),
				),
				'search'  => array(
					'filter'  => FILTER_CALLBACK,
					'options' => function ( $search ) {
						return esc_html( wp_unslash( $search ) );
					},
				),
				'source'  => array(
					'filter'  => FILTER_CALLBACK,
					'options' => function ( $source ) {
						return File::sanitize_source( wp_unslash( $source ) );
					},
				),
				'view'    => array(
					'filter'  => FILTER_VALIDATE_REGEXP,
					'options' => array(
						'regexp'  => '/^(list_files|single_file|search_results|settings)$/',
						'default' => $defaults['view'],
					),
				),
			),
			false
		);
		$params   = wp_parse_args( $params, $defaults );

		if ( count( $param_keys ) > 0 ) {
			$params = array_intersect_key( $params, array_flip( $param_keys ) );
		}

		return $params;
	}

	/**
	 * Get and cache an instance of the list table.
	 *
	 * @param string $view The current view, which determines which list table class to get.
	 *
	 * @return FileListTable|SearchListTable
	 */
	private function get_list_table( string $view ) {
		if ( $this->list_table instanceof WP_List_Table ) {
			return $this->list_table;
		}

		switch ( $view ) {
			case 'list_files':
				$this->list_table = new FileListTable( $this->file_controller, $this );
				break;
			case 'search_results':
				$this->list_table = new SearchListTable( $this->file_controller, $this );
				break;
		}

		return $this->list_table;
	}

	/**
	 * Register screen options for the logging views.
	 *
	 * @param string $view The current view within the Logs tab.
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function setup_screen_options( string $view ): void {
		$handler    = $this->settings->get_default_handler();
		$list_table = null;

		switch ( $handler ) {
			case LogHandlerFileV2::class:
				if ( in_array( $view, array( 'list_files', 'search_results' ), true ) ) {
					$list_table = $this->get_list_table( $view );
				}
				break;
			case 'WC_Log_Handler_DB':
					$list_table = WC_Admin_Status::get_db_log_list_table();
				break;
		}

		if ( $list_table instanceof WP_List_Table ) {
			// Ensure list table columns are initialized early enough to enable column hiding, if available.
			$list_table->prepare_column_headers();

			add_screen_option(
				'per_page',
				array(
					'default' => $list_table->get_per_page_default(),
					'option'  => $list_table::PER_PAGE_USER_OPTION_KEY,
				)
			);
		}
	}

	/**
	 * Process bulk actions initiated from the log file list table.
	 *
	 * @param string $view The current view within the Logs tab.
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function handle_list_table_bulk_actions( string $view ): void {
		// Bail if we're not using the file handler.
		if ( LogHandlerFileV2::class !== $this->settings->get_default_handler() ) {
			return;
		}

		$params = $this->get_query_params( array( 'file_id' ) );

		// Bail if this is not the list table view.
		if ( 'list_files' !== $view ) {
			return;
		}

		$action = $this->get_list_table( $view )->current_action();

		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : $this->get_logs_tab_url();

		if ( $action ) {
			check_admin_referer( 'bulk-log-files' );

			if ( ! current_user_can( 'manage_woocommerce' ) ) {
				wp_die( esc_html__( 'You do not have permission to manage log files.', 'woocommerce' ) );
			}

			$sendback = remove_query_arg( array( 'deleted' ), wp_get_referer() );

			// Multiple file_id[] params will be filtered separately, but assigned to $files as an array.
			$file_ids = $params['file_id'];

			if ( ! is_array( $file_ids ) || count( $file_ids ) < 1 ) {
				wp_safe_redirect( $sendback );
				exit;
			}

			switch ( $action ) {
				case 'export':
					if ( 1 === count( $file_ids ) ) {
						$export_error = $this->file_controller->export_single_file( reset( $file_ids ) );
					} else {
						$export_error = $this->file_controller->export_multiple_files( $file_ids );
					}

					if ( is_wp_error( $export_error ) ) {
						wp_die( wp_kses_post( $export_error->get_error_message() ) );
					}
					break;
				case 'delete':
					$deleted  = $this->file_controller->delete_files( $file_ids );
					$sendback = add_query_arg( 'deleted', $deleted, $sendback );

					/**
					 * If the delete action was triggered on the single file view, don't redirect back there
					 * since the file doesn't exist anymore.
					 */
					$sendback = remove_query_arg( array( 'view', 'file_id' ), $sendback );
					break;
			}

			$sendback = remove_query_arg( array( 'action', 'action2' ), $sendback );

			wp_safe_redirect( $sendback );
			exit;
		} elseif ( ! empty( $_REQUEST['_wp_http_referer'] ) ) {
			$removable_args = array( '_wp_http_referer', '_wpnonce', 'action', 'action2', 'filter_action' );
			wp_safe_redirect( remove_query_arg( $removable_args, $request_uri ) );
			exit;
		}

		$deleted = filter_input( INPUT_GET, 'deleted', FILTER_VALIDATE_INT );

		if ( is_numeric( $deleted ) ) {
			add_action(
				'admin_notices',
				function () use ( $deleted ) {
					?>
					<div class="notice notice-info is-dismissible">
						<p>
							<?php
							printf(
							// translators: %s is a number of files.
								esc_html( _n( '%s log file deleted.', '%s log files deleted.', $deleted, 'woocommerce' ) ),
								esc_html( number_format_i18n( $deleted ) )
							);
							?>
						</p>
					</div>
					<?php
				}
			);
		}
	}

	/**
	 * Format a log file line.
	 *
	 * @param string $line        The unformatted log file line.
	 * @param int    $line_number The line number.
	 *
	 * @return string
	 */
	private function format_line( string $line, int $line_number ): string {
		$classes = array( 'line' );

		$line = esc_html( $line );
		if ( empty( $line ) ) {
			$line = '&nbsp;';
		}

		$segments      = explode( ' ', $line, 3 );
		$has_timestamp = false;
		$has_level     = false;

		if ( isset( $segments[0] ) && false !== strtotime( $segments[0] ) ) {
			$classes[]     = 'log-entry';
			$segments[0]   = sprintf(
				'<span class="log-timestamp">%s</span>',
				$segments[0]
			);
			$has_timestamp = true;
		}

		if ( isset( $segments[1] ) && WC_Log_Levels::is_valid_level( strtolower( $segments[1] ) ) ) {
			$segments[1] = sprintf(
				'<span class="%1$s">%2$s</span>',
				esc_attr( 'log-level log-level--' . strtolower( $segments[1] ) ),
				esc_html( WC_Log_Levels::get_level_label( strtolower( $segments[1] ) ) )
			);
			$has_level   = true;
		}

		if ( isset( $segments[2] ) && $has_timestamp && $has_level ) {
			$message_chunks = explode( 'CONTEXT:', $segments[2], 2 );
			if ( isset( $message_chunks[1] ) ) {
				try {
					$maybe_json = html_entity_decode( addslashes( trim( $message_chunks[1] ) ) );

					// Decode for validation.
					$context = json_decode( $maybe_json, false, 512, JSON_THROW_ON_ERROR );

					// Re-encode to make it pretty.
					$context = wp_json_encode( $context, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE );

					$message_chunks[1] = sprintf(
						'<details><summary>%1$s</summary>%2$s</details>',
						esc_html__( 'Additional context', 'woocommerce' ),
						stripslashes( $context )
					);

					$segments[2] = implode( ' ', $message_chunks );
					$classes[]   = 'has-context';
				} catch ( \JsonException $exception ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
					// It's not valid JSON so don't do anything with it.
				}
			}
		}

		if ( count( $segments ) > 1 ) {
			$line = implode( ' ', $segments );
		}

		$classes = implode( ' ', $classes );

		return sprintf(
			'<span id="L%1$d" class="%2$s">%3$s%4$s</span>',
			absint( $line_number ),
			esc_attr( $classes ),
			sprintf(
				'<a href="#L%1$d" class="line-anchor"></a>',
				absint( $line_number )
			),
			sprintf(
				'<span class="line-content">%s</span>',
				wp_kses_post( $line )
			)
		);
	}

	/**
	 * Render a form for searching within log files.
	 *
	 * @return void
	 */
	private function render_search_field(): void {
		$params     = $this->get_query_params( array( 'date_end', 'date_filter', 'date_start', 'search', 'source' ) );
		$defaults   = $this->get_query_param_defaults();
		$file_count = $this->file_controller->get_files( $params, true );

		if ( $file_count > 0 ) {
			?>
			<form id="logs-search" class="wc-logs-search" method="get">
				<fieldset class="wc-logs-search-fieldset">
					<input type="hidden" name="page" value="wc-status" />
					<input type="hidden" name="tab" value="logs" />
					<input type="hidden" name="view" value="search_results" />
					<?php foreach ( $params as $key => $value ) : ?>
						<?php if ( $value !== $defaults[ $key ] ) : ?>
							<input
								type="hidden"
								name="<?php echo esc_attr( $key ); ?>"
								value="<?php echo esc_attr( $value ); ?>"
							/>
						<?php endif; ?>
					<?php endforeach; ?>
					<label for="logs-search-field">
						<?php esc_html_e( 'Search within these files', 'woocommerce' ); ?>
						<input
							id="logs-search-field"
							class="wc-logs-search-field"
							type="text"
							name="search"
							value="<?php echo esc_attr( $params['search'] ); ?>"
						/>
					</label>
					<?php submit_button( __( 'Search', 'woocommerce' ), 'secondary', null, false ); ?>
				</fieldset>
				<?php if ( $file_count >= $this->file_controller::SEARCH_MAX_FILES ) : ?>
					<div class="wc-logs-search-notice">
						<?php
						printf(
							// translators: %s is a number.
							esc_html__(
								'⚠️ Only %s files can be searched at one time. Try filtering the file list before searching.',
								'woocommerce'
							),
							esc_html( number_format_i18n( $this->file_controller::SEARCH_MAX_FILES ) )
						);
						?>
					</div>
				<?php endif; ?>
			</form>
			<?php
		}
	}
}
PK     [1]wA  A    Admin/Logging/Settings.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Logging;

use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\File;
use Automattic\WooCommerce\Internal\Admin\Logging\LogHandlerFileV2;
use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\FileController;
use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Exception;
use WC_Admin_Settings;
use WC_Log_Handler_DB, WC_Log_Handler_File, WC_Log_Levels;
use WP_Filesystem_Direct;

/**
 * Settings class.
 */
class Settings {

	/**
	 * Default values for logging settings.
	 *
	 * @const array
	 */
	private const DEFAULTS = array(
		'logging_enabled'       => true,
		'default_handler'       => LogHandlerFileV2::class,
		'retention_period_days' => 30,
		'level_threshold'       => 'none',
	);

	/**
	 * The prefix for settings keys used in the options table.
	 *
	 * @const string
	 */
	private const PREFIX = 'woocommerce_logs_';

	/**
	 * Class Settings.
	 */
	public function __construct() {
		add_action( 'wc_logs_load_tab', array( $this, 'save_settings' ) );
	}

	/**
	 * Get the directory for storing log files.
	 *
	 * The `wp_upload_dir` function takes into account the possibility of multisite, and handles changing
	 * the directory if the context is switched to a different site in the network mid-request.
	 *
	 * @param bool $create_dir Optional. True to attempt to create the log directory if it doesn't exist. Default true.
	 *
	 * @return string The full directory path, with trailing slash.
	 */
	public static function get_log_directory( bool $create_dir = true ): string {
		if ( true === Constants::get_constant( 'WC_LOG_DIR_CUSTOM' ) ) {
			$dir = Constants::get_constant( 'WC_LOG_DIR' );
		} else {
			$upload_dir = wc_get_container()->get( LegacyProxy::class )->call_function( 'wp_upload_dir', null, $create_dir );

			/**
			 * Filter to change the directory for storing WooCommerce's log files.
			 *
			 * @param string $dir The full directory path, with trailing slash.
			 *
			 * @since 8.8.0
			 */
			$dir = apply_filters( 'woocommerce_log_directory', $upload_dir['basedir'] . '/wc-logs/' );
		}

		$dir = trailingslashit( $dir );

		if ( true === $create_dir ) {
			$realpath = realpath( $dir );
			if ( false === $realpath ) {
				$result = wp_mkdir_p( $dir );

				if ( true === $result ) {
					// Create infrastructure to prevent listing contents of the logs directory.
					try {
						$filesystem = FilesystemUtil::get_wp_filesystem();
						$filesystem->put_contents( $dir . '.htaccess', 'deny from all' );
						$filesystem->put_contents( $dir . 'index.html', '' );
					} catch ( Exception $exception ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
						// Creation failed.
					}
				}
			}
		}

		return $dir;
	}

	/**
	 * The definitions used by WC_Admin_Settings to render and save settings controls.
	 *
	 * @return array
	 */
	private function get_settings_definitions(): array {
		$settings = array(
			'start'                 => array(
				'title' => __( 'Logs settings', 'woocommerce' ),
				'id'    => self::PREFIX . 'settings',
				'type'  => 'title',
			),
			'logging_enabled'       => array(
				'title'    => __( 'Logger', 'woocommerce' ),
				'desc'     => __( 'Enable logging', 'woocommerce' ),
				'id'       => self::PREFIX . 'logging_enabled',
				'type'     => 'checkbox',
				'value'    => $this->logging_is_enabled() ? 'yes' : 'no',
				'default'  => self::DEFAULTS['logging_enabled'] ? 'yes' : 'no',
				'autoload' => false,
			),
			'default_handler'       => array(),
			'retention_period_days' => array(),
			'level_threshold'       => array(),
			'end'                   => array(
				'id'   => self::PREFIX . 'settings',
				'type' => 'sectionend',
			),
		);

		if ( true === $this->logging_is_enabled() ) {
			$settings['default_handler']       = $this->get_default_handler_setting_definition();
			$settings['retention_period_days'] = $this->get_retention_period_days_setting_definition();
			$settings['level_threshold']       = $this->get_level_threshold_setting_definition();

			$default_handler = $this->get_default_handler();
			if ( in_array( $default_handler, array( LogHandlerFileV2::class, WC_Log_Handler_File::class ), true ) ) {
				$settings += $this->get_filesystem_settings_definitions();
			} elseif ( WC_Log_Handler_DB::class === $default_handler ) {
				$settings += $this->get_database_settings_definitions();
			}
		}

		return $settings;
	}

	/**
	 * The definition for the default_handler setting.
	 *
	 * @return array
	 */
	private function get_default_handler_setting_definition(): array {
		$handler_options = array(
			LogHandlerFileV2::class  => __( 'File system (default)', 'woocommerce' ),
			WC_Log_Handler_DB::class => __( 'Database (not recommended on live sites)', 'woocommerce' ),
		);

		/**
		 * Filter the list of logging handlers that can be set as the default handler.
		 *
		 * @param array $handler_options An associative array of class_name => description.
		 *
		 * @since 8.6.0
		 */
		$handler_options = apply_filters( 'woocommerce_logger_handler_options', $handler_options );

		$current_value = $this->get_default_handler();
		if ( ! array_key_exists( $current_value, $handler_options ) ) {
			$handler_options[ $current_value ] = $current_value;
		}

		$desc = array();

		$desc[] = __( 'Note that if this setting is changed, any log entries that have already been recorded will remain stored in their current location, but will not migrate.', 'woocommerce' );

		$hardcoded = ! is_null( Constants::get_constant( 'WC_LOG_HANDLER' ) );
		if ( $hardcoded ) {
			$desc[] = sprintf(
				// translators: %s is the name of a code variable.
				__( 'This setting cannot be changed here because it is defined in the %s constant.', 'woocommerce' ),
				'<code>WC_LOG_HANDLER</code>'
			);
		}

		return array(
			'title'       => __( 'Log storage', 'woocommerce' ),
			'desc_tip'    => __( 'This determines where log entries are saved.', 'woocommerce' ),
			'id'          => self::PREFIX . 'default_handler',
			'type'        => 'radio',
			'value'       => $current_value,
			'default'     => self::DEFAULTS['default_handler'],
			'autoload'    => false,
			'options'     => $handler_options,
			'disabled'    => $hardcoded ? array_keys( $handler_options ) : array(),
			'desc'        => implode( '<br><br>', $desc ),
			'desc_at_end' => true,
		);
	}

	/**
	 * The definition for the retention_period_days setting.
	 *
	 * @return array
	 */
	private function get_retention_period_days_setting_definition(): array {
		$custom_attributes = array(
			'min'  => 1,
			'step' => 1,
		);

		$desc = array();

		$hardcoded = has_filter( 'woocommerce_logger_days_to_retain_logs' );
		if ( $hardcoded ) {
			$custom_attributes['disabled'] = 'true';

			$desc[] = sprintf(
				// translators: %s is the name of a filter hook.
				__( 'This setting cannot be changed here because it is being set by a filter on the %s hook.', 'woocommerce' ),
				'<code>woocommerce_logger_days_to_retain_logs</code>'
			);
		}

		$file_delete_has_filter = LogHandlerFileV2::class === $this->get_default_handler() && has_filter( 'woocommerce_logger_delete_expired_file' );
		if ( $file_delete_has_filter ) {
			$desc[] = sprintf(
				// translators: %s is the name of a filter hook.
				__( 'The %s hook has a filter set, so some log files may have different retention settings.', 'woocommerce' ),
				'<code>woocommerce_logger_delete_expired_file</code>'
			);
		}

		return array(
			'title'             => __( 'Retention period', 'woocommerce' ),
			'desc_tip'          => __( 'This sets how many days log entries will be kept before being auto-deleted.', 'woocommerce' ),
			'id'                => self::PREFIX . 'retention_period_days',
			'type'              => 'number',
			'value'             => $this->get_retention_period(),
			'default'           => self::DEFAULTS['retention_period_days'],
			'autoload'          => false,
			'custom_attributes' => $custom_attributes,
			'css'               => 'width:70px;',
			'row_class'         => 'logs-retention-period-days',
			'suffix'            => sprintf(
				' %s',
				__( 'days', 'woocommerce' ),
			),
			'desc'              => implode( '<br><br>', $desc ),
		);
	}

	/**
	 * The definition for the level_threshold setting.
	 *
	 * @return array
	 */
	private function get_level_threshold_setting_definition(): array {
		$hardcoded = ! is_null( Constants::get_constant( 'WC_LOG_THRESHOLD' ) );
		$desc      = '';
		if ( $hardcoded ) {
			$desc = sprintf(
				// translators: %1$s is the name of a code variable. %2$s is the name of a file.
				__( 'This setting cannot be changed here because it is defined in the %1$s constant, probably in your %2$s file.', 'woocommerce' ),
				'<code>WC_LOG_THRESHOLD</code>',
				'<b>wp-config.php</b>'
			);
		}

		$labels         = WC_Log_Levels::get_all_level_labels();
		$labels['none'] = __( 'None', 'woocommerce' );

		$custom_attributes = array();
		if ( $hardcoded ) {
			$custom_attributes['disabled'] = 'true';
		}

		return array(
			'title'             => __( 'Level threshold', 'woocommerce' ),
			'desc_tip'          => __( 'This sets the minimum severity level of logs that will be stored. Lower severity levels will be ignored. "None" means all logs will be stored.', 'woocommerce' ),
			'id'                => self::PREFIX . 'level_threshold',
			'type'              => 'select',
			'value'             => $this->get_level_threshold(),
			'default'           => self::DEFAULTS['level_threshold'],
			'autoload'          => false,
			'options'           => $labels,
			'custom_attributes' => $custom_attributes,
			'css'               => 'width:auto;',
			'desc'              => $desc,
		);
	}

	/**
	 * The definitions used by WC_Admin_Settings to render settings related to filesystem log handlers.
	 *
	 * @return array
	 */
	private function get_filesystem_settings_definitions(): array {
		$location_info = array();
		$directory     = self::get_log_directory();

		$status_info = array();
		try {
			$filesystem = FilesystemUtil::get_wp_filesystem();
			if ( $filesystem instanceof WP_Filesystem_Direct ) {
				$status_info[] = __( '✅ Ready', 'woocommerce' );
			} else {
				$status_info[] = __( '⚠️ The file system is not configured for direct writes. This could cause problems for the logger.', 'woocommerce' );
				$status_info[] = __( 'You may want to switch to the database for log storage.', 'woocommerce' );
			}
		} catch ( Exception $exception ) {
			$status_info[] = __( '⚠️ The file system connection could not be initialized.', 'woocommerce' );
			$status_info[] = __( 'You may want to switch to the database for log storage.', 'woocommerce' );
		}

		$location_info[] = sprintf(
			// translators: %s is a location in the filesystem.
			__( 'Log files are stored in this directory: %s', 'woocommerce' ),
			sprintf(
				'<code>%s</code>',
				esc_html( $directory )
			)
		);

		if ( ! wp_is_writable( $directory ) ) {
			$location_info[] = __( '⚠️ This directory does not appear to be writable.', 'woocommerce' );
		}

		$location_info[] = sprintf(
			// translators: %s is an amount of computer disk space, e.g. 5 KB.
			__( 'Directory size: %s', 'woocommerce' ),
			size_format( wc_get_container()->get( FileController::class )->get_log_directory_size() )
		);

		return array(
			'file_start'    => array(
				'title' => __( 'File system settings', 'woocommerce' ),
				'id'    => self::PREFIX . 'settings',
				'type'  => 'title',
			),
			'file_status'   => array(
				'title' => __( 'Status', 'woocommerce' ),
				'type'  => 'info',
				'text'  => implode( "\n\n", $status_info ),
			),
			'log_directory' => array(
				'title' => __( 'Location', 'woocommerce' ),
				'type'  => 'info',
				'text'  => implode( "\n\n", $location_info ),
			),
			'entry_format'  => array(),
			'file_end'      => array(
				'id'   => self::PREFIX . 'settings',
				'type' => 'sectionend',
			),
		);
	}

	/**
	 * The definitions used by WC_Admin_Settings to render settings related to database log handlers.
	 *
	 * @return array
	 */
	private function get_database_settings_definitions(): array {
		global $wpdb;
		$table = "{$wpdb->prefix}woocommerce_log";

		$location_info = sprintf(
			// translators: %s is the name of a table in the database.
			__( 'Log entries are stored in this database table: %s', 'woocommerce' ),
			"<code>$table</code>"
		);

		return array(
			'file_start'     => array(
				'title' => __( 'Database settings', 'woocommerce' ),
				'id'    => self::PREFIX . 'settings',
				'type'  => 'title',
			),
			'database_table' => array(
				'title' => __( 'Location', 'woocommerce' ),
				'type'  => 'info',
				'text'  => $location_info,
			),
			'file_end'       => array(
				'id'   => self::PREFIX . 'settings',
				'type' => 'sectionend',
			),
		);
	}

	/**
	 * Handle the submission of the settings form and update the settings values.
	 *
	 * @param string $view The current view within the Logs tab.
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function save_settings( string $view ): void {
		$is_saving = 'settings' === $view && isset( $_POST['save_settings'] );

		if ( $is_saving ) {
			check_admin_referer( self::PREFIX . 'settings' );

			if ( ! current_user_can( 'manage_woocommerce' ) ) {
				wp_die( esc_html__( 'You do not have permission to manage logging settings.', 'woocommerce' ) );
			}

			$settings = $this->get_settings_definitions();

			WC_Admin_Settings::save_fields( $settings );
		}
	}

	/**
	 * Render the settings page.
	 *
	 * @return void
	 */
	public function render_form(): void {
		$settings = $this->get_settings_definitions();

		?>
		<form id="mainform" class="wc-logs-settings" method="post">
			<?php WC_Admin_Settings::output_fields( $settings ); ?>
			<?php
			/**
			 * Action fires after the built-in logging settings controls have been rendered.
			 *
			 * This is intended as a way to allow other logging settings controls to be added by extensions.
			 *
			 * @param bool $enabled True if logging is currently enabled.
			 *
			 * @since 8.6.0
			 */
			do_action( 'wc_logs_settings_form_fields', $this->logging_is_enabled() );
			?>
			<?php wp_nonce_field( self::PREFIX . 'settings' ); ?>
			<?php submit_button( __( 'Save changes', 'woocommerce' ), 'primary', 'save_settings' ); ?>
		</form>
		<?php
	}

	/**
	 * Determine the current value of the logging_enabled setting.
	 *
	 * @return bool
	 */
	public function logging_is_enabled(): bool {
		$key = self::PREFIX . 'logging_enabled';

		$enabled = WC_Admin_Settings::get_option( $key, self::DEFAULTS['logging_enabled'] );
		$enabled = filter_var( $enabled, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );

		if ( is_null( $enabled ) ) {
			$enabled = self::DEFAULTS['logging_enabled'];
		}

		return $enabled;
	}

	/**
	 * Determine the current value of the default_handler setting.
	 *
	 * @return string
	 */
	public function get_default_handler(): string {
		$key = self::PREFIX . 'default_handler';

		$handler = Constants::get_constant( 'WC_LOG_HANDLER' );

		if ( is_null( $handler ) ) {
			$handler = WC_Admin_Settings::get_option( $key );
		}

		if ( ! class_exists( $handler ) || ! is_a( $handler, 'WC_Log_Handler_Interface', true ) ) {
			$handler = self::DEFAULTS['default_handler'];
		}

		return $handler;
	}

	/**
	 * Determine the current value of the retention_period_days setting.
	 *
	 * @return int
	 */
	public function get_retention_period(): int {
		$key = self::PREFIX . 'retention_period_days';

		$retention_period = self::DEFAULTS['retention_period_days'];

		if ( has_filter( 'woocommerce_logger_days_to_retain_logs' ) ) {
			/**
			 * Filter the retention period of log entries.
			 *
			 * @param int $days The number of days to retain log entries.
			 *
			 * @since 3.4.0
			 */
			$retention_period = apply_filters( 'woocommerce_logger_days_to_retain_logs', $retention_period );
		} else {
			$retention_period = WC_Admin_Settings::get_option( $key );
		}

		$retention_period = absint( $retention_period );

		if ( $retention_period < 1 ) {
			$retention_period = self::DEFAULTS['retention_period_days'];
		}

		return $retention_period;
	}

	/**
	 * Determine the current value of the level_threshold setting.
	 *
	 * @return string
	 */
	public function get_level_threshold(): string {
		$key = self::PREFIX . 'level_threshold';

		$threshold = Constants::get_constant( 'WC_LOG_THRESHOLD' );

		if ( is_null( $threshold ) ) {
			$threshold = WC_Admin_Settings::get_option( $key );
		}

		if ( ! WC_Log_Levels::is_valid_level( $threshold ) ) {
			$threshold = self::DEFAULTS['level_threshold'];
		}

		return $threshold;
	}
}
PK     [1]7E      Admin/CategoryLookup.phpnu         <?php
/**
 * Keeps the product category lookup table in sync with live data.
 */

namespace Automattic\WooCommerce\Internal\Admin;

defined( 'ABSPATH' ) || exit;

/**
 * \Automattic\WooCommerce\Internal\Admin\CategoryLookup class.
 */
class CategoryLookup {

	/**
	 * Stores changes to categories we need to sync.
	 *
	 * @var array
	 */
	protected $edited_product_cats = array();

	/**
	 * The single instance of the class.
	 *
	 * @var object
	 */
	protected static $instance = null;

	/**
	 * Constructor
	 *
	 * @return void
	 */
	protected function __construct() {}

	/**
	 * Get class instance.
	 *
	 * @return object Instance.
	 */
	final public static function instance() {
		if ( null === static::$instance ) {
			static::$instance = new static();
		}
		return static::$instance;
	}

	/**
	 * Init hooks.
	 */
	public function init() {
		add_action( 'generate_category_lookup_table', array( $this, 'regenerate' ) );
		add_action( 'edit_product_cat', array( $this, 'before_edit' ), 99 );
		add_action( 'edited_product_cat', array( $this, 'on_edit' ), 99 );
		add_action( 'created_product_cat', array( $this, 'on_create' ), 99 );
		add_action( 'init', array( $this, 'define_category_lookup_tables_in_wpdb' ) );
	}

	/**
	 * Regenerate all lookup table data.
	 */
	public function regenerate() {
		global $wpdb;

		$wpdb->query( "TRUNCATE TABLE $wpdb->wc_category_lookup" );

		$terms = get_terms(
			'product_cat',
			array(
				'hide_empty' => false,
				'fields'     => 'id=>parent',
			)
		);

		$hierarchy = array();
		$inserts   = array();

		$this->unflatten_terms( $hierarchy, $terms, 0 );
		$this->get_term_insert_values( $inserts, $hierarchy );

		if ( ! $inserts ) {
			return;
		}

		$insert_string = implode(
			'),(',
			array_map(
				function( $item ) {
					return implode( ',', $item );
				},
				$inserts
			)
		);

		$wpdb->query( "INSERT IGNORE INTO $wpdb->wc_category_lookup (category_tree_id,category_id) VALUES ({$insert_string})" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}

	/**
	 * Store edits so we know when the parent ID changes.
	 *
	 * @param int $category_id Term ID being edited.
	 */
	public function before_edit( $category_id ) {
		$category                                  = get_term( $category_id, 'product_cat' );
		$this->edited_product_cats[ $category_id ] = $category->parent;
	}

	/**
	 * When a product category gets edited, see if we need to sync the table.
	 *
	 * @param int $category_id Term ID being edited.
	 */
	public function on_edit( $category_id ) {
		global $wpdb;

		if ( ! isset( $this->edited_product_cats[ $category_id ] ) ) {
			return;
		}

		$category_object = get_term( $category_id, 'product_cat' );
		$prev_parent     = $this->edited_product_cats[ $category_id ];
		$new_parent      = $category_object->parent;

		// No edits - no need to modify relationships.
		if ( $prev_parent === $new_parent ) {
			return;
		}

		$this->delete( $category_id, $prev_parent );
		$this->update( $category_id );
	}

	/**
	 * When a product category gets created, add a new lookup row.
	 *
	 * @param int $category_id Term ID being created.
	 */
	public function on_create( $category_id ) {
		// If WooCommerce is being installed on a multisite, lookup tables haven't been created yet.
		if ( 'yes' === get_transient( 'wc_installing' ) ) {
			return;
		}

		$this->update( $category_id );
	}

	/**
	 * Delete lookup table data from a tree.
	 *
	 * @param int $category_id Category ID to delete.
	 * @param int $category_tree_id Tree to delete from.
	 * @return void
	 */
	protected function delete( $category_id, $category_tree_id ) {
		global $wpdb;

		if ( ! $category_tree_id ) {
			return;
		}

		$ancestors   = get_ancestors( $category_tree_id, 'product_cat', 'taxonomy' );
		$ancestors[] = $category_tree_id;
		$children    = get_term_children( $category_id, 'product_cat' );
		$children[]  = $category_id;
		$id_list     = implode( ',', array_map( 'intval', array_unique( array_filter( $children ) ) ) );

		foreach ( $ancestors as $ancestor ) {
			$wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->wc_category_lookup WHERE category_tree_id = %d AND category_id IN ({$id_list})", $ancestor ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		}
	}

	/**
	 * Updates lookup table data for a category by ID.
	 *
	 * @param int $category_id Category ID to update.
	 */
	protected function update( $category_id ) {
		global $wpdb;

		$ancestors    = get_ancestors( $category_id, 'product_cat', 'taxonomy' );
		$children     = get_term_children( $category_id, 'product_cat' );
		$inserts      = array();
		$inserts[]    = $this->get_insert_sql( $category_id, $category_id );
		$children_ids = array_map( 'intval', array_unique( array_filter( $children ) ) );

		foreach ( $ancestors as $ancestor ) {
			$inserts[] = $this->get_insert_sql( $category_id, $ancestor );

			foreach ( $children_ids as $child_category_id ) {
				$inserts[] = $this->get_insert_sql( $child_category_id, $ancestor );
			}
		}

		$insert_string = implode( ',', $inserts );

		$wpdb->query( "INSERT IGNORE INTO $wpdb->wc_category_lookup (category_id, category_tree_id) VALUES {$insert_string}" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}

	/**
	 * Get category lookup table values to insert.
	 *
	 * @param int $category_id Category ID to insert.
	 * @param int $category_tree_id Tree to insert into.
	 * @return string
	 */
	protected function get_insert_sql( $category_id, $category_tree_id ) {
		global $wpdb;
		return $wpdb->prepare( '(%d,%d)', $category_id, $category_tree_id );
	}

	/**
	 * Used to construct insert query recursively.
	 *
	 * @param  array $inserts Array of data to insert.
	 * @param  array $terms   Terms to insert.
	 * @param  array $parents Parent IDs the terms belong to.
	 */
	protected function get_term_insert_values( &$inserts, $terms, $parents = array() ) {
		foreach ( $terms as $term ) {
			$insert_parents = array_merge( array( $term['term_id'] ), $parents );

			foreach ( $insert_parents as $parent ) {
				$inserts[] = array(
					$parent,
					$term['term_id'],
				);
			}

			$this->get_term_insert_values( $inserts, $term['descendants'], $insert_parents );
		}
	}

	/**
	 * Convert flat terms array into nested array.
	 *
	 * @param array   $hierarchy Array to put terms into.
	 * @param array   $terms Array of terms (id=>parent).
	 * @param integer $parent Parent ID.
	 */
	protected function unflatten_terms( &$hierarchy, &$terms, $parent = 0 ) {
		foreach ( $terms as $term_id => $parent_id ) {
			if ( (int) $parent_id === $parent ) {
				$hierarchy[ $term_id ] = array(
					'term_id'     => $term_id,
					'descendants' => array(),
				);
				unset( $terms[ $term_id ] );
			}
		}
		foreach ( $hierarchy as $term_id => $terms_array ) {
			$this->unflatten_terms( $hierarchy[ $term_id ]['descendants'], $terms, $term_id );
		}
	}

	/**
	 * Get category descendants.
	 *
	 * @param int $category_id The category ID to lookup.
	 * @return array
	 */
	protected function get_descendants( $category_id ) {
		global $wpdb;

		return wp_parse_id_list(
			$wpdb->get_col(
				$wpdb->prepare(
					"SELECT category_id FROM $wpdb->wc_category_lookup WHERE category_tree_id = %d",
					$category_id
				)
			)
		);
	}

	/**
	 * Return all ancestor category ids for a category.
	 *
	 * @param int $category_id The category ID to lookup.
	 * @return array
	 */
	protected function get_ancestors( $category_id ) {
		global $wpdb;

		return wp_parse_id_list(
			$wpdb->get_col(
				$wpdb->prepare(
					"SELECT category_tree_id FROM $wpdb->wc_category_lookup WHERE category_id = %d",
					$category_id
				)
			)
		);
	}

	/**
	 * Add category lookup table to $wpdb object.
	 */
	public static function define_category_lookup_tables_in_wpdb() {
		global $wpdb;

		// List of tables without prefixes.
		$tables = array(
			'wc_category_lookup' => 'wc_category_lookup',
		);

		foreach ( $tables as $name => $table ) {
			$wpdb->$name    = $wpdb->prefix . $table;
			$wpdb->tables[] = $table;
		}
	}
}
PK     [1]9b.  .    Admin/Translations.phpnu         <?php
/**
 * Register the scripts, and handles items needed for managing translations within WooCommerce Admin.
 */

namespace Automattic\WooCommerce\Internal\Admin;

use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Internal\Admin\Loader;

/**
 * Translations Class.
 */
class Translations {

	/**
	 * Class instance.
	 *
	 * @var Translations instance
	 */
	protected static $instance = null;

	/**
	 * Plugin domain.
	 *
	 * @var string
	 */
	private static $plugin_domain = 'woocommerce';

	/**
	 * Get class instance.
	 */
	public static function get_instance() {
		if ( ! self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Constructor.
	 * Hooks added here should be removed in `wc_admin_initialize` via the feature plugin.
	 */
	public function __construct() {
		add_action( 'admin_enqueue_scripts', array( $this, 'potentially_load_translation_script_file' ), 15 );

		// Combine JSON translation files (from chunks) when language packs are updated.
		add_action( 'upgrader_process_complete', array( $this, 'combine_translation_chunk_files' ), 10, 2 );

		// Handler for WooCommerce and WooCommerce Admin plugin activation.
		add_action( 'woocommerce_activated_plugin', array( $this, 'potentially_generate_translation_strings' ) );
		add_action( 'activated_plugin', array( $this, 'potentially_generate_translation_strings' ) );
	}

	/**
	 * Generate a filename to cache translations from JS chunks.
	 *
	 * @param string $domain Text domain.
	 * @param string $locale Locale being retrieved.
	 * @return string Filename.
	 */
	private function get_combined_translation_filename( $domain, $locale ) {
		$filename = implode( '-', array( $domain, $locale, WC_ADMIN_APP ) ) . '.json';

		return $filename;
	}

	/**
	 * Combines data from translation chunk files based on officially downloaded file format.
	 *
	 * @param array $json_i18n_filenames List of JSON chunk files.
	 * @return array Combined translation chunk data.
	 */
	private function combine_official_translation_chunks( $json_i18n_filenames ) {
		// the filesystem object should be hooked up.
		global $wp_filesystem;
		$combined_translation_data = array();

		foreach ( $json_i18n_filenames as $json_filename ) {
			if ( ! $wp_filesystem->is_readable( $json_filename ) ) {
				continue;
			}

			$file_contents = $wp_filesystem->get_contents( $json_filename );
			$chunk_data    = \json_decode( $file_contents, true );

			if ( empty( $chunk_data ) ) {
				continue;
			}

			if ( ! isset( $chunk_data['comment']['reference'] ) ) {
				continue;
			}

			$reference_file = $chunk_data['comment']['reference'];

			// Only combine "app" files (not scripts registered with WP).
			if (
				false === strpos( $reference_file, WC_ADMIN_DIST_JS_FOLDER . 'app/index.js' ) &&
				false === strpos( $reference_file, WC_ADMIN_DIST_JS_FOLDER . 'chunks/' )
			) {
				continue;
			}

			if ( empty( $combined_translation_data ) ) {
				// Use the first translation file as the base structure.
				$combined_translation_data = $chunk_data;
			} else {
				// Combine all messages from all chunk files.
				$combined_translation_data['locale_data']['messages'] = array_merge(
					$combined_translation_data['locale_data']['messages'],
					$chunk_data['locale_data']['messages']
				);
			}
		}

		// Remove inaccurate reference comment.
		unset( $combined_translation_data['comment'] );
		return $combined_translation_data;
	}

	/**
	 * Combines data from translation chunk files based on user-generated file formats,
	 * such as wp-cli tool or Loco Translate plugin.
	 *
	 * @param array $json_i18n_filenames List of JSON chunk files.
	 * @return array Combined translation chunk data.
	 */
	private function combine_user_translation_chunks( $json_i18n_filenames ) {
		// the filesystem object should be hooked up.
		global $wp_filesystem;
		$combined_translation_data = array();

		foreach ( $json_i18n_filenames as $json_filename ) {
			if ( ! $wp_filesystem->is_readable( $json_filename ) ) {
				continue;
			}

			$file_contents = $wp_filesystem->get_contents( $json_filename );
			$chunk_data    = \json_decode( $file_contents, true );

			if ( empty( $chunk_data ) ) {
				continue;
			}

			$reference_file = $chunk_data['source'];

			// Only combine "app" files (not scripts registered with WP).
			if (
				false === strpos( $reference_file, WC_ADMIN_DIST_JS_FOLDER . 'app/index.js' ) &&
				false === strpos( $reference_file, WC_ADMIN_DIST_JS_FOLDER . 'chunks/' )
			) {
				continue;
			}

			if ( empty( $combined_translation_data ) ) {
				// Use the first translation file as the base structure.
				$combined_translation_data = $chunk_data;
			} else {
				// Combine all messages from all chunk files.
				$combined_translation_data['locale_data']['woocommerce'] = array_merge(
					$combined_translation_data['locale_data']['woocommerce'],
					$chunk_data['locale_data']['woocommerce']
				);
			}
		}

		// Remove inaccurate reference comment.
		unset( $combined_translation_data['source'] );
		return $combined_translation_data;
	}

	/**
	 * Find and combine translation chunk files.
	 *
	 * Only targets files that aren't represented by a registered script (e.g. not passed to wp_register_script()).
	 *
	 * @param string $lang_dir Path to language files.
	 * @param string $domain Text domain.
	 * @param string $locale Locale being retrieved.
	 * @return array Combined translation chunk data.
	 */
	private function get_translation_chunk_data( $lang_dir, $domain, $locale ) {
		// So long as this function is called during the 'upgrader_process_complete' action,
		// the filesystem object should be hooked up.
		global $wp_filesystem;

		// Grab all JSON files in the current language pack.
		$json_i18n_filenames       = glob( $lang_dir . $domain . '-' . $locale . '-*.json' );
		$combined_translation_data = array();

		if ( false === $json_i18n_filenames ) {
			return $combined_translation_data;
		}

		// Use first JSON file to determine file format. This check is required due to
		// file format difference between official language files and user translated files.
		$format_determine_file = reset( $json_i18n_filenames );

		if ( ! $wp_filesystem->is_readable( $format_determine_file ) ) {
			return $combined_translation_data;
		}

		$file_contents         = $wp_filesystem->get_contents( $format_determine_file );
		$format_determine_data = \json_decode( $file_contents, true );

		if ( empty( $format_determine_data ) ) {
			return $combined_translation_data;
		}

		if ( isset( $format_determine_data['comment'] ) ) {
			return $this->combine_official_translation_chunks( $json_i18n_filenames );
		} elseif ( isset( $format_determine_data['source'] ) ) {
			return $this->combine_user_translation_chunks( $json_i18n_filenames );
		} else {
			return $combined_translation_data;
		}
	}

	/**
	 * Combine and save translations for a specific locale.
	 *
	 * Note that this assumes \WP_Filesystem is already initialized with write access.
	 *
	 * @param string $language_dir Path to language files.
	 * @param string $plugin_domain Text domain.
	 * @param string $locale Locale being retrieved.
	 */
	private function build_and_save_translations( $language_dir, $plugin_domain, $locale ) {
		global $wp_filesystem;
		$translations_from_chunks = $this->get_translation_chunk_data( $language_dir, $plugin_domain, $locale );

		if ( empty( $translations_from_chunks ) ) {
			return;
		}

		$cache_filename          = $this->get_combined_translation_filename( $plugin_domain, $locale );
		$chunk_translations_json = wp_json_encode( $translations_from_chunks );

		// Cache combined translations strings to a file.
		$wp_filesystem->put_contents( $language_dir . $cache_filename, $chunk_translations_json );
	}

	/**
	 * Combine translation chunks when plugin is activated.
	 *
	 * This function combines JSON translation data auto-extracted by GlotPress
	 * from Webpack-generated JS chunks into a single file. This is necessary
	 * since the JS chunks are not known to WordPress via wp_register_script()
	 * and wp_set_script_translations().
	 */
	private function generate_translation_strings() {
		$locale   = determine_locale();
		$lang_dir = WP_LANG_DIR . '/plugins/';

		// Bail early if not localized.
		if ( 'en_US' === $locale ) {
			return;
		}

		if ( ! function_exists( 'get_filesystem_method' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}

		$access_type = get_filesystem_method();
		if ( 'direct' === $access_type ) {
			\WP_Filesystem();
			$this->build_and_save_translations( $lang_dir, self::$plugin_domain, $locale );
		} else {
			// I'm reluctant to add support for other filesystems here as it would require
			// user's input on activating plugin - which I don't think is common.
			return;
		}
	}

	/**
	 * Loads the required translation scripts on the correct pages.
	 */
	public function potentially_load_translation_script_file() {
		if ( ! PageController::is_admin_or_embed_page() ) {
			return;
		}

		// Grab translation strings from Webpack-generated chunks.
		add_filter( 'load_script_translation_file', array( $this, 'load_script_translation_file' ), 10, 3 );
	}

	/**
	 * Load translation strings from language packs for dynamic imports.
	 *
	 * @param string $file File location for the script being translated.
	 * @param string $handle Script handle.
	 * @param string $domain Text domain.
	 *
	 * @return string New file location for the script being translated.
	 */
	public function load_script_translation_file( $file, $handle, $domain ) {
		// Make sure the main app script is being loaded.
		if ( WC_ADMIN_APP !== $handle ) {
			return $file;
		}

		// Make sure we're handing the correct domain.
		if ( self::$plugin_domain !== $domain ) {
			return $file;
		}

		$locale         = determine_locale();
		$cache_filename = $this->get_combined_translation_filename( $domain, $locale );

		return WP_LANG_DIR . '/plugins/' . $cache_filename;
	}

	/**
	 * Run when plugin is activated (can be WooCommerce or WooCommerce Admin).
	 *
	 * @param string $filename Activated plugin filename.
	 */
	public function potentially_generate_translation_strings( $filename ) {
		$activated_plugin_domain = explode( '/', $filename )[0];

		// Ensure we're only running only on activation hook that originates from our plugin.
		if ( self::$plugin_domain === $activated_plugin_domain ) {
			$this->generate_translation_strings();
		}
	}

	/**
	 * Combine translation chunks when files are updated.
	 *
	 * This function combines JSON translation data auto-extracted by GlotPress
	 * from Webpack-generated JS chunks into a single file that can be used in
	 * subsequent requests. This is necessary since the JS chunks are not known
	 * to WordPress via wp_register_script() and wp_set_script_translations().
	 *
	 * @param Language_Pack_Upgrader $instance Upgrader instance.
	 * @param array                  $hook_extra Info about the upgraded language packs.
	 */
	public function combine_translation_chunk_files( $instance, $hook_extra ) {
		if (
			! is_a( $instance, 'Language_Pack_Upgrader' ) ||
			! isset( $hook_extra['translations'] ) ||
			! is_array( $hook_extra['translations'] )
		) {
			return;
		}

		$locales      = array();
		$language_dir = WP_LANG_DIR . '/plugins/';

		// Gather the locales that were updated in this operation.
		foreach ( $hook_extra['translations'] as $translation ) {
			if (
				'plugin' === $translation['type'] &&
				self::$plugin_domain === $translation['slug']
			) {
				$locales[] = $translation['language'];
			}
		}

		// Build combined translation files for all updated locales.
		foreach ( $locales as $locale ) {
			// So long as this function is hooked to the 'upgrader_process_complete' action,
			// WP_Filesystem should be hooked up to be able to call build_and_save_translations.
			$this->build_and_save_translations( $language_dir, self::$plugin_domain, $locale );
		}
	}
}
PK     [1]_%  %  1  Admin/EmailPreview/EmailPreviewRestController.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\EmailPreview;

use Automattic\WooCommerce\Internal\RestApiControllerBase;
use WP_Error;
use WP_REST_Request;

/**
 * Controller for the REST endpoint to send an email preview.
 */
class EmailPreviewRestController extends RestApiControllerBase {

	/**
	 * Email preview nonce.
	 *
	 * @var string
	 */
	const NONCE_KEY = 'email-preview-nonce';

	/**
	 * Holds the EmailPreview instance for rendering email previews.
	 *
	 * @var EmailPreview
	 */
	private EmailPreview $email_preview;

	/**
	 * The root namespace for the JSON REST API endpoints.
	 *
	 * @var string
	 */
	protected string $route_namespace = 'wc-admin-email';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected string $rest_base = 'settings/email';

	/**
	 * Get the WooCommerce REST API namespace for the class.
	 *
	 * @return string
	 */
	protected function get_rest_api_namespace(): string {
		return 'wc-admin-email';
	}

	/**
	 * The constructor.
	 */
	public function __construct() {
		$this->email_preview = wc_get_container()->get( EmailPreview::class );
	}

	/**
	 * Register the REST API endpoints handled by this controller.
	 */
	public function register_routes() {
		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/send-preview',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->send_email_preview( $request ),
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => $this->get_args_for_send_preview(),
					'schema'              => $this->get_schema_with_message(),
				),
			)
		);

		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/preview-subject',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => fn() => array(
						'subject' => $this->email_preview->get_subject(),
					),
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => $this->get_args_for_preview_subject(),
					'schema'              => $this->get_schema_for_preview_subject(),
				),
			)
		);

		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/save-transient',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->save_transient( $request ),
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => $this->get_args_for_save_transient(),
					'schema'              => $this->get_schema_with_message(),
				),
			)
		);
	}

	/**
	 * Get the accepted arguments for the POST send-preview request.
	 *
	 * @return array[]
	 */
	private function get_args_for_send_preview() {
		return array(
			'type'  => array(
				'description'       => __( 'The email type to preview.', 'woocommerce' ),
				'type'              => 'string',
				'required'          => true,
				'validate_callback' => fn( $key ) => $this->validate_email_type( $key ),
				'sanitize_callback' => 'sanitize_text_field',
			),
			'email' => array(
				'description'       => __( 'Email address to send the email preview to.', 'woocommerce' ),
				'type'              => 'string',
				'format'            => 'email',
				'required'          => true,
				'validate_callback' => 'rest_validate_request_arg',
				'sanitize_callback' => 'sanitize_email',
			),
		);
	}

	/**
	 * Get the accepted arguments for the GET preview-subject request.
	 *
	 * @return array[]
	 */
	private function get_args_for_preview_subject() {
		return array(
			'type' => array(
				'description'       => __( 'The email type to get subject for.', 'woocommerce' ),
				'type'              => 'string',
				'required'          => true,
				'validate_callback' => fn( $key ) => $this->validate_email_type( $key ),
				'sanitize_callback' => 'sanitize_text_field',
			),
		);
	}

	/**
	 * Get the accepted arguments for the POST save-transient request.
	 *
	 * @return array[]
	 */
	private function get_args_for_save_transient() {
		return array(
			'key'   => array(
				'required'          => true,
				'type'              => 'string',
				'description'       => 'The key for the transient. Must be one of the allowed options.',
				'validate_callback' => function ( $key ) {
					if ( ! in_array( $key, EmailPreview::get_all_email_setting_ids(), true ) ) {
						return new \WP_Error(
							'woocommerce_rest_not_allowed_key',
							sprintf( 'The provided key "%s" is not allowed.', $key ),
							array( 'status' => 400 ),
						);
					}
					return true;
				},
				'sanitize_callback' => 'sanitize_text_field',
			),
			'value' => array(
				'required'          => true,
				'type'              => 'string',
				'description'       => 'The value to be saved for the transient.',
				'validate_callback' => 'rest_validate_request_arg',
				'sanitize_callback' => function ( $value, $request ) {
					$key = $request->get_param( 'key' );
					if (
						'woocommerce_email_footer_text' === $key
						|| preg_match( '/_additional_content$/', $key )
					) {
						return wp_kses_post( trim( $value ) );
					}
					return sanitize_text_field( $value );
				},
			),
		);
	}

	/**
	 * Get the schema for the POST send-preview and save-transient requests.
	 *
	 * @return array[]
	 */
	private function get_schema_with_message() {
		return array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'email-preview-with-message',
			'type'       => 'object',
			'properties' => array(
				'message' => array(
					'description' => __( 'A message indicating that the action completed successfully.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
			),
		);
	}

	/**
	 * Get the schema for the GET preview_subject request.
	 *
	 * @return array[]
	 */
	private function get_schema_for_preview_subject() {
		return array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'email-preview-subject',
			'type'       => 'object',
			'properties' => array(
				'subject' => array(
					'description' => __( 'A subject for provided email type after filters are applied and placeholders replaced.', 'woocommerce' ),
					'type'        => 'string',
					'context'     => array( 'view' ),
					'readonly'    => true,
				),
			),
		);
	}

	/**
	 * Validate the email type.
	 *
	 * @param string $email_type The email type to validate.
	 * @return bool|WP_Error True if the email type is valid, otherwise a WP_Error object.
	 */
	private function validate_email_type( string $email_type ) {
		try {
			$this->email_preview->set_email_type( $email_type );
		} catch ( \InvalidArgumentException $e ) {
			return new WP_Error(
				'woocommerce_rest_invalid_email_type',
				__( 'Invalid email type.', 'woocommerce' ),
				array( 'status' => 400 ),
			);
		}
		return true;
	}

	/**
	 * Permission check for REST API endpoint.
	 *
	 * @param WP_REST_Request $request The request for which the permission is checked.
	 * @return bool|WP_Error True if the current user has the capability, otherwise a WP_Error object.
	 */
	private function check_permissions( WP_REST_Request $request ) {
		$nonce = $request->get_param( 'nonce' );
		if ( ! wp_verify_nonce( $nonce, self::NONCE_KEY ) ) {
			return new WP_Error(
				'invalid_nonce',
				__( 'Invalid nonce.', 'woocommerce' ),
				array( 'status' => 403 ),
			);
		}
		return $this->check_permission( $request, 'manage_woocommerce' );
	}

	/**
	 * Handle the POST /settings/email/send-preview.
	 *
	 * @param WP_REST_Request $request The received request.
	 * @return array|WP_Error Request response or an error.
	 */
	public function send_email_preview( WP_REST_Request $request ) {
		$email_address = $request->get_param( 'email' );
		// Start output buffering to prevent partial renders with PHP notices or warnings.
		ob_start();
		try {
			$email_content = $this->email_preview->render();
		} catch ( \Throwable $e ) {
			ob_end_clean();
			return new WP_Error(
				'woocommerce_rest_email_preview_not_rendered',
				__( 'There was an error rendering an email preview.', 'woocommerce' ),
				array( 'status' => 500 )
			);
		}
		ob_end_clean();
		$email_subject = $this->email_preview->get_subject();
		$email         = new \WC_Emails();
		$sent          = $email->send( $email_address, $email_subject, $email_content );

		if ( $sent ) {
			return array(
				// translators: %s: Email address.
				'message' => sprintf( __( 'Test email sent to %s.', 'woocommerce' ), $email_address ),
			);
		}
		return new WP_Error(
			'woocommerce_rest_email_preview_not_sent',
			__( 'Error sending test email. Please try again.', 'woocommerce' ),
			array( 'status' => 500 )
		);
	}

	/**
	 * Handle the POST /settings/email/save-transient.
	 *
	 * @param WP_REST_Request $request The received request.
	 * @return array|WP_Error Request response or an error.
	 */
	public function save_transient( WP_REST_Request $request ) {
		$key    = $request->get_param( 'key' );
		$value  = $request->get_param( 'value' );
		$is_set = set_transient( $key, $value, HOUR_IN_SECONDS );
		if ( ! $is_set ) {
			return new WP_Error(
				'woocommerce_rest_transient_not_set',
				__( 'Error saving transient. Please try again.', 'woocommerce' ),
				array( 'status' => 500 )
			);
		}
		return array(
			// translators: %s: Email settings color key, e.g., "woocommerce_email_base_color".
			'message' => sprintf( __( 'Transient saved for key %s.', 'woocommerce' ), $key ),
		);
	}
}
PK     [1]?e9#b  b  #  Admin/EmailPreview/EmailPreview.phpnu         <?php
/**
 * Renders the email preview.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\EmailPreview;

use Automattic\WooCommerce\Internal\EmailEditor\WooContentProcessor;
use Automattic\WooCommerce\Enums\OrderStatus;
use Throwable;
use WC_Email;
use WC_Order;
use WC_Order_Item_Product;
use WC_Order_Item_Shipping;
use WC_Product;
use WC_Product_Variation;
use WP_User;

defined( 'ABSPATH' ) || exit;


/**
 * EmailPreview Class.
 */
class EmailPreview {
	const DEFAULT_EMAIL_TYPE = 'WC_Email_Customer_Processing_Order';
	const DEFAULT_EMAIL_ID   = 'customer_processing_order';
	const USER_OBJECT_EMAILS = array(
		'WC_Email_Customer_New_Account',
		'WC_Email_Customer_Reset_Password',
	);

	const TRANSIENT_PREVIEW_EMAIL_IMPROVEMENTS = 'woocommerce_preview_email_improvements';

	/**
	 * All fields IDs that can customize email styles in Settings.
	 *
	 * @var array
	 */
	private static array $email_style_setting_ids = array(
		'woocommerce_email_background_color',
		'woocommerce_email_base_color',
		'woocommerce_email_body_background_color',
		'woocommerce_email_font_family',
		'woocommerce_email_footer_text',
		'woocommerce_email_footer_text_color',
		'woocommerce_email_header_alignment',
		'woocommerce_email_header_image',
		'woocommerce_email_header_image_width',
		'woocommerce_email_text_color',
	);

	/**
	 * All fields IDs that can customize specific email content in Settings.
	 *
	 * @var array
	 */
	private static array $email_content_setting_ids = array();

	/**
	 * Whether the email setting IDs are initialized.
	 *
	 * @var bool
	 */
	private static bool $email_setting_ids_initialized = false;

	/**
	 * The email type to preview.
	 *
	 * @var string|null
	 */
	private ?string $email_type = null;

	/**
	 * The email object.
	 *
	 * @var WC_Email|null
	 */
	private ?WC_Email $email = null;

	/**
	 * The single instance of the class.
	 *
	 * @var object
	 */
	protected static $instance = null;

	/**
	 * Whether the locale has been switched when rendering the preview.
	 *
	 * @var bool
	 */
	private bool $locale_switched = false;

	/**
	 * Get class instance.
	 *
	 * @return object Instance.
	 */
	final public static function instance() {
		if ( null === static::$instance ) {
			static::$instance = new static();
		}
		return static::$instance;
	}

	/**
	 * Get all email setting IDs.
	 */
	public static function get_all_email_setting_ids() {
		if ( ! self::$email_setting_ids_initialized ) {
			self::$email_setting_ids_initialized = true;

			$emails = WC()->mailer()->get_emails();
			foreach ( $emails as $email ) {
				self::$email_content_setting_ids = array_merge(
					self::$email_content_setting_ids,
					self::get_email_content_setting_ids( $email->id )
				);
			}
			self::$email_content_setting_ids = array_unique( self::$email_content_setting_ids );
		}
		return array_merge(
			self::$email_style_setting_ids,
			self::$email_content_setting_ids,
		);
	}

	/**
	 * Get email style setting IDs.
	 */
	public static function get_email_style_setting_ids() {
		/**
		 * Filter the email style setting IDs. Email preview automatically refreshes when these settings are changed.
		 *
		 * @param array $setting_ids The email style setting IDs.
		 *
		 * @since 9.8.0
		 */
		return apply_filters( 'woocommerce_email_preview_email_style_setting_ids', self::$email_style_setting_ids );
	}

	/**
	 * Get email content setting IDs for specific email.
	 *
	 * @param string|null $email_id Email ID.
	 */
	public static function get_email_content_setting_ids( ?string $email_id ) {
		if ( ! $email_id ) {
			return array();
		}
		$setting_ids = array(
			"woocommerce_{$email_id}_subject",
			"woocommerce_{$email_id}_heading",
			"woocommerce_{$email_id}_additional_content",
			"woocommerce_{$email_id}_email_type",
		);

		/**
		 * Filter the email content setting IDs for specific email. Email preview automatically refreshes when these settings are changed.
		 *
		 * @param array  $setting_ids The email content setting IDs.
		 * @param string $email_id The email ID.
		 *
		 * @since 9.8.0
		 */
		return apply_filters( 'woocommerce_email_preview_email_content_setting_ids', $setting_ids, $email_id );
	}

	/**
	 * Set the email type to preview.
	 *
	 * @param string $email_type Email type.
	 *
	 * @throws \InvalidArgumentException When the email type is invalid.
	 */
	public function set_email_type( string $email_type ) {
		$this->switch_to_site_locale();

		$wc_emails = WC()->mailer()->get_emails();
		$emails    = array_combine(
			array_map( 'get_class', $wc_emails ),
			$wc_emails
		);
		if ( ! in_array( $email_type, array_keys( $emails ), true ) ) {
			throw new \InvalidArgumentException( 'Invalid email type' );
		}
		$this->email_type = $email_type;
		$this->email      = $emails[ $email_type ];
		$object           = null;

		if ( in_array( $email_type, self::USER_OBJECT_EMAILS, true ) ) {
			$object                  = new WP_User( 0 );
			$object->user_email      = 'user_preview@example.com';
			$object->user_login      = 'user_preview';
			$object->first_name      = 'John';
			$object->last_name       = 'Doe';
			$this->email->user_email = $object->user_email;
			$this->email->user_login = $object->user_login;

			if ( property_exists( $this->email, 'reset_key' ) ) {
				$this->email->reset_key = 'reset_key';
			}

			if ( property_exists( $this->email, 'set_password_url' ) ) {
				$this->email->set_password_url = 'https://example.com/set-password';
			}

			if ( property_exists( $this->email, 'user_id' ) ) {
				$this->email->user_id = 0;
			}

			$this->email->set_object( $object );
		} else {
			$object = $this->get_dummy_order();
			if ( 'WC_Email_Customer_Note' === $email_type ) {
				$this->email->customer_note = $object->get_customer_note();
			}
			if ( 'WC_Email_Customer_Refunded_Order' === $email_type ) {
				$this->email->partial_refund = false;
			}
			$this->email->set_object( $object );
		}
		$this->email->placeholders = array_merge(
			$this->email->placeholders,
			$this->get_placeholders( $object )
		);

		/**
		 * Allow to modify the email object before rendering the preview to add additional data.
		 *
		 * @param WC_Email $email The email object.
		 *
		 * @since 9.6.0
		 */
		$this->email = apply_filters( 'woocommerce_prepare_email_for_preview', $this->email );

		$this->restore_locale();
	}

	/**
	 * Get the email object.
	 *
	 * @return WC_Email
	 */
	public function get_email() {
		return $this->email;
	}

	/**
	 * Get the preview email content.
	 *
	 * @return string
	 */
	public function render() {
		return $this->render_preview_email();
	}

	/**
	 * Ensure links open in new tab. User in WooCommerce Settings,
	 * so the links don't open inside the iframe.
	 *
	 * @param string $content Email content HTML.
	 * @return string
	 */
	public function ensure_links_open_in_new_tab( string $content ) {
		if ( empty( $content ) || strpos( $content, '<a' ) === false ) {
			return $content;
		}

		if ( ! class_exists( 'DOMDocument' ) ) {
			return $content;
		}

		// Suppress libxml errors to prevent them from being displayed.
		$previous_use_internal_errors = libxml_use_internal_errors( true );

		try {
			$dom = new \DOMDocument();

			// Add UTF-8 encoding and load with error suppression flags.
			$html_with_encoding = '<?xml encoding="UTF-8">' . $content;
			$dom->loadHTML(
				$html_with_encoding,
				LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOWARNING | LIBXML_NOERROR
			);

			$links = $dom->getElementsByTagName( 'a' );
			foreach ( $links as $link ) {
				$link->setAttribute( 'target', '_blank' );
				$link->setAttribute( 'rel', 'noopener' );
			}

			$result = $dom->saveHTML();

			// Remove the XML declaration we added earlier, it's not meant to be used in an HTML document.
			$result = preg_replace( '/<\?xml[^>]*>\s*/i', '', $result );

			return $result;
		} catch ( \Exception $e ) {
			return $content;
		} finally {
			libxml_use_internal_errors( $previous_use_internal_errors );
			libxml_clear_errors();
		}
	}

	/**
	 * Get the preview email content.
	 *
	 * @return string
	 */
	public function get_subject() {
		if ( ! $this->email ) {
			return '';
		}
		$this->set_up_filters();
		$subject = $this->email->get_subject();
		$this->clean_up_filters();
		return $subject;
	}

	/**
	 * Return a dummy product when the product is not set in email classes.
	 *
	 * @param WC_Product|null $product Order item product.
	 * @return WC_Product
	 */
	public function get_dummy_product_when_not_set( $product ) {
		if ( $product ) {
			return $product;
		}
		return $this->get_dummy_product();
	}

	/**
	 * Render HTML content of the preview email.
	 *
	 * @return string
	 */
	private function render_preview_email() {
		if ( ! $this->email_type ) {
			$this->set_email_type( self::DEFAULT_EMAIL_TYPE );
		}

		$this->set_up_filters();

		if ( 'plain' === $this->email->get_email_type() ) {
			$content  = '<pre style="word-wrap: break-word; white-space: pre-wrap; text-align: ' . ( is_rtl() ? 'right' : 'left' ) . ';">';
			$content .= $this->email->get_content_plain();
			$content .= '</pre>';
		} else {
			$content = $this->email->get_content_html();
		}
		$inlined = $this->email->style_inline( $content );

		$this->clean_up_filters();

		/** This filter is documented in src/Internal/Admin/EmailPreview/EmailPreview.php */
		return apply_filters( 'woocommerce_mail_content', $inlined ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment
	}

	/**
	 * Get a dummy order object without the need to create in the database.
	 *
	 * @return WC_Order
	 */
	private function get_dummy_order() {
		$product              = $this->get_dummy_product();
		$variation            = $this->get_dummy_product_variation();
		$downloadable_product = $this->get_dummy_downloadable_product();

		$order = new WC_Order();
		$order->set_id( 12345 );

		// Create and add product items manually without saving to database.
		// Use add_item() instead of add_product() to avoid immediate database writes.
		if ( $product ) {
			$item = new WC_Order_Item_Product();
			$item->set_props(
				array(
					'name'         => $product->get_name(),
					'tax_class'    => $product->get_tax_class(),
					'product_id'   => $product->get_id(),
					'variation_id' => 0,
					'quantity'     => 2,
					'subtotal'     => $product->get_price() * 2,
					'total'        => $product->get_price() * 2,
				)
			);
			$order->add_item( $item );
		}
		if ( $variation ) {
			$item = new WC_Order_Item_Product();
			$item->set_props(
				array(
					'name'         => $variation->get_name(),
					'tax_class'    => $variation->get_tax_class(),
					'product_id'   => $variation->get_parent_id(),
					'variation_id' => $variation->get_id(),
					'variation'    => $variation->get_attributes(),
					'quantity'     => 1,
					'subtotal'     => $variation->get_price(),
					'total'        => $variation->get_price(),
				)
			);
			$order->add_item( $item );
		}
		if ( $downloadable_product ) {
			$item = new WC_Order_Item_Product();
			$item->set_props(
				array(
					'name'         => $downloadable_product->get_name(),
					'tax_class'    => $downloadable_product->get_tax_class(),
					'product_id'   => $downloadable_product->get_id(),
					'variation_id' => 0,
					'quantity'     => 1,
					'subtotal'     => $downloadable_product->get_price(),
					'total'        => $downloadable_product->get_price(),
				)
			);
			$order->add_item( $item );
		}

		$order->set_date_created( time() );
		$order->set_currency( 'USD' );
		$order->set_discount_total( 10 );
		$order->set_shipping_total( 5 );
		$order->set_total( 80 );
		$order->set_payment_method_title( __( 'Direct bank transfer', 'woocommerce' ) );
		$order->set_transaction_id( '999999999' );
		$order->set_customer_note( __( "This is a customer note. Customers can add a note to their order on checkout.\n\nIt can be multiple lines. If there's no note, this section is hidden.", 'woocommerce' ) );

		$order = $this->apply_dummy_order_status( $order );

		// Add shipping method.
		$shipping_item = new WC_Order_Item_Shipping();
		$shipping_item->set_props(
			array(
				'method_title' => __( 'Flat rate', 'woocommerce' ),
				'method_id'    => 'flat_rate',
				'total'        => '5.00',
			)
		);
		$order->add_item( $shipping_item );

		$address = $this->get_dummy_address();
		$order->set_billing_address( $address );
		$order->set_shipping_address( $address );

		/**
		 * A dummy WC_Order used in email preview.
		 *
		 * @param WC_Order $order The dummy order object.
		 * @param string   $email_type The email type to preview.
		 *
		 * @since 9.6.0
		 */
		return apply_filters( 'woocommerce_email_preview_dummy_order', $order, $this->email_type );
	}

	/**
	 * Apply a contextual status to the dummy order based on the previewed email type.
	 *
	 * @param WC_Order $order Dummy order instance.
	 * @return WC_Order
	 */
	private function apply_dummy_order_status( WC_Order $order ): WC_Order {
		$email_type_status_map = array(
			'WC_Email_Customer_Completed_Order'  => OrderStatus::COMPLETED,
			'WC_Email_Customer_Processing_Order' => OrderStatus::PROCESSING,
			'WC_Email_Customer_On_Hold_Order'    => OrderStatus::ON_HOLD,
			'WC_Email_Customer_Failed_Order'     => OrderStatus::FAILED,
			'WC_Email_Customer_Cancelled_Order'  => OrderStatus::CANCELLED,
			'WC_Email_Customer_Refunded_Order'   => OrderStatus::REFUNDED,
			'WC_Email_New_Order'                 => OrderStatus::PROCESSING,
			'WC_Email_Cancelled_Order'           => OrderStatus::CANCELLED,
			'WC_Email_Failed_Order'              => OrderStatus::FAILED,
		);

		$status = $email_type_status_map[ $this->email_type ] ?? OrderStatus::PROCESSING;
		$order->set_status( $status );
		return $order;
	}

	/**
	 * Get a dummy product. Also used with `woocommerce_order_item_product` filter
	 * when email templates tries to get the product from the database.
	 *
	 * @return WC_Product
	 */
	private function get_dummy_product() {
		$product = new WC_Product();
		$product->set_name( __( 'Dummy Product', 'woocommerce' ) );
		$product->set_price( 25 );

		/**
		 * A dummy WC_Product used in email preview.
		 *
		 * @param WC_Product $product The dummy product object.
		 * @param string     $email_type The email type to preview.
		 *
		 * @since 9.6.0
		 */
		return apply_filters( 'woocommerce_email_preview_dummy_product', $product, $this->email_type );
	}

	/**
	 * Get a dummy product variation.
	 *
	 * @return WC_Product_Variation
	 */
	private function get_dummy_product_variation() {
		$variation = new WC_Product_Variation();
		$variation->set_name( __( 'Dummy Product Variation', 'woocommerce' ) );
		$variation->set_price( 20 );
		$variation->set_attributes(
			array(
				__( 'Color', 'woocommerce' ) => __( 'Red', 'woocommerce' ),
				__( 'Size', 'woocommerce' )  => __( 'Small', 'woocommerce' ),
			)
		);

		/**
		 * A dummy WC_Product_Variation used in email preview.
		 *
		 * @param WC_Product_Variation $variation The dummy product variation object.
		 * @param string               $email_type The email type to preview.
		 *
		 * @since 9.7.0
		 */
		return apply_filters( 'woocommerce_email_preview_dummy_product_variation', $variation, $this->email_type );
	}

	/**
	 * Get a dummy downloadable/virtual product.
	 *
	 * @return WC_Product
	 */
	private function get_dummy_downloadable_product() {
		$product = new WC_Product();
		$product->set_name( __( 'Dummy Downloadable Product', 'woocommerce' ) );
		$product->set_price( 15 );
		$product->set_virtual( true );
		$product->set_downloadable( true );

		/**
		 * A dummy downloadable WC_Product used in email preview.
		 *
		 * @param WC_Product $product The dummy downloadable product object.
		 * @param string     $email_type The email type to preview.
		 *
		 * @since 10.3.0
		 */
		return apply_filters( 'woocommerce_email_preview_dummy_downloadable_product', $product, $this->email_type );
	}

	/**
	 * Get a dummy address.
	 *
	 * @return array
	 */
	private function get_dummy_address() {
		$address = array(
			'first_name' => 'John',
			'last_name'  => 'Doe',
			'company'    => 'Company',
			'email'      => 'john@company.com',
			'phone'      => '555-555-5555',
			'address_1'  => '123 Fake Street',
			'city'       => 'Faketown',
			'postcode'   => '12345',
			'country'    => 'US',
			'state'      => 'CA',
		);

		/**
		 * A dummy address used in email preview as billing and shipping one.
		 *
		 * @param array  $address The dummy address.
		 * @param string $email_type The email type to preview.
		 *
		 * @since 9.6.0
		 */
		return apply_filters( 'woocommerce_email_preview_dummy_address', $address, $this->email_type );
	}

	/**
	 * Get the placeholders for the email preview.
	 *
	 * @param mixed $email_object The object to render email with. Can be WC_Order, WP_User, etc.
	 * @return array
	 */
	private function get_placeholders( $email_object ) {
		$placeholders = array();

		if ( is_a( $email_object, 'WC_Order' ) ) {
			$placeholders['{order_date}']              = wc_format_datetime( $email_object->get_date_created() );
			$placeholders['{order_number}']            = $email_object->get_order_number();
			$placeholders['{order_billing_full_name}'] = $email_object->get_formatted_billing_full_name();
		}

		/**
		 * Placeholders for email preview.
		 *
		 * @param array  $placeholders Placeholders for email subject.
		 * @param string $email_type   The email type to preview.
		 * @param mixed  $email_object The object to render email with. @since 9.9.0
		 *
		 * @since 9.6.0
		 */
		return apply_filters( 'woocommerce_email_preview_placeholders', $placeholders, $this->email_type, $email_object );
	}

	/**
	 * Set up filters for email preview.
	 */
	public function set_up_filters() {
		$this->switch_to_site_locale();
		// Always show shipping address in the preview email.
		add_filter( 'woocommerce_order_needs_shipping_address', array( $this, 'enable_shipping_address' ) );
		// Email templates fetch product from the database to show additional information, which are not
		// saved in WC_Order_Item_Product. This filter enables fetching that data also in email preview.
		add_filter( 'woocommerce_order_item_product', array( $this, 'get_dummy_product_when_not_set' ), 10, 1 );
		// Enable email preview mode - this way transient values are fetched for live preview.
		add_filter( 'woocommerce_is_email_preview', array( $this, 'enable_preview_mode' ) );
		// Use placeholder image included in WooCommerce files.
		add_filter( 'woocommerce_order_item_thumbnail', array( $this, 'get_placeholder_image' ) );
		// Make products in preview considered downloadable and provide dummy file so WC core shows downloads.
		add_filter( 'woocommerce_is_downloadable', array( $this, 'force_product_downloadable' ), 10, 1 );
		add_filter( 'woocommerce_product_file', array( $this, 'provide_dummy_product_file' ), 10, 1 );
		// Provide dummy downloadable items for email preview.
		add_filter( 'woocommerce_order_get_downloadable_items', array( $this, 'get_dummy_downloadable_items' ), 10, 1 );
	}

	/**
	 * Clean up filters after email preview.
	 */
	public function clean_up_filters() {
		remove_filter( 'woocommerce_order_needs_shipping_address', array( $this, 'enable_shipping_address' ) );
		remove_filter( 'woocommerce_order_item_product', array( $this, 'get_dummy_product_when_not_set' ), 10 );
		remove_filter( 'woocommerce_is_email_preview', array( $this, 'enable_preview_mode' ) );
		remove_filter( 'woocommerce_order_item_thumbnail', array( $this, 'get_placeholder_image' ) );
		remove_filter( 'woocommerce_is_downloadable', array( $this, 'force_product_downloadable' ), 10 );
		remove_filter( 'woocommerce_product_file', array( $this, 'provide_dummy_product_file' ), 10 );
		remove_filter( 'woocommerce_order_get_downloadable_items', array( $this, 'get_dummy_downloadable_items' ), 10 );
		$this->restore_locale();
	}

	/**
	 * Enable shipping address in the preview email. Not using __return_true so
	 * we don't accidentally remove the same filter used by other plugin or theme.
	 *
	 * @return true
	 */
	public function enable_shipping_address() {
		return true;
	}

	/**
	 * Enable preview mode to use transient values in email-styles.php. Not using __return_true
	 * so we don't accidentally remove the same filter used by other plugin or theme.
	 *
	 * @return true
	 */
	public function enable_preview_mode() {
		return true;
	}

	/**
	 * Get the placeholder image for the preview email.
	 *
	 * @return string
	 */
	public function get_placeholder_image() {
		return '<img src="' . WC()->plugin_url() . '/assets/images/placeholder.webp" width="48" height="48" alt="" />';
	}

	/**
	 * Force products in preview to be considered downloadable so core renders downloads section.
	 *
	 * @param bool $is_downloadable Current value.
	 * @return bool
	 */
	public function force_product_downloadable( $is_downloadable ) {
		/**
		 * Filters whether the current request is an email preview.
		 *
		 * When true, products should be considered downloadable so the downloads
		 * section renders in applicable emails during preview.
		 *
		 * @since 9.6.0
		 *
		 * @param bool $is_email_preview Whether preview mode is active.
		 */
		if ( apply_filters( 'woocommerce_is_email_preview', false ) ) {
			return true;
		}
		return $is_downloadable;
	}

	/**
	 * Provide a dummy product file so product->has_file() returns true in preview.
	 *
	 * @param array|null $file Current file array or null.
	 * @return array|null
	 */
	public function provide_dummy_product_file( $file ) {
		/**
		 * Filters whether the current request is an email preview.
		 *
		 * When true, provide a dummy product file array so downloadable template parts
		 * can render during preview.
		 *
		 * @since 9.6.0
		 *
		 * @param bool $is_email_preview Whether preview mode is active.
		 */
		if ( apply_filters( 'woocommerce_is_email_preview', false ) ) {
			return array(
				'name' => __( 'Sample Download File.pdf', 'woocommerce' ),
				'file' => 'sample-download.pdf',
			);
		}
		return $file;
	}

	/**
	 * Get dummy downloadable items for email preview.
	 *
	 * @param array $downloads Existing downloads.
	 * @return array
	 */
	public function get_dummy_downloadable_items( $downloads ) {
		$dummy_downloads = array(
			array(
				'product_name'   => $this->get_dummy_downloadable_product()->get_name(),
				'product_id'     => $this->get_dummy_downloadable_product()->get_id(),
				'download_url'   => 'https://example.com/download',
				'download_name'  => __( 'Sample Download File.pdf', 'woocommerce' ),
				'access_expires' => time() + ( 30 * DAY_IN_SECONDS ),
			),
		);

		return array_merge( $downloads, $dummy_downloads );
	}

	/**
	 * Generate placeholder content for a specific email type, typically used in the email editor.
	 *
	 * Encapsulates the logic for setting the email type, generating raw content, applying styles,
	 * ensuring links open in new tabs, and handling errors based on WP_DEBUG.
	 *
	 * @param string $email_type_class_name The class name of the WC_Email type (e.g., 'WC_Email_Customer_Processing_Order').
	 * @return string The generated and styled HTML content.
	 * @throws \RuntimeException If content generation fails. If rendering fails.
	 */
	public function generate_placeholder_content( string $email_type_class_name ): string {
		// Note: set_email_type can throw InvalidArgumentException.
		$this->set_email_type( $email_type_class_name );

		$woo_content_processor = wc_get_container()->get( WooContentProcessor::class );

		$generate_content_closure = function () use ( $woo_content_processor ) {
			// Note: If 'woocommerce_email_styles' filter was intentional and `prepare_css` isn't
			// the intended callback, adjust accordingly. This assumes `prepare_css` applies styles
			// needed for the Woo content block.
			add_filter( 'woocommerce_email_styles', array( $woo_content_processor, 'prepare_css' ), 10, 2 );
			$content = $woo_content_processor->get_woo_content( $this->get_email() );
			$content = $this->get_email()->style_inline( $content );
			$content = $this->ensure_links_open_in_new_tab( $content );
			return $content;
		};

		$this->set_up_filters();

		$message = '';
		try {
			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
				$message = $generate_content_closure();
			} else {
				// Use output buffering to prevent partial renders with PHP notices or warnings when WP_DEBUG is off.
				ob_start();
				try {
					$message = $generate_content_closure();
				} catch ( Throwable $e ) {
					ob_end_clean();
					// Let the caller handle the exception.
					throw new \RuntimeException( esc_html__( 'There was an error rendering the email editor placeholder content.', 'woocommerce' ), 0, $e );
				}
				ob_end_clean();
			}
		} finally {
			$this->clean_up_filters();
		}

		return $message;
	}

	/**
	 * Switch to the site locale. This is to ensure the email is displayed
	 * in the store's language, as the customer would see it, not the admin's language.
	 */
	private function switch_to_site_locale() {
		if ( ! $this->locale_switched ) {
			wc_switch_to_site_locale();
			$this->locale_switched = true;
		}
	}

	/**
	 * Restore the original locale.
	 */
	private function restore_locale() {
		if ( $this->locale_switched ) {
			wc_restore_locale();
			$this->locale_switched = false;
		}
	}
}
PK     [1]+    +  Admin/Orders/PostsRedirectionController.phpnu         <?php
namespace Automattic\WooCommerce\Internal\Admin\Orders;

use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Utilities\OrderUtil;

/**
 * When {@see OrdersTableDataStore} is in use, this class takes care of redirecting admins from CPT-based URLs
 * to the new ones.
 */
class PostsRedirectionController {

	/**
	 * Instance of the PageController class.
	 *
	 * @var PageController
	 */
	private $page_controller;

	/**
	 * Constructor.
	 *
	 * @param PageController $page_controller Page controller instance. Used to generate links/URLs.
	 */
	public function __construct( PageController $page_controller ) {
		$this->page_controller = $page_controller;

		if ( ! wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ) {
			return;
		}

		add_action(
			'admin_menu',
			function () {
				$this->maybe_update_menu_items();
			},
			9999
		);

		add_action(
			'load-edit.php',
			function() {
				$this->maybe_redirect_to_orders_page();
			}
		);

		add_action(
			'load-post-new.php',
			function() {
				$this->maybe_redirect_to_new_order_page();
			}
		);

		add_action(
			'load-post.php',
			function() {
				$this->maybe_redirect_to_edit_order_page();
			}
		);
	}

	/**
	 * If needed, performs a redirection to the main orders page.
	 *
	 * @return void
	 */
	private function maybe_redirect_to_orders_page(): void {
		$post_type = $_GET['post_type'] ?? ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

		if ( ! $post_type || ! in_array( $post_type, wc_get_order_types( 'admin-menu' ), true ) ) {
			return;
		}

		// Respect query args, except for 'post_type'.
		$query_args = wp_unslash( $_GET );
		$action     = $query_args['action'] ?? '';
		$posts      = $query_args['post'] ?? array();
		unset( $query_args['post_type'], $query_args['post'], $query_args['_wpnonce'], $query_args['_wp_http_referer'], $query_args['action'] );

		// Remap 'post_status' arg.
		if ( isset( $query_args['post_status'] ) ) {
			$query_args['status'] = $query_args['post_status'];
			unset( $query_args['post_status'] );
		}

		$new_url = $this->page_controller->get_base_page_url( $post_type );
		$new_url = add_query_arg( $query_args, $new_url );

		// Handle bulk actions.
		if ( $action && in_array( $action, array( 'trash', 'untrash', 'delete', 'mark_processing', 'mark_on-hold', 'mark_completed', 'mark_cancelled' ), true ) ) {
			check_admin_referer( 'bulk-posts' );

			$new_url = add_query_arg(
				array(
					'action'           => $action,
					'id'               => $posts,
					'_wp_http_referer' => $this->page_controller->get_orders_url(),
					'_wpnonce'         => wp_create_nonce( 'bulk-orders' ),
				),
				$new_url
			);
		}

		wp_safe_redirect( $new_url, 301 );
		exit;
	}

	/**
	 * If needed, performs a redirection to the new order page.
	 *
	 * @return void
	 */
	private function maybe_redirect_to_new_order_page(): void {
		$post_type = $_GET['post_type'] ?? ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

		if ( ! $post_type || ! in_array( $post_type, wc_get_order_types( 'admin-menu' ), true ) ) {
			return;
		}

		// Respect query args, except for 'post_type'.
		$query_args = wp_unslash( $_GET );
		unset( $query_args['post_type'] );

		$new_url = $this->page_controller->get_new_page_url( $post_type );
		$new_url = add_query_arg( $query_args, $new_url );

		wp_safe_redirect( $new_url, 301 );
		exit;
	}

	/**
	 * If needed, performs a redirection to the edit order page.
	 *
	 * @return void
	 */
	private function maybe_redirect_to_edit_order_page(): void {
		$post_id = absint( $_GET['post'] ?? 0 );
		if ( ! $post_id ) {
			return;
		}

		$redirect_from_types   = wc_get_order_types( 'admin-menu' );
		$redirect_from_types[] = 'shop_order_placehold';

		$post_type  = get_post_type( $post_id );
		$order_type = $post_type ? $post_type : OrderUtil::get_order_type( $post_id );
		if ( ! in_array( $order_type, $redirect_from_types, true ) || ! isset( $_GET['action'] ) ) {
			return;
		}

		// Respect query args, except for 'post'.
		$query_args = wp_unslash( $_GET );
		$action     = $query_args['action'];
		unset( $query_args['post'], $query_args['_wpnonce'], $query_args['_wp_http_referer'], $query_args['action'] );

		$new_url = '';

		switch ( $action ) {
			case 'edit':
				$new_url = $this->page_controller->get_edit_url( $post_id );
				break;

			case 'trash':
			case 'untrash':
			case 'delete':
				// Re-generate nonce if validation passes.
				check_admin_referer( $action . '-post_' . $post_id );

				$new_url = add_query_arg(
					array(
						'action'           => $action,
						'order'            => array( $post_id ),
						'_wp_http_referer' => $this->page_controller->get_orders_url(),
						'_wpnonce'         => wp_create_nonce( 'bulk-orders' ),
					),
					$this->page_controller->get_orders_url()
				);

				break;

			default:
				break;
		}

		if ( ! $new_url ) {
			return;
		}

		$new_url = add_query_arg( $query_args, $new_url );

		wp_safe_redirect( $new_url, 301 );
		exit;
	}

	/**
	 * Rewrites legacy post type menu items to point to the HPOS orders page when the main WooCommerce menu is not visible.
	 *
	 * @since 10.3.0
	 */
	private function maybe_update_menu_items(): void {
		global $pagenow, $submenu;

		// Do not conflict with CPT > HPOS redirection.
		if ( 'edit.php' === $pagenow && in_array( $_GET['post_type'] ?? '', wc_get_order_types( 'admin-menu' ), true ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			return;
		}

		if ( \WC_Admin_Menus::can_view_woocommerce_menu_item() ) {
			return;
		}

		$post_types = array_filter( array_map( 'get_post_type_object', wc_get_order_types( 'admin-menu' ) ) );
		foreach ( $post_types as $post_type ) {
			if ( ! current_user_can( $post_type->cap->edit_posts ) || ! isset( $submenu[ 'edit.php?post_type=' . $post_type->name ] ) ) {
				continue;
			}

			$post_type_menu = &$submenu[ 'edit.php?post_type=' . $post_type->name ];
			$menu_indexes   = array_flip( array_map( fn( $x ) => $x[2], $post_type_menu ) );

			// Rewrite URL for the legacy menu item.
			$post_type_menu[ $menu_indexes[ 'edit.php?post_type=' . $post_type->name ] ][2] = $this->page_controller->get_base_page_url( $post_type->name );

			// Hide the legacy "Add New" menu item.
			unset( $post_type_menu[ $menu_indexes[ "post-new.php?post_type={$post_type->name}" ] ] );
		}
	}
}
PK     [1]?[A  [A    Admin/Orders/PageController.phpnu         <?php
namespace Automattic\WooCommerce\Internal\Admin\Orders;

use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;

/**
 * Controls the different pages/screens associated to the "Orders" menu page.
 */
class PageController {

	/**
	 * The order type.
	 *
	 * @var string
	 */
	private $order_type = '';

	/**
	 * Instance of the posts redirection controller.
	 *
	 * @var PostsRedirectionController
	 */
	private $redirection_controller;

	/**
	 * Instance of the orders list table.
	 *
	 * @var ListTable
	 */
	private $orders_table;

	/**
	 * Instance of orders edit form.
	 *
	 * @var Edit
	 */
	private $order_edit_form;

	/**
	 * Current action.
	 *
	 * @var string
	 */
	private $current_action = '';

	/**
	 * Order object to be used in edit/new form.
	 *
	 * @var \WC_Order
	 */
	private $order;

	/**
	 * Verify that user has permission to edit orders.
	 *
	 * @return void
	 */
	private function verify_edit_permission() {
		if ( 'edit_order' === $this->current_action && ( ! isset( $this->order ) || ! $this->order ) ) {
			wp_die( esc_html__( 'You attempted to edit an order that does not exist. Perhaps it was deleted?', 'woocommerce' ) );
		}

		if ( $this->order->get_type() !== $this->order_type ) {
			wp_die( esc_html__( 'Order type mismatch.', 'woocommerce' ) );
		}

		if ( ! current_user_can( get_post_type_object( $this->order_type )->cap->edit_post, $this->order->get_id() ) && ! current_user_can( 'manage_woocommerce' ) ) {
			wp_die( esc_html__( 'You do not have permission to edit this order.', 'woocommerce' ) );
		}

		if ( 'trash' === $this->order->get_status() ) {
			wp_die( esc_html__( 'You cannot edit this item because it is in the Trash. Please restore it and try again.', 'woocommerce' ) );
		}
	}

	/**
	 * Verify that user has permission to create order.
	 *
	 * @return void
	 */
	private function verify_create_permission() {
		if ( ! current_user_can( get_post_type_object( $this->order_type )->cap->publish_posts ) && ! current_user_can( 'manage_woocommerce' ) ) {
			wp_die( esc_html__( 'You don\'t have permission to create a new order.', 'woocommerce' ) );
		}

		if ( isset( $this->order ) ) {
			$this->verify_edit_permission();
		}
	}

	/**
	 * Claims the lock for the order being edited/created (unless it belongs to someone else).
	 * Also handles the 'claim-lock' action which allows taking over the order forcefully.
	 *
	 * @return void
	 */
	private function handle_edit_lock() {
		if ( ! $this->order ) {
			return;
		}

		$edit_lock = wc_get_container()->get( EditLock::class );

		$locked = $edit_lock->is_locked_by_another_user( $this->order );

		// Take over order?
		if ( ! empty( $_GET['claim-lock'] ) && wp_verify_nonce( $_GET['_wpnonce'] ?? '', 'claim-lock-' . $this->order->get_id() ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
			$edit_lock->lock( $this->order );
			wp_safe_redirect( $this->get_edit_url( $this->order->get_id() ) );
			exit;
		}

		if ( ! $locked ) {
			$edit_lock->lock( $this->order );
		}

		add_action(
			'admin_footer',
			function() use ( $edit_lock ) {
				$edit_lock->render_dialog( $this->order );
			}
		);
	}

	/**
	 * Sets up the page controller, including registering the menu item.
	 *
	 * @return void
	 */
	public function setup(): void {
		global $plugin_page, $pagenow;

		$this->redirection_controller = new PostsRedirectionController( $this );

		// Register menu.
		if ( 'admin_menu' === current_action() ) {
			$this->register_menu();
		} else {
			add_action( 'admin_menu', 'register_menu', 9 );
		}

		// Not on an Orders page.
		if ( empty( $plugin_page ) || 'admin.php' !== $pagenow || 0 !== strpos( $plugin_page, 'wc-orders' ) ) {
			return;
		}

		$this->set_order_type();
		$this->set_action();

		$page_suffix = ( 'shop_order' === $this->order_type ? '' : '--' . $this->order_type );
		$page_name   = ( \WC_Admin_Menus::can_view_woocommerce_menu_item() ? 'woocommerce_page_wc-orders' : 'admin_page_wc-orders' ) . $page_suffix;

		add_action( "load-{$page_name}", array( $this, 'handle_load_page_action' ) );
		add_action( 'admin_title', array( $this, 'set_page_title' ) );
	}

	/**
	 * Perform initialization for the current action.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function handle_load_page_action() {
		$screen            = get_current_screen();
		$screen->post_type = $this->order_type;

		if ( method_exists( $this, 'setup_action_' . $this->current_action ) ) {
			$this->{"setup_action_{$this->current_action}"}();
		}
	}

	/**
	 * Set the document title for Orders screens to match what it would be with the shop_order CPT.
	 *
	 * @param string $admin_title The admin screen title before it's filtered.
	 *
	 * @return string The filtered admin title.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function set_page_title( $admin_title ) {
		if ( ! $this->is_order_screen( $this->order_type ) ) {
			return $admin_title;
		}

		$wp_order_type = get_post_type_object( $this->order_type );
		$labels        = get_post_type_labels( $wp_order_type );

		if ( $this->is_order_screen( $this->order_type, 'list' ) ) {
			$admin_title = sprintf(
				// translators: 1: The label for an order type 2: The name of the website.
				esc_html__( '%1$s &lsaquo; %2$s &#8212; WordPress', 'woocommerce' ),
				esc_html( $labels->name ),
				esc_html( get_bloginfo( 'name' ) )
			);
		} elseif ( $this->is_order_screen( $this->order_type, 'edit' ) ) {
			$admin_title = sprintf(
				// translators: 1: The label for an order type 2: The title of the order 3: The name of the website.
				esc_html__( '%1$s #%2$s &lsaquo; %3$s &#8212; WordPress', 'woocommerce' ),
				esc_html( $labels->edit_item ),
				absint( $this->order->get_id() ),
				esc_html( get_bloginfo( 'name' ) )
			);
		} elseif ( $this->is_order_screen( $this->order_type, 'new' ) ) {
			$admin_title = sprintf(
				// translators: 1: The label for an order type 2: The name of the website.
				esc_html__( '%1$s &lsaquo; %2$s &#8212; WordPress', 'woocommerce' ),
				esc_html( $labels->add_new_item ),
				esc_html( get_bloginfo( 'name' ) )
			);
		}

		return $admin_title;
	}

	/**
	 * Determines the order type for the current screen.
	 *
	 * @return void
	 */
	private function set_order_type() {
		global $plugin_page;

		$this->order_type = str_replace( array( 'wc-orders--', 'wc-orders' ), '', $plugin_page );
		$this->order_type = empty( $this->order_type ) ? 'shop_order' : $this->order_type;

		$wc_order_type = wc_get_order_type( $this->order_type );
		$wp_order_type = get_post_type_object( $this->order_type );

		if ( ! $wc_order_type || ! $wp_order_type || ! $wp_order_type->show_ui || ! current_user_can( $wp_order_type->cap->edit_posts ) ) {
			wp_die();
		}
	}

	/**
	 * Sets the current action based on querystring arguments. Defaults to 'list_orders'.
	 *
	 * @return void
	 */
	private function set_action(): void {
		switch ( isset( $_GET['action'] ) ? sanitize_text_field( wp_unslash( $_GET['action'] ) ) : '' ) {
			case 'edit':
				$this->current_action = 'edit_order';
				break;
			case 'new':
				$this->current_action = 'new_order';
				break;
			default:
				$this->current_action = 'list_orders';
				break;
		}
	}

	/**
	 * Registers the "Orders" menu.
	 *
	 * @return void
	 */
	public function register_menu(): void {
		$order_types = wc_get_order_types( 'admin-menu' );

		foreach ( $order_types as $order_type ) {
			$post_type = get_post_type_object( $order_type );

			add_submenu_page(
				\WC_Admin_Menus::can_view_woocommerce_menu_item() ? 'woocommerce' : 'admin.php',
				$post_type->labels->name,
				$post_type->labels->menu_name,
				$post_type->cap->edit_posts,
				'wc-orders' . ( 'shop_order' === $order_type ? '' : '--' . $order_type ),
				array( $this, 'output' )
			);
		}

		// In some cases (such as if the authoritative order store was changed earlier in the current request) we
		// need an extra step to remove the menu entry for the menu post type.
		add_action(
			'admin_init',
			function() use ( $order_types ) {
				foreach ( $order_types as $order_type ) {
					remove_submenu_page( 'woocommerce', 'edit.php?post_type=' . $order_type );
				}
			}
		);
	}

	/**
	 * Outputs content for the current orders screen.
	 *
	 * @return void
	 */
	public function output(): void {
		switch ( $this->current_action ) {
			case 'edit_order':
			case 'new_order':
				$this->order_edit_form->display();
				break;
			case 'list_orders':
			default:
				$this->orders_table->prepare_items();
				$this->orders_table->display();
				break;
		}
	}

	/**
	 * Handles initialization of the orders list table.
	 *
	 * @return void
	 */
	private function setup_action_list_orders(): void {
		$this->orders_table = wc_get_container()->get( ListTable::class );
		$this->orders_table->setup(
			array(
				'order_type' => $this->order_type,
			)
		);

		if ( $this->orders_table->current_action() ) {
			$this->orders_table->handle_bulk_actions();
		}

		$this->strip_http_referer();
	}

	/**
	 * Perform a redirect to remove the `_wp_http_referer` and `_wpnonce` strings if present in the URL (see also
	 * wp-admin/edit.php where a similar process takes place), otherwise the size of this field builds to an
	 * unmanageable length over time.
	 */
	private function strip_http_referer(): void {
		$current_url  = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
		$stripped_url = remove_query_arg( array( '_wp_http_referer', '_wpnonce' ), $current_url );

		if ( $stripped_url !== $current_url ) {
			wp_safe_redirect( $stripped_url );
			exit;
		}
	}

	/**
	 * Prepares the order edit form for creating or editing an order.
	 *
	 * @see \Automattic\WooCommerce\Internal\Admin\Orders\Edit.
	 * @since 8.1.0
	 */
	private function prepare_order_edit_form(): void {
		if ( ! $this->order || ! in_array( $this->current_action, array( 'new_order', 'edit_order' ), true ) ) {
			return;
		}

		$this->order_edit_form = $this->order_edit_form ?? new Edit();
		$this->order_edit_form->setup( $this->order );
		$this->order_edit_form->set_current_action( $this->current_action );
	}

	/**
	 * Handles initialization of the orders edit form.
	 *
	 * @return void
	 */
	private function setup_action_edit_order(): void {
		global $theorder;
		$this->order = wc_get_order( absint( isset( $_GET['id'] ) ? $_GET['id'] : 0 ) );
		$this->verify_edit_permission();
		$this->handle_edit_lock();
		$theorder = $this->order;

		$this->prepare_order_edit_form();
	}

	/**
	 * Handles initialization of the orders edit form with a new order.
	 *
	 * @return void
	 */
	private function setup_action_new_order(): void {
		global $theorder;

		$this->verify_create_permission();

		$order_class_name = wc_get_order_type( $this->order_type )['class_name'];
		if ( ! $order_class_name || ! class_exists( $order_class_name ) ) {
			wp_die();
		}

		$this->order = new $order_class_name();
		$this->order->set_object_read( false );
		$this->order->set_status( 'auto-draft' );
		$this->order->set_created_via( 'admin' );
		$this->order->save();
		$this->handle_edit_lock();

		// Schedule auto-draft cleanup. We re-use the WP event here on purpose.
		if ( ! wp_next_scheduled( 'wp_scheduled_auto_draft_delete' ) ) {
			wp_schedule_event( time(), 'daily', 'wp_scheduled_auto_draft_delete' );
		}

		$theorder = $this->order;

		$this->prepare_order_edit_form();
	}

	/**
	 * Returns the current order type.
	 *
	 * @return string
	 */
	public function get_order_type() {
		return $this->order_type;
	}

	/**
	 * Helper method to generate a link to the main orders screen.
	 *
	 * @return string Orders screen URL.
	 */
	public function get_orders_url(): string {
		return wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ?
			admin_url( 'admin.php?page=wc-orders' ) :
			admin_url( 'edit.php?post_type=shop_order' );
	}

	/**
	 * Helper method to generate edit link for an order.
	 *
	 * @param int $order_id Order ID.
	 *
	 * @return string Edit link.
	 */
	public function get_edit_url( int $order_id ) : string {
		if ( ! wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ) {
			return admin_url( 'post.php?post=' . absint( $order_id ) ) . '&action=edit';
		}

		$order = wc_get_order( $order_id );

		// Confirm we could obtain the order object (since it's possible it will not exist, due to a sync issue, or may
		// have been deleted in a separate concurrent request).
		if ( false === $order ) {
			wc_get_logger()->debug(
				sprintf(
					/* translators: %d order ID. */
					__( 'Attempted to determine the edit URL for order %d, however the order does not exist.', 'woocommerce' ),
					$order_id
				)
			);
			$order_type = 'shop_order';
		} else {
			$order_type = $order->get_type();
		}

		try {
			$base_url = $this->get_base_page_url( $order_type );
		} catch ( \Exception $e ) {
			return '';
		}

		return add_query_arg(
			array(
				'action' => 'edit',
				'id'     => absint( $order_id ),
			),
			$base_url
		);
	}

	/**
	 * Helper method to generate a link for creating order.
	 *
	 * @param string $order_type The order type. Defaults to 'shop_order'.
	 * @return string
	 */
	public function get_new_page_url( $order_type = 'shop_order' ) : string {
		$url = wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ?
			add_query_arg( 'action', 'new', $this->get_base_page_url( $order_type ) ) :
			admin_url( 'post-new.php?post_type=' . $order_type );

		return $url;
	}

	/**
	 * Helper method to generate a link to the main screen for a custom order type.
	 *
	 * @param string $order_type The order type.
	 *
	 * @return string
	 *
	 * @throws \Exception When an invalid order type is passed.
	 */
	public function get_base_page_url( $order_type ): string {
		$order_types_with_ui = wc_get_order_types( 'admin-menu' );

		if ( ! in_array( $order_type, $order_types_with_ui, true ) ) {
			// translators: %s is a custom order type.
			throw new \Exception( sprintf( __( 'Invalid order type: %s.', 'woocommerce' ), esc_html( $order_type ) ) );
		}

		return admin_url( 'admin.php?page=wc-orders' . ( 'shop_order' === $order_type ? '' : '--' . $order_type ) );
	}

	/**
	 * Helper method to check if the current admin screen is related to orders.
	 *
	 * @param string $type   Optional. The order type to check for. Default shop_order.
	 * @param string $action Optional. The purpose of the screen to check for. 'list', 'edit', or 'new'.
	 *                       Leave empty to check for any order screen.
	 *
	 * @return bool
	 */
	public function is_order_screen( $type = 'shop_order', $action = '' ) : bool {
		if ( ! did_action( 'current_screen' ) ) {
			wc_doing_it_wrong(
				__METHOD__,
				sprintf(
					// translators: %s is the name of a function.
					esc_html__( '%s must be called after the current_screen action.', 'woocommerce' ),
					esc_html( __METHOD__ )
				),
				'7.9.0'
			);

			return false;
		}

		$valid_types = wc_get_order_types( 'view-order' );
		if ( ! in_array( $type, $valid_types, true ) ) {
			wc_doing_it_wrong(
				__METHOD__,
				sprintf(
					// translators: %s is the name of an order type.
					esc_html__( '%s is not a valid order type.', 'woocommerce' ),
					esc_html( $type )
				),
				'7.9.0'
			);

			return false;
		}

		if ( wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() ) {
			if ( $action ) {
				switch ( $action ) {
					case 'edit':
						$is_action = 'edit_order' === $this->current_action;
						break;
					case 'list':
						$is_action = 'list_orders' === $this->current_action;
						break;
					case 'new':
						$is_action = 'new_order' === $this->current_action;
						break;
					default:
						$is_action = false;
						break;
				}
			}

			$type_match   = $type === $this->order_type;
			$action_match = ! $action || $is_action;
		} else {
			$screen = get_current_screen();

			if ( $action ) {
				switch ( $action ) {
					case 'edit':
						$screen_match = 'post' === $screen->base && filter_input( INPUT_GET, 'post', FILTER_VALIDATE_INT );
						break;
					case 'list':
						$screen_match = 'edit' === $screen->base;
						break;
					case 'new':
						$screen_match = 'post' === $screen->base && 'add' === $screen->action;
						break;
					default:
						$screen_match = false;
						break;
				}
			}

			$type_match   = $type === $screen->post_type;
			$action_match = ! $action || $screen_match;
		}

		return $type_match && $action_match;
	}
}
PK     [1]PF      Admin/Orders/ListTable.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Admin\Orders;

use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Caches\OrderCountCache;
use Automattic\WooCommerce\Utilities\OrderUtil;
use WC_Order;
use WP_List_Table;
use WP_Screen;

/**
 * Admin list table for orders as managed by the OrdersTableDataStore.
 */
class ListTable extends WP_List_Table {

	/**
	 * Order type.
	 *
	 * @var string
	 */
	private $order_type;

	/**
	 * Underlying WordPress post type. Used for checking permissions.
	 *
	 * @var WP_Post_Type|null
	 */
	private $wp_post_type;

	/**
	 * Request vars.
	 *
	 * @var array
	 */
	private $request = array();

	/**
	 * Contains the arguments to be used in the order query.
	 *
	 * @var array
	 */
	private $order_query_args = array();

	/**
	 * Tracks if a filter (ie, date or customer filter) has been applied.
	 *
	 * @var bool
	 */
	private $has_filter = false;

	/**
	 * Page controller instance for this request.
	 *
	 * @var PageController
	 */
	private $page_controller;

	/**
	 * Tracks whether we're currently inside the trash.
	 *
	 * @var boolean
	 */
	private $is_trash = false;

	/**
	 * Caches order counts by status.
	 *
	 * @var array
	 */
	private $status_count_cache = null;

	/**
	 * Sets up the admin list table for orders (specifically, for orders managed by the OrdersTableDataStore).
	 *
	 * @see WC_Admin_List_Table_Orders for the corresponding class used in relation to the traditional WP Post store.
	 */
	public function __construct() {
		parent::__construct(
			array(
				'singular' => 'order',
				'plural'   => 'orders',
				'ajax'     => false,
			)
		);
	}

	/**
	 * Init method, invoked by DI container.
	 *
	 * @internal This method is not intended to be used directly (except for testing).
	 * @param PageController $page_controller Page controller instance for this request.
	 */
	final public function init( PageController $page_controller ) {
		$this->page_controller = $page_controller;
	}

	/**
	 * Performs setup work required before rendering the table.
	 *
	 * @param array $args Args to initialize this list table.
	 *
	 * @return void
	 */
	public function setup( $args = array() ): void {
		$this->order_type   = $args['order_type'] ?? 'shop_order';
		$this->wp_post_type = get_post_type_object( $this->order_type );

		add_action( 'admin_notices', array( $this, 'bulk_action_notices' ) );
		add_filter( "manage_{$this->screen->id}_columns", array( $this, 'get_columns' ), 0 );
		add_filter( 'set_screen_option_edit_' . $this->order_type . '_per_page', array( $this, 'set_items_per_page' ), 10, 3 );
		add_filter( 'default_hidden_columns', array( $this, 'default_hidden_columns' ), 10, 2 );
		add_action( 'admin_footer', array( $this, 'enqueue_scripts' ) );
		add_action( 'woocommerce_order_list_table_restrict_manage_orders', array( $this, 'created_via_filter' ) );
		add_action( 'woocommerce_order_list_table_restrict_manage_orders', array( $this, 'customers_filter' ) );

		$this->items_per_page();
		set_screen_options();

		add_action( 'manage_' . wc_get_page_screen_id( $this->order_type ) . '_custom_column', array( $this, 'render_column' ), 10, 2 );
	}

	/**
	 * Generates content for a single row of the table.
	 *
	 * @since 7.8.0
	 *
	 * @param \WC_Order $order The current order.
	 */
	public function single_row( $order ) {
		/**
		 * Filters the list of CSS class names for a given order row in the orders list table.
		 *
		 * @since 7.8.0
		 *
		 * @param string[]  $classes An array of CSS class names.
		 * @param \WC_Order $order   The order object.
		 */
		$css_classes = apply_filters(
			'woocommerce_' . $this->order_type . '_list_table_order_css_classes',
			array(
				'order-' . $order->get_id(),
				'type-' . $order->get_type(),
				'status-' . $order->get_status(),
			),
			$order
		);
		$css_classes = array_unique( array_map( 'trim', $css_classes ) );

		// Is locked?
		$edit_lock = wc_get_container()->get( EditLock::class );
		if ( $edit_lock->is_locked_by_another_user( $order ) ) {
			$css_classes[] = 'wp-locked';
		}

		echo '<tr id="order-' . esc_attr( $order->get_id() ) . '" class="' . esc_attr( implode( ' ', $css_classes ) ) . '">';
		$this->single_row_columns( $order );
		echo '</tr>';
	}

	/**
	 * Render individual column.
	 *
	 * @param string   $column_id Column ID to render.
	 * @param WC_Order $order Order object.
	 */
	public function render_column( $column_id, $order ) {
		if ( ! $order ) {
			return;
		}

		if ( is_callable( array( $this, 'render_' . $column_id . '_column' ) ) ) {
			call_user_func( array( $this, 'render_' . $column_id . '_column' ), $order );
		}
	}

	/**
	 * Handles output for the default column.
	 *
	 * @param \WC_Order $order       Current WooCommerce order object.
	 * @param string    $column_name Identifier for the custom column.
	 */
	public function column_default( $order, $column_name ) {
		/**
		 * Fires for each custom column for a specific order type. This hook takes precedence over the generic
		 * action `manage_{$this->screen->id}_custom_column`.
		 *
		 * @param string    $column_name Identifier for the custom column.
		 * @param \WC_Order $order       Current WooCommerce order object.
		 *
		 * @since 7.3.0
		 */
		do_action( 'woocommerce_' . $this->order_type . '_list_table_custom_column', $column_name, $order );

		/**
		 * Fires for each custom column in the Custom Order Table in the administrative screen.
		 *
		 * @param string    $column_name Identifier for the custom column.
		 * @param \WC_Order $order       Current WooCommerce order object.
		 *
		 * @since 7.0.0
		 */
		do_action( "manage_{$this->screen->id}_custom_column", $column_name, $order );
	}

	/**
	 * Sets up an items-per-page control.
	 */
	private function items_per_page(): void {
		add_screen_option(
			'per_page',
			array(
				'default' => 20,
				'option'  => 'edit_' . $this->order_type . '_per_page',
			)
		);
	}

	/**
	 * Saves the items-per-page setting.
	 *
	 * @param mixed  $default The default value.
	 * @param string $option  The option being configured.
	 * @param int    $value   The submitted option value.
	 *
	 * @return mixed
	 */
	public function set_items_per_page( $default, string $option, int $value ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.defaultFound -- backwards compat.
		return 'edit_' . $this->order_type . '_per_page' === $option ? absint( $value ) : $default;
	}

	/**
	 * Render the table.
	 *
	 * @return void
	 */
	public function display() {
		$post_type = get_post_type_object( $this->order_type );

		$title         = esc_html( $post_type->labels->name );
		$add_new       = esc_html( $post_type->labels->add_new );
		$new_page_link = $this->page_controller->get_new_page_url( $this->order_type );
		$search_label  = '';

		if ( ! empty( $this->order_query_args['s'] ) ) {
			$search_label  = '<span class="subtitle">';
			$search_label .= sprintf(
				/* translators: %s: Search query. */
				__( 'Search results for: %s', 'woocommerce' ),
				'<strong>' . esc_html( $this->order_query_args['s'] ) . '</strong>'
			);
			$search_label .= '</span>';
		}

		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		echo wp_kses_post(
			"
			<div class='wrap'>
				<h1 class='wp-heading-inline'>{$title}</h1>
				<a href='" . esc_url( $new_page_link ) . "' class='page-title-action'>{$add_new}</a>
				{$search_label}
				<hr class='wp-header-end'>"
		);

		if ( $this->should_render_blank_state() ) {
			$this->render_blank_state();
			return;
		}

		$this->views();

		echo '<form id="wc-orders-filter" method="get" action="' . esc_url( get_admin_url( null, 'admin.php' ) ) . '">';
		$this->print_hidden_form_fields();
		$this->search_box( esc_html__( 'Search orders', 'woocommerce' ), 'orders-search-input' );

		parent::display();
		echo '</form> </div>';
	}

	/**
	 * Renders advice in the event that no orders exist yet.
	 *
	 * @return void
	 */
	public function render_blank_state(): void {
		?>
			<div class="woocommerce-BlankState">

				<h2 class="woocommerce-BlankState-message">
					<?php esc_html_e( 'When you receive a new order, it will appear here.', 'woocommerce' ); ?>
				</h2>

				<div class="woocommerce-BlankState-buttons">
					<a class="woocommerce-BlankState-cta button-primary button" target="_blank" href="https://woocommerce.com/document/managing-orders/?utm_source=blankslate&utm_medium=product&utm_content=ordersdoc&utm_campaign=woocommerceplugin"><?php esc_html_e( 'Learn more about orders', 'woocommerce' ); ?></a>
				</div>

			<?php
			/**
			 * Renders after the 'blank state' message for the order list table has rendered.
			 *
			 * @since 6.6.1
			 */
			do_action( 'wc_marketplace_suggestions_orders_empty_state' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment
			?>

			</div>
		<?php
	}

	/**
	 * Retrieves the list of bulk actions available for this table.
	 *
	 * @return array
	 */
	protected function get_bulk_actions() {
		$selected_status = $this->order_query_args['status'] ?? false;

		if ( ! current_user_can( $this->wp_post_type->cap->edit_others_posts ) ) {
			return array();
		}

		if ( array( 'trash' ) === $selected_status ) {
			$actions = array(
				'untrash' => __( 'Restore', 'woocommerce' ),
				'delete'  => __( 'Delete permanently', 'woocommerce' ),
			);
		} else {
			$actions = array(
				'mark_processing' => __( 'Change status to processing', 'woocommerce' ),
				'mark_on-hold'    => __( 'Change status to on-hold', 'woocommerce' ),
				'mark_completed'  => __( 'Change status to completed', 'woocommerce' ),
				'mark_cancelled'  => __( 'Change status to cancelled', 'woocommerce' ),
				'trash'           => __( 'Move to Trash', 'woocommerce' ),
			);
		}

		if ( wc_string_to_bool( get_option( 'woocommerce_allow_bulk_remove_personal_data', 'no' ) ) ) {
			$actions['remove_personal_data'] = __( 'Remove personal data', 'woocommerce' );
		}

		return $actions;
	}

	/**
	 * Gets a list of CSS classes for the WP_List_Table table tag.
	 *
	 * @since 7.8.0
	 *
	 * @return string[] Array of CSS classes for the table tag.
	 */
	protected function get_table_classes() {
		/**
		 * Filters the list of CSS class names for the orders list table.
		 *
		 * @since 7.8.0
		 *
		 * @param string[] $classes    An array of CSS class names.
		 * @param string   $order_type The order type.
		 */
		$css_classes = apply_filters(
			'woocommerce_' . $this->order_type . '_list_table_css_classes',
			array_merge(
				parent::get_table_classes(),
				array(
					'wc-orders-list-table',
					'wc-orders-list-table-' . $this->order_type,
				)
			),
			$this->order_type
		);

		return array_unique( array_map( 'trim', $css_classes ) );
	}

	/**
	 * Prepares the list of items for displaying.
	 */
	public function prepare_items() {
		$limit = $this->get_items_per_page( 'edit_' . $this->order_type . '_per_page' );

		$this->order_query_args = array(
			'limit'    => $limit,
			'page'     => $this->get_pagenum(),
			'paginate' => true,
			'type'     => $this->order_type,
		);

		foreach ( array( 'status', 's', 'm', '_customer_user', 'search-filter' ) as $query_var ) {
			$this->request[ $query_var ] = sanitize_text_field( wp_unslash( $_REQUEST[ $query_var ] ?? '' ) );
		}

		/**
		 * Allows 3rd parties to filter the initial request vars before defaults and other logic is applied.
		 *
		 * @param array $request Request to be passed to `wc_get_orders()`.
		 *
		 * @since 7.3.0
		 */
		$this->request = apply_filters( 'woocommerce_' . $this->order_type . '_list_table_request', $this->request );

		$this->set_status_args();
		$this->set_order_args();
		$this->set_date_args();
		$this->set_customer_args();
		$this->set_search_args();
		$this->set_created_via_args();

		/**
		 * Provides an opportunity to modify the query arguments used in the (Custom Order Table-powered) order list
		 * table.
		 *
		 * @since 6.9.0
		 *
		 * @param array $query_args Arguments to be passed to `wc_get_orders()`.
		 */
		$order_query_args = (array) apply_filters( 'woocommerce_order_list_table_prepare_items_query_args', $this->order_query_args );

		/**
		 * Same as `woocommerce_order_list_table_prepare_items_query_args` but for a specific order type.
		 *
		 * @param array $query_args Arguments to be passed to `wc_get_orders()`.
		 *
		 * @since 7.3.0
		 */
		$order_query_args = apply_filters( 'woocommerce_' . $this->order_type . '_list_table_prepare_items_query_args', $order_query_args );

		// We must ensure the 'paginate' argument is set.
		$order_query_args['paginate'] = true;

		// Attempt to use cache if no additional query arguments are used.
		if ( empty( array_diff( array_keys( $this->order_query_args ), array( 'limit', 'page', 'paginate', 'type', 'status', 'orderby', 'order' ) ) ) ) {
			$this->order_query_args['no_found_rows'] = true;
			$order_query_args['no_found_rows']       = true;
		}

		$orders      = wc_get_orders( $order_query_args );
		$this->items = $orders->orders;

		$max_num_pages = $this->get_max_num_pages( $orders );

		// Check in case the user has attempted to page beyond the available range of orders.
		if ( 0 === $max_num_pages && $this->order_query_args['page'] > 1 ) {
			$count_query_args          = $order_query_args;
			$count_query_args['page']  = 1;
			$count_query_args['limit'] = 1;
			$order_count               = wc_get_orders( $count_query_args );
			$max_num_pages             = (int) ceil( $order_count->total / $order_query_args['limit'] );
		}

		$this->set_pagination_args(
			array(
				'total_items' => $orders->total ?? 0,
				'per_page'    => $limit,
				'total_pages' => $max_num_pages,
			)
		);

		// Are we inside the trash?
		$this->is_trash = 'trash' === $this->request['status'];
	}

	/**
	 * Get the max number of pages from orders or from cache.
	 *
	 * @param WC_Order[]|stdClass Number of pages and an array of order objects.
	 * @return int
	 */
	private function get_max_num_pages( &$orders ) {
		if ( ! isset( $this->order_query_args['no_found_rows'] ) || ! $this->order_query_args['no_found_rows'] ) {
			return $orders->max_num_pages;
		}

		$count         = $this->count_orders_by_status( $this->order_query_args['status'] );
		$limit         = $this->get_items_per_page( 'edit_' . $this->order_type . '_per_page' );
		$orders->total = $count;

		return ceil( $count / $limit );
	}

	/**
	 * Updates the WC Order Query arguments as needed to support orderable columns.
	 */
	private function set_order_args() {
		$sortable  = $this->get_sortable_columns();
		$field     = sanitize_text_field( wp_unslash( $_GET['orderby'] ?? '' ) );
		$direction = strtoupper( sanitize_text_field( wp_unslash( $_GET['order'] ?? '' ) ) );

		if ( ! in_array( $field, $sortable, true ) ) {
			$this->order_query_args['orderby'] = 'date';
			$this->order_query_args['order']   = 'DESC';
			return;
		}

		$this->order_query_args['orderby'] = $field;
		$this->order_query_args['order']   = in_array( $direction, array( 'ASC', 'DESC' ), true ) ? $direction : 'ASC';
	}

	/**
	 * Implements date (month-based) filtering.
	 */
	private function set_date_args() {
		$year_month = sanitize_text_field( wp_unslash( $_GET['m'] ?? '' ) );

		if ( empty( $year_month ) || ! preg_match( '/^[0-9]{6}$/', $year_month ) ) {
			return;
		}

		$year  = (int) substr( $year_month, 0, 4 );
		$month = (int) substr( $year_month, 4, 2 );

		if ( $month < 0 || $month > 12 ) {
			return;
		}

		$last_day_of_month                      = date_create( "$year-$month" )->format( 'Y-m-t' );
		$this->order_query_args['date_created'] = "$year-$month-01..." . $last_day_of_month;
		$this->has_filter                       = true;
	}

	/**
	 * Implements filtering of orders by customer.
	 */
	private function set_customer_args() {
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$customer = (int) wp_unslash( $_GET['_customer_user'] ?? '' );

		if ( $customer < 1 ) {
			return;
		}

		$this->order_query_args['customer'] = $customer;
		$this->has_filter                   = true;
	}

	/**
	 * Implements filtering of orders by status.
	 */
	private function set_status_args() {
		$status = array_filter( array_map( 'trim', (array) $this->request['status'] ) );

		if ( empty( $status ) || in_array( 'all', $status, true ) ) {
			/**
			 * Allows 3rd parties to set the default list of statuses for a given order type.
			 *
			 * @param string[] $statuses Statuses.
			 *
			 * @since 7.3.0
			 */
			$status = apply_filters(
				'woocommerce_' . $this->order_type . '_list_table_default_statuses',
				array_intersect(
					array_keys( wc_get_order_statuses() ),
					get_post_stati( array( 'show_in_admin_all_list' => true ), 'names' )
				)
			);
		} else {
			$this->has_filter = true;
		}

		$this->order_query_args['status'] = $status;
	}

	/**
	 * Implements order search.
	 */
	private function set_search_args(): void {
		$search_term = trim( sanitize_text_field( $this->request['s'] ) );

		if ( ! empty( $search_term ) ) {
			$this->order_query_args['s'] = $search_term;
			$this->has_filter            = true;
		}

		$filter = trim( sanitize_text_field( $this->request['search-filter'] ) );
		if ( ! empty( $filter ) ) {
			$this->order_query_args['search_filter'] = $filter;
		}
	}

	/**
	 * Implements filtering of orders by created_via value.
	 */
	private function set_created_via_args(): void {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		$created_via = sanitize_text_field( wp_unslash( $_GET['_created_via'] ?? '' ) );

		if ( empty( $created_via ) ) {
			return;
		}

		$this->order_query_args['created_via'] = array_map( 'trim', explode( ',', $created_via ) );

		$this->has_filter = true;
	}

	/**
	 * Render the created_via filter dropdown.
	 *
	 * @return void
	 */
	public function created_via_filter() {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		$current_created_via = isset( $_GET['_created_via'] ) ? sanitize_text_field( wp_unslash( $_GET['_created_via'] ) ) : '';

		$created_via_options = array(
			''                   => __( 'All sales channels', 'woocommerce' ),
			'admin'              => __( 'Admin', 'woocommerce' ),
			'checkout,store-api' => __( 'Checkout', 'woocommerce' ),
			'pos-rest-api'       => __( 'Point of Sale', 'woocommerce' ),
		);
		?>

		<select name="_created_via" id="filter-by-created-via">
			<?php foreach ( $created_via_options as $value => $label ) : ?>
				<option value="<?php echo esc_attr( $value ); ?>" <?php selected( $value, $current_created_via ); ?>>
					<?php echo esc_html( $label ); ?>
				</option>
			<?php endforeach; ?>
		</select>
		<?php
	}

	/**
	 * Get the list of views for this table (all orders, completed orders, etc, each with a count of the number of
	 * corresponding orders).
	 *
	 * @return array
	 */
	public function get_views() {
		$view_links = array();

		/**
		 * Filters the list of available list table view links before the actual query runs.
		 * This can be used to, e.g., remove counts from the links.
		 *
		 * @since 8.6.0
		 *
		 * @param string[] $views An array of available list table view links.
		 */
		$view_links = apply_filters( 'woocommerce_before_' . $this->order_type . '_list_table_view_links', $view_links );
		if ( ! empty( $view_links ) ) {
			return $view_links;
		}

		$view_counts = array();
		$statuses    = $this->get_visible_statuses();
		$current     = ! empty( $this->request['status'] ) ? sanitize_text_field( $this->request['status'] ) : 'all';
		$all_count   = 0;

		foreach ( array_keys( $statuses ) as $slug ) {
			$total_in_status = $this->count_orders_by_status( $slug );

			if ( $total_in_status > 0 ) {
				$view_counts[ $slug ] = $total_in_status;
			}

			if ( ( get_post_status_object( $slug ) )->show_in_admin_all_list && 'auto-draft' !== $slug ) {
				$all_count += $total_in_status;
			}
		}

		$view_links['all'] = $this->get_view_link( 'all', __( 'All', 'woocommerce' ), $all_count, '' === $current || 'all' === $current );

		foreach ( $view_counts as $slug => $count ) {
			$view_links[ $slug ] = $this->get_view_link( $slug, $statuses[ $slug ], $count, $slug === $current );
		}

		return $view_links;
	}

	/**
	 * Count orders by status.
	 *
	 * @param string|string[] $status The order status we are interested in.
	 *
	 * @return int
	 */
	private function count_orders_by_status( $status ): int {
		$status = (array) $status;
		$counts = OrderUtil::get_count_for_type( $this->order_type );
		$count  = array_sum( array_intersect_key( $counts, array_flip( $status ) ) );

		/**
		 * Allows 3rd parties to modify the count of orders by status.
		 *
		 * @param int      $count  Number of orders for the given status.
		 * @param string[] $status List of order statuses in the count.
		 * @since 7.3.0
		 */
		return apply_filters(
			'woocommerce_' . $this->order_type . '_list_table_order_count',
			$count,
			$status
		);
	}

	/**
	 * Checks whether the blank state should be rendered or not. This depends on whether there are others with a visible
	 * status.
	 *
	 * @return boolean TRUE when the blank state should be rendered, FALSE otherwise.
	 */
	private function should_render_blank_state(): bool {
		/**
		 * Whether we should render a blank state so that custom count queries can be used.
		 *
		 * @since 8.6.0
		 *
		 * @param null           $should_render_blank_state `null` will use the built-in counts. Sending a boolean will short-circuit that path.
		 * @param object         ListTable The current instance of the class.
		*/
		$should_render_blank_state = apply_filters(
			'woocommerce_' . $this->order_type . '_list_table_should_render_blank_state',
			null,
			$this
		);

		if ( is_bool( $should_render_blank_state ) ) {
			return $should_render_blank_state;
		}

		return ( ! $this->has_filter ) && 0 === $this->count_orders_by_status( array_keys( $this->get_visible_statuses() ) );
	}

	/**
	 * Returns a list of slug and labels for order statuses that should be visible in the status list.
	 *
	 * @return array slug => label array of order statuses.
	 */
	private function get_visible_statuses(): array {
		return array_intersect_key(
			array_merge(
				wc_get_order_statuses(),
				array(
					'trash'      => ( get_post_status_object( 'trash' ) )->label,
					'draft'      => ( get_post_status_object( 'draft' ) )->label,
					'auto-draft' => ( get_post_status_object( 'auto-draft' ) )->label,
				)
			),
			array_flip( get_post_stati( array( 'show_in_admin_status_list' => true ) ) )
		);
	}

	/**
	 * Form a link to use in the list of table views.
	 *
	 * @param string $slug    Slug used to identify the view (usually the order status slug).
	 * @param string $name    Human-readable name of the view (usually the order status label).
	 * @param int    $count   Number of items in this view.
	 * @param bool   $current If this is the current view.
	 *
	 * @return string
	 */
	private function get_view_link( string $slug, string $name, int $count, bool $current ): string {
		$base_url = get_admin_url( null, 'admin.php?page=wc-orders' . ( 'shop_order' === $this->order_type ? '' : '--' . $this->order_type ) );
		$url      = esc_url( add_query_arg( 'status', $slug, $base_url ) );
		$name     = esc_html( $name );
		$count    = number_format_i18n( $count );
		$class    = $current ? 'class="current"' : '';

		return "<a href='$url' $class>$name <span class='count'>($count)</span></a>";
	}

	/**
	 * Extra controls to be displayed between bulk actions and pagination.
	 *
	 * @param string $which Either 'top' or 'bottom'.
	 */
	protected function extra_tablenav( $which ) {
		echo '<div class="alignleft actions">';

		if ( 'top' === $which ) {
			ob_start();

			$this->months_filter();

			/**
			 * Fires before the "Filter" button on the list table for orders and other order types.
			 *
			 * @since 7.3.0
			 *
			 * @param string $order_type  The order type.
			 * @param string $which       The location of the extra table nav: 'top' or 'bottom'.
			 */
			do_action( 'woocommerce_order_list_table_restrict_manage_orders', $this->order_type, $which );

			$output = ob_get_clean();

			if ( ! empty( $output ) ) {
				echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
				submit_button( __( 'Filter', 'woocommerce' ), '', 'filter_action', false, array( 'id' => 'order-query-submit' ) );
			}
		}

		if ( $this->is_trash && $this->has_items() && current_user_can( 'edit_others_shop_orders' ) ) {
			submit_button( __( 'Empty Trash', 'woocommerce' ), 'apply', 'delete_all', false );
		}

		/**
		 * Fires immediately following the closing "actions" div in the tablenav for the order
		 * list table.
		 *
		 * @since 7.3.0
		 *
		 * @param string $order_type  The order type.
		 * @param string $which       The location of the extra table nav: 'top' or 'bottom'.
		 */
		do_action( 'woocommerce_order_list_table_extra_tablenav', $this->order_type, $which );

		echo '</div>';
	}

	/**
	 * Render the months filter dropdown.
	 *
	 * @return void
	 */
	private function months_filter() {
		global $wp_locale;

		/**
		 * Filters whether to remove the 'Months' drop-down from the order list table.
		 *
		 * @since 8.6.0
		 *
		 * @param bool   $disable   Whether to disable the drop-down. Default false.
		 */
		if ( apply_filters( 'woocommerce_' . $this->order_type . '_list_table_disable_months_filter', false ) ) {
			return;
		}

		$m = isset( $_GET['m'] ) ? (int) $_GET['m'] : 0;
		echo '<select name="m" id="filter-by-date">';
		echo '<option ' . selected( $m, 0, false ) . ' value="0">' . esc_html__( 'All dates', 'woocommerce' ) . '</option>';

		$order_dates = $this->get_months_filter_options();

		foreach ( $order_dates as $date ) {
			$month           = zeroise( $date->month, 2 );
			$month_year_text = sprintf(
				/* translators: 1: Month name, 2: 4-digit year. */
				esc_html_x( '%1$s %2$d', 'order dates dropdown', 'woocommerce' ),
				$wp_locale->get_month( $month ),
				$date->year
			);

			printf(
				'<option %1$s value="%2$s">%3$s</option>\n',
				selected( $m, $date->year . $month, false ),
				esc_attr( $date->year . $month ),
				esc_html( $month_year_text )
			);
		}

		echo '</select>';
	}

	/**
	 * Get a list of year-month options for filtering the orders list table.
	 *
	 * This finds the oldest order and generates a year-month option for every month in the range between then and the
	 * current month.
	 *
	 * @return \stdClass[]
	 */
	protected function get_months_filter_options(): array {
		global $wpdb;

		$orders_table   = esc_sql( OrdersTableDataStore::get_orders_table_name() );
		$min_max_months = $wpdb->get_row(
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is escaped above.
			$wpdb->prepare(
				"
					SELECT MIN( t.date_created_gmt ) as min_date_gmt,
					       MAX( t.date_created_gmt ) as max_date_gmt
					FROM `{$orders_table}` t
					WHERE type = %s
					AND status != %s
				",
				$this->order_type,
				OrderStatus::TRASH
			)
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		);

		/**
		 * Normalize "this month" to be the first day of the month in the current timezone of the site.
		 */
		$this_month = new \WC_DateTime(
			'now',
			new \DateTimeZone( 'UTC' )
		);
		$this_month->setTimezone( wp_timezone() );
		$this_month->setDate( $this_month->format( 'Y' ), $this_month->format( 'm' ), 1 );
		$this_month->setTime( 0, 0 );

		$options = array();

		if ( isset( $min_max_months ) && ! is_null( $min_max_months->min_date_gmt ) ) {
			$start = new \WC_DateTime(
				$min_max_months->min_date_gmt,
				new \DateTimeZone( 'UTC' )
			);
			$start->setTimezone( wp_timezone() );
			$start->setDate( $start->format( 'Y' ), $start->format( 'm' ), 1 );
			$start->setTime( 0, 0 );

			$end = new \WC_DateTime(
				$min_max_months->max_date_gmt,
				new \DateTimeZone( 'UTC' )
			);
			$end->setTimezone( wp_timezone() );
			$end->setDate( $end->format( 'Y' ), $end->format( 'm' ), 1 );
			$end->setTime( 0, 0 );

			if ( $start > $this_month ) {
				$start = $this_month;
			}

			if ( $end < $this_month ) {
				$end = $this_month;
			}

			$intervals = new \DatePeriod( $start, new \DateInterval( 'P1M' ), $end );

			foreach ( $intervals as $interval ) {
				$option        = new \stdClass();
				$option->year  = $interval->format( 'Y' );
				$option->month = $interval->format( 'n' );
				$options[]     = $option;
			}

			$option        = new \stdClass();
			$option->year  = $end->format( 'Y' );
			$option->month = $end->format( 'n' );
			$options[]     = $option;
		}

		if ( count( $options ) < 1 ) {
			$option        = new \stdClass();
			$option->year  = $this_month->format( 'Y' );
			$option->month = $this_month->format( 'n' );
			$options[]     = $option;
		}

		return array_reverse( $options );
	}

	/**
	 * Get order year-months cache. We cache the results in the options table, since these results will change very infrequently.
	 * We use the heuristic to always return current year-month when getting from cache to prevent an additional query.
	 *
	 * @deprecated 9.9.0
	 *
	 * @return array List of year-months.
	 */
	protected function get_and_maybe_update_months_filter_cache(): array {
		wc_deprecated_function(
			__METHOD__,
			'9.9.0',
			'get_months_filter_options'
		);

		return $this->get_months_filter_options();
	}

	/**
	 * Render the customer filter dropdown.
	 *
	 * @return void
	 */
	public function customers_filter() {
		$user_string = '';
		$user_id     = '';

		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		if ( ! empty( $_GET['_customer_user'] ) ) {
			$user_id = absint( $_GET['_customer_user'] );
			$user    = get_user_by( 'id', $user_id );

			$user_string = sprintf(
				/* translators: 1: user display name 2: user ID 3: user email */
				esc_html__( '%1$s (#%2$s &ndash; %3$s)', 'woocommerce' ),
				$user->display_name,
				absint( $user->ID ),
				$user->user_email
			);
		}

		// Note: use of htmlspecialchars (below) is to prevent XSS when rendered by selectWoo.
		?>
		<select class="wc-customer-search" name="_customer_user" data-placeholder="<?php esc_attr_e( 'Filter by registered customer', 'woocommerce' ); ?>" data-allow_clear="true">
			<option value="<?php echo esc_attr( $user_id ); ?>" selected="selected"><?php echo htmlspecialchars( wp_kses_post( $user_string ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></option>
		</select>
		<?php
	}

	/**
	 * Get list columns.
	 *
	 * @return array
	 */
	public function get_columns() {
		/**
		 * Filters the list of columns.
		 *
		 * @param array $columns List of sortable columns.
		 *
		 * @since 7.3.0
		 */
		return apply_filters(
			'woocommerce_' . $this->order_type . '_list_table_columns',
			array(
				'cb'               => '<input type="checkbox" />',
				'order_number'     => esc_html__( 'Order', 'woocommerce' ),
				'order_date'       => esc_html__( 'Date', 'woocommerce' ),
				'order_status'     => esc_html__( 'Status', 'woocommerce' ),
				'billing_address'  => esc_html__( 'Billing', 'woocommerce' ),
				'shipping_address' => esc_html__( 'Ship to', 'woocommerce' ),
				'order_total'      => esc_html__( 'Total', 'woocommerce' ),
				'wc_actions'       => esc_html__( 'Actions', 'woocommerce' ),
			)
		);
	}

	/**
	 * Defines the default sortable columns.
	 *
	 * @return string[]
	 */
	public function get_sortable_columns() {
		/**
		 * Filters the list of sortable columns.
		 *
		 * @param array $sortable_columns List of sortable columns.
		 *
		 * @since 7.3.0
		 */
		return apply_filters(
			'woocommerce_' . $this->order_type . '_list_table_sortable_columns',
			array(
				'order_number' => 'ID',
				'order_date'   => 'date',
				'order_total'  => 'order_total',
			)
		);
	}

	/**
	 * Specify the columns we wish to hide by default.
	 *
	 * @param array     $hidden Columns set to be hidden.
	 * @param WP_Screen $screen Screen object.
	 *
	 * @return array
	 */
	public function default_hidden_columns( array $hidden, WP_Screen $screen ) {
		if ( isset( $screen->id ) && wc_get_page_screen_id( 'shop-order' ) === $screen->id ) {
			$hidden = array_merge(
				$hidden,
				array(
					'billing_address',
					'shipping_address',
					'wc_actions',
				)
			);
		}

		return $hidden;
	}

	/**
	 * Checklist column, used for selecting items for processing by a bulk action.
	 *
	 * @param WC_Order $item The order object for the current row.
	 *
	 * @return string
	 */
	public function column_cb( $item ) {
		if ( ! $this->wp_post_type || ! current_user_can( $this->wp_post_type->cap->edit_post, $item->get_id() ) ) {
			return;
		}

		ob_start();
		?>
		<input id="cb-select-<?php echo esc_attr( $item->get_id() ); ?>" type="checkbox" name="id[]" value="<?php echo esc_attr( $item->get_id() ); ?>" />

		<div class="locked-indicator">
			<span class="locked-indicator-icon" aria-hidden="true"></span>
			<span class="screen-reader-text">
				<?php
				// translators: %s is an order ID.
				echo esc_html( sprintf( __( 'Order %s is locked.', 'woocommerce' ), $item->get_id() ) );
				?>
			</span>
		</div>
		<?php
		return ob_get_clean();
	}

	/**
	 * Renders the order number, customer name and provides a preview link.
	 *
	 * @param WC_Order $order The order object for the current row.
	 *
	 * @return void
	 */
	public function render_order_number_column( WC_Order $order ): void {
		$buyer = '';

		if ( $order->get_billing_first_name() || $order->get_billing_last_name() ) {
			/* translators: 1: first name 2: last name */
			$buyer = trim( sprintf( _x( '%1$s %2$s', 'full name', 'woocommerce' ), $order->get_billing_first_name(), $order->get_billing_last_name() ) );
		} elseif ( $order->get_billing_company() ) {
			$buyer = trim( $order->get_billing_company() );
		} elseif ( $order->get_customer_id() ) {
			$user  = get_user_by( 'id', $order->get_customer_id() );
			$buyer = ucwords( $user->display_name );
		}

		/**
		 * Filter buyer name in list table orders.
		 *
		 * @since 3.7.0
		 *
		 * @param string   $buyer Buyer name.
		 * @param WC_Order $order Order data.
		 */
		$buyer = apply_filters( 'woocommerce_admin_order_buyer_name', $buyer, $order );

		if ( $order->get_status() === 'trash' ) {
			echo '<strong>#' . esc_attr( $order->get_order_number() ) . ' ' . esc_html( $buyer ) . '</strong>';
		} else {
			echo '<a href="#" class="order-preview" data-order-id="' . absint( $order->get_id() ) . '" title="' . esc_attr( __( 'Preview', 'woocommerce' ) ) . '">' . esc_html( __( 'Preview', 'woocommerce' ) ) . '</a>';
			echo '<a href="' . esc_url( $this->get_order_edit_link( $order ) ) . '" class="order-view"><strong>#' . esc_attr( $order->get_order_number() ) . ' ' . esc_html( $buyer ) . '</strong></a>';
		}

		// Used for showing date & status next to order number/buyer name on small screens.
		echo '<div class="order_date small-screen-only">';
		$this->render_order_date_column( $order );
		echo '</div>';
		echo '<div class="order_status small-screen-only">';
		$this->render_order_status_column( $order );
		echo '</div>';
	}

	/**
	 * Get the edit link for an order.
	 *
	 * @param WC_Order $order Order object.
	 *
	 * @return string Edit link for the order.
	 */
	private function get_order_edit_link( WC_Order $order ): string {
		return $this->page_controller->get_edit_url( $order->get_id() );
	}

	/**
	 * Renders the order date.
	 *
	 * @param WC_Order $order The order object for the current row.
	 *
	 * @return void
	 */
	public function render_order_date_column( WC_Order $order ): void {
		$order_timestamp = $order->get_date_created() ? $order->get_date_created()->getTimestamp() : '';

		if ( ! $order_timestamp ) {
			echo '&ndash;';
			return;
		}

		// Check if the order was created within the last 24 hours, and not in the future.
		if ( $order_timestamp > strtotime( '-1 day', time() ) && $order_timestamp <= time() ) {
			$show_date = sprintf(
			/* translators: %s: human-readable time difference */
				_x( '%s ago', '%s = human-readable time difference', 'woocommerce' ),
				human_time_diff( $order->get_date_created()->getTimestamp(), time() )
			);
		} else {
			$show_date = $order->get_date_created()->date_i18n( apply_filters( 'woocommerce_admin_order_date_format', __( 'M j, Y', 'woocommerce' ) ) ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
		}
		printf(
			'<time datetime="%1$s" title="%2$s">%3$s</time>',
			esc_attr( $order->get_date_created()->date( 'c' ) ),
			esc_html( $order->get_date_created()->date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ) ),
			esc_html( $show_date )
		);
	}

	/**
	 * Renders the order status.
	 *
	 * @param WC_Order $order The order object for the current row.
	 *
	 * @return void
	 */
	public function render_order_status_column( WC_Order $order ): void {
		/* translators: %s: order status label */
		$tooltip = wc_sanitize_tooltip( $this->get_order_status_label( $order ) );

		// Gracefully handle legacy statuses.
		if ( in_array( $order->get_status(), array( 'trash', 'draft', 'auto-draft' ), true ) ) {
			$status_name = ( get_post_status_object( $order->get_status() ) )->label;
		} else {
			$status_name = wc_get_order_status_name( $order->get_status() );
		}

		if ( $tooltip ) {
			printf( '<mark class="order-status %s tips" data-tip="%s"><span>%s</span></mark>', esc_attr( sanitize_html_class( 'status-' . $order->get_status() ) ), wp_kses_post( $tooltip ), esc_html( $status_name ) );
		} else {
			printf( '<mark class="order-status %s"><span>%s</span></mark>', esc_attr( sanitize_html_class( 'status-' . $order->get_status() ) ), esc_html( $status_name ) );
		}
	}

	/**
	 * Gets the order status label for an order.
	 *
	 * @param WC_Order $order The order object.
	 *
	 * @return string
	 */
	private function get_order_status_label( WC_Order $order ): string {
		$status_names = array(
			'pending'        => __( 'The order has been received, but no payment has been made. Pending payment orders are generally awaiting customer action.', 'woocommerce' ),
			'on-hold'        => __( 'The order is awaiting payment confirmation. Stock is reduced, but you need to confirm payment.', 'woocommerce' ),
			'processing'     => __( 'Payment has been received (paid), and the stock has been reduced. The order is awaiting fulfillment.', 'woocommerce' ),
			'completed'      => __( 'Order fulfilled and complete.', 'woocommerce' ),
			'failed'         => __( 'The customer’s payment failed or was declined, and no payment has been successfully made.', 'woocommerce' ),
			'checkout-draft' => __( 'Draft orders are created when customers start the checkout process while the block version of the checkout is in place.', 'woocommerce' ),
			'cancelled'      => __( 'The order was canceled by an admin or the customer.', 'woocommerce' ),
			'refunded'       => __( 'Orders are automatically put in the Refunded status when an admin or shop manager has fully refunded the order’s value after payment.', 'woocommerce' ),
		);

		/**
		 * Provides an opportunity to modify and extend the order status labels.
		 *
		 * @param array    $action Order actions.
		 * @param WC_Order $order  Current order object.
		 * @since 9.1.0
		 */
		$status_names = apply_filters( 'woocommerce_get_order_status_labels', $status_names, $order );

		$status_name = $order->get_status();

		return isset( $status_names[ $status_name ] ) ? $status_names[ $status_name ] : '';
	}

	/**
	 * Renders order billing information.
	 *
	 * @param WC_Order $order The order object for the current row.
	 *
	 * @return void
	 */
	public function render_billing_address_column( WC_Order $order ): void {
		$address = $order->get_formatted_billing_address();

		if ( $address ) {
			echo esc_html( preg_replace( '#<br\s*/?>#i', ', ', $address ) );

			if ( $order->get_payment_method() ) {
				/* translators: %s: payment method */
				echo '<span class="description">' . sprintf( esc_html__( 'via %s', 'woocommerce' ), esc_html( $order->get_payment_method_title() ) ) . '</span>';
			}
		} else {
			echo '&ndash;';
		}
	}

	/**
	 * Renders order shipping information.
	 *
	 * @param WC_Order $order The order object for the current row.
	 *
	 * @return void
	 */
	public function render_shipping_address_column( WC_Order $order ): void {
		$address = $order->get_formatted_shipping_address();

		if ( $address ) {
			echo '<a target="_blank" href="' . esc_url( $order->get_shipping_address_map_url() ) . '">' . esc_html( preg_replace( '#<br\s*/?>#i', ', ', $address ) ) . '</a>';
			if ( $order->get_shipping_method() ) {
				/* translators: %s: shipping method */
				echo '<span class="description">' . sprintf( esc_html__( 'via %s', 'woocommerce' ), esc_html( $order->get_shipping_method() ) ) . '</span>';
			}
		} else {
			echo '&ndash;';
		}
	}

	/**
	 * Renders the order total.
	 *
	 * @param WC_Order $order The order object for the current row.
	 *
	 * @return void
	 */
	public function render_order_total_column( WC_Order $order ): void {
		if ( $order->get_payment_method_title() ) {
			/* translators: %s: method */
			echo '<span class="tips" data-tip="' . esc_attr( sprintf( __( 'via %s', 'woocommerce' ), $order->get_payment_method_title() ) ) . '">' . wp_kses_post( $order->get_formatted_order_total() ) . '</span>';
		} else {
			echo wp_kses_post( $order->get_formatted_order_total() );
		}
	}

	/**
	 * Renders order actions.
	 *
	 * @param WC_Order $order The order object for the current row.
	 *
	 * @return void
	 */
	public function render_wc_actions_column( WC_Order $order ): void {
		echo '<p>';

		/**
		 * Fires before the order action buttons (within the actions column for the order list table)
		 * are registered.
		 *
		 * @param WC_Order $order Current order object.
		 * @since 6.7.0
		 */
		do_action( 'woocommerce_admin_order_actions_start', $order );

		$actions = array();

		if ( $order->has_status( array( 'pending', 'on-hold' ) ) ) {
			$actions['processing'] = array(
				'url'    => wp_nonce_url( admin_url( 'admin-ajax.php?action=woocommerce_mark_order_status&status=processing&order_id=' . $order->get_id() ), 'woocommerce-mark-order-status' ),
				'name'   => __( 'Processing', 'woocommerce' ),
				'action' => 'processing',
			);
		}

		if ( $order->has_status( array( 'pending', 'on-hold', 'processing' ) ) ) {
			$actions['complete'] = array(
				'url'    => wp_nonce_url( admin_url( 'admin-ajax.php?action=woocommerce_mark_order_status&status=completed&order_id=' . $order->get_id() ), 'woocommerce-mark-order-status' ),
				'name'   => __( 'Complete', 'woocommerce' ),
				'action' => 'complete',
			);
		}

		/**
		 * Provides an opportunity to modify the action buttons within the order list table.
		 *
		 * @param array    $action Order actions.
		 * @param WC_Order $order  Current order object.
		 * @since 6.7.0
		 */
		$actions = apply_filters( 'woocommerce_admin_order_actions', $actions, $order );

		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		echo wc_render_action_buttons( $actions );

		/**
		 * Fires after the order action buttons (within the actions column for the order list table)
		 * are rendered.
		 *
		 * @param WC_Order $order Current order object.
		 * @since 6.7.0
		 */
		do_action( 'woocommerce_admin_order_actions_end', $order );

		echo '</p>';
	}

	/**
	 * Outputs hidden fields used to retain state when filtering.
	 *
	 * @return void
	 */
	private function print_hidden_form_fields(): void {
		echo '<input type="hidden" name="page" value="wc-orders' . ( 'shop_order' === $this->order_type ? '' : '--' . $this->order_type ) . '" >'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped

		$state_params = array(
			'paged',
			'status',
		);

		foreach ( $state_params as $param ) {
			if ( ! isset( $_GET[ $param ] ) ) {
				continue;
			}

			echo '<input type="hidden" name="' . esc_attr( $param ) . '" value="' . esc_attr( sanitize_text_field( wp_unslash( $_GET[ $param ] ) ) ) . '" >';
		}
	}

	/**
	 * Gets the current action selected from the bulk actions dropdown.
	 *
	 * @return string|false The action name. False if no action was selected.
	 */
	public function current_action() {
		if ( ! empty( $_REQUEST['delete_all'] ) ) {
			return 'delete_all';
		}

		return parent::current_action();
	}

	/**
	 * Handle bulk actions.
	 */
	public function handle_bulk_actions() {
		$action = $this->current_action();

		if ( ! $action || ! current_user_can( $this->wp_post_type->cap->edit_others_posts ) ) {
			return;
		}

		check_admin_referer( 'bulk-orders' );

		$redirect_to = remove_query_arg( array( 'deleted', 'ids' ), wp_get_referer() );
		$redirect_to = add_query_arg( 'paged', $this->get_pagenum(), $redirect_to );

		if ( 'delete_all' === $action ) {
			// Get all trashed orders.
			$ids = wc_get_orders(
				array(
					'type'   => $this->order_type,
					'status' => 'trash',
					'limit'  => -1,
					'return' => 'ids',
				)
			);

			$action = 'delete';
		} else {
			$ids = isset( $_REQUEST['id'] ) ? array_reverse( array_map( 'absint', (array) $_REQUEST['id'] ) ) : array();
		}

		/**
		 * Allows 3rd parties to modify order IDs about to be affected by a bulk action.
		 *
		 * @param array Array of order IDs.
		 */
		$ids = apply_filters( // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment
			'woocommerce_bulk_action_ids',
			$ids,
			$action,
			'order'
		);

		if ( ! $ids ) {
			wp_safe_redirect( $redirect_to );
			exit;
		}

		$report_action  = '';
		$changed        = 0;
		$action_handled = true;

		if ( 'remove_personal_data' === $action ) {
			$report_action = 'removed_personal_data';
			$changed       = $this->do_bulk_action_remove_personal_data( $ids );
		} elseif ( 'trash' === $action ) {
			$changed       = $this->do_delete( $ids );
			$report_action = 'trashed';
		} elseif ( 'delete' === $action ) {
			$changed       = $this->do_delete( $ids, true );
			$report_action = 'deleted';
		} elseif ( 'untrash' === $action ) {
			$changed       = $this->do_untrash( $ids );
			$report_action = 'untrashed';
		} elseif ( false !== strpos( $action, 'mark_' ) ) {
			$order_statuses = wc_get_order_statuses();
			$new_status     = substr( $action, 5 );
			$report_action  = 'marked_' . $new_status;

			if ( isset( $order_statuses[ 'wc-' . $new_status ] ) ) {
				$changed = $this->do_bulk_action_mark_orders( $ids, $new_status );
			} else {
				$action_handled = false;
			}
		} else {
			$action_handled = false;
		}

		// Custom action.
		if ( ! $action_handled ) {
			$screen = get_current_screen()->id;

			/**
			 * This action is documented in /wp-admin/edit.php (it is a core WordPress hook).
			 *
			 * @since 7.2.0
			 *
			 * @param string $redirect_to The URL to redirect to after processing the bulk actions.
			 * @param string $action      The current bulk action.
			 * @param int[]  $ids         IDs for the orders to be processed.
			 */
			$custom_sendback = apply_filters( "handle_bulk_actions-{$screen}", $redirect_to, $action, $ids ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
		}

		if ( ! empty( $custom_sendback ) ) {
			$redirect_to = $custom_sendback;
		} elseif ( $changed ) {
			$redirect_to = add_query_arg(
				array(
					'bulk_action' => $report_action,
					'changed'     => $changed,
					'ids'         => implode( ',', $ids ),
				),
				$redirect_to
			);
		}

		wp_safe_redirect( $redirect_to );
		exit;
	}

	/**
	 * Implements the "remove personal data" bulk action.
	 *
	 * @param array $order_ids The Order IDs.
	 * @return int Number of orders modified.
	 */
	private function do_bulk_action_remove_personal_data( $order_ids ): int {
		$changed = 0;

		foreach ( $order_ids as $id ) {
			$order = wc_get_order( $id );

			if ( ! $order ) {
				continue;
			}

			do_action( 'woocommerce_remove_order_personal_data', $order ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
			++$changed;
		}

		return $changed;
	}

	/**
	 * Implements the "mark <status>" bulk action.
	 *
	 * @param array  $order_ids  The order IDs to change.
	 * @param string $new_status The new order status.
	 * @return int Number of orders modified.
	 */
	private function do_bulk_action_mark_orders( $order_ids, $new_status ): int {
		$changed = 0;

		// Initialize payment gateways in case order has hooked status transition actions.
		WC()->payment_gateways();

		foreach ( $order_ids as $id ) {
			$order = wc_get_order( $id );

			if ( ! $order ) {
				continue;
			}

			$order->update_status( $new_status, __( 'Order status changed by bulk edit.', 'woocommerce' ), true );
			do_action( 'woocommerce_order_edit_status', $id, $new_status ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
			++$changed;
		}

		return $changed;
	}

	/**
	 * Handles bulk trashing of orders.
	 *
	 * @param int[] $ids Order IDs to be trashed.
	 * @param bool  $force_delete When set, the order will be completed deleted. Otherwise, it will be trashed.
	 *
	 * @return int Number of orders that were trashed.
	 */
	private function do_delete( array $ids, bool $force_delete = false ): int {
		$changed = 0;

		foreach ( $ids as $id ) {
			$order = wc_get_order( $id );
			$order->delete( $force_delete );
			$updated_order = wc_get_order( $id );

			if ( ( $force_delete && false === $updated_order ) || ( ! $force_delete && $updated_order->get_status() === 'trash' ) ) {
				++$changed;
			}
		}

		return $changed;
	}

	/**
	 * Handles bulk restoration of trashed orders.
	 *
	 * @param array $ids Order IDs to be restored to their previous status.
	 *
	 * @return int Number of orders that were restored from the trash.
	 */
	private function do_untrash( array $ids ): int {
		$orders_store = wc_get_container()->get( OrdersTableDataStore::class );
		$changed      = 0;

		foreach ( $ids as $id ) {
			if ( $orders_store->untrash_order( wc_get_order( $id ) ) ) {
				++$changed;
			}
		}

		return $changed;
	}

	/**
	 * Show confirmation message that order status changed for number of orders.
	 */
	public function bulk_action_notices() {
		if ( empty( $_REQUEST['bulk_action'] ) ) {
			return;
		}

		$order_statuses = wc_get_order_statuses();
		$number         = absint( $_REQUEST['changed'] ?? 0 );
		$bulk_action    = wc_clean( wp_unslash( $_REQUEST['bulk_action'] ) );
		$message        = '';

		// Check if any status changes happened.
		foreach ( $order_statuses as $slug => $name ) {
			if ( 'marked_' . str_replace( 'wc-', '', $slug ) === $bulk_action ) { // WPCS: input var ok, CSRF ok.
				/* translators: %s: orders count */
				$message = sprintf( _n( '%s order status changed.', '%s order statuses changed.', $number, 'woocommerce' ), number_format_i18n( $number ) );
				break;
			}
		}

		switch ( $bulk_action ) {
			case 'removed_personal_data':
				/* translators: %s: orders count */
				$message = sprintf( _n( 'Removed personal data from %s order.', 'Removed personal data from %s orders.', $number, 'woocommerce' ), number_format_i18n( $number ) );
				echo '<div class="updated"><p>' . esc_html( $message ) . '</p></div>';
				break;

			case 'trashed':
				/* translators: %s: orders count */
				$message = sprintf( _n( '%s order moved to the Trash.', '%s orders moved to the Trash.', $number, 'woocommerce' ), number_format_i18n( $number ) );
				break;

			case 'untrashed':
				/* translators: %s: orders count */
				$message = sprintf( _n( '%s order restored from the Trash.', '%s orders restored from the Trash.', $number, 'woocommerce' ), number_format_i18n( $number ) );
				break;

			case 'deleted':
				/* translators: %s: orders count */
				$message = sprintf( _n( '%s order permanently deleted.', '%s orders permanently deleted.', $number, 'woocommerce' ), number_format_i18n( $number ) );
				break;
		}

		if ( ! empty( $message ) ) {
			echo '<div class="updated"><p>' . esc_html( $message ) . '</p></div>';
		}
	}

	/**
	 * Enqueue list table scripts.
	 *
	 * @return void
	 */
	public function enqueue_scripts(): void {
		echo $this->get_order_preview_template(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		wp_enqueue_script( 'wc-orders' );
	}

	/**
	 * Returns the HTML for the order preview template.
	 *
	 * @return string HTML template.
	 */
	public function get_order_preview_template(): string {
		$order_edit_url_placeholder =
			wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled()
			? esc_url( admin_url( 'admin.php?page=wc-orders&action=edit' ) ) . '&id={{ data.data.id }}'
			: esc_url( admin_url( 'post.php?action=edit' ) ) . '&post={{ data.data.id }}';

		ob_start();
		?>
		<script type="text/template" id="tmpl-wc-modal-view-order">
			<div class="wc-backbone-modal wc-order-preview">
				<div class="wc-backbone-modal-content">
					<section class="wc-backbone-modal-main" role="main">
						<header class="wc-backbone-modal-header">
							<mark class="order-status status-{{ data.status }}"><span>{{ data.status_name }}</span></mark>
							<?php /* translators: %s: order ID */ ?>
							<h1><?php echo esc_html( sprintf( __( 'Order #%s', 'woocommerce' ), '{{ data.order_number }}' ) ); ?></h1>
							<button class="modal-close modal-close-link dashicons dashicons-no-alt">
								<span class="screen-reader-text"><?php esc_html_e( 'Close modal panel', 'woocommerce' ); ?></span>
							</button>
						</header>
						<article>
							<?php do_action( 'woocommerce_admin_order_preview_start' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment ?>

							<div class="wc-order-preview-addresses">
								<div class="wc-order-preview-address">
									<h2><?php esc_html_e( 'Billing details', 'woocommerce' ); ?></h2>
									{{{ data.formatted_billing_address }}}

									<# if ( data.data.billing.email ) { #>
										<strong><?php esc_html_e( 'Email', 'woocommerce' ); ?></strong>
										<a href="mailto:{{ data.data.billing.email }}">{{ data.data.billing.email }}</a>
									<# } #>

									<# if ( data.data.billing.phone ) { #>
										<strong><?php esc_html_e( 'Phone', 'woocommerce' ); ?></strong>
										<a href="tel:{{ data.data.billing.phone }}">{{ data.data.billing.phone }}</a>
									<# } #>

									<# if ( data.payment_via ) { #>
										<strong><?php esc_html_e( 'Payment via', 'woocommerce' ); ?></strong>
										{{{ data.payment_via }}}
									<# } #>
								</div>
								<# if ( data.needs_shipping ) { #>
									<div class="wc-order-preview-address">
										<h2><?php esc_html_e( 'Shipping details', 'woocommerce' ); ?></h2>
										<# if ( data.ship_to_billing ) { #>
											{{{ data.formatted_billing_address }}}
										<# } else { #>
											<a href="{{ data.shipping_address_map_url }}" target="_blank">{{{ data.formatted_shipping_address }}}</a>
										<# } #>

										<# if ( data.data.shipping.phone ) { #>
											<strong><?php esc_html_e( 'Phone', 'woocommerce' ); ?></strong>
											<a href="tel:{{ data.data.shipping.phone }}">{{ data.data.shipping.phone }}</a>
										<# } #>

										<# if ( data.shipping_via ) { #>
											<strong><?php esc_html_e( 'Shipping method', 'woocommerce' ); ?></strong>
											{{ data.shipping_via }}
										<# } #>
									</div>
								<# } #>

								<# if ( data.data.customer_note ) { #>
									<div class="wc-order-preview-note">
										<strong><?php esc_html_e( 'Note', 'woocommerce' ); ?></strong>
										{{ data.data.customer_note }}
									</div>
								<# } #>
							</div>

							{{{ data.item_html }}}

							<?php do_action( 'woocommerce_admin_order_preview_end' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment ?>
						</article>
						<# if ( data.actions_html || data.is_editable ) { #>
						<footer>
							<div class="inner">
								{{{ data.actions_html }}}

								<# if ( data.is_editable ) { #>
								<a class="button button-primary button-large" aria-label="<?php esc_attr_e( 'Edit this order', 'woocommerce' ); ?>" href="<?php echo $order_edit_url_placeholder; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>"><?php esc_html_e( 'Edit', 'woocommerce' ); ?></a>
								<# } #>
							</div>
						</footer>
						<# } #>
					</section>
				</div>
			</div>
			<div class="wc-backbone-modal-backdrop modal-close"></div>
		</script>
		<?php

		$html = ob_get_clean();

		return $html;
	}

	/**
	 * Renders the search box with various options to limit order search results.
	 *
	 * @param string $text The search button text.
	 * @param string $input_id The search input ID.
	 *
	 * @return void
	 */
	public function search_box( $text, $input_id ) {
		if ( empty( $_REQUEST['s'] ) && ! $this->has_items() ) {
			return;
		}

		$input_id = $input_id . '-search-input';

		if ( ! empty( $_REQUEST['orderby'] ) ) {
			echo '<input type="hidden" name="orderby" value="' . esc_attr( sanitize_text_field( wp_unslash( $_REQUEST['orderby'] ) ) ) . '" />';
		}
		if ( ! empty( $_REQUEST['order'] ) ) {
			echo '<input type="hidden" name="order" value="' . esc_attr( sanitize_text_field( wp_unslash( $_REQUEST['order'] ) ) ) . '" />';
		}
		?>
		<p class="search-box">
			<label class="screen-reader-text" for="<?php echo esc_attr( $input_id ); ?>"><?php echo esc_html( $text ); ?>:</label>
			<input type="search" id="<?php echo esc_attr( $input_id ); ?>" name="s" value="<?php _admin_search_query(); ?>" />
			<?php $this->search_filter(); ?>
			<?php submit_button( $text, '', '', false, array( 'id' => 'search-submit' ) ); ?>
		</p>
		<?php
	}

	/**
	 * Renders the search filter dropdown.
	 *
	 * @return void
	 */
	private function search_filter() {
		$options = array(
			'order_id'       => __( 'Order ID', 'woocommerce' ),
			'customer_email' => __( 'Customer Email', 'woocommerce' ),
			'customers'      => __( 'Customers', 'woocommerce' ),
			'products'       => __( 'Products', 'woocommerce' ),
			'all'            => __( 'All', 'woocommerce' ),
		);

		/**
		 * Filters the search filters available in the admin order search. Can be used to add new or remove existing filters.
		 * When adding new filters, `woocommerce_hpos_generate_where_for_search_filter` should also be used to generate the WHERE clause for the new filter
		 *
		 * @since 8.9.0.
		 *
		 * @param $options array List of available filters.
		 */
		$options       = apply_filters( 'woocommerce_hpos_admin_search_filters', $options );
		$saved_setting = get_user_setting( 'wc-search-filter-hpos-admin', 'all' );
		$selected      = sanitize_text_field( wp_unslash( $_REQUEST['search-filter'] ?? $saved_setting ) );
		if ( $saved_setting !== $selected ) {
			set_user_setting( 'wc-search-filter-hpos-admin', $selected );
		}
		?>
		<select name="search-filter" id="order-search-filter">
			<?php foreach ( $options as $value => $label ) { ?>
				<option value="<?php echo esc_attr( wp_unslash( sanitize_text_field( $value ) ) ); ?>" <?php selected( $value, sanitize_text_field( wp_unslash( $selected ) ) ); ?>><?php echo esc_html( $label ); ?></option>
			<?php } ?>
		</select>
		<?php
	}
}
PK     [1]b  b  *  Admin/Orders/MetaBoxes/CustomerHistory.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes;

use Automattic\WooCommerce\Admin\API\Reports\Customers\Query as CustomersQuery;
use WC_Order;

/**
 * Class CustomerHistory
 *
 * @since 8.5.0
 */
class CustomerHistory {

	/**
	 * Output the customer history template for the order.
	 *
	 * @param WC_Order $order The order object.
	 *
	 * @return void
	 */
	public function output( WC_Order $order ): void {
		// No history when adding a new order.
		if ( 'auto-draft' === $order->get_status() ) {
			return;
		}

		$customer_history = null;

		if ( method_exists( $order, 'get_report_customer_id' ) ) {
			$customer_history = $this->get_customer_history( $order->get_report_customer_id() );
		}

		if ( ! $customer_history ) {
			$customer_history = array(
				'orders_count'    => 0,
				'total_spend'     => 0,
				'avg_order_value' => 0,
			);
		}

		wc_get_template( 'order/customer-history.php', $customer_history );
	}

	/**
	 * Get the order history for the customer (data matches Customers report).
	 *
	 * @param int $customer_report_id The reports customer ID (not necessarily User ID).
	 *
	 * @return array|null Order count, total spend, and average spend per order.
	 */
	private function get_customer_history( $customer_report_id ): ?array {

		$args = array(
			'customers'    => array( $customer_report_id ),
			// If unset, these params have default values that affect the results.
			'order_after'  => null,
			'order_before' => null,
		);

		$customers_query = new CustomersQuery( $args );
		$customer_data   = $customers_query->get_data();
		return $customer_data->data[0] ?? null;
	}

}
PK     [1]    +  Admin/Orders/MetaBoxes/OrderAttribution.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes;

use Automattic\WooCommerce\Internal\Traits\OrderAttributionMeta;
use WC_Order;

/**
 * Class OrderAttribution
 *
 * @since 8.5.0
 */
class OrderAttribution {

	use OrderAttributionMeta;

	/**
	 * OrderAttribution constructor.
	 */
	public function __construct() {
		$this->set_fields_and_prefix();
	}

	/**
	 * Format the meta data for display.
	 *
	 * @since 8.5.0
	 *
	 * @param array $meta The array of meta data to format.
	 *
	 * @return void
	 */
	public function format_meta_data( array &$meta ) {

		if ( array_key_exists( 'device_type', $meta ) ) {

			switch ( $meta['device_type'] ) {
				case 'Mobile':
					$meta['device_type'] = __( 'Mobile', 'woocommerce' );
					break;
				case 'Tablet':
					$meta['device_type'] = __( 'Tablet', 'woocommerce' );
					break;
				case 'Desktop':
					$meta['device_type'] = __( 'Desktop', 'woocommerce' );
					break;

				default:
					$meta['device_type'] = __( 'Unknown', 'woocommerce' );
					break;
			}
		}

	}

	/**
	 * Output the attribution data metabox for the order.
	 *
	 * @since 8.5.0
	 *
	 * @param WC_Order $order The order object.
	 *
	 * @return void
	 */
	public function output( WC_Order $order ) {
		$meta = $this->filter_meta_data( $order->get_meta_data() );

		$this->format_meta_data( $meta );

		// No more details if there is only the origin value - this is for unknown source types.
		$has_more_details = array( 'origin' ) !== array_keys( $meta );

		// For direct, web admin, mobile app or pos orders, also don't show more details.
		$simple_sources = array( 'typein', 'admin', 'mobile_app', 'pos' );
		if ( isset( $meta['source_type'] ) && in_array( $meta['source_type'], $simple_sources, true ) ) {
			$has_more_details = false;
		}

		$template_data = array(
			'meta'             => $meta,
			'has_more_details' => $has_more_details,
		);
		wc_get_template( 'order/attribution-details.php', $template_data );
	}
}
PK     [1]AB  AB  (  Admin/Orders/MetaBoxes/CustomMetaBox.phpnu         <?php
/**
 * Meta box to edit and add custom meta values for an order.
 */

namespace Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes;

use Automattic\WooCommerce\Internal\DataStores\CustomMetaDataStore;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStoreMeta;
use WC_Order;
use WP_Ajax_Response;

/**
 * Class CustomMetaBox.
 */
class CustomMetaBox {

	/**
	 * Update nonce shared among different meta rows.
	 *
	 * @var string
	 */
	private $update_nonce;

	/**
	 * Helper method to get formatted meta data array with proper keys. This can be directly fed to `list_meta()` method.
	 *
	 * @param \WC_Order $order Order object.
	 *
	 * @return array Meta data.
	 */
	private function get_formatted_order_meta_data( \WC_Order $order ) {
		$metadata         = $order->get_meta_data();
		$metadata_to_list = array();
		foreach ( $metadata as $meta ) {
			$data = $meta->get_data();
			if ( is_protected_meta( $data['key'], 'order' ) ) {
				continue;
			}
			$metadata_to_list[] = array(
				'meta_id'    => $data['id'],
				'meta_key'   => $data['key'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- False positive, not a meta query.
				'meta_value' => maybe_serialize( $data['value'] ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- False positive, not a meta query.
			);
		}
		return $metadata_to_list;
	}

	/**
	 * Renders the meta box to manage custom meta.
	 *
	 * @param \WP_Post|\WC_Order $order_or_post Post or order object that we are rendering for.
	 */
	public function output( $order_or_post ) {
		if ( is_a( $order_or_post, \WP_Post::class ) ) {
			$order = wc_get_order( $order_or_post );
		} else {
			$order = $order_or_post;
		}
		$this->render_custom_meta_form( $this->get_formatted_order_meta_data( $order ), $order );
	}

	/**
	 * Helper method to render layout and actual HTML
	 *
	 * @param array     $metadata_to_list List of metadata to render.
	 * @param \WC_Order $order Order object.
	 */
	private function render_custom_meta_form( array $metadata_to_list, \WC_Order $order ) {
		?>
		<div id="postcustomstuff">
			<div id="ajax-response"></div>
			<?php
			list_meta( $metadata_to_list );
			$this->render_meta_form( $order );
			?>
		</div>
		<p>
			<?php
			printf(
				/* translators: 1: opening documentation tag 2: closing documentation tag. */
				esc_html( __( 'Custom fields can be used to add extra metadata to an order that you can %1$suse in your theme%2$s.', 'woocommerce' ) ),
				'<a href="' . esc_attr__( 'https://wordpress.org/support/article/custom-fields/', 'woocommerce' ) . '">',
				'</a>'
			);
			?>
		</p>
		<?php
	}

	/**
	 * Compute keys to display in autofill when adding new meta key entry in custom meta box.
	 * Currently, returns empty keys, will be implemented after caching is merged.
	 *
	 * @param mixed              $deprecated Unused argument. For backwards compatibility.
	 * @param \WP_Post|\WC_Order $order      Order object.
	 *
	 * @return array Array of keys to display in autofill.
	 */
	public function order_meta_keys_autofill( $deprecated, $order ) {
		if ( ! is_a( $order, \WC_Order::class ) ) {
			return array();
		}

		/**
		 * Filters values for the meta key dropdown in the Custom Fields meta box.
		 *
		 * Compatibility filter for `postmeta_form_keys` filter.
		 *
		 * @since 6.9.0
		 *
		 * @param array|null $keys Pre-defined meta keys to be used in place of a postmeta query. Default null.
		 * @param \WC_Order  $order The current post object.
		 */
		$keys = apply_filters( 'postmeta_form_keys', null, $order );
		if ( null === $keys || ! is_array( $keys ) ) {
			/**
			 * Compatibility filter for 'postmeta_form_limit', which filters the number of custom fields to retrieve
			 * for the drop-down in the Custom Fields meta box.
			 *
			 * @since 8.8.0
			 *
			 * @param int $limit Number of custom fields to retrieve. Default 30.
			 */
			$limit = (int) apply_filters( 'postmeta_form_limit', 30 );
			$keys  = wc_get_container()->get( OrdersTableDataStoreMeta::class )->get_meta_keys( $limit );
		}

		if ( $keys ) {
			natcasesort( $keys );
		}

		return $keys;
	}

	/**
	 * Reimplementation of WP core's `meta_form` function. Renders meta form box.
	 *
	 * @param \WC_Order $order WC_Order object.
	 *
	 * @return void
	 */
	public function render_meta_form( \WC_Order $order ) : void {
		?>
		<p><strong><?php esc_html_e( 'Add New Custom Field:', 'woocommerce' ); ?></strong></p>
		<table id="newmeta">
			<thead>
			<tr>
				<th class="left"><label for="metakeyselect"><?php esc_html_e( 'Name', 'woocommerce' ); ?></label></th>
				<th><label for="metavalue"><?php esc_html_e( 'Value', 'woocommerce' ); ?></label></th>
			</tr>
			</thead>

			<tbody>
			<tr>
				<td id="newmetaleft" class="left">
					<span id="metakey-search">
					<select id="metakeyselect" name="metakeyselect" class="wc-order-metakey-search" data-placeholder="<?php esc_attr_e( 'Add existing', 'woocommerce' ); ?>" data-minimum-input-length="0" data-order_id="<?php echo esc_attr( $order->get_id() ); ?>">
					</select>
					</span>
					<input class="hidden" type="text" id="metakeyinput" name="metakeyinput" value="" aria-label="<?php esc_attr_e( 'New custom field name', 'woocommerce' ); ?>" />
					<button type="button" id="newmeta-button" class="button button-small hide-if-no-js" onclick="jQuery('#metakeyinput, #metakeyselect, #enternew, #cancelnew, #metakey-search').toggleClass('hidden');jQuery('#metakeyinput, #metakeyselect').filter(':visible').trigger('focus');">
					<span id="enternew"><?php esc_html_e( 'Enter new', 'woocommerce' ); ?></span>
					<span id="cancelnew" class="hidden"><?php esc_html_e( 'Cancel', 'woocommerce' ); ?></span>
				</td>
				<td><textarea id="metavalue" name="metavalue" rows="2" cols="25"></textarea>
				<?php wp_nonce_field( 'add-meta', '_ajax_nonce-add-meta', false ); ?>
				</td>
			</tr>
			</tbody>
		</table>

		<div class="submit add-custom-field">
			<?php
			submit_button(
				__( 'Add Custom Field', 'woocommerce' ),
				'',
				'addmeta',
				false,
				array(
					'id'            => 'newmeta-submit',
					'data-wp-lists' => 'add:the-list:newmeta',
				)
			);
			?>
		</div>
		<?php
	}

	/**
	 * Helper method to verify order edit permissions.
	 *
	 * @param int $order_id Order ID.
	 *
	 * @return ?WC_Order WC_Order object if the user can edit the order, die otherwise.
	 */
	private function verify_order_edit_permission_for_ajax( int $order_id ): ?WC_Order {
		if ( ! current_user_can( 'manage_woocommerce' ) || ! current_user_can( 'edit_others_shop_orders' ) ) {
			wp_send_json_error( 'missing_capabilities' );
			wp_die();
		}

		$order = wc_get_order( $order_id );
		if ( ! $order ) {
			wp_send_json_error( 'invalid_order_id' );
			wp_die();
		}
		return $order;
	}

	/**
	 * WP Ajax handler to render the list of unique meta keys asynchronously.
	 *
	 * @return void
	 */
	public function search_metakeys_ajax(): void {
		check_ajax_referer( 'search-order-metakeys', 'security' );

		if ( ! isset( $_GET['order_id'] ) || ! current_user_can( 'edit_shop_orders' ) ) {
			wp_die( -1 );
		}

		$order_id = intval( $_GET['order_id'] );
		$order    = wc_get_order( $order_id );
		if ( ! is_a( $order, \WC_Order::class ) ) {
			wp_die( -1 );
		}

		$found_order_meta_keys = $this->order_meta_keys_autofill( null, $order );

		wp_send_json( $found_order_meta_keys );
	}

	/**
	 * Reimplementation of WP core's `wp_ajax_add_meta` method to support order custom meta updates with custom tables.
	 */
	public function add_meta_ajax() {
		if ( ! check_ajax_referer( 'add-meta', '_ajax_nonce-add-meta' ) ) {
			wp_send_json_error( 'invalid_nonce' );
			wp_die();
		}

		$order_id = (int) $_POST['order_id'] ?? 0;
		$order    = $this->verify_order_edit_permission_for_ajax( $order_id );

		$select_meta_key = trim( sanitize_text_field( wp_unslash( $_POST['metakeyselect'] ?? '' ) ) );
		$input_meta_key  = trim( sanitize_text_field( wp_unslash( $_POST['metakeyinput'] ?? '' ) ) );

		if ( empty( $_POST['meta'] ) && in_array( $select_meta_key, array( '', '#NONE#' ), true ) && ! $input_meta_key ) {
			wp_die( 1 );
		}

		if ( ! empty( $_POST['meta'] ) ) { // update.
			$meta = wp_unslash( $_POST['meta'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitization done below in array_walk.
			$this->handle_update_meta( $order, $meta );
		} else { // add meta.
			$meta_value = sanitize_text_field( wp_unslash( $_POST['metavalue'] ?? '' ) );
			$meta_key   = $input_meta_key ? $input_meta_key : $select_meta_key;
			$this->handle_add_meta( $order, $meta_key, $meta_value );
		}
	}

	/**
	 * Part of WP Core's `wp_ajax_add_meta`. This is re-implemented to support updating meta for custom tables.
	 *
	 * @param WC_Order $order Order object.
	 * @param string   $meta_key Meta key.
	 * @param string   $meta_value Meta value.
	 *
	 * @return void
	 */
	private function handle_add_meta( WC_Order $order, string $meta_key, string $meta_value ) {
		$count = 0;
		if ( is_protected_meta( $meta_key ) ) {
			wp_send_json_error( 'protected_meta' );
			wp_die();
		}
		$metas_for_current_key = wp_list_filter( $order->get_meta_data(), array( 'key' => $meta_key ) );
		$meta_ids              = wp_list_pluck( $metas_for_current_key, 'id' );
		$order->add_meta_data( $meta_key, $meta_value );
		$order->save_meta_data();
		$metas_for_current_key_with_new = wp_list_filter( $order->get_meta_data(), array( 'key' => $meta_key ) );
		$meta_id                        = 0;
		$new_meta_ids                   = wp_list_pluck( $metas_for_current_key_with_new, 'id' );
		$new_meta_ids                   = array_values( array_diff( $new_meta_ids, $meta_ids ) );
		if ( count( $new_meta_ids ) > 0 ) {
			$meta_id = $new_meta_ids[0];
		}
		$response = new WP_Ajax_Response(
			array(
				'what'     => 'meta',
				'id'       => $meta_id,
				'data'     => $this->list_meta_row(
					array(
						'meta_id'    => $meta_id,
						'meta_key'   => $meta_key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- false positive, not a meta query.
						'meta_value' => $meta_value, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- false positive, not a meta query.
					),
					$count
				),
				'position' => 1,
			)
		);
		$response->send();
	}

	/**
	 * Handles updating metadata.
	 *
	 * @param WC_Order $order Order object.
	 * @param array    $meta Meta object to update.
	 *
	 * @return void
	 */
	private function handle_update_meta( WC_Order $order, array $meta ) {
		if ( ! is_array( $meta ) ) {
			wp_send_json_error( 'invalid_meta' );
			wp_die();
		}
		array_walk( $meta, 'sanitize_text_field' );
		$mid = (int) key( $meta );
		if ( ! $mid ) {
			wp_send_json_error( 'invalid_meta_id' );
			wp_die();
		}
		$key   = $meta[ $mid ]['key'];
		$value = $meta[ $mid ]['value'];
		if ( is_protected_meta( $key ) ) {
			wp_send_json_error( 'protected_meta' );
			wp_die();
		}
		if ( '' === trim( $key ) ) {
			wp_send_json_error( 'invalid_meta_key' );
			wp_die();
		}

		$count = 0;
		$order->update_meta_data( $key, $value, $mid );
		$order->save_meta_data();
		$response = new WP_Ajax_Response(
			array(
				'what'     => 'meta',
				'id'       => $mid,
				'old_id'   => $mid,
				'data'     => $this->list_meta_row(
					array(
						'meta_key'   => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- false positive, not a meta query.
						'meta_value' => $value, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- false positive, not a meta query.
						'meta_id'    => $mid,
					),
					$count
				),
				'position' => 0,
			)
		);
		$response->send();
	}

	/**
	 * Outputs a single row of public meta data in the Custom Fields meta box.
	 *
	 * @since 2.5.0
	 *
	 * @param array $entry Meta entry.
	 * @param int   $count Sequence number of meta entries.
	 * @return string
	 */
	private function list_meta_row( array $entry, int &$count ) : string {
		if ( is_protected_meta( $entry['meta_key'], 'post' ) ) {
			return '';
		}

		if ( ! $this->update_nonce ) {
			$this->update_nonce = wp_create_nonce( 'add-meta' );
		}

		$r = '';
		++ $count;

		if ( is_serialized( $entry['meta_value'] ) ) {
			if ( is_serialized_string( $entry['meta_value'] ) ) {
				// This is a serialized string, so we should display it.
				$entry['meta_value'] = maybe_unserialize( $entry['meta_value'] ); // // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- false positive, not a meta query.
			} else {
				// This is a serialized array/object so we should NOT display it.
				--$count;
				return '';
			}
		}

		$entry['meta_key']   = esc_attr( $entry['meta_key'] ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- false positive, not a meta query.
		$entry['meta_value'] = esc_textarea( $entry['meta_value'] ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- false positive, not a meta query.
		$entry['meta_id']    = (int) $entry['meta_id'];

		$delete_nonce = wp_create_nonce( 'delete-meta_' . $entry['meta_id'] );

		$r .= "\n\t<tr id='meta-{$entry['meta_id']}'>";
		$r .= "\n\t\t<td class='left'><label class='screen-reader-text' for='meta-{$entry['meta_id']}-key'>" . __( 'Key', 'woocommerce' ) . "</label><input name='meta[{$entry['meta_id']}][key]' id='meta-{$entry['meta_id']}-key' type='text' size='20' value='{$entry['meta_key']}' />";

		$r .= "\n\t\t<div class='submit'>";
		$r .= get_submit_button( __( 'Delete', 'woocommerce' ), 'deletemeta small', "deletemeta[{$entry['meta_id']}]", false, array( 'data-wp-lists' => "delete:the-list:meta-{$entry['meta_id']}::_ajax_nonce:$delete_nonce" ) );
		$r .= "\n\t\t";
		$r .= get_submit_button( __( 'Update', 'woocommerce' ), 'updatemeta small', "meta-{$entry['meta_id']}-submit", false, array( 'data-wp-lists' => "add:the-list:meta-{$entry['meta_id']}::_ajax_nonce-add-meta={$this->update_nonce}" ) );
		$r .= '</div>';
		$r .= wp_nonce_field( 'change-meta', '_ajax_nonce', false, false );
		$r .= '</td>';

		$r .= "\n\t\t<td><label class='screen-reader-text' for='meta-{$entry['meta_id']}-value'>" . __( 'Value', 'woocommerce' ) . "</label><textarea name='meta[{$entry['meta_id']}][value]' id='meta-{$entry['meta_id']}-value' rows='2' cols='30'>{$entry['meta_value']}</textarea></td>\n\t</tr>";
		return $r;
	}

	/**
	 * Reimplementation of WP core's `wp_ajax_delete_meta` method to support order custom meta updates with custom tables.
	 *
	 * @return void
	 */
	public function delete_meta_ajax() {
		$meta_id  = (int) $_POST['id'] ?? 0;
		$order_id = (int) $_POST['order_id'] ?? 0;
		if ( ! $meta_id || ! $order_id ) {
			wp_send_json_error( 'invalid_meta_id' );
			wp_die();
		}
		check_ajax_referer( "delete-meta_$meta_id" );

		$order          = $this->verify_order_edit_permission_for_ajax( $order_id );
		$meta_to_delete = wp_list_filter( $order->get_meta_data(), array( 'id' => $meta_id ) );

		if ( empty( $meta_to_delete ) ) {
			wp_send_json_error( 'invalid_meta_id' );
			wp_die();
		}

		$order->delete_meta_data_by_mid( $meta_id );
		if ( $order->save() ) {
			wp_die( 1 );
		}
		wp_die( 0 );
	}

	/**
	 * Handle the possible changes in order metadata coming from an order edit page in admin
	 * (labeled "custom fields" in the UI).
	 *
	 * This method expects the $_POST array to contain a 'meta' key that is an associative
	 * array of [meta item id => [ 'key' => meta item name, 'value' => meta item value ];
	 * and also to contain (possibly empty) 'metakeyinput' and 'metavalue' keys.
	 *
	 * @param WC_Order $order The order to handle.
	 */
	public function handle_metadata_changes( $order ) {
		$has_meta_changes = false;

		$order_meta = $order->get_meta_data();

		$order_meta =
			array_combine(
				array_map( fn( $meta ) => $meta->id, $order_meta ),
				$order_meta
			);

		// phpcs:disable WordPress.Security.ValidatedSanitizedInput, WordPress.Security.NonceVerification.Missing

		foreach ( ( $_POST['meta'] ?? array() ) as $request_meta_id => $request_meta_data ) {
			$request_meta_id    = wp_unslash( $request_meta_id );
			$request_meta_key   = wp_unslash( $request_meta_data['key'] );
			$request_meta_value = wp_unslash( $request_meta_data['value'] );
			if ( array_key_exists( $request_meta_id, $order_meta ) &&
				( $order_meta[ $request_meta_id ]->key !== $request_meta_key || $order_meta[ $request_meta_id ]->value !== $request_meta_value ) ) {
				$order->update_meta_data( $request_meta_key, $request_meta_value, $request_meta_id );
				$has_meta_changes = true;
			}
		}

		$request_new_key   = wp_unslash( $_POST['metakeyinput'] ?? '' );
		$request_new_value = wp_unslash( $_POST['metavalue'] ?? '' );
		if ( '' !== $request_new_key ) {
			$order->add_meta_data( $request_new_key, $request_new_value );
			$has_meta_changes = true;
		}

		// phpcs:enable WordPress.Security.ValidatedSanitizedInput, WordPress.Security.NonceVerification.Missing

		if ( $has_meta_changes ) {
			$order->save();
		}
	}
}
PK     [1]BP    ,  Admin/Orders/MetaBoxes/TaxonomiesMetaBox.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes;

use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;

/**
 * TaxonomiesMetaBox class, renders taxonomy sidebar widget on order edit screen.
 */
class TaxonomiesMetaBox {

	/**
	 * Order Table data store class.
	 *
	 * @var OrdersTableDataStore
	 */
	private $orders_table_data_store;

	/**
	 * Dependency injection init method.
	 *
	 * @param OrdersTableDataStore $orders_table_data_store Order Table data store class.
	 *
	 * @return void
	 */
	public function init( OrdersTableDataStore $orders_table_data_store ) {
		$this->orders_table_data_store = $orders_table_data_store;
	}

	/**
	 * Registers meta boxes to be rendered in order edit screen for taxonomies.
	 *
	 * Note: This is re-implementation of part of WP core's `register_and_do_post_meta_boxes` function. Since the code block that add meta box for taxonomies is not filterable, we have to re-implement it.
	 *
	 * @param string $screen_id Screen ID.
	 * @param string $order_type Order type to register meta boxes for.
	 *
	 * @return void
	 */
	public function add_taxonomies_meta_boxes( string $screen_id, string $order_type ) {
		include_once ABSPATH . 'wp-admin/includes/meta-boxes.php';
		$taxonomies = get_object_taxonomies( $order_type );
		// All taxonomies.
		foreach ( $taxonomies as $tax_name ) {
			$taxonomy = get_taxonomy( $tax_name );
			if ( ! $taxonomy->show_ui || false === $taxonomy->meta_box_cb ) {
				continue;
			}

			if ( 'post_categories_meta_box' === $taxonomy->meta_box_cb ) {
				$taxonomy->meta_box_cb = array( $this, 'order_categories_meta_box' );
			}

			if ( 'post_tags_meta_box' === $taxonomy->meta_box_cb ) {
				$taxonomy->meta_box_cb = array( $this, 'order_tags_meta_box' );
			}

			$label = $taxonomy->labels->name;

			if ( ! is_taxonomy_hierarchical( $tax_name ) ) {
				$tax_meta_box_id = 'tagsdiv-' . $tax_name;
			} else {
				$tax_meta_box_id = $tax_name . 'div';
			}

			add_meta_box(
				$tax_meta_box_id,
				$label,
				$taxonomy->meta_box_cb,
				$screen_id,
				'side',
				'core',
				array(
					'taxonomy'               => $tax_name,
					'__back_compat_meta_box' => true,
				)
			);
		}
	}

	/**
	 * Save handler for taxonomy data.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 * @param array|null         $taxonomy_input Taxonomy input passed from input.
	 */
	public function save_taxonomies( \WC_Abstract_Order $order, $taxonomy_input ) {
		if ( ! isset( $taxonomy_input ) ) {
			return;
		}

		$sanitized_tax_input = $this->sanitize_tax_input( $taxonomy_input );

		$sanitized_tax_input = $this->orders_table_data_store->init_default_taxonomies( $order, $sanitized_tax_input );
		$this->orders_table_data_store->set_custom_taxonomies( $order, $sanitized_tax_input );
	}

	/**
	 * Sanitize taxonomy input by calling sanitize callbacks for each registered taxonomy.
	 *
	 * @param array|null $taxonomy_data Nonce verified taxonomy input.
	 *
	 * @return array Sanitized taxonomy input.
	 */
	private function sanitize_tax_input( $taxonomy_data ) : array {
		$sanitized_tax_input = array();
		if ( ! is_array( $taxonomy_data ) ) {
			return $sanitized_tax_input;
		}

		// Convert taxonomy input to term IDs, to avoid ambiguity.
		foreach ( $taxonomy_data as $taxonomy => $terms ) {
			$tax_object = get_taxonomy( $taxonomy );
			if ( $tax_object && isset( $tax_object->meta_box_sanitize_cb ) ) {
				$sanitized_tax_input[ $taxonomy ] = call_user_func_array( $tax_object->meta_box_sanitize_cb, array( $taxonomy, $terms ) );
			}
		}

		return $sanitized_tax_input;
	}

	/**
	 * Add the categories meta box to the order screen. This is just a wrapper around the post_categories_meta_box.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 * @param array              $box   Meta box args.
	 *
	 * @return void
	 */
	public function order_categories_meta_box( $order, $box ) {
		$post = get_post( $order->get_id() );
		post_categories_meta_box( $post, $box );
	}

	/**
	 * Add the tags meta box to the order screen. This is just a wrapper around the post_tags_meta_box.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 * @param array              $box   Meta box args.
	 *
	 * @return void
	 */
	public function order_tags_meta_box( $order, $box ) {
		$post = get_post( $order->get_id() );
		post_tags_meta_box( $post, $box );
	}
}
PK     [1]$
  
  )  Admin/Orders/COTRedirectionController.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Admin\Orders;

/**
 * When Custom Order Tables are not the default order store (ie, posts are authoritative), we should take care of
 * redirecting requests for the order editor and order admin list table to the equivalent posts-table screens.
 *
 * If the redirect logic is problematic, it can be unhooked using code like the following example:
 *
 *     remove_action(
 *         'admin_page_access_denied',
 *         array( wc_get_container()->get( COTRedirectionController::class ), 'handle_hpos_admin_requests' )
 *     );
 */
class COTRedirectionController {

	/**
	 * Add hooks needed to perform our magic.
	 */
	public function setup(): void {
		// Only take action in cases where access to the admin screen would otherwise be denied.
		add_action( 'admin_page_access_denied', array( $this, 'handle_hpos_admin_requests' ) );
	}

	/**
	 * Listen for denied admin requests and, if they appear to relate to HPOS admin screens, potentially
	 * redirect the user to the equivalent CPT-driven screens.
	 *
	 * @param array|null $query_params The query parameters to use when determining the redirect. If not provided, the $_GET superglobal will be used.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function handle_hpos_admin_requests( $query_params = null ) {
		$query_params = is_array( $query_params ) ? $query_params : $_GET;

		if ( ! isset( $query_params['page'] ) || 'wc-orders' !== $query_params['page'] ) {
			return;
		}

		$params = wp_unslash( $query_params );
		$action = $params['action'] ?? '';
		unset( $params['page'] );

		if ( 'edit' === $action && isset( $params['id'] ) ) {
			$params['post'] = $params['id'];
			unset( $params['id'] );
			$new_url = add_query_arg( $params, get_admin_url( null, 'post.php' ) );
		} elseif ( 'new' === $action ) {
			unset( $params['action'] );
			$params['post_type'] = 'shop_order';
			$new_url             = add_query_arg( $params, get_admin_url( null, 'post-new.php' ) );
		} else {
			// If nonce parameters are present and valid, rebuild them for the CPT admin list table.
			if ( isset( $params['_wpnonce'] ) && check_admin_referer( 'bulk-orders' ) ) {
				$params['_wp_http_referer'] = get_admin_url( null, 'edit.php?post_type=shop_order' );
				$params['_wpnonce']         = wp_create_nonce( 'bulk-posts' );
			}

			// If an `id` array parameter is present, rename as `post`.
			if ( isset( $params['id'] ) && is_array( $params['id'] ) ) {
				$params['post'] = $params['id'];
				unset( $params['id'] );
			}

			$params['post_type'] = 'shop_order';
			$new_url             = add_query_arg( $params, get_admin_url( null, 'edit.php' ) );
		}

		if ( ! empty( $new_url ) && wp_safe_redirect( $new_url, 301 ) ) {
			exit;
		}
	}
}
PK     [1]I3=?  =?    Admin/Orders/Edit.phpnu         <?php
/**
 * Renders order edit page, works with both post and order object.
 */

namespace Automattic\WooCommerce\Internal\Admin\Orders;

use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomerHistory;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomMetaBox;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\OrderAttribution;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\TaxonomiesMetaBox;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Utilities\OrderUtil;
use WC_Order;

/**
 * Class Edit.
 */
class Edit {

	/**
	 * Screen ID for the edit order screen.
	 *
	 * @var string
	 */
	private $screen_id;

	/**
	 * Instance of the CustomMetaBox class. Used to render meta box for custom meta.
	 *
	 * @var CustomMetaBox
	 */
	private $custom_meta_box;

	/**
	 * Instance of the TaxonomiesMetaBox class. Used to render meta box for taxonomies.
	 *
	 * @var TaxonomiesMetaBox
	 */
	private $taxonomies_meta_box;

	/**
	 * Instance of WC_Order to be used in metaboxes.
	 *
	 * @var \WC_Order
	 */
	private $order;

	/**
	 * Action name that the form is currently handling. Could be new_order or edit_order.
	 *
	 * @var string
	 */
	private $current_action;

	/**
	 * Message to be displayed to the user. Index of message from the messages array registered when declaring shop_order post type.
	 *
	 * @var int
	 */
	private $message;

	/**
	 * Controller for orders page. Used to determine redirection URLs.
	 *
	 * @var PageController
	 */
	private $orders_page_controller;

	/**
	 * Hooks all meta-boxes for order edit page. This is static since this may be called by post edit form rendering.
	 *
	 * @param string $screen_id Screen ID.
	 * @param string $title Title of the page.
	 */
	public static function add_order_meta_boxes( string $screen_id, string $title ) {
		/* Translators: %s order type name. */
		add_meta_box( 'woocommerce-order-data', sprintf( __( '%s data', 'woocommerce' ), $title ), 'WC_Meta_Box_Order_Data::output', $screen_id, 'normal', 'high' );
		add_meta_box( 'woocommerce-order-items', __( 'Items', 'woocommerce' ), 'WC_Meta_Box_Order_Items::output', $screen_id, 'normal', 'high' );
		/* Translators: %s order type name. */
		add_meta_box( 'woocommerce-order-notes', sprintf( __( '%s notes', 'woocommerce' ), $title ), 'WC_Meta_Box_Order_Notes::output', $screen_id, 'side', 'default' );
		add_meta_box( 'woocommerce-order-downloads', __( 'Downloadable product permissions', 'woocommerce' ) . wc_help_tip( __( 'Note: Permissions for order items will automatically be granted when the order status changes to processing/completed.', 'woocommerce' ) ), 'WC_Meta_Box_Order_Downloads::output', $screen_id, 'normal', 'default' );
		/* Translators: %s order type name. */
		add_meta_box( 'woocommerce-order-actions', sprintf( __( '%s actions', 'woocommerce' ), $title ), 'WC_Meta_Box_Order_Actions::output', $screen_id, 'side', 'high' );
		self::maybe_register_order_attribution( $screen_id, $title );
	}

	/**
	 * Hooks metabox save functions for order edit page.
	 *
	 * @return void
	 */
	public static function add_save_meta_boxes() {
		/**
		 * Save Order Meta Boxes.
		 *
		 * In order:
		 *      Save the order items.
		 *      Save the order totals.
		 *      Save the order downloads.
		 *      Save order data - also updates status and sends out admin emails if needed. Last to show latest data.
		 *      Save actions - sends out other emails. Last to show latest data.
		 */
		add_action( 'woocommerce_process_shop_order_meta', 'WC_Meta_Box_Order_Items::save', 10 );
		add_action( 'woocommerce_process_shop_order_meta', 'WC_Meta_Box_Order_Downloads::save', 30, 2 );
		add_action( 'woocommerce_process_shop_order_meta', 'WC_Meta_Box_Order_Data::save', 40 );
		add_action( 'woocommerce_process_shop_order_meta', 'WC_Meta_Box_Order_Actions::save', 50, 2 );
	}

	/**
	 * Enqueue necessary scripts for order edit page.
	 */
	private function enqueue_scripts() {
		if ( wp_is_mobile() ) {
			wp_enqueue_script( 'jquery-touch-punch' );
		}
		wp_enqueue_script( 'post' ); // Ensure existing JS libraries are still available for backward compat.
	}

	/**
	 * Returns the PageController for this edit form. This method is protected to allow child classes to overwrite the PageController object and return custom links.
	 *
	 * @since 8.0.0
	 *
	 * @return PageController PageController object.
	 */
	protected function get_page_controller() {
		if ( ! isset( $this->orders_page_controller ) ) {
			$this->orders_page_controller = wc_get_container()->get( PageController::class );
		}
		return $this->orders_page_controller;
	}

	/**
	 * Setup hooks, actions and variables needed to render order edit page.
	 *
	 * @param \WC_Order $order Order object.
	 */
	public function setup( \WC_Order $order ) {
		$this->order    = $order;
		$current_screen = get_current_screen();
		$current_screen->is_block_editor( false );
		$this->screen_id = $current_screen->id;
		if ( ! isset( $this->custom_meta_box ) ) {
			$this->custom_meta_box = wc_get_container()->get( CustomMetaBox::class );
		}

		if ( ! isset( $this->taxonomies_meta_box ) ) {
			$this->taxonomies_meta_box = wc_get_container()->get( TaxonomiesMetaBox::class );
		}

		$this->add_save_meta_boxes();
		$this->handle_order_update();
		$this->add_order_meta_boxes( $this->screen_id, __( 'Order', 'woocommerce' ) );
		$this->add_order_specific_meta_box();
		$this->add_order_taxonomies_meta_box();

		/**
		 * From wp-admin/includes/meta-boxes.php.
		 *
		 * Fires after all built-in meta boxes have been added. Custom metaboxes may be enqueued here.
		 *
		 * Note that the documentation for this hook (and for the corresponding 'add_meta_boxes_<SCREEN_ID>' hook)
		 * suggest that a post type will be supplied for the first parameter, and and an instance of WP_Post will be
		 * supplied as the second parameter. We are not doing that here, however WordPress itself also deviates from
		 * this in respect of comments and (though now less relevant) links.
		 *
		 * @since 3.8.0.
		 */
		do_action( 'add_meta_boxes', $this->screen_id, $this->order );

		/**
		 * Provides an opportunity to inject custom meta boxes into the order editor screen. This
		 * hook is an analog of `add_meta_boxes_<POST_TYPE>` as provided by WordPress core.
		 *
		 * @since 7.4.0
		 *
		 * @param WC_Order $order The order being edited.
		 */
		do_action( 'add_meta_boxes_' . $this->screen_id, $this->order );

		$this->enqueue_scripts();
	}

	/**
	 * Set the current action for the form.
	 *
	 * @param string $action Action name.
	 */
	public function set_current_action( string $action ) {
		$this->current_action = $action;
	}

	/**
	 * Hooks meta box for order specific meta.
	 */
	private function add_order_specific_meta_box() {
		add_meta_box(
			'order_custom',
			__( 'Custom Fields', 'woocommerce' ),
			array( $this, 'render_custom_meta_box' ),
			$this->screen_id,
			'normal'
		);
	}

	/**
	 * Render custom meta box.
	 *
	 * @return void
	 */
	private function add_order_taxonomies_meta_box() {
		$this->taxonomies_meta_box->add_taxonomies_meta_boxes( $this->screen_id, $this->order->get_type() );
	}

	/**
	 * Register order attribution meta boxes if the feature is enabled.
	 *
	 * @since 8.5.0
	 *
	 * @param string $screen_id Screen ID.
	 * @param string $title     Title of the page.
	 *
	 * @return void
	 */
	private static function maybe_register_order_attribution( string $screen_id, string $title ) {
		/**
		 * Features controller.
		 *
		 * @var FeaturesController $feature_controller
		 */
		$feature_controller = wc_get_container()->get( FeaturesController::class );
		if ( ! $feature_controller->feature_is_enabled( 'order_attribution' ) ) {
			return;
		}

		/**
		 * Order attribution meta box.
		 *
		 * @var OrderAttribution $order_attribution_meta_box
		 */
		$order_attribution_meta_box = wc_get_container()->get( OrderAttribution::class );

		add_meta_box(
			'woocommerce-order-source-data',
			/* Translators: %s order type name. */
			sprintf( __( '%s attribution', 'woocommerce' ), $title ),
			function( $post_or_order ) use ( $order_attribution_meta_box ) {
				$order = $post_or_order instanceof WC_Order ? $post_or_order : wc_get_order( $post_or_order );
				if ( $order instanceof WC_Order ) {
					$order_attribution_meta_box->output( $order );
				}
			},
			$screen_id,
			'side',
			'high'
		);

		// Add customer history meta box if analytics is enabled.
		if ( 'yes' !== get_option( 'woocommerce_analytics_enabled' ) ) {
			return;
		}

		if ( ! OrderUtil::is_order_edit_screen() ) {
			return;
		}

		/**
		 * Customer history meta box.
		 *
		 * @var CustomerHistory $customer_history_meta_box
		 */
		$customer_history_meta_box = wc_get_container()->get( CustomerHistory::class );

		add_meta_box(
			'woocommerce-customer-history',
			__( 'Customer history', 'woocommerce' ),
			function ( $post_or_order ) use ( $customer_history_meta_box ) {
				$order = $post_or_order instanceof WC_Order ? $post_or_order : wc_get_order( $post_or_order );
				if ( $order instanceof WC_Order ) {
					$customer_history_meta_box->output( $order );
				}
			},
			$screen_id,
			'side',
			'high'
		);
	}

	/**
	 * Takes care of updating order data. Fires action that metaboxes can hook to for order data updating.
	 *
	 * @return void
	 */
	public function handle_order_update() {
		if ( ! isset( $this->order ) ) {
			return;
		}

		if ( 'edit_order' !== sanitize_text_field( wp_unslash( $_POST['action'] ?? '' ) ) ) {
			return;
		}

		check_admin_referer( $this->get_order_edit_nonce_action() );

		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized later on by taxonomies_meta_box object.
		$taxonomy_input = isset( $_POST['tax_input'] ) ? wp_unslash( $_POST['tax_input'] ) : null;
		$this->taxonomies_meta_box->save_taxonomies( $this->order, $taxonomy_input );

		/**
		 * Save meta for shop order.
		 *
		 * @param int Order ID.
		 * @param \WC_Order Post object.
		 *
		 * @since 2.1.0
		 */
		do_action( 'woocommerce_process_shop_order_meta', $this->order->get_id(), $this->order );

		$this->custom_meta_box->handle_metadata_changes($this->order);

		// Order updated message.
		$this->message = 1;

		// Claim lock.
		$edit_lock = wc_get_container()->get( EditLock::class );
		$edit_lock->lock( $this->order );

		$this->redirect_order( $this->order );
	}

	/**
	 * Helper method to redirect to order edit page.
	 *
	 * @since 8.0.0
	 *
	 * @param \WC_Order $order Order object.
	 */
	private function redirect_order( \WC_Order $order ) {
		$redirect_to = $this->get_page_controller()->get_edit_url( $order->get_id() );
		if ( isset( $this->message ) ) {
			$redirect_to = add_query_arg( 'message', $this->message, $redirect_to );
		}
		wp_safe_redirect(
			/**
			 * Filter the URL used to redirect after an order is updated. Similar to the WP post's `redirect_post_location` filter.
			 *
			 * @param string    $redirect_to The redirect destination URL.
			 * @param int       $order_id The order ID.
			 * @param \WC_Order $order The order object.
			 *
			 * @since 8.0.0
			 */
			apply_filters(
				'woocommerce_redirect_order_location',
				$redirect_to,
				$order->get_id(),
				$order
			)
		);
		exit;
	}

	/**
	 * Helper method to get the name of order edit nonce.
	 *
	 * @return string Nonce action name.
	 */
	private function get_order_edit_nonce_action() {
		return 'update-order_' . $this->order->get_id();
	}

	/**
	 * Render meta box for order specific meta.
	 */
	public function render_custom_meta_box() {
		$this->custom_meta_box->output( $this->order );
	}

	/**
	 * Render order edit page.
	 */
	public function display() {
		/**
		 * This is used by the order edit page to show messages in the notice fields.
		 * It should be similar to post_updated_messages filter, i.e.:
		 * array(
		 *   {order_type} => array(
		 *      1 => 'Order updated.',
		 *      2 => 'Custom field updated.',
		 * ...
		 * ).
		 *
		 * The index to be displayed is computed from the $_GET['message'] variable.
		 *
		 * @since 7.4.0.
		 */
		$messages = apply_filters( 'woocommerce_order_updated_messages', array() );

		$message = $this->message;
		if ( isset( $_GET['message'] ) ) {
			$message = absint( $_GET['message'] );
		}

		if ( isset( $message ) ) {
			$message = $messages[ $this->order->get_type() ][ $message ] ?? false;
		}

		$this->render_wrapper_start( '', $message );
		$this->render_meta_boxes();
		$this->render_wrapper_end();
	}

	/**
	 * Helper function to render wrapper start.
	 *
	 * @param string $notice Notice to display, if any.
	 * @param string $message Message to display, if any.
	 */
	private function render_wrapper_start( $notice = '', $message = '' ) {
		$post_type = get_post_type_object( $this->order->get_type() );

		$edit_page_url = $this->get_page_controller()->get_edit_url( $this->order->get_id() );
		$form_action   = 'edit_order';
		$referer       = wp_get_referer();
		$new_page_url  = $this->get_page_controller()->get_new_page_url( $this->order->get_type() );

		?>
		<div class="wrap">
		<h1 class="wp-heading-inline">
			<?php
			echo 'new_order' === $this->current_action ? esc_html( $post_type->labels->add_new_item ) : esc_html( $post_type->labels->edit_item );
			?>
		</h1>
		<?php
		if ( 'edit_order' === $this->current_action ) {
			echo ' <a href="' . esc_url( $new_page_url ) . '" class="page-title-action">' . esc_html( $post_type->labels->add_new ) . '</a>';
		}
		?>
		<hr class="wp-header-end">

		<?php
		if ( $notice ) :
			?>
			<div id="notice" class="notice notice-warning"><p
					id="has-newer-autosave"><?php echo wp_kses_post( $notice ); ?></p></div>
		<?php endif; ?>
		<?php if ( $message ) : ?>
			<div id="message" class="updated notice notice-success is-dismissible">
				<p><?php echo wp_kses_post( $message ); ?></p></div>
			<?php
			endif;
		?>

		<form name="order" action="<?php echo esc_url( $edit_page_url ); ?>" method="post" id="order"
		<?php
		/**
		 * Fires inside the order edit form tag.
		 *
		 * @param \WC_Order $order Order object.
		 *
		 * @since 6.9.0
		 */
		do_action( 'order_edit_form_tag', $this->order );
		?>
		>
		<?php wp_nonce_field( $this->get_order_edit_nonce_action() ); ?>
		<?php
		/**
		 * Fires at the top of the order edit form. Can be used as a replacement for edit_form_top hook for HPOS.
		 *
		 * @param \WC_Order $order Order object.
		 *
		 * @since 8.0.0
		 */
		do_action( 'order_edit_form_top', $this->order );

		wp_nonce_field( 'meta-box-order', 'meta-box-order-nonce', false );
		wp_nonce_field( 'closedpostboxes', 'closedpostboxesnonce', false );
		?>
		<input type="hidden" id="hiddenaction" name="action" value="<?php echo esc_attr( $form_action ); ?>"/>

		<?php
		$order_status = $this->order->get_status( 'edit' );
		?>
		<input type="hidden" id="original_order_status" name="original_order_status" value="<?php echo esc_attr( $order_status ); ?>"/>
		<input type="hidden" id="original_post_status" name="original_post_status" value="<?php echo esc_attr( wc_is_order_status( 'wc-' . $order_status ) ? 'wc-' . $order_status : $order_status ); ?>"/>
		<input type="hidden" id="referredby" name="referredby" value="<?php echo $referer ? esc_url( $referer ) : ''; ?>"/>
		<input type="hidden" id="post_ID" name="post_ID" value="<?php echo esc_attr( $this->order->get_id() ); ?>"/>
		<div id="poststuff">
		<div id="post-body"
		class="metabox-holder columns-<?php echo ( 1 === get_current_screen()->get_columns() ) ? '1' : '2'; ?>">
		<?php
	}

	/**
	 * Helper function to render meta boxes.
	 */
	private function render_meta_boxes() {
		?>
		<div id="postbox-container-1" class="postbox-container">
			<?php do_meta_boxes( $this->screen_id, 'side', $this->order ); ?>
		</div>
		<div id="postbox-container-2" class="postbox-container">
			<?php
			do_meta_boxes( $this->screen_id, 'normal', $this->order );
			do_meta_boxes( $this->screen_id, 'advanced', $this->order );
			?>
		</div>
		<?php
	}

	/**
	 * Helper function to render wrapper end.
	 */
	private function render_wrapper_end() {
		?>
		</div> <!-- /post-body -->
		</div> <!-- /poststuff  -->
		</form>
		</div> <!-- /wrap -->
		<?php
	}
}
PK     [1]8Z  Z    Admin/Orders/EditLock.phpnu         <?php
namespace Automattic\WooCommerce\Internal\Admin\Orders;

/**
 * This class takes care of the edit lock logic when HPOS is enabled.
 * For better interoperability with WordPress, edit locks are stored in the same format as posts. That is, as a metadata
 * in the order object (key: '_edit_lock') in the format "timestamp:user_id".
 *
 * @since 7.8.0
 */
class EditLock {

	const META_KEY_NAME = '_edit_lock';

	/**
	 * Obtains lock information for a given order. If the lock has expired or it's assigned to an invalid user,
	 * the order is no longer considered locked.
	 *
	 * @param \WC_Order $order Order to check.
	 * @return bool|array
	 */
	public function get_lock( \WC_Order $order ) {
		$lock = $order->get_meta( self::META_KEY_NAME, true, 'edit' );
		if ( ! $lock ) {
			return false;
		}

		$lock = explode( ':', $lock );
		if ( 2 !== count( $lock ) ) {
			return false;
		}

		$time    = absint( $lock[0] );
		$user_id = isset( $lock[1] ) ? absint( $lock[1] ) : 0;

		if ( ! $time || ! get_user_by( 'id', $user_id ) ) {
			return false;
		}

		/** This filter is documented in WP's wp-admin/includes/ajax-actions.php */
		$time_window = apply_filters( 'wp_check_post_lock_window', 150 ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment
		if ( time() >= ( $time + $time_window ) ) {
			return false;
		}

		return compact( 'time', 'user_id' );
	}

	/**
	 * Checks whether the order is being edited (i.e. locked) by another user.
	 *
	 * @param \WC_Order $order Order to check.
	 * @return bool TRUE if order is locked and currently being edited by another user. FALSE otherwise.
	 */
	public function is_locked_by_another_user( \WC_Order $order ) : bool {
		$lock = $this->get_lock( $order );
		return $lock && ( get_current_user_id() !== $lock['user_id'] );
	}

	/**
	 * Checks whether the order is being edited by any user.
	 *
	 * @param \WC_Order $order Order to check.
	 * @return boolean TRUE if order is locked and currently being edited by a user. FALSE otherwise.
	 */
	public function is_locked( \WC_Order $order ) : bool {
		return (bool) $this->get_lock( $order );
	}

	/**
	 * Assigns an order's edit lock to the current user.
	 *
	 * @param \WC_Order $order The order to apply the lock to.
	 * @return array|bool FALSE if no user is logged-in, an array in the same format as {@see get_lock()} otherwise.
	 */
	public function lock( \WC_Order $order ) {
		$user_id = get_current_user_id();

		if ( ! $user_id ) {
			return false;
		}

		$order->update_meta_data( self::META_KEY_NAME, time() . ':' . $user_id );
		$order->save_meta_data();

		return $order->get_meta( self::META_KEY_NAME, true, 'edit' );
	}

	/**
	 * Hooked to 'heartbeat_received' on the edit order page to refresh the lock on an order being edited by the current user.
	 *
	 * @param array $response The heartbeat response to be sent.
	 * @param array $data     Data sent through the heartbeat.
	 * @return array Response to be sent.
	 */
	public function refresh_lock_ajax( $response, $data ) {
		$order_id = absint( $data['wc-refresh-order-lock'] ?? 0 );
		if ( ! $order_id ) {
			return $response;
		}

		unset( $response['wp-refresh-post-lock'] );

		$order = wc_get_order( $order_id );
		if ( ! $order || ! is_a( $order, \WC_Order::class ) || ( ! current_user_can( get_post_type_object( $order->get_type() )->cap->edit_post, $order->get_id() ) && ! current_user_can( 'manage_woocommerce' ) ) ) {
			return $response;
		}

		$response['wc-refresh-order-lock'] = array();

		if ( ! $this->is_locked_by_another_user( $order ) ) {
			$response['wc-refresh-order-lock']['lock'] = $this->lock( $order );
		} else {
			$current_lock = $this->get_lock( $order );
			$user         = get_user_by( 'id', $current_lock['user_id'] );

			$response['wc-refresh-order-lock']['error'] = array(
				// translators: %s is a user's name.
				'message'            => sprintf( __( '%s has taken over and is currently editing.', 'woocommerce' ), $user->display_name ),
				'user_name'          => $user->display_name,
				'user_avatar_src'    => get_option( 'show_avatars' ) ? get_avatar_url( $user->ID, array( 'size' => 64 ) ) : '',
				'user_avatar_src_2x' => get_option( 'show_avatars' ) ? get_avatar_url( $user->ID, array( 'size' => 128 ) ) : '',
			);
		}

		return $response;
	}

	/**
	 * Hooked to 'heartbeat_received' on the orders screen to refresh the locked status of orders in the list table.
	 *
	 * @param array $response The heartbeat response to be sent.
	 * @param array $data     Data sent through the heartbeat.
	 * @return array Response to be sent.
	 */
	public function check_locked_orders_ajax( $response, $data ) {
		if ( empty( $data['wc-check-locked-orders'] ) || ! is_array( $data['wc-check-locked-orders'] ) ) {
			return $response;
		}

		$response['wc-check-locked-orders'] = array();

		$order_ids = array_unique( array_map( 'absint', $data['wc-check-locked-orders'] ) );
		foreach ( $order_ids as $order_id ) {
			$order = wc_get_order( $order_id );
			if ( ! $order || ! is_a( $order, \WC_Order::class ) ) {
				continue;
			}

			if ( ! $this->is_locked_by_another_user( $order ) || ( ! current_user_can( get_post_type_object( $order->get_type() )->cap->edit_post, $order->get_id() ) && ! current_user_can( 'manage_woocommerce' ) ) ) {
				continue;
			}

			$response['wc-check-locked-orders'][ $order_id ] = true;
		}

		return $response;
	}

	/**
	 * Outputs HTML for the lock dialog based on the status of the lock on the order (if any).
	 * Depending on who owns the lock, this could be a message with the chance to take over or a message indicating that
	 * someone else has taken over the order.
	 *
	 * @param \WC_Order $order Order object.
	 * @return void
	 */
	public function render_dialog( $order ) {
		$lock   = $this->get_lock( $order );
		$user   = $lock ? get_user_by( 'id', $lock['user_id'] ) : false;
		$locked = $user && ( get_current_user_id() !== $user->ID );

		$edit_url = wc_get_container()->get( \Automattic\WooCommerce\Internal\Admin\Orders\PageController::class )->get_edit_url( $order->get_id() );

		$sendback_url = wp_get_referer();
		if ( ! $sendback_url ) {
			$sendback_url = wc_get_container()->get( \Automattic\WooCommerce\Internal\Admin\Orders\PageController::class )->get_base_page_url( $order->get_type() );
		}

		$sendback_text = __( 'Go back', 'woocommerce' );
		?>
		<div id="post-lock-dialog" class="notification-dialog-wrap <?php echo $locked ? '' : 'hidden'; ?> order-lock-dialog">
			<div class="notification-dialog-background"></div>
			<div class="notification-dialog">
			<?php if ( $locked ) : ?>
			<div class="post-locked-message">
				<div class="post-locked-avatar"><?php echo get_avatar( $user->ID, 64 ); ?></div>
				<p class="currently-editing wp-tab-first" tabindex="0">
				<?php
				// translators: %s is a user's name.
				echo esc_html( sprintf( __( '%s is currently editing this order. Do you want to take over?', 'woocommerce' ), esc_html( $user->display_name ) ) );
				?>
				</p>
				<p>
					<a class="button" href="<?php echo esc_url( $sendback_url ); ?>"><?php echo esc_html( $sendback_text ); ?></a>
					<a class="button button-primary wp-tab-last" href="<?php echo esc_url( add_query_arg( 'claim-lock', '1', wp_nonce_url( $edit_url, 'claim-lock-' . $order->get_id() ) ) ); ?>"><?php esc_html_e( 'Take over', 'woocommerce' ); ?></a>
				</p>
			</div>
			<?php else : ?>
			<div class="post-taken-over">
				<div class="post-locked-avatar"></div>
				<p class="wp-tab-first" tabindex="0">
				<span class="currently-editing"></span><br />
				</p>
				<p><a class="button button-primary wp-tab-last" href="<?php echo esc_url( $sendback_url ); ?>"><?php echo esc_html( $sendback_text ); ?></a></p>
			</div>
			<?php endif; ?>
			</div>
		</div>
		<?php
	}

}
PK     [1]_    1  Admin/Settings/PaymentsProviders/NexiCheckout.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * Nexi Checkout payment gateway provider class.
 *
 * This class handles all the custom logic for the Nexi Checkout payment gateway provider.
 */
class NexiCheckout extends PaymentGateway {

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			$sandbox = $this->is_nexi_in_sandbox_mode( $payment_gateway );
			if ( null === $sandbox ) {
				return parent::is_account_connected( $payment_gateway );
			}

			return $sandbox
				? ( ! empty( $payment_gateway->get_option( 'dibs_test_key' ) ) && ! empty( $payment_gateway->get_option( 'dibs_test_checkout_key' ) ) )
				: ( ! empty( $payment_gateway->get_option( 'dibs_live_key' ) ) && ! empty( $payment_gateway->get_option( 'dibs_checkout_key' ) ) );
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway has an account connected: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_account_connected( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode, false otherwise.
	 */
	public function is_in_test_mode( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_nexi_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_test_mode( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in dev mode.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in dev mode, false otherwise.
	 */
	public function is_in_dev_mode( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_nexi_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_dev_mode( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_nexi_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_test_mode_onboarding( $payment_gateway );
	}

	/**
	 * Check if the Nexi Checkout payment gateway is in test/sandbox mode.
	 *
	 * There are two different environments: test/sandbox and production.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return ?bool True if the payment gateway is in sandbox mode, false otherwise.
	 *               Null if the environment could not be determined.
	 */
	private function is_nexi_in_sandbox_mode( WC_Payment_Gateway $payment_gateway ): ?bool {
		try {
			return \wc_string_to_bool( $payment_gateway->get_option( 'test_mode' ) );
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway is in sandbox mode: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		// Let the caller know that we couldn't determine the environment.
		return null;
	}
}
PK     [1]ʄ}  }  +  Admin/Settings/PaymentsProviders/Mollie.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * Mollie payment gateway provider class.
 *
 * This class handles all the custom logic for the Mollie payment gateway provider.
 */
class Mollie extends PaymentGateway {

	/**
	 * Get the settings URL for a payment gateway.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return string The settings URL for the payment gateway.
	 */
	public function get_settings_url( WC_Payment_Gateway $payment_gateway ): string {
		// Don't target any section because there are none to target when Mollie is not connected.
		if ( 'mollie_stand_in' === $payment_gateway->id ) {
			return $this->get_custom_settings_url();
		}

		// Target the payment methods section when the gateway is connected.
		return $this->get_custom_settings_url( 'mollie_payment_methods' );
	}

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			$sandbox_mode = $this->is_mollie_in_sandbox_mode( $payment_gateway );
			// Let null results bubble up to the parent class.
			if ( true === $sandbox_mode ) {
				// If Mollie is in sandbox mode, we consider the account connected if the test API key is set.
				return ! empty( get_option( 'mollie-payments-for-woocommerce_test_api_key', '' ) );
			} elseif ( false === $sandbox_mode ) {
				// In production mode, we check the live API key.
				return ! empty( get_option( 'mollie-payments-for-woocommerce_live_api_key', '' ) );
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway has an account connected: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_account_connected( $payment_gateway );
	}

	/**
	 * Determine if the payment gateway is in test mode.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode, false otherwise.
	 */
	public function is_in_test_mode( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_mollie_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_test_mode( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_mollie_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_test_mode_onboarding( $payment_gateway );
	}

	/**
	 * Determine if at least a Mollie gateway is registered.
	 *
	 * @param array $payment_gateways The payment gateways objects.
	 *
	 * @return bool True if at least a Mollie gateway is registered, false otherwise.
	 */
	public function is_gateway_registered( array $payment_gateways ): bool {
		$mollie_gateways = array_filter(
			$payment_gateways,
			function ( $gateway ) {
				return str_starts_with( $gateway->id, 'mollie_wc_gateway_' );
			}
		);

		return ! empty( $mollie_gateways );
	}

	/**
	 * Get the pseudo Mollie gateway object.
	 *
	 * @param array $suggestion The suggestion data.
	 *
	 * @return PseudoWCPaymentGateway The pseudo gateway object.
	 */
	public function get_pseudo_gateway( array $suggestion ): PseudoWCPaymentGateway {
		// We will generate a generic gateway to represent Mollie in the settings page.
		// The generic gateway's state will be not enabled, not connected, and not onboarded.
		// The presentational details will be minimal, letting the suggestion provide most of the information.
		return new PseudoWCPaymentGateway(
			'mollie_stand_in',
			array(
				'method_title'         => $suggestion['title'],
				'method_description'   => $suggestion['description'],
				'enabled'              => false,
				'needs_setup'          => true,
				'test_mode'            => false,
				'dev_mode'             => false,
				'account_connected'    => false,
				'onboarding_started'   => false,
				'onboarding_completed' => false,
				'settings_url'         => $this->get_custom_settings_url(),
				'plugin_slug'          => $suggestion['plugin']['slug'],
				'plugin_file'          => $suggestion['plugin']['file'],
			),
		);
	}

	/**
	 * Get the URL to the custom settings page for Mollie.
	 *
	 * @param string $section Optional. The section to navigate to.
	 *
	 * @return string The URL to the custom settings page for Mollie.
	 */
	private function get_custom_settings_url( string $section = '' ): string {
		$settings_url = admin_url( 'admin.php?page=wc-settings&tab=mollie_settings' );

		if ( ! empty( $section ) ) {
			$settings_url = add_query_arg( 'section', $section, $settings_url );
		}

		return $settings_url;
	}

	/**
	 * Check if the Mollie payment gateway is in sandbox mode.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return ?bool True if the payment gateway is in sandbox mode, false otherwise.
	 *               Null if the environment could not be determined.
	 */
	private function is_mollie_in_sandbox_mode( WC_Payment_Gateway $payment_gateway ): ?bool {
		try {
			// Unfortunately, Mollie does not provide a standard way to determine if the gateway is in sandbox mode.
			return filter_var( get_option( 'mollie-payments-for-woocommerce_test_mode_enabled', 'yes' ), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
		} catch ( \Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway is in sandbox mode: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		// Let the caller know that we couldn't determine the environment.
		return null;
	}
}
PK     [1]!    +  Admin/Settings/PaymentsProviders/Affirm.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * Affirm payment gateway provider class.
 *
 * This class handles all the custom logic for the Affirm payment gateway provider.
 */
class Affirm extends PaymentGateway {

	/**
	 * Check if the payment gateway needs setup.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway needs setup, false otherwise.
	 */
	public function needs_setup( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			if ( is_callable( array( $payment_gateway, 'isValidForUse' ) ) ) {
				return ! wc_string_to_bool( $payment_gateway->isValidForUse() );
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway needs setup: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::needs_setup( $payment_gateway );
	}
}
PK     [1]bܒ3    +  Admin/Settings/PaymentsProviders/PayPal.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * PayPal payment gateway provider class.
 *
 * This class handles all the custom logic for the PayPal payment gateway provider.
 */
class PayPal extends PaymentGateway {

	/**
	 * Try to determine if the payment gateway is in test mode.
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode, false otherwise.
	 */
	public function is_in_test_mode( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_paypal_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_test_mode( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in dev mode.
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in dev mode, false otherwise.
	 */
	public function is_in_dev_mode( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_paypal_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_dev_mode( $payment_gateway );
	}

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_paypal_onboarded( $payment_gateway ) ?? parent::is_account_connected( $payment_gateway );
	}

	/**
	 * Check if the payment gateway has completed the onboarding process.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway has completed the onboarding process, false otherwise.
	 *              If the payment gateway does not provide the information,
	 *              it will infer it from having a connected account.
	 */
	public function is_onboarding_completed( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_paypal_onboarded( $payment_gateway ) ?? parent::is_onboarding_completed( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_paypal_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_test_mode_onboarding( $payment_gateway );
	}

	/**
	 * Check if the PayPal payment gateway is in sandbox mode.
	 *
	 * For PayPal, there are two different environments: sandbox and production.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return ?bool True if the payment gateway is in sandbox mode, false otherwise.
	 *               Null if the environment could not be determined.
	 */
	private function is_paypal_in_sandbox_mode( WC_Payment_Gateway $payment_gateway ): ?bool {
		if ( class_exists( '\WooCommerce\PayPalCommerce\PPCP' ) &&
			is_callable( '\WooCommerce\PayPalCommerce\PPCP::container' ) ) {
			try {
				$container = \WooCommerce\PayPalCommerce\PPCP::container();

				if ( $container->has( 'settings.connection-state' ) ) {
					$state = $container->get( 'settings.connection-state' );

					return $state->is_sandbox();
				}

				// Backwards compatibility with pre 3.0.0 (deprecated).
				if ( $container->has( 'onboarding.environment' ) &&
					defined( '\WooCommerce\PayPalCommerce\Onboarding\Environment::SANDBOX' ) ) {
					$environment         = $container->get( 'onboarding.environment' );
					$current_environment = $environment->current_environment();

					return \WooCommerce\PayPalCommerce\Onboarding\Environment::SANDBOX === $current_environment;
				}
			} catch ( \Throwable $e ) {
				// Do nothing but log so we can investigate.
				SafeGlobalFunctionProxy::wc_get_logger()->debug(
					'Failed to determine if gateway is in sandbox mode: ' . $e->getMessage(),
					array(
						'gateway'   => $payment_gateway->id,
						'source'    => 'settings-payments',
						'exception' => $e,
					)
				);
			}
		}

		// Let the caller know that we couldn't determine the environment.
		return null;
	}

	/**
	 * Check if the PayPal payment gateway is onboarded.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return ?bool True if the payment gateway is onboarded, false otherwise.
	 *               Null if we failed to determine the onboarding status.
	 */
	private function is_paypal_onboarded( WC_Payment_Gateway $payment_gateway ): ?bool {
		if ( class_exists( '\WooCommerce\PayPalCommerce\PPCP' ) &&
			is_callable( '\WooCommerce\PayPalCommerce\PPCP::container' ) ) {
			try {
				$container = \WooCommerce\PayPalCommerce\PPCP::container();

				if ( $container->has( 'settings.connection-state' ) ) {
					$state = $container->get( 'settings.connection-state' );

					return $state->is_connected();
				}

				// Backwards compatibility with pre 3.0.0 (deprecated).
				if ( $container->has( 'onboarding.state' ) &&
					defined( '\WooCommerce\PayPalCommerce\Onboarding\State::STATE_ONBOARDED' ) ) {
					$state = $container->get( 'onboarding.state' );

					return $state->current_state() >= \WooCommerce\PayPalCommerce\Onboarding\State::STATE_ONBOARDED;
				}
			} catch ( \Throwable $e ) {
				// Do nothing but log so we can investigate.
				SafeGlobalFunctionProxy::wc_get_logger()->debug(
					'Failed to determine if gateway is onboarded: ' . $e->getMessage(),
					array(
						'gateway'   => $payment_gateway->id,
						'source'    => 'settings-payments',
						'exception' => $e,
					)
				);
			}
		}

		// Let the caller know that we couldn't determine the onboarding status.
		return null;
	}
}
PK     [1]	  	  -  Admin/Settings/PaymentsProviders/Razorpay.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * Razorpay payment gateway provider class.
 *
 * This class handles all the custom logic for the Razorpay payment gateway provider.
 */
class Razorpay extends PaymentGateway {

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			if ( is_callable( array( $payment_gateway, 'getSetting' ) ) ) {
				return ! empty( $payment_gateway->getSetting( 'key_id' ) ) &&
						! empty( $payment_gateway->getSetting( 'key_secret' ) );
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway has an account connected: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_account_connected( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode.
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode, false otherwise.
	 */
	public function is_in_test_mode( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			if ( function_exists( '\isTestModeEnabled' ) ) {
				return wc_string_to_bool( \isTestModeEnabled() );
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway is in test mode: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_in_test_mode( $payment_gateway );
	}
}
PK     [1]|b    *  Admin/Settings/PaymentsProviders/Antom.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * Antom payment gateway provider class.
 *
 * This class handles all the custom logic for the Antom payment gateway provider.
 */
class Antom extends PaymentGateway {

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			if ( function_exists( '\antom_is_active' ) &&
				! \antom_is_active() ) {
				return false;
			}

			if ( function_exists( '\antom_get_core_settings' ) ) {
				$core_settings = \antom_get_core_settings();
				if ( ! is_array( $core_settings ) ) {
					return false;
				}

				unset( $core_settings['test_mode'] );
				// All remaining entries must not be empty.
				foreach ( $core_settings as $setting ) {
					if ( empty( $setting ) ) {
						return false;
					}
				}

				return true;
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway has an account connected: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_account_connected( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode.
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode, false otherwise.
	 */
	public function is_in_test_mode( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_antom_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_test_mode( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in dev mode.
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in dev mode, false otherwise.
	 */
	public function is_in_dev_mode( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_antom_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_dev_mode( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_antom_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_test_mode_onboarding( $payment_gateway );
	}

	/**
	 * Check if the Antom payment gateway is in sandbox mode.
	 *
	 * There are two different environments: test and production.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return ?bool True if the payment gateway is in sandbox mode, false otherwise.
	 *               Null if the environment could not be determined.
	 */
	private function is_antom_in_sandbox_mode( WC_Payment_Gateway $payment_gateway ): ?bool {
		try {
			if ( function_exists( '\antom_get_core_settings' ) ) {
				return wc_string_to_bool( \antom_get_core_settings()['test_mode'] );
			}
		} catch ( \Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway is in sandbox mode: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		// Let the caller know that we couldn't determine the environment.
		return null;
	}
}
PK     [1]
  
  3  Admin/Settings/PaymentsProviders/KlarnaCheckout.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * KlarnaCheckout payment gateway provider class.
 *
 * This class handles all the custom logic for the KlarnaCheckout payment gateway provider.
 */
class KlarnaCheckout extends PaymentGateway {

	/**
	 * Check if the payment gateway needs setup.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway needs setup, false otherwise.
	 */
	public function needs_setup( WC_Payment_Gateway $payment_gateway ): bool {
		return ! empty( get_option( 'kco_credentials_error' ) );
	}

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			// Note: Since the credentials used are tied to the WooCommerce store location country (US and non-US),
			// the account can become disconnected if the store location changes.
			if ( function_exists( 'KCO_WC' ) ) {
				$credentials = \KCO_WC()->credentials;
				if ( is_object( $credentials ) && is_callable( array( $credentials, 'get_credentials_from_session' ) ) ) {
					return ! empty( $credentials->get_credentials_from_session() );
				}
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway has an account connected: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_account_connected( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		// Test mode is actually sandbox mode for KlarnaCheckout, affecting the API details used.
		return $this->is_in_test_mode( $payment_gateway );
	}
}
PK     [1]l
  
  ,  Admin/Settings/PaymentsProviders/Vivacom.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * Viva.com payment gateway provider class.
 *
 * This class handles all the custom logic for the Viva.com payment gateway provider.
 */
class Vivacom extends PaymentGateway {

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			if ( $this->is_in_test_mode( $payment_gateway ) ) {
				return property_exists( $payment_gateway, 'test_client_id' ) && ! empty( $payment_gateway->test_client_id )
					&& property_exists( $payment_gateway, 'test_client_secret' ) && ! empty( $payment_gateway->test_client_secret )
					&& property_exists( $payment_gateway, 'test_source_code' ) && ! empty( $payment_gateway->test_source_code );
			} else {
				return property_exists( $payment_gateway, 'client_id' ) && ! empty( $payment_gateway->client_id )
					&& property_exists( $payment_gateway, 'client_secret' ) && ! empty( $payment_gateway->client_secret )
					&& property_exists( $payment_gateway, 'source_code' ) && ! empty( $payment_gateway->source_code );
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway has an account connected: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_account_connected( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		// Test mode is actually sandbox mode for Viva.com, affecting the used API keys.
		return $this->is_in_test_mode( $payment_gateway );
	}
}
PK     [1].U    -  Admin/Settings/PaymentsProviders/Payoneer.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * Payoneer payment gateway provider class.
 *
 * This class handles all the custom logic for the Payoneer payment gateway provider.
 */
class Payoneer extends PaymentGateway {

	/**
	 * Try to determine if the payment gateway is in test mode.
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode, false otherwise.
	 */
	public function is_in_test_mode( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			return ! wc_string_to_bool( $payment_gateway->get_option( 'live_mode' ) );
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway is in test mode: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_in_test_mode( $payment_gateway );
	}

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			$sandbox_prefix = $this->is_in_test_mode( $payment_gateway ) ? 'sandbox_' : '';
			return ! empty( $payment_gateway->get_option( 'merchant_code' ) ) &&
					! empty( $payment_gateway->get_option( $sandbox_prefix . 'merchant_token' ) ) &&
					! empty( $payment_gateway->get_option( $sandbox_prefix . 'store_code' ) );
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway has an account connected: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_account_connected( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		// Test mode is actually sandbox mode for Payoneer, affecting the API credentials used.
		return $this->is_in_test_mode( $payment_gateway );
	}
}
PK     [1]Y?!  ?!  +  Admin/Settings/PaymentsProviders/Stripe.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Admin\Settings\Payments;
use Automattic\WooCommerce\Internal\Admin\Settings\Utils;
use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * Stripe payment gateway provider class.
 *
 * This class handles all the custom logic for the Stripe payment gateway provider.
 */
class Stripe extends PaymentGateway {

	/**
	 * Try to determine if the payment gateway is in test mode.
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode, false otherwise.
	 */
	public function is_in_test_mode( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			if ( class_exists( '\WC_Stripe_Mode' ) &&
				is_callable( '\WC_Stripe_Mode::is_test' ) ) {

				return wc_string_to_bool( \WC_Stripe_Mode::is_test() );
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway is in test mode: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_in_test_mode( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in dev mode.
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in dev mode, false otherwise.
	 */
	public function is_in_dev_mode( WC_Payment_Gateway $payment_gateway ): bool {
		return false;
	}

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			if ( class_exists( '\WC_Stripe' ) && is_callable( '\WC_Stripe::get_instance' ) ) {
				$stripe = \WC_Stripe::get_instance();
				if ( is_object( $stripe ) && isset( $stripe->account ) &&
					class_exists( '\WC_Stripe_Account' ) &&
					defined( '\WC_Stripe_Account::STATUS_NO_ACCOUNT' ) &&
					$stripe->account instanceof \WC_Stripe_Account &&
					is_callable( array( $stripe->account, 'get_account_status' ) ) ) {

					return \WC_Stripe_Account::STATUS_NO_ACCOUNT !== $stripe->account->get_account_status();
				}
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway has account connected: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_account_connected( $payment_gateway );
	}

	/**
	 * Check if the payment gateway has started the onboarding process.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway has started the onboarding process, false otherwise.
	 *              If the payment gateway does not provide the information,
	 *              it will infer it from having a connected account.
	 */
	public function is_onboarding_started( WC_Payment_Gateway $payment_gateway ): bool {
		// Fall back to inferring this from having a connected account.
		return $this->is_account_connected( $payment_gateway );
	}

	/**
	 * Check if the payment gateway has completed the onboarding process.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway has completed the onboarding process, false otherwise.
	 *              If the payment gateway does not provide the information,
	 *              it will infer it from having a connected account.
	 */
	public function is_onboarding_completed( WC_Payment_Gateway $payment_gateway ): bool {
		// Sanity check: If the onboarding has not started, it cannot be completed.
		if ( ! $this->is_onboarding_started( $payment_gateway ) ) {
			return false;
		}

		// Fall back to inferring this from having a connected account.
		return $this->is_account_connected( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			if ( class_exists( '\WC_Stripe' ) && is_callable( '\WC_Stripe::get_instance' ) ) {
				$stripe = \WC_Stripe::get_instance();
				if ( is_object( $stripe ) && isset( $stripe->connect ) &&
					class_exists( '\WC_Stripe_Connect' ) &&
					$stripe->connect instanceof \WC_Stripe_Connect &&
					is_callable( array( $stripe->connect, 'is_connected' ) ) ) {

					return $stripe->connect->is_connected( 'test' )
						&& ! $stripe->connect->is_connected( 'live' );
				}
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway is in test mode onboarding: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_in_test_mode_onboarding( $payment_gateway );
	}

	/**
	 * Get the settings URL for a payment gateway.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return string The settings URL for the payment gateway.
	 */
	public function get_settings_url( WC_Payment_Gateway $payment_gateway ): string {
		return Utils::wc_payments_settings_url(
			null,
			array(
				'section' => strtolower( $payment_gateway->id ),
				'from'    => Payments::FROM_PAYMENTS_SETTINGS,
			)
		);
	}

	/**
	 * Get the onboarding URL for the payment gateway.
	 *
	 * This URL should start or continue the onboarding process.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 * @param string             $return_url      Optional. The URL to return to after onboarding.
	 *                                            This will likely get attached to the onboarding URL.
	 *
	 * @return string The onboarding URL for the payment gateway.
	 */
	public function get_onboarding_url( WC_Payment_Gateway $payment_gateway, string $return_url = '' ): string {
		// Fall back to pointing users to the payment gateway settings page to handle onboarding.
		return $this->get_settings_url( $payment_gateway );
	}

	/**
	 * Try and determine a list of recommended payment methods for a payment gateway.
	 *
	 * This data is not always available, and it is up to the payment gateway to provide it.
	 * This is not a definitive list of payment methods that the gateway supports.
	 * The data is aimed at helping the user understand what payment methods are recommended for the gateway
	 * and potentially help them make a decision on which payment methods to enable.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 * @param string             $country_code    Optional. The country code for which to get recommended payment methods.
	 *                                            This should be an ISO 3166-1 alpha-2 country code.
	 *
	 * @return array The recommended payment methods list for the payment gateway.
	 *               Empty array if there are none.
	 */
	public function get_recommended_payment_methods( WC_Payment_Gateway $payment_gateway, string $country_code = '' ): array {
		return array();
	}
}
PK     [1]F4  4  ,  Admin/Settings/PaymentsProviders/Tilopay.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * Tilopay payment gateway provider class.
 *
 * This class handles all the custom logic for the Tilopay payment gateway provider.
 */
class Tilopay extends PaymentGateway {

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			return property_exists( $payment_gateway, 'tpay_key' ) && ! empty( $payment_gateway->tpay_key ) &&
				property_exists( $payment_gateway, 'tpay_user' ) && ! empty( $payment_gateway->tpay_user ) &&
				property_exists( $payment_gateway, 'tpay_password' ) && ! empty( $payment_gateway->tpay_password );
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway has an account connected: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_account_connected( $payment_gateway );
	}
}
PK     [1]$    )  Admin/Settings/PaymentsProviders/Visa.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * Visa payment gateway provider class.
 *
 * This class handles all the custom logic for the Visa payment gateway provider.
 */
class Visa extends PaymentGateway {

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			if ( is_callable( array( $payment_gateway, 'get_config_settings' ) ) &&
				defined( 'VISA_ACCEPTANCE_ENVIRONMENT_TEST' ) &&
				defined( 'VISA_ACCEPTANCE_ENVIRONMENT_PRODUCTION' ) ) {
				$settings = $payment_gateway->get_config_settings();

				return is_array( $settings ) && isset( $settings['environment'] ) &&
						( ( \VISA_ACCEPTANCE_ENVIRONMENT_TEST === $settings['environment'] &&
						! empty( $settings['test_merchant_id'] ) &&
						! empty( $settings['test_api_key'] ) &&
						! empty( $settings['test_api_shared_secret'] ) ) ||
						( \VISA_ACCEPTANCE_ENVIRONMENT_PRODUCTION === $settings['environment'] &&
						! empty( $settings['merchant_id'] ) &&
						! empty( $settings['api_key'] ) &&
						! empty( $settings['api_shared_secret'] ) ) );
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway has an account connected: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_account_connected( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode, false otherwise.
	 */
	public function is_in_test_mode( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_visa_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_test_mode( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in dev mode.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in dev mode, false otherwise.
	 */
	public function is_in_dev_mode( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_visa_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_dev_mode( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_visa_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_test_mode_onboarding( $payment_gateway );
	}

	/**
	 * Check if the Visa payment gateway is in test/sandbox mode.
	 *
	 * There are two different environments: test/sandbox and production.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return ?bool True if the payment gateway is in sandbox mode, false otherwise.
	 *               Null if the environment could not be determined.
	 */
	private function is_visa_in_sandbox_mode( WC_Payment_Gateway $payment_gateway ): ?bool {
		try {
			if ( is_callable( array( $payment_gateway, 'get_config_settings' ) ) &&
				defined( 'VISA_ACCEPTANCE_ENVIRONMENT_TEST' ) &&
				defined( 'VISA_ACCEPTANCE_ENVIRONMENT_PRODUCTION' ) ) {
				$settings = $payment_gateway->get_config_settings();

				if ( is_array( $settings ) && isset( $settings['environment'] ) ) {
					if ( \VISA_ACCEPTANCE_ENVIRONMENT_TEST === $settings['environment'] ) {
						return true;
					}
					if ( \VISA_ACCEPTANCE_ENVIRONMENT_PRODUCTION === $settings['environment'] ) {
						return false;
					}
				}
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway is in sandbox mode: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		// Let the caller know that we couldn't determine the environment.
		return null;
	}
}
PK     [1]t)  )  +  Admin/Settings/PaymentsProviders/Klarna.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * Klarna payment gateway provider class.
 *
 * This class handles all the custom logic for the Klarna payment gateway provider.
 */
class Klarna extends PaymentGateway {

	/**
	 * Check if the payment gateway needs setup.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway needs setup, false otherwise.
	 */
	public function needs_setup( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			if ( class_exists( '\KP_Settings_Page' ) &&
				is_callable( '\KP_Settings_Page::get_setting_status' ) ) {

				return ! wc_string_to_bool( \KP_Settings_Page::get_setting_status( 'credentials' ) );
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway needs setup: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::needs_setup( $payment_gateway );
	}
}
PK     [1]heI    5  Admin/Settings/PaymentsProviders/AfterpayClearpay.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * Afterpay and Clearpay payment gateway provider class.
 *
 * This class handles all the custom logic for the Afterpay and Clearpay payment gateway provider.
 */
class AfterpayClearpay extends PaymentGateway {

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			if ( is_callable( array( $payment_gateway, 'get_merchant_id' ) ) &&
				is_callable( array( $payment_gateway, 'get_secret_key' ) ) ) {
				return ! empty( $payment_gateway->get_merchant_id() ) &&
					! empty( $payment_gateway->get_secret_key() );
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway has an account connected: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_account_connected( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode.
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode, false otherwise.
	 */
	public function is_in_test_mode( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_afterpay_clearpay_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_test_mode( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in dev mode.
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in dev mode, false otherwise.
	 */
	public function is_in_dev_mode( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_afterpay_clearpay_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_dev_mode( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_afterpay_clearpay_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_test_mode_onboarding( $payment_gateway );
	}

	/**
	 * Check if the Afterpay/Clearpay payment gateway is in sandbox mode.
	 *
	 * There are two different environments: sandbox and production.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return ?bool True if the payment gateway is in sandbox mode, false otherwise.
	 *               Null if the environment could not be determined.
	 */
	private function is_afterpay_clearpay_in_sandbox_mode( WC_Payment_Gateway $payment_gateway ): ?bool {
		try {
			if ( is_callable( array( $payment_gateway, 'get_api_env' ) ) ) {
				return 'production' !== $payment_gateway->get_api_env();
			}
		} catch ( \Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway is in sandbox mode: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		// Let the caller know that we couldn't determine the environment.
		return null;
	}
}
PK     [1]I    .  Admin/Settings/PaymentsProviders/PayUIndia.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * PayU India payment gateway provider class.
 *
 * This class handles all the custom logic for the PayU India payment gateway provider.
 */
class PayUIndia extends PaymentGateway {

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			return ! empty( $payment_gateway->get_option( 'currency1_payu_key' ) ) && ! empty( $payment_gateway->get_option( 'currency1_payu_salt' ) );
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway has an account connected: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_account_connected( $payment_gateway );
	}
}
PK     [1]Vq    3  Admin/Settings/PaymentsProviders/PaymentGateway.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;
use Automattic\WooCommerce\Internal\Admin\Settings\Payments;
use Automattic\WooCommerce\Internal\Admin\Settings\Utils;
use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Throwable;
use WC_HTTPS;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * The payment gateway provider class to handle all payment gateways that don't have a dedicated class.
 *
 * Extend this class for introducing gateway-specific behavior.
 */
class PaymentGateway {

	// This is the default onboarding type for all gateways.
	// It means that the payment extension will handle the onboarding.
	const ONBOARDING_TYPE_EXTERNAL = 'external';

	// This is the onboarding type for gateways that have a WooCommerce-tailored onboarding flow.
	// This might mean just having the payment methods select step in the WooCommerce settings.
	const ONBOARDING_TYPE_NATIVE = 'native';

	// This is the onboarding type for gateways that have a WooCommerce in-context onboarding flow.
	const ONBOARDING_TYPE_NATIVE_IN_CONTEXT = 'native_in_context';

	// Payment method categories to inform the UI about grouping or the emphasis of payment methods.
	const PAYMENT_METHOD_CATEGORY_PRIMARY   = 'primary';
	const PAYMENT_METHOD_CATEGORY_SECONDARY = 'secondary';

	/**
	 * The LegacyProxy instance.
	 *
	 * @var LegacyProxy
	 */
	protected LegacyProxy $proxy;

	/**
	 * Constructor.
	 *
	 * @param LegacyProxy $proxy The LegacyProxy instance.
	 */
	public function __construct( LegacyProxy $proxy ) {
		$this->proxy = $proxy;
	}

	/**
	 * Extract the payment gateway provider details from the object.
	 *
	 * @param WC_Payment_Gateway $gateway      The payment gateway object.
	 * @param int                $order        Optional. The order to assign.
	 *                                         Defaults to 0 if not provided.
	 * @param string             $country_code Optional. The country code for which the details are being gathered.
	 *                                         This should be an ISO 3166-1 alpha-2 country code.
	 *
	 * @return array The payment gateway provider details.
	 */
	public function get_details( WC_Payment_Gateway $gateway, int $order = 0, string $country_code = '' ): array {
		$onboarding_supported = $this->is_onboarding_supported( $gateway, $country_code ) ?? true; // Assume supported if unknown.

		return array(
			'id'          => $gateway->id,
			'_order'      => $order,
			'title'       => $this->get_title( $gateway ),
			'description' => $this->get_description( $gateway ),
			'icon'        => $this->get_icon( $gateway ),
			'supports'    => $this->get_supports_list( $gateway ),
			'links'       => $this->get_provider_links( $gateway, $country_code ),
			'state'       => array(
				'enabled'           => $this->is_enabled( $gateway ),
				'account_connected' => $this->is_account_connected( $gateway ),
				'needs_setup'       => $this->needs_setup( $gateway ),
				'test_mode'         => $this->is_in_test_mode( $gateway ),
				'dev_mode'          => $this->is_in_dev_mode( $gateway ),
			),
			'management'  => array(
				'_links' => array(
					'settings' => array(
						'href' => $this->get_settings_url( $gateway ),
					),
				),
			),
			'onboarding'  => array(
				'type'                        => self::ONBOARDING_TYPE_EXTERNAL,
				'state'                       => array(
					'supported' => $onboarding_supported,
					'started'   => $this->is_onboarding_started( $gateway ),
					'completed' => $this->is_onboarding_completed( $gateway ),
					'test_mode' => $this->is_in_test_mode_onboarding( $gateway ),
				),
				'messages'                    => array(
					'not_supported' => ! $onboarding_supported ? $this->get_onboarding_not_supported_message( $gateway, $country_code ) : null,
				),
				'_links'                      => array(
					'onboard' => array(
						'href' => $this->get_onboarding_url( $gateway ),
					),
				),
				'recommended_payment_methods' => $this->get_recommended_payment_methods( $gateway, $country_code ),
			),
			'plugin'      => $this->get_plugin_details( $gateway ),
		);
	}

	/**
	 * Enhance this provider's payment extension suggestion with additional information.
	 *
	 * The details added do not require the payment extension to be active or a gateway instance.
	 *
	 * @param array $extension_suggestion The extension suggestion details.
	 *
	 * @return array The enhanced payment extension suggestion details.
	 */
	public function enhance_extension_suggestion( array $extension_suggestion ): array {
		if ( empty( $extension_suggestion['onboarding'] ) || ! is_array( $extension_suggestion['onboarding'] ) ) {
			$extension_suggestion['onboarding'] = array();
		}

		if ( ! isset( $extension_suggestion['onboarding']['type'] ) ) {
			$extension_suggestion['onboarding']['type'] = self::ONBOARDING_TYPE_EXTERNAL;
		}

		return $extension_suggestion;
	}

	/**
	 * Get the provider title of the payment gateway.
	 *
	 * This is the intended gateway title to use throughout the WC admin. It should be short.
	 *
	 * Note: We don't allow HTML tags in the title. All HTML tags will be stripped, including their contents.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return string The provider title of the payment gateway.
	 */
	public function get_title( WC_Payment_Gateway $payment_gateway ): string {
		$title = $payment_gateway->get_method_title();
		// If we still couldn't get the WC admin title, fall back to the main title.
		if ( ! is_string( $title ) || empty( $title ) ) {
			$title = $payment_gateway->get_title();
		}
		// If we still couldn't get the title, return a default value.
		if ( ! is_string( $title ) || empty( $title ) ) {
			return esc_html__( 'Unknown', 'woocommerce' );
		}

		// No HTML tags allowed in the title.
		$title = wp_strip_all_tags( html_entity_decode( $title, ENT_QUOTES | ENT_SUBSTITUTE ), true );

		// Truncate the title.
		return Utils::truncate_with_words( $title, 75 );
	}

	/**
	 * Get the provider description of the payment gateway.
	 *
	 * This is the intended gateway description to use throughout the WC admin. It should be short and to the point.
	 *
	 * Note: We don't allow HTML tags in the description. All HTML tags will be stripped, including their contents.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return string The provider description of the payment gateway.
	 */
	public function get_description( WC_Payment_Gateway $payment_gateway ): string {
		$description = $payment_gateway->get_method_description();
		// If we couldn't get the WC admin description, fall back to the main description.
		if ( ! is_string( $description ) || empty( $description ) ) {
			$description = $payment_gateway->get_description();
		}
		// If we still couldn't get the description, use an empty string since the description is not critical.
		if ( ! is_string( $description ) || empty( $description ) ) {
			return '';
		}

		// No HTML tags allowed in the description.
		$description = wp_strip_all_tags( html_entity_decode( $description, ENT_QUOTES | ENT_SUBSTITUTE ), true );

		// Truncate the description.
		return Utils::truncate_with_words( $description, 130, '…' );
	}

	/**
	 * Get the provider icon URL of the payment gateway.
	 *
	 * We expect to receive a URL to an image file.
	 * If the gateway provides an <img> tag or a list of them, we will fall back to the default payments icon.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return string The provider icon URL of the payment gateway.
	 */
	public function get_icon( WC_Payment_Gateway $payment_gateway ): string {
		$icon_url = $payment_gateway->icon ?? '';
		if ( ! is_string( $icon_url ) || empty( $icon_url ) ) {
			$icon_url = '';
		}

		$icon_url = trim( $icon_url );

		// Test if it actually is a URL as some gateways put an <img> tag or a list of them.
		if ( ! wc_is_valid_url( $icon_url ) ) {
			// Fall back to the default payments icon.
			return plugins_url( 'assets/images/icons/default-payments.svg', WC_PLUGIN_FILE );
		}

		return WC_HTTPS::force_https_url( $icon_url );
	}

	/**
	 * Get the provider supports list of the payment gateway.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return string[] The provider supports list of the payment gateway.
	 */
	public function get_supports_list( WC_Payment_Gateway $payment_gateway ): array {
		$supports_list = $payment_gateway->supports ?? array();
		if ( ! is_array( $supports_list ) ) {
			return array();
		}

		// Sanitize the list to ensure it only contains a list of key-like strings.
		$sanitized_list = array();
		foreach ( $supports_list as $support ) {
			if ( ! is_string( $support ) ) {
				continue;
			}

			$sanitized_list[] = sanitize_key( $support );
		}

		// Ensure the list contains unique values and re-indexed.
		return array_values( array_unique( $sanitized_list ) );
	}

	/**
	 * Get the provider links list.
	 *
	 * These are contextual, in general external links aimed to help the user learn more about the payment provider and
	 * reach out for help.
	 *
	 * Each link is an associative array with '_type' and 'url' keys.
	 * The type is a string indicating the type of link, e.g., 'documentation', 'support', 'pricing', etc.
	 * The only accepted types are the ones documented in the PaymentsProviders::LINK_TYPE_* constants.
	 *
	 * Example:
	 *   array(
	 *     array(
	 *       '_type' => 'documentation',
	 *       'url'   => 'https://example.com/docs',
	 *     ),
	 *     array(
	 *       '_type' => 'support',
	 *       'url'   => 'https://example.com/support',
	 *     ),
	 *   );
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 * @param string             $country_code    Optional. The country code for which the providers are being requested.
	 *                                            This should be an ISO 3166-1 alpha-2 country code.
	 *                                            If invalid, it will be ignored.
	 *
	 * @return array The provider links list. Empty array if none are available or an error occurs.
	 */
	public function get_provider_links( WC_Payment_Gateway $payment_gateway, string $country_code = '' ): array {
		$country_code = strtoupper( sanitize_text_field( $country_code ) );
		// Validate the country code format - expect ISO 3166-1 alpha-2.
		// Empty country code is valid (parameter is optional), so only validate non-empty values.
		if ( '' !== $country_code && ( strlen( $country_code ) !== 2 || ! ctype_upper( $country_code ) ) ) {
			// Log so we can investigate non-empty invalid country codes.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Received invalid country code when getting provider links. Ignoring it.',
				array(
					'gateway' => $payment_gateway->id,
					'source'  => 'settings-payments',
					'country' => $country_code,
				)
			);

			$country_code = '';
		}

		$provider_links = array();

		try {
			// Try to get the links list from the payment gateway if it provides such method.
			if ( method_exists( $payment_gateway, 'get_provider_links' ) &&
				is_callable( array( $payment_gateway, 'get_provider_links' ) ) ) {

					$provider_links = call_user_func(
						array( $payment_gateway, 'get_provider_links' ),
						$country_code
					);

				// Validate and normalize the links list.
				$accepted_types  = array(
					PaymentsProviders::LINK_TYPE_ABOUT,
					PaymentsProviders::LINK_TYPE_DOCS,
					PaymentsProviders::LINK_TYPE_SUPPORT,
					PaymentsProviders::LINK_TYPE_PRICING,
					PaymentsProviders::LINK_TYPE_TERMS,
				);
				$validated_links = array();
				if ( is_array( $provider_links ) ) {
					foreach ( $provider_links as $link ) {
						if ( ! is_array( $link ) ) {
							continue;
						}

						$type = ( isset( $link['_type'] ) && is_scalar( $link['_type'] ) ) ? sanitize_key( (string) $link['_type'] ) : '';
						if ( empty( $type ) || ! in_array( $type, $accepted_types, true ) ) {
							continue;
						}
						if ( empty( $link['url'] ) || ! is_string( $link['url'] ) || ! wc_is_valid_url( $link['url'] ) ) {
							continue;
						}

						$url = sanitize_url( $link['url'] );

						// Create a unique key for deduplication (type + URL).
						$link_key = $type . '|' . $url;

						// Skip if we already have this exact link.
						if ( isset( $validated_links[ $link_key ] ) ) {
							continue;
						}

						$validated_links[ $link_key ] = array(
							'_type' => $type,
							'url'   => $url,
						);
					}
				}

				$provider_links = array_values( $validated_links );
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to get provider links: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);

			return array();
		}

		return $provider_links;
	}

	/**
	 * Check if the payment gateway is enabled.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is enabled, false otherwise.
	 */
	public function is_enabled( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			return wc_string_to_bool( $payment_gateway->enabled ?? 'no' );
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway is enabled: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		// If we reach here, just assume that the gateway is not enabled.
		return false;
	}

	/**
	 * Check if the payment gateway needs setup.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway needs setup, false otherwise.
	 */
	public function needs_setup( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			$needs_setup = wc_string_to_bool( $payment_gateway->needs_setup() );
			// If we get a true value, it means the gateway needs setup.
			if ( $needs_setup ) {
				return true;
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway needs setup: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		// If we get a false value, it might mean that it doesn't need setup,
		// but it can also mean that the gateway does not provide the information and just falls back to the default.
		// Check if there is a connected account, as that is the most common indicator of a setup.
		if ( ! $this->is_account_connected( $payment_gateway ) ) {
			return true;
		}

		// If we reach here, just assume that the gateway does not need setup.
		return false;
	}

	/**
	 * Try to determine if the payment gateway is in test mode.
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode, false otherwise.
	 */
	public function is_in_test_mode( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			// Try various gateway methods to check if the payment gateway is in test mode.
			if ( is_callable( array( $payment_gateway, 'is_test_mode' ) ) ) {
				return wc_string_to_bool( $payment_gateway->is_test_mode() );
			}
			if ( is_callable( array( $payment_gateway, 'is_in_test_mode' ) ) ) {
				return wc_string_to_bool( $payment_gateway->is_in_test_mode() );
			}

			// Try various gateway public properties to check if the payment gateway is in test mode.
			if ( isset( $payment_gateway->testmode ) ) {
				return wc_string_to_bool( $payment_gateway->testmode );
			}
			if ( isset( $payment_gateway->test_mode ) ) {
				return wc_string_to_bool( $payment_gateway->test_mode );
			}

			// Try various gateway option entries to check if the payment gateway is in test mode.
			if ( is_callable( array( $payment_gateway, 'get_option' ) ) ) {
				$test_mode = filter_var( $payment_gateway->get_option( 'test_mode', 'not_found' ), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
				if ( ! is_null( $test_mode ) ) {
					return $test_mode;
				}

				$test_mode = filter_var( $payment_gateway->get_option( 'testmode', 'not_found' ), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
				if ( ! is_null( $test_mode ) ) {
					return $test_mode;
				}

				$mode = strtolower( (string) $payment_gateway->get_option( 'mode', 'not_found' ) );
				if ( in_array( $mode, array( 'test', 'sandbox', 'dev' ), true ) ) {
					return true;
				} elseif ( in_array( $mode, array( 'live', 'production', 'prod' ), true ) ) {
					return false;
				}
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway is in test mode: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return false;
	}

	/**
	 * Try to determine if the payment gateway is in dev mode.
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in dev mode, false otherwise.
	 */
	public function is_in_dev_mode( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			// Try various gateway methods to check if the payment gateway is in dev mode.
			if ( is_callable( array( $payment_gateway, 'is_dev_mode' ) ) ) {
				return wc_string_to_bool( $payment_gateway->is_dev_mode() );
			}
			if ( is_callable( array( $payment_gateway, 'is_in_dev_mode' ) ) ) {
				return wc_string_to_bool( $payment_gateway->is_in_dev_mode() );
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway is in dev mode: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return false;
	}

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * Note: Be extra careful if you override this method and rely on needs_setup() since it could lead to an infinite loop.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			if ( method_exists( $payment_gateway, 'is_account_connected' ) && is_callable( array( $payment_gateway, 'is_account_connected' ) ) ) {
				return wc_string_to_bool( $payment_gateway->is_account_connected() );
			}

			if ( method_exists( $payment_gateway, 'is_connected' ) && is_callable( array( $payment_gateway, 'is_connected' ) ) ) {
				return wc_string_to_bool( $payment_gateway->is_connected() );
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway account is connected: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		// Fall back to assuming that it is connected. This is the safest option.
		return true;
	}

	/**
	 * Check if the payment gateway supports the current store state for onboarding.
	 *
	 * Most of the time the current business location should be the main factor, but could also
	 * consider other store settings like currency.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 * @param string             $country_code    Optional. The country code for which to check.
	 *                                            This should be an ISO 3166-1 alpha-2 country code.
	 *
	 * @return bool|null True if the payment gateway supports onboarding, false otherwise.
	 *                   If the payment gateway does not provide the information,
	 *                   we will return null to indicate that we don't know.
	 */
	public function is_onboarding_supported( WC_Payment_Gateway $payment_gateway, string $country_code = '' ): ?bool {
		try {
			if ( method_exists( $payment_gateway, 'is_onboarding_supported' ) &&
				is_callable( array( $payment_gateway, 'is_onboarding_supported' ) ) ) {

				// Call with positional argument; normalize to bool|null.
				$result = call_user_func( array( $payment_gateway, 'is_onboarding_supported' ), $country_code );
				// Preserve null to indicate "unknown" state.
				if ( is_null( $result ) ) {
					return null;
				}
				if ( is_bool( $result ) ) {
					return $result;
				}
				return filter_var( $result, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway supports onboarding: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'country'   => $country_code,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		// If we reach here, just assume that we don't know if the gateway supports onboarding.
		return null;
	}

	/**
	 * Get the message to show when the payment gateway does not support onboarding.
	 *
	 * @see self::is_onboarding_supported()
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 * @param string             $country_code    Optional. The country code for which to check.
	 *                                            This should be an ISO 3166-1 alpha-2 country code.
	 *
	 * @return string|null The message to show when the payment gateway does not support onboarding,
	 *                     or null if no specific message should be provided.
	 */
	public function get_onboarding_not_supported_message( WC_Payment_Gateway $payment_gateway, string $country_code = '' ): ?string {
		try {
			if ( method_exists( $payment_gateway, 'get_onboarding_not_supported_message' ) &&
				is_callable( array( $payment_gateway, 'get_onboarding_not_supported_message' ) ) ) {

				$message = call_user_func( array( $payment_gateway, 'get_onboarding_not_supported_message' ), $country_code, );
				if ( is_string( $message ) && ! empty( $message ) ) {
					return sanitize_textarea_field( trim( $message ) );
				}
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine the gateway onboarding not supported message: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'country'   => $country_code,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		// If we reach here, just assume that no specific message should be provided.
		return null;
	}

	/**
	 * Check if the payment gateway has started the onboarding process.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway has started the onboarding process, false otherwise.
	 *              If the payment gateway does not provide the information,
	 *              it will infer it from having a connected account.
	 */
	public function is_onboarding_started( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			if ( method_exists( $payment_gateway, 'is_onboarding_started' ) && is_callable( array( $payment_gateway, 'is_onboarding_started' ) ) ) {
				return wc_string_to_bool( $payment_gateway->is_onboarding_started() );
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway onboarding started: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		// Fall back to inferring this from having a connected account.
		return $this->is_account_connected( $payment_gateway );
	}

	/**
	 * Check if the payment gateway has completed the onboarding process.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway has completed the onboarding process, false otherwise.
	 *              If the payment gateway does not provide the information,
	 *              it will infer it from having a connected account.
	 */
	public function is_onboarding_completed( WC_Payment_Gateway $payment_gateway ): bool {
		// Sanity check: If the onboarding has not started, it cannot be completed.
		if ( ! $this->is_onboarding_started( $payment_gateway ) ) {
			return false;
		}

		try {
			if ( method_exists( $payment_gateway, 'is_onboarding_completed' ) && is_callable( array( $payment_gateway, 'is_onboarding_completed' ) ) ) {
				return wc_string_to_bool( $payment_gateway->is_onboarding_completed() );
			}

			// Note: This is what WooPayments provides, but it should become standard.
			if ( method_exists( $payment_gateway, 'is_account_partially_onboarded' ) && is_callable( array( $payment_gateway, 'is_account_partially_onboarded' ) ) ) {
				return ! wc_string_to_bool( $payment_gateway->is_account_partially_onboarded() );
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway onboarding is completed: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		// Fall back to inferring this from having a connected account.
		return $this->is_account_connected( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox).
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			// Try various gateway methods to check if the payment gateway is in test mode onboarding.
			if ( method_exists( $payment_gateway, 'is_test_mode_onboarding' ) && is_callable( array( $payment_gateway, 'is_test_mode_onboarding' ) ) ) {
				return wc_string_to_bool( $payment_gateway->is_test_mode_onboarding() );
			}
			if ( method_exists( $payment_gateway, 'is_in_test_mode_onboarding' ) && is_callable( array( $payment_gateway, 'is_in_test_mode_onboarding' ) ) ) {
				return wc_string_to_bool( $payment_gateway->is_in_test_mode_onboarding() );
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway is in test mode onboarding: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return false;
	}

	/**
	 * Get the settings URL for a payment gateway.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return string The settings URL for the payment gateway.
	 */
	public function get_settings_url( WC_Payment_Gateway $payment_gateway ): string {
		try {
			if ( method_exists( $payment_gateway, 'get_settings_url' ) && is_callable( array( $payment_gateway, 'get_settings_url' ) ) ) {
				$url = trim( (string) $payment_gateway->get_settings_url() );
				if ( ! empty( $url ) && ! wc_is_valid_url( $url ) ) {
					// Back-compat: normalize common relative admin URLs.
					$url = ltrim( $url, '/' );
					// Remove the '/wp-admin/' prefix if it exists.
					if ( 0 === strpos( $url, 'wp-admin/' ) ) {
						$url = substr( $url, strlen( 'wp-admin/' ) );
					}
					if ( 0 === strpos( $url, 'admin.php' ) || 0 === strpos( $url, '/admin.php' ) ) {
						$url = admin_url( ltrim( $url, '/' ) );
					}
				}
				if ( ! empty( $url ) && wc_is_valid_url( $url ) ) {
					return add_query_arg(
						array(
							'from' => Payments::FROM_PAYMENTS_SETTINGS,
						),
						$url
					);
				}
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to get gateway settings URL: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		// If we couldn't get a valid settings URL from the gateway, fall back to a general gateway settings URL.
		return Utils::wc_payments_settings_url(
			null,
			array(
				'section' => strtolower( $payment_gateway->id ),
				'from'    => Payments::FROM_PAYMENTS_SETTINGS,
			)
		);
	}

	/**
	 * Get the onboarding URL for the payment gateway.
	 *
	 * This URL should start or continue the onboarding process.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 * @param string             $return_url      Optional. The URL to return to after onboarding.
	 *                                            This will likely get attached to the onboarding URL.
	 *
	 * @return string The onboarding URL for the payment gateway.
	 */
	public function get_onboarding_url( WC_Payment_Gateway $payment_gateway, string $return_url = '' ): string {
		try {
			if ( method_exists( $payment_gateway, 'get_connection_url' ) && is_callable( array( $payment_gateway, 'get_connection_url' ) ) ) {
				// If we received no return URL, we will set the WC Payments Settings page as the return URL.
				$return_url = ! empty( $return_url ) ? $return_url : admin_url( 'admin.php?page=wc-settings&tab=checkout&from=' . Payments::FROM_PROVIDER_ONBOARDING );

				return (string) $payment_gateway->get_connection_url( $return_url );
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to get gateway connection URL: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		// Fall back to pointing users to the payment gateway settings page to handle onboarding.
		return $this->get_settings_url( $payment_gateway );
	}

	/**
	 * Get the plugin details for a payment gateway.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return array The plugin details for the payment gateway.
	 */
	public function get_plugin_details( WC_Payment_Gateway $payment_gateway ): array {
		$entity_type = $this->get_containing_entity_type( $payment_gateway );

		return array(
			'_type'  => $entity_type,
			'slug'   => $this->get_plugin_slug( $payment_gateway ),
			// Only include the plugin file if the entity type is a regular plugin.
			// We don't want to try to change the state of must-use plugins or themes.
			'file'   => PaymentsProviders::EXTENSION_TYPE_WPORG === $entity_type ? $this->get_plugin_file( $payment_gateway ) : '',
			// The gateway's underlying plugin is obviously active (aka the code is running).
			'status' => PaymentsProviders::EXTENSION_ACTIVE,
		);
	}

	/**
	 * Get the source plugin slug of a payment gateway instance.
	 *
	 * It accounts for both regular and must-use plugins.
	 * If the gateway is registered through a theme, it will return the theme slug.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return string The plugin slug of the payment gateway.
	 *                Empty string if a plugin slug could not be determined.
	 */
	public function get_plugin_slug( WC_Payment_Gateway $payment_gateway ): string {
		global $wp_theme_directories;

		// If the payment gateway object has a `plugin_slug` property, use it.
		// This is useful for testing.
		if ( isset( $payment_gateway->plugin_slug ) ) {
			return (string) $payment_gateway->plugin_slug;
		}

		$gateway_class_filename = $this->get_class_filename( $payment_gateway );
		// Bail if we couldn't get the gateway class filename.
		if ( ! is_string( $gateway_class_filename ) ) {
			return '';
		}

		$entity_type = $this->get_containing_entity_type( $payment_gateway );
		// Bail if we couldn't determine the entity type.
		if ( PaymentsProviders::EXTENSION_TYPE_UNKNOWN === $entity_type ) {
			return '';
		}

		if ( PaymentsProviders::EXTENSION_TYPE_THEME === $entity_type ) {
			// Find the theme directory it is part of and extract the slug.
			// This accounts for both parent and child themes.
			if ( is_array( $wp_theme_directories ) ) {
				foreach ( $wp_theme_directories as $dir ) {
					if ( str_starts_with( $gateway_class_filename, $dir ) ) {
						return $this->extract_slug_from_path( substr( $gateway_class_filename, strlen( $dir ) ) );
					}
				}
			}

			// Bail if we couldn't find a match.
			return '';
		}

		// By this point, we know that the payment gateway is part of a plugin.
		// Extract the relative path of the class file to the plugins directory.
		// We account for both regular and must-use plugins.
		$gateway_class_plugins_path = trim( plugin_basename( $gateway_class_filename ), DIRECTORY_SEPARATOR );

		return $this->extract_slug_from_path( $gateway_class_plugins_path );
	}

	/**
	 * Get the corresponding plugin file of the payment gateway, without the .php extension.
	 *
	 * This is useful for using the WP API to change the state of the plugin (activate or deactivate).
	 * We remove the .php extension since the WP API expects plugin files without it.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 * @param string             $plugin_slug     Optional. The payment gateway plugin slug to use directly.
	 *
	 * @return string The plugin file corresponding to the payment gateway plugin. Does not include the .php extension.
	 *                In case of failures, it will return an empty string.
	 */
	public function get_plugin_file( WC_Payment_Gateway $payment_gateway, string $plugin_slug = '' ): string {
		// If the payment gateway object has a `plugin_file` property, use it.
		// This is useful for testing.
		if ( isset( $payment_gateway->plugin_file ) ) {
			$plugin_file = $payment_gateway->plugin_file;
			// Sanity check.
			if ( ! is_string( $plugin_file ) ) {
				return '';
			}
			// Remove the .php extension from the file path. The WP API expects it without it.
			return Utils::trim_php_file_extension( $plugin_file );
		}

		if ( empty( $plugin_slug ) ) {
			$plugin_slug = $this->get_plugin_slug( $payment_gateway );
		}

		// Bail if we couldn't determine the plugin slug.
		if ( empty( $plugin_slug ) ) {
			return '';
		}

		$plugin_file = PluginsHelper::get_plugin_path_from_slug( $plugin_slug );
		// Bail if we couldn't determine the plugin file.
		if ( ! is_string( $plugin_file ) || empty( $plugin_file ) ) {
			return '';
		}

		// Remove the .php extension from the file path. The WP API expects it without it.
		return Utils::trim_php_file_extension( $plugin_file );
	}

	/**
	 * Try and determine a list of recommended payment methods for a payment gateway.
	 *
	 * This data is not always available, and it is up to the payment gateway to provide it.
	 * This is not a definitive list of payment methods that the gateway supports.
	 * The data is aimed at helping the user understand what payment methods are recommended for the gateway
	 * and potentially help them make a decision on which payment methods to enable.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 * @param string             $country_code    Optional. The country code for which to get recommended payment methods.
	 *                                            This should be an ISO 3166-1 alpha-2 country code.
	 *
	 * @return array The recommended payment methods list for the payment gateway.
	 *               Empty array if there are none.
	 */
	public function get_recommended_payment_methods( WC_Payment_Gateway $payment_gateway, string $country_code = '' ): array {
		// Bail if the payment gateway does not implement the method.
		if ( ! method_exists( $payment_gateway, 'get_recommended_payment_methods' ) ||
			! is_callable( array( $payment_gateway, 'get_recommended_payment_methods' ) ) ) {

			return array();
		}

		try {
			// Get the "raw" recommended payment methods from the payment gateway.
			$recommended_pms = call_user_func( array( $payment_gateway, 'get_recommended_payment_methods' ), $country_code );
			if ( ! is_array( $recommended_pms ) ) {
				// Bail if the recommended payment methods are not an array.
				return array();
			}
		} catch ( Throwable $e ) {
			// Log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to get recommended payment methods: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'country'   => $country_code,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);

			return array();
		}

		// Validate the received list items.
		$recommended_pms = array_filter(
			$recommended_pms,
			array( $this, 'validate_recommended_payment_method' )
		);

		// Sort the list.
		$recommended_pms = $this->sort_recommended_payment_methods( $recommended_pms );

		// Extract, standardize, and sanitize the details for each recommended payment method.
		$standardized_pms = array();
		foreach ( $recommended_pms as $index => $recommended_pm ) {
			// Use the index as the order since we sorted (and normalized) the list earlier.
			$standardized_pms[] = $this->standardize_recommended_payment_method( $recommended_pm, $index );
		}

		return $standardized_pms;
	}

	/**
	 * Validate a recommended payment method entry.
	 *
	 * @param mixed $recommended_pm The recommended payment method entry to validate.
	 *
	 * @return bool True if the recommended payment method entry is valid, false otherwise.
	 */
	protected function validate_recommended_payment_method( $recommended_pm ): bool {
		// We require at least `id` and `title`.
		return is_array( $recommended_pm ) &&
				! empty( $recommended_pm['id'] ) &&
				! empty( $recommended_pm['title'] );
	}

	/**
	 * Sort the recommended payment methods.
	 *
	 * @param array $recommended_pms The recommended payment methods list to sort.
	 *
	 * @return array The sorted recommended payment methods list.
	 *               List keys are not preserved.
	 */
	protected function sort_recommended_payment_methods( array $recommended_pms ): array {
		// Sort the recommended payment methods by order/priority, if available.
		usort(
			$recommended_pms,
			function ( $a, $b ) {
				// `order` takes precedence over `priority`.
				// Entries that don't have the order/priority are placed at the end.
				return array( ( $a['order'] ?? PHP_INT_MAX ), ( $a['priority'] ?? PHP_INT_MAX ) ) <=> array( ( $b['order'] ?? PHP_INT_MAX ), ( $b['priority'] ?? PHP_INT_MAX ) );
			}
		);

		return array_values( $recommended_pms );
	}

	/**
	 * Standardize a recommended payment method entry.
	 *
	 * @param array $recommended_pm The recommended payment method entry to standardize.
	 * @param int   $order          Optional. The order of the recommended payment method.
	 *                              Defaults to 0 if not provided.
	 *
	 * @return array The standardized recommended payment method entry.
	 */
	protected function standardize_recommended_payment_method( array $recommended_pm, int $order = 0 ): array {
		$standard_details = array(
			'id'          => sanitize_key( $recommended_pm['id'] ),
			'_order'      => $order,
			// Default to enabled if not explicit.
			'enabled'     => wc_string_to_bool( $recommended_pm['enabled'] ?? true ),
			// Default to not required if not explicit.
			'required'    => wc_string_to_bool( $recommended_pm['required'] ?? false ),
			'title'       => sanitize_text_field( $recommended_pm['title'] ),
			'description' => '',
			'icon'        => '',
			'category'    => self::PAYMENT_METHOD_CATEGORY_PRIMARY, // Default to primary.
		);

		// If the payment method has a description, sanitize it before use.
		if ( ! empty( $recommended_pm['description'] ) ) {
			$standard_details['description'] = (string) $recommended_pm['description'];
			// Make sure that if we have HTML tags, we only allow stylistic tags and anchors.
			if ( preg_match( '/<[^>]+>/', $standard_details['description'] ) ) {
				// Only allow stylistic tags with a few modifications.
				$allowed_tags = wp_kses_allowed_html( 'data' );
				$allowed_tags = array_merge(
					$allowed_tags,
					array(
						'a' => array(
							'href'   => true,
							'target' => true,
						),
					)
				);

				$standard_details['description'] = wp_kses( $standard_details['description'], $allowed_tags );
			}
		}

		// If the payment method has an icon, try to use it.
		if ( ! empty( $recommended_pm['icon'] ) && wc_is_valid_url( $recommended_pm['icon'] ) ) {
			$standard_details['icon'] = sanitize_url( $recommended_pm['icon'] );
		}

		// If the payment method has a category, use it if it's one of the known categories.
		if ( ! empty( $recommended_pm['category'] ) &&
			in_array( $recommended_pm['category'], array( self::PAYMENT_METHOD_CATEGORY_PRIMARY, self::PAYMENT_METHOD_CATEGORY_SECONDARY ), true ) ) {
			$standard_details['category'] = $recommended_pm['category'];
		}

		return $standard_details;
	}

	/**
	 * Get the filename of the payment gateway class.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return string|null The filename of the payment gateway class or null if it cannot be determined.
	 */
	private function get_class_filename( WC_Payment_Gateway $payment_gateway ): ?string {
		// If the payment gateway object has a `class_filename` property, use it.
		// It is only used in development environments (including when running tests).
		if ( isset( $payment_gateway->class_filename ) && in_array( wp_get_environment_type(), array( 'local', 'development' ), true ) ) {
			$class_filename = $payment_gateway->class_filename;
		} else {
			try {
				$reflector      = new \ReflectionClass( get_class( $payment_gateway ) );
				$class_filename = $reflector->getFileName();
			} catch ( Throwable $e ) {
				// Bail but log so we can investigate.
				SafeGlobalFunctionProxy::wc_get_logger()->debug(
					'Failed to get gateway class filename: ' . $e->getMessage(),
					array(
						'gateway'   => $payment_gateway->id,
						'source'    => 'settings-payments',
						'exception' => $e,
					)
				);
				return null;
			}
		}

		// Bail if we couldn't get the gateway class filename.
		if ( ! is_string( $class_filename ) ) {
			return null;
		}

		return $class_filename;
	}

	/**
	 * Get the type of entity the payment gateway class is contained in.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return string The type of extension containing the payment gateway class.
	 */
	private function get_containing_entity_type( WC_Payment_Gateway $payment_gateway ): string {
		global $wp_plugin_paths, $wp_theme_directories;

		// If the payment gateway object has a `extension_type` property, use it.
		// This is useful for testing.
		if ( isset( $payment_gateway->extension_type ) ) {
			// Validate the extension type.
			if ( ! in_array(
				$payment_gateway->extension_type,
				array(
					PaymentsProviders::EXTENSION_TYPE_WPORG,
					PaymentsProviders::EXTENSION_TYPE_MU_PLUGIN,
					PaymentsProviders::EXTENSION_TYPE_THEME,
				),
				true
			) ) {
				return PaymentsProviders::EXTENSION_TYPE_UNKNOWN;
			}

			return $payment_gateway->extension_type;
		}

		$gateway_class_filename = $this->get_class_filename( $payment_gateway );
		// Bail if we couldn't get the gateway class filename.
		if ( ! is_string( $gateway_class_filename ) ) {
			return PaymentsProviders::EXTENSION_TYPE_UNKNOWN;
		}

		// Plugin paths logic closely matches the one in plugin_basename().
		// $wp_plugin_paths contains normalized paths.
		$file = wp_normalize_path( $gateway_class_filename );

		arsort( $wp_plugin_paths );
		// Account for symlinks in the plugin paths.
		foreach ( $wp_plugin_paths as $dir => $realdir ) {
			if ( str_starts_with( $file, $realdir ) ) {
				$gateway_class_filename = $dir . substr( $gateway_class_filename, strlen( $realdir ) );
			}
		}

		// Test for regular plugins.
		if ( str_starts_with( $gateway_class_filename, wp_normalize_path( WP_PLUGIN_DIR ) ) ) {
			// For now, all plugins are considered WordPress.org plugins.
			return PaymentsProviders::EXTENSION_TYPE_WPORG;
		}

		// Test for must-use plugins.
		if ( str_starts_with( $gateway_class_filename, wp_normalize_path( WPMU_PLUGIN_DIR ) ) ) {
			return PaymentsProviders::EXTENSION_TYPE_MU_PLUGIN;
		}

		// Check if it is part of a theme.
		if ( is_array( $wp_theme_directories ) ) {
			foreach ( $wp_theme_directories as $dir ) {
				// Check if the class file is in a theme directory.
				if ( str_starts_with( $gateway_class_filename, $dir ) ) {
					return PaymentsProviders::EXTENSION_TYPE_THEME;
				}
			}
		}

		// Default to an unknown type.
		return PaymentsProviders::EXTENSION_TYPE_UNKNOWN;
	}

	/**
	 * Extract the slug from a given path.
	 *
	 * It can be a directory or file path.
	 * This should be a relative path since the top-level directory or file name will be used as the slug.
	 *
	 * @param string $path The path to extract the slug from.
	 *
	 * @return string The slug extracted from the path.
	 */
	private function extract_slug_from_path( string $path ): string {
		$path = trim( $path );
		$path = trim( $path, DIRECTORY_SEPARATOR );

		// If the path is just a file name, use it as the slug.
		if ( false === strpos( $path, DIRECTORY_SEPARATOR ) ) {
			return Utils::trim_php_file_extension( $path );
		}

		$parts = explode( DIRECTORY_SEPARATOR, $path );
		// Bail if we couldn't get the parts.
		if ( ! is_array( $parts ) ) {
			return '';
		}

		return reset( $parts );
	}
}
PK     [1].T    -  Admin/Settings/PaymentsProviders/Paytrail.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * Paytrail payment gateway provider class.
 *
 * This class handles all the custom logic for the Paytrail payment gateway provider.
 */
class Paytrail extends PaymentGateway {

	/**
	 * Try to determine if the payment gateway is in test mode.
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode, false otherwise.
	 */
	public function is_in_test_mode( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			return wc_string_to_bool( $payment_gateway->get_option( 'enable_test_mode', 'no' ) );
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway is in test mode: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_in_test_mode( $payment_gateway );
	}

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			// When in test mode onboarding, hard coded credentials are used, so we consider it connected.
			if ( $this->is_in_test_mode_onboarding( $payment_gateway ) ) {
				return true;
			}

			return ! empty( $payment_gateway->get_option( 'merchant_id' ) ) &&
				! empty( $payment_gateway->get_option( 'secret_key' ) );
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway has an account connected: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_account_connected( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		// Test mode is actually sandbox mode for Paytrail, affecting the API keys used.
		return $this->is_in_test_mode( $payment_gateway );
	}
}
PK     [1]x\׬    .  Admin/Settings/PaymentsProviders/AmazonPay.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * AmazonPay payment gateway provider class.
 *
 * This class handles all the custom logic for the AmazonPay payment gateway provider.
 */
class AmazonPay extends PaymentGateway {

	/**
	 * Try to determine if the payment gateway is in test mode.
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode, false otherwise.
	 */
	public function is_in_test_mode( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_amazon_pay_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_test_mode( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in dev mode.
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in dev mode, false otherwise.
	 */
	public function is_in_dev_mode( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_amazon_pay_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_dev_mode( $payment_gateway );
	}

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_amazon_pay_onboarded( $payment_gateway ) ?? parent::is_account_connected( $payment_gateway );
	}

	/**
	 * Check if the payment gateway has completed the onboarding process.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway has completed the onboarding process, false otherwise.
	 *              If the payment gateway does not provide the information,
	 *              it will infer it from having a connected account.
	 */
	public function is_onboarding_completed( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_amazon_pay_onboarded( $payment_gateway ) ?? parent::is_onboarding_completed( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_amazon_pay_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_test_mode_onboarding( $payment_gateway );
	}

	/**
	 * Check if the AmazonPay payment gateway is in sandbox mode.
	 *
	 * For AmazonPay, there are two different environments: sandbox and production.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return ?bool True if the payment gateway is in sandbox mode, false otherwise.
	 *               Null if the environment could not be determined.
	 */
	private function is_amazon_pay_in_sandbox_mode( WC_Payment_Gateway $payment_gateway ): ?bool {
		try {
			if ( class_exists( '\WC_Amazon_Payments_Advanced_API' ) &&
				is_callable( '\WC_Amazon_Payments_Advanced_API::get_settings' ) ) {

				$settings = \WC_Amazon_Payments_Advanced_API::get_settings();
				if ( isset( $settings['sandbox'] ) ) {
					return wc_string_to_bool( $settings['sandbox'] );
				}
			}
		} catch ( \Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway is in sandbox mode: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		// Let the caller know that we couldn't determine the environment.
		return null;
	}

	/**
	 * Check if the AmazonPay payment gateway is onboarded.
	 *
	 * For AmazonPay, there are two different environments: sandbox and production.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return ?bool True if the payment gateway is onboarded, false otherwise.
	 *               Null if we failed to determine the onboarding status.
	 */
	private function is_amazon_pay_onboarded( WC_Payment_Gateway $payment_gateway ): ?bool {
		try {
			if ( class_exists( '\WC_Amazon_Payments_Advanced_API' ) &&
				is_callable( '\WC_Amazon_Payments_Advanced_API::validate_api_settings' ) ) {

				return true === \WC_Amazon_Payments_Advanced_API::validate_api_settings();
			}
		} catch ( \Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway is onboarded: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		// Let the caller know that we couldn't determine the onboarding status.
		return null;
	}
}
PK     [1]*a  a  ;  Admin/Settings/PaymentsProviders/PseudoWCPaymentGateway.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

/**
 * Pseudo payment gateway for registering pseudo payment gateways for the settings page.
 *
 * It is similar to the FakePaymentGateway class used for testing purposes.
 *
 * Use it when a certain payment gateway doesn't register properly in the context of the settings page and
 * you need an in-between gateway to handle the settings page logic.
 *
 * @internal
 */
class PseudoWCPaymentGateway extends \WC_Payment_Gateway {
	/**
	 * Gateway ID.
	 *
	 * @var string
	 */
	public $id = '';

	/**
	 * Gateway title.
	 *
	 * @var string
	 */
	public $title = '';

	/**
	 * Gateway description.
	 *
	 * @var string
	 */
	public $description = '';

	/**
	 * Gateway method title.
	 *
	 * @var string
	 */
	public $method_title = '';

	/**
	 * Gateway method description.
	 *
	 * @var string
	 */
	public $method_description = '';

	/**
	 * Corresponding gateway plugin slug.
	 *
	 * @var string
	 */
	public string $plugin_slug = 'generic-plugin-slug';

	/**
	 * Corresponding gateway plugin file.
	 *
	 * Skip the .php extension to match the format used by the WP API.
	 *
	 * @var string
	 */
	public string $plugin_file = 'generic-plugin-slug/generic-plugin-file';

	/**
	 * The recommended payment methods list.
	 *
	 * @var array
	 */
	public array $recommended_payment_methods = array();

	/**
	 * Whether or not this gateway still requires setup to function.
	 *
	 * @var bool
	 */
	public bool $needs_setup = false;

	/**
	 * The test mode.
	 *
	 * @var bool
	 */
	public bool $test_mode = false;

	/**
	 * The dev mode.
	 *
	 * @var bool
	 */
	public bool $dev_mode = false;

	/**
	 * The account connected flag.
	 *
	 * @var bool
	 */
	public bool $account_connected = false;

	/**
	 * The onboarding started flag.
	 *
	 * @var bool
	 */
	public bool $onboarding_started = false;

	/**
	 * The onboarding completed flag.
	 *
	 * @var bool
	 */
	public bool $onboarding_completed = false;

	/**
	 * The test mode onboarding flag.
	 *
	 * @var bool
	 */
	public bool $test_mode_onboarding = false;

	/**
	 * Constructor.
	 *
	 * @param string $id    The gateway ID.
	 * @param array  $props Optional. The gateway properties to apply.
	 */
	public function __construct( string $id, array $props = array() ) {
		$this->id = $id;

		// Go through the props and set them on the object.
		foreach ( $props as $prop => $value ) {
			$this->$prop = $value;
		}
	}

	/**
	 * Return whether or not this gateway still requires setup to function.
	 *
	 * @return bool
	 */
	public function needs_setup() {
		return $this->needs_setup;
	}

	/**
	 * Get the gateway settings page URL.
	 *
	 * @return string The gateway settings page URL.
	 */
	public function get_settings_url(): string {
		if ( isset( $this->settings_url ) ) {
			return $this->settings_url;
		}

		return admin_url( 'admin.php?page=wc-settings&tab=checkout&section=' . strtolower( $this->id ) );
	}

	/**
	 * Get the gateway onboarding start/continue URL.
	 *
	 * @return string The gateway onboarding start/continue URL.
	 */
	public function get_connection_url(): string {
		if ( isset( $this->connection_url ) ) {
			return $this->connection_url;
		}

		return $this->get_settings_url();
	}

	/**
	 * Get the recommended payment methods list.
	 *
	 * @param string $country_code Optional. The business location country code.
	 *
	 * @return array List of recommended payment methods for the given country.
	 */
	public function get_recommended_payment_methods( string $country_code = '' ): array {
		return $this->recommended_payment_methods;
	}

	/**
	 * Check if the gateway is in test mode.
	 *
	 * @return bool True if the gateway is in test mode, false otherwise.
	 */
	public function is_test_mode(): bool {
		return $this->test_mode;
	}

	/**
	 * Check if the gateway is in dev mode.
	 *
	 * @return bool True if the gateway is in dev mode, false otherwise.
	 */
	public function is_dev_mode(): bool {
		return $this->dev_mode;
	}

	/**
	 * Check if the gateway has an account connected.
	 *
	 * @return bool True if the gateway has an account connected, false otherwise.
	 */
	public function is_account_connected(): bool {
		return $this->account_connected;
	}

	/**
	 * Check if the gateway has started onboarding.
	 *
	 * @return bool True if the gateway has started onboarding, false otherwise.
	 */
	public function is_onboarding_started(): bool {
		return $this->onboarding_started;
	}

	/**
	 * Check if the gateway has completed onboarding.
	 *
	 * @return bool True if the gateway has completed onboarding, false otherwise.
	 */
	public function is_onboarding_completed(): bool {
		return $this->onboarding_completed;
	}

	/**
	 * Check if the gateway is in test mode onboarding.
	 *
	 * @return bool True if the gateway is in test mode onboarding, false otherwise.
	 */
	public function is_test_mode_onboarding(): bool {
		return $this->test_mode_onboarding;
	}
}
PK     [1]l;  ;  +  Admin/Settings/PaymentsProviders/WCCore.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use WC_Payment_Gateway;
use WC_Gateway_BACS;
use WC_Gateway_Cheque;
use WC_Gateway_COD;
use WC_Gateway_Paypal;

defined( 'ABSPATH' ) || exit;

/**
 * WooCommerce core payment gateways provider class.
 *
 * This class handles all the custom logic for the payment gateways built into the WC core.
 */
class WCCore extends PaymentGateway {

	/**
	 * Get the provider icon URL of the payment gateway.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return string The provider icon URL of the payment gateway.
	 */
	public function get_icon( WC_Payment_Gateway $payment_gateway ): string {
		// Provide custom icons for core payment gateways.
		switch ( $payment_gateway->id ) {
			case WC_Gateway_BACS::ID:
				return plugins_url( 'assets/images/payment_methods/bacs.svg', WC_PLUGIN_FILE );
			case WC_Gateway_Cheque::ID:
				return plugins_url( 'assets/images/payment_methods/cheque.svg', WC_PLUGIN_FILE );
			case WC_Gateway_COD::ID:
				return plugins_url( 'assets/images/payment_methods/cod.svg', WC_PLUGIN_FILE );
			case WC_Gateway_Paypal::ID:
				return plugins_url( 'assets/images/payment_methods/72x72/paypal.png', WC_PLUGIN_FILE );
		}

		return parent::get_icon( $payment_gateway );
	}

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		// Provide custom account connected logic for core payment gateways.
		switch ( $payment_gateway->id ) {
			case WC_Gateway_BACS::ID:
				// BACS requires bank account details to be set up.
				return property_exists( $payment_gateway, 'account_details' ) && ! empty( $payment_gateway->account_details );
			case WC_Gateway_Cheque::ID:
			case WC_Gateway_COD::ID:
				// There is no account setup for these gateways, so we return true.
				return true;
			case WC_Gateway_Paypal::ID:
				// PayPal requires just an account email address to be set up.
				return property_exists( $payment_gateway, 'email' ) && is_email( $payment_gateway->email );
		}

		return parent::is_account_connected( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		// Provide custom test mode onboarding logic for core payment gateways.
		switch ( $payment_gateway->id ) {
			case WC_Gateway_BACS::ID:
			case WC_Gateway_Cheque::ID:
			case WC_Gateway_COD::ID:
				return false; // These gateways do not have a test mode onboarding.
			case WC_Gateway_Paypal::ID:
				// Test mode is actually sandbox mode for PayPal, affecting the API keys used.
				return $this->is_in_test_mode( $payment_gateway );
		}

		return parent::is_in_test_mode_onboarding( $payment_gateway );
	}

	/**
	 * Get the plugin details for a WC core-provided payment gateway.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return array The plugin details for the payment gateway.
	 */
	public function get_plugin_details( WC_Payment_Gateway $payment_gateway ): array {
		$plugin_details = parent::get_plugin_details( $payment_gateway );

		// Since these are core-provided gateways, we need to make sure that the provider (WC) can't be deactivated.
		// The way to do this is to NOT provide a plugin file path.
		$plugin_details['file'] = '';

		return $plugin_details;
	}
}
PK     [1]!    *  Admin/Settings/PaymentsProviders/Monei.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * Monei payment gateway provider class.
 *
 * This class handles all the custom logic for the Monei payment gateway provider.
 */
class Monei extends PaymentGateway {

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			if ( is_callable( array( $payment_gateway, 'getAccountId' ) ) &&
				is_callable( array( $payment_gateway, 'getApiKey' ) ) ) {
				return ! empty( $payment_gateway->getAccountId() ) &&
						! empty( $payment_gateway->getApiKey() );
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway has an account connected: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_account_connected( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		// Test mode is actually sandbox mode for Monei, affecting the API keys used.
		return $this->is_in_test_mode( $payment_gateway );
	}
}
PK     [1]ނ7  7  -  Admin/Settings/PaymentsProviders/Paystack.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * Paystack payment gateway provider class.
 *
 * This class handles all the custom logic for the Paystack payment gateway provider.
 */
class Paystack extends PaymentGateway {

	/**
	 * Check if the payment gateway needs setup.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway needs setup, false otherwise.
	 */
	public function needs_setup( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			$is_valid_for_use = true;
			if ( is_callable( array( $payment_gateway, 'is_valid_for_use' ) ) ) {
				$is_valid_for_use = wc_string_to_bool( $payment_gateway->is_valid_for_use() );
			}

			return ! $is_valid_for_use || ! $this->is_account_connected( $payment_gateway );
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway needs setup: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::needs_setup( $payment_gateway );
	}

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			return property_exists( $payment_gateway, 'public_key' ) && ! empty( $payment_gateway->public_key ) &&
				property_exists( $payment_gateway, 'secret_key' ) && ! empty( $payment_gateway->secret_key );
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway has an account connected: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_account_connected( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		// Test mode is actually sandbox mode for Paystack, affecting the used API keys.
		return $this->is_in_test_mode( $payment_gateway );
	}
}
PK     [1]h!    .  Admin/Settings/PaymentsProviders/Airwallex.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * Airwallex payment gateway provider class.
 *
 * This class handles all the custom logic for the Airwallex payment gateway provider.
 */
class Airwallex extends PaymentGateway {

	/**
	 * Try to determine if the payment gateway is in test mode.
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode, false otherwise.
	 */
	public function is_in_test_mode( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_airwallex_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_test_mode( $payment_gateway );
	}

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			if ( class_exists( '\Airwallex\Services\Util' ) &&
				is_callable( '\Airwallex\Services\Util::getClientId' ) &&
				is_callable( '\Airwallex\Services\Util::getApiKey' ) &&
				is_callable( '\Airwallex\Services\Util::getWebhookSecret' ) ) {

				return ! empty( \Airwallex\Services\Util::getClientId() ) &&
					! empty( \Airwallex\Services\Util::getApiKey() ) &&
					! empty( \Airwallex\Services\Util::getWebhookSecret() );
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway has an account connected: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_account_connected( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_airwallex_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_test_mode_onboarding( $payment_gateway );
	}

	/**
	 * Check if the Airwallex payment gateway is in sandbox mode.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return ?bool True if the payment gateway is in sandbox mode, false otherwise.
	 *               Null if the environment could not be determined.
	 */
	private function is_airwallex_in_sandbox_mode( WC_Payment_Gateway $payment_gateway ): ?bool {
		try {
			if ( class_exists( '\Airwallex\Services\Util' ) &&
				is_callable( '\Airwallex\Services\Util::getEnvironment' ) ) {

				return 'demo' === \Airwallex\Services\Util::getEnvironment();
			}
		} catch ( \Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway is in sandbox mode: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		// Let the caller know that we couldn't determine the environment.
		return null;
	}
}
PK     [1][    ,  Admin/Settings/PaymentsProviders/Payfast.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * Payfast payment gateway provider class.
 *
 * This class handles all the custom logic for the Payfast payment gateway provider.
 */
class Payfast extends PaymentGateway {

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			return ! empty( $payment_gateway->get_option( 'merchant_id' ) ) &&
					! empty( $payment_gateway->get_option( 'merchant_key' ) ) &&
					! empty( $payment_gateway->get_option( 'pass_phrase' ) ) &&
					! wc_string_to_bool( get_option( 'woocommerce_payfast_invalid_credentials', 'no' ) );
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway has an account connected: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_account_connected( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		// Test mode is actually sandbox mode for Payfast, affecting the API endpoints used.
		return $this->is_in_test_mode( $payment_gateway );
	}
}
PK     [1]?    J  Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsRestController.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\WooPayments;

use Automattic\WooCommerce\Internal\Admin\Settings\Exceptions\ApiException;
use Automattic\WooCommerce\Internal\Admin\Settings\Payments;
use Automattic\WooCommerce\Internal\RestApiControllerBase;
use Automattic\WooCommerce\Internal\Utilities\ArrayUtil;
use Exception;
use WP_Error;
use WP_Http;
use WP_REST_Request;
use WP_REST_Response;

/**
 * Controller for the WooPayments-specific REST endpoints to service the Payments settings page.
 *
 * @internal
 */
class WooPaymentsRestController extends RestApiControllerBase {

	/**
	 * The root namespace for the JSON REST API endpoints.
	 *
	 * @var string
	 */
	protected string $route_namespace = 'wc-admin';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected string $rest_base = 'settings/payments/woopayments';

	/**
	 * The payments settings page service.
	 *
	 * @var Payments
	 */
	private Payments $payments;

	/**
	 * The WooPayments-specific Payments settings page service.
	 *
	 * @var WooPaymentsService
	 */
	private WooPaymentsService $woopayments;

	/**
	 * Get the WooCommerce REST API namespace for the class.
	 *
	 * @return string
	 */
	protected function get_rest_api_namespace(): string {
		return 'wc-admin-settings-payments-woopayments';
	}

	/**
	 * Register the REST API endpoints handled by this controller.
	 *
	 * @param bool $override Whether to override the existing routes. Useful for testing.
	 */
	public function register_routes( bool $override = false ) {
		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/onboarding',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'get_onboarding_details' ),
					'validation_callback' => 'rest_validate_request_arg',
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => array(
						'location' => array(
							'description'       => esc_html__( 'ISO3166 alpha-2 country code. Defaults to the stored providers business location country code.', 'woocommerce' ),
							'type'              => 'string',
							'pattern'           => '[a-zA-Z]{2}', // Two alpha characters.
							'required'          => false,
							'validate_callback' => fn( $value, $request ) => $this->check_location_arg( $value, $request ),
						),
						'source'   => array(
							'description'       => esc_html__( 'The upmost entry point from where the merchant entered the onboarding flow.', 'woocommerce' ),
							'type'              => 'string',
							'required'          => false,
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
				'schema' => fn() => $this->get_schema_for_get_onboarding_details(),
			),
			$override
		);
		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/onboarding/step/(?P<step>[a-zA-Z0-9_-]+)/start',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'handle_onboarding_step_start' ),
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => array(
						'location' => array(
							'description'       => esc_html__( 'ISO3166 alpha-2 country code. Defaults to the stored providers business location country code.', 'woocommerce' ),
							'type'              => 'string',
							'pattern'           => '[a-zA-Z]{2}', // Two alpha characters.
							'required'          => false,
							'validate_callback' => fn( $value, $request ) => $this->check_location_arg( $value, $request ),
						),
						'source'   => array(
							'description'       => esc_html__( 'The upmost entry point from where the merchant entered the onboarding flow.', 'woocommerce' ),
							'type'              => 'string',
							'required'          => false,
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
			),
			$override
		);
		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/onboarding/step/(?P<step>[a-zA-Z0-9_-]+)/save',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'handle_onboarding_step_save' ),
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => array(
						'location' => array(
							'description'       => esc_html__( 'ISO3166 alpha-2 country code. Defaults to the stored providers business location country code.', 'woocommerce' ),
							'type'              => 'string',
							'pattern'           => '[a-zA-Z]{2}', // Two alpha characters.
							'required'          => false,
							'validate_callback' => fn( $value, $request ) => $this->check_location_arg( $value, $request ),
						),
						'source'   => array(
							'description'       => esc_html__( 'The upmost entry point from where the merchant entered the onboarding flow.', 'woocommerce' ),
							'type'              => 'string',
							'required'          => false,
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
			),
			$override
		);
		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/onboarding/step/(?P<step>[a-zA-Z0-9_-]+)/check',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'handle_onboarding_step_check' ),
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => array(
						'location' => array(
							'description'       => esc_html__( 'ISO3166 alpha-2 country code. Defaults to the stored providers business location country code.', 'woocommerce' ),
							'type'              => 'string',
							'pattern'           => '[a-zA-Z]{2}', // Two alpha characters.
							'required'          => false,
							'validate_callback' => fn( $value, $request ) => $this->check_location_arg( $value, $request ),
						),
					),
				),
			),
			$override
		);
		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/onboarding/step/(?P<step>[a-zA-Z0-9_-]+)/finish',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'handle_onboarding_step_finish' ),
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => array(
						'location' => array(
							'description'       => esc_html__( 'ISO3166 alpha-2 country code. Defaults to the stored providers business location country code.', 'woocommerce' ),
							'type'              => 'string',
							'pattern'           => '[a-zA-Z]{2}', // Two alpha characters.
							'required'          => false,
							'validate_callback' => fn( $value, $request ) => $this->check_location_arg( $value, $request ),
						),
						'source'   => array(
							'description'       => esc_html__( 'The upmost entry point from where the merchant entered the onboarding flow.', 'woocommerce' ),
							'type'              => 'string',
							'required'          => false,
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
			),
			$override
		);
		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/onboarding/step/(?P<step>[a-zA-Z0-9_-]+)/clean',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'handle_onboarding_step_clean' ),
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => array(
						'location' => array(
							'description'       => esc_html__( 'ISO3166 alpha-2 country code. Defaults to the stored providers business location country code.', 'woocommerce' ),
							'type'              => 'string',
							'pattern'           => '[a-zA-Z]{2}', // Two alpha characters.
							'required'          => false,
							'validate_callback' => fn( $value, $request ) => $this->check_location_arg( $value, $request ),
						),
					),
				),
			),
			$override
		);
		// Onboarding step specific routes.
		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/onboarding/step/' . WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT . '/init',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'handle_onboarding_test_account_init' ),
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => array(
						'location' => array(
							'description'       => esc_html__( 'ISO3166 alpha-2 country code. Defaults to the stored providers business location country code.', 'woocommerce' ),
							'type'              => 'string',
							'pattern'           => '[a-zA-Z]{2}', // Two alpha characters.
							'required'          => false,
							'validate_callback' => fn( $value, $request ) => $this->check_location_arg( $value, $request ),
						),
						'source'   => array(
							'description'       => esc_html__( 'The upmost entry point from where the merchant entered the onboarding flow.', 'woocommerce' ),
							'type'              => 'string',
							'required'          => false,
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
			),
			$override
		);
		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/onboarding/step/' . WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT . '/reset',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'handle_onboarding_test_account_reset' ),
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => array(
						'location' => array(
							'description'       => esc_html__( 'ISO3166 alpha-2 country code. Defaults to the stored providers business location country code.', 'woocommerce' ),
							'type'              => 'string',
							'pattern'           => '[a-zA-Z]{2}', // Two alpha characters.
							'required'          => false,
							'validate_callback' => fn( $value, $request ) => $this->check_location_arg( $value, $request ),
						),
						'source'   => array(
							'description'       => esc_html__( 'The upmost entry point from where the merchant entered the onboarding flow.', 'woocommerce' ),
							'type'              => 'string',
							'required'          => false,
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
			),
			$override
		);
		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/onboarding/step/' . WooPaymentsService::ONBOARDING_STEP_BUSINESS_VERIFICATION . '/kyc_session',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'handle_onboarding_business_verification_kyc_session_init' ),
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => array(
						'location' => array(
							'description'       => esc_html__( 'ISO3166 alpha-2 country code. Defaults to the stored providers business location country code.', 'woocommerce' ),
							'type'              => 'string',
							'pattern'           => '[a-zA-Z]{2}', // Two alpha characters.
							'required'          => false,
							'validate_callback' => fn( $value, $request ) => $this->check_location_arg( $value, $request ),
						),
						'source'   => array(
							'description'       => esc_html__( 'The upmost entry point from where the merchant entered the onboarding flow.', 'woocommerce' ),
							'type'              => 'string',
							'required'          => false,
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
			),
			$override
		);
		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/onboarding/step/' . WooPaymentsService::ONBOARDING_STEP_BUSINESS_VERIFICATION . '/kyc_session/finish',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'handle_onboarding_business_verification_kyc_session_finish' ),
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => array(
						'location' => array(
							'description'       => esc_html__( 'ISO3166 alpha-2 country code. Defaults to the stored providers business location country code.', 'woocommerce' ),
							'type'              => 'string',
							'pattern'           => '[a-zA-Z]{2}', // Two alpha characters.
							'required'          => false,
							'validate_callback' => fn( $value, $request ) => $this->check_location_arg( $value, $request ),
						),
						'source'   => array(
							'description'       => esc_html__( 'The upmost entry point from where the merchant entered the onboarding flow.', 'woocommerce' ),
							'type'              => 'string',
							'required'          => false,
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
			),
			$override
		);
		// This is a route to disable test accounts for the native onboarding UX.
		// The handler is the same as the one for the non-native onboarding UX.
		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/onboarding/step/' . WooPaymentsService::ONBOARDING_STEP_BUSINESS_VERIFICATION . '/test_account/disable',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'handle_test_account_disable' ),
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => array(
						'location' => array(
							'description'       => esc_html__( 'ISO3166 alpha-2 country code. Defaults to the stored providers business location country code.', 'woocommerce' ),
							'type'              => 'string',
							'pattern'           => '[a-zA-Z]{2}', // Two alpha characters.
							'required'          => false,
							'validate_callback' => fn( $value, $request ) => $this->check_location_arg( $value, $request ),
						),
						'from'     => array(
							'description'       => esc_html__( 'Where from in the onboarding flow this request was triggered.', 'woocommerce' ),
							'type'              => 'string',
							'required'          => false,
							'sanitize_callback' => 'sanitize_text_field',
						),
						'source'   => array(
							'description'       => esc_html__( 'The upmost entry point from where the merchant entered the onboarding flow.', 'woocommerce' ),
							'type'              => 'string',
							'required'          => false,
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
			),
			$override
		);
		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/onboarding/preload',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'handle_onboarding_preload' ),
					'validation_callback' => 'rest_validate_request_arg',
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => array(
						'location' => array(
							'description'       => esc_html__( 'ISO3166 alpha-2 country code. Defaults to the stored providers business location country code.', 'woocommerce' ),
							'type'              => 'string',
							'pattern'           => '[a-zA-Z]{2}', // Two alpha characters.
							'required'          => false,
							'validate_callback' => fn( $value, $request ) => $this->check_location_arg( $value, $request ),
						),
					),
				),
			),
			$override
		);
		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/onboarding/reset',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'reset_onboarding' ),
					'validation_callback' => 'rest_validate_request_arg',
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => array(
						'location' => array(
							'description'       => esc_html__( 'ISO3166 alpha-2 country code. Defaults to the stored providers business location country code.', 'woocommerce' ),
							'type'              => 'string',
							'pattern'           => '[a-zA-Z]{2}', // Two alpha characters.
							'required'          => false,
							'validate_callback' => fn( $value, $request ) => $this->check_location_arg( $value, $request ),
						),
						'from'     => array(
							'description'       => esc_html__( 'Where from in the onboarding flow this request was triggered.', 'woocommerce' ),
							'type'              => 'string',
							'required'          => false,
							'sanitize_callback' => 'sanitize_text_field',
						),
						'source'   => array(
							'description'       => esc_html__( 'The upmost entry point from where the merchant entered the onboarding flow.', 'woocommerce' ),
							'type'              => 'string',
							'required'          => false,
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
			),
			$override
		);
		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/woopay-eligibility',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'get_woopay_eligibility' ),
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
				),
			),
			$override
		);
		// This is the route to disable test accounts when not in a native in-context UX.
		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/onboarding/test_account/disable',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'handle_test_account_disable' ),
					'validation_callback' => 'rest_validate_request_arg',
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => array(
						'location' => array(
							'description'       => esc_html__( 'ISO3166 alpha-2 country code. Defaults to the stored providers business location country code.', 'woocommerce' ),
							'type'              => 'string',
							'pattern'           => '[a-zA-Z]{2}', // Two alpha characters.
							'required'          => false,
							'validate_callback' => fn( $value, $request ) => $this->check_location_arg( $value, $request ),
						),
						'from'     => array(
							'description'       => esc_html__( 'Where from in the onboarding flow this request was triggered.', 'woocommerce' ),
							'type'              => 'string',
							'required'          => false,
							'sanitize_callback' => 'sanitize_text_field',
						),
						'source'   => array(
							'description'       => esc_html__( 'The upmost entry point from where the merchant entered the onboarding flow.', 'woocommerce' ),
							'type'              => 'string',
							'required'          => false,
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
			),
			$override
		);
	}

	/**
	 * Get the controller's REST URL path.
	 *
	 * @param string $relative_path Optional. Relative path to append to the REST URL.
	 *
	 * @return string The REST URL path.
	 */
	public function get_rest_url_path( string $relative_path = '' ): string {
		$path = '/' . trim( $this->route_namespace, '/' ) . '/' . trim( $this->rest_base, '/' );
		if ( ! empty( $relative_path ) ) {
			$path .= '/' . ltrim( $relative_path, '/' );
		}

		return $path;
	}

	/**
	 * Initialize the class instance.
	 *
	 * @param Payments           $payments    The general payments settings page service.
	 * @param WooPaymentsService $woopayments The WooPayments-specific Payments settings page service.
	 *
	 * @internal
	 */
	final public function init( Payments $payments, WooPaymentsService $woopayments ): void {
		$this->payments    = $payments;
		$this->woopayments = $woopayments;
	}

	/**
	 * Get the onboarding details for the given location.
	 *
	 * @param WP_REST_Request $request The request object.
	 * @return WP_Error|WP_REST_Response The response or error.
	 */
	protected function get_onboarding_details( WP_REST_Request $request ) {
		$location = $request->get_param( 'location' );
		if ( empty( $location ) ) {
			// Fall back to the providers country if no location is provided.
			$location = $this->payments->get_country();
		}

		$source = $request->get_param( 'source' );

		try {
			$onboarding_details = $this->woopayments->get_onboarding_details( $location, $this->get_rest_url_path( 'onboarding' ), $source );
		} catch ( ApiException $e ) {
			return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
		} catch ( Exception $e ) {
			return new WP_Error( 'woocommerce_rest_woopayments_onboarding_error', $e->getMessage(), array( 'status' => WP_Http::INTERNAL_SERVER_ERROR ) );
		}

		return rest_ensure_response( $this->prepare_onboarding_details_response( $onboarding_details ) );
	}

	/**
	 * Handle the onboarding step start action.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_Error|WP_REST_Response The response or error.
	 */
	protected function handle_onboarding_step_start( WP_REST_Request $request ) {
		$step_id = $request->get_param( 'step' ) ?? '';

		$location = $request->get_param( 'location' );
		if ( empty( $location ) ) {
			// Fall back to the providers country if no location is provided.
			$location = $this->payments->get_country();
		}

		$source = $request->get_param( 'source' );

		try {
			$previous_status = $this->woopayments->get_onboarding_step_status( $step_id, $location );

			$this->woopayments->mark_onboarding_step_started( $step_id, $location, false, $source );

			$response = array(
				'success'         => true,
				'previous_status' => $previous_status,
				'current_status'  => $this->woopayments->get_onboarding_step_status( $step_id, $location ),
			);
		} catch ( ApiException $e ) {
			return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
		}

		return rest_ensure_response( $response );
	}

	/**
	 * Handle the onboarding step save action.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_Error|WP_REST_Response The response.
	 */
	protected function handle_onboarding_step_save( WP_REST_Request $request ) {
		$step_id = $request->get_param( 'step' ) ?? '';

		$location = $request->get_param( 'location' );
		if ( empty( $location ) ) {
			// Fall back to the providers country if no location is provided.
			$location = $this->payments->get_country();
		}

		$source = $request->get_param( 'source' );

		try {
			$this->woopayments->onboarding_step_save( $step_id, $location, $request->get_params() );

			// If some step data was saved, we also ensure that the step is marked as started, if not already.
			// This way we maintain onboarding state consistency if the frontend does not call the start endpoint.
			$this->woopayments->mark_onboarding_step_started( $step_id, $location, false, $source );
		} catch ( ApiException $e ) {
			return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
		}

		return rest_ensure_response( array( 'success' => true ) );
	}

	/**
	 * Handle the onboarding step check action.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_Error|WP_REST_Response The response or error.
	 */
	protected function handle_onboarding_step_check( WP_REST_Request $request ) {
		$step_id = $request->get_param( 'step' ) ?? '';

		$location = $request->get_param( 'location' );
		if ( empty( $location ) ) {
			// Fall back to the providers country if no location is provided.
			$location = $this->payments->get_country();
		}

		try {
			$result = $this->woopayments->onboarding_step_check( $step_id, $location );
		} catch ( ApiException $e ) {
			return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
		}

		// Merge the result with the success flag.
		$response = array_merge( array( 'success' => true ), $result );

		return rest_ensure_response( $response );
	}

	/**
	 * Handle the onboarding step finish action.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_Error|WP_REST_Response The response or error.
	 */
	protected function handle_onboarding_step_finish( WP_REST_Request $request ) {
		$step_id = $request->get_param( 'step' ) ?? '';

		$location = $request->get_param( 'location' );
		if ( empty( $location ) ) {
			// Fall back to the providers country if no location is provided.
			$location = $this->payments->get_country();
		}

		$source = $request->get_param( 'source' );

		try {
			$previous_status = $this->woopayments->get_onboarding_step_status( $step_id, $location );

			$this->woopayments->mark_onboarding_step_completed( $step_id, $location, false, $source );

			$response = array(
				'success'         => true,
				'previous_status' => $previous_status,
				'current_status'  => $this->woopayments->get_onboarding_step_status( $step_id, $location ),
			);
		} catch ( ApiException $e ) {
			return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
		}

		return rest_ensure_response( $response );
	}

	/**
	 * Handle the onboarding step clean action.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_Error|WP_REST_Response The response or error.
	 */
	protected function handle_onboarding_step_clean( WP_REST_Request $request ) {
		$step_id = $request->get_param( 'step' ) ?? '';

		$location = $request->get_param( 'location' );
		if ( empty( $location ) ) {
			// Fall back to the providers country if no location is provided.
			$location = $this->payments->get_country();
		}

		try {
			$previous_status = $this->woopayments->get_onboarding_step_status( $step_id, $location );

			$this->woopayments->clean_onboarding_step_progress( $step_id, $location );

			$response = array(
				'success'         => true,
				'previous_status' => $previous_status,
				'current_status'  => $this->woopayments->get_onboarding_step_status( $step_id, $location ),
			);
		} catch ( ApiException $e ) {
			return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
		}

		return rest_ensure_response( $response );
	}

	/**
	 * Handle the onboarding test account initialize action.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_Error|WP_REST_Response The response or error.
	 */
	protected function handle_onboarding_test_account_init( WP_REST_Request $request ) {
		$location = $request->get_param( 'location' );
		if ( empty( $location ) ) {
			// Fall back to the providers country if no location is provided.
			$location = $this->payments->get_country();
		}

		$source = $request->get_param( 'source' );

		try {
			// Mark the step as started, if not already.
			$this->woopayments->mark_onboarding_step_started( WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT, $location, false, $source );

			$result = $this->woopayments->onboarding_test_account_init( $location, $source );
		} catch ( ApiException $e ) {
			return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
		}

		return rest_ensure_response(
			array_merge(
				array( 'success' => true ),
				$result
			)
		);
	}

	/**
	 * Handle the onboarding test account reset action.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_Error|WP_REST_Response The response or error.
	 */
	protected function handle_onboarding_test_account_reset( WP_REST_Request $request ) {
		$location = $request->get_param( 'location' );
		if ( empty( $location ) ) {
			// Fall back to the providers country if no location is provided.
			$location = $this->payments->get_country();
		}

		$source = $request->get_param( 'source' );

		// For now, just "forward" the request to the generic onboarding reset endpoint.
		$request->set_param( 'location', $location );
		$request->set_param( 'from', WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT );
		$request->set_param( 'source', $source );
		return $this->reset_onboarding( $request );
	}

	/**
	 * Handle the onboarding business verification step KYC session initialization action.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_Error|WP_REST_Response The response or error.
	 */
	protected function handle_onboarding_business_verification_kyc_session_init( WP_REST_Request $request ) {
		// If we receive self-assessment data with the request, we will use it.
		$self_assessment = ! empty( $request->get_param( 'self_assessment' ) ) ? wc_clean( wp_unslash( $request->get_param( 'self_assessment' ) ) ) : array();

		$location = $request->get_param( 'location' );
		if ( empty( $location ) ) {
			// Fall back to the providers country if no location is provided.
			$location = $this->payments->get_country();
		}

		$source = $request->get_param( 'source' );

		try {
			$account_session = $this->woopayments->get_onboarding_kyc_session( $location, $self_assessment, $source );
		} catch ( ApiException $e ) {
			return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'session' => $account_session,
			)
		);
	}

	/**
	 * Handle the onboarding business verification step KYC session finish action.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_Error|WP_REST_Response The response or error.
	 */
	protected function handle_onboarding_business_verification_kyc_session_finish( WP_REST_Request $request ) {
		$location = $request->get_param( 'location' );
		if ( empty( $location ) ) {
			// Fall back to the providers country if no location is provided.
			$location = $this->payments->get_country();
		}

		$source = $request->get_param( 'source' );

		try {
			$response = $this->woopayments->finish_onboarding_kyc_session( $location, $source );
		} catch ( ApiException $e ) {
			return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
		}

		// If there is no success key in the response, we assume the operation was successful.
		if ( ! isset( $response['success'] ) ) {
			$response['success'] = true;
		}

		return rest_ensure_response( $response );
	}

	/**
	 * Handle the onboarding preload action.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_Error|WP_REST_Response The response or error.
	 */
	protected function handle_onboarding_preload( WP_REST_Request $request ) {
		$location = $request->get_param( 'location' );
		if ( empty( $location ) ) {
			// Fall back to the providers country if no location is provided.
			$location = $this->payments->get_country();
		}

		try {
			$response = $this->woopayments->onboarding_preload( $location );
		} catch ( ApiException $e ) {
			return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
		}

		// If there is no success key in the response, we assume the operation was successful.
		if ( ! isset( $response['success'] ) ) {
			$response['success'] = true;
		}

		return rest_ensure_response( $response );
	}

	/**
	 * Handle the onboarding reset action.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_Error|WP_REST_Response The response or error.
	 */
	protected function reset_onboarding( WP_REST_Request $request ) {
		$location = $request->get_param( 'location' );
		if ( empty( $location ) ) {
			// Fall back to the providers country if no location is provided.
			$location = $this->payments->get_country();
		}

		try {
			$this->woopayments->reset_onboarding( $location, $request->get_param( 'from' ) ?? '', $request->get_param( 'source' ) ?? '' );
		} catch ( ApiException $e ) {
			return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
		}

		return rest_ensure_response(
			array(
				'success' => true,
			)
		);
	}

	/**
	 * Handle the onboarding test mode disable action.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_Error|WP_REST_Response The response or error.
	 */
	protected function handle_test_account_disable( WP_REST_Request $request ) {
		$location = $request->get_param( 'location' );
		if ( empty( $location ) ) {
			// Fall back to the providers country if no location is provided.
			$location = $this->payments->get_country();
		}

		try {
			$this->woopayments->disable_test_account(
				$location,
				$request->get_param( 'from' ) ?? '',
				$request->get_param( 'source' ) ?? ''
			);
		} catch ( ApiException $e ) {
			return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
		}

		return rest_ensure_response(
			array(
				'success' => true,
			)
		);
	}

	/**
	 * Get WooPay eligibility status.
	 *
	 * @return WP_REST_Response The response.
	 */
	protected function get_woopay_eligibility() {
		// We use the Payments Settings stored business location to determine the eligibility.
		$location = $this->payments->get_country();

		$woopay_eligible_countries = array( 'US' );
		$is_eligible               = in_array( $location, $woopay_eligible_countries, true );

		return rest_ensure_response(
			array(
				'is_eligible' => $is_eligible,
			)
		);
	}


	/**
	 * General permissions check for WooPayments settings REST API endpoint.
	 *
	 * @param WP_REST_Request $request The request for which the permission is checked.
	 *
	 * @return bool|WP_Error True if the current user has the capability, otherwise an "Unauthorized" error or False if no error is available for the request method.
	 */
	private function check_permissions( WP_REST_Request $request ) {
		$context = 'read';
		if ( 'POST' === $request->get_method() ) {
			$context = 'edit';
		} elseif ( 'DELETE' === $request->get_method() ) {
			$context = 'delete';
		}

		if ( wc_rest_check_manager_permissions( 'payment_gateways', $context ) ) {
			return true;
		}

		$error_information = $this->get_authentication_error_by_method( $request->get_method() );
		if ( is_null( $error_information ) ) {
			return false;
		}

		return new WP_Error(
			$error_information['code'],
			$error_information['message'],
			array( 'status' => rest_authorization_required_code() )
		);
	}

	/**
	 * Validate the location argument.
	 *
	 * @param mixed           $value   Value of the argument.
	 * @param WP_REST_Request $request The current request object.
	 *
	 * @return WP_Error|true True if the location argument is valid, otherwise a WP_Error object.
	 */
	private function check_location_arg( $value, WP_REST_Request $request ) {
		// If the 'location' argument is not a string return an error.
		if ( ! is_string( $value ) ) {
			return new WP_Error( 'rest_invalid_param', esc_html__( 'The location argument must be a string.', 'woocommerce' ), array( 'status' => 400 ) );
		}

		// Get the registered attributes for this endpoint request.
		$attributes = $request->get_attributes();

		// Grab the location param schema.
		$args = $attributes['args']['location'];

		// If the location param doesn't match the regex pattern then we should return an error as well.
		if ( ! preg_match( '/^' . $args['pattern'] . '$/', $value ) ) {
			return new WP_Error( 'rest_invalid_param', esc_html__( 'The location argument must be a valid ISO3166 alpha-2 country code.', 'woocommerce' ), array( 'status' => 400 ) );
		}

		return true;
	}

	/**
	 * Prepare the response for the GET onboarding details request.
	 *
	 * @param array $response The response to prepare.
	 *
	 * @return array The prepared response.
	 */
	private function prepare_onboarding_details_response( array $response ): array {
		return $this->prepare_onboarding_details_response_recursive( $response, $this->get_schema_for_get_onboarding_details() );
	}

	/**
	 * Recursively prepare the response items for the GET onboarding details request.
	 *
	 * @param mixed $response_item The response item to prepare.
	 * @param array $schema        The schema to use for preparing the response.
	 *
	 * @return mixed The prepared response item.
	 */
	private function prepare_onboarding_details_response_recursive( $response_item, array $schema ) {
		if ( is_null( $response_item ) ) {
			return null;
		}

		if ( ! array_key_exists( 'properties', $schema ) ||
			! is_array( $schema['properties'] ) ) {

			// Filter out null values for loosely defined schema types.
			if ( is_array( $response_item ) ) {
				return ArrayUtil::filter_null_values_recursive( $response_item );
			}
			return $response_item;
		}

		$prepared_response = array();
		foreach ( $schema['properties'] as $key => $property_schema ) {
			if ( is_array( $response_item ) && array_key_exists( $key, $response_item ) ) {
				if ( is_array( $property_schema ) && array_key_exists( 'properties', $property_schema ) ) {
					$prepared_response[ $key ] = $this->prepare_onboarding_details_response_recursive( $response_item[ $key ], $property_schema );
				} elseif ( is_array( $property_schema ) && array_key_exists( 'items', $property_schema ) ) {
					$prepared_response[ $key ] = array_map(
						fn( $item ) => $this->prepare_onboarding_details_response_recursive( $item, $property_schema['items'] ),
						$response_item[ $key ]
					);
				} else {
					$prepared_response[ $key ] = $response_item[ $key ];
				}
			}
		}

		// Ensure the order is the same as in the schema.
		$prepared_response = array_merge( array_fill_keys( array_keys( $schema['properties'] ), null ), $prepared_response );

		// Remove any null values from the response.
		return ArrayUtil::filter_null_values_recursive( $prepared_response );
	}

	/**
	 * Get the schema for the GET onboarding details request.
	 *
	 * @return array[]
	 */
	private function get_schema_for_get_onboarding_details(): array {
		$schema               = array(
			'$schema' => 'http://json-schema.org/draft-04/schema#',
			'title'   => 'WooCommerce Settings Payments WooPayments onboarding details for the given location.',
			'type'    => 'object',
		);
		$schema['properties'] = array(
			'state'    => array(
				'type'        => 'object',
				'description' => esc_html__( 'The general state of the onboarding process.', 'woocommerce' ),
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'properties'  => array(
					'supported' => array(
						'type'        => 'boolean',
						'description' => esc_html__( 'Whether onboarding is supported.', 'woocommerce' ),
						'context'     => array( 'view', 'edit' ),
						'readonly'    => true,
					),
					'started'   => array(
						'type'        => 'boolean',
						'description' => esc_html__( 'Whether the onboarding process is started.', 'woocommerce' ),
						'context'     => array( 'view', 'edit' ),
						'readonly'    => true,
					),
					'completed' => array(
						'type'        => 'boolean',
						'description' => esc_html__( 'Whether the onboarding process is completed.', 'woocommerce' ),
						'context'     => array( 'view', 'edit' ),
						'readonly'    => true,
					),
					'test_mode' => array(
						'type'        => 'boolean',
						'description' => esc_html__( 'Whether the onboarding process is in test mode.', 'woocommerce' ),
						'context'     => array( 'view', 'edit' ),
						'readonly'    => true,
					),
					'dev_mode'  => array(
						'type'        => 'boolean',
						'description' => esc_html__( 'Whether WooPayments is in dev mode.', 'woocommerce' ),
						'context'     => array( 'view', 'edit' ),
						'readonly'    => true,
					),
				),
			),
			'messages' => array(
				'type'                 => 'object',
				'description'          => esc_html__( 'Various messages to possibly show the user.', 'woocommerce' ),
				'context'              => array( 'view', 'edit' ),
				'readonly'             => true,
				'additionalProperties' => array(
					'type'        => 'string',
					'description' => esc_html__( 'Message to show the user.', 'woocommerce' ),
					'readonly'    => true,
				),
			),
			'steps'    => array(
				'type'        => 'array',
				'description' => esc_html__( 'The onboarding steps.', 'woocommerce' ),
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'items'       => array(
					'type'       => 'object',
					'properties' => array(
						'id'             => array(
							'type'        => 'string',
							'description' => esc_html__( 'The unique identifier for the step.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
						'path'           => array(
							'type'        => 'string',
							'description' => esc_html__( 'The relative path of the step to use for frontend navigation.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
						'required_steps' => array(
							'type'        => 'array',
							'description' => esc_html__( 'The steps that are required to be completed before this step.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
							'items'       => array(
								'type' => 'string',
							),
						),
						'status'         => array(
							'type'        => 'string',
							'description' => esc_html__( 'The current status of the step.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
							'enum'        => array(
								WooPaymentsService::ONBOARDING_STEP_STATUS_NOT_STARTED,
								WooPaymentsService::ONBOARDING_STEP_STATUS_STARTED,
								WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED,
							),
						),
						'errors'         => array(
							'type'        => 'array',
							'description' => esc_html__( 'Errors list for the step.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
							'items'       => array(
								'type'       => 'object',
								'properties' => array(
									'code'    => array(
										'type'     => 'string',
										'readonly' => true,
									),
									'message' => array(
										'type'     => 'string',
										'readonly' => true,
									),
									'context' => array(
										'type'     => 'object',
										'readonly' => true,
									),
								),
							),
						),
						'actions'        => array(
							'type'        => 'object',
							'description' => esc_html__( 'The available actions for the step.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
							'properties'  => array(
								'start'                => array(
									'type'        => 'object',
									'description' => esc_html__( 'Action to signal the step start.', 'woocommerce' ),
									'properties'  => $this->get_schema_properties_for_onboarding_step_action(),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
								),
								'save'                 => array(
									'type'        => 'object',
									'description' => esc_html__( 'Action to save step information in the database.', 'woocommerce' ),
									'properties'  => $this->get_schema_properties_for_onboarding_step_action(),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
								),
								'check'                => array(
									'type'        => 'object',
									'description' => esc_html__( 'Action to check the step status.', 'woocommerce' ),
									'properties'  => $this->get_schema_properties_for_onboarding_step_action(),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
								),
								'finish'               => array(
									'type'        => 'object',
									'description' => esc_html__( 'Action to signal the step completion.', 'woocommerce' ),
									'properties'  => $this->get_schema_properties_for_onboarding_step_action(),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
								),
								'clean'                => array(
									'type'        => 'object',
									'description' => esc_html__( 'Action to clean the step progress.', 'woocommerce' ),
									'properties'  => $this->get_schema_properties_for_onboarding_step_action(),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
								),
								'auth'                 => array(
									'type'        => 'object',
									'description' => esc_html__( 'Action to authorize the WPCOM connection.', 'woocommerce' ),
									'properties'  => $this->get_schema_properties_for_onboarding_step_action(),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
								),
								'init'                 => array(
									'type'        => 'object',
									'description' => esc_html__( 'Action to initialize a test account.', 'woocommerce' ),
									'properties'  => $this->get_schema_properties_for_onboarding_step_action(),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
								),
								'kyc_session'          => array(
									'type'        => 'object',
									'description' => esc_html__( 'Action to create or resume an embedded KYC session.', 'woocommerce' ),
									'properties'  => $this->get_schema_properties_for_onboarding_step_action(),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
								),
								'kyc_session_finish'   => array(
									'type'        => 'object',
									'description' => esc_html__( 'Action to finish an embedded KYC session.', 'woocommerce' ),
									'properties'  => $this->get_schema_properties_for_onboarding_step_action(),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
								),
								'kyc_fallback'         => array(
									'type'        => 'object',
									'description' => esc_html__( 'Action to use as a fallback when dealing with errors with the embedded KYC.', 'woocommerce' ),
									'properties'  => $this->get_schema_properties_for_onboarding_step_action(),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
								),
								'reset'                => array(
									'type'        => 'object',
									'description' => esc_html__( 'Action to reset the onboarding process, either partially, for a certain step, or fully.', 'woocommerce' ),
									'properties'  => $this->get_schema_properties_for_onboarding_step_action(),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
								),
								'test_account_disable' => array(
									'type'        => 'object',
									'description' => esc_html__( 'Action to disable the test account currently in use', 'woocommerce' ),
									'properties'  => $this->get_schema_properties_for_onboarding_step_action(),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
								),
							),
						),
						'context'        => array(
							'type'        => 'object',
							'description' => esc_html__( 'Various contextual data for the step to use.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
					),
				),
			),
			'context'  => array(
				'type'        => 'object',
				'description' => esc_html__( 'Various contextual data for the onboarding process to use.', 'woocommerce' ),
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
		);

		return $schema;
	}

	/**
	 * Get the schema properties for an onboarding step action.
	 *
	 * @return array[] The schema properties for an onboarding step action.
	 */
	private function get_schema_properties_for_onboarding_step_action(): array {
		return array(
			'type' => array(
				'type'        => 'string',
				'description' => esc_html__( 'The action type to determine how to use the URL.', 'woocommerce' ),
				'enum'        => array( WooPaymentsService::ACTION_TYPE_REST, WooPaymentsService::ACTION_TYPE_REDIRECT ),
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
			'href' => array(
				'type'        => 'string',
				'description' => esc_html__( 'The URL to use for the action.', 'woocommerce' ),
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
			),
		);
	}
}
PK     [1]8+4Ϙ Ϙ C  Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsService.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\WooPayments;

use Automattic\Jetpack\Connection\Manager as WPCOM_Connection_Manager;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Admin\Settings\Exceptions\ApiArgumentException;
use Automattic\WooCommerce\Internal\Admin\Settings\Exceptions\ApiException;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;
use Automattic\WooCommerce\Internal\Admin\Settings\Utils;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Exception;
use WP_Error;
use WP_Http;

defined( 'ABSPATH' ) || exit;
/**
 * WooPayments-specific Payments settings page service class.
 *
 * @internal
 */
class WooPaymentsService {

	const GATEWAY_ID = 'woocommerce_payments';

	/**
	 * The minimum required version of the WooPayments extension.
	 */
	const EXTENSION_MINIMUM_VERSION = '9.3.0';

	const ONBOARDING_PATH_BASE = '/woopayments/onboarding';

	const ONBOARDING_STEP_PAYMENT_METHODS       = 'payment_methods';
	const ONBOARDING_STEP_WPCOM_CONNECTION      = 'wpcom_connection';
	const ONBOARDING_STEP_TEST_ACCOUNT          = 'test_account';
	const ONBOARDING_STEP_BUSINESS_VERIFICATION = 'business_verification';

	/**
	 * A step is not started if the user has not interacted with it yet.
	 */
	const ONBOARDING_STEP_STATUS_NOT_STARTED = 'not_started';

	/**
	 * A step should be considered started if the user has interacted with it.
	 * There will be cases where a step may be auto-started based on the current state of the store.
	 */
	const ONBOARDING_STEP_STATUS_STARTED = 'started';

	/**
	 * A step is completed if the user has successfully completed it.
	 * This is the final state of a step.
	 */
	const ONBOARDING_STEP_STATUS_COMPLETED = 'completed';

	/**
	 * Failure generally refers to some error that occurred during a step action.
	 * Retrying the action should be possible and lead to a different step status.
	 */
	const ONBOARDING_STEP_STATUS_FAILED = 'failed';

	/**
	 * Blocked generally refers to a step can't progress to a completed state due to some technical requirements
	 * that are beyond the purview of the Payments Settings page or the WooPayments extension.
	 * Most of the time, the reasons will be environment-related.
	 * For example, the store may not use HTTPS, or live onboarding might be prevented due to environment settings.
	 */
	const ONBOARDING_STEP_STATUS_BLOCKED = 'blocked';

	const ACTION_TYPE_REST     = 'REST';
	const ACTION_TYPE_REDIRECT = 'REDIRECT';

	const NOX_PROFILE_OPTION_KEY    = 'woocommerce_woopayments_nox_profile';
	const NOX_ONBOARDING_LOCKED_KEY = 'woocommerce_woopayments_nox_onboarding_locked';
	/**
	 * The TTL for the onboarding lock.
	 * This is to prevent the onboarding from being locked indefinitely in case of uncaught errors.
	 * If the lock timestamp is older than this, we consider the lock expired and allow onboarding actions again.
	 * 2 minutes (120 seconds) should be more than enough for any onboarding action/request to complete.
	 * If at some point we have more complex onboarding actions that may take longer, we can revisit this value,
	 * but we should keep it as low as possible to prevent long lockouts.
	 */
	const NOX_ONBOARDING_LOCKED_TTL_SECONDS = 120;

	const SESSION_ENTRY_DEFAULT = 'settings_payments';
	const SESSION_ENTRY_LYS     = 'lys';

	const FROM_PAYMENT_SETTINGS = 'WCADMIN_PAYMENT_SETTINGS';
	const FROM_NOX_IN_CONTEXT   = 'WCADMIN_NOX_IN_CONTEXT';
	const FROM_KYC              = 'KYC';
	const FROM_WPCOM            = 'WPCOM';

	const WPCOM_CONNECTION_RETURN_PARAM = 'wpcom_connection_return';

	const EVENT_PREFIX = 'settings_payments_woopayments_';

	/**
	 * The PaymentsProviders instance.
	 *
	 * @var PaymentsProviders
	 */
	private PaymentsProviders $payments_providers;

	/**
	 * The LegacyProxy instance.
	 *
	 * @var LegacyProxy
	 */
	private LegacyProxy $proxy;

	/**
	 * The WPCOM connection manager instance.
	 *
	 * @var WPCOM_Connection_Manager|object
	 */
	private $wpcom_connection_manager;

	/**
	 * The WooPayments provider instance.
	 *
	 * @var PaymentsProviders\PaymentGateway
	 */
	private PaymentsProviders\PaymentGateway $provider;

	/**
	 * Initialize the class instance.
	 *
	 * @param PaymentsProviders $payment_providers The PaymentsProviders instance.
	 * @param LegacyProxy       $proxy             The LegacyProxy instance.
	 *
	 * @internal
	 */
	final public function init( PaymentsProviders $payment_providers, LegacyProxy $proxy ): void {
		$this->payments_providers = $payment_providers;
		$this->proxy              = $proxy;

		$this->wpcom_connection_manager = $this->proxy->get_instance_of( WPCOM_Connection_Manager::class, 'woocommerce' );
		$this->provider                 = $this->payments_providers->get_payment_gateway_provider_instance( self::GATEWAY_ID );
	}

	/**
	 * Get the onboarding details for the Payments settings page.
	 *
	 * @param string      $location  The location for which we are onboarding.
	 *                               This is an ISO 3166-1 alpha-2 country code.
	 * @param string      $rest_path The REST API path to use for constructing REST API URLs.
	 * @param string|null $source    Optional. The source for the onboarding flow.
	 *
	 * @return array The onboarding details.
	 * @throws ApiException If the onboarding action can not be performed due to the current state of the site.
	 * @throws Exception If there were errors when generating the onboarding details.
	 */
	public function get_onboarding_details( string $location, string $rest_path, ?string $source = null ): array {
		// Since getting the onboarding details is not idempotent, we will check it as an action.
		$this->check_if_onboarding_action_is_acceptable();

		$source = $this->validate_onboarding_source( $source );

		$gateway = $this->get_payment_gateway();

		$onboarding_supported = $this->provider->is_onboarding_supported( $gateway, $location ) ?? true;
		$onboarding_started   = $this->provider->is_onboarding_started( $gateway );
		if ( ! $onboarding_started && ! empty( $this->get_nox_profile_onboarding( $location ) ) ) {
			// If the onboarding profile is stored, we consider the onboarding started.
			$onboarding_started = true;
		}

		return array(
			// This state is high-level data, independent of the type of onboarding flow.
			'state'    => array(
				'supported' => $onboarding_supported,
				'started'   => $onboarding_started,
				'completed' => $this->provider->is_onboarding_completed( $gateway ),
				'test_mode' => $this->provider->is_in_test_mode_onboarding( $gateway ),
				'dev_mode'  => $this->provider->is_in_dev_mode( $gateway ),
			),
			'messages' => array(
				'not_supported' => ! $onboarding_supported ? $this->provider->get_onboarding_not_supported_message( $gateway, $location ) : null,
			),
			'steps'    => $this->get_onboarding_steps( $location, trailingslashit( $rest_path ) . 'step', $source ),
			'context'  => array(
				'urls' => array(
					'overview_page' => $this->get_overview_page_url(),
				),
			),
		);
	}

	/**
	 * Check if the given onboarding step ID is valid.
	 *
	 * @param string $step_id The ID of the onboarding step.
	 *
	 * @return bool Whether the given onboarding step ID is valid.
	 */
	public function is_valid_onboarding_step_id( string $step_id ): bool {
		return in_array(
			$step_id,
			array(
				self::ONBOARDING_STEP_PAYMENT_METHODS,
				self::ONBOARDING_STEP_WPCOM_CONNECTION,
				self::ONBOARDING_STEP_TEST_ACCOUNT,
				self::ONBOARDING_STEP_BUSINESS_VERIFICATION,
			),
			true
		);
	}

	/**
	 * Get the status of an onboarding step.
	 *
	 * @param string $step_id  The ID of the onboarding step.
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 *
	 * @return string The status of the onboarding step.
	 * @throws ApiArgumentException If the given onboarding step ID is invalid.
	 */
	public function get_onboarding_step_status( string $step_id, string $location ): string {
		if ( ! $this->is_valid_onboarding_step_id( $step_id ) ) {
			throw new ApiArgumentException(
				'woocommerce_woopayments_onboarding_invalid_step_id',
				esc_html__( 'Invalid onboarding step ID.', 'woocommerce' ),
				(int) WP_Http::BAD_REQUEST
			);
		}

		$meets_requirements = $this->check_onboarding_step_requirements( $step_id, $location );

		// First, determine if the step should be reported as completed based on the current state of the store.
		// The step can only be auto-completed if the requirements are met.
		if ( $meets_requirements ) {
			switch ( $step_id ) {
				case self::ONBOARDING_STEP_PAYMENT_METHODS:
					// If there is already a valid account, report the step as completed
					// since allowing the user to configure payment methods won't have any effect.
					if ( $this->has_valid_account() ) {
						return self::ONBOARDING_STEP_STATUS_COMPLETED;
					}
					break;
				case self::ONBOARDING_STEP_WPCOM_CONNECTION:
					// If we have a working WPCOM connection, report the step as completed.
					// The step can only be auto-completed if the requirements are met.
					if ( $this->has_working_wpcom_connection() ) {
						return self::ONBOARDING_STEP_STATUS_COMPLETED;
					}
					break;
				case self::ONBOARDING_STEP_TEST_ACCOUNT:
					// If the account is a valid, working test or sandbox account, the step is completed.
					if ( ( $this->has_test_account() || $this->has_sandbox_account() ) && $this->has_valid_account() && $this->has_working_account() ) {
						// Since it takes a while for the account to be fully working after the test account initialization,
						// we will force mark the step as completed here, if it is not already.
						// This is a fail-safe to guard against the case when the frontend doesn't mark the step as completed.
						// The step has no reason to be blocked or failed.
						$this->clear_onboarding_step_failed( self::ONBOARDING_STEP_TEST_ACCOUNT, $location );
						$this->clear_onboarding_step_blocked( self::ONBOARDING_STEP_TEST_ACCOUNT, $location );
						$this->mark_onboarding_step_completed( self::ONBOARDING_STEP_TEST_ACCOUNT, $location );

						return self::ONBOARDING_STEP_STATUS_COMPLETED;
					}
					break;
				case self::ONBOARDING_STEP_BUSINESS_VERIFICATION:
					// The step can only be auto-completed if the requirements are met.
					// If the current account is fully onboarded and is a live account,
					// we report the business verification step as completed.
					if ( $this->has_valid_account() && $this->has_live_account() ) {
						return self::ONBOARDING_STEP_STATUS_COMPLETED;
					}
					break;
			}
		}

		// Second, try to determine the status of the onboarding step based on the step's stored statuses.
		// We take a waterfall approach: completed > blocked > failed > started > not started.
		// Reporting a completed status involves additional logic.
		switch ( $step_id ) {
			case self::ONBOARDING_STEP_WPCOM_CONNECTION:
				// Ignore any completed stored statuses because of the critical nature of the WPCOM connection.
				break;
			case self::ONBOARDING_STEP_TEST_ACCOUNT:
				// If there is a stored completed status, we respect that IF there is NO invalid test account.
				// This is the case when the user first creates a test account and then switches to live.
				// The step can only be completed if the requirements are met.
				if ( $meets_requirements &&
					$this->was_onboarding_step_marked_completed( $step_id, $location ) &&
					! ( $this->has_test_account() && ! $this->has_valid_account() )
				) {
					return self::ONBOARDING_STEP_STATUS_COMPLETED;
				}
				break;
			case self::ONBOARDING_STEP_BUSINESS_VERIFICATION:
				// The step can only be completed if the requirements are met. Otherwise, ignore the stored completed status.
				// Sanity check: we only report the completed status if there is a live account and the account is valid (i.e. completed KYC).
				if ( $meets_requirements &&
					$this->was_onboarding_step_marked_completed( $step_id, $location ) &&
					$this->has_valid_account() &&
					( $this->has_live_account() || $this->has_sandbox_account() )
				) {
					return self::ONBOARDING_STEP_STATUS_COMPLETED;
				}
				break;
			case self::ONBOARDING_STEP_PAYMENT_METHODS:
			default:
				// The step can only be completed if the requirements are met. Otherwise, ignore the stored completed status.
				if ( $meets_requirements && $this->was_onboarding_step_marked_completed( $step_id, $location ) ) {
					return self::ONBOARDING_STEP_STATUS_COMPLETED;
				}

				break;
		}
		// Blocked and failed statuses are only reported if the step's requirements are met.
		if ( $meets_requirements ) {
			if ( $this->is_onboarding_step_blocked( $step_id, $location ) ) {
				return self::ONBOARDING_STEP_STATUS_BLOCKED;
			}
			if ( $this->is_onboarding_step_failed( $step_id, $location ) ) {
				return self::ONBOARDING_STEP_STATUS_FAILED;
			}
		}
		if ( $this->was_onboarding_step_marked_started( $step_id, $location ) ) {
			// Special treatment for the test account step:
			// If the step was marked as started more than 1 minutes ago (plenty of time for the slowest of webhooks to
			// come through) and it is obviously not completed, and there is no account connected,
			// we will unmark it as started (aka clean its progress). Something went wrong with the step!
			// This is an auto-healing measure to prevent the step from being stuck in a started state indefinitely.
			if ( self::ONBOARDING_STEP_TEST_ACCOUNT === $step_id && ! $this->has_account() ) {
				$statuses          = (array) $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses' );
				$started_timestamp = ! empty( $statuses[ self::ONBOARDING_STEP_STATUS_STARTED ] )
					? (int) $statuses[ self::ONBOARDING_STEP_STATUS_STARTED ]
					: 0;
				if ( $started_timestamp &&
					( $this->proxy->call_function( 'time' ) - $started_timestamp ) > 60 // 1 minute.
				) {
					$this->clean_onboarding_step_progress( $step_id, $location );

					// Record an event for the step being cleaned due to timeout.
					$this->record_event(
						self::EVENT_PREFIX . 'onboarding_step_progress_reset_due_to_timeout',
						$location,
						array(
							'step_id' => $step_id,
						)
					);

					return self::ONBOARDING_STEP_STATUS_NOT_STARTED;
				}
			}

			return self::ONBOARDING_STEP_STATUS_STARTED;
		}

		// Finally, we default to not started.
		return self::ONBOARDING_STEP_STATUS_NOT_STARTED;
	}

	/**
	 * Check if an onboarding step has been marked as started.
	 *
	 * This means that, at some point, the step was marked/recorded as started in the DB.
	 * This doesn't mean that the current reported status is started. The step status might be different now.
	 *
	 * @see get_onboarding_step_status() for that.
	 *
	 * @param string $step_id  The ID of the onboarding step.
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 *
	 * @return bool Whether the onboarding step has been marked as started.
	 */
	private function was_onboarding_step_marked_started( string $step_id, string $location ): bool {
		$statuses = (array) $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses' );

		return ! empty( $statuses[ self::ONBOARDING_STEP_STATUS_STARTED ] );
	}

	/**
	 * Mark an onboarding step as started.
	 *
	 * @param string      $step_id   The ID of the onboarding step.
	 * @param string      $location  The location for which we are onboarding.
	 *                               This is an ISO 3166-1 alpha-2 country code.
	 * @param bool        $overwrite Whether to overwrite the step status if it is already started and update the timestamp.
	 * @param string|null $source    Optional. The source for the current onboarding flow.
	 *                               If not provided, it will identify the source as the WC Admin Payments settings.
	 *
	 * @return bool Whether the onboarding step was marked as started.
	 * @throws ApiArgumentException If the given onboarding step ID is invalid.
	 * @throws ApiException If the onboarding action can not be performed due to the current state of the site.
	 */
	public function mark_onboarding_step_started( string $step_id, string $location, bool $overwrite = false, ?string $source = self::SESSION_ENTRY_DEFAULT ): bool {
		$this->check_if_onboarding_step_action_is_acceptable( $step_id, $location );

		// Clear possible failed status for the step.
		$this->clear_onboarding_step_failed( $step_id, $location );

		$statuses = (array) $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses' );
		if ( ! $overwrite && ! empty( $statuses[ self::ONBOARDING_STEP_STATUS_STARTED ] ) ) {
			return true;
		}

		// Mark the step as started and record the timestamp.
		$statuses[ self::ONBOARDING_STEP_STATUS_STARTED ] = $this->proxy->call_function( 'time' );

		// Store the updated step data.
		$result = $this->save_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses', $statuses );

		if ( $result ) {
			$source = $this->validate_onboarding_source( $source );

			// Record an event for the step being started.
			$this->record_event(
				self::EVENT_PREFIX . 'onboarding_step_started',
				$location,
				array(
					'step_id' => $step_id,
					'source'  => $source,
				)
			);
		}

		return $result;
	}

	/**
	 * Check if the onboarding step has a completed status.
	 *
	 * @param string $step_id  The ID of the onboarding step.
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 *
	 * @return bool Whether the onboarding step is completed.
	 * @throws ApiException On invalid step ID.
	 */
	private function is_onboarding_step_completed( string $step_id, string $location ): bool {
		return self::ONBOARDING_STEP_STATUS_COMPLETED === $this->get_onboarding_step_status( $step_id, $location );
	}

	/**
	 * Check if an onboarding step has been marked as completed.
	 *
	 * This means that, at some point, the step was marked/recorded as completed in the DB.
	 * This doesn't mean that the current reported status is completed. The step status might be different now.
	 *
	 * @see get_onboarding_step_status() for that.
	 *
	 * @param string $step_id  The ID of the onboarding step.
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 *
	 * @return bool Whether the onboarding step has been marked as completed.
	 */
	private function was_onboarding_step_marked_completed( string $step_id, string $location ): bool {
		$statuses = (array) $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses' );

		return ! empty( $statuses[ self::ONBOARDING_STEP_STATUS_COMPLETED ] );
	}

	/**
	 * Mark an onboarding step as completed.
	 *
	 * @param string      $step_id   The ID of the onboarding step.
	 * @param string      $location  The location for which we are onboarding.
	 *                               This is an ISO 3166-1 alpha-2 country code.
	 * @param bool        $overwrite Whether to overwrite the step status if it is already completed and update the timestamp.
	 * @param string|null $source    Optional. The source for the current onboarding flow.
	 *                               If not provided, it will identify the source as the WC Admin Payments settings.
	 *
	 * @return bool Whether the onboarding step was marked as completed.
	 * @throws ApiArgumentException If the given onboarding step ID is invalid.
	 * @throws ApiException If the onboarding action can not be performed due to the current state of the site.
	 */
	public function mark_onboarding_step_completed( string $step_id, string $location, bool $overwrite = false, ?string $source = self::SESSION_ENTRY_DEFAULT ): bool {
		$this->check_if_onboarding_step_action_is_acceptable( $step_id, $location );

		// Clear possible failed status for the step.
		$this->clear_onboarding_step_failed( $step_id, $location );

		$statuses = (array) $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses' );
		if ( ! $overwrite && ! empty( $statuses[ self::ONBOARDING_STEP_STATUS_COMPLETED ] ) ) {
			return true;
		}

		// Mark the step as completed and record the timestamp.
		$statuses[ self::ONBOARDING_STEP_STATUS_COMPLETED ] = $this->proxy->call_function( 'time' );

		// Store the updated step data.
		$result = $this->save_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses', $statuses );

		if ( $result ) {
			$source = $this->validate_onboarding_source( $source );

			// Record an event for the step being completed.
			$this->record_event(
				self::EVENT_PREFIX . 'onboarding_step_completed',
				$location,
				array(
					'step_id' => $step_id,
					'source'  => $source,
				)
			);
		}

		return $result;
	}

	/**
	 * Cleans an onboarding step progress.
	 *
	 * @param string $step_id   The ID of the onboarding step.
	 * @param string $location  The location for which we are onboarding.
	 *                          This is an ISO 3166-1 alpha-2 country code.
	 *
	 * @return bool Whether the onboarding step was cleaned.
	 * @throws ApiArgumentException If the given onboarding step ID is invalid.
	 */
	public function clean_onboarding_step_progress( string $step_id, string $location ): bool {
		// We need to do reduced acceptance checks here because this is a cleanup action.
		// First, check general if the onboarding action is acceptable.
		$this->check_if_onboarding_action_is_acceptable();
		// Second, check if the step ID is valid.
		if ( ! $this->is_valid_onboarding_step_id( $step_id ) ) {
			throw new ApiArgumentException(
				'woocommerce_woopayments_onboarding_invalid_step_id',
				esc_html__( 'Invalid onboarding step ID.', 'woocommerce' ),
				(int) WP_Http::BAD_REQUEST
			);
		}

		// Clear possible failed or blocked status for the step.
		$this->clear_onboarding_step_failed( $step_id, $location );
		$this->clear_onboarding_step_blocked( $step_id, $location );

		// Reset the stored step statuses.
		$result = $this->save_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses', array() );

		if ( $result ) {
			// Record an event for the step being cleaned.
			$this->record_event(
				self::EVENT_PREFIX . 'onboarding_step_progress_reset',
				$location,
				array(
					'step_id' => $step_id,
				)
			);
		}

		return $result;
	}

	/**
	 * Check if an onboarding step has a failed status.
	 *
	 * @param string $step_id  The ID of the onboarding step.
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 *
	 * @return bool Whether the onboarding step is failed.
	 */
	private function is_onboarding_step_failed( string $step_id, string $location ): bool {
		$statuses = (array) $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses' );

		return ! empty( $statuses[ self::ONBOARDING_STEP_STATUS_FAILED ] );
	}

	/**
	 * Mark an onboarding step as failed.
	 *
	 * This is for internal use only as a failed step status should not be the result of a user action.
	 *
	 * @param string $step_id  The ID of the onboarding step.
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 * @param array  $error    Optional. An error to be stored for the step to provide context to API consumers.
	 *                         The error should be an associative array with the following keys:
	 *                         - 'code': A string representing the error code.
	 *                         - 'message': A string representing the error message.
	 *                         - 'context': Optional. An array of additional data related to the error.
	 *
	 * @return bool Whether the onboarding step was marked as failed.
	 */
	private function mark_onboarding_step_failed( string $step_id, string $location, array $error = array() ): bool {
		// There is no need to do onboarding checks because setting a step as failed should be possible at any time.

		// Record the error for the step, even if it is empty.
		// This will ensure we only store the most recent error.
		$this->save_nox_profile_onboarding_step_data_entry( $step_id, $location, 'error', $this->sanitize_onboarding_step_error( $error ) );

		$statuses = (array) $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses' );

		// Mark the step as failed and record the timestamp.
		$statuses[ self::ONBOARDING_STEP_STATUS_FAILED ] = $this->proxy->call_function( 'time' );

		// Make sure we clear the blocked status if it was set since blocked and failed should be mutually exclusive.
		unset( $statuses[ self::ONBOARDING_STEP_STATUS_BLOCKED ] );

		// Store the updated step data.
		$result = $this->save_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses', $statuses );

		if ( $result ) {
			// Record an event for the step being failed.
			$this->record_event(
				self::EVENT_PREFIX . 'onboarding_step_failed',
				$location,
				array(
					'step_id'    => $step_id,
					'error_code' => ! empty( $error['code'] ) ? $error['code'] : '',
				)
			);
		}

		return $result;
	}

	/**
	 * Clear the failed status of an onboarding step.
	 *
	 * @param string $step_id  The ID of the onboarding step.
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 *
	 * @return bool Whether the onboarding step was cleared from failed status.
	 *              Returns false if the step was not failed.
	 */
	private function clear_onboarding_step_failed( string $step_id, string $location ): bool {
		if ( ! $this->is_onboarding_step_failed( $step_id, $location ) ) {
			return false;
		}

		// Clear any error for the step.
		$this->save_nox_profile_onboarding_step_data_entry( $step_id, $location, 'error', array() );

		$statuses = (array) $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses' );

		// Clear the failed status.
		unset( $statuses[ self::ONBOARDING_STEP_STATUS_FAILED ] );

		// Store the updated step data.
		return $this->save_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses', $statuses );
	}

	/**
	 * Check if an onboarding step has a blocked status.
	 *
	 * @param string $step_id The ID of the onboarding step.
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 *
	 * @return bool Whether the onboarding step is blocked.
	 */
	private function is_onboarding_step_blocked( string $step_id, string $location ): bool {
		$statuses = (array) $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses' );

		return ! empty( $statuses[ self::ONBOARDING_STEP_STATUS_BLOCKED ] );
	}

	/**
	 * Mark an onboarding step as blocked.
	 *
	 * This is for internal use only as a blocked step status should not be the result of a user action.
	 *
	 * @param string $step_id  The ID of the onboarding step.
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 * @param array  $errors   Optional. A list of errors to be stored for the step to provide context to API consumers.
	 *
	 * @return bool Whether the onboarding step was marked as blocked.
	 */
	private function mark_onboarding_step_blocked( string $step_id, string $location, array $errors = array() ): bool {
		// There is no need to do onboarding checks because setting a step as blocked should be possible at any time.

		// Record the error for the step, even if it is empty.
		// This will ensure we only store the most recent error.
		$this->save_nox_profile_onboarding_step_data_entry( $step_id, $location, 'error', $this->sanitize_onboarding_step_error( $errors ) );

		$statuses = (array) $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses' );

		// Mark the step as blocked and record the timestamp.
		$statuses[ self::ONBOARDING_STEP_STATUS_BLOCKED ] = $this->proxy->call_function( 'time' );

		// Make sure we clear the failed status if it was set since blocked and failed should be mutually exclusive.
		unset( $statuses[ self::ONBOARDING_STEP_STATUS_FAILED ] );

		// Store the updated step data.
		return $this->save_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses', $statuses );
	}

	/**
	 * Clear the blocked status of an onboarding step.
	 *
	 * @param string $step_id  The ID of the onboarding step.
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 *
	 * @return bool Whether the onboarding step was cleared from blocked status.
	 *              Returns false if the step was not blocked.
	 */
	private function clear_onboarding_step_blocked( string $step_id, string $location ): bool {
		if ( ! $this->is_onboarding_step_blocked( $step_id, $location ) ) {
			return false;
		}

		// Clear any error for the step.
		$this->save_nox_profile_onboarding_step_data_entry( $step_id, $location, 'error', array() );

		$statuses = (array) $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses' );

		// Clear the blocked status.
		unset( $statuses[ self::ONBOARDING_STEP_STATUS_BLOCKED ] );

		// Store the updated step data.
		return $this->save_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses', $statuses );
	}

	/**
	 * Get the current stored error for an onboarding step.
	 *
	 * @param string $step_id  The ID of the onboarding step.
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 *
	 * @return array The error for the onboarding step.
	 */
	private function get_onboarding_step_error( string $step_id, string $location ): array {
		return (array) $this->get_nox_profile_onboarding_step_data_entry( $step_id, $location, 'error', array() );
	}

	/**
	 * Sanitize an error for an onboarding step.
	 *
	 * @param array $error The error to sanitize.
	 *
	 * @return array The sanitized error.
	 */
	private function sanitize_onboarding_step_error( array $error ): array {
		$sanitized_error = array(
			'code'    => isset( $error['code'] ) ? sanitize_text_field( $error['code'] ) : '',
			'message' => isset( $error['message'] ) ? sanitize_text_field( $error['message'] ) : '',
			'context' => array(),
		);

		// Move all extra keys (not code, message, context) into the context.
		$reserved_keys = array( 'code', 'message', 'context' );
		foreach ( $error as $key => $value ) {
			if ( ! in_array( $key, $reserved_keys, true ) ) {
				$sanitized_error['context'][ $key ] = $value;
			}
		}

		// Merge any existing context data.
		if ( isset( $error['context'] ) && ( is_array( $error['context'] ) || is_object( $error['context'] ) ) ) {
			// Make sure we are dealing with an array.
			$existing_context = json_decode( wp_json_encode( $error['context'] ), true );
			if ( is_array( $existing_context ) ) {
				$sanitized_error['context'] = array_merge( $sanitized_error['context'], $existing_context );
			}
		}

		// Flatten any nested 'context' key (e.g., from WP_Error data that includes its own context).
		// The nested context values take precedence over the top-level values.
		if ( isset( $sanitized_error['context']['context'] ) && is_array( $sanitized_error['context']['context'] ) ) {
			$nested_context = $sanitized_error['context']['context'];
			unset( $sanitized_error['context']['context'] );
			$sanitized_error['context'] = array_merge( $sanitized_error['context'], $nested_context );
		}

		if ( ! empty( $sanitized_error['context'] ) ) {

			// Sanitize the context data.
			// It can only contain strings or arrays of strings.
			// Scalar values will be converted to strings. Other types will be ignored.
			foreach ( $sanitized_error['context'] as $key => $value ) {
				if ( is_string( $value ) ) {
					$sanitized_error['context'][ $key ] = sanitize_text_field( $value );
				} elseif ( is_array( $value ) ) {
					// Arrays can only contain strings.
					$sanitized_error['context'][ $key ] = array_map(
						function ( $item ) {
							if ( is_string( $item ) ) {
								return sanitize_text_field( $item );
							} elseif ( is_scalar( $item ) ) {
								return sanitize_text_field( (string) $item );
							} else {
								return '';
							}
						},
						$value
					);
					// Remove any empty values from the array.
					$sanitized_error['context'][ $key ] = array_filter(
						$sanitized_error['context'][ $key ],
						function ( $item ) {
							return '' !== $item;
						}
					);
				} else {
					unset( $sanitized_error['context'][ $key ] );
				}
			}
		}

		return $sanitized_error;
	}

	/**
	 * Save the data for an onboarding step.
	 *
	 * @param string $step_id      The ID of the onboarding step.
	 * @param string $location     The location for which we are onboarding.
	 *                             This is an ISO 3166-1 alpha-2 country code.
	 * @param array  $request_data The entire data received in the request.
	 *
	 * @return bool Whether the onboarding step data was saved.
	 * @throws ApiArgumentException If the given onboarding step ID or step data is invalid.
	 * @throws ApiException If the onboarding action can not be performed due to the current state of the site.
	 */
	public function onboarding_step_save( string $step_id, string $location, array $request_data ): bool {
		$this->check_if_onboarding_step_action_is_acceptable( $step_id, $location );

		// Validate the received step data.
		// If we didn't receive any known data for the step, we consider it an invalid save operation.
		if ( ! $this->is_valid_onboarding_step_data( $step_id, $request_data ) ) {
			throw new ApiArgumentException(
				'woocommerce_woopayments_onboarding_invalid_step_data',
				esc_html__( 'Invalid onboarding step data.', 'woocommerce' ),
				(int) WP_Http::BAD_REQUEST
			);
		}

		$step_details = $this->get_nox_profile_onboarding_step( $step_id, $location );
		if ( empty( $step_details['data'] ) ) {
			$step_details['data'] = array();
		}

		// Extract the data for the step.
		switch ( $step_id ) {
			case self::ONBOARDING_STEP_PAYMENT_METHODS:
				if ( isset( $request_data['payment_methods'] ) ) {
					$step_details['data']['payment_methods'] = $request_data['payment_methods'];
				}
				break;
			case self::ONBOARDING_STEP_BUSINESS_VERIFICATION:
				if ( isset( $request_data['self_assessment'] ) ) {
					$step_details['data']['self_assessment'] = $request_data['self_assessment'];
				}
				if ( isset( $request_data['sub_steps'] ) ) {
					$step_details['data']['sub_steps'] = $request_data['sub_steps'];
				}
				break;
			default:
				throw new ApiException(
					'woocommerce_woopayments_onboarding_step_action_not_supported',
					esc_html__( 'Save action not supported for the onboarding step ID.', 'woocommerce' ),
					(int) WP_Http::NOT_ACCEPTABLE
				);
		}

		// Store the updated step data.
		return $this->save_nox_profile_onboarding_step( $step_id, $location, $step_details );
	}

	/**
	 * Check if the given onboarding step data is valid.
	 *
	 * If we didn't receive any known data for the step, we consider it invalid.
	 *
	 * @param string $step_id      The ID of the onboarding step.
	 * @param array  $request_data The entire data received in the request.
	 *
	 * @return bool Whether the given onboarding step data is valid.
	 */
	private function is_valid_onboarding_step_data( string $step_id, array $request_data ): bool {
		switch ( $step_id ) {
			case self::ONBOARDING_STEP_PAYMENT_METHODS:
				// Check that we have at least one piece of data.
				if ( ! isset( $request_data['payment_methods'] ) ) {
					return false;
				}

				// Check that the data is in the expected format.
				if ( ! is_array( $request_data['payment_methods'] ) ) {
					return false;
				}
				break;
			case self::ONBOARDING_STEP_BUSINESS_VERIFICATION:
				// Check that we have at least one piece of data.
				if ( ! isset( $request_data['self_assessment'] ) &&
					! isset( $request_data['sub_steps'] ) ) {
					return false;
				}

				// Check that the data is in the expected format.
				if ( isset( $request_data['self_assessment'] ) && ! is_array( $request_data['self_assessment'] ) ) {
					return false;
				}
				if ( isset( $request_data['sub_steps'] ) && ! is_array( $request_data['sub_steps'] ) ) {
					return false;
				}
				break;
			default:
				// If we don't know how to validate the data, we assume it is valid.
				return true;
		}

		return true;
	}

	/**
	 * Check an onboarding step's status/progress.
	 *
	 * @param string $step_id The ID of the onboarding step.
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 *
	 * @return array The check result.
	 * @throws ApiArgumentException If the given onboarding step ID or step data is invalid.
	 * @throws ApiException If the onboarding action can not be performed due to the current state of the site.
	 */
	public function onboarding_step_check( string $step_id, string $location ): array {
		$this->check_if_onboarding_step_action_is_acceptable( $step_id, $location );

		return array(
			'status' => $this->get_onboarding_step_status( $step_id, $location ),
			'error'  => $this->get_onboarding_step_error( $step_id, $location ),
		);
	}

	/**
	 * Get the recommended payment methods details for onboarding.
	 *
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 *
	 * @return array The recommended payment methods details.
	 */
	public function get_onboarding_recommended_payment_methods( string $location ): array {
		return $this->provider->get_recommended_payment_methods( $this->get_payment_gateway(), $location );
	}

	/**
	 * Initialize the test account for onboarding.
	 *
	 * @param string      $location The location for which we are onboarding.
	 *                              This is an ISO 3166-1 alpha-2 country code.
	 * @param string|null $source   Optional. The source for the current onboarding flow.
	 *                              If not provided, it will identify the source as the WC Admin Payments settings.
	 *
	 * @return array The result of the test account initialization.
	 * @throws ApiException If the given onboarding step ID or step data is invalid.
	 *                      If the onboarding action can not be performed due to the current state
	 *                      of the site or there was an error initializing the test account.
	 */
	public function onboarding_test_account_init( string $location, ?string $source = self::SESSION_ENTRY_DEFAULT ): array {
		$this->check_if_onboarding_step_action_is_acceptable( self::ONBOARDING_STEP_TEST_ACCOUNT, $location );

		// Nothing to do if we already have a connected test account.
		if ( $this->has_test_account() ) {
			throw new ApiException(
				'woocommerce_woopayments_test_account_already_exists',
				esc_html__( 'A test account is already set up.', 'woocommerce' ),
				(int) WP_Http::FORBIDDEN
			);
		}

		// Nothing to do if there is a connected account, but it is not a test account.
		if ( $this->has_account() ) {
			// Mark the onboarding step as completed, if it is not already.
			$this->mark_onboarding_step_completed( self::ONBOARDING_STEP_TEST_ACCOUNT, $location );

			throw new ApiException(
				'woocommerce_woopayments_onboarding_action_error',
				esc_html__( 'An account is already set up. Reset the onboarding first.', 'woocommerce' ),
				(int) WP_Http::FORBIDDEN
			);
		}

		// Clear any previous failed status for the step.
		$this->clear_onboarding_step_failed( self::ONBOARDING_STEP_TEST_ACCOUNT, $location );

		$configured_payment_methods = $this->get_nox_profile_onboarding_step_data_entry( self::ONBOARDING_STEP_PAYMENT_METHODS, $location, 'payment_methods', array() );

		// Ensure the payment gateways logic is initialized in case actions need to be taken on payment gateway changes.
		WC()->payment_gateways();

		$source = $this->validate_onboarding_source( $source );

		// Lock the onboarding to prevent concurrent actions.
		$this->set_onboarding_lock();

		try {
			// Call the WooPayments API to initialize the test account.
			$response = $this->proxy->call_static(
				Utils::class,
				'rest_endpoint_post_request',
				'/wc/v3/payments/onboarding/test_drive_account/init',
				array(
					'country'      => $location,
					'capabilities' => $configured_payment_methods,
					'source'       => $source,
					'from'         => self::FROM_NOX_IN_CONTEXT,
				)
			);
		} catch ( Exception $e ) {
			// Catch any exceptions to allow for proper error handling and onboarding unlock.
			$response = new WP_Error(
				'woocommerce_woopayments_onboarding_client_api_exception',
				esc_html__( 'An unexpected error happened while initializing the test account.', 'woocommerce' ),
				array(
					'code'    => $e->getCode(),
					'message' => $e->getMessage(),
					'trace'   => $e->getTrace(),
				)
			);
		}

		// Unlock the onboarding after the API call finished or errored.
		$this->clear_onboarding_lock();

		if ( is_wp_error( $response ) ) {
			// Mark the onboarding step as failed.
			$this->mark_onboarding_step_failed(
				self::ONBOARDING_STEP_TEST_ACCOUNT,
				$location,
				array(
					'code'    => $response->get_error_code(),
					'message' => $response->get_error_message(),
					'context' => $response->get_error_data(),
				)
			);

			throw new ApiException(
				'woocommerce_woopayments_onboarding_client_api_error',
				esc_html( $response->get_error_message() ),
				(int) WP_Http::FAILED_DEPENDENCY,
				map_deep( (array) $response->get_error_data(), 'esc_html' )
			);
		}

		if ( ! is_array( $response ) || empty( $response['success'] ) ) {
			// Mark the onboarding step as failed.
			$this->mark_onboarding_step_failed(
				self::ONBOARDING_STEP_TEST_ACCOUNT,
				$location,
				array(
					'code'    => 'malformed_response',
					'message' => esc_html__( 'Received an unexpected response from the platform.', 'woocommerce' ),
					'context' => array(
						'response' => $response,
					),
				)
			);

			throw new ApiException(
				'woocommerce_woopayments_onboarding_client_api_error',
				esc_html__( 'Failed to initialize the test account.', 'woocommerce' ),
				(int) WP_Http::FAILED_DEPENDENCY
			);
		}

		// Record an event for the test account being initialized.
		$payment_methods_enabled  = array();
		$payment_methods_disabled = array();
		if ( ! empty( $configured_payment_methods ) && is_array( $configured_payment_methods ) ) {
			foreach ( $configured_payment_methods as $pm_id => $enabled ) {
				if ( ! is_string( $pm_id ) || ! is_bool( $enabled ) ) {
					continue; // Skip invalid entries.
				}

				if ( $enabled ) {
					$payment_methods_enabled[] = sanitize_key( $pm_id );
				} else {
					$payment_methods_disabled[] = sanitize_key( $pm_id );
				}
			}
		}
		$payment_methods_enabled  = array_unique( $payment_methods_enabled );
		$payment_methods_disabled = array_unique( $payment_methods_disabled );

		$event_props = array(
			'payment_methods_enabled'  => implode( ', ', $payment_methods_enabled ),
			'payment_methods_disabled' => implode( ', ', $payment_methods_disabled ),
			'source'                   => $source,
		);
		$this->record_event(
			self::EVENT_PREFIX . 'onboarding_test_account_init',
			$location,
			$event_props
		);

		return $response;
	}

	/**
	 * Get the onboarding KYC account session.
	 *
	 * @param string      $location        The location for which we are onboarding.
	 *                                     This is an ISO 3166-1 alpha-2 country code.
	 * @param array       $self_assessment Optional. The self-assessment data.
	 *                                     If not provided, the stored data will be used.
	 * @param string|null $source          Optional. The source for the current onboarding flow.
	 *                                     If not provided, it will identify the source as the WC Admin Payments settings.
	 *
	 * @return array The KYC account session data.
	 * @throws ApiException If the extension is not active, step requirements are not met, or
	 *                      the KYC session data could not be retrieved.
	 */
	public function get_onboarding_kyc_session( string $location, array $self_assessment = array(), ?string $source = self::SESSION_ENTRY_DEFAULT ): array {
		$this->check_if_onboarding_step_action_is_acceptable( self::ONBOARDING_STEP_BUSINESS_VERIFICATION, $location );

		if ( empty( $self_assessment ) ) {
			// Get the stored self-assessment data.
			$self_assessment = (array) $this->get_nox_profile_onboarding_step_data_entry( self::ONBOARDING_STEP_BUSINESS_VERIFICATION, $location, 'self_assessment' );
		}

		// Clear any previous failed status for the step.
		$this->clear_onboarding_step_failed( self::ONBOARDING_STEP_BUSINESS_VERIFICATION, $location );

		// Get the selected payment methods from the NOX profile.
		$selected_payment_methods = $this->get_nox_profile_onboarding_step_data_entry( self::ONBOARDING_STEP_PAYMENT_METHODS, $location, 'payment_methods', array() );

		// Ensure the payment gateways logic is initialized in case actions need to be taken on payment gateway changes.
		WC()->payment_gateways();

		$source = $this->validate_onboarding_source( $source );

		// Lock the onboarding to prevent concurrent actions.
		$this->set_onboarding_lock();

		try {
			// Call the WooPayments API to get the KYC session.
			$response = $this->proxy->call_static(
				Utils::class,
				'rest_endpoint_post_request',
				'/wc/v3/payments/onboarding/kyc/session',
				array(
					'self_assessment' => $self_assessment,
					'capabilities'    => $selected_payment_methods,
				)
			);
		} catch ( Exception $e ) {
			// Catch any exceptions to allow for proper error handling and onboarding unlock.
			$response = new WP_Error(
				'woocommerce_woopayments_onboarding_client_api_exception',
				esc_html__( 'An unexpected error happened while creating the KYC session.', 'woocommerce' ),
				array(
					'code'    => $e->getCode(),
					'message' => $e->getMessage(),
					'trace'   => $e->getTrace(),
				)
			);
		}

		// Unlock the onboarding after the API call finished or errored.
		$this->clear_onboarding_lock();

		if ( is_wp_error( $response ) ) {
			// Mark the onboarding step as failed.
			$this->mark_onboarding_step_failed(
				self::ONBOARDING_STEP_BUSINESS_VERIFICATION,
				$location,
				array(
					'code'    => $response->get_error_code(),
					'message' => $response->get_error_message(),
					'context' => $response->get_error_data(),
				)
			);

			throw new ApiException(
				'woocommerce_woopayments_onboarding_client_api_error',
				esc_html( $response->get_error_message() ),
				(int) WP_Http::FAILED_DEPENDENCY,
				map_deep( (array) $response->get_error_data(), 'esc_html' )
			);
		}

		if ( ! is_array( $response ) ) {
			// Mark the onboarding step as failed.
			$this->mark_onboarding_step_failed(
				self::ONBOARDING_STEP_BUSINESS_VERIFICATION,
				$location,
				array(
					'code'    => 'malformed_response',
					'message' => esc_html__( 'Received an unexpected response from the platform.', 'woocommerce' ),
					'context' => array(
						'response' => $response,
					),
				)
			);

			throw new ApiException(
				'woocommerce_woopayments_onboarding_client_api_error',
				esc_html__( 'Failed to get the KYC session data.', 'woocommerce' ),
				(int) WP_Http::FAILED_DEPENDENCY
			);
		}

		// Add the user locale to the account session data to allow for localized KYC sessions.
		$response['locale'] = $this->proxy->call_function( 'get_user_locale' );

		// For sanity, make sure the test account step is marked as completed, if not already,
		// since we are doing live account KYC.
		$this->mark_onboarding_step_completed( self::ONBOARDING_STEP_TEST_ACCOUNT, $location, false, $source );

		// Record an event for the KYC session being created.
		$event_props = array(
			'new_account_created' => $response['accountCreated'] ?? false,
			'account_mode'        => ( $response['isLive'] ?? false ) ? 'live' : 'test',
			'source'              => $source,
		);
		$this->record_event(
			self::EVENT_PREFIX . 'onboarding_kyc_session_created',
			$location,
			$event_props
		);

		return $response;
	}

	/**
	 * Finish the onboarding KYC account session.
	 *
	 * @param string      $location The location for which we are onboarding.
	 *                              This is an ISO 3166-1 alpha-2 country code.
	 * @param string|null $source   Optional. The source for the current onboarding flow.
	 *                              If not provided, it will identify the source as the WC Admin Payments settings.
	 *
	 * @return array The response from the WooPayments API.
	 * @throws ApiException If the extension is not active, step requirements are not met, or
	 *                      the KYC session could not be finished.
	 */
	public function finish_onboarding_kyc_session( string $location, ?string $source = self::SESSION_ENTRY_DEFAULT ): array {
		$this->check_if_onboarding_step_action_is_acceptable( self::ONBOARDING_STEP_BUSINESS_VERIFICATION, $location );

		// Ensure the payment gateways logic is initialized in case actions need to be taken on payment gateway changes.
		WC()->payment_gateways();

		$source = $this->validate_onboarding_source( $source );

		// Lock the onboarding to prevent concurrent actions.
		$this->set_onboarding_lock();

		try {
			// Call the WooPayments API to finalize the KYC session.
			$response = $this->proxy->call_static(
				Utils::class,
				'rest_endpoint_post_request',
				'/wc/v3/payments/onboarding/kyc/finalize',
				array(
					'source' => $source,
					'from'   => self::FROM_NOX_IN_CONTEXT,
				)
			);
		} catch ( Exception $e ) {
			// Catch any exceptions to allow for proper error handling and onboarding unlock.
			$response = new WP_Error(
				'woocommerce_woopayments_onboarding_client_api_exception',
				esc_html__( 'An unexpected error happened while finalizing the KYC session.', 'woocommerce' ),
				array(
					'code'    => $e->getCode(),
					'message' => $e->getMessage(),
					'trace'   => $e->getTrace(),
				)
			);
		}

		// Unlock the onboarding after the API call finished or errored.
		$this->clear_onboarding_lock();

		if ( is_wp_error( $response ) ) {
			// Mark the onboarding step as failed.
			$this->mark_onboarding_step_failed(
				self::ONBOARDING_STEP_BUSINESS_VERIFICATION,
				$location,
				array(
					'code'    => $response->get_error_code(),
					'message' => $response->get_error_message(),
					'context' => $response->get_error_data(),
				)
			);

			throw new ApiException(
				'woocommerce_woopayments_onboarding_client_api_error',
				esc_html( $response->get_error_message() ),
				(int) WP_Http::FAILED_DEPENDENCY,
				map_deep( (array) $response->get_error_data(), 'esc_html' )
			);
		}

		if ( ! is_array( $response ) ) {
			// Mark the onboarding step as failed.
			$this->mark_onboarding_step_failed(
				self::ONBOARDING_STEP_BUSINESS_VERIFICATION,
				$location,
				array(
					'code'    => 'malformed_response',
					'message' => esc_html__( 'Received an unexpected response from the platform.', 'woocommerce' ),
					'context' => array(
						'response' => $response,
					),
				)
			);

			throw new ApiException(
				'woocommerce_woopayments_onboarding_client_api_error',
				esc_html__( 'Failed to finish the KYC session.', 'woocommerce' ),
				(int) WP_Http::FAILED_DEPENDENCY
			);
		}

		// For sanity, make sure the test account step is marked as completed, if not already,
		// since we are doing live account KYC.
		$this->mark_onboarding_step_completed( self::ONBOARDING_STEP_TEST_ACCOUNT, $location, false, $source );

		// Record an event for the KYC session being finished.
		$event_props = array(
			'successful_kyc'    => filter_var( $response['success'] ?? false, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ) ?? false,
			'account_mode'      => ( 'live' === ( $response['mode'] ?? false ) ) ? 'live' : 'test',
			'details_submitted' => filter_var( $response['details_submitted'] ?? false, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ) ?? false,
			'promotion_id'      => $response['promotion_id'] ?? 'none',
			'source'            => $source,
		);
		$this->record_event(
			self::EVENT_PREFIX . 'onboarding_kyc_session_finished',
			$location,
			$event_props
		);

		// Mark the business verification step as completed.
		$this->mark_onboarding_step_completed( self::ONBOARDING_STEP_BUSINESS_VERIFICATION, $location, false, $source );

		return $response;
	}

	/**
	 * Preload the onboarding process.
	 *
	 * This method is used to run the heavier logic required for onboarding ahead of time,
	 * so that we can be quicker to respond to the user when they start the onboarding process.
	 *
	 * @return array An array containing the success status and any errors encountered during the preload.
	 *               'success' => true if the preload was successful, false otherwise.
	 *               'errors'  => An array of error messages if any errors occurred, empty if no errors.
	 * @throws ApiException If the onboarding preload failed or the onboarding is locked.
	 */
	public function onboarding_preload(): array {
		// If the onboarding is locked, we shouldn't do anything.
		if ( $this->is_onboarding_locked() ) {
			throw new ApiException(
				'woocommerce_woopayments_onboarding_locked',
				esc_html__( 'Another onboarding action is already in progress. Please wait for it to finish.', 'woocommerce' ),
				(int) WP_Http::CONFLICT
			);
		}

		$result = true;

		// Register the site to WPCOM if it is not already registered.
		// This sets up the site for connection. For new sites, this tends to take a while.
		// It is a prerequisite to generating the WPCOM/Jetpack authorization URL.
		if ( ! $this->wpcom_connection_manager->is_connected() ) {
			$result = $this->wpcom_connection_manager->try_registration();
			if ( is_wp_error( $result ) ) {
				throw new ApiException(
					'woocommerce_woopayments_onboarding_action_error',
					esc_html( $result->get_error_message() ),
					(int) WP_Http::INTERNAL_SERVER_ERROR,
					map_deep( (array) $result->get_error_data(), 'esc_html' )
				);
			}
		}

		return array(
			'success' => $result,
		);
	}

	/**
	 * Reset onboarding.
	 *
	 * @param string      $location The location for which we are onboarding.
	 *                              This is an ISO 3166-1 alpha-2 country code.
	 * @param string      $from     Optional. Where in the UI the request is coming from.
	 *                              If not provided, it will identify the origin as the WC Admin Payments settings.
	 * @param string|null $source   Optional. The source for the current onboarding flow.
	 *                              If not provided, it will identify the source as the WC Admin Payments settings.
	 *
	 * @return array The response from the WooPayments API.
	 * @throws ApiException If we could not reset onboarding or there was an error.
	 */
	public function reset_onboarding( string $location, string $from = '', ?string $source = self::SESSION_ENTRY_DEFAULT ): array {
		$this->check_if_onboarding_action_is_acceptable();

		// Ensure the payment gateways logic is initialized in case actions need to be taken on payment gateway changes.
		WC()->payment_gateways();

		$event_props = array();
		$source      = $this->validate_onboarding_source( $source );

		// Lock the onboarding to prevent concurrent actions.
		$this->set_onboarding_lock();

		try {
			// Before resetting the onboarding, record its details for tracking purposes.
			$event_props = array(
				'has_account'  => $this->has_account(),
				'account_mode' => $this->has_account() ? ( $this->has_live_account() ? 'live' : 'test' ) : 'none',
				'test_account' => $this->has_test_account(),
				'source'       => $source,
			);

			if ( $this->has_account() ) {
				// Call the WooPayments API to reset onboarding.
				$response = $this->proxy->call_static(
					Utils::class,
					'rest_endpoint_post_request',
					'/wc/v3/payments/onboarding/reset',
					array(
						'from'   => ! empty( $from ) ? esc_attr( $from ) : self::FROM_PAYMENT_SETTINGS,
						'source' => $source,
					)
				);
			} else {
				// If there is no account to reset, we can just use a success response.
				$response = array(
					'success' => true,
				);
			}
		} catch ( Exception $e ) {
			// Catch any exceptions to allow for proper error handling and onboarding unlock.
			$response = new WP_Error(
				'woocommerce_woopayments_onboarding_client_api_exception',
				esc_html__( 'An unexpected error happened while resetting onboarding.', 'woocommerce' ),
				array(
					'code'    => $e->getCode(),
					'message' => $e->getMessage(),
					'trace'   => $e->getTrace(),
				)
			);
		}

		// Unlock the onboarding after the API call finished or errored.
		$this->clear_onboarding_lock();

		// Clean up any NOX-specific onboarding data, regardless of the API response.
		$this->proxy->call_function( 'delete_option', self::NOX_PROFILE_OPTION_KEY );

		// Make sure the onboarding mode is reset.
		if ( class_exists( 'WC_Payments_Onboarding_Service' ) && defined( 'WC_Payments_Onboarding_Service::TEST_MODE_OPTION' ) ) {
			$this->proxy->call_function( 'update_option', Constants::get_constant( 'WC_Payments_Onboarding_Service::TEST_MODE_OPTION' ), 'no' );
		}

		if ( is_wp_error( $response ) ) {
			throw new ApiException(
				'woocommerce_woopayments_onboarding_client_api_error',
				esc_html( $response->get_error_message() ),
				(int) WP_Http::FAILED_DEPENDENCY,
				map_deep( (array) $response->get_error_data(), 'esc_html' )
			);
		}

		if ( ! is_array( $response ) || empty( $response['success'] ) ) {
			throw new ApiException(
				'woocommerce_woopayments_onboarding_client_api_error',
				esc_html__( 'Failed to reset onboarding.', 'woocommerce' ),
				(int) WP_Http::FAILED_DEPENDENCY
			);
		}

		// Record an event for the onboarding reset.
		$this->record_event(
			self::EVENT_PREFIX . 'onboarding_reset',
			$location,
			$event_props
		);

		return $response;
	}

	/**
	 * Disable a test account during the switch-to-live onboarding flow.
	 *
	 * @param string      $location The location for which we are onboarding.
	 *                              This is an ISO 3166-1 alpha-2 country code.
	 * @param string      $from     Optional. Where in the UI the request is coming from.
	 *                              If not provided, it will identify the origin as the WC Admin Payments settings.
	 * @param string|null $source   Optional. The source for the current onboarding flow.
	 *                              If not provided, it will identify the source as the WC Admin Payments settings.
	 *
	 * @return array The response from the WooPayments API.
	 * @throws ApiException If we could not disable the test account or there was an error.
	 */
	public function disable_test_account( string $location, string $from = '', ?string $source = self::SESSION_ENTRY_DEFAULT ): array {
		$this->check_if_onboarding_action_is_acceptable();

		// Ensure the payment gateways logic is initialized in case actions need to be taken on payment gateway changes.
		WC()->payment_gateways();

		$response = array(
			'success' => true,
		);

		$event_props = array();
		$source      = $this->validate_onboarding_source( $source );

		// Lock the onboarding to prevent concurrent actions.
		$this->set_onboarding_lock();

		try {
			$has_test_account    = $this->has_test_account();
			$has_sandbox_account = $this->has_sandbox_account();

			$event_props = array(
				'account_type' => $has_test_account ? 'test_drive' : ( $has_sandbox_account ? 'sandbox' : 'unknown' ),
				'source'       => $source,
			);

			// First, check if we have a test account to disable.
			if ( $has_test_account ) {
				// Call the WooPayments API to disable the test account and prepare for the switch to live.
				$response = $this->proxy->call_static(
					Utils::class,
					'rest_endpoint_post_request',
					'/wc/v3/payments/onboarding/test_drive_account/disable',
					array(
						'from'   => ! empty( $from ) ? esc_attr( $from ) : self::FROM_PAYMENT_SETTINGS,
						'source' => $source,
					)
				);
			} elseif ( $has_sandbox_account ) {
				// Call the WooPayments API to reset onboarding.
				$response = $this->proxy->call_static(
					Utils::class,
					'rest_endpoint_post_request',
					'/wc/v3/payments/onboarding/reset',
					array(
						'from'   => ! empty( $from ) ? esc_attr( $from ) : self::FROM_PAYMENT_SETTINGS,
						'source' => $source,
					)
				);
			}
		} catch ( Exception $e ) {
			// Catch any exceptions to allow for proper error handling and onboarding unlock.
			$response = new WP_Error(
				'woocommerce_woopayments_onboarding_client_api_exception',
				esc_html__( 'An unexpected error happened while disabling the test account.', 'woocommerce' ),
				array(
					'code'    => $e->getCode(),
					'message' => $e->getMessage(),
					'trace'   => $e->getTrace(),
				)
			);
		}

		// Unlock the onboarding after the API call finished or errored.
		$this->clear_onboarding_lock();

		// Make sure the onboarding mode is reset.
		if ( class_exists( 'WC_Payments_Onboarding_Service' ) && defined( 'WC_Payments_Onboarding_Service::TEST_MODE_OPTION' ) ) {
			$this->proxy->call_function( 'update_option', Constants::get_constant( 'WC_Payments_Onboarding_Service::TEST_MODE_OPTION' ), 'no' );
		}

		// Track the failure to disable the test account.
		if ( is_wp_error( $response ) || ! is_array( $response ) || empty( $response['success'] ) ) {
			$this->record_event(
				self::EVENT_PREFIX . 'onboarding_test_account_disable_error',
				$location,
				array(
					'source' => $source,
				)
			);
		}

		if ( is_wp_error( $response ) ) {
			throw new ApiException(
				'woocommerce_woopayments_onboarding_client_api_error',
				esc_html( $response->get_error_message() ),
				(int) WP_Http::FAILED_DEPENDENCY,
				map_deep( (array) $response->get_error_data(), 'esc_html' )
			);
		}

		if ( ! is_array( $response ) || empty( $response['success'] ) ) {
			throw new ApiException(
				'woocommerce_woopayments_onboarding_client_api_error',
				esc_html__( 'Failed to disable the test account.', 'woocommerce' ),
				(int) WP_Http::FAILED_DEPENDENCY
			);
		}

		// For sanity, make sure the payment methods step is marked as completed.
		// This is to avoid the user being prompted to set up payment methods again.
		$this->mark_onboarding_step_completed( self::ONBOARDING_STEP_PAYMENT_METHODS, $location );
		// For sanity, make sure the test account step is marked as completed and not blocked or failed.
		// After disabling a test account, the user should be prompted to set up a live account.
		$this->mark_onboarding_step_completed( self::ONBOARDING_STEP_TEST_ACCOUNT, $location );
		$this->clear_onboarding_step_blocked( self::ONBOARDING_STEP_TEST_ACCOUNT, $location );
		$this->clear_onboarding_step_failed( self::ONBOARDING_STEP_TEST_ACCOUNT, $location );
		// Clear the NOX profile data for the business verification step sub-step data.
		// This way the user will be prompted to complete ALL the business verification sub-steps.
		$business_verification_sub_step_data = $this->get_nox_profile_onboarding_step_data_entry( self::ONBOARDING_STEP_BUSINESS_VERIFICATION, $location, 'sub_steps', array() );
		if ( ! empty( $business_verification_sub_step_data ) ) {
			$this->save_nox_profile_onboarding_step_data_entry( self::ONBOARDING_STEP_BUSINESS_VERIFICATION, $location, 'sub_steps', array() );
		}

		// Record an event for the test account being disabled.
		$this->record_event(
			self::EVENT_PREFIX . 'onboarding_test_account_disabled',
			$location,
			$event_props
		);

		return $response;
	}

	/**
	 * Send a Tracks event.
	 *
	 * By default, Woo adds `url`, `blog_lang`, `blog_id`, `store_id`, `products_count`, and `wc_version`
	 * properties to every event.
	 *
	 * @param string $name              The event name.
	 *                                  If it is not prefixed with self::EVENT_PREFIX, it will be prefixed with it.
	 * @param string $business_country  The business registration country code as set in the WooCommerce Payments settings.
	 *                                  This is an ISO 3166-1 alpha-2 country code.
	 * @param array  $properties        Optional. The event custom properties.
	 *                                  These properties will be merged with the default properties.
	 *                                  Default properties values take precedence over the provided ones.
	 *
	 * @return void
	 */
	public function record_event( string $name, string $business_country, array $properties = array() ) {
		if ( ! function_exists( 'wc_admin_record_tracks_event' ) ) {
			return;
		}

		// If the event name is empty, we don't record it.
		if ( empty( $name ) ) {
			return;
		}

		// If the event name is not prefixed with `settings_payments_`, we prefix it.
		if ( ! str_starts_with( $name, self::EVENT_PREFIX ) ) {
			$name = self::EVENT_PREFIX . $name;
		}

		// Add default properties to every event and overwrite custom properties with the same keys.
		$properties = array_merge(
			$properties,
			array(
				'business_country' => $business_country,
			),
		);

		wc_admin_record_tracks_event( $name, $properties );
	}

	/**
	 * Check if an onboarding action should be allowed to be processed.
	 *
	 * @return void
	 * @throws ApiException If the extension is not active or onboarding is locked.
	 */
	private function check_if_onboarding_action_is_acceptable() {
		// If the WooPayments plugin is not active, we can't do anything.
		if ( ! $this->is_extension_active() ) {
			throw new ApiException(
				'woocommerce_woopayments_onboarding_extension_not_active',
				/* translators: %s: WooPayments. */
				sprintf( esc_html__( 'The %s extension is not active.', 'woocommerce' ), 'WooPayments' ),
				(int) WP_Http::FORBIDDEN
			);
		}

		// If the WooPayments installed version is less than the minimum required version, we can't do anything.
		if ( Constants::is_defined( 'WCPAY_VERSION_NUMBER' ) &&
			version_compare( Constants::get_constant( 'WCPAY_VERSION_NUMBER' ), self::EXTENSION_MINIMUM_VERSION, '<' ) ) {
			throw new ApiException(
				'woocommerce_woopayments_onboarding_extension_version',
				/* translators: %s: WooPayments. */
				sprintf( esc_html__( 'The %s extension is not up-to-date. Please update to the latest version and try again.', 'woocommerce' ), 'WooPayments' ),
				(int) WP_Http::FORBIDDEN
			);
		}

		// If the onboarding is locked, we shouldn't do anything.
		if ( $this->is_onboarding_locked() ) {
			throw new ApiException(
				'woocommerce_woopayments_onboarding_locked',
				esc_html__( 'Another onboarding action is already in progress. Please wait for it to finish.', 'woocommerce' ),
				(int) WP_Http::CONFLICT
			);
		}
	}

	/**
	 * Check if an onboarding step action should be allowed to be processed.
	 *
	 * @param string $step_id The ID of the onboarding step.
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 *
	 * @return void
	 * @throws ApiArgumentException If the onboarding step ID is invalid.
	 * @throws ApiException If the extension is not active or step requirements are not met.
	 */
	private function check_if_onboarding_step_action_is_acceptable( string $step_id, string $location ): void {
		// First, check general onboarding actions.
		$this->check_if_onboarding_action_is_acceptable();

		// Second, do onboarding step specific checks.
		if ( ! $this->is_valid_onboarding_step_id( $step_id ) ) {
			throw new ApiArgumentException(
				'woocommerce_woopayments_onboarding_invalid_step_id',
				esc_html__( 'Invalid onboarding step ID.', 'woocommerce' ),
				(int) WP_Http::BAD_REQUEST
			);
		}
		if ( ! $this->check_onboarding_step_requirements( $step_id, $location ) ) {
			throw new ApiException(
				'woocommerce_woopayments_onboarding_step_requirements_not_met',
				esc_html__( 'Onboarding step requirements are not met.', 'woocommerce' ),
				(int) WP_Http::FORBIDDEN
			);
		}
		if ( $this->is_onboarding_step_blocked( $step_id, $location ) ) {
			throw new ApiException(
				'woocommerce_woopayments_onboarding_step_blocked',
				esc_html__( 'There are environment or store setup issues which are blocking progress. Please resolve them to proceed.', 'woocommerce' ),
				(int) WP_Http::FORBIDDEN,
				array(
					'error' => map_deep( $this->get_onboarding_step_error( $step_id, $location ), 'esc_html' ),
				),
			);
		}
	}

	/**
	 * Check if the onboarding is locked.
	 *
	 * @return bool Whether the onboarding is locked.
	 */
	private function is_onboarding_locked(): bool {
		$lock_timestamp = (int) $this->proxy->call_function(
			'absint',
			$this->proxy->call_function( 'get_option', self::NOX_ONBOARDING_LOCKED_KEY, 0 )
		);

		if ( 0 === $lock_timestamp ) {
			return false;
		}

		$now = $this->proxy->call_function( 'time' );

		// If the lock timestamp is older than the TTL, consider it unlocked and self-heal.
		if ( $lock_timestamp < ( $now - self::NOX_ONBOARDING_LOCKED_TTL_SECONDS ) ) {
			$this->clear_onboarding_lock();

			return false;
		}

		return true;
	}

	/**
	 * Lock the onboarding.
	 *
	 * This will save a flag in the database to indicate that onboarding is locked.
	 * This is used to prevent certain onboarding actions to happen while others have not finished.
	 * This is especially important for actions that modify the account (initializing it, deleting it, etc.)
	 * These actions tend to be longer-running and we want to have backstops in place to prevent race conditions.
	 *
	 * @return void
	 */
	private function set_onboarding_lock(): void {
		$now = $this->proxy->call_function( 'time' );
		$this->proxy->call_function( 'update_option', self::NOX_ONBOARDING_LOCKED_KEY, $now, false );
	}

	/**
	 * Unlock the onboarding.
	 *
	 * @return void
	 */
	private function clear_onboarding_lock(): void {
		// We update rather than delete the option for performance reasons.
		$this->proxy->call_function( 'update_option', self::NOX_ONBOARDING_LOCKED_KEY, 0, false );
	}

	/**
	 * Get the onboarding details for each step.
	 *
	 * @param string      $location  The location for which we are onboarding.
	 *                               This is an ISO 3166-1 alpha-2 country code.
	 * @param string      $rest_path The REST API path to use for constructing REST API URLs.
	 * @param string|null $source    Optional. The source for the onboarding flow.
	 *
	 * @return array[] The list of onboarding steps details.
	 * @throws Exception If there was an error generating the onboarding steps details.
	 */
	private function get_onboarding_steps( string $location, string $rest_path, ?string $source = self::SESSION_ENTRY_DEFAULT ): array {
		$steps = array();

		// Add the payment methods onboarding step details, but only if we have recommended payment methods.
		$recommended_pms = $this->get_onboarding_recommended_payment_methods( $location );
		if ( ! empty( $recommended_pms ) ) {
			$steps[] = $this->standardize_onboarding_step_details(
				array(
					'id'      => self::ONBOARDING_STEP_PAYMENT_METHODS,
					'context' => array(
						'recommended_pms' => $recommended_pms,
						'pms_state'       => $this->get_onboarding_payment_methods_state( $location, $recommended_pms ),
					),
					'actions' => array(
						'start'  => array(
							'type' => self::ACTION_TYPE_REST,
							'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_PAYMENT_METHODS . '/start' ),
						),
						'save'   => array(
							'type' => self::ACTION_TYPE_REST,
							'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_PAYMENT_METHODS . '/save' ),
						),
						'finish' => array(
							'type' => self::ACTION_TYPE_REST,
							'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_PAYMENT_METHODS . '/finish' ),
						),
					),
				),
				$location,
				$rest_path
			);
		}

		// Add the WPCOM connection onboarding step details.
		$wpcom_step = $this->standardize_onboarding_step_details(
			array(
				'id'      => self::ONBOARDING_STEP_WPCOM_CONNECTION,
				'context' => array(
					'connection_state' => $this->get_wpcom_connection_state(),
				),
			),
			$location,
			$rest_path
		);

		// If the WPCOM connection is already set up, we don't need to add anything more.
		if ( self::ONBOARDING_STEP_STATUS_COMPLETED !== $wpcom_step['status'] ) {
			// Craft the return URL.
			switch ( $source ) {
				case self::SESSION_ENTRY_LYS:
					// If the source is LYS, we return the user to the Launch Your Store flow.
					$return_url = $this->proxy->call_function(
						'admin_url',
						'admin.php?page=wc-admin&path=/launch-your-store' . self::ONBOARDING_PATH_BASE . '&sidebar=hub&content=payments'
					);
					break;
				default:
					// By default, we return the user to the onboarding modal in the Settings > Payments page.
					$return_url = $this->proxy->call_static(
						Utils::class,
						'wc_payments_settings_url',
						self::ONBOARDING_PATH_BASE
					);
					break;
			}

			// Add standardized query arguments to the return URL.
			$return_url = add_query_arg(
				array(
					// URL query flag so we can properly identify when the user returns
					// either by accepting or rejecting the WPCOM connection.
					self::WPCOM_CONNECTION_RETURN_PARAM => '1',
					// Keep the source.
					'source'                            => $source,
					// Attach the `from` parameter to more easily identify where the return request is coming from.
					'from'                              => self::FROM_WPCOM,
				),
				$return_url
			);

			// Try to generate the authorization URL.
			$wpcom_connection = $this->get_wpcom_connection_authorization( $return_url );
			if ( ! $wpcom_connection['success'] ) {
				// In case of errors, make sure we work with a list of error messages.
				$wpcom_step['errors'] = array_values( (array) ( $wpcom_connection['errors'] ?? array() ) );
			}
			$wpcom_step['actions'] = array(
				'start' => array(
					'type' => self::ACTION_TYPE_REST,
					'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_WPCOM_CONNECTION . '/start' ),
				),
				'auth'  => array(
					'type' => self::ACTION_TYPE_REDIRECT,
					'href' => $wpcom_connection['url'],
				),
			);
		}

		$steps[] = $wpcom_step;

		// Test account onboarding step is unavailable in UAE and Singapore.
		if ( ! in_array( $location, array( 'AE', 'SG' ), true ) ) {
			$test_account_step = $this->standardize_onboarding_step_details(
				array(
					'id' => self::ONBOARDING_STEP_TEST_ACCOUNT,
				),
				$location,
				$rest_path
			);

			// If the step is not completed, we need to add the actions.
			if ( self::ONBOARDING_STEP_STATUS_COMPLETED !== $test_account_step['status'] ) {
				$test_account_step['actions'] = array(
					'start'  => array(
						'type' => self::ACTION_TYPE_REST,
						'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_TEST_ACCOUNT . '/start' ),
					),
					'init'   => array(
						'type' => self::ACTION_TYPE_REST,
						'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_TEST_ACCOUNT . '/init' ),
					),
					'finish' => array(
						'type' => self::ACTION_TYPE_REST,
						'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_TEST_ACCOUNT . '/finish' ),
					),
				);
			}

			$test_account_step['actions']['reset'] = array(
				'type' => self::ACTION_TYPE_REST,
				'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_TEST_ACCOUNT . '/reset' ),
			);

			$steps[] = $test_account_step;
		}

		// Add the live account business verification onboarding step details.
		$business_verification_step_sub_steps = $this->get_nox_profile_onboarding_step_data_entry(
			self::ONBOARDING_STEP_BUSINESS_VERIFICATION,
			$location,
			'sub_steps',
			array()
		);
		// Sanity check: If there is no account connected, the sub-steps details should be forced empty.
		// This way we allow for the Transact Platform account reset to take effect and
		// allow the user to restart the business verification process, including the self-assessment business step.
		if ( ! $this->has_account() ) {
			$business_verification_step_sub_steps = array();
		}
		$business_verification_step = $this->standardize_onboarding_step_details(
			array(
				'id'      => self::ONBOARDING_STEP_BUSINESS_VERIFICATION,
				'context' => array(
					'fields'              => array(),
					'sub_steps'           => $business_verification_step_sub_steps,
					'self_assessment'     => $this->get_nox_profile_onboarding_step_data_entry( self::ONBOARDING_STEP_BUSINESS_VERIFICATION, $location, 'self_assessment', array() ),
					'has_test_account'    => $this->has_test_account(),
					'has_sandbox_account' => $this->has_sandbox_account(),
				),
			),
			$location,
			$rest_path
		);

		// Try to get the pre-KYC fields, but only if the required step is completed.
		// This is because WooPayments needs a working WPCOM connection to be able to fetch the fields.
		if ( $this->check_onboarding_step_requirements( self::ONBOARDING_STEP_BUSINESS_VERIFICATION, $location ) ) {
			try {
				$business_verification_step['context']['fields'] = $this->get_onboarding_kyc_fields( $location );
			} catch ( Exception $e ) {
				$business_verification_step['errors'][] = array(
					'code'    => 'fields_error',
					'message' => $e->getMessage(),
				);
			}
		}

		// If the step is not completed, we need to add the actions.
		if ( self::ONBOARDING_STEP_STATUS_COMPLETED !== $business_verification_step['status'] ) {
			$business_verification_step['actions'] = array(
				'start'                => array(
					'type' => self::ACTION_TYPE_REST,
					'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_BUSINESS_VERIFICATION . '/start' ),
				),
				'save'                 => array(
					'type' => self::ACTION_TYPE_REST,
					'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_BUSINESS_VERIFICATION . '/save' ),
				),
				'kyc_session'          => array(
					'type' => self::ACTION_TYPE_REST,
					'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_BUSINESS_VERIFICATION . '/kyc_session' ),
				),
				'kyc_session_finish'   => array(
					'type' => self::ACTION_TYPE_REST,
					'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_BUSINESS_VERIFICATION . '/kyc_session/finish' ),
				),
				'kyc_fallback'         => array(
					'type' => self::ACTION_TYPE_REDIRECT,
					'href' => $this->get_onboarding_kyc_fallback_url(),
				),
				'finish'               => array(
					'type' => self::ACTION_TYPE_REST,
					'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_BUSINESS_VERIFICATION . '/finish' ),
				),
				'test_account_disable' => array(
					'type' => self::ACTION_TYPE_REST,
					'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_BUSINESS_VERIFICATION . '/test_account/disable' ),
				),
			);
		}

		$steps[] = $business_verification_step;

		// Do a complete list standardization, for safety.
		return $this->standardize_onboarding_steps_details( $steps, $location, $rest_path );
	}

	/**
	 * Standardize (and sanity check) the onboarding step details.
	 *
	 * @param array  $step_details The onboarding step details to standardize.
	 * @param string $location     The location for which we are onboarding.
	 *                             This is an ISO 3166-1 alpha-2 country code.
	 * @param string $rest_path    The REST API path to use for constructing REST API URLs.
	 *
	 * @return array The standardized onboarding step details.
	 * @throws Exception If the onboarding step details are missing required entries or if the step ID is invalid.
	 */
	private function standardize_onboarding_step_details( array $step_details, string $location, string $rest_path ): array {
		// If the required keys are not present, throw.
		if ( ! isset( $step_details['id'] ) ) {
			/* translators: %s: The required key that is missing. */
			throw new Exception( sprintf( esc_html__( 'The onboarding step is missing required entries: %s', 'woocommerce' ), 'id' ) );
		}
		// Validate the step ID.
		if ( ! $this->is_valid_onboarding_step_id( $step_details['id'] ) ) {
			/* translators: %s: The invalid step ID. */
			throw new Exception( sprintf( esc_html__( 'The onboarding step ID is invalid: %s', 'woocommerce' ), esc_attr( $step_details['id'] ) ) );
		}

		if ( empty( $step_details['status'] ) ) {
			$step_details['status'] = $this->get_onboarding_step_status( $step_details['id'], $location );
		}

		if ( empty( $step_details['errors'] ) ) {
			$step_details['errors'] = array();

			// For blocked or failed steps, we include any stored error.
			if ( in_array( $step_details['status'], array( self::ONBOARDING_STEP_STATUS_BLOCKED, self::ONBOARDING_STEP_STATUS_FAILED ), true ) ) {
				$stored_error = $this->get_onboarding_step_error( $step_details['id'], $location );
				if ( ! empty( $stored_error ) ) {
					$step_details['errors'] = array( $stored_error );
				}
			}
		}
		// Standardize errors to be a list of arrays with `code`, `message`, and optional extra keys.
		$standardized_errors = array();
		// If the errors is not a list of errors or it has any of the reserved entries,
		// treat it as a single error.
		if ( ! is_array( $step_details['errors'] )
			|| array_key_exists( 'code', $step_details['errors'] )
			|| array_key_exists( 'message', $step_details['errors'] )
			|| array_key_exists( 'context', $step_details['errors'] )
		) {
			$raw_errors = array( $step_details['errors'] );
		} else {
			$raw_errors = $step_details['errors'];
		}

		foreach ( $raw_errors as $error ) {
			if ( $error instanceof \WP_Error ) {
				$error = array(
					'code'    => $error->get_error_code(),
					'message' => $error->get_error_message(),
					'context' => $error->get_error_data(),
				);
			} elseif ( is_array( $error ) ) {
				if ( empty( $error['code'] ) ) {
					$error['code'] = 'general_error';
				}
				if ( ! array_key_exists( 'message', $error ) ) {
					$error['message'] = '';
				}
			} else {
				$error = array(
					'code'    => 'general_error',
					'message' => (string) $error,
				);
			}

			$standardized_errors[] = $this->sanitize_onboarding_step_error( $error );
		}
		$step_details['errors'] = $standardized_errors;

		// Ensure that any step has the general actions.
		if ( empty( $step_details['actions'] ) ) {
			$step_details['actions'] = array();
		}
		// Any step can be checked for its status.
		if ( empty( $step_details['actions']['check'] ) ) {
			$step_details['actions']['check'] = array(
				'type' => self::ACTION_TYPE_REST,
				'href' => rest_url( trailingslashit( $rest_path ) . $step_details['id'] . '/check' ),
			);
		}
		// Any step can be cleaned of its progress.
		if ( empty( $step_details['actions']['clean'] ) ) {
			$step_details['actions']['clean'] = array(
				'type' => self::ACTION_TYPE_REST,
				'href' => rest_url( trailingslashit( $rest_path ) . $step_details['id'] . '/clean' ),
			);
		}

		return array(
			'id'             => $step_details['id'],
			'path'           => $step_details['path'] ?? trailingslashit( self::ONBOARDING_PATH_BASE ) . $step_details['id'],
			'required_steps' => $step_details['required_steps'] ?? $this->get_onboarding_step_required_steps( $step_details['id'] ),
			'status'         => $step_details['status'],
			'errors'         => $step_details['errors'],
			'actions'        => $step_details['actions'],
			'context'        => $step_details['context'] ?? array(),
		);
	}

	/**
	 * Standardize (and sanity check) the onboarding steps list.
	 *
	 * @param array  $steps The onboarding steps list to standardize.
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 * @param string $rest_path The REST API path to use for constructing REST API URLs.
	 *
	 * @return array The standardized onboarding steps list.
	 * @throws Exception If some onboarding steps are missing required entries or if invalid step IDs are present.
	 */
	private function standardize_onboarding_steps_details( array $steps, string $location, string $rest_path ): array {
		$standardized_steps = array();
		foreach ( $steps as $step ) {
			$standardized_steps[] = $this->standardize_onboarding_step_details( $step, $location, $rest_path );
		}

		return $standardized_steps;
	}

	/**
	 * Get the entire stored NOX profile data.
	 *
	 * @return array The stored NOX profile.
	 */
	private function get_nox_profile(): array {
		$nox_profile = $this->proxy->call_function( 'get_option', self::NOX_PROFILE_OPTION_KEY, array() );

		if ( empty( $nox_profile ) ) {
			$nox_profile = array();
		} else {
			$nox_profile = maybe_unserialize( $nox_profile );
		}

		return $nox_profile;
	}

	/**
	 * Save the NOX profile data.
	 *
	 * @param array $data The data to save in the profile.
	 *
	 * @return bool Whether the data was saved.
	 */
	private function save_nox_profile( array $data ): bool {
		return $this->proxy->call_function( 'update_option', self::NOX_PROFILE_OPTION_KEY, $data, false );
	}

	/**
	 * Get the onboarding data from the NOX profile.
	 *
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 *
	 * @return array The onboarding stored data from the NOX profile.
	 *               If the step data is not found, an empty array is returned.
	 */
	private function get_nox_profile_onboarding( string $location ): array {
		$nox_profile = $this->get_nox_profile();

		if ( empty( $nox_profile['onboarding'] ) ) {
			$nox_profile['onboarding'] = array();
		}
		if ( empty( $nox_profile['onboarding'][ $location ] ) ) {
			$nox_profile['onboarding'][ $location ] = array();
		}

		return $nox_profile['onboarding'][ $location ];
	}

	/**
	 * Save the onboarding data in the NOX profile.
	 *
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 * @param array  $data     The onboarding step data to save in the profile.
	 *
	 * @return bool Whether the onboarding data was saved.
	 */
	private function save_nox_profile_onboarding( string $location, array $data ): bool {
		$nox_profile = $this->get_nox_profile();

		if ( empty( $nox_profile['onboarding'] ) ) {
			$nox_profile['onboarding'] = array();
		}

		// Update the stored data.
		$nox_profile['onboarding'][ $location ] = $data;

		return $this->save_nox_profile( $nox_profile );
	}

	/**
	 * Get the onboarding step data from the NOX profile.
	 *
	 * @param string $step_id  The ID of the onboarding step.
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 *
	 * @return array The onboarding step stored data from the NOX profile.
	 *               If the step data is not found, an empty array is returned.
	 */
	private function get_nox_profile_onboarding_step( string $step_id, string $location ): array {
		$nox_profile_onboarding = $this->get_nox_profile_onboarding( $location );

		if ( empty( $nox_profile_onboarding['steps'] ) ) {
			$nox_profile_onboarding['steps'] = array();
		}
		if ( empty( $nox_profile_onboarding['steps'][ $step_id ] ) ) {
			$nox_profile_onboarding['steps'][ $step_id ] = array();
		}

		return $nox_profile_onboarding['steps'][ $step_id ];
	}

	/**
	 * Save the onboarding step data in the NOX profile.
	 *
	 * @param string $step_id  The ID of the onboarding step.
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 * @param array  $data     The onboarding step data to save in the profile.
	 *
	 * @return bool Whether the onboarding step data was saved.
	 */
	private function save_nox_profile_onboarding_step( string $step_id, string $location, array $data ): bool {
		$nox_profile_onboarding = $this->get_nox_profile_onboarding( $location );

		if ( empty( $nox_profile_onboarding['steps'] ) ) {
			$nox_profile_onboarding['steps'] = array();
		}

		// Update the stored step data.
		$nox_profile_onboarding['steps'][ $step_id ] = $data;

		return $this->save_nox_profile_onboarding( $location, $nox_profile_onboarding );
	}

	/**
	 * Get an entry from the NOX profile onboarding step details.
	 *
	 * @param string $step_id       The ID of the onboarding step.
	 * @param string $location      The location for which we are onboarding.
	 *                              This is an ISO 3166-1 alpha-2 country code.
	 * @param string $entry         The entry to get from the step data.
	 * @param mixed  $default_value The default value to return if the entry is not found.
	 *
	 * @return mixed The entry from the NOX profile step details. If the entry is not found, the default value is returned.
	 */
	private function get_nox_profile_onboarding_step_entry( string $step_id, string $location, string $entry, $default_value = array() ): array {
		$step_details = $this->get_nox_profile_onboarding_step( $step_id, $location );

		if ( ! isset( $step_details[ $entry ] ) ) {
			return $default_value;
		}

		return $step_details[ $entry ];
	}

	/**
	 * Save an entry in the NOX profile onboarding step details.
	 *
	 * @param string $step_id  The ID of the onboarding step.
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 * @param string $entry    The entry key under which to save in the step data.
	 * @param array  $data     The data to save in the step data.
	 *
	 * @return bool Whether the onboarding step data was saved.
	 */
	private function save_nox_profile_onboarding_step_entry( string $step_id, string $location, string $entry, array $data ): bool {
		$step_details = $this->get_nox_profile_onboarding_step( $step_id, $location );

		// Update the stored step data.
		$step_details[ $entry ] = $data;

		return $this->save_nox_profile_onboarding_step( $step_id, $location, $step_details );
	}

	/**
	 * Get a data entry from the NOX profile onboarding step details.
	 *
	 * @param string $step_id       The ID of the onboarding step.
	 * @param string $location      The location for which we are onboarding.
	 *                              This is an ISO 3166-1 alpha-2 country code.
	 * @param string $entry         The entry to get from the step `data`.
	 * @param mixed  $default_value The default value to return if the entry is not found.
	 *
	 * @return mixed The entry value from the NOX profile stored step data.
	 *               If the entry is not found, the default value is returned.
	 */
	private function get_nox_profile_onboarding_step_data_entry( string $step_id, string $location, string $entry, $default_value = false ) {
		$step_details_data = $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'data' );

		if ( ! isset( $step_details_data[ $entry ] ) ) {
			return $default_value;
		}

		return $step_details_data[ $entry ];
	}

	/**
	 * Save a data entry in the NOX profile onboarding step details.
	 *
	 * @param string $step_id  The ID of the onboarding step.
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 * @param string $entry    The entry key under which to save in the step `data`.
	 * @param mixed  $data     The value to save.
	 *
	 * @return bool Whether the onboarding step data was saved.
	 */
	private function save_nox_profile_onboarding_step_data_entry( string $step_id, string $location, string $entry, $data ): bool {
		$step_details_data = $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'data' );

		// Update the stored step data.
		$step_details_data[ $entry ] = $data;

		return $this->save_nox_profile_onboarding_step_entry( $step_id, $location, 'data', $step_details_data );
	}

	/**
	 * Get the IDs of the onboarding steps that are required for the given step.
	 *
	 * @param string $step_id The ID of the onboarding step.
	 *
	 * @return array|string[] The IDs of the onboarding steps that are required for the given step.
	 */
	private function get_onboarding_step_required_steps( string $step_id ): array {
		switch ( $step_id ) {
			// Both the test account and business verification (live account) steps require a working WPCOM connection.
			case self::ONBOARDING_STEP_TEST_ACCOUNT:
			case self::ONBOARDING_STEP_BUSINESS_VERIFICATION:
				return array(
					self::ONBOARDING_STEP_WPCOM_CONNECTION,
				);
			default:
				return array();
		}
	}

	/**
	 * Check if the requirements for an onboarding step are met.
	 *
	 * @param string $step_id  The ID of the onboarding step.
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 *
	 * @return bool Whether the onboarding step requirements are met.
	 * @throws ApiArgumentException If the given onboarding step ID is invalid.
	 */
	private function check_onboarding_step_requirements( string $step_id, string $location ): bool {
		$requirements = $this->get_onboarding_step_required_steps( $step_id );

		foreach ( $requirements as $required_step_id ) {
			if ( $this->get_onboarding_step_status( $required_step_id, $location ) !== self::ONBOARDING_STEP_STATUS_COMPLETED ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Get the payment methods state for onboarding.
	 *
	 * @param string     $location        The location for which we are onboarding.
	 *                                    This is an ISO 3166-1 alpha-2 country code.
	 * @param array|null $recommended_pms Optional. The recommended payment methods to use.
	 *
	 * @return array The onboarding payment methods state.
	 */
	private function get_onboarding_payment_methods_state( string $location, ?array $recommended_pms ): array {
		// First, get the recommended payment methods details from the provider.
		// We will use their enablement state as the default.
		// Note: The list is validated and standardized by the provider, so we don't need to do it here.
		if ( null === $recommended_pms ) {
			$recommended_pms = $this->get_onboarding_recommended_payment_methods( $location );
		}
		if ( empty( $recommended_pms ) ) {
			// If there are no recommended payment methods, return an empty array.
			return array();
		}

		// Grab the stored payment methods state
		// (a key-value array of payment method IDs and if they should be automatically enabled or not).
		$step_pms_data = (array) $this->get_nox_profile_onboarding_step_data_entry( self::ONBOARDING_STEP_PAYMENT_METHODS, $location, 'payment_methods' );

		$payment_methods_state = array();
		$apple_pay_enabled     = false;
		$google_pay_enabled    = false;

		foreach ( $recommended_pms as $recommended_pm ) {
			$pm_id = $recommended_pm['id'];

			/**
			 * We need to handle Apple Pay and Google Pay separately.
			 * They are not stored in the same way as the other payment methods.
			 */
			if ( 'apple_pay' === $pm_id ) {
				$apple_pay_enabled = $recommended_pm['enabled'];
				continue;
			}

			if ( 'google_pay' === $pm_id ) {
				$google_pay_enabled = $recommended_pm['enabled'];
				continue;
			}

			// Start with the recommended enabled state.
			$payment_methods_state[ $pm_id ] = $recommended_pm['enabled'];

			// Force enable if required.
			if ( $recommended_pm['required'] ) {
				$payment_methods_state[ $pm_id ] = true;
				continue;
			}

			// Check the stored state, if any.
			if ( isset( $step_pms_data[ $pm_id ] ) ) {
				$payment_methods_state[ $pm_id ] = wc_string_to_bool( $step_pms_data[ $pm_id ] );
			}
		}

		// Combine Apple Pay and Google Pay into a single `apple_google` entry.
		// First check if apple_google is explicitly stored, otherwise fallback to combining individual states.
		if ( isset( $step_pms_data['apple_google'] ) ) {
			$apple_google_enabled = wc_string_to_bool( $step_pms_data['apple_google'] );
		} else {
			// Fallback to OR logic for backward compatibility.
			$apple_google_enabled = $apple_pay_enabled || $google_pay_enabled;
		}

		$payment_methods_state['apple_google'] = $apple_google_enabled;

		return $payment_methods_state;
	}

	/**
	 * Get the WPCOM (Jetpack) connection authorization details.
	 *
	 * @param string $return_url The URL to redirect to after the connection is set up.
	 *
	 * @return array The WPCOM connection authorization details.
	 */
	private function get_wpcom_connection_authorization( string $return_url ): array {
		return $this->proxy->call_static( Utils::class, 'get_wpcom_connection_authorization', $return_url );
	}

	/**
	 * Get the store's WPCOM (Jetpack) connection state.
	 *
	 * @return array The WPCOM connection state.
	 */
	private function get_wpcom_connection_state(): array {
		$is_connected        = $this->wpcom_connection_manager->is_connected();
		$has_connected_owner = $this->wpcom_connection_manager->has_connected_owner();

		return array(
			'has_working_connection' => $this->has_working_wpcom_connection(),
			'is_store_connected'     => $is_connected,
			'has_connected_owner'    => $has_connected_owner,
			'is_connection_owner'    => $has_connected_owner && $this->wpcom_connection_manager->is_connection_owner(),
		);
	}

	/**
	 * Check if the store has a working WPCOM connection.
	 *
	 * The store is considered to have a working WPCOM connection if:
	 * - The store is connected to WPCOM (blog ID and tokens are set).
	 * - The store connection has a connected owner (connection owner is set).
	 *
	 * @return bool Whether the store has a working WPCOM connection.
	 */
	private function has_working_wpcom_connection(): bool {
		return $this->wpcom_connection_manager->is_connected() && $this->wpcom_connection_manager->has_connected_owner();
	}

	/**
	 * Check if the WooPayments plugin is active.
	 *
	 * @return boolean
	 */
	private function is_extension_active(): bool {
		return $this->proxy->call_function( 'class_exists', '\WC_Payments' );
	}

	/**
	 * Get the main payment gateway instance.
	 *
	 * @return \WC_Payment_Gateway The main payment gateway instance.
	 */
	private function get_payment_gateway(): \WC_Payment_Gateway {
		return $this->proxy->call_static( '\WC_Payments', 'get_gateway' );
	}

	/**
	 * Determine if WooPayments has an account set up.
	 *
	 * @return bool Whether WooPayments has an account set up.
	 */
	private function has_account(): bool {
		return $this->provider->is_account_connected( $this->get_payment_gateway() );
	}

	/**
	 * Determine if WooPayments has a valid, fully onboarded account set up.
	 *
	 * @return bool Whether WooPayments has a valid, fully onboarded account set up.
	 */
	private function has_valid_account(): bool {
		if ( ! $this->has_account() ) {
			return false;
		}

		$account_service = $this->proxy->call_static( '\WC_Payments', 'get_account_service' );

		return $account_service->is_stripe_account_valid();
	}

	/**
	 * Determine if WooPayments has a working account set up.
	 *
	 * This is a more specific check than has_valid_account() and checks if payments are enabled for the account.
	 *
	 * @return bool Whether WooPayments has a working account set up.
	 */
	private function has_working_account(): bool {
		if ( ! $this->has_account() ) {
			return false;
		}

		$account_service = $this->proxy->call_static( '\WC_Payments', 'get_account_service' );
		$account_status  = $account_service->get_account_status_data();

		return ! empty( $account_status['paymentsEnabled'] );
	}

	/**
	 * Determine if WooPayments has a test account set up.
	 *
	 * @return bool Whether WooPayments has a test account set up.
	 */
	private function has_test_account(): bool {
		if ( ! $this->has_account() ) {
			return false;
		}

		$account_service = $this->proxy->call_static( '\WC_Payments', 'get_account_service' );
		$account_status  = $account_service->get_account_status_data();

		return ! empty( $account_status['testDrive'] );
	}

	/**
	 * Determine if WooPayments has a sandbox account set up.
	 *
	 * @return bool Whether WooPayments has a sandbox account set up.
	 */
	private function has_sandbox_account(): bool {
		if ( ! $this->has_account() ) {
			return false;
		}

		$account_service = $this->proxy->call_static( '\WC_Payments', 'get_account_service' );
		$account_status  = $account_service->get_account_status_data();

		return empty( $account_status['isLive'] ) && empty( $account_status['testDrive'] );
	}

	/**
	 * Determine if WooPayments has a live account set up.
	 *
	 * @return bool Whether WooPayments has a test account set up.
	 */
	private function has_live_account(): bool {
		if ( ! $this->has_account() ) {
			return false;
		}

		$account_service = $this->proxy->call_static( '\WC_Payments', 'get_account_service' );
		$account_status  = $account_service->get_account_status_data();

		return ! empty( $account_status['isLive'] );
	}

	/**
	 * Get the onboarding fields data for the KYC business verification.
	 *
	 * @param string $location The location for which we are onboarding.
	 *                         This is an ISO 3166-1 alpha-2 country code.
	 *
	 * @return array The onboarding fields data.
	 * @throws Exception If the onboarding fields data could not be retrieved or there was an error.
	 */
	private function get_onboarding_kyc_fields( string $location ): array {
		// Call the WooPayments API to get the onboarding fields.
		$response = $this->proxy->call_static( Utils::class, 'rest_endpoint_get_request', '/wc/v3/payments/onboarding/fields' );

		if ( is_wp_error( $response ) ) {
			throw new Exception( esc_html( $response->get_error_message() ) );
		}

		if ( ! is_array( $response ) || ! isset( $response['data'] ) ) {
			throw new Exception( esc_html__( 'Failed to get onboarding fields data.', 'woocommerce' ) );
		}

		$fields = $response['data'];

		// If there is no available_countries entry, add it.
		if ( ! isset( $fields['available_countries'] ) &&
			class_exists( '\WC_Payments_Utils' ) &&
			$this->proxy->call_function( 'is_callable', '\WC_Payments_Utils::supported_countries' ) ) {

			$fields['available_countries'] = $this->proxy->call_static( '\WC_Payments_Utils', 'supported_countries' );
		}

		$fields['location'] = $location;

		return $fields;
	}

	/**
	 * Get the fallback URL for the embedded KYC flow.
	 *
	 * @return string The fallback URL for the embedded KYC flow.
	 */
	private function get_onboarding_kyc_fallback_url(): string {
		if ( $this->proxy->call_function( 'is_callable', '\WC_Payments_Account::get_connect_url' ) ) {
			return $this->proxy->call_static( '\WC_Payments_Account', 'get_connect_url', self::FROM_NOX_IN_CONTEXT );
		}

		// Fall back to the provider onboarding URL.
		return $this->provider->get_onboarding_url(
			$this->get_payment_gateway(),
			Utils::wc_payments_settings_url( self::ONBOARDING_PATH_BASE, array( 'from' => self::FROM_KYC ) )
		);
	}

	/**
	 * Get the WooPayments Overview page URL.
	 *
	 * @return string The WooPayments Overview page URL.
	 */
	private function get_overview_page_url(): string {
		if ( $this->proxy->call_function( 'is_callable', '\WC_Payments_Account::get_overview_page_url' ) ) {
			return add_query_arg(
				array(
					'from' => self::FROM_NOX_IN_CONTEXT,
				),
				$this->proxy->call_static( '\WC_Payments_Account', 'get_overview_page_url' )
			);
		}

		// Fall back to the known WooPayments Overview page URL.
		return add_query_arg(
			array(
				'page' => 'wc-admin',
				'path' => '/payments/overview',
				'from' => self::FROM_NOX_IN_CONTEXT,
			),
			admin_url( 'admin.php' )
		);
	}

	/**
	 * Check the onboarding source and ensure it is a valid value.
	 *
	 * @param string|null $source The source of the onboarding request.
	 *
	 * @return string The validated onboarding source.
	 */
	private function validate_onboarding_source( ?string $source ): string {
		if ( empty( $source ) ) {
			return self::SESSION_ENTRY_DEFAULT;
		}

		$valid_sources = array(
			self::SESSION_ENTRY_DEFAULT,
			self::SESSION_ENTRY_LYS,
		);

		return in_array( $source, $valid_sources, true ) ? $source : self::SESSION_ENTRY_DEFAULT;
	}
}
PK     [1]9O    F  Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsController.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\WooPayments;

use Automattic\WooCommerce\Internal\Admin\Settings\Payments;

defined( 'ABSPATH' ) || exit;

/**
 * WooPayments provider controller class.
 *
 * Use this class for hooks and actions related to the WooPayments provider as it relates to the Payments settings page.
 *
 * @internal
 */
class WooPaymentsController {

	/**
	 * The payments settings page service.
	 *
	 * @var Payments
	 */
	private Payments $payments;

	/**
	 * The WooPayments-specific Payments settings page service.
	 *
	 * @var WooPaymentsService
	 */
	private WooPaymentsService $woopayments;

	/**
	 * Register hooks.
	 */
	public function register() {
		add_action( 'admin_init', array( $this, 'handle_returns_from_wpcom' ) );
	}

	/**
	 * Initialize the class instance.
	 *
	 * @param Payments           $payments The general payments settings page service.
	 * @param WooPaymentsService $woopayments The WooPayments-specific Payments settings page service.
	 *
	 * @internal
	 */
	final public function init( Payments $payments, WooPaymentsService $woopayments ): void {
		$this->payments    = $payments;
		$this->woopayments = $woopayments;
	}

	/**
	 * Handle returns from WordPress.com after the user has accepted or declined the WPCOM connection.
	 *
	 * @internal
	 */
	public function handle_returns_from_wpcom(): void {
		// Handle the return from WPCOM after the user has accepted or declined the WordPress.com connection.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( ! empty( $_GET[ WooPaymentsService::WPCOM_CONNECTION_RETURN_PARAM ] ) ) {
			// We are only interested in connection flows that are initiated from NOX session entry points.
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended
			if ( empty( $_GET['source'] ) ) {
				return;
			}
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$source = sanitize_text_field( wp_unslash( $_GET['source'] ) );
			if ( ! in_array( $source, array( WooPaymentsService::SESSION_ENTRY_DEFAULT, WooPaymentsService::SESSION_ENTRY_LYS ), true ) ) {
				return;
			}

			$location = $this->payments->get_country();

			// Determine the connection state by querying the WPCOM connection onboarding step status.
			$wpcom_connected = WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED === $this->woopayments->get_onboarding_step_status( WooPaymentsService::ONBOARDING_STEP_WPCOM_CONNECTION, $location );

			// Track the connection attempt result.
			$event_props = array(
				'step_id' => WooPaymentsService::ONBOARDING_STEP_WPCOM_CONNECTION,
				'source'  => $source,
			);
			$this->woopayments->record_event(
				$wpcom_connected ? 'wpcom_connection_success' : 'wpcom_connection_failure',
				$location,
				$event_props
			);

			// On successful connection, mark the onboarding step as completed, if not already.
			if ( $wpcom_connected ) {
				$this->woopayments->mark_onboarding_step_completed( WooPaymentsService::ONBOARDING_STEP_WPCOM_CONNECTION, $location );
			}
		}
	}
}
PK     [1]pwMdo  do  0  Admin/Settings/PaymentsProviders/WooPayments.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\Jetpack\Connection\Manager as WPCOM_Connection_Manager;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Admin\WCAdminHelper;
use Automattic\WooCommerce\Enums\OrderInternalStatus;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\WooPayments\WooPaymentsRestController;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\WooPayments\WooPaymentsService;
use Automattic\WooCommerce\Internal\Admin\Settings\Payments;
use Automattic\WooCommerce\Internal\Admin\Settings\Utils;
use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Abstract_Order;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * WooPayments payment gateway provider class.
 *
 * This class handles all the custom logic for the WooPayments payment gateway provider.
 */
class WooPayments extends PaymentGateway {

	const PREFIX = 'woocommerce_admin_settings_payments__woopayments__';

	/**
	 * Extract the payment gateway provider details from the object.
	 *
	 * @param WC_Payment_Gateway $gateway      The payment gateway object.
	 * @param int                $order        Optional. The order to assign.
	 *                                         Defaults to 0 if not provided.
	 * @param string             $country_code Optional. The country code for which the details are being gathered.
	 *                                         This should be an ISO 3166-1 alpha-2 country code.
	 *
	 * @return array The payment gateway provider details.
	 *
	 * phpcs:ignore Squiz.Commenting.FunctionCommentThrowTag.Missing -- We wrap the throw in a try/catch.
	 */
	public function get_details( WC_Payment_Gateway $gateway, int $order = 0, string $country_code = '' ): array {
		$details = parent::get_details( $gateway, $order, $country_code );

		$has_test_account    = $this->has_test_account();
		$has_sandbox_account = $this->has_sandbox_account();

		// Switch the onboarding type to native.
		$details['onboarding']['type'] = self::ONBOARDING_TYPE_NATIVE;

		// Add the test [drive] account details to the onboarding state.
		$details['onboarding']['state']['test_drive_account'] = $has_test_account;

		// Add WPCOM/Jetpack connection details to the onboarding state.
		$details['onboarding']['state'] = array_merge( $details['onboarding']['state'], $this->get_wpcom_connection_state() );

		// If the WooPayments installed version is less than minimum required version,
		// we can't use the in-context onboarding flows.
		if ( Constants::is_defined( 'WCPAY_VERSION_NUMBER' ) &&
			version_compare( Constants::get_constant( 'WCPAY_VERSION_NUMBER' ), WooPaymentsService::EXTENSION_MINIMUM_VERSION, '<' ) ) {

			return $details;
		}

		// Switch the onboarding type to native in-context.
		$details['onboarding']['type'] = self::ONBOARDING_TYPE_NATIVE_IN_CONTEXT;

		// Provide the native, in-context onboarding URL instead of the external one.
		// This is a catch-all URL that should start or continue the onboarding process.
		$details['onboarding']['_links']['onboard'] = array(
			'href' => Utils::wc_payments_settings_url( '/woopayments/onboarding', array( 'from' => Payments::FROM_PAYMENTS_SETTINGS ) ),
		);

		try {
			/**
			 * The WooPayments REST controller instance.
			 *
			 * @var WooPaymentsRestController $rest_controller
			 */
			$rest_controller = wc_get_container()->get( WooPaymentsRestController::class );

			// Add disable test account URL to onboarding links, if the current account is a test or sandbox account.
			if ( $has_test_account || $has_sandbox_account ) {
				$details['onboarding']['_links']['disable_test_account'] = array(
					'href' => rest_url( $rest_controller->get_rest_url_path( 'onboarding/test_account/disable' ) ),
				);
			}

			// Add reset account/onboarding URL to onboarding links.
			$details['onboarding']['_links']['reset'] = array(
				'href' => rest_url( $rest_controller->get_rest_url_path( 'onboarding/reset' ) ),
			);
		} catch ( \Throwable $e ) {
			// If the REST controller is not available, we can't generate the REST API endpoint URLs.
			// This is not a critical error, so we just ignore it.
			// Log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->error(
				'Failed to get the WooPayments REST controller instance: ' . $e->getMessage(),
				array(
					'source' => 'settings-payments',
				)
			);
		}

		// Override the onboarding state with the entries provided by the WooPayments service.
		if ( ! empty( $country_code ) ) {
			try {
				/**
				 * The WooPayments service instance.
				 *
				 * @var WooPaymentsService $service
				 */
				$service = wc_get_container()->get( WooPaymentsService::class );

				// Ensure we have a valid rest_controller from the earlier try block.
				if ( ! isset( $rest_controller ) ) {
					throw new \RuntimeException( 'WooPayments REST controller not available' );
				}

				$onboarding_details = $service->get_onboarding_details( $country_code, $rest_controller->get_rest_url_path( 'onboarding' ) );
				// Merge the onboarding state with the one provided by the service.
				if ( ! empty( $onboarding_details['state'] ) && is_array( $onboarding_details['state'] ) ) {
					$details['onboarding']['state'] = array_merge(
						$details['onboarding']['state'],
						$onboarding_details['state']
					);
				}
				// Merge any messages provided by the service.
				if ( ! empty( $onboarding_details['messages'] ) && is_array( $onboarding_details['messages'] ) ) {
					if ( ! isset( $details['onboarding']['messages'] ) || ! is_array( $details['onboarding']['messages'] ) ) {
						$details['onboarding']['messages'] = array();
					}
					$details['onboarding']['messages'] = array_merge(
						$details['onboarding']['messages'],
						$onboarding_details['messages']
					);
				}
				// The steps provided by the service override any existing steps.
				if ( ! empty( $onboarding_details['steps'] ) && is_array( $onboarding_details['steps'] ) ) {
					$details['onboarding']['steps'] = $onboarding_details['steps'];
				}
				// Merge any context provided by the service.
				if ( ! empty( $onboarding_details['context'] ) && is_array( $onboarding_details['context'] ) ) {
					if ( ! isset( $details['onboarding']['context'] ) || ! is_array( $details['onboarding']['context'] ) ) {
						$details['onboarding']['context'] = array();
					}
					$details['onboarding']['context'] = array_merge(
						$details['onboarding']['context'],
						$onboarding_details['context']
					);
				}
			} catch ( \Throwable $e ) {
				// If the service is not available, we can't impose the more specific logic.
				// This is not a critical error, so we just ignore it.
				// Log so we can investigate.
				SafeGlobalFunctionProxy::wc_get_logger()->error(
					'Failed to get the WooPayments service instance: ' . $e->getMessage(),
					array(
						'source' => 'settings-payments',
					)
				);
			}
		}

		return $details;
	}

	/**
	 * Enhance this provider's payment extension suggestion with additional information.
	 *
	 * The details added do not require the payment extension to be active or a gateway instance.
	 *
	 * @param array $extension_suggestion The extension suggestion details.
	 *
	 * @return array The enhanced payment extension suggestion details.
	 */
	public function enhance_extension_suggestion( array $extension_suggestion ): array {
		$extension_suggestion = parent::enhance_extension_suggestion( $extension_suggestion );

		// If the extension is installed, we can get the plugin data and act upon it.
		if ( ! empty( $extension_suggestion['plugin']['file'] ) &&
			isset( $extension_suggestion['plugin']['status'] ) &&
			in_array( $extension_suggestion['plugin']['status'], array( PaymentsProviders::EXTENSION_INSTALLED, PaymentsProviders::EXTENSION_ACTIVE ), true ) ) {

			// Switch to the native in-context onboarding type if the WooPayments extension its version is compatible.
			// We need to put back the '.php' extension to construct the plugin filename.
			$plugin_data = $this->proxy->call_static( PluginsHelper::class, 'get_plugin_data', $extension_suggestion['plugin']['file'] . '.php' );
			if ( $plugin_data && ! empty( $plugin_data['Version'] ) &&
				version_compare( $plugin_data['Version'], PaymentsProviders\WooPayments\WooPaymentsService::EXTENSION_MINIMUM_VERSION, '>=' ) ) {

				$extension_suggestion['onboarding']['type'] = self::ONBOARDING_TYPE_NATIVE_IN_CONTEXT;
			}
		} else {
			// We assume the latest version of the WooPayments extension will be installed.
			$extension_suggestion['onboarding']['type'] = self::ONBOARDING_TYPE_NATIVE_IN_CONTEXT;
		}

		// Add onboarding state.
		if ( ! isset( $extension_suggestion['onboarding']['state'] ) || ! is_array( $extension_suggestion['onboarding']['state'] ) ) {
			$extension_suggestion['onboarding']['state'] = array();
		}
		// Add the store's WPCOM/Jetpack connection state to the onboarding state.
		$extension_suggestion['onboarding']['state'] = array_merge(
			$extension_suggestion['onboarding']['state'],
			$this->get_wpcom_connection_state()
		);

		// Add onboarding links.
		if ( empty( $extension_suggestion['onboarding']['_links'] ) || ! is_array( $extension_suggestion['onboarding']['_links'] ) ) {
			$extension_suggestion['onboarding']['_links'] = array();
		}

		// We only add the preload link if we don't have a working WPCOM connection.
		// This is because WooPayments onboarding preloading focuses on hydrating the WPCOM connection.
		if ( ! $extension_suggestion['onboarding']['state']['wpcom_has_working_connection'] ) {
			try {
				/**
				 * The WooPayments REST controller instance.
				 *
				 * @var WooPaymentsRestController $rest_controller
				 */
				$rest_controller = wc_get_container()->get( WooPaymentsRestController::class );

				// Add the onboarding preload URL.
				$extension_suggestion['onboarding']['_links']['preload'] = array(
					'href' => rest_url( $rest_controller->get_rest_url_path( 'onboarding/preload' ) ),
				);
			} catch ( Throwable $e ) {
				// If the REST controller is not available, we can't preload the onboarding data.
				// This is not a critical error, so we just ignore it.
				// Log so we can investigate.
				SafeGlobalFunctionProxy::wc_get_logger()->error(
					'Failed to get the WooPayments REST controller instance: ' . $e->getMessage(),
					array(
						'source' => 'settings-payments',
					)
				);
			}
		}

		return $extension_suggestion;
	}

	/**
	 * Check if the payment gateway needs setup.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway needs setup, false otherwise.
	 */
	public function needs_setup( WC_Payment_Gateway $payment_gateway ): bool {
		// No account means we need setup.
		if ( ! $this->is_account_connected( $payment_gateway ) ) {
			return true;
		}

		// Test-drive accounts don't need setup.
		if ( $this->has_test_account() ) {
			return false;
		}

		return parent::needs_setup( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode.
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode, false otherwise.
	 */
	public function is_in_test_mode( WC_Payment_Gateway $payment_gateway ): bool {
		if ( $this->proxy->call_function( 'class_exists', 'WC_Payments' ) &&
			$this->proxy->call_function( 'is_callable', 'WC_Payments::mode' ) ) {

			$woopayments_mode = $this->proxy->call_static( 'WC_Payments', 'mode' );
			if ( $this->proxy->call_function( 'method_exists', $woopayments_mode, 'is_test' ) &&
				$this->proxy->call_function( 'is_callable', array( $woopayments_mode, 'is_test' ) ) ) {

				return $woopayments_mode->is_test();
			}
		}

		return parent::is_in_test_mode( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in dev mode.
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in dev mode, false otherwise.
	 */
	public function is_in_dev_mode( WC_Payment_Gateway $payment_gateway ): bool {
		if ( $this->proxy->call_function( 'class_exists', 'WC_Payments' ) &&
			$this->proxy->call_function( 'is_callable', 'WC_Payments::mode' ) ) {

			$woopayments_mode = $this->proxy->call_static( 'WC_Payments', 'mode' );
			if ( $this->proxy->call_function( 'method_exists', $woopayments_mode, 'is_dev' ) &&
				$this->proxy->call_function( 'is_callable', array( $woopayments_mode, 'is_dev' ) ) ) {

				return $woopayments_mode->is_dev();
			}
		}

		return parent::is_in_dev_mode( $payment_gateway );
	}

	/**
	 * Check if the payment gateway supports the current store state for onboarding.
	 *
	 * Most of the time the current business location should be the main factor, but could also
	 * consider other store settings like currency.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 * @param string             $country_code    Optional. The country code for which to check.
	 *                                            This should be an ISO 3166-1 alpha-2 country code.
	 *
	 * @return bool|null True if the payment gateway supports onboarding, false otherwise.
	 *                   If the payment gateway does not provide the information,
	 *                   we will return null to indicate that we don't know.
	 */
	public function is_onboarding_supported( WC_Payment_Gateway $payment_gateway, string $country_code = '' ): ?bool {
		$is_onboarding_supported = parent::is_onboarding_supported( $payment_gateway, $country_code );
		if ( ! is_null( $is_onboarding_supported ) ) {
			return $is_onboarding_supported;
		}

		// Without a country code to check against, we assume onboarding is supported to avoid blocking the user.
		if ( empty( $country_code ) ) {
			return true;
		}

		// Normalize the country code.
		$country_code = strtoupper( $country_code );

		// The payment gateway didn't provide the information. We will do it the hard way.
		$supported_country_codes = $this->get_supported_country_codes();
		// If we can't get the supported countries, we assume onboarding supported to avoid blocking the user.
		if ( is_null( $supported_country_codes ) ) {
			return true;
		}

		return in_array( $country_code, $supported_country_codes, true );
	}

	/**
	 * Get the message to show when the payment gateway does not support onboarding.
	 *
	 * @see self::is_onboarding_supported()
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 * @param string             $country_code    Optional. The country code for which to check.
	 *                                            This should be an ISO 3166-1 alpha-2 country code.
	 *
	 * @return string|null The message to show when the payment gateway does not support onboarding,
	 *                     or null if no specific message should be provided.
	 */
	public function get_onboarding_not_supported_message( WC_Payment_Gateway $payment_gateway, string $country_code = '' ): ?string {
		$message = parent::get_onboarding_not_supported_message( $payment_gateway, $country_code );
		if ( ! is_null( $message ) ) {
			return $message;
		}

		return sprintf(
			/* translators: %s: WooPayments. */
			esc_html__( '%s is not supported in the selected business location.', 'woocommerce' ),
			'WooPayments'
		);
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		if ( $this->proxy->call_function( 'class_exists', 'WC_Payments' ) &&
			$this->proxy->call_function( 'is_callable', 'WC_Payments::mode' ) ) {

			$woopayments_mode = $this->proxy->call_static( 'WC_Payments', 'mode' );
			if ( $this->proxy->call_function( 'method_exists', $woopayments_mode, 'is_test_mode_onboarding' ) &&
				$this->proxy->call_function( 'is_callable', array( $woopayments_mode, 'is_test_mode_onboarding' ) ) ) {

				return $woopayments_mode->is_test_mode_onboarding();
			}
		}

		return parent::is_in_test_mode_onboarding( $payment_gateway );
	}

	/**
	 * Get the onboarding URL for the payment gateway.
	 *
	 * This URL should start or continue the onboarding process.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 * @param string             $return_url      Optional. The URL to return to after onboarding.
	 *                                            This will likely get attached to the onboarding URL.
	 *
	 * @return string The onboarding URL for the payment gateway.
	 */
	public function get_onboarding_url( WC_Payment_Gateway $payment_gateway, string $return_url = '' ): string {
		if ( $this->proxy->call_function( 'class_exists', 'WC_Payments_Account' ) &&
			$this->proxy->call_function( 'is_callable', 'WC_Payments_Account::get_connect_url' ) ) {

			$connect_url = $this->proxy->call_static( 'WC_Payments_Account', 'get_connect_url' );
		} else {
			$connect_url = parent::get_onboarding_url( $payment_gateway, $return_url );
		}

		// Default URL params to set, regardless if they exist.
		$params = array(
			'from'                      => Constants::is_defined( 'WC_Payments_Onboarding_Service::FROM_WCADMIN_PAYMENTS_SETTINGS' ) ? (string) Constants::get_constant( 'WC_Payments_Onboarding_Service::FROM_WCADMIN_PAYMENTS_SETTINGS' ) : 'WCADMIN_PAYMENT_SETTINGS',
			'source'                    => Constants::is_defined( 'WC_Payments_Onboarding_Service::SOURCE_WCADMIN_SETTINGS_PAGE' ) ? (string) Constants::get_constant( 'WC_Payments_Onboarding_Service::SOURCE_WCADMIN_SETTINGS_PAGE' ) : 'wcadmin-settings-page',
			'redirect_to_settings_page' => 'true',
		);

		// First, sanity check to handle existing accounts.
		// Such accounts should keep their current onboarding mode.
		// Do not force things either way.
		if ( $this->is_account_connected( $payment_gateway ) ) {
			return add_query_arg( $params, $connect_url );
		}

		// We don't have an account yet, so the onboarding link is used to kickstart the process.

		// Default to test-account-first onboarding.
		$live_onboarding = false;

		/*
		 * Apply our routing logic to determine if we should do a live onboarding/account.
		 *
		 * For new stores (not yet launched aka in Coming Soon mode),
		 * based on the answers provided in the onboarding profile, we will do live onboarding if:
		 * - Merchant selected “I’m already selling” AND answered either:
		 *   - Yes, I’m selling online.
		 *   - I’m selling both online and offline.
		 *
		 * For launched stores, we will only consider live onboarding if all are true:
		 * - Store is at least 90 days old.
		 * - Store has an active payments gateway (other than WooPayments).
		 * - Store has processed a live electronic payment in the past 90 days (any gateway).
		 *
		 * @see plugins/woocommerce/client/admin/client/core-profiler/pages/UserProfile.tsx for the values.
		 */
		if ( filter_var( get_option( 'woocommerce_coming_soon' ), FILTER_VALIDATE_BOOLEAN ) ) {
			$onboarding_profile = get_option( OnboardingProfile::DATA_OPTION, array() );
			if (
				isset( $onboarding_profile['business_choice'] ) && 'im_already_selling' === $onboarding_profile['business_choice'] &&
				isset( $onboarding_profile['selling_online_answer'] ) && (
					'yes_im_selling_online' === $onboarding_profile['selling_online_answer'] ||
					'im_selling_both_online_and_offline' === $onboarding_profile['selling_online_answer']
				)
			) {
				$live_onboarding = true;
			}
		} elseif (
			WCAdminHelper::is_wc_admin_active_for( 90 * DAY_IN_SECONDS ) &&
			$this->has_enabled_other_ecommerce_gateways() &&
			$this->has_orders()
		) {
			$live_onboarding = true;
		}

		// If we are doing live onboarding, we don't need to add more to the URL.
		// But for test-drive/sandbox mode, we have work to do.
		if ( ! $live_onboarding ) {
			$params['test_drive']                       = 'true';
			$params['auto_start_test_drive_onboarding'] = 'true';
		}

		return add_query_arg( $params, $connect_url );
	}

	/**
	 * Check if the store has any paid orders.
	 *
	 * Currently, we look at the past 90 days and only consider orders
	 * with status `wc-completed`, `wc-processing`, or `wc-refunded`.
	 *
	 * @return boolean Whether the store has any paid orders.
	 */
	private function has_orders(): bool {
		$store_has_orders_transient_name = self::PREFIX . 'store_has_orders';

		// First, get the stored value, if it exists.
		// This way we avoid costly DB queries and API calls.
		$has_orders = get_transient( $store_has_orders_transient_name );
		if ( false !== $has_orders ) {
			return wc_string_to_bool( $has_orders );
		}

		// We need to determine the value.
		// Start with the assumption that the store doesn't have orders in the timeframe we look at.
		$has_orders = false;
		// By default, we will check for new orders every 6 hours.
		$expiration = 6 * HOUR_IN_SECONDS;

		// Get the latest completed, processing, or refunded order.
		$latest_order = wc_get_orders(
			array(
				'status'  => array( OrderInternalStatus::COMPLETED, OrderInternalStatus::PROCESSING, OrderInternalStatus::REFUNDED ),
				'limit'   => 1,
				'orderby' => 'date',
				'order'   => 'DESC',
			)
		);
		if ( ! empty( $latest_order ) ) {
			$latest_order = reset( $latest_order );
			// If the latest order is within the timeframe we look at, we consider the store to have orders.
			// Otherwise, it clearly doesn't have orders.
			if ( $latest_order instanceof WC_Abstract_Order
				&& strtotime( (string) $latest_order->get_date_created() ) >= strtotime( '-90 days' ) ) {

				$has_orders = true;

				// For ultimate efficiency, we will check again after 90 days from the latest order
				// because in all that time we will consider the store to have orders regardless of new orders.
				$expiration = strtotime( (string) $latest_order->get_date_created() ) + 90 * DAY_IN_SECONDS - time();
			}
		}

		// Store the value for future use.
		set_transient( $store_has_orders_transient_name, $has_orders ? 'yes' : 'no', $expiration );

		return $has_orders;
	}

	/**
	 * Check if the store has any other enabled ecommerce gateways.
	 *
	 * We exclude offline payment methods from this check.
	 *
	 * @return bool True if the store has any enabled ecommerce gateways, false otherwise.
	 */
	private function has_enabled_other_ecommerce_gateways(): bool {
		$gateways                 = WC()->payment_gateways()->payment_gateways;
		$other_ecommerce_gateways = array_filter(
			$gateways,
			function ( $gateway ) {
				// Filter out offline gateways and WooPayments.
				return 'yes' === $gateway->enabled &&
					! in_array(
						$gateway->id,
						array( 'woocommerce_payments', ...PaymentsProviders::OFFLINE_METHODS ),
						true
					);
			}
		);

		return ! empty( $other_ecommerce_gateways );
	}

	/**
	 * Determines if the current account is a test account.
	 *
	 * Test accounts are test-drive accounts.
	 * They are different from sandbox accounts (i.e. accounts onboarded in test mode).
	 *
	 * @return bool True if the account is a test account, false otherwise.
	 */
	private function has_test_account(): bool {
		if ( $this->proxy->call_function( 'function_exists', 'wcpay_get_container' ) &&
			$this->proxy->call_function( 'class_exists', 'WC_Payments_Account' ) ) {

			$woopayments_container = $this->proxy->call_function( 'wcpay_get_container' );
			$account_service       = $woopayments_container->get( 'WC_Payments_Account' );
			if ( ! empty( $account_service ) &&
				$this->proxy->call_function( 'method_exists', $account_service, 'get_account_status_data' ) &&
				$this->proxy->call_function( 'is_callable', array( $account_service, 'get_account_status_data' ) ) ) {

				$account_status = $account_service->get_account_status_data();

				return ! empty( $account_status['testDrive'] );
			}
		}

		return false;
	}

	/**
	 * Determines if the current account is a sandbox account.
	 *
	 * Sandbox accounts are accounts that were onboarded in test mode.
	 * They are different from test accounts (i.e. test-drive accounts).
	 *
	 * Sandbox accounts are generally created in development or staging environments when simulating live onboarding.
	 *
	 * @return bool True if the account is a sandbox account, false otherwise.
	 */
	private function has_sandbox_account(): bool {
		if ( $this->proxy->call_function( 'function_exists', 'wcpay_get_container' ) &&
			$this->proxy->call_function( 'class_exists', 'WC_Payments_Account' ) ) {

			$woopayments_container = $this->proxy->call_function( 'wcpay_get_container' );
			$account_service       = $woopayments_container->get( 'WC_Payments_Account' );
			if ( ! empty( $account_service ) &&
				$this->proxy->call_function( 'method_exists', $account_service, 'get_account_status_data' ) &&
				$this->proxy->call_function( 'is_callable', array( $account_service, 'get_account_status_data' ) ) ) {

				$account_status = $account_service->get_account_status_data();

				return empty( $account_status['isLive'] ) && empty( $account_status['testDrive'] );
			}
		}

		return false;
	}

	/**
	 * Get the list of supported country codes for WooPayments.
	 *
	 * @return array|null The list of supported countries as ISO 3166-1 alpha-2 country codes.
	 *                    The country codes are normalized in uppercase.
	 *                    If the list cannot be retrieved, null is returned.
	 */
	private function get_supported_country_codes(): ?array {
		try {
			if ( $this->proxy->call_function( 'class_exists', 'WC_Payments_Utils' ) &&
				$this->proxy->call_function( 'is_callable', 'WC_Payments_Utils::supported_countries' ) ) {

				$supported_country_codes = $this->proxy->call_static( 'WC_Payments_Utils', 'supported_countries' );
				if ( is_array( $supported_country_codes ) ) {
					return array_unique( array_map( 'strtoupper', array_keys( $supported_country_codes ) ) );
				}
			}
		} catch ( Throwable $e ) {
			// This is not a critical error, so we just ignore it.
			// Log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->error(
				'Failed to get the WooPayments supported country codes list: ' . $e->getMessage(),
				array(
					'source' => 'settings-payments',
				)
			);
		}

		return null;
	}

	/**
	 * Get the current state of the store's WPCOM/Jetpack connection.
	 *
	 * @return array The store's WPCOM/Jetpack connection state.
	 */
	private function get_wpcom_connection_state(): array {
		try {
			$wpcom_connection_manager = $this->proxy->get_instance_of( WPCOM_Connection_Manager::class, 'woocommerce' );
		} catch ( \Throwable $e ) {
			// Log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->error(
				'Failed to get the WPCOM/Jetpack Connection Manager instance: ' . $e->getMessage(),
				array(
					'source' => 'settings-payments',
				)
			);

			// Assume no connection.
			return array(
				'wpcom_has_working_connection' => false,
				'wpcom_is_store_connected'     => false,
				'wpcom_has_connected_owner'    => false,
				'wpcom_is_connection_owner'    => false,
			);
		}

		$is_connected        = $wpcom_connection_manager->is_connected();
		$has_connected_owner = $wpcom_connection_manager->has_connected_owner();

		return array(
			'wpcom_has_working_connection' => $is_connected && $has_connected_owner,
			'wpcom_is_store_connected'     => $is_connected,
			'wpcom_has_connected_owner'    => $has_connected_owner,
			'wpcom_is_connection_owner'    => $has_connected_owner && $wpcom_connection_manager->is_connection_owner(),
		);
	}
}
PK     [1][Qm    )  Admin/Settings/PaymentsProviders/Eway.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * Eway payment gateway provider class.
 *
 * This class handles all the custom logic for the Eway payment gateway provider.
 */
class Eway extends PaymentGateway {

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			return ! empty( $payment_gateway->get_option( 'customer_api' ) ) && ! empty( $payment_gateway->get_option( 'customer_password' ) );
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway has an account connected: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_account_connected( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode, false otherwise.
	 */
	public function is_in_test_mode( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_eway_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_test_mode( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in dev mode.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in dev mode, false otherwise.
	 */
	public function is_in_dev_mode( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_eway_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_dev_mode( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_eway_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_test_mode_onboarding( $payment_gateway );
	}

	/**
	 * Check if the Eway payment gateway is in test/sandbox mode.
	 *
	 * There are two different environments: test/sandbox and production.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return ?bool True if the payment gateway is in sandbox mode, false otherwise.
	 *               Null if the environment could not be determined.
	 */
	private function is_eway_in_sandbox_mode( WC_Payment_Gateway $payment_gateway ): ?bool {
		try {
			// Prefer option over property.
			$raw_option = $payment_gateway->get_option( 'testmode' );
			if ( '' !== $raw_option && null !== $raw_option ) {
				return \wc_string_to_bool( $raw_option );
			}
			if ( isset( $payment_gateway->testmode ) ) {
				return \wc_string_to_bool( $payment_gateway->testmode );
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway is in sandbox mode: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		// Let the caller know that we couldn't determine the environment.
		return null;
	}
}
PK     [1]ϡ    -  Admin/Settings/PaymentsProviders/HelioPay.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * HelioPay payment gateway provider class.
 *
 * This class handles all the custom logic for the HelioPay payment gateway provider.
 */
class HelioPay extends PaymentGateway {

	/**
	 * Try to determine if the payment gateway is in test mode.
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode, false otherwise.
	 */
	public function is_in_test_mode( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			if ( defined( 'HELIO_DEVNET_ENABLED' ) ) {
				return wc_string_to_bool( $payment_gateway->get_option( \HELIO_DEVNET_ENABLED ) );
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway is in test mode: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_in_test_mode( $payment_gateway );
	}

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		try {
			if ( $this->is_in_test_mode( $payment_gateway ) ) {
				if ( defined( 'HELIO_API_KEY_DEVNET' ) &&
					defined( 'HELIO_API_SECRET_DEVNET' ) ) {

					return ! empty( $payment_gateway->get_option( \HELIO_API_KEY_DEVNET ) ) && ! empty( $payment_gateway->get_option( \HELIO_API_SECRET_DEVNET ) );
				}
			} elseif ( defined( 'HELIO_API_KEY_MAINNET' ) &&
					defined( 'HELIO_API_SECRET_MAINNET' ) ) {

					return ! empty( $payment_gateway->get_option( \HELIO_API_KEY_MAINNET ) ) && ! empty( $payment_gateway->get_option( \HELIO_API_SECRET_MAINNET ) );
			}
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway has an account connected: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_account_connected( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		// Test mode is actually sandbox mode for HelioPay, affecting the API credentials used.
		return $this->is_in_test_mode( $payment_gateway );
	}
}
PK     [1]    /  Admin/Settings/PaymentsProviders/GoCardless.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * GoCardless payment gateway provider class.
 *
 * This class handles all the custom logic for the GoCardless payment gateway provider.
 */
class GoCardless extends PaymentGateway {

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		try {
				return ! empty( $payment_gateway->get_option( 'access_token', '' ) );
		} catch ( Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway has an account connected: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		return parent::is_account_connected( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		// Test mode is actually sandbox mode for GoCardless, affecting the API keys used.
		return $this->is_in_test_mode( $payment_gateway );
	}
}
PK     [1]"W\    +  Admin/Settings/PaymentsProviders/Paymob.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * Paymob payment gateway provider class.
 *
 * This class handles all the custom logic for the Paymob payment gateway provider.
 */
class Paymob extends PaymentGateway {

	/**
	 * Check if the payment gateway needs setup.
	 *
	 * Note: We are overriding the parent method to avoid infinite recursion with the is_account_connected method.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway needs setup, false otherwise.
	 */
	public function needs_setup( WC_Payment_Gateway $payment_gateway ): bool {
		$needs_setup = wc_string_to_bool( $payment_gateway->needs_setup() );
		// If we get a true value, it means the gateway needs setup.
		if ( $needs_setup ) {
			return true;
		}

		// If we reach here, just assume that the gateway does not need setup.
		return false;
	}

	/**
	 * Try to determine if the payment gateway is in test mode.
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode, false otherwise.
	 */
	public function is_in_test_mode( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_paymob_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_test_mode( $payment_gateway );
	}

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		// The Paymob gateway ties needs_setup only to the API keys, so if they are set, we consider the account connected.
		return ! $this->needs_setup( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_paymob_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_test_mode_onboarding( $payment_gateway );
	}

	/**
	 * Check if the Paymob payment gateway is in sandbox mode.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return ?bool True if the payment gateway is in sandbox mode, false otherwise.
	 *               Null if the environment could not be determined.
	 */
	private function is_paymob_in_sandbox_mode( WC_Payment_Gateway $payment_gateway ): ?bool {
		try {
			// Unfortunately, Paymob does not provide a standard way to determine if the gateway is in sandbox mode.
			$options = get_option( 'woocommerce_paymob-main_settings', array() );
			return 'test' === ( $options['mode'] ?? 'test' );
		} catch ( \Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway is in sandbox mode: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		// Let the caller know that we couldn't determine the environment.
		return null;
	}
}
PK     [1]}e  e  0  Admin/Settings/PaymentsProviders/MercadoPago.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;

use Automattic\WooCommerce\Internal\Admin\Settings\Utils;
use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use WC_Payment_Gateway;

defined( 'ABSPATH' ) || exit;

/**
 * MercadoPago payment gateway provider class.
 *
 * This class handles all the custom logic for the MercadoPago payment gateway provider.
 */
class MercadoPago extends PaymentGateway {

	/**
	 * Check if the payment gateway needs setup.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway needs setup, false otherwise.
	 */
	public function needs_setup( WC_Payment_Gateway $payment_gateway ): bool {
		$is_onboarded = $this->is_mercado_pago_onboarded( $payment_gateway );
		if ( ! is_null( $is_onboarded ) ) {
			return ! $is_onboarded;
		}

		return parent::needs_setup( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode.
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode, false otherwise.
	 */
	public function is_in_test_mode( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_mercado_pago_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_test_mode( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in dev mode.
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in dev mode, false otherwise.
	 */
	public function is_in_dev_mode( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_mercado_pago_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_dev_mode( $payment_gateway );
	}

	/**
	 * Check if the payment gateway has a payments processor account connected.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway account is connected, false otherwise.
	 *              If the payment gateway does not provide the information, it will return true.
	 */
	public function is_account_connected( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_mercado_pago_onboarded( $payment_gateway ) ?? parent::is_account_connected( $payment_gateway );
	}

	/**
	 * Check if the payment gateway has completed the onboarding process.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway has completed the onboarding process, false otherwise.
	 *              If the payment gateway does not provide the information,
	 *              it will infer it from having a connected account.
	 */
	public function is_onboarding_completed( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_mercado_pago_onboarded( $payment_gateway ) ?? parent::is_onboarding_completed( $payment_gateway );
	}

	/**
	 * Try to determine if the payment gateway is in test mode onboarding (aka sandbox or test-drive).
	 *
	 * This is a best-effort attempt, as there is no standard way to determine this.
	 * Trust the true value, but don't consider a false value as definitive.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is in test mode onboarding, false otherwise.
	 */
	public function is_in_test_mode_onboarding( WC_Payment_Gateway $payment_gateway ): bool {
		return $this->is_mercado_pago_in_sandbox_mode( $payment_gateway ) ?? parent::is_in_test_mode_onboarding( $payment_gateway );
	}

	/**
	 * Check if the MercadoPago payment gateway is in sandbox mode.
	 *
	 * For MercadoPago, there are two different environments: sandbox and production.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return ?bool True if the payment gateway is in sandbox mode, false otherwise.
	 *               Null if the environment could not be determined.
	 */
	private function is_mercado_pago_in_sandbox_mode( WC_Payment_Gateway $payment_gateway ): ?bool {
		global $mercadopago;

		try {
			// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
			if ( class_exists( '\MercadoPago\Woocommerce\WoocommerceMercadoPago' ) &&
				class_exists( '\MercadoPago\Woocommerce\Configs\Store' ) &&
				$mercadopago instanceof \MercadoPago\Woocommerce\WoocommerceMercadoPago &&
				! is_null( $mercadopago->storeConfig ) &&
				$mercadopago->storeConfig instanceof \MercadoPago\Woocommerce\Configs\Store &&
				is_callable( array( $mercadopago->storeConfig, 'isTestMode' ) )
			) {
				return wc_string_to_bool( $mercadopago->storeConfig->isTestMode() );

			}
		} catch ( \Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway is in sandbox mode: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		// Let the caller know that we couldn't determine the environment.
		return null;
	}

	/**
	 * Check if the MercadoPago payment gateway is onboarded.
	 *
	 * For MercadoPago, there are two different environments: sandbox/test and production/sale.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return ?bool True if the payment gateway is onboarded, false otherwise.
	 *               Null if we failed to determine the onboarding status.
	 */
	private function is_mercado_pago_onboarded( WC_Payment_Gateway $payment_gateway ): ?bool {
		global $mercadopago;

		try {
			// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
			if ( class_exists( '\MercadoPago\Woocommerce\WoocommerceMercadoPago' ) &&
				class_exists( '\MercadoPago\Woocommerce\Configs\Seller' ) &&
				$mercadopago instanceof \MercadoPago\Woocommerce\WoocommerceMercadoPago &&
				! is_null( $mercadopago->sellerConfig ) &&
				$mercadopago->sellerConfig instanceof \MercadoPago\Woocommerce\Configs\Seller &&
				is_callable( array( $mercadopago->sellerConfig, 'getCredentialsPublicKey' ) ) &&
				is_callable( array( $mercadopago->sellerConfig, 'getCredentialsAccessToken' ) )
			) {
				return ! empty( $mercadopago->sellerConfig->getCredentialsPublicKey() ) &&
						! empty( $mercadopago->sellerConfig->getCredentialsAccessToken() );

			}
		} catch ( \Throwable $e ) {
			// Do nothing but log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->debug(
				'Failed to determine if gateway is onboarded: ' . $e->getMessage(),
				array(
					'gateway'   => $payment_gateway->id,
					'source'    => 'settings-payments',
					'exception' => $e,
				)
			);
		}

		// Let the caller know that we couldn't determine the onboarding status.
		return null;
	}
}
PK     [1] =!  !  *  Admin/Settings/Exceptions/ApiException.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\Exceptions;

/**
 * ApiException class.
 */
class ApiException extends \Exception {
	/**
	 * Sanitized error code.
	 *
	 * @var string
	 */
	public string $error_code;

	/**
	 * Additional error data.
	 *
	 * @var array
	 */
	public array $additional_data = array();

	/**
	 * Setup exception.
	 *
	 * @param string $error_code       Machine-readable error code, e.g `woocommerce_invalid_step_id`.
	 * @param string $message          User-friendly translated error message, e.g. 'Step ID is invalid'.
	 * @param int    $http_status_code Optional. Proper HTTP status code to respond with.
	 *                                 Defaults to 400 (Bad request).
	 * @param array  $additional_data  Optional. Extra data (key value pairs) to expose in the error response.
	 *                                 Defaults to empty array.
	 */
	public function __construct( string $error_code, string $message, int $http_status_code = 400, array $additional_data = array() ) {
		$this->error_code      = $error_code;
		$this->additional_data = array_filter( (array) $additional_data );
		parent::__construct( $message, $http_status_code );
	}

	/**
	 * Returns the error code.
	 *
	 * @return string The machine-readable error code.
	 */
	public function getErrorCode(): string {
		return $this->error_code;
	}

	/**
	 * Returns additional error data.
	 *
	 * @return array Extra data (key value pairs).
	 */
	public function getAdditionalData(): array {
		return $this->additional_data;
	}
}
PK     [1]0_      2  Admin/Settings/Exceptions/ApiArgumentException.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings\Exceptions;

/**
 * ApiArgumentException class.
 */
class ApiArgumentException extends ApiException {}
PK     [1]IDt    )  Admin/Settings/PaymentsRestController.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings;

use Automattic\WooCommerce\Internal\RestApiControllerBase;
use Automattic\WooCommerce\Internal\Utilities\ArrayUtil;
use Exception;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;

/**
 * Controller for the REST endpoints to service the Payments settings page.
 *
 * @internal
 */
class PaymentsRestController extends RestApiControllerBase {

	/**
	 * The root namespace for the JSON REST API endpoints.
	 *
	 * @var string
	 */
	protected string $route_namespace = 'wc-admin';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected string $rest_base = 'settings/payments';

	/**
	 * The payments settings page service.
	 *
	 * @var Payments
	 */
	private Payments $payments;

	/**
	 * Get the WooCommerce REST API namespace for the class.
	 *
	 * @return string
	 */
	protected function get_rest_api_namespace(): string {
		return 'wc-admin-settings-payments';
	}

	/**
	 * Register the REST API endpoints handled by this controller.
	 *
	 * @param bool $override Whether to override the existing routes. Useful for testing.
	 */
	public function register_routes( bool $override = false ) {
		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/country',
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'set_country' ),
					'validation_callback' => 'rest_validate_request_arg',
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => array(
						'location' => array(
							'description'       => esc_html__( 'The ISO3166 alpha-2 country code to save for the current user.', 'woocommerce' ),
							'type'              => 'string',
							'pattern'           => '[a-zA-Z]{2}', // Two alpha characters.
							'required'          => true,
							'validate_callback' => fn( $value, $request ) => $this->check_location_arg( $value, $request ),
						),
					),
				),
			),
			$override
		);
		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/providers',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'get_providers' ),
					'validation_callback' => 'rest_validate_request_arg',
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => array(
						'location' => array(
							'description'       => esc_html__( 'ISO3166 alpha-2 country code. Defaults to WooCommerce\'s base location country.', 'woocommerce' ),
							'type'              => 'string',
							'pattern'           => '[a-zA-Z]{2}', // Two alpha characters.
							'required'          => false,
							'validate_callback' => fn( $value, $request ) => $this->check_location_arg( $value, $request ),
						),
					),
				),
				'schema' => fn() => $this->get_schema_for_get_payment_providers(),
			),
			$override
		);
		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/providers/order',
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'update_providers_order' ),
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => array(
						'order_map' => array(
							'description'       => esc_html__( 'A map of provider ID to integer values representing the sort order.', 'woocommerce' ),
							'type'              => 'object',
							'required'          => true,
							'validate_callback' => fn( $value ) => $this->check_providers_order_map_arg( $value ),
							'sanitize_callback' => fn( $value ) => $this->sanitize_providers_order_arg( $value ),
						),
					),
				),
			),
			$override
		);
		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/suggestion/(?P<id>[\w\d\-]+)/attach',
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'attach_payment_extension_suggestion' ),
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
				),
			),
			$override
		);
		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/suggestion/(?P<id>[\w\d\-]+)/hide',
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'hide_payment_extension_suggestion' ),
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
				),
			),
			$override
		);
		register_rest_route(
			$this->route_namespace,
			'/' . $this->rest_base . '/suggestion/(?P<suggestion_id>[\w\d\-]+)/incentive/(?P<incentive_id>[\w\d\-]+)/dismiss',
			array(
				array(
					'methods'             => \WP_REST_Server::EDITABLE,
					'callback'            => fn( $request ) => $this->run( $request, 'dismiss_payment_extension_suggestion_incentive' ),
					'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
					'args'                => array(
						'context'      => array(
							'description'       => esc_html__( 'The context ID for which to dismiss the incentive. If not provided, will dismiss the incentive for all contexts.', 'woocommerce' ),
							'type'              => 'string',
							'required'          => false,
							'sanitize_callback' => 'sanitize_key',
						),
						'do_not_track' => array(
							'description'       => esc_html__( 'If true, the incentive dismissal will be ignored by tracking.', 'woocommerce' ),
							'type'              => 'boolean',
							'required'          => false,
							'default'           => false,
							'sanitize_callback' => 'rest_sanitize_boolean',
						),
					),
				),
			),
			$override
		);
	}

	/**
	 * Initialize the class instance.
	 *
	 * @param Payments $payments The payments settings page service.
	 *
	 * @internal
	 */
	final public function init( Payments $payments ): void {
		$this->payments = $payments;
	}

	/**
	 * Get the payment providers for the given location.
	 *
	 * @param WP_REST_Request $request The request object.
	 * @return WP_Error|WP_REST_Response
	 */
	protected function get_providers( WP_REST_Request $request ) {
		$location = $request->get_param( 'location' );
		if ( empty( $location ) ) {
			// Fall back to the providers country if no location is provided.
			$location = $this->payments->get_country();
		}

		try {
			$providers = $this->payments->get_payment_providers( $location );
		} catch ( Exception $e ) {
			return new WP_Error( 'woocommerce_rest_payment_providers_error', $e->getMessage(), array( 'status' => 500 ) );
		}

		try {
			$suggestions = $this->get_extension_suggestions( $location );
		} catch ( Exception $e ) {
			return new WP_Error( 'woocommerce_rest_payment_providers_error', $e->getMessage(), array( 'status' => 500 ) );
		}

		// Separate the offline PMs from the main providers list.
		$offline_payment_providers = array_values(
			array_filter(
				$providers,
				fn( $provider ) => PaymentsProviders::TYPE_OFFLINE_PM === $provider['_type']
			)
		);
		$providers                 = array_values(
			array_filter(
				$providers,
				fn( $provider ) => PaymentsProviders::TYPE_OFFLINE_PM !== $provider['_type']
			)
		);

		$response = array(
			'providers'               => $providers,
			'offline_payment_methods' => $offline_payment_providers,
			'suggestions'             => $suggestions,
			'suggestion_categories'   => $this->payments->get_payment_extension_suggestion_categories(),
		);

		return rest_ensure_response( $this->prepare_payment_providers_response( $response ) );
	}

	/**
	 * Set the country for the payment providers.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_Error|WP_REST_Response
	 */
	protected function set_country( WP_REST_Request $request ) {
		$location = $request->get_param( 'location' );

		$result = $this->payments->set_country( $location );

		return rest_ensure_response( array( 'success' => $result ) );
	}

	/**
	 * Update the payment providers order.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_Error|WP_REST_Response
	 */
	protected function update_providers_order( WP_REST_Request $request ) {
		$order_map = $request->get_param( 'order_map' );

		$result = $this->payments->update_payment_providers_order_map( $order_map );

		return rest_ensure_response( array( 'success' => $result ) );
	}

	/**
	 * Attach a payment extension suggestion.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_Error|WP_REST_Response
	 */
	protected function attach_payment_extension_suggestion( WP_REST_Request $request ) {
		$suggestion_id = $request->get_param( 'id' );

		try {
			$result = $this->payments->attach_payment_extension_suggestion( $suggestion_id );
		} catch ( Exception $e ) {
			return new WP_Error( 'woocommerce_rest_payment_extension_suggestion_error', $e->getMessage(), array( 'status' => 400 ) );
		}

		return rest_ensure_response( array( 'success' => $result ) );
	}

	/**
	 * Hide a payment extension suggestion.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_Error|WP_REST_Response
	 */
	protected function hide_payment_extension_suggestion( WP_REST_Request $request ) {
		$suggestion_id = $request->get_param( 'id' );

		try {
			$result = $this->payments->hide_payment_extension_suggestion( $suggestion_id );
		} catch ( Exception $e ) {
			return new WP_Error( 'woocommerce_rest_payment_extension_suggestion_error', $e->getMessage(), array( 'status' => 400 ) );
		}

		return rest_ensure_response( array( 'success' => $result ) );
	}

	/**
	 * Dismiss a payment extension suggestion incentive.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return WP_Error|WP_REST_Response
	 */
	protected function dismiss_payment_extension_suggestion_incentive( WP_REST_Request $request ) {
		$suggestion_id = $request->get_param( 'suggestion_id' );
		$incentive_id  = $request->get_param( 'incentive_id' );
		$context       = $request->get_param( 'context' ) ?? 'all';
		$do_not_track  = $request->get_param( 'do_not_track' ) ?? false;

		try {
			$result = $this->payments->dismiss_extension_suggestion_incentive( $suggestion_id, $incentive_id, $context, $do_not_track );
		} catch ( Exception $e ) {
			return new WP_Error( 'woocommerce_rest_payment_extension_suggestion_incentive_error', $e->getMessage(), array( 'status' => 400 ) );
		}

		return rest_ensure_response( array( 'success' => $result ) );
	}

	/**
	 * Get the payment extension suggestions (other) for the given location.
	 *
	 * @param string $location The location for which the suggestions are being fetched.
	 *
	 * @return array[]   The payment extension suggestions for the given location,
	 *                   excluding the ones part of the main providers list.
	 * @throws Exception If there are malformed or invalid suggestions.
	 */
	private function get_extension_suggestions( string $location ): array {
		// If the requesting user can't install plugins, we don't suggest any extensions.
		if ( ! current_user_can( 'install_plugins' ) ) {
			return array();
		}

		$suggestions = $this->payments->get_payment_extension_suggestions( $location );

		return $suggestions['other'] ?? array();
	}

	/**
	 * General permissions check for payments settings REST API endpoint.
	 *
	 * @param WP_REST_Request $request The request for which the permission is checked.
	 * @return bool|WP_Error True if the current user has the capability, otherwise an "Unauthorized" error or False if no error is available for the request method.
	 */
	private function check_permissions( WP_REST_Request $request ) {
		$context = 'read';
		if ( 'POST' === $request->get_method() ) {
			$context = 'edit';
		} elseif ( 'DELETE' === $request->get_method() ) {
			$context = 'delete';
		}

		if ( wc_rest_check_manager_permissions( 'payment_gateways', $context ) ) {
			return true;
		}

		$error_information = $this->get_authentication_error_by_method( $request->get_method() );
		if ( is_null( $error_information ) ) {
			return false;
		}

		return new WP_Error(
			$error_information['code'],
			$error_information['message'],
			array( 'status' => rest_authorization_required_code() )
		);
	}

	/**
	 * Validate the location argument.
	 *
	 * @param mixed           $value   Value of the argument.
	 * @param WP_REST_Request $request The current request object.
	 *
	 * @return WP_Error|true True if the location argument is valid, otherwise a WP_Error object.
	 */
	private function check_location_arg( $value, WP_REST_Request $request ) {
		// If the 'location' argument is not a string return an error.
		if ( ! is_string( $value ) ) {
			return new WP_Error( 'rest_invalid_param', esc_html__( 'The location argument must be a string.', 'woocommerce' ), array( 'status' => 400 ) );
		}

		// Get the registered attributes for this endpoint request.
		$attributes = $request->get_attributes();

		// Grab the location param schema.
		$args = $attributes['args']['location'];

		// If the location param doesn't match the regex pattern then we should return an error as well.
		if ( ! preg_match( '/^' . $args['pattern'] . '$/', $value ) ) {
			return new WP_Error( 'rest_invalid_param', esc_html__( 'The location argument must be a valid ISO3166 alpha-2 country code.', 'woocommerce' ), array( 'status' => 400 ) );
		}

		return true;
	}

	/**
	 * Validate the providers order map argument.
	 *
	 * @param mixed $value Value of the argument.
	 *
	 * @return WP_Error|true True if the providers order map argument is valid, otherwise a WP_Error object.
	 */
	private function check_providers_order_map_arg( $value ) {
		if ( ! is_array( $value ) ) {
			return new WP_Error( 'rest_invalid_param', esc_html__( 'The ordering argument must be an object.', 'woocommerce' ), array( 'status' => 400 ) );
		}

		foreach ( $value as $provider_id => $order ) {
			if ( ! is_string( $provider_id ) || ! is_numeric( $order ) ) {
				return new WP_Error( 'rest_invalid_param', esc_html__( 'The ordering argument must be an object with provider IDs as keys and numeric values as values.', 'woocommerce' ), array( 'status' => 400 ) );
			}

			if ( $this->sanitize_provider_id( $provider_id ) !== $provider_id ) {
				return new WP_Error( 'rest_invalid_param', esc_html__( 'The provider ID must be a string with only ASCII letters, digits, underscores, and dashes.', 'woocommerce' ), array( 'status' => 400 ) );
			}

			if ( false === filter_var( $order, FILTER_VALIDATE_INT ) ) {
				return new WP_Error( 'rest_invalid_param', esc_html__( 'The order value must be an integer.', 'woocommerce' ), array( 'status' => 400 ) );
			}
		}

		return true;
	}

	/**
	 * Sanitize the providers ordering argument.
	 *
	 * @param array $value Value of the argument.
	 *
	 * @return array
	 */
	private function sanitize_providers_order_arg( array $value ): array {
		// Sanitize the ordering object to ensure that the order values are integers and the provider IDs are safe strings.
		foreach ( $value as $provider_id => $order ) {
			$id           = $this->sanitize_provider_id( $provider_id );
			$value[ $id ] = intval( $order );
		}

		return $value;
	}

	/**
	 * Sanitize a provider ID.
	 *
	 * This method ensures that the provider ID is a safe string by removing any unwanted characters.
	 * It strips all HTML tags, removes accents, percent-encoded characters, and HTML entities,
	 * and allows only lowercase and uppercase letters, digits, underscores, and dashes.
	 *
	 * @param string $provider_id The provider ID to sanitize.
	 *
	 * @return string The sanitized provider ID.
	 */
	private function sanitize_provider_id( string $provider_id ): string {
		$provider_id = wp_strip_all_tags( $provider_id );
		$provider_id = remove_accents( $provider_id );
		// Remove percent-encoded characters.
		$provider_id = preg_replace( '|%([a-fA-F0-9][a-fA-F0-9])|', '', $provider_id );
		// Remove HTML entities.
		$provider_id = preg_replace( '/&.+?;/', '', $provider_id );

		// Only lowercase and uppercase ASCII letters, digits, underscores, and dashes are allowed.
		$provider_id = preg_replace( '|[^a-z0-9_\-]|i', '', $provider_id );

		return $provider_id;
	}

	/**
	 * Prepare the response for the GET payment providers request.
	 *
	 * @param array $response The response to prepare.
	 *
	 * @return array The prepared response.
	 */
	private function prepare_payment_providers_response( array $response ): array {
		$response = $this->prepare_payment_providers_response_recursive( $response, $this->get_schema_for_get_payment_providers() );

		$response['providers']   = $this->add_provider_links( $response['providers'] );
		$response['suggestions'] = $this->add_suggestion_links( $response['suggestions'] );

		return $response;
	}

	/**
	 * Recursively prepare the response items for the GET payment providers request.
	 *
	 * @param mixed $response_item The response item to prepare.
	 * @param array $schema        The schema to use for preparing the response.
	 *
	 * @return mixed The prepared response item.
	 */
	private function prepare_payment_providers_response_recursive( $response_item, array $schema ) {
		if ( is_null( $response_item ) ) {
			return null;
		}

		if ( ! array_key_exists( 'properties', $schema ) ||
			! is_array( $schema['properties'] ) ) {

			// Filter out null values for loosely defined schema types.
			if ( is_array( $response_item ) ) {
				return ArrayUtil::filter_null_values_recursive( $response_item );
			}
			return $response_item;
		}

		$prepared_response = array();
		foreach ( $schema['properties'] as $key => $property_schema ) {
			if ( is_array( $response_item ) && array_key_exists( $key, $response_item ) ) {
				if ( is_array( $property_schema ) && array_key_exists( 'properties', $property_schema ) ) {
					$prepared_response[ $key ] = $this->prepare_payment_providers_response_recursive( $response_item[ $key ], $property_schema );
				} elseif ( is_array( $property_schema ) && array_key_exists( 'items', $property_schema ) ) {
					$prepared_response[ $key ] = array_map(
						fn( $item ) => $this->prepare_payment_providers_response_recursive( $item, $property_schema['items'] ),
						$response_item[ $key ]
					);
				} else {
					$prepared_response[ $key ] = $response_item[ $key ];
				}
			}
		}

		// Ensure the order is the same as in the schema.
		$prepared_response = array_merge( array_fill_keys( array_keys( $schema['properties'] ), null ), $prepared_response );

		// Remove any null values from the response.
		return ArrayUtil::filter_null_values_recursive( $prepared_response );
	}

	/**
	 * Add links to providers list items.
	 *
	 * @param array $providers The providers list.
	 *
	 * @return array The providers list with added links.
	 */
	private function add_provider_links( array $providers ): array {
		foreach ( $providers as $key => $provider ) {
			if ( empty( $provider['_links'] ) ) {
				$providers[ $key ]['_links'] = array();
			}

			// If this is a suggestion, add dedicated links.
			if ( ! empty( $provider['_type'] ) &&
				PaymentsProviders::TYPE_SUGGESTION === $provider['_type'] &&
				! empty( $provider['_suggestion_id'] )
			) {
				$providers[ $key ]['_links']['attach'] = array(
					'href' => rest_url( sprintf( '/%s/%s/suggestion/%s/attach', $this->route_namespace, $this->rest_base, $provider['_suggestion_id'] ) ),
				);
				$providers[ $key ]['_links']['hide']   = array(
					'href' => rest_url( sprintf( '/%s/%s/suggestion/%s/hide', $this->route_namespace, $this->rest_base, $provider['_suggestion_id'] ) ),
				);
			}

			// If we have an incentive, add a link to dismiss it.
			if ( ! empty( $provider['_incentive'] ) && ! empty( $provider['_suggestion_id'] ) ) {
				if ( empty( $provider['_incentive']['_links'] ) ) {
					$providers[ $key ]['_incentive']['_links'] = array();
				}

				$providers[ $key ]['_incentive']['_links']['dismiss'] = array(
					'href' => rest_url( sprintf( '/%s/%s/suggestion/%s/incentive/%s/dismiss', $this->route_namespace, $this->rest_base, $provider['_suggestion_id'], $provider['_incentive']['id'] ) ),
				);
			}
		}

		return $providers;
	}

	/**
	 * Add links to suggestions list items.
	 *
	 * @param array $suggestions The suggestions list.
	 *
	 * @return array The suggestions list with added links.
	 */
	private function add_suggestion_links( array $suggestions ): array {
		foreach ( $suggestions as $key => $suggestion ) {
			if ( empty( $suggestion['id'] ) ) {
				continue;
			}

			if ( empty( $suggestion['_links'] ) ) {
				$suggestions[ $key ]['_links'] = array();
			}

			$suggestions[ $key ]['_links']['attach'] = array(
				'href' => rest_url( sprintf( '/%s/%s/suggestion/%s/attach', $this->route_namespace, $this->rest_base, $suggestion['id'] ) ),
			);
			$suggestions[ $key ]['_links']['hide']   = array(
				'href' => rest_url( sprintf( '/%s/%s/suggestion/%s/hide', $this->route_namespace, $this->rest_base, $suggestion['id'] ) ),
			);
		}

		return $suggestions;
	}

	/**
	 * Get the schema for the GET payment providers request.
	 *
	 * @return array[]
	 */
	private function get_schema_for_get_payment_providers(): array {
		$schema               = array(
			'$schema' => 'http://json-schema.org/draft-04/schema#',
			'title'   => 'WooCommerce Settings Payments providers for the given location.',
			'type'    => 'object',
		);
		$schema['properties'] = array(
			'providers'               => array(
				'type'        => 'array',
				'description' => esc_html__( 'The ordered providers list. This includes registered payment gateways, suggestions, and offline payment methods group entry. The individual offline payment methods are separate.', 'woocommerce' ),
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'items'       => $this->get_schema_for_payment_provider(),
			),
			'offline_payment_methods' => array(
				'type'        => 'array',
				'description' => esc_html__( 'The ordered offline payment methods providers list.', 'woocommerce' ),
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'items'       => $this->get_schema_for_payment_provider(),
			),
			'suggestions'             => array(
				'type'        => 'array',
				'description' => esc_html__( 'The list of suggestions, excluding the ones part of the providers list.', 'woocommerce' ),
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'items'       => $this->get_schema_for_suggestion(),
			),
			'suggestion_categories'   => array(
				'type'        => 'array',
				'description' => esc_html__( 'The suggestion categories.', 'woocommerce' ),
				'context'     => array( 'view', 'edit' ),
				'readonly'    => true,
				'items'       => array(
					'type'        => 'object',
					'description' => esc_html__( 'A suggestion category.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
					'properties'  => array(
						'id'          => array(
							'type'        => 'string',
							'description' => esc_html__( 'The unique identifier for the category.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
						'_priority'   => array(
							'type'        => 'integer',
							'description' => esc_html__( 'The priority of the category.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
						'title'       => array(
							'type'        => 'string',
							'description' => esc_html__( 'The title of the category.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
						'description' => array(
							'type'        => 'string',
							'description' => esc_html__( 'The description of the category.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),

					),
				),
			),
		);

		return $schema;
	}

	/**
	 * Get the schema for a payment provider.
	 *
	 * @return array The schema for a payment provider.
	 */
	private function get_schema_for_payment_provider(): array {
		return array(
			'type'        => 'object',
			'description' => esc_html__( 'A payment provider in the context of the main Payments Settings page list.', 'woocommerce' ),
			'properties'  => array(
				'id'             => array(
					'type'        => 'string',
					'description' => esc_html__( 'The unique identifier for the provider.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'_order'         => array(
					'type'        => 'integer',
					'description' => esc_html__( 'The sort order of the provider.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'_type'          => array(
					'type'        => 'string',
					'description' => esc_html__( 'The type of payment provider. Use this to differentiate between the various items in the list and determine their intended use.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'title'          => array(
					'type'        => 'string',
					'description' => esc_html__( 'The title of the provider.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'description'    => array(
					'type'        => 'string',
					'description' => esc_html__( 'The description of the provider.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'supports'       => array(
					'description' => esc_html__( 'Supported features for this provider.', 'woocommerce' ),
					'type'        => 'array',
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
					'items'       => array(
						'type' => 'string',
					),
				),
				'plugin'         => array(
					'type'        => 'object',
					'description' => esc_html__( 'The corresponding plugin details of the provider.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
					'properties'  => array(
						'_type'  => array(
							'type'        => 'string',
							'enum'        => array(
								PaymentsProviders::EXTENSION_TYPE_WPORG,
								PaymentsProviders::EXTENSION_TYPE_MU_PLUGIN,
								PaymentsProviders::EXTENSION_TYPE_THEME,
								PaymentsProviders::EXTENSION_TYPE_UNKNOWN,
							),
							'description' => esc_html__( 'The type of the containing entity. Generally this is a regular plugin but it can also be a non-standard entity like a theme or a must-user plugin.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
						'slug'   => array(
							'type'        => 'string',
							'description' => esc_html__( 'The slug of the containing entity.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
						'file'   => array(
							'type'        => 'string',
							'description' => esc_html__( 'The plugin main file. This is a relative path to the plugins directory.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
						'status' => array(
							'type'        => 'string',
							'enum'        => array(
								PaymentsProviders::EXTENSION_NOT_INSTALLED,
								PaymentsProviders::EXTENSION_INSTALLED,
								PaymentsProviders::EXTENSION_ACTIVE,
							),
							'description' => esc_html__( 'The status of the containing entity.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
					),
				),
				'image'          => array(
					'type'        => 'string',
					'description' => esc_html__( 'The URL of the provider image.', 'woocommerce' ),
					'readonly'    => true,
				),
				'icon'           => array(
					'type'        => 'string',
					'description' => esc_html__( 'The URL of the provider icon (square aspect ratio - 72px by 72px).', 'woocommerce' ),
					'readonly'    => true,
				),
				'links'          => array(
					'type'        => 'array',
					'description' => esc_html__( 'Links for the provider.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
					'items'       => array(
						'type'       => 'object',
						'properties' => array(
							'_type' => array(
								'type'        => 'string',
								'description' => esc_html__( 'The type of the link.', 'woocommerce' ),
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
							'url'   => array(
								'type'        => 'string',
								'description' => esc_html__( 'The URL of the link.', 'woocommerce' ),
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
						),
					),
				),
				'state'          => array(
					'type'        => 'object',
					'description' => esc_html__( 'The general state of the provider with regards to it\'s payments processing.', 'woocommerce' ),
					'properties'  => array(
						'enabled'           => array(
							'type'        => 'boolean',
							'description' => esc_html__( 'Whether the provider is enabled for use on checkout.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
						'account_connected' => array(
							'type'        => 'boolean',
							'description' => esc_html__( 'Whether the provider has a payments processing account connected.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
						'needs_setup'       => array(
							'type'        => 'boolean',
							'description' => esc_html__( 'Whether the provider needs setup.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
						'test_mode'         => array(
							'type'        => 'boolean',
							'description' => esc_html__( 'Whether the provider is in test mode for payments processing.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
						'dev_mode'          => array(
							'type'        => 'boolean',
							'description' => esc_html__( 'Whether the provider is in dev mode. Having this true usually leads to forcing test payments. ', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
					),
				),
				'management'     => array(
					'type'        => 'object',
					'description' => esc_html__( 'The management details of the provider.', 'woocommerce' ),
					'properties'  => array(
						'_links' => array(
							'type'       => 'object',
							'context'    => array( 'view', 'edit' ),
							'readonly'   => true,
							'properties' => array(
								'settings' => array(
									'type'        => 'object',
									'description' => esc_html__( 'The link to the settings page for the payment gateway.', 'woocommerce' ),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
									'properties'  => array(
										'href' => array(
											'type'        => 'string',
											'description' => esc_html__( 'The URL to the settings page for the payment gateway.', 'woocommerce' ),
											'context'     => array( 'view', 'edit' ),
											'readonly'    => true,
										),
									),
								),
							),
						),
					),
				),
				'onboarding'     => array(
					'type'        => 'object',
					'description' => esc_html__( 'Onboarding-related details for the provider.', 'woocommerce' ),
					'properties'  => array(
						'type'                        => array(
							'type'        => 'string',
							'description' => esc_html__( 'The type of onboarding process the provider supports.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
						'state'                       => array(
							'type'        => 'object',
							'description' => esc_html__( 'The state of the onboarding process.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
						),
						'messages'                    => array(
							'type'                 => 'object',
							'description'          => esc_html__( 'Various messages to possibly show the user.', 'woocommerce' ),
							'context'              => array( 'view', 'edit' ),
							'readonly'             => true,
							'additionalProperties' => array(
								'type'        => 'string',
								'description' => esc_html__( 'Message to show the user.', 'woocommerce' ),
								'readonly'    => true,
							),
						),
						'steps'                       => array(
							'type'        => 'array',
							'description' => esc_html__( 'The onboarding steps in case this provider supports native in-context onboarding.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
						'_links'                      => array(
							'type'       => 'object',
							'context'    => array( 'view', 'edit' ),
							'readonly'   => true,
							'properties' => array(
								'preload'              => array(
									'type'        => 'object',
									'description' => esc_html__( 'The onboarding preload link for the payment gateway.', 'woocommerce' ),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
									'properties'  => array(
										'href' => array(
											'type'        => 'string',
											'description' => esc_html__( 'The URL to do onboarding preload for the payment gateway.', 'woocommerce' ),
											'context'     => array( 'view', 'edit' ),
											'readonly'    => true,
										),
									),
								),
								'onboard'              => array(
									'type'        => 'object',
									'description' => esc_html__( 'The start/continue onboarding link for the payment gateway.', 'woocommerce' ),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
									'properties'  => array(
										'href' => array(
											'type'        => 'string',
											'description' => esc_html__( 'The URL to start/continue onboarding for the payment gateway.', 'woocommerce' ),
											'context'     => array( 'view', 'edit' ),
											'readonly'    => true,
										),
									),
								),
								'disable_test_account' => array(
									'type'        => 'object',
									'description' => esc_html__( 'The link to disable the test account for the payment gateway.', 'woocommerce' ),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
									'properties'  => array(
										'href' => array(
											'type'        => 'string',
											'description' => esc_html__( 'The URL to POST to disable the test account for the payment gateway.', 'woocommerce' ),
											'context'     => array( 'view', 'edit' ),
											'readonly'    => true,
										),
									),
								),
								'reset'                => array(
									'type'        => 'object',
									'description' => esc_html__( 'The link to reset the provider state/account and restart the onboarding.', 'woocommerce' ),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
									'properties'  => array(
										'href' => array(
											'type'        => 'string',
											'description' => esc_html__( 'The URL to POST to for resetting the provider onboarding.', 'woocommerce' ),
											'context'     => array( 'view', 'edit' ),
											'readonly'    => true,
										),
									),
								),
							),
						),
						'recommended_payment_methods' => array(
							'type'        => 'array',
							'description' => esc_html__( 'The list of recommended payment methods details for the payment gateway.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
							'items'       => array(
								'type'        => 'object',
								'description' => esc_html__( 'The details for a recommended payment method.', 'woocommerce' ),
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
								'properties'  => array(
									'id'          => array(
										'type'        => 'string',
										'description' => esc_html__( 'The unique identifier for the payment method.', 'woocommerce' ),
										'context'     => array( 'view', 'edit' ),
										'readonly'    => true,
									),
									'_order'      => array(
										'type'        => 'integer',
										'description' => esc_html__( 'The sort order of the payment method.', 'woocommerce' ),
										'context'     => array( 'view', 'edit' ),
										'readonly'    => true,
									),
									'enabled'     => array(
										'type'        => 'boolean',
										'description' => esc_html__( 'Whether the payment method should be recommended as enabled or not.', 'woocommerce' ),
										'context'     => array( 'view', 'edit' ),
										'readonly'    => true,
									),
									'required'    => array(
										'type'        => 'boolean',
										'description' => esc_html__( 'Whether the payment method should be required (and force-enabled) or not.', 'woocommerce' ),
										'context'     => array( 'view', 'edit' ),
										'readonly'    => true,
									),
									'title'       => array(
										'type'        => 'string',
										'description' => esc_html__( 'The title of the payment method. Does not include HTML tags.', 'woocommerce' ),
										'context'     => array( 'view', 'edit' ),
										'readonly'    => true,
									),
									'description' => array(
										'type'        => 'string',
										'description' => esc_html__( 'The description of the payment method. It can contain basic HTML.', 'woocommerce' ),
										'context'     => array( 'view', 'edit' ),
										'readonly'    => true,
									),
									'icon'        => array(
										'type'        => 'string',
										'description' => esc_html__( 'The URL of the payment method icon or a base64-encoded SVG image.', 'woocommerce' ),
										'context'     => array( 'view', 'edit' ),
										'readonly'    => true,
									),
								),
							),
						),
						'context'                     => array(
							'type'        => 'object',
							'description' => esc_html__( 'Various contextual data for the onboarding process to use.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
					),
				),
				'tags'           => array(
					'type'        => 'array',
					'description' => esc_html__( 'The tags associated with the provider.', 'woocommerce' ),
					'uniqueItems' => true,
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
					'items'       => array(
						'type'        => 'string',
						'description' => esc_html__( 'Tag associated with the provider.', 'woocommerce' ),
						'readonly'    => true,
					),
				),
				'_suggestion_id' => array(
					'type'        => 'string',
					'description' => esc_html__( 'The suggestion ID matching this provider.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'_incentive'     => $this->get_schema_for_incentive(),
				'_links'         => array(
					'type'       => 'object',
					'context'    => array( 'view', 'edit' ),
					'readonly'   => true,
					'properties' => array(
						'attach' => array(
							'type'        => 'object',
							'description' => esc_html__( 'The link to mark the suggestion as attached. This should be called when an extension is installed.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
							'properties'  => array(
								'href' => array(
									'type'        => 'string',
									'description' => esc_html__( 'The URL to attach the suggestion.', 'woocommerce' ),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
								),
							),
						),
						'hide'   => array(
							'type'        => 'object',
							'description' => esc_html__( 'The link to hide the suggestion.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
							'properties'  => array(
								'href' => array(
									'type'        => 'string',
									'description' => esc_html__( 'The URL to hide the suggestion.', 'woocommerce' ),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
								),
							),
						),
					),
				),
			),
		);
	}

	/**
	 * Get the schema for a suggestion.
	 *
	 * @return array The schema for a suggestion.
	 */
	private function get_schema_for_suggestion(): array {
		return array(
			'type'        => 'object',
			'description' => esc_html__( 'A suggestion with full details.', 'woocommerce' ),
			'context'     => array( 'view', 'edit' ),
			'readonly'    => true,
			'properties'  => array(
				'id'          => array(
					'type'        => 'string',
					'description' => esc_html__( 'The unique identifier for the suggestion.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'_priority'   => array(
					'type'        => 'integer',
					'description' => esc_html__( 'The priority of the suggestion.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'_type'       => array(
					'type'        => 'string',
					'description' => esc_html__( 'The type of the suggestion.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'title'       => array(
					'type'        => 'string',
					'description' => esc_html__( 'The title of the suggestion.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'description' => array(
					'type'        => 'string',
					'description' => esc_html__( 'The description of the suggestion.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'plugin'      => array(
					'type'       => 'object',
					'context'    => array( 'view', 'edit' ),
					'readonly'   => true,
					'properties' => array(
						'_type'  => array(
							'type'        => 'string',
							'enum'        => array( PaymentsProviders::EXTENSION_TYPE_WPORG ),
							'description' => esc_html__( 'The type of the plugin.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
						'slug'   => array(
							'type'        => 'string',
							'description' => esc_html__( 'The slug of the plugin.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
						'status' => array(
							'type'        => 'string',
							'enum'        => array(
								PaymentsProviders::EXTENSION_NOT_INSTALLED,
								PaymentsProviders::EXTENSION_INSTALLED,
								PaymentsProviders::EXTENSION_ACTIVE,
							),
							'description' => esc_html__( 'The status of the plugin.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
						),
					),
				),
				'image'       => array(
					'type'        => 'string',
					'description' => esc_html__( 'The URL of the image.', 'woocommerce' ),
					'readonly'    => true,
				),
				'icon'        => array(
					'type'        => 'string',
					'description' => esc_html__( 'The URL of the icon (square aspect ratio).', 'woocommerce' ),
					'readonly'    => true,
				),
				'links'       => array(
					'type'     => 'array',
					'context'  => array( 'view', 'edit' ),
					'readonly' => true,
					'items'    => array(
						'type'       => 'object',
						'properties' => array(
							'_type' => array(
								'type'        => 'string',
								'description' => esc_html__( 'The type of the link.', 'woocommerce' ),
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
							'url'   => array(
								'type'        => 'string',
								'description' => esc_html__( 'The URL of the link.', 'woocommerce' ),
								'context'     => array( 'view', 'edit' ),
								'readonly'    => true,
							),
						),
					),
				),
				'_incentive'  => $this->get_schema_for_incentive(),
				'tags'        => array(
					'description' => esc_html__( 'The tags associated with the suggestion.', 'woocommerce' ),
					'type'        => 'array',
					'uniqueItems' => true,
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
					'items'       => array(
						'type'        => 'string',
						'description' => esc_html__( 'The tags associated with the suggestion.', 'woocommerce' ),
						'readonly'    => true,
					),
				),
				'category'    => array(
					'type'        => 'string',
					'description' => esc_html__( 'The category of the suggestion.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'_links'      => array(
					'type'       => 'object',
					'context'    => array( 'view', 'edit' ),
					'readonly'   => true,
					'properties' => array(
						'attach' => array(
							'type'        => 'object',
							'description' => esc_html__( 'The link to mark the suggestion as attached. This should be called when an extension is installed.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
							'properties'  => array(
								'href' => array(
									'type'        => 'string',
									'description' => esc_html__( 'The URL to attach the suggestion.', 'woocommerce' ),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
								),
							),
						),
						'hide'   => array(
							'type'        => 'object',
							'description' => esc_html__( 'The link to hide the suggestion.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
							'properties'  => array(
								'href' => array(
									'type'        => 'string',
									'description' => esc_html__( 'The URL to hide the suggestion.', 'woocommerce' ),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
								),
							),
						),
					),
				),
			),
		);
	}

	/**
	 * Get the schema for an incentive.
	 *
	 * @return array The incentive schema.
	 */
	private function get_schema_for_incentive(): array {
		return array(
			'type'        => 'object',
			'description' => esc_html__( 'The active incentive for the provider.', 'woocommerce' ),
			'context'     => array( 'view', 'edit' ),
			'readonly'    => true,
			'properties'  => array(
				'id'                => array(
					'type'        => 'string',
					'description' => esc_html__( 'The incentive unique ID. This ID needs to be used for incentive dismissals.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'promo_id'          => array(
					'type'        => 'string',
					'description' => esc_html__( 'The incentive promo ID. This ID need to be fed into the onboarding flow.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'title'             => array(
					'type'        => 'string',
					'description' => esc_html__( 'The incentive title. It can contain stylistic HTML.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'description'       => array(
					'type'        => 'string',
					'description' => esc_html__( 'The incentive description. It can contain stylistic HTML.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'short_description' => array(
					'type'        => 'string',
					'description' => esc_html__( 'The short description of the incentive. It can contain stylistic HTML.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'cta_label'         => array(
					'type'        => 'string',
					'description' => esc_html__( 'The call to action label for the incentive.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'tc_url'            => array(
					'type'        => 'string',
					'description' => esc_html__( 'The URL to the terms and conditions for the incentive.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'badge'             => array(
					'type'        => 'string',
					'description' => esc_html__( 'The badge label for the incentive.', 'woocommerce' ),
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
				),
				'_dismissals'       => array(
					'type'        => 'array',
					'description' => esc_html__( 'The dismissals list for the incentive. Each dismissal entry includes a context and a timestamp. The `all` entry means the incentive was dismissed for all contexts.', 'woocommerce' ),
					'uniqueItems' => true,
					'context'     => array( 'view', 'edit' ),
					'readonly'    => true,
					'items'       => array(
						'type'       => 'object',
						'properties' => array(
							'context'   => array(
								'type'        => 'string',
								'description' => esc_html__( 'Context ID in which the incentive was dismissed.', 'woocommerce' ),
								'readonly'    => true,
							),
							'timestamp' => array(
								'type'        => 'integer',
								'description' => esc_html__( 'Unix timestamp representing when the incentive was dismissed.', 'woocommerce' ),
								'readonly'    => true,
							),
						),
					),
				),
				'_links'            => array(
					'type'       => 'object',
					'context'    => array( 'view', 'edit' ),
					'readonly'   => true,
					'properties' => array(
						'dismiss' => array(
							'type'        => 'object',
							'description' => esc_html__( 'The link to dismiss the incentive.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
							'properties'  => array(
								'href' => array(
									'type'        => 'string',
									'description' => esc_html__( 'The URL to dismiss the incentive.', 'woocommerce' ),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
								),
							),
						),
						'onboard' => array(
							'type'        => 'object',
							'description' => esc_html__( 'The start/continue onboarding link for the payment gateway.', 'woocommerce' ),
							'context'     => array( 'view', 'edit' ),
							'readonly'    => true,
							'properties'  => array(
								'href' => array(
									'type'        => 'string',
									'description' => esc_html__( 'The URL to start/continue onboarding for the payment gateway.', 'woocommerce' ),
									'context'     => array( 'view', 'edit' ),
									'readonly'    => true,
								),
							),
						),
					),
				),
			),
		);
	}
}
PK     [1]R1m  m    Admin/Settings/Payments.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings;

use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\WooPayments\WooPaymentsService;
use Automattic\WooCommerce\Internal\Admin\Suggestions\PaymentsExtensionSuggestions as ExtensionSuggestions;
use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Exception;

defined( 'ABSPATH' ) || exit;
/**
 * Payments settings service class.
 *
 * @internal
 */
class Payments {

	const PAYMENTS_NOX_PROFILE_KEY              = 'woocommerce_payments_nox_profile';
	const PAYMENTS_PROVIDER_STATE_SNAPSHOTS_KEY = 'woocommerce_payments_provider_state_snapshots';

	const SUGGESTIONS_CONTEXT = 'wc_settings_payments';

	const EVENT_PREFIX = 'settings_payments_';

	const FROM_PAYMENTS_SETTINGS        = 'WCADMIN_PAYMENT_SETTINGS';
	const FROM_PAYMENTS_MENU_ITEM       = 'PAYMENTS_MENU_ITEM';
	const FROM_PAYMENTS_TASK            = 'WCADMIN_PAYMENT_TASK';
	const FROM_ADDITIONAL_PAYMENTS_TASK = 'WCADMIN_ADDITIONAL_PAYMENT_TASK';
	const FROM_PROVIDER_ONBOARDING      = 'PROVIDER_ONBOARDING';

	/**
	 * The payment providers service.
	 *
	 * @var PaymentsProviders
	 */
	private PaymentsProviders $providers;

	/**
	 * The payment extension suggestions service.
	 *
	 * @var ExtensionSuggestions
	 */
	private ExtensionSuggestions $extension_suggestions;

	/**
	 * Initialize the class instance.
	 *
	 * @param PaymentsProviders    $payment_providers             The payment providers service.
	 * @param ExtensionSuggestions $payment_extension_suggestions The payment extension suggestions service.
	 *
	 * @internal
	 */
	final public function init( PaymentsProviders $payment_providers, ExtensionSuggestions $payment_extension_suggestions ): void {
		$this->providers             = $payment_providers;
		$this->extension_suggestions = $payment_extension_suggestions;
	}

	/**
	 * Get the payment provider details list for the settings page.
	 *
	 * @param string $location    The location for which the providers are being determined.
	 *                            This is an ISO 3166-1 alpha-2 country code.
	 * @param bool   $for_display Optional. Whether the payment providers list is intended for display purposes or
	 *                            it is meant to be used for internal business logic.
	 *                            Primarily, this means that when it is not for display, we will use the raw
	 *                            payment gateways list (all the registered gateways), not just the ones that
	 *                            should be shown to the user on the Payments Settings page.
	 *                            This complication is for backward compatibility as it relates to legacy settings hooks
	 *                            being fired or not.
	 * @param bool   $remove_shells Optional. Whether to remove the payment providers shells from the list.
	 *                              If the $for_display is true, this will be ignored since the display logic will
	 *                              handle the shells itself.
	 *
	 * @return array The payment providers details list.
	 * @throws Exception If there are malformed or invalid suggestions.
	 */
	public function get_payment_providers( string $location, bool $for_display = true, bool $remove_shells = false ): array {
		$payment_gateways = $this->providers->get_payment_gateways( $for_display );
		if ( ! $for_display && $remove_shells ) {
			$payment_gateways = $this->providers->remove_shell_payment_gateways( $payment_gateways, $location );
		}

		$providers_order_map = $this->providers->get_order_map();

		$payment_providers = array();

		// Only include suggestions if the requesting user can install plugins.
		$suggestions = array();
		if ( current_user_can( 'install_plugins' ) ) {
			$suggestions = $this->providers->get_extension_suggestions( $location, self::SUGGESTIONS_CONTEXT );
		}
		// If we have preferred suggestions, add them to the providers list.
		if ( ! empty( $suggestions['preferred'] ) ) {
			// Sort them by priority, ASC.
			usort(
				$suggestions['preferred'],
				function ( $a, $b ) {
					return $a['_priority'] <=> $b['_priority'];
				}
			);

			// By default, we will add the preferred suggestions at the top of the list.
			$last_preferred_order = -1;
			// If WooPayments is already present, we add the preferred suggestions after it.
			// This way we ensure default installed WooPayments is at the same place as its suggestion would be.
			if ( isset( $providers_order_map[ WooPaymentsService::GATEWAY_ID ] ) ) {
				$last_preferred_order = $providers_order_map[ WooPaymentsService::GATEWAY_ID ];
			}

			foreach ( $suggestions['preferred'] as $suggestion ) {
				$suggestion_order_map_id = $this->providers->get_suggestion_order_map_id( $suggestion['id'] );
				// Determine the suggestion's order value.
				// If we don't have an order for it, add it to the top but keep the relative order:
				// PSP first, APM after PSP, offline PSP after PSP and APM.
				if ( ! isset( $providers_order_map[ $suggestion_order_map_id ] ) ) {
					$providers_order_map = Utils::order_map_add_at_order( $providers_order_map, $suggestion_order_map_id, $last_preferred_order + 1 );
				}

				// Save the preferred provider's order to know where we should be inserting next.
				// But only if the last preferred order is less than the current one.
				if ( $last_preferred_order < $providers_order_map[ $suggestion_order_map_id ] ) {
					$last_preferred_order = $providers_order_map[ $suggestion_order_map_id ];
				}

				// Change suggestion details to align it with a regular payment gateway.
				$suggestion['_suggestion_id'] = $suggestion['id'];
				$suggestion['id']             = $suggestion_order_map_id;
				$suggestion['_type']          = PaymentsProviders::TYPE_SUGGESTION;
				$suggestion['_order']         = $providers_order_map[ $suggestion_order_map_id ];
				unset( $suggestion['_priority'] );

				$payment_providers[] = $suggestion;
			}
		}

		foreach ( $payment_gateways as $payment_gateway ) {
			// Determine the gateway's order value.
			// If we don't have an order for it, add it to the end.
			if ( ! isset( $providers_order_map[ $payment_gateway->id ] ) ) {
				$providers_order_map = Utils::order_map_add_at_order( $providers_order_map, $payment_gateway->id, count( $payment_providers ) );
			}

			$payment_providers[] = $this->providers->get_payment_gateway_details(
				$payment_gateway,
				$providers_order_map[ $payment_gateway->id ],
				$location
			);
		}

		// Add offline payment methods group entry if we have offline payment methods.
		if ( in_array( PaymentsProviders::TYPE_OFFLINE_PM, array_column( $payment_providers, '_type' ), true ) ) {
			// Determine the item's order value.
			// If we don't have an order for it, add it to the end.
			if ( ! isset( $providers_order_map[ PaymentsProviders::OFFLINE_METHODS_ORDERING_GROUP ] ) ) {
				$providers_order_map = Utils::order_map_add_at_order( $providers_order_map, PaymentsProviders::OFFLINE_METHODS_ORDERING_GROUP, count( $payment_providers ) );
			}

			$payment_providers[] = array(
				'id'          => PaymentsProviders::OFFLINE_METHODS_ORDERING_GROUP,
				'_type'       => PaymentsProviders::TYPE_OFFLINE_PMS_GROUP,
				'_order'      => $providers_order_map[ PaymentsProviders::OFFLINE_METHODS_ORDERING_GROUP ],
				'title'       => esc_html__( 'Take offline payments', 'woocommerce' ),
				'description' => esc_html__( 'Accept payments offline using multiple different methods. These can also be used to test purchases.', 'woocommerce' ),
				'icon'        => plugins_url( 'assets/images/payment_methods/cod.svg', WC_PLUGIN_FILE ),
				// The offline PMs (and their group) are obviously from WooCommerce, and WC is always active.
				'plugin'      => array(
					'_type'  => 'wporg',
					'slug'   => 'woocommerce',
					'file'   => '', // This pseudo-provider should have no use for the plugin file.
					'status' => PaymentsProviders::EXTENSION_ACTIVE,
				),
				'management'  => array(
					'_links' => array(
						'settings' => array(
							'href' => Utils::wc_payments_settings_url( '/' . ( class_exists( '\WC_Settings_Payment_Gateways' ) ? \WC_Settings_Payment_Gateways::OFFLINE_SECTION_NAME : 'offline' ) ),
						),
					),
				),
			);
		}

		// Determine the final, standardized providers order map.
		$providers_order_map = $this->providers->enhance_order_map( $providers_order_map );
		// Enforce the order map on all providers, just in case.
		foreach ( $payment_providers as $key => $provider ) {
			$payment_providers[ $key ]['_order'] = $providers_order_map[ $provider['id'] ];
		}
		// NOTE: For now, save it back to the DB. This is temporary until we have a better way to handle this!
		$this->providers->save_order_map( $providers_order_map );

		// Sort the payment providers by order, ASC.
		usort(
			$payment_providers,
			function ( $a, $b ) {
				return $a['_order'] <=> $b['_order'];
			}
		);

		// Only process payment provider states if we are displaying the providers.
		// This is to ensure we don't introduce any performance issues outside the Payments settings page.
		if ( $for_display ) {
			$this->process_payment_provider_states( $payment_providers );
		}

		return $payment_providers;
	}

	/**
	 * Get the payment extension suggestions for the given location.
	 *
	 * @param string $location The location for which the suggestions are being fetched.
	 *
	 * @return array[] The payment extension suggestions for the given location, split into preferred and other.
	 * @throws Exception If there are malformed or invalid suggestions.
	 */
	public function get_payment_extension_suggestions( string $location ): array {
		return $this->providers->get_extension_suggestions( $location, self::SUGGESTIONS_CONTEXT );
	}

	/**
	 * Get the payment extension suggestions categories details.
	 *
	 * @return array The payment extension suggestions categories.
	 */
	public function get_payment_extension_suggestion_categories(): array {
		return $this->providers->get_extension_suggestion_categories();
	}

	/**
	 * Get the business location country code for the Payments settings.
	 *
	 * @return string The ISO 3166-1 alpha-2 country code to use for the overall business location.
	 *                If the user didn't set a location, the WC base location country code is used.
	 */
	public function get_country(): string {
		$user_nox_meta = get_user_meta( get_current_user_id(), self::PAYMENTS_NOX_PROFILE_KEY, true );
		if ( ! empty( $user_nox_meta['business_country_code'] ) ) {
			return $user_nox_meta['business_country_code'];
		}

		return WC()->countries->get_base_country();
	}

	/**
	 * Set the business location country for the Payments settings.
	 *
	 * @param string $location The country code. This should be an ISO 3166-1 alpha-2 country code.
	 */
	public function set_country( string $location ): bool {
		$previous_country = $this->get_country();

		$user_payments_nox_profile = get_user_meta( get_current_user_id(), self::PAYMENTS_NOX_PROFILE_KEY, true );

		if ( empty( $user_payments_nox_profile ) ) {
			$user_payments_nox_profile = array();
		} else {
			$user_payments_nox_profile = maybe_unserialize( $user_payments_nox_profile );
		}
		$user_payments_nox_profile['business_country_code'] = $location;

		$result = false !== update_user_meta( get_current_user_id(), self::PAYMENTS_NOX_PROFILE_KEY, $user_payments_nox_profile );

		if ( $result && $previous_country !== $location ) {
			// Record an event that the business location (registration country code) was changed.
			$this->record_event(
				'business_location_update',
				array(
					'business_country'          => $location,
					'previous_business_country' => $previous_country,
				)
			);
		}

		return $result;
	}

	/**
	 * Update the payment providers order map.
	 *
	 * @param array $order_map The new order for payment providers.
	 *
	 * @return bool True if the payment providers ordering was successfully updated, false otherwise.
	 */
	public function update_payment_providers_order_map( array $order_map ): bool {
		$result = $this->providers->update_payment_providers_order_map( $order_map );

		if ( $result ) {
			// Record an event that the payment providers order map was updated.
			$this->record_event(
				'payment_providers_order_map_updated',
				array(
					'order_map' => implode( ', ', array_keys( $this->providers->get_order_map() ) ),
				)
			);
		}

		return $result;
	}

	/**
	 * Attach a payment extension suggestion.
	 *
	 * This is only an internal recording of attachment. No actual extension installation or activation happens.
	 *
	 * @param string $id The ID of the payment extension suggestion to attach.
	 *
	 * @return bool True if the suggestion was successfully marked as attached, false otherwise.
	 * @throws Exception If the suggestion ID is invalid.
	 */
	public function attach_payment_extension_suggestion( string $id ): bool {
		$result = $this->providers->attach_extension_suggestion( $id );

		if ( $result ) {
			// Record an event that the suggestion was attached.
			$this->record_event(
				'extension_suggestion_attached',
				array(
					'suggestion_id' => $id,
				)
			);
		}

		return $result;
	}

	/**
	 * Hide a payment extension suggestion.
	 *
	 * @param string $id The ID of the payment extension suggestion to hide.
	 *
	 * @return bool True if the suggestion was successfully hidden, false otherwise.
	 * @throws Exception If the suggestion ID is invalid.
	 */
	public function hide_payment_extension_suggestion( string $id ): bool {
		$result = $this->providers->hide_extension_suggestion( $id );

		if ( $result ) {
			// Record an event that the suggestion was hidden.
			$this->record_event(
				'extension_suggestion_hidden',
				array(
					'suggestion_id' => $id,
				)
			);
		}

		return $result;
	}

	/**
	 * Dismiss a payment extension suggestion incentive.
	 *
	 * @param string $suggestion_id The suggestion ID.
	 * @param string $incentive_id  The incentive ID.
	 * @param string $context       Optional. The context in which the incentive should be dismissed.
	 *                              Default is to dismiss the incentive in all contexts.
	 * @param bool   $do_not_track  Optional. If true, the incentive dismissal will not be tracked.
	 *
	 * @return bool True if the incentive was not previously dismissed and now it is.
	 *              False if the incentive was already dismissed or could not be dismissed.
	 * @throws Exception If the incentive could not be dismissed due to an error.
	 */
	public function dismiss_extension_suggestion_incentive( string $suggestion_id, string $incentive_id, string $context = 'all', bool $do_not_track = false ): bool {
		$result = $this->extension_suggestions->dismiss_incentive( $incentive_id, $suggestion_id, $context );

		if ( ! $do_not_track && $result ) {
			// Record an event that the incentive was dismissed.
			$this->record_event(
				'incentive_dismiss',
				array(
					'suggestion_id'   => $suggestion_id,
					'incentive_id'    => $incentive_id,
					'display_context' => $context,
				)
			);
		}

		return $result;
	}

	/**
	 * Send a Tracks event.
	 *
	 * By default, Woo adds `url`, `blog_lang`, `blog_id`, `store_id`, `products_count`, and `wc_version`
	 * properties to every event.
	 *
	 * @param string $name The event name.
	 *                     If it is not prefixed with self::EVENT_PREFIX, it will be prefixed with it.
	 * @param array  $properties Optional. The event custom properties.
	 *                           These properties will be merged with the default properties.
	 *                           Default properties values take precedence over the provided ones.
	 *
	 * @return void
	 */
	private function record_event( string $name, array $properties = array() ) {
		if ( ! function_exists( 'wc_admin_record_tracks_event' ) ) {
			return;
		}

		// If the event name is empty, we don't record it.
		if ( empty( $name ) ) {
			return;
		}

		// If the event name is not prefixed with `settings_payments_`, we prefix it.
		if ( ! str_starts_with( $name, self::EVENT_PREFIX ) ) {
			$name = self::EVENT_PREFIX . $name;
		}

		// Add default properties to every event and overwrite custom properties with the same keys.
		$properties = array_merge(
			$properties,
			array(
				'business_country' => $this->get_country(),
			),
		);

		wc_admin_record_tracks_event( $name, $properties );
	}

	/**
	 * Process the payment providers states and update the snapshots in the DB.
	 *
	 * @param array $payment_providers The payment providers details list.
	 */
	private function process_payment_provider_states( array $payment_providers ): void {
		// Read the current state snapshots from the DB.
		$snapshots = get_option( self::PAYMENTS_PROVIDER_STATE_SNAPSHOTS_KEY, array() );
		if ( ! is_array( $snapshots ) ) {
			$snapshots = array();
		}

		$default_snapshot = array(
			'extension_active'  => false,
			'account_connected' => false,
			'account_test_mode' => false,
			'needs_setup'       => false,
			'test_mode'         => false,
		);

		// Iterate through the payment providers and generate their updated snapshots.
		// We will use the provider's plugin slug as the key for the snapshot to ensure uniqueness.
		// For now, we will only focus on the provider state for official extensions, not all the gateways.
		$new_snapshots = array();
		foreach ( $payment_providers as $provider ) {
			if ( empty( $provider['plugin']['slug'] ) ||
				empty( $provider['id'] ) ||
				empty( $provider['state'] ) || ! is_array( $provider['state'] ) ||
				empty( $provider['onboarding']['state'] ) || ! is_array( $provider['onboarding']['state'] ) ||
				empty( $provider['_type'] ) ||
				PaymentsProviders::TYPE_GATEWAY !== $provider['_type'] ||
				empty( $provider['_suggestion_id'] )
			) {
				continue;
			}

			$snapshot_key = $provider['plugin']['slug'];

			// Since we are going after the provider general state, not that of the specific gateway,
			// we only need to look at the first found gateway from a given provider.
			if ( isset( $new_snapshots[ $snapshot_key ] ) ) {
				continue;
			}

			// If we don't have an already existing snapshot for this provider, we create one with default values.
			// This way we can track changes even for the first time we see a provider.
			if ( ! isset( $snapshots[ $snapshot_key ] ) ) {
				$snapshots[ $snapshot_key ] = $default_snapshot;
			} else {
				// Make sure the old snapshot has the same keys as the default one.
				$snapshots[ $snapshot_key ] = array_merge( $default_snapshot, $snapshots[ $snapshot_key ] );
				// Remove any keys that are not in the default snapshot.
				$snapshot_keys = array_keys( $default_snapshot );
				foreach ( $snapshots[ $snapshot_key ] as $key => $v ) {
					if ( ! in_array( $key, $snapshot_keys, true ) ) {
						unset( $snapshots[ $snapshot_key ][ $key ] );
					}
				}

				// Always sort the old snapshot by keys to ensure consistency.
				ksort( $snapshots[ $snapshot_key ] );
			}

			// Generate the new snapshot for the provider.
			$new_snapshots[ $snapshot_key ] = array(
				'extension_active'  => true, // The extension is definitely active since we have a gateway from it.
				'account_connected' => $provider['state']['account_connected'] ?? $default_snapshot['account_connected'],
				'account_test_mode' => $provider['onboarding']['state']['test_mode'] ?? $default_snapshot['account_test_mode'],
				'needs_setup'       => $provider['state']['needs_setup'] ?? $default_snapshot['needs_setup'],
				'test_mode'         => $provider['state']['test_mode'] ?? $default_snapshot['test_mode'],
			);

			// Always sort the new snapshot by keys to ensure consistency.
			ksort( $new_snapshots[ $snapshot_key ] );
		}

		// Provider snapshots that are not in the new snapshots but were in the old ones should be kept but marked as inactive.
		foreach ( $snapshots as $snapshot_key => $old_snapshot ) {
			if ( ! isset( $new_snapshots[ $snapshot_key ] ) ) {
				$new_snapshots[ $snapshot_key ]                     = $old_snapshot;
				$new_snapshots[ $snapshot_key ]['extension_active'] = false;
			}
		}

		// Always order the new snapshots by keys to ensure DB updates happen only when the data changes.
		ksort( $new_snapshots );

		// Save the new snapshots back to the DB, as soon as we have them ready to avoid concurrent state change tracking.
		// No need to autoload this option since it will be used only in the Payments Settings area.
		$result = update_option( self::PAYMENTS_PROVIDER_STATE_SNAPSHOTS_KEY, $new_snapshots, false );
		if ( ! $result ) {
			// If we didn't update the option, we don't need to track any changes.
			return;
		}

		try {
			$this->maybe_track_providers_state_change( $payment_providers, $snapshots, $new_snapshots );
		} catch ( \Throwable $exception ) {
			// If we failed to track the changes, we log the error but don't throw it.
			// This is to avoid breaking the Payments Settings page.
			SafeGlobalFunctionProxy::wc_get_logger()->error(
				'Failed to track payment providers state change: ' . $exception->getMessage(),
				array(
					'source' => 'settings-payments',
				)
			);
		}
	}

	/**
	 * Maybe track the payment providers state change.
	 *
	 * This method will iterate through the new snapshots and compare them with the old ones.
	 * If there are any changes, it will track them.
	 *
	 * @param array $providers      The list of payment provider details.
	 * @param array $old_snapshots  The old snapshots of the providers' states.
	 * @param array $new_snapshots  The new snapshots of the providers' states.
	 */
	private function maybe_track_providers_state_change( array $providers, array $old_snapshots, array $new_snapshots ): void {
		foreach ( $new_snapshots as $provider_extension_slug => $new_snapshot ) {
			if ( ! isset( $old_snapshots[ $provider_extension_slug ] ) ) {
				// If we don't have an old snapshot for this provider, we can't track the change.
				continue;
			}

			// If there are no changes, we don't need to track anything.
			if ( maybe_serialize( $old_snapshots[ $provider_extension_slug ] ) === maybe_serialize( $new_snapshot ) ) {
				continue;
			}

			// Search for the provider by its plugin slug.
			$provider = null;
			foreach ( $providers as $p ) {
				if ( isset( $p['plugin']['slug'] ) && $p['plugin']['slug'] === $provider_extension_slug ) {
					$provider = $p;
					break;
				}
			}
			if ( ! $provider ) {
				// If we couldn't find the provider in the list it means the extension was deactivated.
				// Get the matching suggestion by its slug.
				$provider = $this->providers->get_extension_suggestion_by_plugin_slug( $provider_extension_slug );
				if ( ! empty( $provider['id'] ) ) {
					// If we found the suggestion, we can use it as a replacement provider.
					// We need to set the `_suggestion_id` so we can handle the date more uniformly.
					$provider['_suggestion_id'] = $provider['id'];
				}
			}
			if ( ! $provider ) {
				continue;
			}

			$this->maybe_track_provider_state_change( $provider, $old_snapshots[ $provider_extension_slug ], $new_snapshot );
		}
	}

	/**
	 * Track the payment provider state change.
	 *
	 * @param array $provider       The payment provider details.
	 * @param array $old_snapshot   The old snapshot of the provider's state.
	 * @param array $new_snapshot   The new snapshot of the provider's state.
	 */
	private function maybe_track_provider_state_change( array $provider, array $old_snapshot, array $new_snapshot ): void {
		// Note: Keep the order of the events in a way that makes sense for the onboarding flow.

		// Track extension_active change.
		if ( $old_snapshot['extension_active'] && ! $new_snapshot['extension_active'] ) {
			$this->record_event(
				'provider_extension_deactivated',
				array(
					'provider_id'             => $provider['id'],
					'suggestion_id'           => $provider['_suggestion_id'],
					'provider_extension_slug' => $provider['plugin']['slug'],
				)
			);

			// If the extension was also uninstalled, we can track that as well.
			if ( ! empty( $provider['plugin']['status'] ) && PaymentsProviders::EXTENSION_NOT_INSTALLED === $provider['plugin']['status'] ) {
				$this->record_event(
					'provider_extension_uninstalled',
					array(
						'provider_id'             => $provider['id'],
						'suggestion_id'           => $provider['_suggestion_id'],
						'provider_extension_slug' => $provider['plugin']['slug'],
					)
				);
			}
		} elseif ( ! $old_snapshot['extension_active'] && $new_snapshot['extension_active'] ) {
			$this->record_event(
				'provider_extension_activated',
				array(
					'provider_id'             => $provider['id'],
					'suggestion_id'           => $provider['_suggestion_id'],
					'provider_extension_slug' => $provider['plugin']['slug'],
				)
			);
		}

		// Track account_connected change.
		if ( $old_snapshot['account_connected'] && ! $new_snapshot['account_connected'] ) {
			$this->record_event(
				'provider_account_disconnected',
				array(
					'provider_id'                => $provider['id'],
					'suggestion_id'              => $provider['_suggestion_id'],
					'provider_extension_slug'    => $provider['plugin']['slug'],
					'provider_account_test_mode' => $old_snapshot['account_test_mode'] ? 'yes' : 'no',
				)
			);
		} elseif ( ! $old_snapshot['account_connected'] && $new_snapshot['account_connected'] ) {
			$this->record_event(
				'provider_account_connected',
				array(
					'provider_id'                => $provider['id'],
					'suggestion_id'              => $provider['_suggestion_id'],
					'provider_extension_slug'    => $provider['plugin']['slug'],
					'provider_account_test_mode' => $new_snapshot['account_test_mode'] ? 'yes' : 'no',
				)
			);
		}

		// Track needs_setup change.
		if ( $old_snapshot['needs_setup'] && ! $new_snapshot['needs_setup'] ) {
			$this->record_event(
				'provider_setup_completed',
				array(
					'provider_id'             => $provider['id'],
					'suggestion_id'           => $provider['_suggestion_id'],
					'provider_extension_slug' => $provider['plugin']['slug'],
				)
			);
		} elseif ( ! $old_snapshot['needs_setup'] && $new_snapshot['needs_setup'] ) {
			$this->record_event(
				'provider_setup_required',
				array(
					'provider_id'             => $provider['id'],
					'suggestion_id'           => $provider['_suggestion_id'],
					'provider_extension_slug' => $provider['plugin']['slug'],
				)
			);
		}

		// Track payments test_mode change, but only if an account is connected.
		if ( $new_snapshot['account_connected'] ) {
			if ( $old_snapshot['test_mode'] && ! $new_snapshot['test_mode'] ) {
				$this->record_event(
					'provider_live_payments_enabled',
					array(
						'provider_id'             => $provider['id'],
						'suggestion_id'           => $provider['_suggestion_id'],
						'provider_extension_slug' => $provider['plugin']['slug'],
					)
				);
			} elseif ( ! $old_snapshot['test_mode'] && $new_snapshot['test_mode'] ) {
				$this->record_event(
					'provider_test_payments_enabled',
					array(
						'provider_id'             => $provider['id'],
						'suggestion_id'           => $provider['_suggestion_id'],
						'provider_extension_slug' => $provider['plugin']['slug'],
					)
				);
			}
		}

		// Track account_test_mode change, but only if the account is connected.
		if ( $new_snapshot['account_connected'] ) {
			if ( $old_snapshot['account_test_mode'] && ! $new_snapshot['account_test_mode'] ) {
				$this->record_event(
					'provider_account_live_mode_enabled',
					array(
						'provider_id'             => $provider['id'],
						'suggestion_id'           => $provider['_suggestion_id'],
						'provider_extension_slug' => $provider['plugin']['slug'],
					)
				);
			} elseif ( ! $old_snapshot['account_test_mode'] && $new_snapshot['account_test_mode'] ) {
				$this->record_event(
					'provider_account_test_mode_enabled',
					array(
						'provider_id'             => $provider['id'],
						'suggestion_id'           => $provider['_suggestion_id'],
						'provider_extension_slug' => $provider['plugin']['slug'],
					)
				);
			}
		}
	}
}
PK     [1]C>̬;  ;    Admin/Settings/Utils.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings;

use Automattic\WooCommerce\Internal\Jetpack\JetpackConnection;
use WP_REST_Request;

defined( 'ABSPATH' ) || exit;
/**
 * Payments settings utilities class.
 *
 * @internal
 */
class Utils {
	/**
	 * Apply order mappings to a base order map.
	 *
	 * @param array $base_map     The base order map.
	 * @param array $new_mappings The order mappings to apply.
	 *                            This can be a full or partial list of the base one,
	 *                            but it can also contain (only) new IDs and their orders.
	 *
	 * @return array The updated base order map, normalized.
	 */
	public static function order_map_apply_mappings( array $base_map, array $new_mappings ): array {
		// Make sure the base map is sorted ascending by their order values.
		// We don't normalize first because the order values have meaning.
		asort( $base_map );

		$updated_map = $base_map;
		// Apply the new mappings in the order they were given.
		foreach ( $new_mappings as $id => $order ) {
			// If the ID is not in the base map, we ADD it at the desired order. Otherwise, we MOVE it.
			if ( ! isset( $base_map[ $id ] ) ) {
				$updated_map = self::order_map_add_at_order( $updated_map, $id, $order );
				continue;
			}

			$updated_map = self::order_map_move_at_order( $updated_map, $id, $order );
		}

		return self::order_map_normalize( $updated_map );
	}

	/**
	 * Move an id at a specific order in an order map.
	 *
	 * This method is used to simulate the behavior of a drag&drop sorting UI:
	 * - When moving an id down, all the ids with an order equal or lower than the desired order
	 *   but equal or higher than the current order are decreased by 1.
	 * - When moving an id up, all the ids with an order equal or higher than the desired order
	 *   but equal or lower than the current order are increased by 1.
	 *
	 * @param array  $order_map The order map.
	 * @param string $id        The id to place.
	 * @param int    $order     The order at which to place the id.
	 *
	 * @return array The updated order map. This map is not normalized.
	 */
	public static function order_map_move_at_order( array $order_map, string $id, int $order ): array {
		// If the id is not in the order map, return the order map as is.
		if ( ! isset( $order_map[ $id ] ) ) {
			return $order_map;
		}

		// If the id is already at the desired order, return the order map as is.
		if ( $order_map[ $id ] === $order ) {
			return $order_map;
		}

		// If there is no id at the desired order, just place the id there.
		if ( ! in_array( $order, $order_map, true ) ) {
			$order_map[ $id ] = $order;

			return $order_map;
		}

		// We apply the normal behavior of a drag&drop sorting UI.
		$existing_order = $order_map[ $id ];
		if ( $order > $existing_order ) {
			// Moving down.
			foreach ( $order_map as $key => $value ) {
				if ( $value <= $order && $value >= $existing_order ) {
					--$order_map[ $key ];
				}
			}
		} else {
			// Moving up.
			foreach ( $order_map as $key => $value ) {
				if ( $value >= $order && $value <= $existing_order ) {
					++$order_map[ $key ];
				}
			}
		}

		// Place the id at the desired order.
		$order_map[ $id ] = $order;

		return $order_map;
	}

	/**
	 * Place an id at a specific order in an order map.
	 *
	 * @param array  $order_map The order map.
	 * @param string $id        The id to place.
	 * @param int    $order     The order at which to place the id.
	 *
	 * @return array The updated order map.
	 */
	public static function order_map_place_at_order( array $order_map, string $id, int $order ): array {
		// If the id is already at the desired order, return the order map as is.
		if ( isset( $order_map[ $id ] ) && $order_map[ $id ] === $order ) {
			return $order_map;
		}

		// If there is no id at the desired order, just place the id there.
		if ( ! in_array( $order, $order_map, true ) ) {
			$order_map[ $id ] = $order;

			return $order_map;
		}

		// Bump the order of everything with an order equal or higher than the desired order.
		foreach ( $order_map as $key => $value ) {
			if ( $value >= $order ) {
				++$order_map[ $key ];
			}
		}

		// Place the id at the desired order.
		$order_map[ $id ] = $order;

		return $order_map;
	}

	/**
	 * Add an id to a specific order in an order map.
	 *
	 * @param array  $order_map The order map.
	 * @param string $id        The id to move.
	 * @param int    $order     The order to move the id to.
	 *
	 * @return array The updated order map. If the id is already in the order map, the order map is returned as is.
	 */
	public static function order_map_add_at_order( array $order_map, string $id, int $order ): array {
		// If the id is in the order map, return the order map as is.
		if ( isset( $order_map[ $id ] ) ) {
			return $order_map;
		}

		return self::order_map_place_at_order( $order_map, $id, $order );
	}

	/**
	 * Normalize an order map.
	 *
	 * Sort the order map by the order and ensure the order values start from 0 and are consecutive.
	 *
	 * @param array $order_map The order map.
	 *
	 * @return array The normalized order map.
	 */
	public static function order_map_normalize( array $order_map ): array {
		asort( $order_map );

		return array_flip( array_keys( $order_map ) );
	}

	/**
	 * Change the minimum order of an order map.
	 *
	 * @param array $order_map     The order map.
	 * @param int   $new_min_order The new minimum order.
	 *
	 * @return array The updated order map.
	 */
	public static function order_map_change_min_order( array $order_map, int $new_min_order ): array {
		// Sanity checks.
		if ( empty( $order_map ) ) {
			return array();
		}

		$updated_map = array();
		$bump        = $new_min_order - min( $order_map );
		foreach ( $order_map as $id => $order ) {
			$updated_map[ $id ] = $order + $bump;
		}

		asort( $updated_map );

		return $updated_map;
	}

	/**
	 * Get the list of plugin slug suffixes used for handling non-standard testing slugs.
	 *
	 * @return string[] The list of plugin slug suffixes used for handling non-standard testing slugs.
	 */
	public static function get_testing_plugin_slug_suffixes(): array {
		return array( '-dev', '-rc', '-test', '-beta', '-alpha' );
	}

	/**
	 * Generate a list of testing plugin slugs from a standard/official plugin slug.
	 *
	 * @param string $slug             The standard/official plugin slug. Most likely the WPORG slug.
	 * @param bool   $include_original Optional. Whether to include the original slug in the list.
	 *                                 If true, the original slug will be the first item in the list.
	 *
	 * @return string[] The list of testing plugin slugs generated from the standard/official plugin slug.
	 */
	public static function generate_testing_plugin_slugs( string $slug, bool $include_original = false ): array {
		$slugs = array();
		if ( $include_original ) {
			$slugs[] = $slug;
		}

		foreach ( self::get_testing_plugin_slug_suffixes() as $suffix ) {
			$slugs[] = $slug . $suffix;
		}

		return $slugs;
	}

	/**
	 * Normalize a plugin slug to a standard/official slug.
	 *
	 * This is a best-effort approach.
	 * It will remove beta testing suffixes and lowercase the slug.
	 * It will NOT convert plugin titles to slugs or sanitize the slug like sanitize_title() does.
	 *
	 * @param string $slug The plugin slug.
	 *
	 * @return string The normalized plugin slug.
	 */
	public static function normalize_plugin_slug( string $slug ): string {
		// If the slug is empty or contains anything other than alphanumeric and dash characters, it will be left as is.
		if ( empty( $slug ) || ! preg_match( '/^[\w-]+$/', $slug, $matches ) ) {
			return $slug;
		}

		// Lowercase the slug.
		$slug = strtolower( $slug );
		// Remove testing suffixes.
		foreach ( self::get_testing_plugin_slug_suffixes() as $suffix ) {
			$slug = str_ends_with( $slug, $suffix ) ? substr( $slug, 0, -strlen( $suffix ) ) : $slug;
		}

		return $slug;
	}

	/**
	 * Trim the .php file extension from a path.
	 *
	 * @param string $path The path to trim.
	 *
	 * @return string The trimmed path. If the path does not end with .php, it will be returned as is.
	 */
	public static function trim_php_file_extension( string $path ): string {
		if ( ! empty( $path ) && str_ends_with( $path, '.php' ) ) {
			$path = substr( $path, 0, - 4 );
		}

		return $path;
	}

	/**
	 * Truncate a text to a target character length while preserving whole words.
	 *
	 * We take a greedy approach: if some characters of a word fit in the target length, the whole word is included.
	 * This means we might exceed the target length by a few characters.
	 * The append string length is not included in the character count.
	 *
	 * @param string $text          The text to truncate.
	 *                              It will not be sanitized, stripped of HTML tags, or modified in any way before truncation.
	 * @param int    $target_length The target character length of the truncated text.
	 * @param string $append        Optional. The string to append to the truncated text, if there is any truncation.
	 *
	 * @return string The truncated text.
	 */
	public static function truncate_with_words( string $text, int $target_length, string $append = '' ): string {
		// First, deal with locale that doesn't have words separated by spaces, but instead deals with characters.
		// Borrowed from wp_trim_words().
		if ( str_starts_with( wp_get_word_count_type(), 'characters' ) && preg_match( '/^utf\-?8$/i', get_option( 'blog_charset' ) ) ) {
			$text = trim( preg_replace( "/[\n\r\t ]+/", ' ', $text ), ' ' );
			preg_match_all( '/./u', $text, $words_array );

			// Nothing to do if the text is already short enough.
			if ( count( $words_array[0] ) <= $target_length ) {
				return $text;
			}

			$words_array = array_slice( $words_array[0], 0, $target_length );
			$truncated   = implode( '', $words_array );
			if ( $append ) {
				$truncated .= $append;
			}

			return $truncated;
		}

		// Deal with locale that has words separated by spaces.
		if ( strlen( $text ) <= $target_length ) {
			return $text;
		}

		$words_array = preg_split( "/[\n\r\t ]+/", $text, - 1, PREG_SPLIT_NO_EMPTY );
		$sep         = ' ';

		// Include words until the target length is reached.
		$truncated        = '';
		$remaining_length = $target_length;
		while ( $remaining_length > 0 && ! empty( $words_array ) ) {
			$word              = array_shift( $words_array );
			$truncated        .= $word . $sep;
			$remaining_length -= strlen( $word . $sep );
		}

		// Remove the last separator.
		$truncated = rtrim( $truncated, $sep );

		if ( null !== $append ) {
			$truncated .= $append;
		}

		return $truncated;
	}

	/**
	 * Retrieves a URL to relative path inside WooCommerce admin Payments settings with
	 * the provided query parameters.
	 *
	 * @param string|null $path  Relative path of the desired page.
	 * @param array       $query Query parameters to append to the path.
	 *
	 * @return string       Fully qualified URL pointing to the desired path.
	 */
	public static function wc_payments_settings_url( ?string $path = null, array $query = array() ): string {
		$path = $path ? '&path=' . $path : '';

		$query_string = '';
		if ( ! empty( $query ) ) {
			$query_string = '&' . http_build_query( $query );
		}

		return admin_url( 'admin.php?page=wc-settings&tab=checkout' . $path . $query_string );
	}

	/**
	 * Get data from a WooCommerce API endpoint.
	 *
	 * @param string $endpoint Endpoint.
	 * @param array  $params   Params to pass with request query.
	 *
	 * @return array|\WP_Error The response data or a WP_Error object.
	 */
	public static function rest_endpoint_get_request( string $endpoint, array $params = array() ) {
		$request = new \WP_REST_Request( 'GET', $endpoint );
		if ( $params ) {
			$request->set_query_params( $params );
		}

		// Do the internal request.
		// This has minimal overhead compared to an external request.
		$response = rest_do_request( $request );

		$server        = rest_get_server();
		$response_data = json_decode( wp_json_encode( $server->response_to_data( $response, false ) ), true );

		// Handle non-200 responses.
		if ( 200 !== $response->get_status() ) {
			return new \WP_Error(
				'woocommerce_settings_payments_rest_error',
				sprintf(
					/* translators: 1: the endpoint relative URL, 2: error code, 3: error message */
					esc_html__( 'REST request GET %1$s failed with: (%2$s) %3$s', 'woocommerce' ),
					$endpoint,
					$response_data['code'] ?? 'unknown_error',
					$response_data['message'] ?? esc_html__( 'Unknown error', 'woocommerce' )
				),
				$response_data
			);
		}

		// If the response is 200, return the data.
		return $response_data;
	}

	/**
	 * Post data to a WooCommerce API endpoint and return the response data.
	 *
	 * @param string $endpoint Endpoint.
	 * @param array  $params   Params to pass with request body.
	 *
	 * @return array|\WP_Error The response data or a WP_Error object.
	 */
	public static function rest_endpoint_post_request( string $endpoint, array $params = array() ) {
		$request = new \WP_REST_Request( 'POST', $endpoint );
		if ( $params ) {
			$request->set_body_params( $params );
		}

		// Do the internal request.
		// This has minimal overhead compared to an external request.
		$response = rest_do_request( $request );

		$server        = rest_get_server();
		$response_data = json_decode( wp_json_encode( $server->response_to_data( $response, false ) ), true );

		// Handle non-200 responses.
		if ( 200 !== $response->get_status() ) {
			return new \WP_Error(
				'woocommerce_settings_payments_rest_error',
				sprintf(
				/* translators: 1: the endpoint relative URL, 2: error code, 3: error message */
					esc_html__( 'REST request POST %1$s failed with: (%2$s) %3$s', 'woocommerce' ),
					$endpoint,
					$response_data['code'] ?? 'unknown_error',
					$response_data['message'] ?? esc_html__( 'Unknown error', 'woocommerce' )
				),
				$response_data
			);
		}

		// If the response is 200, return the data.
		return $response_data;
	}

	/**
	 * Get the details to authorize a connection to WordPress.com.
	 *
	 * The most important part of the result is the URL to redirect to for authorization.
	 *
	 * @param string $return_url The URL to redirect to after the connection is authorized.
	 *
	 * @return array {
	 *               'success' => bool Whether the request was successful.
	 *               'errors' => array An array of error messages, if any.
	 *               'color_scheme' => string The color scheme to use for the authorization page.
	 *               'url' => string The URL to redirect to for authorization.
	 * }
	 */
	public static function get_wpcom_connection_authorization( string $return_url ): array {
		$result = JetpackConnection::get_authorization_url( $return_url );

		if ( ! empty( $result['url'] ) ) {
			$result['url'] = add_query_arg(
				array(
					// We use the new WooDNA value.
					'from'         => 'woocommerce-onboarding',
					// We inform Calypso that this is a WooPayments onboarding flow.
					'plugin_name'  => 'woocommerce-payments',
					// Use the current user's WP admin color scheme.
					'color_scheme' => $result['color_scheme'],
				),
				$result['url']
			);
		}

		return $result;
	}
}
PK     [1]V1  1  %  Admin/Settings/PaymentsController.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings;

use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\WooPayments\WooPaymentsService;
use Automattic\WooCommerce\Internal\Logging\SafeGlobalFunctionProxy;
use Throwable;
use WC_Gateway_BACS;
use WC_Gateway_Cheque;
use WC_Gateway_COD;

defined( 'ABSPATH' ) || exit;
/**
 * Payments settings controller class.
 *
 * Use this class for hooks and actions related to the Payments settings page.
 *
 * @internal
 */
class PaymentsController {

	const TRANSIENT_HAS_PROVIDERS_WITH_INCENTIVE_KEY = 'woocommerce_admin_settings_payments_has_providers_with_incentive';

	/**
	 * The payment service.
	 *
	 * @var Payments
	 */
	private Payments $payments;

	/**
	 * Register hooks.
	 */
	public function register() {
		add_action( 'admin_menu', array( $this, 'add_menu' ) );
		add_filter( 'admin_body_class', array( $this, 'add_body_classes' ), 20 );
		add_filter( 'woocommerce_admin_shared_settings', array( $this, 'preload_settings' ) );
		add_filter( 'woocommerce_admin_allowed_promo_notes', array( $this, 'add_allowed_promo_notes' ) );
		add_filter( 'woocommerce_get_sections_checkout', array( $this, 'handle_sections' ), 20 );
		add_action( 'woocommerce_admin_payments_extension_suggestion_incentive_dismissed', array( $this, 'handle_incentive_dismissed' ) );
	}

	/**
	 * Initialize the class instance.
	 *
	 * @param Payments $payments The payments service.
	 *
	 * @internal
	 */
	final public function init( Payments $payments ): void {
		$this->payments = $payments;
	}

	/**
	 * Adds the Payments top-level menu item.
	 */
	public function add_menu() {
		global $menu;

		// When the WooPayments account is onboarded, WooPayments will own the Payments menu item since it is the native Woo payments solution.
		if ( $this->is_woopayments_account_onboarded() ) {
			return;
		} else {
			// Otherwise, remove the Payments menu item linking to the Connect page to avoid Payments menu item duplication.
			remove_menu_page( 'wc-admin&path=/payments/connect' );
		}

		$menu_title = esc_html__( 'Payments', 'woocommerce' );
		$menu_icon  = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4NTIiIGhlaWdodD0iNjg0Ij48cGF0aCBmaWxsPSIjYTJhYWIyIiBkPSJNODIgODZ2NTEyaDY4NFY4NlptMCA1OThjLTQ4IDAtODQtMzgtODQtODZWODZDLTIgMzggMzQgMCA4MiAwaDY4NGM0OCAwIDg0IDM4IDg0IDg2djUxMmMwIDQ4LTM2IDg2LTg0IDg2em0zODQtNTU2djQ0aDg2djg0SDM4MnY0NGgxMjhjMjQgMCA0MiAxOCA0MiA0MnYxMjhjMCAyNC0xOCA0Mi00MiA0MmgtNDR2NDRoLTg0di00NGgtODZ2LTg0aDE3MHYtNDRIMzM4Yy0yNCAwLTQyLTE4LTQyLTQyVjIxNGMwLTI0IDE4LTQyIDQyLTQyaDQ0di00NHoiLz48L3N2Zz4=';
		// Link to the Payments settings page.
		$menu_path = 'admin.php?page=wc-settings&tab=checkout&from=' . Payments::FROM_PAYMENTS_MENU_ITEM;

		add_menu_page(
			$menu_title,
			$menu_title,
			'manage_woocommerce', // Capability required to see the menu item.
			$menu_path,
			null,
			$menu_icon,
			56, // Position after WooCommerce Product menu item.
		);

		// If there are providers with an active incentive, add a notice badge to the Payments menu item.
		if ( $this->store_has_providers_with_incentive() ) {
			$badge = ' <span class="wcpay-menu-badge awaiting-mod count-1"><span class="plugin-count">1</span></span>';
			foreach ( $menu as $index => $menu_item ) {
				// Only add the badge markup if not already present, and the menu item is the Payments menu item.
				if ( 0 === strpos( $menu_item[0], $menu_title )
					&& $menu_path === $menu_item[2]
					&& false === strpos( $menu_item[0], $badge ) ) {

					$menu[ $index ][0] .= $badge; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited

					// One menu item with a badge is more than enough.
					break;
				}
			}
		}
	}

	/**
	 * Adds body classes when in the Payments Settings admin area.
	 *
	 * @param string $classes The existing body classes for the admin area.
	 *
	 * @return string The modified body classes for the admin area.
	 */
	public function add_body_classes( $classes = '' ) {
		global $current_tab;

		// Bail if the type is invalid.
		if ( ! is_string( $classes ) ) {
			return $classes;
		}

		if ( 'checkout' === $current_tab && ! str_contains( 'woocommerce-settings-payments-tab', $classes ) ) {
			$classes = "$classes woocommerce-settings-payments-tab";
		}

		return $classes;
	}

	/**
	 * Preload settings to make them available to the Payments settings page frontend logic.
	 *
	 * Added keys will be available in the window.wcSettings.admin object.
	 *
	 * @param array $settings The settings array.
	 *
	 * @return array Settings array with additional settings added.
	 */
	public function preload_settings( $settings = array() ) {
		// We only preload settings in the WP admin.
		if ( ! is_admin() ) {
			return $settings;
		}

		// Reset the received value if the type is invalid.
		if ( ! is_array( $settings ) ) {
			$settings = array();
		}

		// Add the business location country to the settings.
		if ( ! isset( $settings[ Payments::PAYMENTS_NOX_PROFILE_KEY ] ) ) {
			$settings[ Payments::PAYMENTS_NOX_PROFILE_KEY ] = array();
		}
		$settings[ Payments::PAYMENTS_NOX_PROFILE_KEY ]['business_country_code'] = $this->payments->get_country();

		return $settings;
	}

	/**
	 * Adds promo note IDs to the list of allowed ones.
	 *
	 * @param array $promo_notes Allowed promo note IDs.
	 *
	 * @return array The updated list of allowed promo note IDs.
	 */
	public function add_allowed_promo_notes( $promo_notes = array() ): array {
		// Reset the value if the type is invalid.
		if ( ! is_array( $promo_notes ) ) {
			$promo_notes = array();
		}

		try {
			$providers = $this->payments->get_payment_providers( $this->payments->get_country(), false );
		} catch ( Throwable $e ) {
			// Catch everything since we don't want to break all the WP admin pages.
			// Log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->error(
				'Failed to get payment providers: ' . $e->getMessage(),
				array(
					'source' => 'settings-payments',
				)
			);

			return $promo_notes;
		}

		// Add all incentive promo IDs to the allowed promo notes list.
		foreach ( $providers as $provider ) {
			if ( ! empty( $provider['_incentive']['promo_id'] ) ) {
				$promo_notes[] = $provider['_incentive']['promo_id'];
			}
		}

		return $promo_notes;
	}

	/**
	 * Alter the Payments tab sections under certain conditions.
	 *
	 * @param array $sections The payments/checkout tab sections.
	 *
	 * @return array The filtered sections.
	 */
	public function handle_sections( $sections = array() ): array {
		global $current_section;

		// Reset the value if the type is invalid.
		if ( ! is_array( $sections ) ) {
			$sections = array();
		}

		// Bail if the current section global is empty or of the wrong type.
		if ( empty( $current_section ) || ! is_string( $current_section ) ) {
			return $sections;
		}

		// For WooPayments and offline payment methods settings pages, we don't want any section navigation.
		if ( in_array( $current_section, array( WooPaymentsService::GATEWAY_ID, WC_Gateway_BACS::ID, WC_Gateway_Cheque::ID, WC_Gateway_COD::ID ), true ) ) {
			return array();
		}

		return $sections;
	}

	/**
	 * Handle the payments extension suggestion incentive dismissed event.
	 *
	 * @return void
	 */
	public function handle_incentive_dismissed(): void {
		// Clear the transient to force a new check for providers with an incentive.
		delete_transient( self::TRANSIENT_HAS_PROVIDERS_WITH_INCENTIVE_KEY );
	}

	/**
	 * Check if the store has any enabled gateways (including offline payment methods).
	 *
	 * @return bool True if the store has any enabled gateways, false otherwise.
	 */
	private function store_has_enabled_gateways(): bool {
		$gateways         = WC()->payment_gateways->get_available_payment_gateways();
		$enabled_gateways = array_filter(
			$gateways,
			function ( $gateway ) {
				return 'yes' === $gateway->enabled;
			}
		);

		return ! empty( $enabled_gateways );
	}

	/**
	 * Check if the store has any payment providers that have an active incentive.
	 *
	 * @return bool True if the store has providers with an active incentive.
	 */
	private function store_has_providers_with_incentive(): bool {
		// First, try to use the transient value.
		$transient = get_transient( self::TRANSIENT_HAS_PROVIDERS_WITH_INCENTIVE_KEY );
		if ( false !== $transient ) {
			return filter_var( $transient, FILTER_VALIDATE_BOOLEAN );
		}

		try {
			$providers = $this->payments->get_payment_providers( $this->payments->get_country(), false );
		} catch ( Throwable $e ) {
			// Catch everything since we don't want to break all the WP admin pages.
			// Log so we can investigate.
			SafeGlobalFunctionProxy::wc_get_logger()->error(
				'Failed to get payment providers: ' . $e->getMessage(),
				array(
					'source' => 'settings-payments',
				)
			);

			// In case of an error, default to false.
			// Set the transient to avoid repeated errors.
			set_transient( self::TRANSIENT_HAS_PROVIDERS_WITH_INCENTIVE_KEY, 'no', HOUR_IN_SECONDS );
			return false;
		}

		$has_providers_with_incentive = false;
		// Go through the providers and check if any of them have a "prominently" visible incentive (i.e., modal or banner).
		foreach ( $providers as $provider ) {
			if ( empty( $provider['_incentive'] ) ) {
				continue;
			}

			$dismissals = $provider['_incentive']['_dismissals'] ?? array();

			// If there are no dismissals at all, the incentive is prominently visible.
			if ( empty( $dismissals ) ) {
				$has_providers_with_incentive = true;
				break;
			}

			// First, we check to see if the incentive was dismissed in the banner context.
			// The banner context has the lowest priority, so if it was dismissed, we don't need to check the modal context.
			// If the banner is dismissed, there is no prominent incentive.
			$is_dismissed_banner = ! empty(
				array_filter(
					$dismissals,
					function ( $dismissal ) {
						return isset( $dismissal['context'] ) && 'wc_settings_payments__banner' === $dismissal['context'];
					}
				)
			);
			if ( $is_dismissed_banner ) {
				continue;
			}

			// In case an incentive uses the modal surface also (like the WooPayments Switch incentive),
			// we rely on the fact that the modal falls back to the banner, once dismissed, after 30 days.
			// @see here's its frontend "brother" in client/admin/client/settings-payments/settings-payments-main.tsx.
			$is_dismissed_modal = ! empty(
				array_filter(
					$dismissals,
					function ( $dismissal ) {
						return isset( $dismissal['context'] ) && 'wc_settings_payments__modal' === $dismissal['context'];
					}
				)
			);
			// If there are no modal dismissals, the incentive is still visible.
			if ( ! $is_dismissed_modal ) {
				$has_providers_with_incentive = true;
				break;
			}

			$is_dismissed_modal_more_than_30_days_ago = ! empty(
				array_filter(
					$dismissals,
					function ( $dismissal ) {
						return isset( $dismissal['context'], $dismissal['timestamp'] ) &&
							'wc_settings_payments__modal' === $dismissal['context'] &&
							$dismissal['timestamp'] < strtotime( '-30 days' );
					}
				)
			);
			// If the modal was dismissed less than 30 days ago, there is no prominent incentive (aka the banner is not shown).
			if ( ! $is_dismissed_modal_more_than_30_days_ago ) {
				continue;
			}

			// The modal was dismissed more than 30 days ago, so the banner is visible.
			$has_providers_with_incentive = true;
			break;
		}

		// Save the value in a transient to avoid unnecessary processing throughout the WP admin.
		// Incentives don't change frequently, so it is safe to cache the value for 1 hour.
		set_transient( self::TRANSIENT_HAS_PROVIDERS_WITH_INCENTIVE_KEY, $has_providers_with_incentive ? 'yes' : 'no', HOUR_IN_SECONDS );

		return $has_providers_with_incentive;
	}

	/**
	 * Check if the WooPayments account is onboarded.
	 *
	 * @return boolean
	 */
	private function is_woopayments_account_onboarded(): bool {
		// Sanity check: the WooPayments extension must be active.
		if ( ! class_exists( '\WC_Payments' ) ) {
			return false;
		}

		$account_data = get_option( 'wcpay_account_data', array() );

		// The account ID must be present.
		if ( empty( $account_data['data']['account_id'] ) ) {
			return false;
		}

		// We consider the store to have an onboarded WooPayments account if account data in the WooPayments account cache
		// contains a details_submitted = true entry. This implies that WooPayments is also connected.
		if ( empty( $account_data['data']['details_submitted'] ) ) {
			return false;
		}

		return filter_var( $account_data['data']['details_submitted'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ) ?? false;
	}
}
PK     [1]ic    $  Admin/Settings/PaymentsProviders.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Settings;

use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\Affirm;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\AfterpayClearpay;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\Airwallex;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\AmazonPay;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\Antom;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\Eway;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\GoCardless;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\HelioPay;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\Klarna;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\KlarnaCheckout;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\MercadoPago;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\Mollie;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\Monei;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\NexiCheckout;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\Payfast;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\PaymentGateway;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\Paymob;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\Payoneer;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\PayPal;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\Paystack;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\Paytrail;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\PayUIndia;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\Razorpay;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\Stripe;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\Tilopay;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\Visa;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\Vivacom;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\WCCore;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\WooPayments;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\WooPayments\WooPaymentsService;
use Automattic\WooCommerce\Internal\Admin\Suggestions\PaymentsExtensionSuggestions as ExtensionSuggestions;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Exception;
use WC_Payment_Gateway;
use WC_Gateway_BACS;
use WC_Gateway_Cheque;
use WC_Gateway_COD;
use WC_Gateway_Paypal;

defined( 'ABSPATH' ) || exit;

/**
 * Payments Providers class.
 *
 * @internal
 */
class PaymentsProviders {

	public const TYPE_GATEWAY           = 'gateway';
	public const TYPE_OFFLINE_PM        = 'offline_pm';
	public const TYPE_OFFLINE_PMS_GROUP = 'offline_pms_group';
	public const TYPE_SUGGESTION        = 'suggestion';

	public const OFFLINE_METHODS = array( WC_Gateway_BACS::ID, WC_Gateway_Cheque::ID, WC_Gateway_COD::ID );

	public const EXTENSION_NOT_INSTALLED = 'not_installed';
	public const EXTENSION_INSTALLED     = 'installed';
	public const EXTENSION_ACTIVE        = 'active';

	// For providers that are delivered through a plugin available on the WordPress.org repository.
	public const EXTENSION_TYPE_WPORG = 'wporg';
	// For providers that are delivered through a must-use plugin.
	public const EXTENSION_TYPE_MU_PLUGIN = 'mu_plugin';
	// For providers that are delivered through a theme.
	public const EXTENSION_TYPE_THEME = 'theme';
	// For providers that are delivered through an unknown mechanism.
	public const EXTENSION_TYPE_UNKNOWN = 'unknown';

	public const PROVIDERS_ORDER_OPTION         = 'woocommerce_gateway_order';
	public const SUGGESTION_ORDERING_PREFIX     = '_wc_pes_';
	public const OFFLINE_METHODS_ORDERING_GROUP = '_wc_offline_payment_methods_group';

	public const CATEGORY_EXPRESS_CHECKOUT = 'express_checkout';
	public const CATEGORY_BNPL             = 'bnpl';
	public const CATEGORY_CRYPTO           = 'crypto';
	public const CATEGORY_PSP              = 'psp';

	/*
	 * The provider link types.
	 *
	 * These are hints for the UI to determine if and how to display the link.
	 */
	public const LINK_TYPE_SUPPORT = 'support';
	public const LINK_TYPE_DOCS    = 'documentation';
	public const LINK_TYPE_ABOUT   = 'about';
	public const LINK_TYPE_TERMS   = 'terms';
	public const LINK_TYPE_PRICING = 'pricing';

	/**
	 * The map of gateway IDs to their respective provider classes.
	 *
	 * @var \class-string[]
	 */
	private array $payment_gateways_providers_class_map = array(
		WC_Gateway_BACS::ID           => WCCore::class,
		WC_Gateway_Cheque::ID         => WCCore::class,
		WC_Gateway_COD::ID            => WCCore::class,
		WC_Gateway_Paypal::ID         => WCCore::class,
		'woocommerce_payments'        => WooPayments::class,
		'ppcp-gateway'                => PayPal::class,
		'stripe'                      => Stripe::class,
		'stripe_*'                    => Stripe::class,
		'mollie'                      => Mollie::class,
		'mollie_wc_gateway_*'         => Mollie::class, // Target all the Mollie gateways.
		'amazon_payments_advanced*'   => AmazonPay::class,
		'woo-mercado-pago-*'          => MercadoPago::class,
		'affirm'                      => Affirm::class,
		'klarna_payments'             => Klarna::class,
		'afterpay'                    => AfterpayClearpay::class,
		'clearpay'                    => AfterpayClearpay::class,
		'antom_*'                     => Antom::class,
		'razorpay'                    => Razorpay::class,
		'paystack'                    => Paystack::class,
		'paystack-*'                  => Paystack::class,
		'payfast'                     => Payfast::class,
		'payoneer-*'                  => Payoneer::class,
		'payubiz'                     => PayUIndia::class,
		'paymob'                      => Paymob::class,
		'paymob-*'                    => Paymob::class,
		'airwallex_*'                 => Airwallex::class,
		'vivawallet*'                 => Vivacom::class,
		'tilopay'                     => Tilopay::class,
		'helio'                       => HelioPay::class,
		'paytrail'                    => Paytrail::class,
		'monei'                       => Monei::class,
		'monei_*'                     => Monei::class,
		'gocardless'                  => GoCardless::class,
		'kco'                         => KlarnaCheckout::class,
		'visa_acceptance_solutions_*' => Visa::class,
		'eway'                        => Eway::class,
		'dibs_easy'                   => NexiCheckout::class,
	);

	/**
	 * The map of payment extension suggestion IDs to their respective provider classes.
	 *
	 * This is used to instantiate providers to provide details for the payment extension suggestions, pre-attachment.
	 *
	 * @var \class-string[]
	 */
	private array $payment_extension_suggestions_providers_class_map = array(
		ExtensionSuggestions::WOOPAYMENTS       => WooPayments::class,
		ExtensionSuggestions::PAYPAL_FULL_STACK => PayPal::class,
		ExtensionSuggestions::PAYPAL_WALLET     => PayPal::class,
		ExtensionSuggestions::STRIPE            => Stripe::class,
		ExtensionSuggestions::MOLLIE            => Mollie::class,
		ExtensionSuggestions::AMAZON_PAY        => AmazonPay::class,
		ExtensionSuggestions::MERCADO_PAGO      => MercadoPago::class,
		ExtensionSuggestions::AFFIRM            => Affirm::class,
		ExtensionSuggestions::KLARNA            => Klarna::class,
		ExtensionSuggestions::AFTERPAY          => AfterpayClearpay::class,
		ExtensionSuggestions::CLEARPAY          => AfterpayClearpay::class,
		ExtensionSuggestions::ANTOM             => Antom::class,
		ExtensionSuggestions::RAZORPAY          => Razorpay::class,
		ExtensionSuggestions::PAYSTACK          => Paystack::class,
		ExtensionSuggestions::PAYFAST           => Payfast::class,
		ExtensionSuggestions::PAYONEER          => Payoneer::class,
		ExtensionSuggestions::PAYU_INDIA        => PayUIndia::class,
		ExtensionSuggestions::PAYMOB            => Paymob::class,
		ExtensionSuggestions::AIRWALLEX         => Airwallex::class,
		ExtensionSuggestions::VIVA_WALLET       => Vivacom::class,
		ExtensionSuggestions::TILOPAY           => Tilopay::class,
		ExtensionSuggestions::HELIOPAY          => HelioPay::class,
		ExtensionSuggestions::PAYTRAIL          => Paytrail::class,
		ExtensionSuggestions::MONEI             => Monei::class,
		ExtensionSuggestions::GOCARDLESS        => GoCardless::class,
		ExtensionSuggestions::KLARNA_CHECKOUT   => KlarnaCheckout::class,
		ExtensionSuggestions::VISA              => Visa::class,
		ExtensionSuggestions::EWAY              => Eway::class,
		ExtensionSuggestions::NEXI_CHECKOUT     => NexiCheckout::class,
	);

	/**
	 * The instances of the payment providers.
	 *
	 * @var PaymentGateway[]
	 */
	private array $instances = array();

	/**
	 * The memoized payment gateways to avoid computing the list multiple times during a request.
	 *
	 * @var array
	 */
	private array $payment_gateways_memo = array();

	/**
	 * The memoized payment gateways for display to avoid computing the list multiple times during a request.
	 *
	 * This is especially important since it avoids triggering the legacy action multiple times during a request.
	 *
	 * @var array
	 */
	private array $payment_gateways_for_display_memo = array();

	/**
	 * The payment extension suggestions service.
	 *
	 * @var ExtensionSuggestions
	 */
	private ExtensionSuggestions $extension_suggestions;

	/**
	 * The LegacyProxy instance.
	 *
	 * @var LegacyProxy
	 */
	private LegacyProxy $proxy;

	/**
	 * Initialize the class instance.
	 *
	 * @param ExtensionSuggestions $payment_extension_suggestions The payment extension suggestions service.
	 * @param LegacyProxy          $proxy                         The LegacyProxy instance.
	 *
	 * @internal
	 */
	final public function init( ExtensionSuggestions $payment_extension_suggestions, LegacyProxy $proxy ): void {
		$this->extension_suggestions = $payment_extension_suggestions;
		$this->proxy                 = $proxy;
	}

	/**
	 * Get the payment gateways for the settings page.
	 *
	 * We apply the same actions and logic that the non-React Payments settings page uses to get the gateways.
	 * This way we maintain backwards compatibility.
	 *
	 * @param bool   $for_display  Whether the payment gateway list is intended for display purposes.
	 *                             This triggers the legacy `woocommerce_admin_field_payment_gateways` action and
	 *                             the exclusion of "shell" gateways.
	 *                             Default is true.
	 * @param string $country_code Optional. The country code for which the payment gateways are being generated.
	 *                             This should be an ISO 3166-1 alpha-2 country code.
	 *
	 * @return array The payment gateway objects list.
	 */
	public function get_payment_gateways( bool $for_display = true, string $country_code = '' ): array {
		// Normalize the country code to uppercase.
		$country_code = strtoupper( $country_code );

		// If we are asked for a display gateways list, we need to fire legacy actions and filter out "shells".
		if ( $for_display ) {
			if ( isset( $this->payment_gateways_for_display_memo[ $country_code ] ) ) {
				return $this->payment_gateways_for_display_memo[ $country_code ];
			}

			// We don't want to output anything from the action. So we buffer it and discard it.
			// We just want to give the payment extensions a chance to adjust the payment gateways list for the settings page.
			// This is primarily for backwards compatibility.
			ob_start();
			/**
			 * Fires before the payment gateways settings fields are rendered.
			 *
			 * @since 1.5.7
			 */
			do_action( 'woocommerce_admin_field_payment_gateways' );
			ob_end_clean();

			// Get all payment gateways, ordered by the user.
			$payment_gateways = WC()->payment_gateways()->payment_gateways;

			// Handle edge-cases for certain providers.
			$payment_gateways = $this->handle_non_standard_registration_for_payment_gateways( $payment_gateways );

			// Remove "shell" gateways from the list.
			$payment_gateways = $this->remove_shell_payment_gateways( $payment_gateways, $country_code );

			// Store the entire payment gateways list for display for later use.
			$this->payment_gateways_for_display_memo[ $country_code ] = $payment_gateways;

			return $payment_gateways;
		}

		// We were asked for the raw payment gateways list.
		if ( isset( $this->payment_gateways_memo[ $country_code ] ) ) {
			return $this->payment_gateways_memo[ $country_code ];
		}

		// Get all payment gateways, ordered by the user.
		$payment_gateways = WC()->payment_gateways()->payment_gateways;

		// Handle edge-cases for certain providers.
		$payment_gateways = $this->handle_non_standard_registration_for_payment_gateways( $payment_gateways );

		// Store the entire payment gateways list for later use.
		$this->payment_gateways_memo[ $country_code ] = $payment_gateways;

		return $payment_gateways;
	}

	/**
	 * Remove "shell" gateways from the provided payment gateways list.
	 *
	 * We consider a gateway to be a "shell" if it has no WC admin title or description.
	 * The removal is done in a way that ensures we do not remove all gateways from an extension,
	 * thus preventing user access to the settings page(s) for that extension.
	 *
	 * @param array  $payment_gateways The payment gateways list to process.
	 * @param string $country_code     Optional. The country code for which the payment gateways are being generated.
	 *                                 This should be an ISO 3166-1 alpha-2 country code.
	 *
	 * @return array The processed payment gateways list.
	 */
	public function remove_shell_payment_gateways( array $payment_gateways, string $country_code = '' ): array {
		// Normalize the country code to uppercase.
		$country_code = strtoupper( $country_code );

		$grouped_payment_gateways = $this->group_gateways_by_extension( $payment_gateways, $country_code );
		return array_filter(
			$payment_gateways,
			function ( $gateway ) use ( $grouped_payment_gateways, $country_code ) {
				// If the gateway is a shell, we only remove it if there are other, non-shell gateways from that extension.
				// This is to avoid removing all the gateways registered by an extension and
				// preventing user access to the settings page(s) for that extension.
				if ( $this->is_shell_payment_gateway( $gateway ) ) {
					$gateway_details = $this->get_payment_gateway_details( $gateway, 0, $country_code );
					// In case we don't have the needed extension details,
					// we allow the gateway to be displayed (aka better safe than sorry).
					if ( empty( $gateway_details ) || ! isset( $gateway_details['plugin'] ) || empty( $gateway_details['plugin']['file'] ) ) {
						return true;
					}

					if ( empty( $grouped_payment_gateways[ $gateway_details['plugin']['file'] ] ) ||
						count( $grouped_payment_gateways[ $gateway_details['plugin']['file'] ] ) <= 1 ) {
						// If there are no other gateways from the same extension, we let the shell gateway be displayed.
						return true;
					}

					// Check if there are any other gateways from the same extension that are NOT shells.
					foreach ( $grouped_payment_gateways[ $gateway_details['plugin']['file'] ] as $extension_gateway ) {
						if ( ! $this->is_shell_payment_gateway( $extension_gateway ) ) {
							// If we found a gateway from the same extension that is not a shell,
							// we hide all shells from that extension.
							return false;
						}
					}
				}

				// By this point, we know that the gateway is not a shell or that it is a shell
				// but there are no non-shell gateways from the same extension. Include it.
				return true;
			}
		);
	}

	/**
	 * Get the payment gateway provider instance.
	 *
	 * @param string $gateway_id The gateway ID.
	 *
	 * @return PaymentGateway The payment gateway provider instance.
	 *                        Will return the general provider of no specific provider is found.
	 */
	public function get_payment_gateway_provider_instance( string $gateway_id ): PaymentGateway {
		if ( isset( $this->instances[ $gateway_id ] ) ) {
			return $this->instances[ $gateway_id ];
		}

		/**
		 * The provider class for the gateway.
		 *
		 * @var class-string<PaymentGateway>|null $provider_class
		 */
		$provider_class = null;
		if ( isset( $this->payment_gateways_providers_class_map[ $gateway_id ] ) ) {
			$provider_class = $this->payment_gateways_providers_class_map[ $gateway_id ];
		} else {
			// Check for wildcard mappings.
			foreach ( $this->payment_gateways_providers_class_map as $gateway_id_pattern => $mapped_class ) {
				// Try to see if we have a wildcard mapping and if the gateway ID matches it.
				// Use the first found match.
				if ( false !== strpos( $gateway_id_pattern, '*' ) ) {
					$gateway_id_pattern = str_replace( '*', '.*', $gateway_id_pattern );
					if ( preg_match( '/^' . $gateway_id_pattern . '$/', $gateway_id ) ) {
						$provider_class = $mapped_class;
						break;
					}
				}
			}
		}

		// Check that the provider class extends the PaymentGateway class.
		if ( ! is_null( $provider_class ) && ! is_subclass_of( $provider_class, PaymentGateway::class ) ) {
			wc_doing_it_wrong(
				__METHOD__,
				sprintf(
					/* translators: %s: Gateway ID. */
					esc_html__( 'The provider class for gateway ID "%s" must extend the PaymentGateway class.', 'woocommerce' ),
					$gateway_id
				),
				'10.4.0'
			);
			// Return the generic provider as a fallback.
			$provider_class = null;
		}

		// If the gateway ID is not mapped to a provider class, return the generic provider.
		if ( is_null( $provider_class ) ) {
			if ( ! isset( $this->instances['generic'] ) ) {
				$this->instances['generic'] = new PaymentGateway( $this->proxy );
			}

			return $this->instances['generic'];
		}

		$this->instances[ $gateway_id ] = new $provider_class( $this->proxy );

		return $this->instances[ $gateway_id ];
	}

	/**
	 * Get the payment extension suggestion (PES) provider instance.
	 *
	 * @param string $pes_id The payment extension suggestion ID.
	 *
	 * @return PaymentGateway The payment extension suggestion provider instance.
	 *                        Will return the general provider of no specific provider is found.
	 */
	public function get_payment_extension_suggestion_provider_instance( string $pes_id ): PaymentGateway {
		if ( isset( $this->instances[ $pes_id ] ) ) {
			return $this->instances[ $pes_id ];
		}

		/**
		 * The provider class for the payment extension suggestion (PES).
		 *
		 * @var class-string<PaymentGateway>|null $provider_class
		 */
		$provider_class = null;
		if ( isset( $this->payment_extension_suggestions_providers_class_map[ $pes_id ] ) ) {
			if ( ! is_subclass_of( $this->payment_extension_suggestions_providers_class_map[ $pes_id ], PaymentGateway::class ) ) {
				wc_doing_it_wrong(
					__METHOD__,
					sprintf(
						/* translators: %s: Payment extension suggestion ID. */
						esc_html__( 'The provider class for payment extension suggestion ID "%s" must extend the PaymentGateway class.', 'woocommerce' ),
						$pes_id
					),
					'10.4.0'
				);
				// Return the generic provider as a fallback.
			} else {
				$provider_class = $this->payment_extension_suggestions_providers_class_map[ $pes_id ];
			}
		}

		// If the gateway ID is not mapped to a provider class, return the generic provider.
		if ( is_null( $provider_class ) ) {
			if ( ! isset( $this->instances['generic'] ) ) {
				$this->instances['generic'] = new PaymentGateway( $this->proxy );
			}

			return $this->instances['generic'];
		}

		$this->instances[ $pes_id ] = new $provider_class( $this->proxy );

		return $this->instances[ $pes_id ];
	}

	/**
	 * Get the payment gateways details.
	 *
	 * @param WC_Payment_Gateway $payment_gateway       The payment gateway object.
	 * @param int                $payment_gateway_order The order of the payment gateway.
	 * @param string             $country_code          Optional. The country code for which the details are being gathered.
	 *                                                  This should be an ISO 3166-1 alpha-2 country code.
	 *
	 * @return array The payment gateway details.
	 */
	public function get_payment_gateway_details( WC_Payment_Gateway $payment_gateway, int $payment_gateway_order, string $country_code = '' ): array {
		// Normalize the country code to uppercase.
		$country_code = strtoupper( $country_code );

		return $this->enhance_payment_gateway_details(
			$this->get_payment_gateway_base_details( $payment_gateway, $payment_gateway_order, $country_code ),
			$payment_gateway,
			$country_code
		);
	}

	/**
	 * Get the payment gateways details from the object.
	 *
	 * @param WC_Payment_Gateway $payment_gateway       The payment gateway object.
	 * @param int                $payment_gateway_order The order of the payment gateway.
	 * @param string             $country_code          Optional. The country code for which the details are being gathered.
	 *                                                  This should be an ISO 3166-1 alpha-2 country code.
	 *
	 * @return array The payment gateway base details.
	 */
	public function get_payment_gateway_base_details( WC_Payment_Gateway $payment_gateway, int $payment_gateway_order, string $country_code = '' ): array {
		// Normalize the country code to uppercase.
		$country_code = strtoupper( $country_code );

		$provider = $this->get_payment_gateway_provider_instance( $payment_gateway->id );

		return $provider->get_details( $payment_gateway, $payment_gateway_order, $country_code );
	}

	/**
	 * Get the source plugin slug of a payment gateway instance.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 *
	 * @return string The plugin slug of the payment gateway.
	 *                Empty string if a plugin slug could not be determined.
	 */
	public function get_payment_gateway_plugin_slug( WC_Payment_Gateway $payment_gateway ): string {
		$provider = $this->get_payment_gateway_provider_instance( $payment_gateway->id );

		return $provider->get_plugin_slug( $payment_gateway );
	}

	/**
	 * Get the plugin file of payment gateway, without the .php extension.
	 *
	 * This is useful for the WP API, which expects the plugin file without the .php extension.
	 *
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 * @param string             $plugin_slug     Optional. The payment gateway plugin slug to use directly.
	 *
	 * @return string The plugin file corresponding to the payment gateway plugin. Does not include the .php extension.
	 */
	public function get_payment_gateway_plugin_file( WC_Payment_Gateway $payment_gateway, string $plugin_slug = '' ): string {
		$provider = $this->get_payment_gateway_provider_instance( $payment_gateway->id );

		return $provider->get_plugin_file( $payment_gateway, $plugin_slug );
	}

	/**
	 * Get the offline payment methods gateways.
	 *
	 * @return array The registered offline payment methods gateways keyed by their global gateways list order/index.
	 */
	public function get_offline_payment_methods_gateways(): array {
		return array_filter(
			$this->get_payment_gateways( false ), // We request the raw gateways list to get the global order/index.
			function ( $gateway ) {
				return $this->is_offline_payment_method( $gateway->id );
			}
		);
	}

	/**
	 * Check if a payment gateway is an offline payment method.
	 *
	 * @param string $id The ID of the payment gateway.
	 *
	 * @return bool True if the payment gateway is an offline payment method, false otherwise.
	 */
	public function is_offline_payment_method( string $id ): bool {
		return in_array( $id, self::OFFLINE_METHODS, true );
	}

	/**
	 * Check if a payment gateway is a shell payment gateway.
	 *
	 * A shell payment gateway is generally one that has no method title or description.
	 * This is used to identify gateways that are not intended for display in the admin UI.
	 *
	 * @param WC_Payment_Gateway $gateway The payment gateway object.
	 *
	 * @return bool True if the payment gateway is a shell, false otherwise.
	 */
	public function is_shell_payment_gateway( WC_Payment_Gateway $gateway ): bool {
		return ( empty( $gateway->get_method_title() ) && empty( $gateway->get_method_description() ) ) ||
			// Special case for WooPayments gateways that are not the main one: their method title is "WooPayments",
			// but their ID is made up of the main gateway ID and a suffix for the payment method.
			( 'WooPayments' === $gateway->get_method_title() && str_starts_with( $gateway->id, WooPaymentsService::GATEWAY_ID . '_' ) );
	}

	/**
	 * Get the payment extension suggestions for the given location.
	 *
	 * @param string $location The location for which the suggestions are being fetched.
	 * @param string $context  Optional. The context ID of where these extensions are being used.
	 *
	 * @return array[] The payment extension suggestions for the given location, split into preferred and other.
	 * @throws Exception If there are malformed or invalid suggestions.
	 */
	public function get_extension_suggestions( string $location, string $context = '' ): array {
		// Normalize the location to uppercase.
		$location = strtoupper( $location );

		$preferred_psp         = null;
		$preferred_apm         = null;
		$preferred_offline_psp = null;
		$other                 = array();

		$extensions = $this->extension_suggestions->get_country_extensions( $location, $context );
		// Sort them by _priority.
		usort(
			$extensions,
			function ( $a, $b ) {
				return $a['_priority'] <=> $b['_priority'];
			}
		);

		$has_enabled_ecommerce_gateways = $this->has_enabled_ecommerce_gateways();

		// Keep track of the active extensions.
		$active_extensions = array();

		foreach ( $extensions as $extension ) {
			$extension = $this->enhance_extension_suggestion( $extension );

			if ( self::EXTENSION_ACTIVE === $extension['plugin']['status'] ) {
				// If the suggested extension is active, we no longer suggest it.
				// But remember it for later.
				$active_extensions[] = $extension['id'];
				continue;
			}

			// Determine if the suggestion is preferred or not by looking at its tags.
			$is_preferred = in_array( ExtensionSuggestions::TAG_PREFERRED, $extension['tags'], true );

			// Determine if the suggestion is hidden (from the preferred locations).
			$is_hidden = $this->is_payment_extension_suggestion_hidden( $extension );

			if ( ! $is_hidden && $is_preferred ) {
				// If we don't have a preferred offline payments PSP and the suggestion is an offline payments preferred PSP,
				// add it to the preferred list.
				// Check this first so we don't inadvertently "fill" the preferred PSP slot.
				if ( empty( $preferred_offline_psp ) &&
					ExtensionSuggestions::TYPE_PSP === $extension['_type'] &&
					in_array( ExtensionSuggestions::TAG_PREFERRED_OFFLINE, $extension['tags'], true ) ) {

					$preferred_offline_psp = $extension;
					continue;
				}

				// If we don't have a preferred PSP and the suggestion is a preferred PSP, add it to the preferred list.
				if ( empty( $preferred_psp ) && ExtensionSuggestions::TYPE_PSP === $extension['_type'] ) {
					$preferred_psp = $extension;
					continue;
				}

				// If we don't have a preferred APM and the suggestion is a preferred APM, add it to the preferred list.
				// In the preferred APM slot we might surface APMs but also Express Checkouts (PayPal Wallet).
				if ( empty( $preferred_apm ) &&
					in_array( $extension['_type'], array( ExtensionSuggestions::TYPE_APM, ExtensionSuggestions::TYPE_EXPRESS_CHECKOUT ), true ) ) {

					$preferred_apm = $extension;
					continue;
				}
			}

			if ( $is_hidden &&
				ExtensionSuggestions::TYPE_APM === $extension['_type'] &&
				ExtensionSuggestions::PAYPAL_FULL_STACK === $extension['id'] ) {
				// If the PayPal Full Stack suggestion is hidden, we no longer suggest it,
				// because we have the PayPal Express Checkout (Wallet) suggestion.
				continue;
			}

			// If there are no enabled ecommerce gateways (no PSP selected),
			// we don't suggest express checkout, BNPL, or crypto extensions.
			if ( ! $has_enabled_ecommerce_gateways &&
				in_array( $extension['_type'], array( ExtensionSuggestions::TYPE_EXPRESS_CHECKOUT, ExtensionSuggestions::TYPE_BNPL, ExtensionSuggestions::TYPE_CRYPTO ), true )
			) {
				continue;
			}

			// If WooPayments or Stripe is active, we don't suggest other BNPLs.
			// Note: Affirm is available in the UK even with WooPayments or Stripe active
			// because Stripe does not support it there, yet.
			if ( ExtensionSuggestions::TYPE_BNPL === $extension['_type'] &&
				(
					in_array( ExtensionSuggestions::STRIPE, $active_extensions, true ) ||
					in_array( ExtensionSuggestions::WOOPAYMENTS, $active_extensions, true )
				) &&
				! (
					ExtensionSuggestions::AFFIRM === $extension['id'] &&
					'GB' === $location
				)
			) {
				continue;
			}

			// If we made it to this point, the suggestion goes into the other list.
			// But first, make sure there isn't already an extension added to the other list with the same plugin slug.
			// This can happen if the same extension is suggested as both a PSP and an APM.
			// The first entry that we encounter is the one that we keep.
			$extension_slug   = $extension['plugin']['slug'];
			$extension_exists = array_filter(
				$other,
				function ( $suggestion ) use ( $extension_slug ) {
					return $suggestion['plugin']['slug'] === $extension_slug;
				}
			);
			if ( ! empty( $extension_exists ) ) {
				continue;
			}

			$other[] = $extension;
		}

		// Make sure that the preferred suggestions are not among the other list by removing any entries with their plugin slug.
		$other = array_values(
			array_filter(
				$other,
				function ( $suggestion ) use ( $preferred_psp, $preferred_apm ) {
					return ( empty( $preferred_psp ) || $suggestion['plugin']['slug'] !== $preferred_psp['plugin']['slug'] ) &&
							( empty( $preferred_apm ) || $suggestion['plugin']['slug'] !== $preferred_apm['plugin']['slug'] );
				}
			)
		);

		// The preferred PSP gets a recommended tag that instructs the UI to highlight it further.
		if ( ! empty( $preferred_psp ) ) {
			$preferred_psp['tags'][] = ExtensionSuggestions::TAG_RECOMMENDED;
		}

		return array(
			'preferred' => array_values(
				array_filter(
					array(
						// The PSP should naturally have a higher priority than the APM, with the preferred offline PSP last.
						// No need to impose a specific order here.
						$preferred_psp,
						$preferred_apm,
						$preferred_offline_psp,
					)
				)
			),
			'other'     => $other,
		);
	}

	/**
	 * Get a payment extension suggestion by ID.
	 *
	 * @param string $id The ID of the payment extension suggestion.
	 *
	 * @return ?array The payment extension suggestion details, or null if not found.
	 */
	public function get_extension_suggestion_by_id( string $id ): ?array {
		$suggestion = $this->extension_suggestions->get_by_id( $id );
		if ( ! is_null( $suggestion ) ) {
			// Enhance the suggestion details.
			$suggestion = $this->enhance_extension_suggestion( $suggestion );
		}

		return $suggestion;
	}

	/**
	 * Get a payment extension suggestion by plugin slug.
	 *
	 * @param string $slug         The plugin slug of the payment extension suggestion.
	 * @param string $country_code Optional. The business location country code to get the suggestions for.
	 *
	 * @return ?array The payment extension suggestion details, or null if not found.
	 */
	public function get_extension_suggestion_by_plugin_slug( string $slug, string $country_code = '' ): ?array {
		// Normalize the country code to uppercase.
		$country_code = strtoupper( $country_code );

		$suggestion = $this->extension_suggestions->get_by_plugin_slug( $slug, $country_code, Payments::SUGGESTIONS_CONTEXT );
		if ( ! is_null( $suggestion ) ) {
			// Enhance the suggestion details.
			$suggestion = $this->enhance_extension_suggestion( $suggestion );
		}

		return $suggestion;
	}

	/**
	 * Attach a payment extension suggestion.
	 *
	 * Attachment is a broad concept that can mean different things depending on the suggestion.
	 * Currently, we use it to record the extension installation. This is why we expect to receive
	 * instructions to record attachment when the extension is installed.
	 *
	 * @param string $id The ID of the payment extension suggestion to attach.
	 *
	 * @return bool True if the suggestion was successfully marked as attached, false otherwise.
	 * @throws Exception If the suggestion ID is invalid.
	 */
	public function attach_extension_suggestion( string $id ): bool {
		// We may receive a suggestion ID that is actually an order map ID used in the settings page providers list.
		// Extract the suggestion ID from the order map ID.
		if ( $this->is_suggestion_order_map_id( $id ) ) {
			$id = $this->get_suggestion_id_from_order_map_id( $id );
		}

		$suggestion = $this->get_extension_suggestion_by_id( $id );
		if ( is_null( $suggestion ) ) {
			throw new Exception( esc_html__( 'Invalid suggestion ID.', 'woocommerce' ) );
		}

		$payments_nox_profile = get_option( Payments::PAYMENTS_NOX_PROFILE_KEY, array() );
		if ( empty( $payments_nox_profile ) ) {
			$payments_nox_profile = array();
		} else {
			$payments_nox_profile = maybe_unserialize( $payments_nox_profile );
		}

		// Check if it is already marked as attached.
		if ( ! empty( $payments_nox_profile['suggestions'][ $id ]['attached']['timestamp'] ) ) {
			return true;
		}

		// Mark the suggestion as attached.
		if ( empty( $payments_nox_profile['suggestions'] ) ) {
			$payments_nox_profile['suggestions'] = array();
		}
		if ( empty( $payments_nox_profile['suggestions'][ $id ] ) ) {
			$payments_nox_profile['suggestions'][ $id ] = array();
		}
		if ( empty( $payments_nox_profile['suggestions'][ $id ]['attached'] ) ) {
			$payments_nox_profile['suggestions'][ $id ]['attached'] = array();
		}
		$payments_nox_profile['suggestions'][ $id ]['attached']['timestamp'] = time();

		// Store the modified profile data.
		$result = update_option( Payments::PAYMENTS_NOX_PROFILE_KEY, $payments_nox_profile, false );
		// Since we already check if the suggestion is already attached, we should not get a false result
		// for trying to update with the same value.
		// False means the update failed and the suggestion is not marked as attached.
		if ( false === $result ) {
			return false;
		}

		// Handle custom attachment logic per-provider.
		switch ( $id ) {
			case ExtensionSuggestions::PAYPAL_FULL_STACK:
			case ExtensionSuggestions::PAYPAL_WALLET:
				// Set an option to inform the extension.
				update_option( 'woocommerce_paypal_branded', 'payments_settings', false );
				break;
			default:
				break;
		}

		return true;
	}

	/**
	 * Hide a payment extension suggestion.
	 *
	 * @param string $id The ID of the payment extension suggestion to hide.
	 *
	 * @return bool True if the suggestion was successfully hidden, false otherwise.
	 * @throws Exception If the suggestion ID is invalid.
	 */
	public function hide_extension_suggestion( string $id ): bool {
		// We may receive a suggestion ID that is actually an order map ID used in the settings page providers list.
		// Extract the suggestion ID from the order map ID.
		if ( $this->is_suggestion_order_map_id( $id ) ) {
			$id = $this->get_suggestion_id_from_order_map_id( $id );
		}

		$suggestion = $this->get_extension_suggestion_by_id( $id );
		if ( is_null( $suggestion ) ) {
			throw new Exception( esc_html__( 'Invalid suggestion ID.', 'woocommerce' ) );
		}

		$user_payments_nox_profile = get_user_meta( get_current_user_id(), Payments::PAYMENTS_NOX_PROFILE_KEY, true );
		if ( empty( $user_payments_nox_profile ) ) {
			$user_payments_nox_profile = array();
		} else {
			$user_payments_nox_profile = maybe_unserialize( $user_payments_nox_profile );
		}

		// Mark the suggestion as hidden.
		if ( empty( $user_payments_nox_profile['hidden_suggestions'] ) ) {
			$user_payments_nox_profile['hidden_suggestions'] = array();
		}
		// Check if it is already hidden.
		if ( in_array( $id, array_column( $user_payments_nox_profile['hidden_suggestions'], 'id' ), true ) ) {
			return true;
		}
		$user_payments_nox_profile['hidden_suggestions'][] = array(
			'id'        => $id,
			'timestamp' => time(),
		);

		$result = update_user_meta( get_current_user_id(), Payments::PAYMENTS_NOX_PROFILE_KEY, $user_payments_nox_profile );
		// Since we already check if the suggestion is already hidden, we should not get a false result
		// for trying to update with the same value. False means the update failed and the suggestion is not hidden.
		if ( false === $result ) {
			return false;
		}

		return true;
	}

	/**
	 * Get the payment extension suggestions categories details.
	 *
	 * @return array The payment extension suggestions categories.
	 */
	public function get_extension_suggestion_categories(): array {
		$categories   = array();
		$categories[] = array(
			'id'          => self::CATEGORY_EXPRESS_CHECKOUT,
			'_priority'   => 10,
			'title'       => esc_html__( 'Wallets & Express checkouts', 'woocommerce' ),
			'description' => esc_html__( 'Allow shoppers to fast-track the checkout process with express options like Apple Pay and Google Pay.', 'woocommerce' ),
		);
		$categories[] = array(
			'id'          => self::CATEGORY_BNPL,
			'_priority'   => 20,
			'title'       => esc_html__( 'Buy Now, Pay Later', 'woocommerce' ),
			'description' => esc_html__( 'Offer flexible payment options to your shoppers.', 'woocommerce' ),
		);
		$categories[] = array(
			'id'          => self::CATEGORY_CRYPTO,
			'_priority'   => 30,
			'title'       => esc_html__( 'Crypto Payments', 'woocommerce' ),
			'description' => esc_html__( 'Offer cryptocurrency payment options to your shoppers.', 'woocommerce' ),
		);
		$categories[] = array(
			'id'          => self::CATEGORY_PSP,
			'_priority'   => 40,
			'title'       => esc_html__( 'Payment Providers', 'woocommerce' ),
			'description' => esc_html__( 'Give your shoppers additional ways to pay.', 'woocommerce' ),
		);

		return $categories;
	}

	/**
	 * Get the payment providers order map.
	 *
	 * @return array The payment providers order map.
	 */
	public function get_order_map(): array {
		// This will also handle backwards compatibility.
		return $this->enhance_order_map( get_option( self::PROVIDERS_ORDER_OPTION, array() ) );
	}

	/**
	 * Save the payment providers order map.
	 *
	 * @param array $order_map The order map to save.
	 *
	 * @return bool True if the payment providers order map was successfully saved, false otherwise.
	 */
	public function save_order_map( array $order_map ): bool {
		return update_option( self::PROVIDERS_ORDER_OPTION, $order_map );
	}

	/**
	 * Update the payment providers order map.
	 *
	 * This has effects both on the Payments settings page and the checkout page
	 * since registered payment gateways (enabled or not) are among the providers.
	 *
	 * @param array $order_map The new order for payment providers.
	 *                         The order map should be an associative array where the keys are the payment provider IDs
	 *                         and the values are the new integer order for the payment provider.
	 *                         This can be a partial list of payment providers and their orders.
	 *                         It can also contain new IDs and their orders.
	 *
	 * @return bool True if the payment providers ordering was successfully updated, false otherwise.
	 */
	public function update_payment_providers_order_map( array $order_map ): bool {
		$existing_order_map = get_option( self::PROVIDERS_ORDER_OPTION, array() );

		$new_order_map = $this->payment_providers_order_map_apply_mappings( $existing_order_map, $order_map );

		// This will also handle backwards compatibility.
		$new_order_map = $this->enhance_order_map( $new_order_map );

		// Save the new order map to the DB.
		return $this->save_order_map( $new_order_map );
	}

	/**
	 * Enhance a payment providers order map.
	 *
	 * If the payments providers order map is empty, it will be initialized with the current WC payment gateway ordering.
	 * If there are missing entries (registered payment gateways, suggestions, offline PMs, etc.), they will be added.
	 * Various rules will be enforced (e.g., offline PMs and their relation with the offline PMs group).
	 *
	 * @param array $order_map The payment providers order map.
	 *
	 * @return array The updated payment providers order map.
	 */
	public function enhance_order_map( array $order_map ): array {
		// We don't request the display gateways list because we need to get the order of all the registered payment gateways.
		$payment_gateways = $this->get_payment_gateways( false );
		// Make it a list keyed by the payment gateway ID.
		$payment_gateways = array_combine(
			array_map(
				fn( $gateway ) => $gateway->id,
				$payment_gateways
			),
			$payment_gateways
		);
		// Get the payment gateways order map.
		$payment_gateways_order_map = array_flip( array_keys( $payment_gateways ) );
		// Get the payment gateways to suggestions map.
		// There will be null entries for payment gateways where we couldn't find a suggestion.
		$payment_gateways_to_suggestions_map = array_map(
			fn( $gateway ) => $this->extension_suggestions->get_by_plugin_slug( Utils::normalize_plugin_slug( $this->get_payment_gateway_plugin_slug( $gateway ) ) ),
			$payment_gateways
		);

		/*
		 * Initialize the order map with the current ordering.
		 */
		if ( empty( $order_map ) ) {
			$order_map = $payment_gateways_order_map;
		}

		$order_map = Utils::order_map_normalize( $order_map );

		$handled_suggestion_ids = array();

		/*
		 * Go through the registered gateways and add any missing ones.
		 */
		// Use a map to keep track of the insertion offset for each suggestion ID.
		// We need this so we can place multiple PGs matching a suggestion right after it but maintain their relative order.
		$suggestion_order_map_id_to_offset_map = array();
		foreach ( $payment_gateways_order_map as $id => $order ) {
			if ( isset( $order_map[ $id ] ) ) {
				continue;
			}

			// If there is a suggestion entry matching this payment gateway,
			// we will add the payment gateway right after it so gateways pop-up in place of matching suggestions.
			// We rely on suggestions and matching registered PGs being mutually exclusive in the UI.
			if ( ! empty( $payment_gateways_to_suggestions_map[ $id ] ) ) {
				$suggestion_id           = $payment_gateways_to_suggestions_map[ $id ]['id'];
				$suggestion_order_map_id = $this->get_suggestion_order_map_id( $suggestion_id );

				if ( isset( $order_map[ $suggestion_order_map_id ] ) ) {
					// Determine the offset for placing missing PGs after this suggestion.
					if ( ! isset( $suggestion_order_map_id_to_offset_map[ $suggestion_order_map_id ] ) ) {
						$suggestion_order_map_id_to_offset_map[ $suggestion_order_map_id ] = 0;
					}
					$suggestion_order_map_id_to_offset_map[ $suggestion_order_map_id ] += 1;

					// Place the missing payment gateway right after the suggestion,
					// with an offset to maintain relative order between multiple PGs matching the same suggestion.
					$order_map = Utils::order_map_place_at_order(
						$order_map,
						$id,
						$order_map[ $suggestion_order_map_id ] + $suggestion_order_map_id_to_offset_map[ $suggestion_order_map_id ]
					);

					// Remember that we handled this suggestion - don't worry about remembering it multiple times.
					$handled_suggestion_ids[] = $suggestion_id;
					continue;
				}
			}

			// Add the missing payment gateway at the end.
			$order_map[ $id ] = empty( $order_map ) ? 0 : max( $order_map ) + 1;
		}

		$handled_suggestion_ids = array_unique( $handled_suggestion_ids );

		/*
		 * Place not yet handled suggestion entries right before their matching registered payment gateway IDs.
		 * This means that registered PGs already in the order map force the suggestions
		 * to be placed/moved right before them. We rely on suggestions and registered PGs being mutually exclusive.
		 */
		foreach ( array_keys( $order_map ) as $id ) {
			// If the id is not of a payment gateway or there is no suggestion for this payment gateway, ignore it.
			if ( ! array_key_exists( $id, $payment_gateways_to_suggestions_map ) ||
				empty( $payment_gateways_to_suggestions_map[ $id ] )
			) {
				continue;
			}

			$suggestion = $payment_gateways_to_suggestions_map[ $id ];
			// If the suggestion was already handled, skip it.
			if ( in_array( $suggestion['id'], $handled_suggestion_ids, true ) ) {
				continue;
			}

			// Place the suggestion at the same order as the payment gateway
			// thus ensuring that the suggestion is placed right before the payment gateway.
			$order_map = Utils::order_map_place_at_order(
				$order_map,
				$this->get_suggestion_order_map_id( $suggestion['id'] ),
				$order_map[ $id ]
			);

			// Remember that we've handled this suggestion to avoid adding it multiple times.
			// We only want to attach the suggestion to the first payment gateway that matches the plugin slug.
			$handled_suggestion_ids[] = $suggestion['id'];
		}

		// Extract all the registered offline PMs and keep their order values.
		$offline_methods = array_filter(
			$order_map,
			array( $this, 'is_offline_payment_method' ),
			ARRAY_FILTER_USE_KEY
		);
		if ( ! empty( $offline_methods ) ) {
			/*
			 * If the offline PMs group is missing, add it before the last offline PM.
			 */
			if ( ! array_key_exists( self::OFFLINE_METHODS_ORDERING_GROUP, $order_map ) ) {
				$last_offline_method_order = max( $offline_methods );

				$order_map = Utils::order_map_place_at_order( $order_map, self::OFFLINE_METHODS_ORDERING_GROUP, $last_offline_method_order );
			}

			/*
			 * Place all the offline PMs right after the offline PMs group entry.
			 */
			$target_order = $order_map[ self::OFFLINE_METHODS_ORDERING_GROUP ] + 1;
			// Sort the offline PMs by their order.
			asort( $offline_methods );
			foreach ( $offline_methods as $offline_method => $order ) {
				$order_map = Utils::order_map_place_at_order( $order_map, $offline_method, $target_order );
				++$target_order;
			}
		}

		return Utils::order_map_normalize( $order_map );
	}

	/**
	 * Get the ID of the suggestion order map entry.
	 *
	 * @param string $suggestion_id The ID of the suggestion.
	 *
	 * @return string The ID of the suggestion order map entry.
	 */
	public function get_suggestion_order_map_id( string $suggestion_id ): string {
		return self::SUGGESTION_ORDERING_PREFIX . $suggestion_id;
	}

	/**
	 * Check if the ID is a suggestion order map entry ID.
	 *
	 * @param string $id The ID to check.
	 *
	 * @return bool True if the ID is a suggestion order map entry ID, false otherwise.
	 */
	public function is_suggestion_order_map_id( string $id ): bool {
		return 0 === strpos( $id, self::SUGGESTION_ORDERING_PREFIX );
	}

	/**
	 * Get the ID of the suggestion from the suggestion order map entry ID.
	 *
	 * @param string $order_map_id The ID of the suggestion order map entry.
	 *
	 * @return string The ID of the suggestion.
	 */
	public function get_suggestion_id_from_order_map_id( string $order_map_id ): string {
		return str_replace( self::SUGGESTION_ORDERING_PREFIX, '', $order_map_id );
	}

	/**
	 * Reset the memoized data. Useful for testing purposes.
	 *
	 * @internal
	 * @return void
	 */
	public function reset_memo(): void {
		$this->payment_gateways_memo             = array();
		$this->payment_gateways_for_display_memo = array();
	}

	/**
	 * Handle payment gateways with non-standard registration behavior.
	 *
	 * @param array $payment_gateways The payment gateways list.
	 *
	 * @return array The payment gateways list with the necessary adjustments.
	 */
	private function handle_non_standard_registration_for_payment_gateways( array $payment_gateways ): array {
		/*
		 * Handle the Mollie gateway's particular behavior: if there are no API keys or no PMs enabled,
		 * the extension doesn't register a gateway instance.
		 * We will need to register a mock gateway to represent Mollie in the settings page.
		 */
		$payment_gateways = $this->maybe_add_pseudo_mollie_gateway( $payment_gateways );

		return $payment_gateways;
	}

	/**
	 * Add the pseudo Mollie gateway to the payment gateways list if necessary.
	 *
	 * @param array $payment_gateways The payment gateways list.
	 *
	 * @return array The payment gateways list with the pseudo Mollie gateway added if necessary.
	 */
	private function maybe_add_pseudo_mollie_gateway( array $payment_gateways ): array {
		$mollie_provider = $this->get_payment_gateway_provider_instance( 'mollie' );

		// Do nothing if there is a Mollie gateway registered.
		if ( $mollie_provider->is_gateway_registered( $payment_gateways ) ) {
			return $payment_gateways;
		}

		// Get the Mollie suggestion and determine if the plugin is active.
		$mollie_suggestion = $this->get_extension_suggestion_by_id( ExtensionSuggestions::MOLLIE );
		if ( empty( $mollie_suggestion ) ) {
			return $payment_gateways;
		}
		// Do nothing if the plugin is not active.
		if ( self::EXTENSION_ACTIVE !== $mollie_suggestion['plugin']['status'] ) {
			return $payment_gateways;
		}

		// Add the pseudo Mollie gateway to the list since the plugin is active but there is no Mollie gateway registered.
		$payment_gateways[] = $mollie_provider->get_pseudo_gateway( $mollie_suggestion );

		return $payment_gateways;
	}

	/**
	 * Enhance the payment gateway details with additional information from other sources.
	 *
	 * @param array              $gateway_details The gateway details to enhance.
	 * @param WC_Payment_Gateway $payment_gateway The payment gateway object.
	 * @param string             $country_code    The country code for which the details are being enhanced.
	 *                                            This should be an ISO 3166-1 alpha-2 country code.
	 *
	 * @return array The enhanced gateway details.
	 */
	private function enhance_payment_gateway_details( array $gateway_details, WC_Payment_Gateway $payment_gateway, string $country_code ): array {
		// We discriminate between offline payment methods and gateways.
		$gateway_details['_type'] = $this->is_offline_payment_method( $payment_gateway->id ) ? self::TYPE_OFFLINE_PM : self::TYPE_GATEWAY;

		$plugin_slug = $gateway_details['plugin']['slug'];
		// The payment gateway plugin might use a non-standard directory name.
		// Try to normalize it to the common slug to avoid false negatives when matching.
		$normalized_plugin_slug = Utils::normalize_plugin_slug( $plugin_slug );

		// If we have a matching suggestion, hoist details from there.
		// The suggestions only know about the normalized (aka official) plugin slug.
		$suggestion = $this->get_extension_suggestion_by_plugin_slug( $normalized_plugin_slug, $country_code );
		if ( ! is_null( $suggestion ) ) {
			// The title, description, icon, and image from the suggestion take precedence over the ones from the gateway.
			// This is temporary until we update the partner extensions.
			// Do not override the title and description for certain suggestions because theirs are more descriptive
			// (like including the payment method when registering multiple gateways for the same provider).
			if ( ! in_array(
				$suggestion['id'],
				array(
					ExtensionSuggestions::PAYPAL_FULL_STACK,
					ExtensionSuggestions::PAYPAL_WALLET,
					ExtensionSuggestions::MOLLIE,
					ExtensionSuggestions::MONEI,
					ExtensionSuggestions::ANTOM,
					ExtensionSuggestions::MERCADO_PAGO,
					ExtensionSuggestions::AMAZON_PAY,
					ExtensionSuggestions::SQUARE,
					ExtensionSuggestions::PAYONEER,
					ExtensionSuggestions::AIRWALLEX,
					ExtensionSuggestions::COINBASE,         // We don't have suggestion details yet.
					ExtensionSuggestions::AUTHORIZE_NET,    // We don't have suggestion details yet.
					ExtensionSuggestions::BOLT,             // We don't have suggestion details yet.
					ExtensionSuggestions::DEPAY,            // We don't have suggestion details yet.
					ExtensionSuggestions::ELAVON,           // We don't have suggestion details yet.
					ExtensionSuggestions::FORTISPAY,        // We don't have suggestion details yet.
					ExtensionSuggestions::PAYPAL_ZETTLE,    // We don't have suggestion details yet.
					ExtensionSuggestions::RAPYD,            // We don't have suggestion details yet.
					ExtensionSuggestions::PAYPAL_BRAINTREE, // We don't have suggestion details yet.
				),
				true
			) ) {
				if ( ! empty( $suggestion['title'] ) ) {
					$gateway_details['title'] = $suggestion['title'];
				}

				if ( ! empty( $suggestion['description'] ) ) {
					$gateway_details['description'] = $suggestion['description'];
				}
			}

			if ( ! empty( $suggestion['icon'] ) ) {
				$gateway_details['icon'] = $suggestion['icon'];
			}

			if ( ! empty( $suggestion['image'] ) ) {
				$gateway_details['image'] = $suggestion['image'];
			}

			if ( empty( $gateway_details['links'] ) && ! empty( $suggestion['links'] ) ) {
				$gateway_details['links'] = $suggestion['links'];
			}
			if ( empty( $gateway_details['tags'] ) && ! empty( $suggestion['tags'] ) ) {
				$gateway_details['tags'] = $suggestion['tags'];
			}
			if ( empty( $gateway_details['plugin'] ) && ! empty( $suggestion['plugin'] ) ) {
				$gateway_details['plugin'] = $suggestion['plugin'];
			}
			if ( empty( $gateway_details['_incentive'] ) && ! empty( $suggestion['_incentive'] ) ) {
				$gateway_details['_incentive'] = $suggestion['_incentive'];
			}

			// Attach the suggestion ID to the gateway details so we can reference it with precision.
			$gateway_details['_suggestion_id'] = $suggestion['id'];
		}

		// Get the gateway's corresponding plugin details.
		$plugin_data = $this->proxy->call_static( PluginsHelper::class, 'get_plugin_data', $plugin_slug );
		if ( ! empty( $plugin_data ) ) {
			// If there are no links, try to get them from the plugin data.
			if ( empty( $gateway_details['links'] ) ) {
				if ( is_array( $plugin_data ) && ! empty( $plugin_data['PluginURI'] ) ) {
					$gateway_details['links'] = array(
						array(
							'_type' => self::LINK_TYPE_ABOUT,
							'url'   => esc_url( $plugin_data['PluginURI'] ),
						),
					);
				} elseif ( ! empty( $gateway_details['plugin']['_type'] ) &&
							ExtensionSuggestions::PLUGIN_TYPE_WPORG === $gateway_details['plugin']['_type'] ) {

					// Fallback to constructing the WPORG plugin URI from the normalized plugin slug.
					$gateway_details['links'] = array(
						array(
							'_type' => self::LINK_TYPE_ABOUT,
							'url'   => 'https://wordpress.org/plugins/' . $normalized_plugin_slug,
						),
					);
				}
			}
		}

		return $gateway_details;
	}

	/**
	 * Check if the store has any enabled ecommerce gateways.
	 *
	 * We exclude offline payment methods from this check.
	 *
	 * @return bool True if the store has any enabled ecommerce gateways, false otherwise.
	 */
	private function has_enabled_ecommerce_gateways(): bool {
		$gateways         = $this->get_payment_gateways( false ); // We want the raw gateways list.
		$enabled_gateways = array_filter(
			$gateways,
			function ( $gateway ) {
				// Filter out offline gateways.
				return 'yes' === $gateway->enabled && ! $this->is_offline_payment_method( $gateway->id );
			}
		);

		return ! empty( $enabled_gateways );
	}

	/**
	 * Enhance a payment extension suggestion with additional information.
	 *
	 * @param array $extension_suggestion The extension suggestion.
	 *
	 * @return array The enhanced payment extension suggestion.
	 */
	private function enhance_extension_suggestion( array $extension_suggestion ): array {
		// Determine the category of the extension.
		switch ( $extension_suggestion['_type'] ) {
			case ExtensionSuggestions::TYPE_PSP:
				$extension_suggestion['category'] = self::CATEGORY_PSP;
				break;
			case ExtensionSuggestions::TYPE_EXPRESS_CHECKOUT:
				$extension_suggestion['category'] = self::CATEGORY_EXPRESS_CHECKOUT;
				break;
			case ExtensionSuggestions::TYPE_BNPL:
				$extension_suggestion['category'] = self::CATEGORY_BNPL;
				break;
			case ExtensionSuggestions::TYPE_CRYPTO:
				$extension_suggestion['category'] = self::CATEGORY_CRYPTO;
				break;
			default:
				$extension_suggestion['category'] = '';
				break;
		}

		// Determine the PES's plugin status.
		// Default to not installed.
		$extension_suggestion['plugin']['status'] = self::EXTENSION_NOT_INSTALLED;
		// Put in the default plugin file.
		$extension_suggestion['plugin']['file'] = '';
		if ( ! empty( $extension_suggestion['plugin']['slug'] ) ) {
			// This is a best-effort approach, as the plugin might be sitting under a directory (slug) that we can't handle.
			// Always try the official plugin slug first, then the testing variations.
			$plugin_slug_variations = Utils::generate_testing_plugin_slugs( $extension_suggestion['plugin']['slug'], true );
			// Favor active plugins by checking the entire variations list for active plugins first.
			// This way we handle cases where there are multiple variations installed and one is active.
			$found = false;
			foreach ( $plugin_slug_variations as $plugin_slug ) {
				if ( $this->proxy->call_static( PluginsHelper::class, 'is_plugin_active', $plugin_slug ) ) {
					$found                                    = true;
					$extension_suggestion['plugin']['status'] = self::EXTENSION_ACTIVE;
					// Make sure we put in the actual slug and file path that we found.
					$extension_suggestion['plugin']['slug'] = $plugin_slug;
					$extension_suggestion['plugin']['file'] = $this->proxy->call_static( PluginsHelper::class, 'get_plugin_path_from_slug', $plugin_slug );
					// Sanity check.
					if ( ! is_string( $extension_suggestion['plugin']['file'] ) ) {
						$extension_suggestion['plugin']['file'] = '';
						break;
					}
					// Remove the .php extension from the file path. The WP API expects it without it.
					$extension_suggestion['plugin']['file'] = Utils::trim_php_file_extension( $extension_suggestion['plugin']['file'] );
					break;
				}
			}
			if ( ! $found ) {
				foreach ( $plugin_slug_variations as $plugin_slug ) {
					if ( $this->proxy->call_static( PluginsHelper::class, 'is_plugin_installed', $plugin_slug ) ) {
						$extension_suggestion['plugin']['status'] = self::EXTENSION_INSTALLED;
						// Make sure we put in the actual slug and file path that we found.
						$extension_suggestion['plugin']['slug'] = $plugin_slug;
						$extension_suggestion['plugin']['file'] = $this->proxy->call_static( PluginsHelper::class, 'get_plugin_path_from_slug', $plugin_slug );
						// Sanity check.
						if ( ! is_string( $extension_suggestion['plugin']['file'] ) ) {
							$extension_suggestion['plugin']['file'] = '';
							break;
						}
						// Remove the .php extension from the file path. The WP API expects it without it.
						$extension_suggestion['plugin']['file'] = Utils::trim_php_file_extension( $extension_suggestion['plugin']['file'] );
						break;
					}
				}
			}
		}

		// Finally, allow the extension suggestion's matching provider to add further details.
		$gateway_provider     = $this->get_payment_extension_suggestion_provider_instance( $extension_suggestion['id'] );
		$extension_suggestion = $gateway_provider->enhance_extension_suggestion( $extension_suggestion );

		return $extension_suggestion;
	}

	/**
	 * Check if a payment extension suggestion has been hidden by the user.
	 *
	 * @param array $extension The extension suggestion.
	 *
	 * @return bool True if the extension suggestion is hidden, false otherwise.
	 */
	private function is_payment_extension_suggestion_hidden( array $extension ): bool {
		$user_payments_nox_profile = get_user_meta( get_current_user_id(), Payments::PAYMENTS_NOX_PROFILE_KEY, true );
		if ( empty( $user_payments_nox_profile ) ) {
			return false;
		}
		$user_payments_nox_profile = maybe_unserialize( $user_payments_nox_profile );

		if ( empty( $user_payments_nox_profile['hidden_suggestions'] ) ) {
			return false;
		}

		return in_array( $extension['id'], array_column( $user_payments_nox_profile['hidden_suggestions'], 'id' ), true );
	}

	/**
	 * Apply order mappings to a base payment providers order map.
	 *
	 * @param array $base_map     The base order map.
	 * @param array $new_mappings The order mappings to apply.
	 *                            This can be a full or partial list of the base one,
	 *                            but it can also contain (only) new provider IDs and their orders.
	 *
	 * @return array The updated base order map, normalized.
	 */
	private function payment_providers_order_map_apply_mappings( array $base_map, array $new_mappings ): array {
		// Sanity checks.
		// Remove any null or non-integer values.
		$new_mappings = array_filter( $new_mappings, 'is_int' );
		if ( empty( $new_mappings ) ) {
			$new_mappings = array();
		}

		// If we have no existing order map or
		// both the base and the new map have the same length and keys, we can simply use the new map.
		if ( empty( $base_map ) ||
			( count( $base_map ) === count( $new_mappings ) &&
				empty( array_diff( array_keys( $base_map ), array_keys( $new_mappings ) ) ) )
		) {
			$new_order_map = $new_mappings;
		} else {
			// If we are dealing with ONLY offline PMs updates (for all that are registered) and their group is present,
			// normalize the new order map to keep behavior as intended (i.e., reorder only inside the offline PMs list).
			$offline_pms = $this->get_offline_payment_methods_gateways();
			// Make it a list keyed by the payment gateway ID.
			$offline_pms = array_combine(
				array_map(
					fn( $gateway ) => $gateway->id,
					$offline_pms
				),
				$offline_pms
			);
			if (
				isset( $base_map[ self::OFFLINE_METHODS_ORDERING_GROUP ] ) &&
				count( $new_mappings ) === count( $offline_pms ) &&
				empty( array_diff( array_keys( $new_mappings ), array_keys( $offline_pms ) ) )
			) {

				$new_mappings = Utils::order_map_change_min_order( $new_mappings, $base_map[ self::OFFLINE_METHODS_ORDERING_GROUP ] + 1 );
			}

			$new_order_map = Utils::order_map_apply_mappings( $base_map, $new_mappings );
		}

		return Utils::order_map_normalize( $new_order_map );
	}

	/**
	 * Group payment gateways by their plugin extension filename.
	 *
	 * @param WC_Payment_Gateway[] $gateways     The list of payment gateway instances to group.
	 * @param string               $country_code Optional. The country code for which the gateways are being generated.
	 *                                           This should be an ISO 3166-1 alpha-2 country code.
	 *
	 * @return array The grouped payment gateway instances, keyed by the plugin file.
	 *               Each group contains an array of payment gateway instances that belong to the same plugin.
	 *               If a payment gateway does not have a corresponding plugin file,
	 *               it will be grouped under the 'unknown_extension' key.
	 */
	private function group_gateways_by_extension( array $gateways, string $country_code = '' ): array {
		$grouped = array(
			// This is the group for gateways that we don't know how to group by extension.
			// It can be used for gateways that are not registered by a WP plugin.
			'unknown_extension' => array(),
		);

		foreach ( $gateways as $gateway ) {
			// Get the payment gateway details, but use a dummy gateway order since it is inconsequential here.
			$gateway_details = $this->get_payment_gateway_details( $gateway, 0, $country_code );
			// If we don't have the necessary plugin details, put it in the unknown group.
			if ( empty( $gateway_details ) || ! isset( $gateway_details['plugin'] ) || empty( $gateway_details['plugin']['file'] ) ) {
				$grouped['unknown_extension'][] = $gateway;
				continue;
			}

			if ( empty( $grouped[ $gateway_details['plugin']['file'] ] ) ) {
				$grouped[ $gateway_details['plugin']['file'] ] = array();
			}

			$grouped[ $gateway_details['plugin']['file'] ][] = $gateway;
		}

		return $grouped;
	}
}
PK     [1]GH  H  &  Admin/ImportExport/CSVUploadHelper.phpnu         <?php
declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\Admin\ImportExport;

use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;

/**
 * Helper for CSV import functionality.
 *
 * @since 9.3.0
 */
class CSVUploadHelper {

	/**
	 * Name (inside the uploads folder) to use for the CSV import directory.
	 *
	 * @return string
	 */
	protected function get_import_subdir_name(): string {
		return 'wc-imports';
	}

	/**
	 * Returns the full path to the CSV import directory within the uploads folder.
	 * It will attempt to create the directory if it doesn't exist.
	 *
	 * @param bool $create TRUE to attempt to create the directory. FALSE otherwise.
	 * @return string
	 * @throws \Exception In case the upload directory doesn't exits or can't be created.
	 */
	public function get_import_dir( bool $create = true ): string {
		$wp_upload_dir = wp_upload_dir( null, $create );
		if ( $wp_upload_dir['error'] ) {
			throw new \Exception( esc_html( $wp_upload_dir['error'] ) );
		}

		$upload_dir = trailingslashit( $wp_upload_dir['basedir'] ) . $this->get_import_subdir_name();
		if ( $create ) {
			FilesystemUtil::mkdir_p_not_indexable( $upload_dir );
		}
		return $upload_dir;
	}

	/**
	 * Handles a CSV file upload.
	 *
	 * @param string     $import_type        Type of upload or context.
	 * @param string     $files_index        $_FILES index that contains the file to upload.
	 * @param array|null $allowed_mime_types List of allowed MIME types.
	 * @return array {
	 *     Details for the uploaded file.
	 *
	 *     @type int    $id   Attachment ID.
	 *     @type string $file Full path to uploaded file.
	 * }
	 *
	 * @throws \Exception In case of error.
	 */
	public function handle_csv_upload( string $import_type, string $files_index = 'import', ?array $allowed_mime_types = null ): array {
		$import_type = sanitize_key( $import_type );
		if ( ! $import_type ) {
			throw new \Exception( 'Import type is invalid.' );
		}

		if ( ! $allowed_mime_types ) {
			$allowed_mime_types = array(
				'csv' => 'text/csv',
				'txt' => 'text/plain',
			);
		}

		$file = $_FILES[ $files_index ] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Missing
		if ( ! isset( $file['tmp_name'] ) || ! is_uploaded_file( $file['tmp_name'] ) ) {
			throw new \Exception( esc_html__( 'File is empty. Please upload something more substantial. This error could also be caused by uploads being disabled in your php.ini or by post_max_size being defined as smaller than upload_max_filesize in php.ini.', 'woocommerce' ) );
		}

		if ( ! function_exists( 'wp_import_handle_upload' ) ) {
			require_once ABSPATH . 'wp-admin/includes/import.php';
		}

		// Make sure upload dir exists.
		$this->get_import_dir();

		// Add prefix.
		$file['name'] = $import_type . '-' . $file['name'];

		$overrides_callback = function ( $overrides_ ) use ( $allowed_mime_types ) {
			$overrides_['test_form'] = false;
			$overrides_['test_type'] = true;
			$overrides_['mimes']     = $allowed_mime_types;
			return $overrides_;
		};

		add_filter( 'upload_dir', array( $this, 'override_upload_dir' ) );
		add_filter( 'wp_unique_filename', array( $this, 'override_unique_filename' ), 0, 2 );
		add_filter( 'wp_handle_upload_overrides', $overrides_callback, 999 );
		add_filter( 'wp_handle_upload_prefilter', array( $this, 'remove_txt_from_uploaded_file' ), 0 );
		add_filter( 'wp_check_filetype_and_ext', array( $this, 'filter_woocommerce_check_filetype_for_csv' ), 10, 5 );

		$orig_files_import = $_FILES['import'] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Missing
		$_FILES['import']  = $file;  // wp_import_handle_upload() expects the file to be in 'import'.

		$upload = wp_import_handle_upload();

		remove_filter( 'upload_dir', array( $this, 'override_upload_dir' ) );
		remove_filter( 'wp_unique_filename', array( $this, 'override_unique_filename' ), 0 );
		remove_filter( 'wp_handle_upload_overrides', $overrides_callback, 999 );
		remove_filter( 'wp_handle_upload_prefilter', array( $this, 'remove_txt_from_uploaded_file' ), 0 );
		remove_filter( 'wp_check_filetype_and_ext', array( $this, 'filter_woocommerce_check_filetype_for_csv' ), 10 );

		if ( $orig_files_import ) {
			$_FILES['import'] = $orig_files_import;
		} else {
			unset( $_FILES['import'] );
		}

		if ( ! empty( $upload['error'] ) ) {
			throw new \Exception( esc_html( $upload['error'] ) );
		}

		if ( ! wc_is_file_valid_csv( $upload['file'], false ) ) {
			wp_delete_attachment( $file['id'], true );
			throw new \Exception( esc_html__( 'Invalid file type for a CSV import.', 'woocommerce' ) );
		}

		return $upload;
	}

	/**
	 * Hooked onto 'upload_dir' to override the default upload directory for a CSV upload.
	 *
	 * @param array $uploads WP upload dir details.
	 * @return array
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function override_upload_dir( $uploads ): array {
		$new_subdir = '/' . $this->get_import_subdir_name();

		$uploads['path']   = $uploads['basedir'] . $new_subdir;
		$uploads['url']    = $uploads['baseurl'] . $new_subdir;
		$uploads['subdir'] = $new_subdir;

		return $uploads;
	}

	/**
	 * Adds a random string to the name of an uploaded CSV file to make it less discoverable. Hooked onto 'wp_unique_filename'.
	 *
	 * @param string $filename File name.
	 * @param string $ext      File extension.
	 * @return string
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function override_unique_filename( string $filename, string $ext ): string {
		$length = min( 10, 255 - strlen( $filename ) - 1 );
		if ( 1 < $length ) {
			$suffix   = strtolower( wp_generate_password( $length, false, false ) );
			$filename = substr( $filename, 0, strlen( $filename ) - strlen( $ext ) ) . '-' . $suffix . $ext;
		}

		return $filename;
	}

	/**
	 * `wp_import_handle_upload()` appends .txt to any file name. This function is hooked onto 'wp_handle_upload_prefilter'
	 * to remove those extra characters.
	 *
	 * @param array $file File details in the form of a $_FILES entry.
	 * @return array Modified file details.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function remove_txt_from_uploaded_file( array $file ): array {
		$file['name'] = substr( $file['name'], 0, -4 );
		return $file;
	}

	/**
	 * Filters the WordPress determination of a file's type and extension, specifically to correct
	 * CSV files that are misidentified as 'text/html'.
	 *
	 * @param array  $data      An array of file data: ['ext'] (string), ['type'] (string), ['proper_filename'] (string|false).
	 * @param string $file      Full path to the file.
	 * @param string $filename  The Mime type of the file.
	 * @param array  $mimes     Array of mime types.
	 * @param string $real_mime The actual mime type or empty string.
	 * @return array Filtered file data.
	 */
	public function filter_woocommerce_check_filetype_for_csv( $data, $file, $filename, $mimes, $real_mime ) {
		// Check if the file was misidentified as 'text/html' by PHP.
		if ( 'text/html' === $real_mime ) {
			// Determine the expected file type based on the filename extension.
			// $mimes here is the context-specific list of mimes for the current upload.
			$filename_check = wp_check_filetype( $filename, $mimes );

			$file_ext  = $filename_check['ext'];
			$file_type = $filename_check['type'];

			if ( ( 'csv' === $file_ext && 'text/csv' === $file_type ) ) {
				$data['ext']  = 'csv';
				$data['type'] = 'text/csv';
			}
		}

		return $data;
	}
}
PK     [1]3ls
  s
  #  Admin/RemoteFreeExtensions/Init.phpnu         <?php
/**
 * Handles running payment method specs
 */

namespace Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions\DefaultFreeExtensions;
use Automattic\WooCommerce\Admin\RemoteSpecs\RemoteSpecsEngine;

/**
 * Remote Payment Methods engine.
 * This goes through the specs and gets eligible payment methods.
 */
class Init extends RemoteSpecsEngine {

	/**
	 * Constructor.
	 */
	public function __construct() {
		add_action( 'woocommerce_updated', array( __CLASS__, 'delete_specs_transient' ) );
	}

	/**
	 * Go through the specs and run them.
	 *
	 * @param array $allowed_bundles Optional array of allowed bundles to be returned.
	 * @return array
	 */
	public static function get_extensions( $allowed_bundles = array() ) {
		$locale = get_user_locale();

		$specs           = self::get_specs();
		$results         = EvaluateExtension::evaluate_bundles( $specs, $allowed_bundles );
		$specs_to_return = $results['bundles'];
		$specs_to_save   = null;

		$plugins = array_filter(
			$results['bundles'],
			function( $bundle ) {
				return count( $bundle['plugins'] ) > 0;
			}
		);

		if ( empty( $plugins ) ) {
			// When no plugins are visible, replace it with defaults and save for 3 hours.
			$specs_to_save   = DefaultFreeExtensions::get_all();
			$specs_to_return = EvaluateExtension::evaluate_bundles( $specs_to_save, $allowed_bundles )['bundles'];
		} elseif ( count( $results['errors'] ) > 0 ) {
			// When suggestions is not empty but has errors, save it for 3 hours.
			$specs_to_save = $specs;
		}

		// When plugins is not empty but has errors, save it for 3 hours.
		if ( count( $results['errors'] ) > 0 ) {
			self::log_errors( $results['errors'] );
		}

		if ( $specs_to_save ) {
			RemoteFreeExtensionsDataSourcePoller::get_instance()->set_specs_transient( array( $locale => $specs_to_save ), 3 * HOUR_IN_SECONDS );
		}

		return $specs_to_return;
	}

	/**
	 * Delete the specs transient.
	 */
	public static function delete_specs_transient() {
		RemoteFreeExtensionsDataSourcePoller::get_instance()->delete_specs_transient();
	}

	/**
	 * Get specs or fetch remotely if they don't exist.
	 */
	public static function get_specs() {
		if ( 'no' === get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) ) {
			return DefaultFreeExtensions::get_all();
		}
		$specs = RemoteFreeExtensionsDataSourcePoller::get_instance()->get_specs_from_data_sources();

		// Fetch specs if they don't yet exist.
		if ( false === $specs || ! is_array( $specs ) || 0 === count( $specs ) ) {
			return DefaultFreeExtensions::get_all();
		}

		return $specs;
	}
}
PK     [1]J[  J[  4  Admin/RemoteFreeExtensions/DefaultFreeExtensions.phpnu         <?php
/**
 * Gets a list of fallback methods if remote fetching is disabled.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions;

use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\DefaultPaymentGateways;

defined( 'ABSPATH' ) || exit;


/**
 * Default Free Extensions
 */
class DefaultFreeExtensions {

	/**
	 * Get Woo logo path.
	 *
	 * @return string
	 */
	private static function get_woo_logo() {
		return plugins_url( '/assets/images/core-profiler/logo-woo.svg', WC_PLUGIN_FILE );
	}

	/**
	 * Get default specs.
	 *
	 * @return array Default specs.
	 */
	public static function get_all() {
		$bundles = array(
			array(
				'key'     => 'obw/basics',
				'title'   => __( 'Get the basics', 'woocommerce' ),
				'plugins' => array(
					self::get_plugin( 'woocommerce-payments' ),
					self::get_plugin( 'woocommerce-shipping' ),
					self::get_plugin( 'woocommerce-services:tax' ),
					self::get_plugin( 'jetpack' ),
				),
			),
			array(
				'key'     => 'obw/grow',
				'title'   => __( 'Grow your store', 'woocommerce' ),
				'plugins' => array(
					self::get_plugin( 'mailpoet' ),
					self::get_plugin( 'google-listings-and-ads' ),
					self::get_plugin( 'pinterest-for-woocommerce' ),
					self::get_plugin( 'facebook-for-woocommerce' ),
				),
			),
			array(
				'key'     => 'task-list/reach',
				'title'   => __( 'Reach out to customers', 'woocommerce' ),
				'plugins' => array(
					self::get_plugin( 'mailpoet:alt' ),
					// IMPORTANT: Klaviyo needs to be above Mailchimp as per partnership agreement.
					// P2 for context: pdibGW-3XM-p2.
					self::get_plugin( 'klaviyo:alt' ),
					self::get_plugin( 'mailchimp-for-woocommerce' ),
				),
			),
			array(
				'key'     => 'task-list/grow',
				'title'   => __( 'Grow your store', 'woocommerce' ),
				'plugins' => array(
					self::get_plugin( 'google-listings-and-ads:alt' ),
					self::get_plugin( 'tiktok-for-business' ),
					self::get_plugin( 'pinterest-for-woocommerce:alt' ),
					self::get_plugin( 'facebook-for-woocommerce:alt' ),
				),
			),
			array(
				'key'     => 'obw/core-profiler',
				'title'   => __( 'Grow your store', 'woocommerce' ),
				'plugins' => self::with_core_profiler_fields(
					array(
						self::get_plugin( 'woocommerce-payments' ),
						self::get_plugin( 'woocommerce-shipping' ),
						self::get_plugin( 'jetpack' ),
						self::get_plugin( 'pinterest-for-woocommerce' ),
						self::get_plugin( 'mailpoet' ),
						self::get_plugin( 'klaviyo' ),
						self::get_plugin( 'google-listings-and-ads' ),
						self::get_plugin( 'woocommerce-services:tax' ),
						self::get_plugin( 'tiktok-for-business' ),
					)
				),
			),
		);

		$bundles = wp_json_encode( $bundles );
		return json_decode( $bundles );
	}

	/**
	 * Get the plugin arguments by slug.
	 *
	 * @param string $slug Slug.
	 * @return array
	 */
	public static function get_plugin( $slug ) {
		$plugins = array(
			'google-listings-and-ads'       => array(
				'min_php_version' => '7.4',
				'name'            => __( 'Google for WooCommerce', 'woocommerce' ),
				'description'     => sprintf(
					/* translators: 1: opening product link tag. 2: closing link tag */
					__( 'Drive sales with %1$sGoogle for WooCommerce%2$s', 'woocommerce' ),
					'<a href="https://woocommerce.com/products/google-listings-and-ads" target="_blank">',
					'</a>'
				),
				'image_url'       => plugins_url( '/assets/images/onboarding/google.svg', WC_PLUGIN_FILE ),
				'manage_url'      => 'admin.php?page=wc-admin&path=%2Fgoogle%2Fstart',
				'is_built_by_wc'  => true,
				'is_visible'      => array(
					array(
						'type'    => 'not',
						'operand' => array(
							array(
								'type'    => 'plugins_activated',
								'plugins' => array( 'google-listings-and-ads' ),
							),
						),
					),
				),
			),
			'google-listings-and-ads:alt'   => array(
				'name'           => __( 'Google for WooCommerce', 'woocommerce' ),
				'description'    => __( 'Reach more shoppers and drive sales for your store. Integrate with Google to list your products for free and launch paid ad campaigns.', 'woocommerce' ),
				'image_url'      => plugins_url( '/assets/images/onboarding/google.svg', WC_PLUGIN_FILE ),
				'manage_url'     => 'admin.php?page=wc-admin&path=%2Fgoogle%2Fstart',
				'is_built_by_wc' => true,
			),
			'facebook-for-woocommerce'      => array(
				'name'           => __( 'Facebook for WooCommerce', 'woocommerce' ),
				'description'    => __( 'List products and create ads on Facebook and Instagram with <a href="https://woocommerce.com/products/facebook/">Facebook for WooCommerce</a>', 'woocommerce' ),
				'image_url'      => plugins_url( '/assets/images/onboarding/facebook.png', WC_PLUGIN_FILE ),
				'manage_url'     => 'admin.php?page=wc-facebook',
				'is_visible'     => false,
				'is_built_by_wc' => false,
			),
			'facebook-for-woocommerce:alt'  => array(
				'name'           => __( 'Facebook for WooCommerce', 'woocommerce' ),
				'description'    => __( 'List products and create ads on Facebook and Instagram.', 'woocommerce' ),
				'image_url'      => plugins_url( '/assets/images/onboarding/facebook.png', WC_PLUGIN_FILE ),
				'manage_url'     => 'admin.php?page=wc-facebook',
				'is_visible'     => false,
				'is_built_by_wc' => false,
			),
			'pinterest-for-woocommerce'     => array(
				'name'            => __( 'Pinterest for WooCommerce', 'woocommerce' ),
				'description'     => __( 'Get your products in front of Pinners searching for ideas and things to buy.', 'woocommerce' ),
				'image_url'       => plugins_url( '/assets/images/onboarding/pinterest.png', WC_PLUGIN_FILE ),
				'manage_url'      => 'admin.php?page=wc-admin&path=%2Fpinterest%2Flanding',
				'is_visible'      => true,
				'is_built_by_wc'  => true,
				'min_php_version' => '7.3',
			),
			'pinterest-for-woocommerce:alt' => array(
				'name'           => __( 'Pinterest for WooCommerce', 'woocommerce' ),
				'description'    => __( 'Get your products in front of Pinterest users searching for ideas and things to buy. Get started with Pinterest and make your entire product catalog browsable.', 'woocommerce' ),
				'image_url'      => plugins_url( '/assets/images/onboarding/pinterest.png', WC_PLUGIN_FILE ),
				'manage_url'     => 'admin.php?page=wc-admin&path=%2Fpinterest%2Flanding',
				'is_built_by_wc' => true,
			),
			'mailpoet'                      => array(
				'name'           => __( 'MailPoet', 'woocommerce' ),
				'description'    => __( 'Create and send purchase follow-up emails, newsletters, and promotional campaigns straight from your dashboard.', 'woocommerce' ),
				'image_url'      => plugins_url( '/assets/images/onboarding/mailpoet.png', WC_PLUGIN_FILE ),
				'manage_url'     => 'admin.php?page=mailpoet-newsletters',
				'is_visible'     => array(
					array(
						'type'        => 'option',
						'option_name' => 'woocommerce_remote_variant_assignment',
						'value'       => array( 1, 84 ), // 70% segment with klaviyo
						'default'     => false,
						'operation'   => 'range',
					),
				),
				'is_built_by_wc' => true,
			),
			'kliken-ads-pixel-for-meta'     => array(
				'name'        => __( 'Meta Ads & Pixel for WooCommerce', 'woocommerce' ),
				'description' => __( 'Sync your store catalog, set up pixel tracking, and run targeted ad campaigns.', 'woocommerce' ),
				'image_url'   => plugins_url( '/assets/images/onboarding/kliken.svg', WC_PLUGIN_FILE ),
				'manage_url'  => 'admin.php?page=kliken-ads-pixel-for-meta',
				'is_visible'  => false,
			),
			'mailchimp-for-woocommerce'     => array(
				'name'           => __( 'Mailchimp', 'woocommerce' ),
				'description'    => __( 'Send targeted campaigns, recover abandoned carts and much more with Mailchimp.', 'woocommerce' ),
				'image_url'      => plugins_url( '/assets/images/onboarding/mailchimp-for-woocommerce.png', WC_PLUGIN_FILE ),
				'manage_url'     => 'admin.php?page=mailchimp-woocommerce',
				'is_built_by_wc' => false,
			),
			'klaviyo'                       => array(
				'name'           => __( 'Klaviyo', 'woocommerce' ),
				'description'    => __( 'Grow and retain customers with email, SMS, automations, and a consolidated view of customer interactions.', 'woocommerce' ),
				'image_url'      => plugins_url( '/assets/images/onboarding/klaviyo.png', WC_PLUGIN_FILE ),
				'manage_url'     => 'admin.php?page=klaviyo_settings',
				'is_visible'     => array(
					array(
						'type'        => 'option',
						'option_name' => 'woocommerce_remote_variant_assignment',
						'value'       => array( 85, 120 ), // 30% segment with mailpoet
						'default'     => false,
						'operation'   => 'range',
					),
				),
				'is_built_by_wc' => false,
			),
			'klaviyo:alt'                   => array(
				'name'           => __( 'Klaviyo', 'woocommerce' ),
				'description'    => __( 'Grow and retain customers with intelligent, impactful email and SMS marketing automation and a consolidated view of customer interactions.', 'woocommerce' ),
				'image_url'      => plugins_url( '/assets/images/onboarding/klaviyo.png', WC_PLUGIN_FILE ),
				'manage_url'     => 'admin.php?page=klaviyo_settings',
				'is_built_by_wc' => false,
			),
			'woocommerce-payments'          => array(
				'name'           => __( 'WooPayments', 'woocommerce' ),
				'image_url'      => plugins_url( '/assets/images/onboarding/wcpay.svg', WC_PLUGIN_FILE ),
				'description'    => sprintf(
					/* translators: 1: opening product link tag. 2: closing link tag */
					__( 'Accept credit cards and other popular payment methods with %1$sWooPayments%2$s', 'woocommerce' ),
					'<a href="https://woocommerce.com/products/woocommerce-payments" target="_blank">',
					'</a>'
				),
				'is_visible'     => array(
					array(
						'type'      => 'base_location_country',
						'value'     => array(
							'US',
							'PR',
							'AU',
							'CA',
							'DE',
							'ES',
							'FR',
							'GB',
							'IE',
							'IT',
							'NZ',
							'AT',
							'BE',
							'NL',
							'PL',
							'PT',
							'CH',
							'HK',
							'SG',
							'CY',
							'DK',
							'EE',
							'FI',
							'GR',
							'LU',
							'LT',
							'LV',
							'NO',
							'MT',
							'SI',
							'SK',
							'BG',
							'CZ',
							'HR',
							'HU',
							'RO',
							'SE',
							'JP',
							'AE',
						),
						'operation' => 'in',
					),
					DefaultPaymentGateways::get_rules_for_cbd( false ),
				),
				'is_built_by_wc' => true,
				'min_wp_version' => '5.9',
			),
			'woocommerce-shipping'          => array(
				'name'           => __( 'WooCommerce Shipping', 'woocommerce' ),
				'image_url'      => self::get_woo_logo(),
				'description'    => sprintf(
				/* translators: 1: opening product link tag. 2: closing link tag */
					__( 'Print shipping labels with %1$sWooCommerce Shipping%2$s', 'woocommerce' ),
					'<a href="https://woocommerce.com/products/shipping" target="_blank">',
					'</a>'
				),
				'is_visible'     => array(
					array(
						'type'      => 'base_location_country',
						'value'     => 'US',
						'operation' => '=',
					),
					array(
						'type'     => 'or',
						'operands' => array(
							array(
								array(
									'type'         => 'option',
									'transformers' => array(
										array(
											'use'       => 'dot_notation',
											'arguments' => array(
												'path' => 'product_types',
											),
										),
										array(
											'use' => 'count',
										),
									),
									'option_name'  => 'woocommerce_onboarding_profile',
									'value'        => 1,
									'default'      => array(),
									'operation'    => '!=',
								),
							),
							array(
								array(
									'type'         => 'option',
									'transformers' => array(
										array(
											'use'       => 'dot_notation',
											'arguments' => array(
												'path' => 'product_types.0',
											),
										),
									),
									'option_name'  => 'woocommerce_onboarding_profile',
									'value'        => 'downloads',
									'default'      => '',
									'operation'    => '!=',
								),
							),
						),
					),
				),
				'is_built_by_wc' => true,
			),
			'woocommerce-services:tax'      => array(
				'name'           => __( 'WooCommerce Tax', 'woocommerce' ),
				'image_url'      => self::get_woo_logo(),
				'description'    => sprintf(
					/* translators: 1: opening product link tag. 2: closing link tag */
					__( 'Get automated sales tax with %1$sWooCommerce Tax%2$s', 'woocommerce' ),
					'<a href="https://woocommerce.com/products/tax" target="_blank">',
					'</a>'
				),
				'is_visible'     => array(
					self::get_rules_for_wcservices_tax_countries(),
				),
				'is_built_by_wc' => true,
			),
			'jetpack'                       => array(
				'name'           => __( 'Jetpack', 'woocommerce' ),
				'image_url'      => plugins_url( '/assets/images/onboarding/jetpack.svg', WC_PLUGIN_FILE ),
				'description'    => sprintf(
					/* translators: 1: opening product link tag. 2: closing link tag */
					__( 'Enhance speed and security with %1$sJetpack%2$s', 'woocommerce' ),
					'<a href="https://woocommerce.com/products/jetpack" target="_blank">',
					'</a>'
				),
				'is_visible'     => array(
					array(
						'type'    => 'not',
						'operand' => array(
							array(
								'type'    => 'plugins_activated',
								'plugins' => array( 'jetpack' ),
							),
						),
					),
				),
				'is_built_by_wc' => false,
				'min_wp_version' => '6.0',
			),
			'mailpoet:alt'                  => array(
				'name'           => __( 'MailPoet', 'woocommerce' ),
				'description'    => __( 'Create and send purchase follow-up emails, newsletters, and promotional campaigns straight from your dashboard.', 'woocommerce' ),
				'image_url'      => plugins_url( '/assets/images/onboarding/mailpoet.png', WC_PLUGIN_FILE ),
				'manage_url'     => 'admin.php?page=mailpoet-newsletters',
				'is_built_by_wc' => true,
			),
			'tiktok-for-business'           => array(
				'name'           => __( 'TikTok for WooCommerce', 'woocommerce' ),
				'image_url'      => plugins_url( '/assets/images/onboarding/tiktok.svg', WC_PLUGIN_FILE ),
				'description'    =>
					__( 'Grow your online sales by promoting your products on TikTok to over one billion monthly active users around the world.', 'woocommerce' ),
				'manage_url'     => 'admin.php?page=tiktok',
				'is_visible'     => array(
					array(
						'type'      => 'base_location_country',
						'value'     => array(
							'US',
							'CA',
							'MX',
							'AT',
							'BE',
							'CZ',
							'DK',
							'FI',
							'FR',
							'DE',
							'GR',
							'HU',
							'IE',
							'IT',
							'NL',
							'PL',
							'PT',
							'RO',
							'ES',
							'SE',
							'GB',
							'CH',
							'NO',
							'AU',
							'NZ',
							'SG',
							'MY',
							'PH',
							'ID',
							'VN',
							'TH',
							'KR',
							'IL',
							'AE',
							'RU',
							'UA',
							'TR',
							'SA',
							'BR',
							'JP',
						),
						'operation' => 'in',
					),
				),
				'is_built_by_wc' => false,
			),
			'tiktok-for-business:alt'       => array(
				'name'           => __( 'TikTok for WooCommerce', 'woocommerce' ),
				'image_url'      => plugins_url( '/assets/images/onboarding/tiktok.svg', WC_PLUGIN_FILE ),
				'description'    => sprintf(
					/* translators: 1: opening product link tag. 2: closing link tag */
					__( 'Create ad campaigns and reach one billion global users with %1$sTikTok for WooCommerce%2$s', 'woocommerce' ),
					'<a href="https://woocommerce.com/products/tiktok-for-woocommerce" target="_blank">',
					'</a>'
				),
				'manage_url'     => 'admin.php?page=tiktok',
				'is_built_by_wc' => false,
				'is_visible'     => false,
			),
		);

		$plugin        = $plugins[ $slug ];
		$plugin['key'] = $slug;

		return $plugin;
	}

	/**
	 * Decorate plugin data with core profiler fields.
	 *
	 * - Updated description for the core-profiler.
	 * - Adds learn_more_link and label.
	 * - Adds install_priority, which is used to sort the plugins. The value is determined by the plugin size. Lower = smaller.
	 *
	 * @param array $plugins Array of plugins.
	 *
	 * @return array
	 */
	public static function with_core_profiler_fields( array $plugins ) {
		$_plugins = array(
			'woocommerce-payments'      => array(
				/* translators: %s: Payment provider name. */
				'label'            => sprintf( __( 'Get paid with %s', 'woocommerce' ), 'WooPayments' ),
				'image_url'        => self::get_woo_logo(),
				'description'      => __( "Securely accept payments and manage payment activity straight from your store's dashboard", 'woocommerce' ),
				'learn_more_link'  => 'https://woocommerce.com/products/woocommerce-payments?utm_source=storeprofiler&utm_medium=product&utm_campaign=freefeatures',
				'install_priority' => 5,
				'requires_jpc'     => true,
			),
			'woocommerce-shipping'      => array(
				'label'            => __( 'Save on shipping with WooCommerce Shipping', 'woocommerce' ),
				'image_url'        => self::get_woo_logo(),
				'description'      => __( 'Print discounted USPS, UPS, and DHL labels', 'woocommerce' ),
				'learn_more_link'  => 'https://woocommerce.com/woocommerce-shipping?utm_source=storeprofiler&utm_medium=product&utm_campaign=freefeatures',
				'install_priority' => 3,
			),
			'jetpack'                   => array(
				'label'            => __( 'Protect your store and your shoppers with Jetpack', 'woocommerce' ),
				'image_url'        => plugins_url( '/assets/images/core-profiler/logo-jetpack.svg', WC_PLUGIN_FILE ),
				'description'      => __( 'Keep your store online with full security and backups', 'woocommerce' ),
				'learn_more_link'  => 'https://woocommerce.com/products/jetpack?utm_source=storeprofiler&utm_medium=product&utm_campaign=freefeatures',
				'install_priority' => 8,
				'requires_jpc'     => true,
			),
			'pinterest-for-woocommerce' => array(
				'label'            => __( 'Showcase your products with Pinterest', 'woocommerce' ),
				'image_url'        => plugins_url( '/assets/images/core-profiler/logo-pinterest.svg', WC_PLUGIN_FILE ),
				'description'      => __( 'Get your products in front of a highly engaged audience.', 'woocommerce' ),
				'learn_more_link'  => 'https://woocommerce.com/products/pinterest-for-woocommerce?utm_source=storeprofiler&utm_medium=product&utm_campaign=freefeatures',
				'install_priority' => 2,
			),
			'kliken-ads-pixel-for-meta' => array(
				'label'            => __( 'Grow your business with Facebook and Instagram', 'woocommerce' ),
				'image_url'        => plugins_url( '/assets/images/core-profiler/logo-kliken.svg', WC_PLUGIN_FILE ),
				'description'      => __( 'Sync your store catalog, set up pixel tracking, and run targeted ad campaigns.', 'woocommerce' ),
				'learn_more_link'  => 'https://woocommerce.com/products/meta-ads-and-pixel?utm_source=storeprofiler&utm_medium=product&utm_campaign=freefeatures',
				'install_priority' => 2,
			),
			'mailpoet'                  => array(
				'label'            => __( 'Reach your customers with MailPoet', 'woocommerce' ),
				'image_url'        => plugins_url( '/assets/images/core-profiler/logo-mailpoet.svg', WC_PLUGIN_FILE ),
				'description'      => __( 'Send purchase follow-up emails, newsletters, and promotional campaigns.', 'woocommerce' ),
				'learn_more_link'  => 'https://woocommerce.com/products/mailpoet?utm_source=storeprofiler&utm_medium=product&utm_campaign=freefeatures',
				'install_priority' => 7,
			),
			'klaviyo'                   => array(
				'label'            => __( 'Klaviyo', 'woocommerce' ),
				'image_url'        => plugins_url( '/assets/images/onboarding/klaviyo.png', WC_PLUGIN_FILE ),
				'description'      => __( 'Grow and retain customers with email, SMS, automations, and a consolidated view of customer interactions.', 'woocommerce' ),
				'learn_more_link'  => 'https://woocommerce.com/products/klaviyo-for-woocommerce?utm_source=storeprofiler&utm_medium=product&utm_campaign=freefeatures',
				'install_priority' => 7,
			),
			'tiktok-for-business'       => array(
				'label'            => __( 'Create ad campaigns with TikTok', 'woocommerce' ),
				'image_url'        => plugins_url( '/assets/images/core-profiler/logo-tiktok.png', WC_PLUGIN_FILE ),
				'description'      => __( 'Create advertising campaigns and reach one billion global users.', 'woocommerce' ),
				'learn_more_link'  => 'https://woocommerce.com/products/tiktok-for-woocommerce?utm_source=storeprofiler&utm_medium=product&utm_campaign=freefeatures',
				'install_priority' => 1,
			),
			'google-listings-and-ads'   => array(
				'label'            => __( 'Drive sales with Google for WooCommerce', 'woocommerce' ),
				'image_url'        => plugins_url( '/assets/images/core-profiler/logo-google.svg', WC_PLUGIN_FILE ),
				'description'      => __( 'Reach millions of active shoppers across Google with free product listings and ads.', 'woocommerce' ),
				'learn_more_link'  => 'https://woocommerce.com/products/google-listings-and-ads?utm_source=storeprofiler&utm_medium=product&utm_campaign=freefeatures',
				'install_priority' => 6,
			),
			'woocommerce-services:tax'  => array(
				'label'            => __( 'Get automated tax rates with WooCommerce Tax', 'woocommerce' ),
				'image_url'        => self::get_woo_logo(),
				'description'      => __( 'Automatically calculate how much sales tax should be collected – by city, country, or state.', 'woocommerce' ),
				'learn_more_link'  => 'https://woocommerce.com/products/tax?utm_source=storeprofiler&utm_medium=product&utm_campaign=freefeatures',
				'install_priority' => 4,
			),
		);

		$_plugins['woocommerce-shipping']['is_visible'] = array(
			array(
				'type'      => 'base_location_country',
				'value'     => 'US',
				'operation' => '=',
			),
		);

		$_plugins['woocommerce-services:tax']['is_visible'] = array(
			self::get_rules_for_wcservices_tax_countries(),
		);

		$remove_plugins_activated_rule = function ( $is_visible ) {
			$is_visible = array_filter(
				array_map(
					function ( $rule ) {
						if ( is_object( $rule ) || ! isset( $rule['operand'] ) ) {
							return $rule;
						}

						return array_filter(
							$rule['operand'],
							function ( $operand ) {
								return 'plugins_activated' !== $operand['type'];
							}
						);
					},
					$is_visible
				)
			);

			return empty( $is_visible ) ? true : $is_visible;
		};

		foreach ( $plugins as &$plugin ) {
			if ( isset( $_plugins[ $plugin['key'] ] ) ) {
				$plugin = array_merge( $plugin, $_plugins[ $plugin['key'] ] );

				if ( isset( $plugin['is_visible'] ) && is_array( $plugin['is_visible'] ) ) {
					$plugin['is_visible'] = $remove_plugins_activated_rule( $plugin['is_visible'] );
				}
			}
		}

		return $plugins;
	}

	/**
	 * Returns the country restrictions for use in the `is_visible` key for
	 * recommending the tax functionality of WooCommerce Shipping & Tax.
	 *
	 * @return array
	 */
	private static function get_rules_for_wcservices_tax_countries() {
		return array(
			'type'      => 'base_location_country',
			'operation' => 'in',
			'value'     => array(
				'US',
				'FR',
				'GB',
				'DE',
				'CA',
				'AU',
				'GR',
				'BE',
				'PT',
				'DK',
				'SE',
			),
		);
	}
}
PK     [1]9    F  Admin/RemoteFreeExtensions/ProcessCoreProfilerPluginInstallOptions.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions;

use WC_Logger_Interface;

/**
 * Process install options for plugins.
 */
class ProcessCoreProfilerPluginInstallOptions {
	/**
	 * List of plugins.
	 *
	 * @var array List of plugins
	 */
	private array $plugins;

	/**
	 * Plugin slug.
	 *
	 * @var string Plugin slug
	 */
	private string $slug;

	/**
	 * Logger instance.
	 *
	 * @var WC_Logger_Interface Logger instance
	 */
	private WC_Logger_Interface $logger;

	private const DISALLOWED_OPTIONS = array(
		'siteurl',              // The URL to your WordPress installation.
		'home',                 // The home URL of the site.
		'admin_email',          // Administrator email address.
		'wp_user_roles',        // Serialized roles and capabilities.
		'active_plugins',       // List of active plugins.
		'template',             // The current theme template.
		'stylesheet',           // The current theme stylesheet.
		'default_role',         // Default role for new users.
		'ftp_hostname',         // FTP server hostname.
		'ftp_username',         // FTP server username.
		'ftp_password',         // FTP server password.
		'ftp_port',             // FTP server port.
		'ftp_ssl',              // Whether to use FTP over SSL.
		'ftp_pasv',             // Whether to use passive FTP.
		'rewrite_rules',        // URL rewrite rules.
		'permalink_structure',  // Structure of permalinks.
		'cron',                 // Scheduled tasks (WP-Cron jobs).
		'upload_path',          // Filesystem path for uploads.
		'upload_url_path',      // URL path for uploads.
		'mailserver_url',       // Mail server hostname.
		'mailserver_login',     // Mail server login.
		'mailserver_pass',      // Mail server password.
		'mailserver_port',       // Mail server port.
	);

	/**
	 * Constructor.
	 *
	 * @param array                    $plugins List of plugins.
	 * @param string                   $slug Plugin slug.
	 * @param WC_Logger_Interface|null $logger Logger instance.
	 */
	public function __construct( array $plugins, string $slug, ?WC_Logger_Interface $logger = null ) {
		$this->plugins = $plugins;
		$this->slug    = $slug;
		$this->logger  = $logger ?? wc_get_logger();
	}

	/**
	 * Retrieve install options for a plugin.
	 *
	 * @param string $plugin_slug Plugin slug.
	 * @return array|null Install options or null if not found.
	 */
	public function get_install_options( string $plugin_slug ): ?array {
		foreach ( $this->plugins as $plugin ) {
			if ( $this->matches_plugin_slug( $plugin, $plugin_slug ) ) {
				return $plugin->install_options ?? null;
			}
		}
		return null;
	}

	/**
	 * Process install options based on a filtering function.
	 */
	public function process_install_options() {
		$install_options = $this->get_install_options( $this->slug );
		if ( ! $install_options ) {
			return;
		}

		foreach ( $install_options as $install_option ) {
			$this->add_install_option( $install_option );
		}
	}

	/**
	 * Updates an install option in the WordPress database.
	 *
	 * @param object $install_option Install option object.
	 */
	protected function add_install_option( object $install_option ) {
		$default_options = array(
			'force_array' => false,
			'autoload'    => false,
		);

		$options = isset( $install_option->options )
			? (object) $install_option->options
			: new \stdClass();

		foreach ( $default_options as $key => $value ) {
			if ( ! isset( $options->$key ) ) {
				$options->$key = $value;
			}
		}

		if ( $options->force_array ) {
			$install_option->value = json_decode( wp_json_encode( $install_option->value ), true );
			// In case of JSON error, return early.
			if ( json_last_error() !== JSON_ERROR_NONE ) {
				$this->logger && $this->logger->error( 'Failed to decode JSON for install option value for ' . $install_option->name . ': ' . json_last_error_msg() );
				return;
			}
		}

		$autoload = null;

		if ( isset( $options->autoload ) ) {
			if ( 'yes' === $options->autoload ) {
				$autoload = true;
			} elseif ( 'no' === $options->autoload ) {
				$autoload = false;
			} elseif ( true === $options->autoload || false === $options->autoload ) {
				$autoload = $options->autoload;
			}
		}

		$this->add_option( $install_option->name, $install_option->value, $autoload );
	}

	/**
	 * Updates an option in the WordPress database.
	 *
	 * @param string $name Option name.
	 * @param mixed  $value Option value.
	 * @param string $autoload Autoload option.
	 *
	 * @return void
	 */
	protected function add_option( string $name, $value, $autoload = null ) {
		if ( in_array( $name, self::DISALLOWED_OPTIONS, true ) ) {
			$this->logger && $this->logger->error( 'Disallowed option: ' . $name );
			return;
		}

		add_option( $name, $value, '', $autoload );
	}

	/**
	 * Checks if the given plugin matches the provided slug.
	 *
	 * @param object $plugin Plugin object.
	 * @param string $plugin_slug Plugin slug.
	 * @return bool True if it matches, false otherwise.
	 */
	private function matches_plugin_slug( object $plugin, string $plugin_slug ): bool {
		return explode( ':', $plugin->key )[0] === $plugin_slug;
	}
}
PK     [1]0&    0  Admin/RemoteFreeExtensions/EvaluateExtension.phpnu         <?php
/**
 * Evaluates the spec and returns a status.
 */

namespace Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\EvaluateOverrides;
use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\RuleEvaluator;

/**
 * Evaluates the extension and returns it.
 */
class EvaluateExtension {
	/**
	 * Evaluates the extension and returns it.
	 *
	 * @param object $extension The extension to evaluate.
	 * @return object The evaluated extension.
	 */
	private static function evaluate( $extension ) {
		global $wp_version;
		$rule_evaluator = new RuleEvaluator();

		if ( isset( $extension->is_visible ) ) {
			$is_visible            = $rule_evaluator->evaluate( $extension->is_visible );
			$extension->is_visible = $is_visible;
		} else {
			$extension->is_visible = true;
		}

		// Run PHP and WP version chcecks.
		if ( true === $extension->is_visible ) {
			if ( isset( $extension->min_php_version ) && ! version_compare( PHP_VERSION, $extension->min_php_version, '>=' ) ) {
				$extension->is_visible = false;
			}

			if ( isset( $extension->min_wp_version ) && ! version_compare( $wp_version, $extension->min_wp_version, '>=' ) ) {
				$extension->is_visible = false;
			}
		}

		$installed_plugins       = PluginsHelper::get_installed_plugin_slugs();
		$activated_plugins       = PluginsHelper::get_active_plugin_slugs();
		$extension->is_installed = in_array( explode( ':', $extension->key )[0], $installed_plugins, true );
		$extension->is_activated = in_array( explode( ':', $extension->key )[0], $activated_plugins, true );

		return $extension;
	}

	/**
	 * Evaluates the specs and returns the bundles with visible extensions.
	 *
	 * @param array $specs extensions spec array.
	 * @param array $allowed_bundles Optional array of allowed bundles to be returned.
	 * @return array The bundles and errors.
	 */
	public static function evaluate_bundles( $specs, $allowed_bundles = array() ) {
		$bundles        = array();
		$evaluate_order = new EvaluateOverrides();
		$context        = array();

		foreach ( $specs as $spec ) {
			$spec              = (object) $spec;
			$bundle            = (array) $spec;
			$bundle['plugins'] = array();

			if ( ! empty( $allowed_bundles ) && ! in_array( $spec->key, $allowed_bundles, true ) ) {
				continue;
			}

			$errors = array();
			foreach ( $spec->plugins as $plugin ) {
				try {
					$extension = self::evaluate( (object) $plugin );
					if ( ! property_exists( $extension, 'is_visible' ) || $extension->is_visible ) {
						$bundle['plugins'][] = $extension;
					}
				} catch ( \Throwable $e ) {
					$errors[] = $e;
				}
			}

			$context['plugins'] = $bundle['plugins'];
			$bundle['plugins']  = $evaluate_order->evaluate( $bundle['plugins'], $context );

			$bundles[] = $bundle;
		}

		return array(
			'bundles' => $bundles,
			'errors'  => $errors,
		);
	}
}
PK     [1]ǫJ'D  D  C  Admin/RemoteFreeExtensions/RemoteFreeExtensionsDataSourcePoller.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions;

use Automattic\WooCommerce\Admin\RemoteSpecs\DataSourcePoller;
use WC_Helper;
/**
 * Specs data source poller class for remote free extensions.
 */
class RemoteFreeExtensionsDataSourcePoller extends DataSourcePoller {

	const ID = 'remote_free_extensions';

	/**
	 * Default data sources array.
	 *
	 * @deprecated since 9.5.0. Use get_data_sources() instead.
	 */
	const DATA_SOURCES = array();

	/**
	 * Class instance.
	 *
	 * @var RemoteFreeExtensionsDataSourcePoller instance
	 */
	protected static $instance = null;

	/**
	 * Get class instance.
	 */
	public static function get_instance() {
		if ( ! self::$instance ) {
			self::$instance = new self(
				self::ID,
				self::get_data_sources(),
				array(
					'spec_key' => 'key',
				)
			);
		}
		return self::$instance;
	}

	/**
	 * Get data sources.
	 *
	 * @return array
	 */
	public static function get_data_sources() {
		return array(
			WC_Helper::get_woocommerce_com_base_url() . 'wp-json/wccom/obw-free-extensions/4.0/extensions.json',
		);
	}
}
PK     [1]lGQF  F  #  Admin/CustomerEffortScoreTracks.phpnu         <?php
/**
 * WooCommerce Customer effort score tracks
 *
 * @package WooCommerce\Admin\Features
 */

namespace Automattic\WooCommerce\Internal\Admin;

defined( 'ABSPATH' ) || exit;

/**
 * Triggers customer effort score on several different actions.
 */
class CustomerEffortScoreTracks {
	/**
	 * Option name for the CES Tracks queue.
	 */
	const CES_TRACKS_QUEUE_OPTION_NAME = 'woocommerce_ces_tracks_queue';

	/**
	 * Option name for the clear CES Tracks queue for page.
	 */
	const CLEAR_CES_TRACKS_QUEUE_FOR_PAGE_OPTION_NAME =
		'woocommerce_clear_ces_tracks_queue_for_page';

	/**
	 * Option name for the set of actions that have been shown.
	 */
	const SHOWN_FOR_ACTIONS_OPTION_NAME = 'woocommerce_ces_shown_for_actions';

	/**
	 * Action name for product add/publish.
	 */
	const PRODUCT_ADD_PUBLISH_ACTION_NAME = 'product_add_publish';

	/**
	 * Action name for product update.
	 */
	const PRODUCT_UPDATE_ACTION_NAME = 'product_update';

	/**
	 * Action name for shop order update.
	 */
	const SHOP_ORDER_UPDATE_ACTION_NAME = 'shop_order_update';

	/**
	 * Action name for settings change.
	 */
	const SETTINGS_CHANGE_ACTION_NAME = 'settings_change';

	/**
	 * Action name for add product categories.
	 */
	const ADD_PRODUCT_CATEGORIES_ACTION_NAME = 'add_product_categories';

	/**
	 * Action name for add product tags.
	 */
	const ADD_PRODUCT_TAGS_ACTION_NAME = 'add_product_tags';

	/*
	 * Action name for add product attributes.
	 */
	const ADD_PRODUCT_ATTRIBUTES_ACTION_NAME = 'add_product_attributes';

	/**
	 * Action name for import products.
	 */
	const IMPORT_PRODUCTS_ACTION_NAME = 'import_products';

	/**
	 * Action name for search.
	 */
	const SEARCH_ACTION_NAME = 'ces_search';

	/**
	 * Label for the snackbar that appears when a user submits the survey.
	 *
	 * @var string
	 */
	private $onsubmit_label;

	/**
	 * Constructor. Sets up filters to hook into WooCommerce.
	 */
	public function __construct() {
		$this->enable_survey_enqueing_if_tracking_is_enabled();
	}

	/**
	 * Add actions that require woocommerce_allow_tracking.
	 */
	private function enable_survey_enqueing_if_tracking_is_enabled() {
		// Only hook up the action handlers if in wp-admin.
		if ( ! is_admin() ) {
			return;
		}

		// Do not hook up the action handlers if a mobile device is used.
		if ( wp_is_mobile() ) {
			return;
		}

		// Only enqueue a survey if tracking is allowed.
		$allow_tracking = 'yes' === get_option( 'woocommerce_allow_tracking', 'no' );
		if ( ! $allow_tracking ) {
			return;
		}

		add_action( 'admin_init', array( $this, 'maybe_clear_ces_tracks_queue' ) );
		add_action( 'woocommerce_update_options', array( $this, 'run_on_update_options' ), 10, 3 );
		add_action( 'product_cat_add_form', array( $this, 'add_script_track_product_categories' ), 10, 3 );
		add_action( 'product_tag_add_form', array( $this, 'add_script_track_product_tags' ), 10, 3 );
		add_action( 'woocommerce_attribute_added', array( $this, 'run_on_add_product_attributes' ), 10, 3 );
		add_action( 'load-edit.php', array( $this, 'run_on_load_edit_php' ), 10, 3 );
		add_action( 'product_page_product_importer', array( $this, 'run_on_product_import' ), 10, 3 );
		// Only hook up the transition_post_status action handler
		// if on the edit page.
		global $pagenow;
		if ( 'post.php' === $pagenow ) {
			add_action(
				'transition_post_status',
				array(
					$this,
					'run_on_transition_post_status',
				),
				10,
				3
			);
		}
		$this->onsubmit_label = __( 'Thank you for your feedback!', 'woocommerce' );
	}

	/**
	 * Returns a generated script for tracking tags added on edit-tags.php page.
	 * CES survey is triggered via direct access to wc/customer-effort-score store
	 * via wp.data.dispatch method.
	 *
	 * Due to lack of options to directly hook ourselves into the ajax post request
	 * initiated by edit-tags.php page, we infer a successful request by observing
	 * an increase of the number of rows in tags table
	 *
	 * @param string $action Action name for the survey.
	 * @param string $title Title for the snackbar.
	 * @param string $first_question The text for the first question.
	 * @param string $second_question The text for the second question.
	 *
	 * @return string Generated JavaScript to append to page.
	 */
	private function get_script_track_edit_php( $action, $title, $first_question, $second_question ) {
		return sprintf(
			"(function( $ ) {
				'use strict';
				// Hook on submit button and sets a 1000ms interval function
				// to determine successful add tag or otherwise.
				$('#addtag #submit').on( 'click', function() {
					const initialCount = $('.tags tbody > tr').length;
					const interval = setInterval( function() {
						if ( $('.tags tbody > tr').length > initialCount ) {
							// New tag detected.
							clearInterval( interval );
							wp.data.dispatch('wc/customer-effort-score').addCesSurvey({ action: '%s', title: '%s', firstQuestion: '%s', secondQuestion: '%s', onsubmitLabel: '%s' });
						} else {
							// Form is no longer loading, most likely failed.
							if ( $( '#addtag .submit .spinner.is-active' ).length < 1 ) {
								clearInterval( interval );
							}
						}
					}, 1000 );
				});
			})( jQuery );",
			esc_js( $action ),
			esc_js( $title ),
			esc_js( $first_question ),
			esc_js( $second_question ),
			esc_js( $this->onsubmit_label )
		);
	}

	/**
	 * Get the current published product count.
	 *
	 * @return integer The current published product count.
	 */
	private function get_product_count() {
		$query         = new \WC_Product_Query(
			array(
				'limit'    => 1,
				'paginate' => true,
				'return'   => 'ids',
				'status'   => array( 'publish' ),
			)
		);
		$products      = $query->get_products();
		$product_count = intval( $products->total );

		return $product_count;
	}

	/**
	 * Get the current shop order count.
	 *
	 * @return integer The current shop order count.
	 */
	private function get_shop_order_count() {
		$query            = new \WC_Order_Query(
			array(
				'limit'    => 1,
				'paginate' => true,
				'return'   => 'ids',
			)
		);
		$shop_orders      = $query->get_orders();
		$shop_order_count = intval( $shop_orders->total );

		return $shop_order_count;
	}

	/**
	 * Return whether the action has already been shown.
	 *
	 * @param string $action The action to check.
	 *
	 * @return bool Whether the action has already been shown.
	 */
	private function has_been_shown( $action ) {
		$shown_for_features = get_option( self::SHOWN_FOR_ACTIONS_OPTION_NAME, array() );
		$has_been_shown     = in_array( $action, $shown_for_features, true );

		return $has_been_shown;
	}

	/**
	 * Enqueue the item to the CES tracks queue.
	 *
	 * @param array $item The item to enqueue.
	 */
	private function enqueue_to_ces_tracks( $item ) {
		$queue = get_option(
			self::CES_TRACKS_QUEUE_OPTION_NAME,
			array()
		);

		$queue = is_array( $queue ) ? $queue : array();

		$has_duplicate = array_filter(
			$queue,
			function ( $queue_item ) use ( $item ) {
				return $queue_item['action'] === $item['action'];
			}
		);
		if ( $has_duplicate ) {
			return;
		}

		$queue[] = $item;

		update_option(
			self::CES_TRACKS_QUEUE_OPTION_NAME,
			$queue
		);
	}

	/**
	 * Enqueue the CES survey on using search dynamically.
	 *
	 * @param string $search_area Search area such as "product" or "shop_order".
	 * @param string $page_now Value of window.pagenow.
	 * @param string $admin_page Value of window.adminpage.
	 */
	public function enqueue_ces_survey_for_search( $search_area, $page_now, $admin_page ) {
		if ( $this->has_been_shown( self::SEARCH_ACTION_NAME ) ) {
			return;
		}

		$this->enqueue_to_ces_tracks(
			array(
				'action'         => self::SEARCH_ACTION_NAME,
				'title'          => __(
					'How easy was it to use search?',
					'woocommerce'
				),
				'firstQuestion'  => __(
					'The search feature in WooCommerce is easy to use.',
					'woocommerce'
				),
				'secondQuestion' => __(
					'The search\'s functionality meets my needs.',
					'woocommerce'
				),
				'onsubmit_label' => $this->onsubmit_label,
				'pagenow'        => $page_now,
				'adminpage'      => $admin_page,
				'props'          => (object) array(
					'search_area' => $search_area,
				),
			)
		);
	}

	/**
	 * Hook into the post status lifecycle, to detect relevant user actions
	 * that we want to survey about.
	 *
	 * @param string $new_status The new status.
	 * @param string $old_status The old status.
	 * @param Post   $post The post.
	 */
	public function run_on_transition_post_status(
		$new_status,
		$old_status,
		$post
	) {
		if ( 'product' === $post->post_type ) {
			$this->maybe_enqueue_ces_survey_for_product( $new_status, $old_status );
		} elseif ( 'shop_order' === $post->post_type ) {
			$this->enqueue_ces_survey_for_edited_shop_order();
		}
	}

	/**
	 * Maybe enqueue the CES survey, if product is being added or edited.
	 *
	 * @param string $new_status The new status.
	 * @param string $old_status The old status.
	 */
	private function maybe_enqueue_ces_survey_for_product(
		$new_status,
		$old_status
	) {
		if ( 'publish' !== $new_status ) {
			return;
		}

		if ( 'publish' !== $old_status ) {
			$this->enqueue_ces_survey_for_new_product();
		} else {
			$this->enqueue_ces_survey_for_edited_product();
		}
	}

	/**
	 * Enqueue the CES survey trigger for a new product.
	 */
	private function enqueue_ces_survey_for_new_product() {
		if ( $this->has_been_shown( self::PRODUCT_ADD_PUBLISH_ACTION_NAME ) ) {
			return;
		}

		$this->enqueue_to_ces_tracks(
			array(
				'action'         => self::PRODUCT_ADD_PUBLISH_ACTION_NAME,
				'title'          => __(
					'🎉 Congrats on adding your first product!',
					'woocommerce'
				),
				'firstQuestion'  => __(
					'The product creation screen is easy to use.',
					'woocommerce'
				),
				'secondQuestion' => __(
					'The product creation screen\'s functionality meets my needs.',
					'woocommerce'
				),
				'onsubmit_label' => $this->onsubmit_label,
				'pagenow'        => 'product',
				'adminpage'      => 'post-php',
				'props'          => array(
					'product_count' => $this->get_product_count(),
				),
			)
		);
	}

	/**
	 * Enqueue the CES survey trigger for an existing product.
	 */
	private function enqueue_ces_survey_for_edited_product() {
		if ( $this->has_been_shown( self::PRODUCT_UPDATE_ACTION_NAME ) ) {
			return;
		}

		$this->enqueue_to_ces_tracks(
			array(
				'action'         => self::PRODUCT_UPDATE_ACTION_NAME,
				'title'          => __(
					'How easy was it to edit your product?',
					'woocommerce'
				),
				'firstQuestion'  => __(
					'The product update process is easy to complete.',
					'woocommerce'
				),
				'secondQuestion' => __(
					'The product update process meets my needs.',
					'woocommerce'
				),
				'onsubmit_label' => $this->onsubmit_label,
				'pagenow'        => 'product',
				'adminpage'      => 'post-php',
				'props'          => array(
					'product_count' => $this->get_product_count(),
				),
			)
		);
	}

	/**
	 * Enqueue the CES survey trigger for an existing shop order.
	 */
	private function enqueue_ces_survey_for_edited_shop_order() {
		if ( $this->has_been_shown( self::SHOP_ORDER_UPDATE_ACTION_NAME ) ) {
			return;
		}

		$this->enqueue_to_ces_tracks(
			array(
				'action'         => self::SHOP_ORDER_UPDATE_ACTION_NAME,
				'title'          => __(
					'How easy was it to update an order?',
					'woocommerce'
				),
				'firstQuestion'  => __(
					'The order details screen is easy to use.',
					'woocommerce'
				),
				'secondQuestion' => __(
					'The order details screen\'s functionality meets my needs.',
					'woocommerce'
				),
				'onsubmit_label' => $this->onsubmit_label,
				'pagenow'        => 'shop_order',
				'adminpage'      => 'post-php',
				'props'          => array(
					'order_count' => $this->get_shop_order_count(),
				),
			)
		);
	}

	/**
	 * Maybe clear the CES tracks queue, executed on every page load. If the
	 * clear option is set it clears the queue. In practice, this executes a
	 * page load after the queued CES tracks are displayed on the client, which
	 * sets the clear option.
	 */
	public function maybe_clear_ces_tracks_queue() {
		$clear_ces_tracks_queue_for_page = get_option(
			self::CLEAR_CES_TRACKS_QUEUE_FOR_PAGE_OPTION_NAME,
			false
		);

		if ( ! $clear_ces_tracks_queue_for_page ) {
			return;
		}

		$queue = get_option(
			self::CES_TRACKS_QUEUE_OPTION_NAME,
			array()
		);

		$queue = is_array( $queue ) ? $queue : array();

		$remaining_items = array_filter(
			$queue,
			function ( $item ) use ( $clear_ces_tracks_queue_for_page ) {
				return $clear_ces_tracks_queue_for_page['pagenow'] !== $item['pagenow']
				|| $clear_ces_tracks_queue_for_page['adminpage'] !== $item['adminpage'];
			}
		);

		update_option(
			self::CES_TRACKS_QUEUE_OPTION_NAME,
			array_values( $remaining_items )
		);
		update_option( self::CLEAR_CES_TRACKS_QUEUE_FOR_PAGE_OPTION_NAME, false );
	}

	/**
	 * Appends a script to footer to trigger CES on adding product categories.
	 */
	public function add_script_track_product_categories() {
		if ( $this->has_been_shown( self::ADD_PRODUCT_CATEGORIES_ACTION_NAME ) ) {
			return;
		}

		$handle = 'wc-tracks-customer-effort-score-product-categories';
		wp_register_script( $handle, '', array( 'jquery' ), WC_VERSION, true );
		wp_enqueue_script( $handle );
		wp_add_inline_script(
			$handle,
			$this->get_script_track_edit_php(
				self::ADD_PRODUCT_CATEGORIES_ACTION_NAME,
				__( 'How easy was it to add product category?', 'woocommerce' ),
				__( 'The product category details screen is easy to use.', 'woocommerce' ),
				__( "The product category details screen's functionality meets my needs.", 'woocommerce' )
			)
		);
	}

	/**
	 * Appends a script to footer to trigger CES on adding product tags.
	 */
	public function add_script_track_product_tags() {
		if ( $this->has_been_shown( self::ADD_PRODUCT_TAGS_ACTION_NAME ) ) {
			return;
		}

		$handle = 'wc-tracks-customer-effort-score-product-tags';
		wp_register_script( $handle, '', array( 'jquery' ), WC_VERSION, true );
		wp_enqueue_script( $handle );
		wp_add_inline_script(
			$handle,
			$this->get_script_track_edit_php(
				self::ADD_PRODUCT_TAGS_ACTION_NAME,
				__( 'How easy was it to add a product tag?', 'woocommerce' ),
				__( 'The product tag details screen is easy to use.', 'woocommerce' ),
				__( "The product tag details screen's functionality meets my needs.", 'woocommerce' )
			)
		);
	}

	/**
	 * Maybe enqueue the CES survey on product import, if step is done.
	 */
	public function run_on_product_import() {
		// We're only interested in when the importer completes.
		if ( empty( $_GET['step'] ) || 'done' !== $_GET['step'] ) { // phpcs:ignore CSRF ok.
			return;
		}

		if ( $this->has_been_shown( self::IMPORT_PRODUCTS_ACTION_NAME ) ) {
			return;
		}

		$this->enqueue_to_ces_tracks(
			array(
				'action'         => self::IMPORT_PRODUCTS_ACTION_NAME,
				'title'          => __(
					'How easy was it to import products?',
					'woocommerce'
				),
				'firstQuestion'  => __(
					'The product import process is easy to complete.',
					'woocommerce'
				),
				'secondQuestion' => __(
					'The product import process meets my needs.',
					'woocommerce'
				),
				'onsubmit_label' => $this->onsubmit_label,
				'pagenow'        => 'product_page_product_importer',
				'adminpage'      => 'product_page_product_importer',
				'props'          => (object) array(),
			)
		);
	}

	/**
	 * Enqueue the CES survey trigger for setting changes.
	 */
	public function run_on_update_options() {
		// $current_tab is set when WC_Admin_Settings::save_settings is called.
		global $current_tab;
		global $current_section;

		if ( $this->has_been_shown( self::SETTINGS_CHANGE_ACTION_NAME ) ) {
			return;
		}

		$props = array(
			'settings_area' => $current_tab,
		);

		if ( $current_section ) {
			$props['settings_section'] = $current_section;
		}

		$this->enqueue_to_ces_tracks(
			array(
				'action'         => self::SETTINGS_CHANGE_ACTION_NAME,
				'title'          => __(
					'How easy was it to update your settings?',
					'woocommerce'
				),
				'firstQuestion'  => __(
					'The settings screen is easy to use.',
					'woocommerce'
				),
				'secondQuestion' => __(
					'The settings screen\'s functionality meets my needs.',
					'woocommerce'
				),
				'onsubmit_label' => $this->onsubmit_label,
				'pagenow'        => 'woocommerce_page_wc-settings',
				'adminpage'      => 'woocommerce_page_wc-settings',
				'props'          => (object) $props,
			)
		);
	}

	/**
	 * Enqueue the CES survey on adding new product attributes.
	 */
	public function run_on_add_product_attributes() {
		if ( $this->has_been_shown( self::ADD_PRODUCT_ATTRIBUTES_ACTION_NAME ) ) {
			return;
		}

		$this->enqueue_to_ces_tracks(
			array(
				'action'         => self::ADD_PRODUCT_ATTRIBUTES_ACTION_NAME,
				'title'          => __(
					'How easy was it to add a product attribute?',
					'woocommerce'
				),
				'firstQuestion'  => __(
					'Product attributes are easy to use.',
					'woocommerce'
				),
				'secondQuestion' => __(
					'Product attributes\' functionality meets my needs.',
					'woocommerce'
				),
				'onsubmit_label' => $this->onsubmit_label,
				'pagenow'        => 'product_page_product_attributes',
				'adminpage'      => 'product_page_product_attributes',
				'props'          => (object) array(),
			)
		);
	}

	/**
	 * Determine on initiating CES survey on searching for product or orders.
	 */
	public function run_on_load_edit_php() {
		$allowed_types = array( 'product', 'shop_order' );
		$post_type     = get_current_screen()->post_type;

		// We're only interested for certain post types.
		if ( ! in_array( $post_type, $allowed_types, true ) ) {
			return;
		}

		// Determine whether request is search by "s" GET parameter.
		if ( empty( $_GET['s'] ) ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended
			return;
		}

		$page_now = 'edit-' . $post_type;
		$this->enqueue_ces_survey_for_search( $post_type, $page_now, 'edit-php' );
	}
}
PK     [1]#UȨ"  "    Admin/Events.phpnu         <?php
/**
 * Handle cron events.
 */

namespace Automattic\WooCommerce\Internal\Admin;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\RemoteInboxNotifications\RemoteInboxNotificationsDataSourcePoller;
use Automattic\WooCommerce\Admin\RemoteInboxNotifications\RemoteInboxNotificationsEngine;
use Automattic\WooCommerce\Internal\Admin\Notes\CustomizeStoreWithBlocks;
use Automattic\WooCommerce\Internal\Admin\Notes\CustomizingProductCatalog;
use Automattic\WooCommerce\Internal\Admin\Notes\EditProductsOnTheMove;
use Automattic\WooCommerce\Internal\Admin\Notes\EmailImprovements;
use Automattic\WooCommerce\Internal\Admin\Notes\EUVATNumber;
use Automattic\WooCommerce\Internal\Admin\Notes\FirstProduct;
use Automattic\WooCommerce\Internal\Admin\Notes\InstallJPAndWCSPlugins;
use Automattic\WooCommerce\Internal\Admin\Notes\LaunchChecklist;
use Automattic\WooCommerce\Internal\Admin\Notes\MagentoMigration;
use Automattic\WooCommerce\Internal\Admin\Notes\ManageOrdersOnTheGo;
use Automattic\WooCommerce\Internal\Admin\Notes\MarketingJetpack;
use Automattic\WooCommerce\Internal\Admin\Notes\MigrateFromShopify;
use Automattic\WooCommerce\Internal\Admin\Notes\MobileApp;
use Automattic\WooCommerce\Internal\Admin\Notes\NewSalesRecord;
use Automattic\WooCommerce\Internal\Admin\Notes\OnboardingPayments;
use Automattic\WooCommerce\Internal\Admin\Notes\OnlineClothingStore;
use Automattic\WooCommerce\Internal\Admin\Notes\OrderMilestones;
use Automattic\WooCommerce\Internal\Admin\Notes\PaymentsMoreInfoNeeded;
use Automattic\WooCommerce\Internal\Admin\Notes\PaymentsRemindMeLater;
use Automattic\WooCommerce\Internal\Admin\Notes\PerformanceOnMobile;
use Automattic\WooCommerce\Internal\Admin\Notes\PersonalizeStore;
use Automattic\WooCommerce\Internal\Admin\Notes\RealTimeOrderAlerts;
use Automattic\WooCommerce\Internal\Admin\Notes\ScheduledUpdatesPromotion;
use Automattic\WooCommerce\Internal\Admin\Notes\SellingOnlineCourses;
use Automattic\WooCommerce\Internal\Admin\Notes\TrackingOptIn;
use Automattic\WooCommerce\Internal\Admin\Notes\UnsecuredReportFiles;
use Automattic\WooCommerce\Internal\Admin\Notes\WooCommercePayments;
use Automattic\WooCommerce\Internal\Admin\Notes\WooCommerceSubscriptions;
use Automattic\WooCommerce\Internal\Admin\Notes\WooSubscriptionsNotes;
use Automattic\WooCommerce\Internal\Admin\Schedulers\MailchimpScheduler;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\PaymentGatewaySuggestionsDataSourcePoller;
use Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions\RemoteFreeExtensionsDataSourcePoller;

/**
 * Events Class.
 */
class Events {
	/**
	 * The single instance of the class.
	 *
	 * @var object
	 */
	protected static $instance = null;

	/**
	 * Constructor
	 *
	 * @return void
	 */
	protected function __construct() {}

	/**
	 * Array of note class to be added or updated.
	 *
	 * @var array
	 */
	private static $note_classes_to_added_or_updated = array(
		CustomizeStoreWithBlocks::class,
		CustomizingProductCatalog::class,
		EditProductsOnTheMove::class,
		EmailImprovements::class,
		EUVATNumber::class,
		FirstProduct::class,
		LaunchChecklist::class,
		MagentoMigration::class,
		ManageOrdersOnTheGo::class,
		MarketingJetpack::class,
		MigrateFromShopify::class,
		MobileApp::class,
		NewSalesRecord::class,
		OnboardingPayments::class,
		OnlineClothingStore::class,
		PaymentsMoreInfoNeeded::class,
		PaymentsRemindMeLater::class,
		PerformanceOnMobile::class,
		PersonalizeStore::class,
		RealTimeOrderAlerts::class,
		ScheduledUpdatesPromotion::class,
		TrackingOptIn::class,
		WooCommercePayments::class,
		WooCommerceSubscriptions::class,
	);

	/**
	 * The other note classes that are added in other places.
	 *
	 * @var array
	 */
	private static $other_note_classes = array(
		InstallJPAndWCSPlugins::class,
		OrderMilestones::class,
		SellingOnlineCourses::class,
		UnsecuredReportFiles::class,
		WooSubscriptionsNotes::class,
	);


	/**
	 * Get class instance.
	 *
	 * @return object Instance.
	 */
	final public static function instance() {
		if ( null === static::$instance ) {
			static::$instance = new static();
		}
		return static::$instance;
	}

	/**
	 * Cron event handlers.
	 */
	public function init() {
		add_action( 'wc_admin_daily', array( $this, 'do_wc_admin_daily' ) );
		add_filter( 'woocommerce_get_note_from_db', array( $this, 'get_note_from_db' ), 10, 1 );

		// Initialize the WC_Notes_Refund_Returns Note to attach hook.
		\WC_Notes_Refund_Returns::init();
	}

	/**
	 * Daily events to run.
	 *
	 * Note: Order_Milestones::possibly_add_note is hooked to this as well.
	 */
	public function do_wc_admin_daily() {
		$this->possibly_add_notes();
		$this->possibly_delete_notes();
		$this->possibly_update_notes();
		$this->possibly_refresh_data_source_pollers();

		if ( $this->is_remote_inbox_notifications_enabled() ) {
			RemoteInboxNotificationsDataSourcePoller::get_instance()->read_specs_from_data_sources();
			RemoteInboxNotificationsEngine::run();
		}

		if ( Features::is_enabled( 'core-profiler' ) ) {
			( new MailchimpScheduler() )->run();
		}
	}

	/**
	 * Get note.
	 *
	 * @param Note $note_from_db The note object from the database.
	 */
	public function get_note_from_db( $note_from_db ) {
		if ( ! $note_from_db instanceof Note || get_user_locale() === $note_from_db->get_locale() ) {
			return $note_from_db;
		}

		$note_classes = array_merge( self::$note_classes_to_added_or_updated, self::$other_note_classes );
		foreach ( $note_classes as $note_class ) {
			if ( defined( "$note_class::NOTE_NAME" ) && $note_class::NOTE_NAME === $note_from_db->get_name() ) {
				$note_from_class = method_exists( $note_class, 'get_note' ) ? $note_class::get_note() : null;

				if ( $note_from_class instanceof Note ) {
					$note = clone $note_from_db;
					$note->set_title( $note_from_class->get_title() );
					$note->set_content( $note_from_class->get_content() );
					$actions = $note_from_class->get_actions();
					foreach ( $actions as $action ) {
						$matching_action = $note->get_action( $action->name );
						if ( $matching_action && $matching_action->id ) {
							$action->id = $matching_action->id;
						}
					}
					$note->set_actions( $actions );
					return $note;
				}
				break;
			}
		}
		return $note_from_db;
	}

	/**
	 * Adds notes that should be added.
	 */
	protected function possibly_add_notes() {
		foreach ( self::$note_classes_to_added_or_updated as $note_class ) {
			if ( method_exists( $note_class, 'possibly_add_note' ) ) {
				$note_class::possibly_add_note();
			}
		}
	}

	/**
	 * Deletes notes that should be deleted.
	 */
	protected function possibly_delete_notes() {
		PaymentsRemindMeLater::delete_if_not_applicable();
		PaymentsMoreInfoNeeded::delete_if_not_applicable();
	}

	/**
	 * Updates notes that should be updated.
	 */
	protected function possibly_update_notes() {
		foreach ( self::$note_classes_to_added_or_updated as $note_class ) {
			if ( method_exists( $note_class, 'possibly_update_note' ) ) {
				$note_class::possibly_update_note();
			}
		}
	}

	/**
	 * Checks if remote inbox notifications are enabled.
	 *
	 * @return bool Whether remote inbox notifications are enabled.
	 */
	protected function is_remote_inbox_notifications_enabled() {
		// Check if the feature flag is disabled.
		if ( ! Features::is_enabled( 'remote-inbox-notifications' ) ) {
			return false;
		}

		// Check if the site has opted out of marketplace suggestions.
		if ( get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) !== 'yes' ) {
			return false;
		}

		// All checks have passed.
		return true;
	}

	/**
	 * Checks if merchant email notifications are enabled.
	 *
	 * @return bool Whether merchant email notifications are enabled.
	 */
	protected function is_merchant_email_notifications_enabled() {
		// Check if the feature flag is disabled.
		if ( get_option( 'woocommerce_merchant_email_notifications', 'no' ) !== 'yes' ) {
			return false;
		}

		// All checks have passed.
		return true;
	}

	/**
	 *   Refresh transient for the following DataSourcePollers on wc_admin_daily cron job.
	 *   - PaymentGatewaySuggestionsDataSourcePoller
	 *   - RemoteFreeExtensionsDataSourcePoller
	 */
	protected function possibly_refresh_data_source_pollers() {
		$completed_tasks = get_option( 'woocommerce_task_list_tracked_completed_tasks', array() );

		if ( ! in_array( 'payments', $completed_tasks, true ) && ! in_array( 'woocommerce-payments', $completed_tasks, true ) ) {
			PaymentGatewaySuggestionsDataSourcePoller::get_instance()->read_specs_from_data_sources();
		}

		if ( ! in_array( 'store_details', $completed_tasks, true ) && ! in_array( 'marketing', $completed_tasks, true ) ) {
			RemoteFreeExtensionsDataSourcePoller::get_instance()->read_specs_from_data_sources();
		}
	}
}
PK     [1]	    "  Admin/RemoteInboxNotifications.phpnu         <?php
/**
 * Remote Inbox Notifications feature.
 */

namespace Automattic\WooCommerce\Internal\Admin;

use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\RemoteInboxNotifications\RemoteInboxNotificationsEngine;

/**
 * Remote Inbox Notifications feature logic.
 */
class RemoteInboxNotifications {
	/**
	 * Option name used to toggle this feature.
	 */
	const TOGGLE_OPTION_NAME = 'woocommerce_show_marketplace_suggestions';

	/**
	 * Class instance.
	 *
	 * @var RemoteInboxNotifications instance
	 */
	protected static $instance = null;

	/**
	 * Get class instance.
	 */
	public static function get_instance() {
		if ( ! self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Hook into WooCommerce.
	 */
	public function __construct() {
		if ( Features::is_enabled( 'remote-inbox-notifications' ) ) {
			RemoteInboxNotificationsEngine::init();
		}
	}
}
PK     [1]<       Admin/Marketplace.phpnu         <?php
/**
 * WooCommerce Marketplace.
 */

namespace Automattic\WooCommerce\Internal\Admin;

use WC_Helper_Options;
use WC_Helper_Updater;

/**
 * Contains backend logic for the Marketplace feature.
 */
class Marketplace {
	const MARKETPLACE_TAB_SLUG = 'woo';

	/**
	 * Class initialization, to be executed when the class is resolved by the container.
	 *
	 * @internal
	 */
	final public function init() {
		add_action( 'init', array( $this, 'on_init' ) );
	}

	/**
	 * Hook into WordPress on init.
	 */
	public function on_init() {
		add_action( 'admin_menu', array( $this, 'register_pages' ), 70 );
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) );

		// Add a Woo Marketplace link to the plugin install action links.
		add_filter( 'install_plugins_tabs', array( $this, 'add_woo_plugin_install_action_link' ) );
		add_action( 'install_plugins_pre_woo', array( $this, 'maybe_open_woo_tab' ) );
	}

	/**
	 * Registers report pages.
	 */
	public function register_pages() {
		if ( ! function_exists( 'wc_admin_register_page' ) ) {
			return;
		}

		$marketplace_pages = $this->get_marketplace_pages();

		foreach ( $marketplace_pages as $marketplace_page ) {
			if ( ! is_null( $marketplace_page ) ) {
				wc_admin_register_page( $marketplace_page );
			}
		}
	}

	/**
	 * Get report pages.
	 */
	public function get_marketplace_pages() {
		$marketplace_pages = array(
			array(
				'id'         => 'woocommerce-marketplace',
				'parent'     => 'woocommerce',
				'title'      => __( 'Extensions', 'woocommerce' ) . $this->badge(),
				'page_title' => __( 'Extensions', 'woocommerce' ),
				'path'       => '/extensions',
			),
		);

		/**
		 * The marketplace items used in the menu.
		 *
		 * @since 8.0
		 */
		return apply_filters( 'woocommerce_marketplace_menu_items', $marketplace_pages );
	}

	private function badge(): string {
		$option = WC_Helper_Options::get( 'my_subscriptions_tab_loaded' );

		if ( ! $option ) {
			return WC_Helper_Updater::get_updates_count_html();
		}

		return '';
	}

	/**
	 * Enqueue update script.
	 *
	 * @param string $hook_suffix The current admin page.
	 */
	public function enqueue_scripts( $hook_suffix ) {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		if ( 'woocommerce_page_wc-admin' !== $hook_suffix ) {
			return;
		}

		if ( ! isset( $_GET['path'] ) || '/extensions' !== $_GET['path'] ) {
			return;
		}

		// Enqueue WordPress updates script to enable plugin and theme installs and updates.
		wp_enqueue_script( 'updates' );
		// phpcs:enable WordPress.Security.NonceVerification.Recommended
	}

	/**
	 * Add a Woo Marketplace link to the plugin install action links.
	 *
	 * @param array $tabs Plugins list tabs.
	 * @return array
	 */
	public function add_woo_plugin_install_action_link( $tabs ) {
		$tabs[ self::MARKETPLACE_TAB_SLUG ] = 'WooCommerce Marketplace';
		return $tabs;
	}

	/**
	 * Open the Woo tab when the user clicks on the Woo link in the plugin installer.
	 */
	public function maybe_open_woo_tab() {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		if ( ! isset( $_GET['tab'] ) || self::MARKETPLACE_TAB_SLUG !== $_GET['tab'] ) {
			return;
		}
		// phpcs:enable WordPress.Security.NonceVerification.Recommended

		$woo_url = add_query_arg(
			array(
				'page' => 'wc-admin',
				'path' => '/extensions',
				'tab'  => 'extensions',
				'ref'  => 'plugins',
			),
			admin_url( 'admin.php' )
		);

		wc_admin_record_tracks_event( 'marketplace_plugin_install_woo_clicked' );
		wp_safe_redirect( $woo_url );
		exit;
	}
}
PK     [1]H^    )  Admin/ProductReviews/ReviewsListTable.phpnu         <?php
/**
 * Product > Reviews
 */

namespace Automattic\WooCommerce\Internal\Admin\ProductReviews;

use WC_Product;
use WP_Comment;
use WP_Comments_List_Table;
use WP_List_Table;
use WP_Post;

/**
 * Handles the Product Reviews page.
 */
class ReviewsListTable extends WP_List_Table {

	/**
	 * Memoization flag to determine if the current user can edit the current review.
	 *
	 * @var bool
	 */
	private $current_user_can_edit_review = false;

	/**
	 * Memoization flag to determine if the current user can moderate reviews.
	 *
	 * @var bool
	 */
	private $current_user_can_moderate_reviews;

	/**
	 * Current rating of reviews to display.
	 *
	 * @var int
	 */
	private $current_reviews_rating = 0;

	/**
	 * Current product the reviews should be displayed for.
	 *
	 * @var WC_Product|null Product or null for all products.
	 */
	private $current_product_for_reviews;

	/**
	 * Constructor.
	 *
	 * @param array|string $args Array or string of arguments.
	 */
	public function __construct( $args = [] ) {
		parent::__construct(
			wp_parse_args(
				$args,
				[
					'plural'   => 'product-reviews',
					'singular' => 'product-review',
				]
			)
		);

		$this->current_user_can_moderate_reviews = current_user_can( Reviews::get_capability( 'moderate' ) );
	}

	/**
	 * Prepares reviews for display.
	 *
	 * @return void
	 */
	public function prepare_items() : void {

		$this->set_review_status();
		$this->set_review_type();
		$this->current_reviews_rating = isset( $_REQUEST['review_rating'] ) ? absint( $_REQUEST['review_rating'] ) : 0;
		$this->set_review_product();

		$args = [
			'number'    => $this->get_per_page(),
			'post_type' => 'product',
		];

		// Include the order & orderby arguments.
		$args = wp_parse_args( $this->get_sort_arguments(), $args );
		// Handle the review item types filter.
		$args = wp_parse_args( $this->get_filter_type_arguments(), $args );
		// Handle the reviews rating filter.
		$args = wp_parse_args( $this->get_filter_rating_arguments(), $args );
		// Handle the review product filter.
		$args = wp_parse_args( $this->get_filter_product_arguments(), $args );
		// Include the review status arguments.
		$args = wp_parse_args( $this->get_status_arguments(), $args );
		// Include the search argument.
		$args = wp_parse_args( $this->get_search_arguments(), $args );
		// Include the offset argument.
		$args = wp_parse_args( $this->get_offset_arguments(), $args );

		/**
		 * Provides an opportunity to alter the comment query arguments used within
		 * the product reviews admin list table.
		 *
		 * @since 7.0.0
		 *
		 * @param array $args Comment query args.
		 */
		$args     = (array) apply_filters( 'woocommerce_product_reviews_list_table_prepare_items_args', $args );
		$comments = get_comments( $args );

		update_comment_cache( $comments );

		$this->items = $comments;

		$this->set_pagination_args(
			[
				'total_items' => get_comments( $this->get_total_comments_arguments( $args ) ),
				'per_page'    => $this->get_per_page(),
			]
		);
	}

	/**
	 * Returns the number of items to show per page.
	 *
	 * @return int Customized per-page value if available, or 20 as the default.
	 */
	protected function get_per_page() : int {
		return $this->get_items_per_page( 'edit_comments_per_page' );
	}

	/**
	 * Sets the product to filter reviews by.
	 *
	 * @return void
	 */
	protected function set_review_product() : void {

		$product_id = isset( $_REQUEST['product_id'] ) ? absint( $_REQUEST['product_id'] ) : null;
		$product = $product_id ? wc_get_product( $product_id ) : null;

		if ( $product instanceof WC_Product ) {
			$this->current_product_for_reviews = $product;
		}
	}

	/**
	 * Sets the `$comment_status` global based on the current request.
	 *
	 * @global string $comment_status
	 *
	 * @return void
	 */
	protected function set_review_status() : void {
		global $comment_status;

		$comment_status = sanitize_text_field( wp_unslash( $_REQUEST['comment_status'] ?? 'all' ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited

		if ( ! in_array( $comment_status, [ 'all', 'moderated', 'approved', 'spam', 'trash' ], true ) ) {
			$comment_status = 'all'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
		}
	}

	/**
	 * Sets the `$comment_type` global based on the current request.
	 *
	 * @global string $comment_type
	 *
	 * @return void
	 */
	protected function set_review_type() : void {
		global $comment_type;

		$review_type = sanitize_text_field( wp_unslash( $_REQUEST['review_type'] ?? 'all' ) );

		if ( 'all' !== $review_type && ! empty( $review_type ) ) {
			$comment_type = $review_type; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
		}
	}

	/**
	 * Builds the `orderby` and `order` arguments based on the current request.
	 *
	 * @return array
	 */
	protected function get_sort_arguments() : array {
		$orderby = sanitize_text_field( wp_unslash( $_REQUEST['orderby'] ?? '' ) );
		$order   = sanitize_text_field( wp_unslash( $_REQUEST['order'] ?? '' ) );

		$args = [];

		if ( ! in_array( $orderby, $this->get_sortable_columns(), true ) ) {
			$orderby = 'comment_date_gmt';
		}

		// If ordering by "rating", then we need to adjust to sort by meta value.
		if ( 'rating' === $orderby ) {
			$orderby          = 'meta_value_num';
			$args['meta_key'] = 'rating';
		}

		if ( ! in_array( strtolower( $order ), [ 'asc', 'desc' ], true ) ) {
			$order = 'desc';
		}

		return wp_parse_args(
			[
				'orderby' => $orderby,
				'order'   => strtolower( $order ),
			],
			$args
		);
	}

	/**
	 * Builds the `type` argument based on the current request.
	 *
	 * @return array
	 */
	protected function get_filter_type_arguments() : array {

		$args      = [];
		$item_type = isset( $_REQUEST['review_type'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['review_type'] ) ) : 'all';

		if ( 'all' === $item_type ) {
			return $args;
		}

		$args['type'] = $item_type;

		return $args;
	}

	/**
	 * Builds the `meta_query` arguments based on the current request.
	 *
	 * @return array
	 */
	protected function get_filter_rating_arguments() : array {

		$args = [];

		if ( empty( $this->current_reviews_rating ) ) {
			return $args;
		}

		$args['meta_query'] = [
			[
				'key'     => 'rating',
				'value'   => (int) $this->current_reviews_rating,
				'compare' => '=',
				'type'    => 'NUMERIC',
			],
		];

		return $args;
	}

	/**
	 * Gets the `post_id` argument based on the current request.
	 *
	 * @return array
	 */
	public function get_filter_product_arguments() : array {

		$args = [];

		if ( $this->current_product_for_reviews instanceof WC_Product ) {
			$args['post_id'] = $this->current_product_for_reviews->get_id();
		}

		return $args;
	}

	/**
	 * Gets the `status` argument based on the current request.
	 *
	 * @return array
	 */
	protected function get_status_arguments() : array {
		$args = [];

		global $comment_status;

		if ( ! empty( $comment_status ) && 'all' !== $comment_status && array_key_exists( $comment_status, $this->get_status_filters() ) ) {
			$args['status'] = $this->convert_status_to_query_value( $comment_status );
		}

		return $args;
	}

	/**
	 * Gets the `search` argument based on the current request.
	 *
	 * @return array
	 */
	protected function get_search_arguments() : array {
		$args = [];

		if ( ! empty( $_REQUEST['s'] ) ) {
			$args['search'] = sanitize_text_field( wp_unslash( $_REQUEST['s'] ) );
		}

		return $args;
	}

	/**
	 * Returns the `offset` argument based on the current request.
	 *
	 * @return array
	 */
	protected function get_offset_arguments() : array {
		$args = [];

		if ( isset( $_REQUEST['start'] ) ) {
			$args['offset'] = absint( wp_unslash( $_REQUEST['start'] ) );
		} else {
			$args['offset'] = ( $this->get_pagenum() - 1 ) * $this->get_per_page();
		}

		return $args;
	}

	/**
	 * Returns the arguments used to count the total number of comments.
	 *
	 * @param array $default_query_args Query args for the main request.
	 * @return array
	 */
	protected function get_total_comments_arguments( array $default_query_args ) : array {
		return wp_parse_args(
			[
				'count'  => true,
				'offset' => 0,
				'number' => 0,
			],
			$default_query_args
		);
	}

	/**
	 * Displays the product reviews HTML table.
	 *
	 * Reimplements {@see WP_Comment_::display()} but we change the ID to match the one output by {@see WP_Comments_List_Table::display()}.
	 * This will automatically handle additional CSS for consistency with the comments page.
	 *
	 * @return void
	 */
	public function display() : void {
		$this->display_tablenav( 'top' );

		$this->screen->render_screen_reader_content( 'heading_list' );

		?>
		<table class="wp-list-table <?php echo esc_attr( implode( ' ', $this->get_table_classes() ) ); ?>">
			<thead>
			<tr>
				<?php $this->print_column_headers(); ?>
			</tr>
			</thead>
			<tbody id="the-comment-list" data-wp-lists="list:comment">
			<?php $this->display_rows_or_placeholder(); ?>
			</tbody>
			<tfoot>
			<tr>
				<?php $this->print_column_headers( false ); ?>
			</tr>
			</tfoot>
		</table>
		<?php

		$this->display_tablenav( 'bottom' );
	}

	/**
	 * Render a single row HTML.
	 *
	 * @global WP_Post $post
	 * @global WP_Comment $comment
	 *
	 * @param WP_Comment|mixed $item Review or reply being rendered.
	 * @return void
	 */
	public function single_row( $item ) : void {
		global $post, $comment;

		// Overrides the comment global for properly rendering rows.
		$comment           = $item; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
		$the_comment_class = (string) wp_get_comment_status( $comment->comment_ID );
		$the_comment_class = implode( ' ', get_comment_class( $the_comment_class, $comment->comment_ID, $comment->comment_post_ID ) );
		// Sets the post for the product in context.
		$post = get_post( $comment->comment_post_ID ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited

		$this->current_user_can_edit_review = current_user_can( 'edit_comment', $comment->comment_ID );

		?>
		<tr id="comment-<?php echo esc_attr( $comment->comment_ID ); ?>" class="comment <?php echo esc_attr( $the_comment_class ); ?>">
			<?php $this->single_row_columns( $comment ); ?>
		</tr>
		<?php
	}

	/**
	 * Generate and display row actions links.
	 *
	 * @see WP_Comments_List_Table::handle_row_actions() for consistency.
	 *
	 * @global string $comment_status Status for the current listed comments.
	 *
	 * @param WP_Comment|mixed $item        The product review or reply in context.
	 * @param string|mixed     $column_name Current column name.
	 * @param string|mixed     $primary     Primary column name.
	 * @return string
	 */
	protected function handle_row_actions( $item, $column_name, $primary ) : string {
		global $comment_status;

		if ( $primary !== $column_name || ! $this->current_user_can_edit_review ) {
			return '';
		}

		$review_status = wp_get_comment_status( $item );

		$url = add_query_arg(
			[
				'c' => urlencode( $item->comment_ID ),
			],
			admin_url( 'comment.php' )
		);

		$approve_url   = wp_nonce_url( add_query_arg( 'action', 'approvecomment', $url ), "approve-comment_$item->comment_ID" );
		$unapprove_url = wp_nonce_url( add_query_arg( 'action', 'unapprovecomment', $url ), "approve-comment_$item->comment_ID" );
		$spam_url      = wp_nonce_url( add_query_arg( 'action', 'spamcomment', $url ), "delete-comment_$item->comment_ID" );
		$unspam_url    = wp_nonce_url( add_query_arg( 'action', 'unspamcomment', $url ), "delete-comment_$item->comment_ID" );
		$trash_url     = wp_nonce_url( add_query_arg( 'action', 'trashcomment', $url ), "delete-comment_$item->comment_ID" );
		$untrash_url   = wp_nonce_url( add_query_arg( 'action', 'untrashcomment', $url ), "delete-comment_$item->comment_ID" );
		$delete_url    = wp_nonce_url( add_query_arg( 'action', 'deletecomment', $url ), "delete-comment_$item->comment_ID" );

		$actions = [
			'approve'   => '',
			'unapprove' => '',
			'reply'     => '',
			'quickedit' => '',
			'edit'      => '',
			'spam'      => '',
			'unspam'    => '',
			'trash'     => '',
			'untrash'   => '',
			'delete'    => '',
		];

		if ( $comment_status && 'all' !== $comment_status ) {
			if ( 'approved' === $review_status ) {
				$actions['unapprove'] = sprintf(
					'<a href="%s" data-wp-lists="%s" class="vim-u vim-destructive aria-button-if-js" aria-label="%s">%s</a>',
					esc_url( $unapprove_url ),
					esc_attr( "delete:the-comment-list:comment-{$item->comment_ID}:e7e7d3:action=dim-comment&amp;new=unapproved" ),
					esc_attr__( 'Unapprove this review', 'woocommerce' ),
					esc_html__( 'Unapprove', 'woocommerce' )
				);
			} elseif ( 'unapproved' === $review_status ) {
				$actions['approve'] = sprintf(
					'<a href="%s" data-wp-lists="%s" class="vim-a vim-destructive aria-button-if-js" aria-label="%s">%s</a>',
					esc_url( $approve_url ),
					esc_attr( "delete:the-comment-list:comment-{$item->comment_ID}:e7e7d3:action=dim-comment&amp;new=approved" ),
					esc_attr__( 'Approve this review', 'woocommerce' ),
					esc_html__( 'Approve', 'woocommerce' )
				);
			}
		} else {
			$actions['approve'] = sprintf(
				'<a href="%s" data-wp-lists="%s" class="vim-a aria-button-if-js" aria-label="%s">%s</a>',
				esc_url( $approve_url ),
				esc_attr( "dim:the-comment-list:comment-{$item->comment_ID}:unapproved:e7e7d3:e7e7d3:new=approved" ),
				esc_attr__( 'Approve this review', 'woocommerce' ),
				esc_html__( 'Approve', 'woocommerce' )
			);

			$actions['unapprove'] = sprintf(
				'<a href="%s" data-wp-lists="%s" class="vim-u aria-button-if-js" aria-label="%s">%s</a>',
				esc_url( $unapprove_url ),
				esc_attr( "dim:the-comment-list:comment-{$item->comment_ID}:unapproved:e7e7d3:e7e7d3:new=unapproved" ),
				esc_attr__( 'Unapprove this review', 'woocommerce' ),
				esc_html__( 'Unapprove', 'woocommerce' )
			);
		}

		if ( 'spam' !== $review_status ) {
			$actions['spam'] = sprintf(
				'<a href="%s" data-wp-lists="%s" class="vim-s vim-destructive aria-button-if-js" aria-label="%s">%s</a>',
				esc_url( $spam_url ),
				esc_attr( "delete:the-comment-list:comment-{$item->comment_ID}::spam=1" ),
				esc_attr__( 'Mark this review as spam', 'woocommerce' ),
				/* translators: "Mark as spam" link. */
				esc_html_x( 'Spam', 'verb', 'woocommerce' )
			);
		} else {
			$actions['unspam'] = sprintf(
				'<a href="%s" data-wp-lists="%s" class="vim-z vim-destructive aria-button-if-js" aria-label="%s">%s</a>',
				esc_url( $unspam_url ),
				esc_attr( "delete:the-comment-list:comment-{$item->comment_ID}:66cc66:unspam=1" ),
				esc_attr__( 'Restore this review from the spam', 'woocommerce' ),
				esc_html_x( 'Not Spam', 'review', 'woocommerce' )
			);
		}

		if ( 'trash' === $review_status ) {
			$actions['untrash'] = sprintf(
				'<a href="%s" data-wp-lists="%s" class="vim-z vim-destructive aria-button-if-js" aria-label="%s">%s</a>',
				esc_url( $untrash_url ),
				esc_attr( "delete:the-comment-list:comment-{$item->comment_ID}:66cc66:untrash=1" ),
				esc_attr__( 'Restore this review from the Trash', 'woocommerce' ),
				esc_html__( 'Restore', 'woocommerce' )
			);
		}

		if ( 'spam' === $review_status || 'trash' === $review_status || ! EMPTY_TRASH_DAYS ) {
			$actions['delete'] = sprintf(
				'<a href="%s" data-wp-lists="%s" class="delete vim-d vim-destructive aria-button-if-js" aria-label="%s">%s</a>',
				esc_url( $delete_url ),
				esc_attr( "delete:the-comment-list:comment-{$item->comment_ID}::delete=1" ),
				esc_attr__( 'Delete this review permanently', 'woocommerce' ),
				esc_html__( 'Delete Permanently', 'woocommerce' )
			);
		} else {
			$actions['trash'] = sprintf(
				'<a href="%s" data-wp-lists="%s" class="delete vim-d vim-destructive aria-button-if-js" aria-label="%s">%s</a>',
				esc_url( $trash_url ),
				esc_attr( "delete:the-comment-list:comment-{$item->comment_ID}::trash=1" ),
				esc_attr__( 'Move this review to the Trash', 'woocommerce' ),
				esc_html_x( 'Trash', 'verb', 'woocommerce' )
			);
		}

		if ( 'spam' !== $review_status && 'trash' !== $review_status ) {
			$actions['edit'] = sprintf(
				'<a href="%s" aria-label="%s">%s</a>',
				esc_url(
					add_query_arg(
						[
							'action' => 'editcomment',
							'c'      => urlencode( $item->comment_ID ),
						],
						admin_url( 'comment.php' )
					)
				),
				esc_attr__( 'Edit this review', 'woocommerce' ),
				esc_html__( 'Edit', 'woocommerce' )
			);

			$format = '<button type="button" data-comment-id="%d" data-post-id="%d" data-action="%s" class="%s button-link" aria-expanded="false" aria-label="%s">%s</button>';

			$actions['quickedit'] = sprintf(
				$format,
				esc_attr( $item->comment_ID ),
				esc_attr( $item->comment_post_ID ),
				'edit',
				'vim-q comment-inline',
				esc_attr__( 'Quick edit this review inline', 'woocommerce' ),
				esc_html__( 'Quick Edit', 'woocommerce' )
			);

			$actions['reply'] = sprintf(
				$format,
				esc_attr( $item->comment_ID ),
				esc_attr( $item->comment_post_ID ),
				'replyto',
				'vim-r comment-inline',
				esc_attr__( 'Reply to this review', 'woocommerce' ),
				esc_html__( 'Reply', 'woocommerce' )
			);
		}

		/**
		 * Filters the action links displayed for each review in the Reviews list table.
		 *
		 * @since 9.8.0
		 * @param string[]   $actions An array of comment actions. Default actions include:
		 *                            'Approve', 'Unapprove', 'Edit', 'Reply', 'Spam',
		 *                            'Delete', and 'Trash'.
		 * @param WP_Comment $item The comment object.
		 * */
		$actions = apply_filters( 'comment_row_actions', array_filter( $actions ), $item );

		$always_visible = 'excerpt' === get_user_setting( 'posts_list_mode', 'list' );

		$output = '<div class="' . ( $always_visible ? 'row-actions visible' : 'row-actions' ) . '">';

		$i = 0;

		foreach ( array_filter( $actions ) as $action => $link ) {
			++$i;

			if ( ( ( 'approve' === $action || 'unapprove' === $action ) && 2 === $i ) || 1 === $i ) {
				$sep = '';
			} else {
				$sep = ' | ';
			}

			if ( ( 'reply' === $action || 'quickedit' === $action ) && ! wp_doing_ajax() ) {
				$action .= ' hide-if-no-js';
			} elseif ( ( 'untrash' === $action && 'trash' === $review_status ) || ( 'unspam' === $action && 'spam' === $review_status ) ) {
				if ( '1' === get_comment_meta( $item->comment_ID, '_wp_trash_meta_status', true ) ) {
					$action .= ' approve';
				} else {
					$action .= ' unapprove';
				}
			}

			$output .= "<span class='$action'>$sep$link</span>";
		}

		$output .= '</div>';
		$output .= '<button type="button" class="toggle-row"><span class="screen-reader-text">' . esc_html__( 'Show more details', 'woocommerce' ) . '</span></button>';

		return $output;
	}

	/**
	 * Gets the columns for the table.
	 *
	 * @return array Table columns and their headings.
	 */
	public function get_columns() : array {
		$columns = [
			'cb'       => '<input type="checkbox" />',
			'type'     => _x( 'Type', 'review type', 'woocommerce' ),
			'author'   => __( 'Author', 'woocommerce' ),
			'rating'   => __( 'Rating', 'woocommerce' ),
			'comment'  => _x( 'Review', 'column name', 'woocommerce' ),
			'response' => __( 'Product', 'woocommerce' ),
			'date'     => _x( 'Submitted on', 'column name', 'woocommerce' ),
		];

		/**
		 * Filters the table columns.
		 *
		 * @since 6.7.0
		 *
		 * @param array $columns
		 */
		return (array) apply_filters( 'woocommerce_product_reviews_table_columns', $columns );
	}

	/**
	 * Gets the name of the default primary column.
	 *
	 * @return string Name of the primary column.
	 */
	protected function get_primary_column_name() : string {
		return 'comment';
	}

	/**
	 * Gets a list of sortable columns.
	 *
	 * Key is the column ID and value is which database column we perform the sorting on.
	 * The `rating` column uses a unique key instead, as that requires sorting by meta value.
	 *
	 * @return array
	 */
	protected function get_sortable_columns() : array {
		return [
			'author'   => 'comment_author',
			'response' => 'comment_post_ID',
			'date'     => 'comment_date_gmt',
			'type'     => 'comment_type',
			'rating'   => 'rating',
		];
	}

	/**
	 * Returns a list of available bulk actions.
	 *
	 * @global string $comment_status
	 *
	 * @return array
	 */
	protected function get_bulk_actions() : array {
		global $comment_status;

		$actions = [];

		if ( in_array( $comment_status, [ 'all', 'approved' ], true ) ) {
			$actions['unapprove'] = __( 'Unapprove', 'woocommerce' );
		}

		if ( in_array( $comment_status, [ 'all', 'moderated' ], true ) ) {
			$actions['approve'] = __( 'Approve', 'woocommerce' );
		}

		if ( in_array( $comment_status, [ 'all', 'moderated', 'approved', 'trash' ], true ) ) {
			$actions['spam'] = _x( 'Mark as spam', 'review', 'woocommerce' );
		}

		if ( 'trash' === $comment_status ) {
			$actions['untrash'] = __( 'Restore', 'woocommerce' );
		} elseif ( 'spam' === $comment_status ) {
			$actions['unspam'] = _x( 'Not spam', 'review', 'woocommerce' );
		}

		if ( in_array( $comment_status, [ 'trash', 'spam' ], true ) || ! EMPTY_TRASH_DAYS ) {
			$actions['delete'] = __( 'Delete permanently', 'woocommerce' );
		} else {
			$actions['trash'] = __( 'Move to Trash', 'woocommerce' );
		}

		return $actions;
	}

	/**
	 * Returns the current action select in bulk actions menu.
	 *
	 * This is overridden in order to support `delete_all` for use in {@see ReviewsListTable::process_bulk_action()}
	 *
	 * {@see WP_Comments_List_Table::current_action()} for reference.
	 *
	 * @return string|false
	 */
	public function current_action() {
		if ( isset( $_REQUEST['delete_all'] ) || isset( $_REQUEST['delete_all2'] ) ) {
			return 'delete_all';
		}

		return parent::current_action();
	}

	/**
	 * Processes the bulk actions.
	 *
	 * @return void
	 */
	public function process_bulk_action() : void {

		if ( ! $this->current_user_can_moderate_reviews ) {
			return;
		}

		if ( $this->current_action() ) {
			check_admin_referer( 'bulk-product-reviews' );

			$query_string = remove_query_arg( [ 'page', '_wpnonce' ], wp_unslash( ( $_SERVER['QUERY_STRING'] ?? '' ) ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

			// Replace current nonce with bulk-comments nonce.
			$comments_nonce = wp_create_nonce( 'bulk-comments' );
			$query_string   = add_query_arg( '_wpnonce', $comments_nonce, $query_string );

			// Redirect to edit-comments.php, which will handle processing the action for us.
			wp_safe_redirect( esc_url_raw( admin_url( 'edit-comments.php?' . $query_string ) ) );
			exit;
		} elseif ( ! empty( $_GET['_wp_http_referer'] ) ) {

			wp_safe_redirect( remove_query_arg( [ '_wp_http_referer', '_wpnonce' ] ) );
			exit;
		}
	}

	/**
	 * Returns an array of supported statuses and their labels.
	 *
	 * @return array
	 */
	protected function get_status_filters() : array {
		return [
			/* translators: %s: Number of reviews. */
			'all'       => _nx_noop(
				'All <span class="count">(%s)</span>',
				'All <span class="count">(%s)</span>',
				'product reviews',
				'woocommerce'
			),
			/* translators: %s: Number of reviews. */
			'moderated' => _nx_noop(
				'Pending <span class="count">(%s)</span>',
				'Pending <span class="count">(%s)</span>',
				'product reviews',
				'woocommerce'
			),
			/* translators: %s: Number of reviews. */
			'approved'  => _nx_noop(
				'Approved <span class="count">(%s)</span>',
				'Approved <span class="count">(%s)</span>',
				'product reviews',
				'woocommerce'
			),
			/* translators: %s: Number of reviews. */
			'spam'      => _nx_noop(
				'Spam <span class="count">(%s)</span>',
				'Spam <span class="count">(%s)</span>',
				'product reviews',
				'woocommerce'
			),
			/* translators: %s: Number of reviews. */
			'trash'     => _nx_noop(
				'Trash <span class="count">(%s)</span>',
				'Trash <span class="count">(%s)</span>',
				'product reviews',
				'woocommerce'
			),
		];
	}

	/**
	 * Returns the available status filters.
	 *
	 * @see WP_Comments_List_Table::get_views() for consistency.
	 *
	 * @global int    $post_id
	 * @global string $comment_status
	 * @global string $comment_type
	 *
	 * @return array An associative array of fully-formed comment status links. Includes 'All', 'Pending', 'Approved', 'Spam', and 'Trash'.
	 */
	protected function get_views() : array {
		global $post_id, $comment_status, $comment_type;

		$status_links = [];

		$status_labels = $this->get_status_filters();

		if ( ! EMPTY_TRASH_DAYS ) {
			unset( $status_labels['trash'] );
		}

		$link = $this->get_view_url( (string) $comment_type, (int) $post_id );

		foreach ( $status_labels as $status => $label ) {
			$current_link_attributes = '';

			if ( $status === $comment_status ) {
				$current_link_attributes = ' class="current" aria-current="page"';
			}

			$link = add_query_arg( 'comment_status', urlencode( $status ), $link );

			$number_reviews_for_status = $this->get_review_count( $status, (int) $post_id );

			$count_html = sprintf(
				'<span class="%s-count">%s</span>',
				( 'moderated' === $status ) ? 'pending' : $status,
				number_format_i18n( $number_reviews_for_status )
			);

			$status_links[ $status ] = '<a href="' . esc_url( $link ) . '"' . $current_link_attributes . '>' . sprintf( translate_nooped_plural( $label, $number_reviews_for_status ), $count_html ) . '</a>';
		}

		return $status_links;
	}

	/**
	 * Gets the base URL for a view, excluding the status (that should be appended).
	 *
	 * @param string $comment_type Comment type filter.
	 * @param int    $post_id      Current post ID.
	 * @return string
	 */
	protected function get_view_url( string $comment_type, int $post_id ) : string {
		$link = Reviews::get_reviews_page_url();

		if ( ! empty( $comment_type ) && 'all' !== $comment_type ) {
			$link = add_query_arg( 'comment_type', urlencode( $comment_type ), $link );
		}
		if ( ! empty( $post_id ) ) {
			$link = add_query_arg( 'p', absint( $post_id ), $link );
		}

		return $link;
	}

	/**
	 * Gets the number of reviews (including review replies) for a given status.
	 *
	 * @param string $status     Status key from {@see ReviewsListTable::get_status_filters()}.
	 * @param int    $product_id ID of the product if we're filtering by product in this request. Otherwise, `0` for no product filters.
	 * @return int
	 */
	protected function get_review_count( string $status, int $product_id ) : int {
		return (int) get_comments(
			[
				'type__in'  => [ 'review', 'comment' ],
				'status'    => $this->convert_status_to_query_value( $status ),
				'post_type' => 'product',
				'post_id'   => $product_id,
				'count'     => true,
			]
		);
	}

	/**
	 * Converts a status key into its equivalent `comment_approved` database column value.
	 *
	 * @param string $status Status key from {@see ReviewsListTable::get_status_filters()}.
	 * @return string
	 */
	protected function convert_status_to_query_value( string $status ) : string {
		// These keys exactly match the database column.
		if ( in_array( $status, [ 'spam', 'trash' ], true ) ) {
			return $status;
		}

		switch ( $status ) {
			case 'moderated':
				return '0';
			case 'approved':
				return '1';
			default:
				return 'all';
		}
	}

	/**
	 * Outputs the text to display when there are no reviews to display.
	 *
	 * @see WP_List_Table::no_items()
	 *
	 * @global string $comment_status
	 *
	 * @return void
	 */
	public function no_items() : void {
		global $comment_status;

		if ( 'moderated' === $comment_status ) {
			esc_html_e( 'No reviews awaiting moderation.', 'woocommerce' );
		} else {
			esc_html_e( 'No reviews found.', 'woocommerce' );
		}
	}

	/**
	 * Renders the checkbox column.
	 *
	 * @param WP_Comment|mixed $item Review or reply being rendered.
	 * @return void
	 */
	protected function column_cb( $item ) : void {

		ob_start();

		if ( $this->current_user_can_edit_review ) {
			?>
			<label class="screen-reader-text" for="cb-select-<?php echo esc_attr( $item->comment_ID ); ?>"><?php esc_html_e( 'Select review', 'woocommerce' ); ?></label>
			<input
				id="cb-select-<?php echo esc_attr( $item->comment_ID ); ?>"
				type="checkbox"
				name="delete_comments[]"
				value="<?php echo esc_attr( $item->comment_ID ); ?>"
			/>
			<?php
		}

		echo $this->filter_column_output( 'cb', ob_get_clean(), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	}

	/**
	 * Renders the review column.
	 *
	 * @see WP_Comments_List_Table::column_comment() for consistency.
	 *
	 * @param WP_Comment|mixed $item Review or reply being rendered.
	 * @return void
	 */
	protected function column_comment( $item ) : void {

		$in_reply_to = $this->get_in_reply_to_review_text( $item );

		ob_start();

		if ( $in_reply_to ) {
			echo $in_reply_to . '<br><br>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		}

		echo '<div class="comment-text">';
		comment_text( $item->comment_ID );
		echo '</div>';

		if ( $this->current_user_can_edit_review ) {
			?>
			<div id="inline-<?php echo esc_attr( $item->comment_ID ); ?>" class="hidden">
				<textarea class="comment" rows="1" cols="1"><?php echo esc_textarea( $item->comment_content ); ?></textarea>
				<div class="author-email"><?php echo esc_attr( $item->comment_author_email ); ?></div>
				<div class="author"><?php echo esc_attr( $item->comment_author ); ?></div>
				<div class="author-url"><?php echo esc_attr( $item->comment_author_url ); ?></div>
				<div class="comment_status"><?php echo esc_html( $item->comment_approved ); ?></div>
			</div>
			<?php
		}

		echo $this->filter_column_output( 'comment', ob_get_clean(), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	}

	/**
	 * Gets the in-reply-to-review text.
	 *
	 * @param WP_Comment|mixed $reply Reply to review.
	 * @return string
	 */
	private function get_in_reply_to_review_text( $reply ) : string {

		$review = $reply->comment_parent ? get_comment( $reply->comment_parent ) : null;

		if ( ! $review ) {
			return '';
		}

		$parent_review_link = get_comment_link( $review );
		$review_author_name = get_comment_author( $review );

		return sprintf(
			/* translators: %s: Parent review link with review author name. */
			ent2ncr( __( 'In reply to %s.', 'woocommerce' ) ),
			'<a href="' . esc_url( $parent_review_link ) . '">' . esc_html( $review_author_name ) . '</a>'
		);
	}

	/**
	 * Renders the author column.
	 *
	 * @see WP_Comments_List_Table::column_author() for consistency.
	 *
	 * @param WP_Comment|mixed $item Review or reply being rendered.
	 * @return void
	 */
	protected function column_author( $item ) : void {
		global $comment_status;

		$author_url = $this->get_item_author_url();
		$author_url_display = $this->get_item_author_url_for_display( $author_url );

		if ( get_option( 'show_avatars' ) ) {
			$author_avatar = get_avatar( $item, 32, 'mystery' );
		} else {
			$author_avatar = '';
		}

		ob_start();

		echo '<strong>' . $author_avatar; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		comment_author();
		echo '</strong><br>';

		if ( ! empty( $author_url ) ) :

			?>
			<a title="<?php echo esc_attr( $author_url ); ?>" href="<?php echo esc_url( $author_url ); ?>" rel="noopener noreferrer"><?php echo esc_html( $author_url_display ); ?></a>
			<br>
			<?php

		endif;

		if ( $this->current_user_can_edit_review ) :

			if ( ! empty( $item->comment_author_email ) && is_email( $item->comment_author_email ) ) :

				?>
				<a href="mailto:<?php echo esc_attr( $item->comment_author_email ); ?>"><?php echo esc_html( $item->comment_author_email ); ?></a><br>
				<?php

			endif;

			$link = add_query_arg(
				[
					's'    => urlencode( get_comment_author_IP( $item->comment_ID ) ),
					'page' => Reviews::MENU_SLUG,
					'mode' => 'detail',
				],
				'admin.php'
			);

			if ( 'spam' === $comment_status ) :
				$link = add_query_arg( [ 'comment_status' => 'spam' ], $link );
			endif;

			?>
			<a href="<?php echo esc_url( $link ); ?>"><?php comment_author_IP( $item->comment_ID ); ?></a>
			<?php

		endif;

		echo $this->filter_column_output( 'author', ob_get_clean(), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	}

	/**
	 * Gets the item author URL.
	 *
	 * @return string
	 */
	private function get_item_author_url() : string {

		$author_url = get_comment_author_url();
		$protocols = [ 'https://', 'http://' ];

		if ( in_array( $author_url, $protocols ) ) {
			$author_url = '';
		}

		return $author_url;
	}

	/**
	 * Gets the item author URL for display.
	 *
	 * @param string $author_url The review or reply author URL (raw).
	 * @return string
	 */
	private function get_item_author_url_for_display( $author_url ) : string {

		$author_url_display = untrailingslashit( preg_replace( '|^http(s)?://(www\.)?|i', '', $author_url ) );

		if ( strlen( $author_url_display ) > 50 ) {
			$author_url_display = wp_html_excerpt( $author_url_display, 49, '&hellip;' );
		}

		return $author_url_display;
	}

	/**
	 * Renders the "submitted on" column.
	 *
	 * Note that the output is consistent with {@see WP_Comments_List_Table::column_date()}.
	 *
	 * @param WP_Comment|mixed $item Review or reply being rendered.
	 * @return void
	 */
	protected function column_date( $item ) : void {

		$submitted = sprintf(
			/* translators: 1 - Product review date, 2: Product review time. */
			__( '%1$s at %2$s', 'woocommerce' ),
			/* translators: Review date format. See https://www.php.net/manual/datetime.format.php */
			get_comment_date( __( 'Y/m/d', 'woocommerce' ), $item ),
			/* translators: Review time format. See https://www.php.net/manual/datetime.format.php */
			get_comment_date( __( 'g:i a', 'woocommerce' ), $item )
		);

		ob_start();

		?>
		<div class="submitted-on">
			<?php

			if ( 'approved' === wp_get_comment_status( $item ) && ! empty( $item->comment_post_ID ) ) :
				printf(
					'<a href="%1$s">%2$s</a>',
					esc_url( get_comment_link( $item ) ),
					esc_html( $submitted )
				);
			else :
				echo esc_html( $submitted );
			endif;

			?>
		</div>
		<?php

		echo $this->filter_column_output( 'date', ob_get_clean(), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	}

	/**
	 * Renders the product column.
	 *
	 * @see WP_Comments_List_Table::column_response() for consistency.
	 *
	 * @param WP_Comment|mixed $item Review or reply being rendered.
	 * @return void
	 */
	protected function column_response( $item ) : void {
		$product_post = get_post();

		ob_start();

		if ( $product_post ) :

			?>
			<div class="response-links">
				<?php

				if ( current_user_can( 'edit_product', $product_post->ID ) ) :
					$post_link  = "<a href='" . esc_url( get_edit_post_link( $product_post->ID ) ) . "' class='comments-edit-item-link'>";
					$post_link .= esc_html( get_the_title( $product_post->ID ) ) . '</a>';
				else :
					$post_link = esc_html( get_the_title( $product_post->ID ) );
				endif;

				echo $post_link; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped

				$post_type_object = get_post_type_object( $product_post->post_type );

				?>
				<a href="<?php echo esc_url( get_permalink( $product_post->ID ) ); ?>" class="comments-view-item-link">
					<?php echo esc_html( $post_type_object->labels->view_item ); ?>
				</a>
				<span class="post-com-count-wrapper post-com-count-<?php echo esc_attr( $product_post->ID ); ?>">
					<?php $this->comments_bubble( $product_post->ID, get_pending_comments_num( $product_post->ID ) ); ?>
				</span>
			</div>
			<?php

		endif;

		echo $this->filter_column_output( 'response', ob_get_clean(), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	}

	/**
	 * Renders the type column.
	 *
	 * @param WP_Comment|mixed $item Review or reply being rendered.
	 * @return void
	 */
	protected function column_type( $item ) : void {

		$type = 'review' === $item->comment_type
			? '&#9734;&nbsp;' . __( 'Review', 'woocommerce' )
			: __( 'Reply', 'woocommerce' );

		echo $this->filter_column_output( 'type', esc_html( $type ), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	}

	/**
	 * Renders the rating column.
	 *
	 * @param WP_Comment|mixed $item Review or reply being rendered.
	 * @return void
	 */
	protected function column_rating( $item ) : void {
		$rating = get_comment_meta( $item->comment_ID, 'rating', true );

		ob_start();

		if ( ! empty( $rating ) && is_numeric( $rating ) ) {
			$rating = (int) $rating;

			$accessibility_label = sprintf(
				/* translators: 1: number representing a rating */
				__( '%1$d out of 5', 'woocommerce' ),
				$rating
			);

			$stars = str_repeat( '&#9733;', $rating );
			$stars .= str_repeat( '&#9734;', 5 - $rating );

			?>
			<span aria-label="<?php echo esc_attr( $accessibility_label ); ?>"><?php echo esc_html( $stars ); ?></span>
			<?php
		}

		echo $this->filter_column_output( 'rating', ob_get_clean(), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	}

	/**
	 * Renders any custom columns.
	 *
	 * @param WP_Comment|mixed $item        Review or reply being rendered.
	 * @param string|mixed     $column_name Name of the column being rendered.
	 * @return void
	 */
	protected function column_default( $item, $column_name ) : void {

		ob_start();

		/**
		 * Fires when the default column output is displayed for a single row.
		 *
		 * This action can be used to render custom columns that have been added.
		 *
		 * @since 6.7.0
		 *
		 * @param WP_Comment $item The review or reply being rendered.
		 */
		do_action( 'woocommerce_product_reviews_table_column_' . $column_name, $item );

		echo $this->filter_column_output( $column_name, ob_get_clean(), $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	}

	/**
	 * Runs a filter hook for a given column content.
	 *
	 * @param string|mixed     $column_name The column being output.
	 * @param string|mixed     $output      The output content (may include HTML).
	 * @param WP_Comment|mixed $item        The review or reply being rendered.
	 * @return string
	 */
	protected function filter_column_output( $column_name, $output, $item ) : string {

		/**
		 * Filters the output of a column.
		 *
		 * @since 6.7.0
		 *
		 * @param string     $output The column output.
		 * @param WP_Comment $item   The product review being rendered.
		 */
		return (string) apply_filters( 'woocommerce_product_reviews_table_column_' . $column_name . '_content', $output, $item );
	}

	/**
	 * Renders the extra controls to be displayed between bulk actions and pagination.
	 *
	 * @global string $comment_status
	 * @global string $comment_type
	 *
	 * @param string|mixed $which Position (top or bottom).
	 * @return void
	 */
	protected function extra_tablenav( $which ) : void {
		global $comment_status, $comment_type;

		echo '<div class="alignleft actions">';

		if ( 'top' === $which ) {

			ob_start();

			echo '<input type="hidden" name="comment_status" value="' . esc_attr( $comment_status ?? 'all' ) . '" />';

			$this->review_type_dropdown( $comment_type );
			$this->review_rating_dropdown( $this->current_reviews_rating );
			$this->product_search( $this->current_product_for_reviews );

			echo ob_get_clean(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped

			submit_button( __( 'Filter', 'woocommerce' ), '', 'filter_action', false, [ 'id' => 'post-query-submit' ] );
		}

		if ( ( 'spam' === $comment_status || 'trash' === $comment_status ) && $this->has_items() && $this->current_user_can_moderate_reviews ) {

			wp_nonce_field( 'bulk-destroy', '_destroy_nonce' );

			$title = 'spam' === $comment_status
				? esc_attr__( 'Empty Spam', 'woocommerce' )
				: esc_attr__( 'Empty Trash', 'woocommerce' );

			submit_button( $title, 'apply', 'delete_all', false );
		}

		echo '</div>';
	}

	/**
	 * Displays a review type drop-down for filtering reviews in the Product Reviews list table.
	 *
	 * @see WP_Comments_List_Table::comment_type_dropdown() for consistency.
	 *
	 * @param string|mixed $current_type The current comment item type slug.
	 * @return void
	 */
	protected function review_type_dropdown( $current_type ) : void {
		/**
		 * Sets the possible options used in the Product Reviews List Table's filter-by-review-type
		 * selector.
		 *
		 * @since 7.0.0
		 *
		 * @param array Map of possible review types.
		 */
		$item_types = apply_filters(
			'woocommerce_product_reviews_list_table_item_types',
			array(
				'all'     => __( 'All types', 'woocommerce' ),
				'comment' => __( 'Replies', 'woocommerce' ),
				'review'  => __( 'Reviews', 'woocommerce' ),
			)
		);

		?>
		<label class="screen-reader-text" for="filter-by-review-type"><?php esc_html_e( 'Filter by review type', 'woocommerce' ); ?></label>
		<select id="filter-by-review-type" name="review_type">
			<?php foreach ( $item_types as $type => $label ) : ?>
				<option value="<?php echo esc_attr( $type ); ?>" <?php selected( $type, $current_type ); ?>><?php echo esc_html( $label ); ?></option>
			<?php endforeach; ?>
		</select>
		<?php
	}

	/**
	 * Displays a review rating drop-down for filtering reviews in the Product Reviews list table.
	 *
	 * @param int|string|mixed $current_rating Rating to display reviews for.
	 * @return void
	 */
	public function review_rating_dropdown( $current_rating ) : void {

		$rating_options = [
			'0' => __( 'All ratings', 'woocommerce' ),
			'1' => '&#9733;',
			'2' => '&#9733;&#9733;',
			'3' => '&#9733;&#9733;&#9733;',
			'4' => '&#9733;&#9733;&#9733;&#9733;',
			'5' => '&#9733;&#9733;&#9733;&#9733;&#9733;',
		];

		?>
		<label class="screen-reader-text" for="filter-by-review-rating"><?php esc_html_e( 'Filter by review rating', 'woocommerce' ); ?></label>
		<select id="filter-by-review-rating" name="review_rating">
			<?php foreach ( $rating_options as $rating => $label ) : ?>
				<?php

				$title = 0 === (int) $rating
					? $label
					: sprintf(
						/* translators: %s: Star rating (1-5). */
						__( '%s-star rating', 'woocommerce' ),
						$rating
					);

				?>
				<option value="<?php echo esc_attr( $rating ); ?>" <?php selected( $rating, (string) $current_rating ); ?> title="<?php echo esc_attr( $title ); ?>"><?php echo esc_html( $label ); ?></option>
			<?php endforeach; ?>
		</select>
		<?php
	}

	/**
	 * Displays a product search input for filtering reviews by product in the Product Reviews list table.
	 *
	 * @param WC_Product|null $current_product The current product (or null when displaying all reviews).
	 * @return void
	 */
	protected function product_search( ?WC_Product $current_product ) : void {
		?>
		<label class="screen-reader-text" for="filter-by-product"><?php esc_html_e( 'Filter by product', 'woocommerce' ); ?></label>
		<select
			id="filter-by-product"
			class="wc-product-search"
			name="product_id"
			style="width: 200px;"
			data-placeholder="<?php esc_attr_e( 'Search for a product&hellip;', 'woocommerce' ); ?>"
			data-action="woocommerce_json_search_products"
			data-allow_clear="true">
			<?php if ( $current_product instanceof WC_Product ) : ?>
				<option value="<?php echo esc_attr( $current_product->get_id() ); ?>" selected="selected"><?php echo esc_html( $current_product->get_formatted_name() ); ?></option>
			<?php endif; ?>
		</select>
		<?php
	}

	/**
	 * Displays a review count bubble.
	 *
	 * Based on {@see WP_List_Table::comments_bubble()}, but overridden, so we can customize the URL and text output.
	 *
	 * @param int|mixed $post_id          The product ID.
	 * @param int|mixed $pending_comments Number of pending reviews.
	 *
	 * @return void
	 */
	protected function comments_bubble( $post_id, $pending_comments ) : void {
		$approved_review_count = get_comments_number();

		$approved_reviews_number = number_format_i18n( $approved_review_count );
		$pending_reviews_number  = number_format_i18n( $pending_comments );

		$approved_only_phrase = sprintf(
			/* translators: %s: Number of reviews. */
			_n( '%s review', '%s reviews', $approved_review_count, 'woocommerce' ),
			$approved_reviews_number
		);

		$approved_phrase = sprintf(
			/* translators: %s: Number of reviews. */
			_n( '%s approved review', '%s approved reviews', $approved_review_count, 'woocommerce' ),
			$approved_reviews_number
		);

		$pending_phrase = sprintf(
			/* translators: %s: Number of reviews. */
			_n( '%s pending review', '%s pending reviews', $pending_comments, 'woocommerce' ),
			$pending_reviews_number
		);

		if ( ! $approved_review_count && ! $pending_comments ) {
			// No reviews at all.
			printf(
				'<span aria-hidden="true">&#8212;</span><span class="screen-reader-text">%s</span>',
				esc_html__( 'No reviews', 'woocommerce' )
			);
		} elseif ( $approved_review_count && 'trash' === get_post_status( $post_id ) ) {
			// Don't link the comment bubble for a trashed product.
			printf(
				'<span class="post-com-count post-com-count-approved"><span class="comment-count-approved" aria-hidden="true">%s</span><span class="screen-reader-text">%s</span></span>',
				esc_html( $approved_reviews_number ),
				$pending_comments ? esc_html( $approved_phrase ) : esc_html( $approved_only_phrase )
			);
		} elseif ( $approved_review_count ) {
			// Link the comment bubble to approved reviews.
			printf(
				'<a href="%s" class="post-com-count post-com-count-approved"><span class="comment-count-approved" aria-hidden="true">%s</span><span class="screen-reader-text">%s</span></a>',
				esc_url(
					add_query_arg(
						[
							'product_id'     => urlencode( $post_id ),
							'comment_status' => 'approved',
						],
						Reviews::get_reviews_page_url()
					)
				),
				esc_html( $approved_reviews_number ),
				$pending_comments ? esc_html( $approved_phrase ) : esc_html( $approved_only_phrase )
			);
		} else {
			// Don't link the comment bubble when there are no approved reviews.
			printf(
				'<span class="post-com-count post-com-count-no-comments"><span class="comment-count comment-count-no-comments" aria-hidden="true">%s</span><span class="screen-reader-text">%s</span></span>',
				esc_html( $approved_reviews_number ),
				$pending_comments ? esc_html__( 'No approved reviews', 'woocommerce' ) : esc_html__( 'No reviews', 'woocommerce' )
			);
		}

		if ( $pending_comments ) {
			printf(
				'<a href="%s" class="post-com-count post-com-count-pending"><span class="comment-count-pending" aria-hidden="true">%s</span><span class="screen-reader-text">%s</span></a>',
				esc_url(
					add_query_arg(
						[
							'product_id'     => urlencode( $post_id ),
							'comment_status' => 'moderated',
						],
						Reviews::get_reviews_page_url()
					)
				),
				esc_html( $pending_reviews_number ),
				esc_html( $pending_phrase )
			);
		} else {
			printf(
				'<span class="post-com-count post-com-count-pending post-com-count-no-pending"><span class="comment-count comment-count-no-pending" aria-hidden="true">%s</span><span class="screen-reader-text">%s</span></span>',
				esc_html( $pending_reviews_number ),
				$approved_review_count ? esc_html__( 'No pending reviews', 'woocommerce' ) : esc_html__( 'No reviews', 'woocommerce' )
			);
		}
	}

}
PK     [1]&aÓ    1  Admin/ProductReviews/ReviewsCommentsOverrides.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Admin\ProductReviews;

use WP_Comment_Query;
use WP_Screen;

/**
 * Tweaks the WordPress comments page to exclude reviews.
 */
class ReviewsCommentsOverrides {

	const REVIEWS_MOVED_NOTICE_ID = 'product_reviews_moved';

	/**
	 * Constructor.
	 */
	public function __construct() {
		add_action( 'admin_notices', array( $this, 'display_notices' ) );
		add_filter( 'woocommerce_dismiss_admin_notice_capability', array( $this, 'get_dismiss_capability' ), 10, 2 );
		add_filter( 'comments_list_table_query_args', array( $this, 'exclude_reviews_from_comments' ) );
	}

	/**
	 * Renders admin notices.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function display_notices(): void {
		$screen = get_current_screen();

		if ( empty( $screen ) || $screen->base !== 'edit-comments' ) {
			return;
		}

		$this->maybe_display_reviews_moved_notice();
	}

	/**
	 * May render an admin notice informing the user that reviews were moved to a new page.
	 *
	 * @return void
	 */
	protected function maybe_display_reviews_moved_notice() : void {
		if ( $this->should_display_reviews_moved_notice() ) {
			$this->display_reviews_moved_notice();
		}
	}

	/**
	 * Checks if the admin notice informing the user that reviews were moved to a new page should be displayed.
	 *
	 * @return bool
	 */
	protected function should_display_reviews_moved_notice() : bool {
		// Do not display if the user does not have the capability  to see the new page.
		if ( ! WC()->call_function( 'current_user_can', Reviews::get_capability() ) ) {
			return false;
		}

		// Do not display if the current user has dismissed this notice.
		if ( WC()->call_function( 'get_user_meta', get_current_user_id(), 'dismissed_' . static::REVIEWS_MOVED_NOTICE_ID . '_notice', true ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Renders an admin notice informing the user that reviews were moved to a new page.
	 *
	 * @return void
	 */
	protected function display_reviews_moved_notice() : void {
		?>
		<div class="notice notice-info is-dismissible">
			<p><strong><?php esc_html_e( 'Product reviews have moved!', 'woocommerce' ); ?></strong></p>
			<p><?php esc_html_e( 'Product reviews can now be managed from Products > Reviews.', 'woocommerce' ); ?></p>
			<p class="submit">
				<a href="<?php echo esc_url( admin_url( 'edit.php?post_type=product&page=product-reviews' ) ); ?>" class="button-primary"><?php esc_html_e( 'Visit new location', 'woocommerce' ); ?></a>
			</p>

			<form action="<?php echo esc_url( admin_url( 'edit-comments.php' ) ); ?>" method="get">
				<input type="hidden" name="wc-hide-notice" value="<?php echo esc_attr( static::REVIEWS_MOVED_NOTICE_ID ); ?>" />

				<?php if ( ! empty( $_GET['comment_status'] ) ): ?>
					<input type="hidden" name="comment_status" value="<?php echo esc_attr( $_GET['comment_status'] ); ?>" />
				<?php endif; ?>

				<?php if ( ! empty( $_GET['paged'] ) ): ?>
					<input type="hidden" name="paged" value="<?php echo esc_attr( $_GET['paged'] ); ?>" />
				<?php endif; ?>

				<?php wp_nonce_field( 'woocommerce_hide_notices_nonce', '_wc_notice_nonce' ); ?>

				<button type="submit" class="notice-dismiss">
					<span class="screen-reader-text"><?php esc_html_e( 'Dismiss this notice.', 'woocommerce' ); ?></span>
				</button>
			</form>
		</div>
		<?php
	}

	/**
	 * Gets the capability required to dismiss the notice.
	 *
	 * This is required so that users who do not have the manage_woocommerce capability (e.g. Editors) can still dismiss
	 * the notice displayed in the Comments page.
	 *
	 * @param string|mixed $default_capability The default required capability.
	 * @param string|mixed $notice_name The notice name.
	 * @return string
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function get_dismiss_capability( $default_capability, $notice_name ) {
		return $notice_name === self::REVIEWS_MOVED_NOTICE_ID ? Reviews::get_capability() : $default_capability;
	}

	/**
	 * Excludes product reviews from showing in the comments page.
	 *
	 * @param array|mixed $args {@see WP_Comment_Query} query args.
	 * @return array
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function exclude_reviews_from_comments( $args ): array {
		$screen = get_current_screen();

		// We only wish to intervene if the edit comments screen has been requested.
		if ( ! $screen instanceof WP_Screen || 'edit-comments' !== $screen->id ) {
			return $args;
		}

		if ( ! empty( $args['post_type'] ) && $args['post_type'] !== 'any' ) {
			$post_types = (array) $args['post_type'];
		} else {
			$post_types = get_post_types();
		}

		$index = array_search( 'product', $post_types );

		if ( $index !== false ) {
			unset( $post_types[ $index ] );
		}

		if ( ! is_array( $args ) ) {
			$args = [];
		}

		$args['post_type'] = $post_types;

		return $args;
	}

}
PK     [1]Y%aS  aS     Admin/ProductReviews/Reviews.phpnu         <?php
/**
 * Products > Reviews
 */

namespace Automattic\WooCommerce\Internal\Admin\ProductReviews;

use WP_Ajax_Response;
use WP_Comment;
use WP_Screen;

/**
 * Handles backend logic for the Reviews component.
 */
class Reviews {

	/**
	 * Admin page identifier.
	 */
	const MENU_SLUG = 'product-reviews';

	/**
	 * Reviews page hook name.
	 *
	 * @var string|null
	 */
	protected $reviews_page_hook = null;

	/**
	 * Reviews list table instance.
	 *
	 * @var ReviewsListTable|null
	 */
	protected $reviews_list_table;

	/**
	 * Constructor.
	 */
	public function __construct() {

		add_action( 'admin_menu', array( $this, 'add_reviews_page' ) );
		add_action( 'admin_enqueue_scripts', array( $this, 'load_javascript' ) );

		// These ajax callbacks need a low priority to ensure they run before their WordPress core counterparts.
		add_action( 'wp_ajax_edit-comment', array( $this, 'handle_edit_review' ), -1 );
		add_action( 'wp_ajax_replyto-comment', array( $this, 'handle_reply_to_review' ), -1 );

		add_filter( 'parent_file', array( $this, 'edit_review_parent_file' ) );
		add_action( 'admin_notices', array( $this, 'display_notices' ) );
	}

	/**
	 * Gets the required capability to access the reviews page and manage product reviews.
	 *
	 * @param string $context The context for which the capability is needed (e.g. `view` or `moderate`).
	 * @return string
	 */
	public static function get_capability( string $context = 'view' ): string {

		/**
		 * Filters whether the current user can manage product reviews.
		 *
		 * This is aligned to {@see \wc_rest_check_product_reviews_permissions()}
		 *
		 * @since 6.7.0
		 *
		 * @param string $capability The capability (defaults to `moderate_comments` for viewing and `edit_products` for editing).
		 * @param string $context    The context for which the capability is needed.
		 */
		return (string) apply_filters( 'woocommerce_product_reviews_page_capability', 'view' === $context ? 'moderate_comments' : 'edit_products', $context );
	}

	/**
	 * Registers the Product Reviews submenu page.
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function add_reviews_page(): void {

		$this->reviews_page_hook = add_submenu_page(
			'edit.php?post_type=product',
			__( 'Reviews', 'woocommerce' ),
			__( 'Reviews', 'woocommerce' ) . $this->get_pending_count_bubble(),
			static::get_capability(),
			static::MENU_SLUG,
			array( $this, 'render_reviews_list_table' )
		);

		add_action( "load-{$this->reviews_page_hook}", array( $this, 'load_reviews_screen' ) );
	}

	/**
	 * Retrieves the URL to the product reviews page.
	 *
	 * @return string
	 */
	public static function get_reviews_page_url(): string {
		return add_query_arg(
			array(
				'post_type' => 'product',
				'page'      => static::MENU_SLUG,
			),
			admin_url( 'edit.php' )
		);
	}

	/**
	 * Determines whether the current page is the reviews page.
	 *
	 * @global WP_Screen $current_screen
	 *
	 * @return bool
	 */
	public function is_reviews_page(): bool {
		global $current_screen;

		return isset( $current_screen->base ) && 'product_page_' . static::MENU_SLUG === $current_screen->base;
	}

	/**
	 * Loads the JavaScript required for inline replies and quick edit.
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function load_javascript(): void {
		if ( $this->is_reviews_page() ) {
			wp_enqueue_script( 'admin-comments' );
			enqueue_comment_hotkeys_js();
		}
	}

	// phpcs:disable Universal.NamingConventions.NoReservedKeywordParameterNames.objectFound

	/**
	 * Determines if the object is a review or a reply to a review.
	 *
	 * @param WP_Comment|mixed $object Object to check.
	 * @return bool
	 */
	protected function is_review_or_reply( $object ): bool {

		$is_review_or_reply = $object instanceof WP_Comment && in_array( $object->comment_type, array( 'review', 'comment' ), true ) && get_post_type( $object->comment_post_ID ) === 'product';

		/**
		 * Filters whether the object is a review or a reply to a review.
		 *
		 * @since 6.7.0
		 *
		 * @param bool             $is_review_or_reply Whether the object in context is a review or a reply to a review.
		 * @param WP_Comment|mixed $object             The object in context.
		 */
		return (bool) apply_filters( 'woocommerce_product_reviews_is_product_review_or_reply', $is_review_or_reply, $object );
	}

	// phpcs:enable Universal.NamingConventions.NoReservedKeywordParameterNames.objectFound

	/**
	 * Ajax callback for editing a review.
	 *
	 * This functionality is taken from {@see wp_ajax_edit_comment()} and is largely copy and pasted. The only thing
	 * we want to change is the review row HTML in the response. WordPress core uses a comment list table and we need
	 * to use our own {@see ReviewsListTable} class to support our custom columns.
	 *
	 * This ajax callback is registered with a lower priority than WordPress core's so that our code can run
	 * first. If the supplied comment ID is not a review or a reply to a review, then we `return` early from this method
	 * to allow the WordPress core callback to take over.
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function handle_edit_review(): void {
		// Don't interfere with comment functionality relating to the reviews meta box within the product editor.
		if ( sanitize_text_field( wp_unslash( $_POST['mode'] ?? '' ) ) === 'single' ) {
			return;
		}

		check_ajax_referer( 'replyto-comment', '_ajax_nonce-replyto-comment' );

		$comment_id = isset( $_POST['comment_ID'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['comment_ID'] ) ) : 0;

		if ( empty( $comment_id ) || ! current_user_can( 'edit_comment', $comment_id ) ) {
			wp_die( -1 );
		}

		$review = get_comment( $comment_id );

		// Bail silently if this is not a review, or a reply to a review. That allows `wp_ajax_edit_comment()` to handle any further actions.
		if ( ! $this->is_review_or_reply( $review ) ) {
			return;
		}

		if ( empty( $review->comment_ID ) ) {
			wp_die( -1 );
		}

		if ( empty( $_POST['content'] ) ) {
			wp_die( esc_html__( 'Error: Please type your review text.', 'woocommerce' ) );
		}

		if ( isset( $_POST['status'] ) ) {
			$_POST['comment_status'] = sanitize_text_field( wp_unslash( $_POST['status'] ) );
		}

		$updated = edit_comment();
		if ( is_wp_error( $updated ) ) {
			wp_die( esc_html( $updated->get_error_message() ) );
		}

		$position      = isset( $_POST['position'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['position'] ) ) : -1;
		$wp_list_table = $this->make_reviews_list_table();

		ob_start();
		$wp_list_table->single_row( $review );
		$review_list_item = ob_get_clean();

		$x = new WP_Ajax_Response();

		$x->add(
			array(
				'what'     => 'edit_comment',
				'id'       => $review->comment_ID,
				'data'     => $review_list_item,
				'position' => $position,
			)
		);

		$x->send();
	}

	/**
	 * Ajax callback for replying to a review inline.
	 *
	 * This functionality is taken from {@see wp_ajax_replyto_comment()} and is largely copy and pasted. The only thing
	 * we want to change is the review row HTML in the response. WordPress core uses a comment list table and we need
	 * to use our own {@see ReviewsListTable} class to support our custom columns.
	 *
	 * This ajax callback is registered with a lower priority than WordPress core's so that our code can run
	 * first. If the supplied comment ID is not a review or a reply to a review, then we `return` early from this method
	 * to allow the WordPress core callback to take over.
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function handle_reply_to_review(): void {
		// Don't interfere with comment functionality relating to the reviews meta box within the product editor.
		if ( sanitize_text_field( wp_unslash( $_POST['mode'] ?? '' ) ) === 'single' ) {
			return;
		}

		check_ajax_referer( 'replyto-comment', '_ajax_nonce-replyto-comment' );

		$comment_post_ID = isset( $_POST['comment_post_ID'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['comment_post_ID'] ) ) : 0; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
		$post            = get_post( $comment_post_ID ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

		if ( ! $post ) {
			wp_die( -1 );
		}

		// Inline Review replies will use the `detail` mode. If that's not what we have, then let WordPress core take over.
		if ( isset( $_REQUEST['mode'] ) && 'dashboard' === $_REQUEST['mode'] ) {
			return;
		}

		// If this is not a a reply to a review, bail silently to let WordPress core take over.
		if ( get_post_type( $post ) !== 'product' ) {
			return;
		}

		if ( ! current_user_can( 'edit_post', $comment_post_ID ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
			wp_die( -1 );
		}

		if ( empty( $post->post_status ) ) {
			wp_die( 1 );
		} elseif ( in_array( $post->post_status, array( 'draft', 'pending', 'trash' ), true ) ) {
			wp_die( esc_html__( 'Error: You can\'t reply to a review on a draft product.', 'woocommerce' ) );
		}

		$user = wp_get_current_user();

		if ( $user->exists() ) {
			$user_ID              = $user->ID;
			$comment_author       = wp_slash( $user->display_name );
			$comment_author_email = wp_slash( $user->user_email );
			$comment_author_url   = wp_slash( $user->user_url );
			// WordPress core already sanitizes `content` during the `pre_comment_content` hook, which is why it's not needed here, {@see wp_filter_comment()} and {@see kses_init_filters()}.
			$comment_content = isset( $_POST['content'] ) ? wp_unslash( $_POST['content'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
			$comment_type    = isset( $_POST['comment_type'] ) ? sanitize_text_field( wp_unslash( $_POST['comment_type'] ) ) : 'comment';

			if ( current_user_can( 'unfiltered_html' ) ) {
				if ( ! isset( $_POST['_wp_unfiltered_html_comment'] ) ) {
					$_POST['_wp_unfiltered_html_comment'] = '';
				}

				if ( wp_create_nonce( 'unfiltered-html-comment' ) !== $_POST['_wp_unfiltered_html_comment'] ) {
					kses_remove_filters(); // Start with a clean slate.
					kses_init_filters();   // Set up the filters.
					remove_filter( 'pre_comment_content', 'wp_filter_post_kses' );
					add_filter( 'pre_comment_content', 'wp_filter_kses' );
				}
			}
		} else {
			wp_die( esc_html__( 'Sorry, you must be logged in to reply to a review.', 'woocommerce' ) );
		}

		if ( '' === $comment_content ) {
			wp_die( esc_html__( 'Error: Please type your reply text.', 'woocommerce' ) );
		}

		$comment_parent = 0;

		if ( isset( $_POST['comment_ID'] ) ) {
			$comment_parent = absint( wp_unslash( $_POST['comment_ID'] ) );
		}

		$comment_auto_approved = false;
		$commentdata           = compact( 'comment_post_ID', 'comment_author', 'comment_author_email', 'comment_author_url', 'comment_content', 'comment_type', 'comment_parent', 'user_ID' );

		// Automatically approve parent comment.
		if ( ! empty( $_POST['approve_parent'] ) ) {
			$parent = get_comment( $comment_parent );

			if ( $parent && '0' === $parent->comment_approved && $parent->comment_post_ID === $comment_post_ID ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
				if ( ! current_user_can( 'edit_comment', $parent->comment_ID ) ) {
					wp_die( -1 );
				}

				if ( wp_set_comment_status( $parent, 'approve' ) ) {
					$comment_auto_approved = true;
				}
			}
		}

		$comment_id = wp_new_comment( $commentdata );

		if ( is_wp_error( $comment_id ) ) {
			wp_die( esc_html( $comment_id->get_error_message() ) );
		}

		$comment = get_comment( $comment_id );

		if ( ! $comment ) {
			wp_die( 1 );
		}

		$position = ( isset( $_POST['position'] ) && (int) $_POST['position'] ) ? (int) $_POST['position'] : '-1';

		ob_start();
		$wp_list_table = $this->make_reviews_list_table();
		$wp_list_table->single_row( $comment );
		$comment_list_item = ob_get_clean();

		$response = array(
			'what'     => 'comment',
			'id'       => $comment->comment_ID,
			'data'     => $comment_list_item,
			'position' => $position,
		);

		$counts                   = wp_count_comments();
		$response['supplemental'] = array(
			'in_moderation'        => $counts->moderated,
			'i18n_comments_text'   => sprintf(
			/* translators: %s: Number of reviews. */
				_n( '%s Review', '%s Reviews', $counts->approved, 'woocommerce' ),
				number_format_i18n( $counts->approved )
			),
			'i18n_moderation_text' => sprintf(
			/* translators: %s: Number of reviews. */
				_n( '%s Review in moderation', '%s Reviews in moderation', $counts->moderated, 'woocommerce' ),
				number_format_i18n( $counts->moderated )
			),
		);

		if ( $comment_auto_approved && isset( $parent ) ) {
			$response['supplemental']['parent_approved'] = $parent->comment_ID;
			$response['supplemental']['parent_post_id']  = $parent->comment_post_ID;
		}

		$x = new WP_Ajax_Response();
		$x->add( $response );
		$x->send();
	}

	/**
	 * Displays notices on the Reviews page.
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function display_notices(): void {
		if ( $this->is_reviews_page() ) {
			$this->maybe_display_reviews_bulk_action_notice();
		}
	}

	/**
	 * May display the bulk action admin notice.
	 *
	 * @return void
	 */
	protected function maybe_display_reviews_bulk_action_notice(): void {

		$messages = $this->get_bulk_action_notice_messages();

		echo ! empty( $messages ) ? '<div id="moderated" class="updated"><p>' . implode( "<br/>\n", $messages ) . '</p></div>' : '';  // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	}

	/**
	 * Gets the applicable bulk action admin notice messages.
	 *
	 * @return array
	 */
	protected function get_bulk_action_notice_messages(): array {

		$approved   = isset( $_REQUEST['approved'] ) ? (int) $_REQUEST['approved'] : 0;
		$unapproved = isset( $_REQUEST['unapproved'] ) ? (int) $_REQUEST['unapproved'] : 0;
		$deleted    = isset( $_REQUEST['deleted'] ) ? (int) $_REQUEST['deleted'] : 0;
		$trashed    = isset( $_REQUEST['trashed'] ) ? (int) $_REQUEST['trashed'] : 0;
		$untrashed  = isset( $_REQUEST['untrashed'] ) ? (int) $_REQUEST['untrashed'] : 0;
		$spammed    = isset( $_REQUEST['spammed'] ) ? (int) $_REQUEST['spammed'] : 0;
		$unspammed  = isset( $_REQUEST['unspammed'] ) ? (int) $_REQUEST['unspammed'] : 0;

		$messages = array();

		if ( $approved > 0 ) {
			/* translators: %s is an integer higher than 0 (1, 2, 3...) */
			$messages[] = sprintf( _n( '%s review approved', '%s reviews approved', $approved, 'woocommerce' ), $approved );
		}

		if ( $unapproved > 0 ) {
			/* translators: %s is an integer higher than 0 (1, 2, 3...) */
			$messages[] = sprintf( _n( '%s review unapproved', '%s reviews unapproved', $unapproved, 'woocommerce' ), $unapproved );
		}

		if ( $spammed > 0 ) {
			$ids = isset( $_REQUEST['ids'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['ids'] ) ) : 0;
			/* translators: %s is an integer higher than 0 (1, 2, 3...) */
			$messages[] = sprintf( _n( '%s review marked as spam.', '%s reviews marked as spam.', $spammed, 'woocommerce' ), $spammed ) . ' <a href="' . esc_url( wp_nonce_url( "edit-comments.php?doaction=undo&action=unspam&ids=$ids", 'bulk-comments' ) ) . '">' . __( 'Undo', 'woocommerce' ) . '</a><br />';
		}

		if ( $unspammed > 0 ) {
			/* translators: %s is an integer higher than 0 (1, 2, 3...) */
			$messages[] = sprintf( _n( '%s review restored from the spam', '%s reviews restored from the spam', $unspammed, 'woocommerce' ), $unspammed );
		}

		if ( $trashed > 0 ) {
			$ids = isset( $_REQUEST['ids'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['ids'] ) ) : 0;
			/* translators: %s is an integer higher than 0 (1, 2, 3...) */
			$messages[] = sprintf( _n( '%s review moved to the Trash.', '%s reviews moved to the Trash.', $trashed, 'woocommerce' ), $trashed ) . ' <a href="' . esc_url( wp_nonce_url( "edit-comments.php?doaction=undo&action=untrash&ids=$ids", 'bulk-comments' ) ) . '">' . __( 'Undo', 'woocommerce' ) . '</a><br />';
		}

		if ( $untrashed > 0 ) {
			/* translators: %s is an integer higher than 0 (1, 2, 3...) */
			$messages[] = sprintf( _n( '%s review restored from the Trash', '%s reviews restored from the Trash', $untrashed, 'woocommerce' ), $untrashed );
		}

		if ( $deleted > 0 ) {
			/* translators: %s is an integer higher than 0 (1, 2, 3...) */
			$messages[] = sprintf( _n( '%s review permanently deleted', '%s reviews permanently deleted', $deleted, 'woocommerce' ), $deleted );
		}

		return $messages;
	}

	/**
	 * Counts the number of pending product reviews/replies, and returns the notification bubble if there's more than zero.
	 *
	 * @return string Empty string if there are no pending reviews, or bubble HTML if there are.
	 */
	protected function get_pending_count_bubble(): string {
		// Quirks related to https://github.com/woocommerce/woocommerce/issues/37464.
		if ( method_exists( \WC_Comments::class, 'get_products_reviews_pending_moderation_counter' ) ) {
			$count = \WC_Comments::get_products_reviews_pending_moderation_counter();
		} else {
			$count = (int) get_comments(
				array(
					'type__in'  => array( 'review', 'comment' ),
					'status'    => '0',
					'post_type' => 'product',
					'count'     => true,
				)
			);
		}

		/**
		 * Provides an opportunity to alter the pending comment count used within
		 * the product reviews admin list table.
		 *
		 * @since 7.0.0
		 *
		 * @param array $count Current count of comments pending review.
		 */
		$count = apply_filters( 'woocommerce_product_reviews_pending_count', $count );

		if ( empty( $count ) ) {
			return '';
		}

		return ' <span class="awaiting-mod count-' . esc_attr( $count ) . '"><span class="pending-count">' . esc_html( $count ) . '</span></span>';
	}

	/**
	 * Highlights Product -> Reviews admin menu item when editing a review or a reply to a review.
	 *
	 * @global string $submenu_file
	 *
	 * @param string|mixed $parent_file Parent menu item.
	 * @return string
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function edit_review_parent_file( $parent_file ) {
		global $submenu_file, $current_screen;

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( isset( $current_screen->id, $_GET['c'] ) && 'comment' === $current_screen->id ) {

			$comment_id = absint( $_GET['c'] );
			$comment    = get_comment( $comment_id );

			if ( isset( $comment->comment_parent ) && $comment->comment_parent > 0 ) {
				$comment = get_comment( $comment->comment_parent );
			}

			if ( isset( $comment->comment_post_ID ) && get_post_type( $comment->comment_post_ID ) === 'product' ) {
				$parent_file  = 'edit.php?post_type=product';
				$submenu_file = 'product-reviews'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
			}
		}

		return $parent_file;
	}

	/**
	 * Returns a new instance of `ReviewsListTable`, with the screen argument specified.
	 *
	 * @return ReviewsListTable
	 */
	protected function make_reviews_list_table(): ReviewsListTable {
		return new ReviewsListTable( array( 'screen' => $this->reviews_page_hook ? $this->reviews_page_hook : 'product_page_product-reviews' ) );
	}

	/**
	 * Initializes the list table.
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function load_reviews_screen(): void {
		$this->reviews_list_table = $this->make_reviews_list_table();
		$this->reviews_list_table->process_bulk_action();
	}

	/**
	 * Renders the Reviews page.
	 *
	 * @return void
	 */
	public function render_reviews_list_table(): void {

		$this->reviews_list_table->prepare_items();

		ob_start();

		?>
		<div class="wrap">
			<h2><?php echo esc_html( get_admin_page_title() ); ?></h2>

			<?php $this->reviews_list_table->views(); ?>

			<form id="reviews-filter" method="get">
				<?php $page = isset( $_REQUEST['page'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['page'] ) ) : static::MENU_SLUG; ?>

				<input type="hidden" name="page" value="<?php echo esc_attr( $page ); ?>" />
				<input type="hidden" name="post_type" value="product" />
				<input type="hidden" name="pagegen_timestamp" value="<?php echo esc_attr( current_time( 'mysql', true ) ); ?>" />

				<?php $this->reviews_list_table->search_box( __( 'Search Reviews', 'woocommerce' ), 'reviews' ); ?>

				<?php $this->reviews_list_table->display(); ?>
			</form>
		</div>
		<?php
		wp_comment_reply( '-1', true, 'detail' );
		wp_comment_trashnotice();

		/**
		 * Filters the contents of the product reviews list table output.
		 *
		 * @since 6.7.0
		 *
		 * @param string           $output             The HTML output of the list table.
		 * @param ReviewsListTable $reviews_list_table The reviews list table instance.
		 */
		echo apply_filters( 'woocommerce_product_reviews_list_table', ob_get_clean(), $this->reviews_list_table ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	}
}
PK     [1]N}    $  Admin/ProductReviews/ReviewsUtil.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Admin\ProductReviews;

/**
 * A utility class for handling comments that are product reviews.
 */
class ReviewsUtil {

	/**
	 * Modifies the moderation URLs in the email notifications for product reviews.
	 *
	 * @param string $message The email notification message.
	 * @param int    $comment_id The comment ID.
	 * @return string The modified email notification message.
	 */
	public static function modify_product_review_moderation_urls( $message, $comment_id ) {
		$comment = get_comment( $comment_id );

		// Only modify URLs for product reviews.
		if ( ! $comment || get_post_type( $comment->comment_post_ID ) !== 'product' ) {
			return $message;
		}

		// Replace the WordPress comment moderation URLs with WooCommerce product review URLs.
		$product_reviews_url = admin_url( 'edit.php?post_type=product&page=product-reviews' );

		// Replace the moderation panel URL (this is the "show all reviews pending" link).
		$message = str_replace(
			admin_url( 'edit-comments.php?comment_status=moderated#wpbody-content' ),
			$product_reviews_url . '&comment_status=moderated',
			$message
		);

		return $message;
	}

	/**
	 * Removes product reviews from the edit-comments page to fix the "Mine" tab counter.
	 *
	 * @param array|mixed       $clauses A compacted array of comment query clauses.
	 * @param \WP_Comment_Query $comment_query The WP_Comment_Query instance being filtered.
	 *
	 * @return array|mixed
	 */
	public static function comments_clauses_without_product_reviews( $clauses, $comment_query ) {
		global $wpdb;

		if ( ! empty( $comment_query->query_vars['post_type'] ) ) {
			$post_type = $comment_query->query_vars['post_type'];
			if ( ! is_array( $post_type ) ) {
				$post_type = explode( ',', $post_type );
			}
			if ( in_array( 'product', $post_type, true ) ) {
				return $clauses;
			}
		}

		/**
		 * Any comment queries with these values are likely to be custom handling where we don't want to change default behavior.
		 * This may change for the `type` query vars in the future if we break out review replies as their own type.
		 */
		foreach ( array( 'ID', 'parent', 'parent__in', 'post_author__in', 'post_author', 'post_name', 'type', 'type__in', 'type__not_in', 'post_type__in', 'comment__in', 'comment__not_in' ) as $arg ) {
			if ( ! empty( $comment_query->query_vars[ $arg ] ) ) {
				return $clauses;
			}
		}

		if ( ! empty( $comment_query->query_vars['post_id'] ) && absint( $comment_query->query_vars['post_id'] ) > 0 ) {
			if ( 'product' === get_post_type( absint( $comment_query->query_vars['post_id'] ) ) ) {
				return $clauses;
			}
		}

		if ( ! empty( $comment_query->query_vars['post__in'] ) ) {
			$post_ids = wp_parse_id_list( $comment_query->query_vars['post__in'] );
			_prime_post_caches( $post_ids, false, false );
			foreach ( $post_ids as $post_id ) {
				if ( 'product' === get_post_type( $post_id ) ) {
					return $clauses;
				}
			}
		}

		$clauses['join']  .= " LEFT JOIN {$wpdb->posts} AS wp_posts_to_exclude_reviews ON comment_post_ID = wp_posts_to_exclude_reviews.ID ";
		$clauses['where'] .= ( trim( $clauses['where'] ) ? ' AND ' : '' ) . " wp_posts_to_exclude_reviews.post_type NOT IN ('product') ";

		return $clauses;
	}
}
PK     [1]z`£P  P    Admin/ActivityPanels.phpnu         <?php
/**
 * WooCommerce Activity Panel.
 */

namespace Automattic\WooCommerce\Internal\Admin;

use Automattic\WooCommerce\Admin\Notes\Notes;

/**
 * Contains backend logic for the activity panel feature.
 */
class ActivityPanels {
	/**
	 * Class instance.
	 *
	 * @var ActivityPanels instance
	 */
	protected static $instance = null;

	/**
	 * Get class instance.
	 */
	public static function get_instance() {
		if ( ! self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Hook into WooCommerce.
	 */
	public function __construct() {
		add_filter( 'woocommerce_admin_get_user_data_fields', array( $this, 'add_user_data_fields' ) );
		// Run after Automattic\WooCommerce\Internal\Admin\Loader.
		add_filter( 'woocommerce_components_settings', array( $this, 'component_settings' ), 20 );
		// New settings injection.
		add_filter( 'woocommerce_admin_shared_settings', array( $this, 'component_settings' ), 20 );
	}

	/**
	 * Adds fields so that we can store activity panel last read and open times.
	 *
	 * @param array $user_data_fields User data fields.
	 * @return array
	 */
	public function add_user_data_fields( $user_data_fields ) {
		return array_merge(
			$user_data_fields,
			array(
				'activity_panel_inbox_last_read',
				'activity_panel_reviews_last_read',
			)
		);
	}

	/**
	 * Add alert count to the component settings.
	 *
	 * @param array $settings Component settings.
	 */
	public function component_settings( $settings ) {
		$settings['alertCount'] = Notes::get_notes_count( array( 'error', 'update' ), array( 'unactioned' ) );
		return $settings;
	}
}
PK     [1]ѓg        Admin/Survey.phpnu         <?php
/**
 * Survey helper methods.
 */

namespace Automattic\WooCommerce\Internal\Admin;

defined( 'ABSPATH' ) || exit;

/**
 * Survey Class.
 */
class Survey {
	/**
	 * Survey URL.
	 */
	const SURVEY_URL = 'https://automattic.survey.fm';

	/**
	 * Get a survey's URL from a path.
	 *
	 * @param  string $path Path of the survey.
	 * @param  array  $query Query arguments as key value pairs.
	 * @return string Full URL to survey.
	 */
	public static function get_url( $path, $query = array() ) {
		$url = self::SURVEY_URL . $path;

		$query_args = apply_filters( 'woocommerce_admin_survey_query', $query );

		if ( ! empty( $query_args ) ) {
			$query_string = http_build_query( $query_args );
			$url          = $url . '?' . $query_string;
		}

		return $url;
	}
}
PK     [1]fT      Admin/FeaturePlugin.phpnu         <?php
/**
 * WooCommerce Admin: Feature plugin main class.
 */

namespace Automattic\WooCommerce\Internal\Admin;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\API;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Internal\Admin\Notes\OrderMilestones;
use Automattic\WooCommerce\Internal\Admin\Notes\WooSubscriptionsNotes;
use Automattic\WooCommerce\Internal\Admin\Notes\TrackingOptIn;
use Automattic\WooCommerce\Internal\Admin\Notes\WooCommercePayments;
use Automattic\WooCommerce\Internal\Admin\Notes\InstallJPAndWCSPlugins;
use Automattic\WooCommerce\Internal\Admin\Notes\SellingOnlineCourses;
use Automattic\WooCommerce\Internal\Admin\Notes\MagentoMigration;
use Automattic\WooCommerce\Internal\Admin\Notes\ScheduledUpdatesPromotion;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Admin\PluginsInstaller;
use Automattic\WooCommerce\Admin\ReportExporter;
use Automattic\WooCommerce\Admin\ReportsSync;
use Automattic\WooCommerce\Internal\Admin\CategoryLookup;
use Automattic\WooCommerce\Internal\Admin\Events;
use Automattic\WooCommerce\Internal\Admin\Onboarding\Onboarding;

/**
 * Feature plugin main class.
 *
 * @internal This file will not be bundled with woo core, only the feature plugin.
 * @internal Note this is not called WC_Admin due to a class already existing in core with that name.
 */
class FeaturePlugin {
	/**
	 * The single instance of the class.
	 *
	 * @var object
	 */
	protected static $instance = null;

	/**
	 * Indicates if init has been invoked already.
	 *
	 * @var bool
	 */
	private bool $initialized = false;

	/**
	 * Constructor
	 *
	 * @return void
	 */
	protected function __construct() {}

	/**
	 * Get class instance.
	 *
	 * @return object Instance.
	 */
	final public static function instance() {
		if ( null === static::$instance ) {
			static::$instance = new static();
		}
		return static::$instance;
	}

	/**
	 * Init the feature plugin, only if we can detect both Gutenberg and WooCommerce.
	 */
	public function init() {
		// Bail if WC isn't initialized (This can be called from WCAdmin's entrypoint).
		if ( ! defined( 'WC_ABSPATH' ) ) {
			return;
		}

		if ( $this->initialized ) {
			return;
		}
		$this->initialized = true;

		// Load the page controller functions file first to prevent fatal errors when disabling WooCommerce Admin.
		$this->define_constants();
		require_once WC_ADMIN_ABSPATH . '/includes/react-admin/page-controller-functions.php';
		require_once WC_ADMIN_ABSPATH . '/src/Admin/Notes/DeprecatedNotes.php';
		require_once WC_ADMIN_ABSPATH . '/includes/react-admin/core-functions.php';
		require_once WC_ADMIN_ABSPATH . '/includes/react-admin/feature-config.php';
		require_once WC_ADMIN_ABSPATH . '/includes/react-admin/wc-admin-update-functions.php';
		require_once WC_ADMIN_ABSPATH . '/includes/react-admin/class-experimental-abtest.php';

		if ( did_action( 'plugins_loaded' ) ) {
			self::on_plugins_loaded();
		} else {
			// Make sure we hook into `plugins_loaded` before core's Automattic\WooCommerce\Package::init().
			// If core is network activated but we aren't, the packaged version of WooCommerce Admin will
			// attempt to use a data store that hasn't been loaded yet - because we've defined our constants here.
			// See: https://github.com/woocommerce/woocommerce-admin/issues/3869.
			add_action( 'plugins_loaded', array( $this, 'on_plugins_loaded' ), 9 );
		}
	}

	/**
	 * Setup plugin once all other plugins are loaded.
	 *
	 * @return void
	 */
	public function on_plugins_loaded() {
		$this->hooks();
		$this->includes();
	}

	/**
	 * Define Constants.
	 */
	protected function define_constants() {
		$this->define( 'WC_ADMIN_APP', 'wc-admin-app' );
		$this->define( 'WC_ADMIN_ABSPATH', WC_ABSPATH );
		$this->define( 'WC_ADMIN_DIST_JS_FOLDER', 'assets/client/admin/' );
		$this->define( 'WC_ADMIN_DIST_CSS_FOLDER', 'assets/client/admin/' );
		$this->define( 'WC_ADMIN_PLUGIN_FILE', WC_PLUGIN_FILE );

		/**
		 * Define the WC Admin Images Folder URL.
		 *
		 * @deprecated 6.7.0
		 * @var string
		 */
		if ( ! defined( 'WC_ADMIN_IMAGES_FOLDER_URL' ) ) {
			/**
			 * Define the WC Admin Images Folder URL.
			 *
			 * @deprecated 6.7.0
			 * @var string
			 */
			define( 'WC_ADMIN_IMAGES_FOLDER_URL', plugins_url( 'assets/images', WC_PLUGIN_FILE ) );
		}

		/**
		 * Define the current WC Admin version.
		 *
		 * @deprecated 6.4.0
		 * @var string
		 */
		if ( ! defined( 'WC_ADMIN_VERSION_NUMBER' ) ) {
			/**
			 * Define the current WC Admin version.
			 *
			 * @deprecated 6.4.0
			 * @var string
			 */
			define( 'WC_ADMIN_VERSION_NUMBER', '3.3.0' );
		}
	}

	/**
	 * Include WC Admin classes.
	 */
	public function includes() {
		// Initialize Database updates, option migrations, and Notes.
		Events::instance()->init();
		Notes::init();

		// Initialize Plugins Installer.
		PluginsInstaller::init();
		PluginsHelper::init();

		// Initialize API.
		API\Init::instance();

		if ( Features::is_enabled( 'onboarding' ) ) {
			Onboarding::init();
		}

		if ( Features::is_enabled( 'analytics' ) ) {
			// Initialize Reports syncing.
			ReportsSync::init();
			CategoryLookup::instance()->init();

			// Initialize Reports exporter.
			ReportExporter::init();
		}

		// Admin note providers.
		// @todo These should be bundled in the features/ folder, but loading them from there currently has a load order issue.
		new WooSubscriptionsNotes();
		new OrderMilestones();
		new TrackingOptIn();
		new WooCommercePayments();
		new InstallJPAndWCSPlugins();
		new SellingOnlineCourses();
		new MagentoMigration();
		new ScheduledUpdatesPromotion();
	}

	/**
	 * Set up our admin hooks and plugin loader.
	 */
	protected function hooks() {
		add_filter( 'woocommerce_admin_features', array( $this, 'replace_supported_features' ), 0 );

		Loader::get_instance();
		WCAdminAssets::get_instance();
	}


	/**
	 * Overwrites the allowed features array using a local `feature-config.php` file.
	 *
	 * @param array $features Array of feature slugs.
	 */
	public function replace_supported_features( $features ) {
		/**
		 * Get additional feature config
		 *
		 * @since 6.5.0
		 */
		$feature_config = apply_filters( 'woocommerce_admin_get_feature_config', wc_admin_get_feature_config() );
		$features       = array_keys( array_filter( $feature_config ) );
		return $features;
	}

	/**
	 * Define constant if not already set.
	 *
	 * @param string      $name  Constant name.
	 * @param string|bool $value Constant value.
	 */
	protected function define( $name, $value ) {
		if ( ! defined( $name ) ) {
			define( $name, $value );
		}
	}

	/**
	 * Prevent cloning.
	 */
	private function __clone() {}

	/**
	 * Prevent unserializing.
	 */
	public function __wakeup() {
		die();
	}
}
PK     [1]1      Admin/ShippingLabelBanner.phpnu         <?php
/**
 * WooCommerce Shipping Label banner.
 */

namespace Automattic\WooCommerce\Internal\Admin;

use Automattic\Jetpack\Connection\Manager as Jetpack_Connection_Manager;
use Automattic\WooCommerce\Utilities\OrderUtil;
use function WP_CLI\Utils\get_plugin_name;

/**
 * Shows print shipping label banner on edit order page.
 */
class ShippingLabelBanner {

	/**
	 * Singleton for the display rules class
	 *
	 * @var ShippingLabelBannerDisplayRules
	 */
	private $shipping_label_banner_display_rules;

	private const MIN_COMPATIBLE_WCST_VERSION       = '2.7.0';
	private const MIN_COMPATIBLE_WCSHIPPING_VERSION = '1.1.0';

	/**
	 * Constructor
	 */
	public function __construct() {
		if ( ! is_admin() ) {
			return;
		}
		add_action( 'add_meta_boxes', array( $this, 'add_meta_boxes' ), 6, 2 );
	}

	/**
	 * Check if WooCommerce Shipping makes sense for this merchant.
	 *
	 * @return bool
	 */
	private function should_show_meta_box() {
		if ( ! $this->shipping_label_banner_display_rules ) {
			$dotcom_connected = null;
			$wcs_version      = null;

			if ( class_exists( Jetpack_Connection_Manager::class ) ) {
				$dotcom_connected = ( new Jetpack_Connection_Manager() )->has_connected_owner();
			}

			if ( class_exists( '\Automattic\WCShipping\Utils' ) ) {
				$wcs_version = \Automattic\WCShipping\Utils::get_wcshipping_version();
			}

			$incompatible_plugins = class_exists( '\WC_Shipping_Fedex_Init' ) ||
				class_exists( '\WC_Shipping_UPS_Init' ) ||
				class_exists( '\WC_Integration_ShippingEasy' ) ||
				class_exists( '\WC_ShipStation_Integration' );

			$this->shipping_label_banner_display_rules =
				new ShippingLabelBannerDisplayRules(
					$dotcom_connected,
					$wcs_version,
					$incompatible_plugins
				);
		}

		return $this->shipping_label_banner_display_rules->should_display_banner();
	}

	/**
	 * Add metabox to order page.
	 */
	public function add_meta_boxes() {
		if ( ! OrderUtil::is_order_edit_screen() ) {
			return;
		}

		if ( $this->should_show_meta_box() ) {
			add_meta_box(
				'woocommerce-admin-print-label',
				__( 'Shipping Label', 'woocommerce' ),
				array( $this, 'meta_box' ),
				null,
				'normal',
				'high',
				array(
					'context' => 'shipping_label',
				)
			);
			add_action( 'admin_enqueue_scripts', array( $this, 'add_print_shipping_label_script' ) );
		}
	}

	/**
	 * Count shippable items
	 *
	 * @param \WC_Order $order Current order.
	 * @return int
	 */
	private function count_shippable_items( \WC_Order $order ) {
		$count = 0;
		foreach ( $order->get_items() as $item ) {
			if ( $item instanceof \WC_Order_Item_Product ) {
				$product = $item->get_product();
				if ( $product && $product->needs_shipping() ) {
					$count += $item->get_quantity();
				}
			}
		}
		return $count;
	}
	/**
	 * Adds JS to order page to render shipping banner.
	 *
	 * @param string $hook current page hook.
	 */
	public function add_print_shipping_label_script( $hook ) {
		WCAdminAssets::register_style( 'print-shipping-label-banner', 'style', array( 'wp-components' ) );
		WCAdminAssets::register_script( 'wp-admin-scripts', 'print-shipping-label-banner', true );
		$wcst_version                 = null;
		$wcshipping_installed_version = null;
		$order                        = wc_get_order();
		if ( class_exists( '\WC_Connect_Loader' ) ) {
			$wcst_version = \WC_Connect_Loader::get_wcs_version();
		}

		$wc_shipping_plugin_file = WP_PLUGIN_DIR . '/woocommerce-shipping/woocommerce-shipping.php';
		if ( file_exists( $wc_shipping_plugin_file ) ) {
			$plugin_data                  = get_plugin_data( $wc_shipping_plugin_file );
			$wcshipping_installed_version = $plugin_data['Version'];
		}

		$payload = array(
			// If WCS&T is not installed, it's considered compatible.
			'is_wcst_compatible'                   => $wcst_version ? (int) version_compare( $wcst_version, self::MIN_COMPATIBLE_WCST_VERSION, '>=' ) : 1,
			'order_id'                             => $order ? $order->get_id() : null,
			// The banner is shown if the plugin is installed but not active, so we need to check if the installed version is compatible.
			'is_incompatible_wcshipping_installed' => $wcshipping_installed_version ?
			(int) version_compare( $wcshipping_installed_version, self::MIN_COMPATIBLE_WCSHIPPING_VERSION, '<' )
			: 0,
		);

		wp_localize_script( 'wc-admin-print-shipping-label-banner', 'wcShippingCoreData', $payload );
	}

	/**
	 * Render placeholder metabox.
	 *
	 * @param \WP_Post $post current post.
	 * @param array    $args empty args.
	 */
	public function meta_box( $post, $args ) {

		?>
		<div id="wc-admin-shipping-banner-root" class="woocommerce <?php echo esc_attr( 'wc-admin-shipping-banner' ); ?>" data-args="<?php echo esc_attr( wp_json_encode( $args['args'] ) ); ?>">
		</div>
		<?php
	}
}
PK     [1]E7Yb b 2  Admin/Suggestions/PaymentsExtensionSuggestions.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Suggestions;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Internal\Admin\Settings\Payments;
use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders;
use Automattic\WooCommerce\Internal\Utilities\ArrayUtil;

/**
 * Partner payments extension suggestions provider class.
 *
 * @internal
 */
class PaymentsExtensionSuggestions {
	/*
	 * The unique IDs for the payment extension suggestions.
	 *
	 * The ID is the primary extension identifier throughout the system.
	 */
	const AIRWALLEX         = 'airwallex';
	const ANTOM             = 'antom';
	const MERCADO_PAGO      = 'mercado_pago';
	const MOLLIE            = 'mollie';
	const PAYFAST           = 'payfast';
	const PAYMOB            = 'paymob';
	const PAYPAL_FULL_STACK = 'paypal_full_stack';
	const PAYPAL_WALLET     = 'paypal_wallet';
	const PAYONEER          = 'payoneer';
	const PAYSTACK          = 'paystack';
	const PAYTRAIL          = 'paytrail';
	const PAYU_INDIA        = 'payu_india';
	const RAZORPAY          = 'razorpay';
	const SQUARE            = 'square';
	const STRIPE            = 'stripe';
	const TILOPAY           = 'tilopay';
	const VIVA_WALLET       = 'viva_wallet';
	const WOOPAYMENTS       = 'woopayments';
	const AMAZON_PAY        = 'amazon_pay';
	const AFFIRM            = 'affirm';
	const AFTERPAY          = 'afterpay';
	const CLEARPAY          = 'clearpay';
	const KLARNA            = 'klarna';
	const KLARNA_CHECKOUT   = 'klarna_checkout';
	const HELIOPAY          = 'heliopay';
	const MONEI             = 'monei';
	const COINBASE          = 'coinbase';
	const BILLIE            = 'billie';
	const BOLT              = 'bolt_checkout';
	const AUTHORIZE_NET     = 'authorize_net';
	const DEPAY             = 'depay';
	const ELAVON            = 'elavon';
	const EWAY              = 'eway';
	const FORTISPAY         = 'fortis';
	const GOCARDLESS        = 'gocardless';
	const NEXI_CHECKOUT     = 'nexi_checkout';
	const PAYPAL_ZETTLE     = 'paypal_zettle';
	const RAPYD             = 'rapyd';
	const PAYPAL_BRAINTREE  = 'paypal_braintree';
	const VISA              = 'visa_as';
	const NGENIUS           = 'ngenius';

	/*
	 * The extension types.
	 *
	 * The type is related to the extension's underlying payments methods scope and type.
	 */
	const TYPE_PSP              = 'psp'; // Payment Service Provider.
	const TYPE_APM              = 'apm'; // Alternative Payment Methods.
	const TYPE_EXPRESS_CHECKOUT = 'express_checkout';
	const TYPE_BNPL             = 'bnpl'; // Buy now, pay later.
	const TYPE_CRYPTO           = 'crypto';

	/*
	 * The extension plugin types.
	 *
	 * This will inform how we handle the extension installation and activation.
	 */
	const PLUGIN_TYPE_WPORG = 'wporg';

	/*
	 * Extension tags.
	 *
	 * These are used to categorize the extensions and provide additional information to the system.
	 * Some tags may carry special meaning and will be used to influence the suggestions' behavior.
	 */
	const TAG_PREFERRED         = 'preferred';
	const TAG_PREFERRED_OFFLINE = 'preferred_offline'; // For extensions that are preferred for offline payments.
	const TAG_MADE_IN_WOO       = 'made_in_woo'; // For extensions developed by Woo.
	const TAG_RECOMMENDED       = 'recommended'; // For extensions that should be further emphasized.

	/**
	 * The memoized extensions base details to avoid computing them multiple times during a request.
	 *
	 * @var array|null
	 */
	private ?array $extensions_base_details_memo = null;

	/**
	 * The payment extension list for each country.
	 *
	 * The order is important as it will be used to determine the priority of the suggestions.
	 *
	 * Each entry is keyed by the two-letter country code and consists of a list of payment extensions.
	 * Each payment extension can be identified by its ID (the shorthand version) or by an array with the following format:
	 * array(
	 *   'id' => 'woopayments', // This is required.
	 *   '_type' => 'provider', // Overrides the '_type' key.
	 *   // Special entry that instructs the system to append the given items to a list-type entry.
	 *   // If the original entry is not a list, we will throw an exception.
	 *   // If the original entry does not exist, we will create it.
	 *   // This is useful when you want to add tags to a suggestion's default list of tags.
	 *   '_append' => array(
	 *       'tags' => array( self::TAG_PREFERRED ),
	 *   ),
	 *   // Special entry that instructs the system to remove the given items from a list-type entry.
	 *   // If the original entry is not a list, we will throw an exception.
	 *   // If the original entry does not exist, we will ignore the instruction.
	 *   // This is useful when you want to remove tags from a suggestion's default list of tags.
	 *   '_remove' => array(
	 *       'tags' => array( self::TAG_PREFERRED ),
	 *   ),
	 *   // Special entry that instructs the system to merge a list of items based on their _type key value,
	 *   // overriding the original entry with the provided one.
	 *   // If the original entry is not a list of arrays each with a _type entry, we will throw an exception.
	 *   // If the provided entry is not a list of arrays each with a _type entry, we will throw an exception.
	 *   // If the original entry does not exist, we will create it.
	 *   // This is useful when you want to override certain default details for a particular country.
	 *   '_merge_on_type' => array(
	 *       'links' => array(
	 *           array(
	 *               _type' => self::LINK_TYPE_PRICING,
	 *               'url'  => 'https://www.example.com/pricing',
	 *           ),
	 *       ),
	 *   ),
	 * )
	 * Use the extended format when you need to override the extension's default details for a particular country.
	 *
	 * @see plugins/woocommerce/i18n/countries.php for the list of supported country codes and their names.
	 *
	 * @var array
	 */
	private array $country_extensions = array(
		// North America.
		'CA' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::SQUARE     => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://squareup.com/ca/en/pricing',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://squareup.com/ca/en/legal/general/ua',
						),
					),
				),
			),
			self::VISA,
			self::GOCARDLESS => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://gocardless.com/en-ca/pricing/',
						),
					),
				),
			),
			self::PAYPAL_WALLET,
			self::AFFIRM,
			self::AFTERPAY,
			self::KLARNA     => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/ca/business/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/ca/legal/',
						),
					),
				),
			),
		),
		'PM' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'US' => array(
			self::WOOPAYMENTS => array(
				'_append' => array(
					'tags' => array( 'woopay_eligible' ), // Add a special tag that will be used to determine if the merchant is eligible for WooPay.
				),
			),
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::SQUARE, // Use the default details.
			self::VISA,
			self::AIRWALLEX,
			self::PAYPAL_WALLET,
			self::AMAZON_PAY,
			self::AFFIRM,
			self::AFTERPAY,
			self::KLARNA, // Use the default details.
		),
		'UM' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),

		// UK + Europe.
		'GB' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::SQUARE          => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://squareup.com/gb/en/pricing',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://squareup.com/gb/en/legal/general/ua',
						),
					),
				),
			),
			self::MOLLIE,
			self::VISA,
			self::AIRWALLEX,
			self::VIVA_WALLET,
			self::KLARNA_CHECKOUT => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/uk/business/payment-methods/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/uk/terms-and-conditions/',
						),
					),
				),
			),
			self::PAYPAL_WALLET,
			self::AMAZON_PAY,
			self::AFFIRM          => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.affirm.com/en-gb/business',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.affirm.com/en-gb/terms',
						),
					),
				),
			),
			self::CLEARPAY,
			self::KLARNA          => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/uk/business/payment-methods/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/uk/terms-and-conditions/',
						),
					),
				),
			),
		),
		'AX' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'AL' => array(
			self::VISA          => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_WALLET => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'AD' => array(
			self::MONEI,
			self::PAYPAL_WALLET => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::VISA,
		),
		'AM' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'AT' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::AIRWALLEX,
			self::VIVA_WALLET,
			self::GOCARDLESS      => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://gocardless.com/en-ie/pricing/',
						),
					),
				),
			),
			self::KLARNA_CHECKOUT => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/at/verkaeufer/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/at/agb/',
						),
					),
				),
			),
			self::NEXI_CHECKOUT,
			self::PAYPAL_WALLET,
			self::AMAZON_PAY,
			self::KLARNA          => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/at/verkaeufer/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/at/agb/',
						),
					),
				),
			),
		),
		'BY' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'BE' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::AIRWALLEX,
			self::VIVA_WALLET,
			self::GOCARDLESS => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://gocardless.com/en-ie/pricing/',
						),
					),
				),
			),
			self::PAYPAL_WALLET,
			self::AMAZON_PAY,
			self::KLARNA     => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/be/fr/entreprise/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/be/fr/conditions-generales/',
						),
					),
				),
			),
		),
		'BA' => array(
			self::VISA          => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_WALLET => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'BV' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'BG' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::VIVA_WALLET,
			self::PAYPAL_WALLET,
		),
		'HR' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::VIVA_WALLET,
			self::GOCARDLESS => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://gocardless.com/en-ie/pricing/',
						),
					),
				),
			),
			self::PAYPAL_WALLET,
		),
		'CY' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::VIVA_WALLET,
			self::GOCARDLESS => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://gocardless.com/en-ie/pricing/',
						),
					),
				),
			),
			self::PAYPAL_WALLET,
			self::AMAZON_PAY,
		),
		'CZ' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::VIVA_WALLET,
			self::PAYPAL_WALLET,
			self::KLARNA => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/cz/firmy/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/cz/obchodni-podminky/',
						),
					),
				),
			),
		),
		'DK' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::VIVA_WALLET,
			self::GOCARDLESS      => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://gocardless.com/da-dk/priser/',
						),
					),
				),
			),
			self::KLARNA_CHECKOUT => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/dk/erhverv/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/dk/vilkar/',
						),
					),
				),
			),
			self::NEXI_CHECKOUT,
			self::PAYPAL_WALLET,
			self::AMAZON_PAY,
			self::KLARNA          => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/dk/erhverv/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/dk/vilkar/',
						),
					),
				),
			),
		),
		'EE' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::GOCARDLESS => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://gocardless.com/en-ie/pricing/',
						),
					),
				),
			),
			self::PAYPAL_WALLET,
		),
		'FI' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::VIVA_WALLET,
			self::GOCARDLESS      => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://gocardless.com/en-ie/pricing/',
						),
					),
				),
			),
			self::KLARNA_CHECKOUT => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/fi/yritys/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/fi/ehdot/',
						),
					),
				),
			),
			self::PAYTRAIL,
			self::PAYPAL_WALLET,
			self::KLARNA          => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/fi/yritys/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/fi/ehdot/',
						),
					),
				),
			),
		),
		'FO' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
		),
		'FR' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::SQUARE     => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://squareup.com/fr/fr/pricing',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://squareup.com/fr/fr/legal/general/ua',
						),
					),
				),
			),
			self::MOLLIE,
			self::VISA,
			self::AIRWALLEX,
			self::VIVA_WALLET,
			self::GOCARDLESS => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://gocardless.com/fr-fr/tarifs/',
						),
					),
				),
			),
			self::PAYPAL_WALLET,
			self::AMAZON_PAY,
			self::KLARNA     => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/fr/entreprise/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/fr/legal/',
						),
					),
				),
			),
		),
		'PF' => array(
			self::VISA          => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_WALLET => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'GI' => array(
			self::STRIPE => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
		),
		'DE' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::AIRWALLEX,
			self::VIVA_WALLET,
			self::GOCARDLESS      => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://gocardless.com/de-de/preise/',
						),
					),
				),
			),
			self::KLARNA_CHECKOUT => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/de/verkaeufer/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/de/agb/',
						),
					),
				),
			),
			self::NEXI_CHECKOUT,
			self::PAYPAL_WALLET,
			self::AMAZON_PAY,
			self::KLARNA          => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/de/verkaeufer/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/de/agb/',
						),
					),
				),
			),
		),
		'GR' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::VIVA_WALLET,
			self::PAYPAL_WALLET,
			self::KLARNA => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/gr/business/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/gr/oroi-kai-proypotheseis/',
						),
					),
				),
			),
		),
		'GL' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
		),
		'GG' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'VA' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'HU' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::VIVA_WALLET,
			self::PAYPAL_WALLET,
			self::AMAZON_PAY,
			self::KLARNA => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/hu/uzlet/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/hu/jogi-informaciok/',
						),
					),
				),
			),
		),
		'IS' => array(
			self::MOLLIE        => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_WALLET => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::VISA,
		),
		'IE' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::SQUARE => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://squareup.com/ie/en/pricing',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://squareup.com/ie/en/legal/general/ua',
						),
					),
				),
			),
			self::MOLLIE,
			self::VISA,
			self::AIRWALLEX,
			self::VIVA_WALLET,
			self::PAYPAL_WALLET,
			self::AMAZON_PAY,
			self::KLARNA => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/ie/business/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/ie/terms-and-conditions/',
						),
					),
				),
			),
		),
		'IM' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'IT' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::AIRWALLEX,
			self::VIVA_WALLET,
			self::PAYPAL_WALLET,
			self::AMAZON_PAY,
			self::KLARNA => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/it/aziende/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/it/legal/',
						),
					),
				),
			),
		),
		'JE' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'LV' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::PAYPAL_WALLET,
		),
		'LI' => array(
			self::STRIPE => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::MOLLIE,
			self::VISA,
			self::PAYPAL_WALLET,
		),
		'LT' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::AIRWALLEX,
			self::PAYPAL_WALLET,
		),
		'LU' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::VIVA_WALLET,
			self::PAYPAL_WALLET,
			self::AMAZON_PAY,
		),
		'MT' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::VIVA_WALLET,
			self::PAYPAL_WALLET,
		),
		'MD' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
		),
		'MC' => array(
			self::VISA          => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_WALLET => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'ME' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'NL' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::VIVA_WALLET,
			self::KLARNA_CHECKOUT => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/nl/zakelijk/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/nl/voorwaarden/',
						),
					),
				),
			),
			self::PAYPAL_WALLET,
			self::AMAZON_PAY,
			self::KLARNA          => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/nl/zakelijk/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/nl/voorwaarden/',
						),
					),
				),
			),
		),
		'MK' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'NO' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::KLARNA_CHECKOUT => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/no/bedrift/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/no/vilkar/',
						),
					),
				),
			),
			self::NEXI_CHECKOUT,
			self::PAYPAL_WALLET,
			self::KLARNA          => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/no/bedrift/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/no/vilkar/',
						),
					),
				),
			),
		),
		'PL' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::AIRWALLEX,
			self::VIVA_WALLET,
			self::PAYPAL_WALLET,
			self::KLARNA => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/pl/biznes/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/pl/zasady-i-warunki/',
						),
					),
				),
			),
		),
		'PT' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::AIRWALLEX,
			self::VIVA_WALLET,
			self::PAYPAL_WALLET,
			self::AMAZON_PAY,
			self::KLARNA => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/pt/empresa/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/pt/termos-e-condicoes/',
						),
					),
				),
			),
		),
		'RO' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::VIVA_WALLET,
			self::PAYPAL_WALLET,
			self::KLARNA => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/ro/companii/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/ro/aspecte-juridice/',
						),
					),
				),
			),
		),
		'RU' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'SM' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
		),
		'RS' => array(
			self::VISA          => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_WALLET => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'SK' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::PAYPAL_WALLET,
			self::KLARNA => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/sk/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/sk/zmluvne-podmienky/',
						),
					),
				),
			),
		),
		'SI' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::PAYPAL_WALLET,
		),
		'ES' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::SQUARE => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://squareup.com/es/es/pricing',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://squareup.com/es/es/legal/general/ua',
						),
					),
				),
			),
			self::MOLLIE,
			self::VISA,
			self::MONEI,
			self::AIRWALLEX,
			self::VIVA_WALLET,
			self::PAYPAL_WALLET,
			self::AMAZON_PAY,
			self::KLARNA => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/es/empresa/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/es/legal/',
						),
					),
				),
			),
		),
		'SJ' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'SE' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::VIVA_WALLET,
			self::KLARNA_CHECKOUT => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/international/enterprise/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/se/villkor/',
						),
					),
				),
			),
			self::NEXI_CHECKOUT,
			self::PAYPAL_WALLET,
			self::AMAZON_PAY,
		),
		'CH' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::MOLLIE,
			self::VISA,
			self::PAYPAL_WALLET,
			self::AMAZON_PAY,
			self::KLARNA => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/ch/fr/entreprise/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/ch/fr/conditions-generales-de-vente/',
						),
					),
				),
			),
		),
		'TR' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'UA' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),

		// LATAM & Caribbeans.
		'AG' => array(
			self::TILOPAY,
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'AI' => array(
			self::TILOPAY,
			self::VISA,
			self::HELIOPAY,
		),
		'AR' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'AW' => array(
			self::TILOPAY,
			self::VISA,
			self::HELIOPAY,
		),
		'BS' => array(
			self::TILOPAY,
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'BB' => array(
			self::TILOPAY,
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'BZ' => array(
			self::TILOPAY,
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'BM' => array(
			self::TILOPAY,
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'BO' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::HELIOPAY,
		),
		'BQ' => array(
			self::TILOPAY,
			self::VISA,
			self::HELIOPAY,
		),
		'BR' => array(
			self::STRIPE => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'VG' => array(
			self::TILOPAY,
			self::VISA,
			self::HELIOPAY,
		),
		'KY' => array(
			self::TILOPAY,
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'CL' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'CO' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'CR' => array(
			self::TILOPAY,
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'CU' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'CW' => array(
			self::TILOPAY,
			self::VISA,
			self::HELIOPAY,
		),
		'DM' => array(
			self::TILOPAY,
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'DO' => array(
			self::TILOPAY,
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'EC' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'SV' => array(
			self::TILOPAY,
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'FK' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::HELIOPAY,
		),
		'GF' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'GD' => array(
			self::TILOPAY,
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'GP' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'GT' => array(
			self::TILOPAY,
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'GY' => array(
			self::TILOPAY,
			self::VISA,
			self::HELIOPAY,
		),
		'HT' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'HN' => array(
			self::TILOPAY,
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'JM' => array(
			self::TILOPAY,
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'MQ' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'MX' => array(
			self::STRIPE => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
			self::KLARNA => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/mx/negocios/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/mx/terminos-y-condiciones/',
						),
					),
				),
			),
			self::HELIOPAY,
		),
		'MS' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'NI' => array(
			self::TILOPAY,
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'PA' => array(
			self::TILOPAY,
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'PY' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::HELIOPAY,
		),
		'PE' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'PR' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::HELIOPAY,
		),
		'BL' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::HELIOPAY,
		),
		'KN' => array(
			self::TILOPAY,
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'LC' => array(
			self::TILOPAY,
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'MF' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'VC' => array(
			self::TILOPAY,
			self::VISA,
			self::HELIOPAY,
		),
		'SX' => array(
			self::TILOPAY,
			self::VISA,
			self::HELIOPAY,
		),
		'GS' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'SR' => array(
			self::TILOPAY,
			self::VISA,
			self::HELIOPAY,
		),
		'TT' => array(
			self::TILOPAY,
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'TC' => array(
			self::TILOPAY,
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'UY' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),
		'VI' => array(
			self::TILOPAY,
			self::VISA,
			self::HELIOPAY,
		),
		'VE' => array(
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
			self::HELIOPAY,
		),

		// Antarctica.
		'AQ' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),

		// APAC.
		'AS' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'AU' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::SQUARE     => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://squareup.com/au/en/pricing',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://squareup.com/au/en/legal/general/ua',
						),
					),
				),
			),
			self::EWAY,
			self::VISA,
			self::AIRWALLEX,
			self::GOCARDLESS => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://gocardless.com/en-au/pricing/',
						),
					),
				),
			),
			self::ANTOM,
			self::PAYPAL_WALLET,
			self::AFTERPAY,
			self::KLARNA     => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/au/business/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/au/legal/',
						),
					),
				),
			),
		),
		'BD' => array(
			self::PAYONEER => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::VISA,
		),
		'IO' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'BN' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'KH' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'CN' => array(
			self::PAYPAL_FULL_STACK => array(
				'_type'   => self::TYPE_PSP, // Change the type to PSP.
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::ANTOM,
			self::AIRWALLEX,
			self::PAYONEER,
			self::VISA,
		),
		'CX' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'CC' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'CK' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'FJ' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
		),
		'GU' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'HM' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'HK' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::ANTOM,
			self::AIRWALLEX,
			self::PAYONEER,
			self::VISA,
			self::PAYPAL_WALLET,
		),
		'IN' => array(
			self::STRIPE => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::RAZORPAY,
			self::PAYU_INDIA,
			self::PAYONEER,
			self::VISA,
			self::PAYPAL_WALLET,
		),
		'ID' => array(
			self::PAYONEER => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
		),
		'JP' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::SQUARE => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://squareup.com/jp/ja/pricing',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://squareup.com/jp/ja/legal/general/ua',
						),
					),
				),
			),
			self::VISA,
			self::PAYPAL_WALLET,
			self::AMAZON_PAY,
		),
		'KI' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'LA' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'MO' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'MY' => array(
			self::STRIPE => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYONEER,
			self::VISA,
			self::PAYPAL_WALLET,
		),
		'MV' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'MH' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'FM' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'MN' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'MM' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'NR' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'NP' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'NC' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
		),
		'NZ' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::EWAY   => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://eway.io/nz/online-payments/#pricing',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://eway.io/docs/eWAY-Terms-and-Conditions-NZ.pdf',
						),
					),
				),
			),
			self::VISA,
			self::AIRWALLEX,
			self::PAYPAL_WALLET,
			self::AFTERPAY,
			self::KLARNA => array(
				'_merge_on_type' => array(
					'links' => array(
						array(
							'_type' => PaymentsProviders::LINK_TYPE_PRICING,
							'url'   => 'https://www.klarna.com/nz/business/',
						),
						array(
							'_type' => PaymentsProviders::LINK_TYPE_TERMS,
							'url'   => 'https://www.klarna.com/nz/legal/',
						),
					),
				),
			),
		),
		'NU' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'NF' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'MP' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'PW' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
		),
		'PG' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'PH' => array(
			self::PAYONEER => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
		),
		'PN' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'WS' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'SG' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::ANTOM,
			self::AIRWALLEX,
			self::VISA,
			self::PAYPAL_WALLET,
		),
		'SB' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'LK' => array(
			self::PAYONEER => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::VISA,
		),
		'KR' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
		),
		'TW' => array(
			self::VISA          => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_WALLET => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'TH' => array(
			self::STRIPE => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYONEER,
			self::VISA,
			self::PAYPAL_WALLET,
		),
		'TL' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'TK' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'TO' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'TV' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'VU' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'VN' => array(
			self::PAYONEER => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
		),
		'WF' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),

		// Africa.
		'DZ' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
		),
		'AO' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'BJ' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'BW' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
		),
		'BF' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'BI' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'CV' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'CM' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'CF' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'TD' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'KM' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'CG' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'CI' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'EG' => array(
			self::PAYMOB => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
		),
		'CD' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'DJ' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'GQ' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'ER' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'SZ' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
		),
		'ET' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'TF' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'GA' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'GH' => array(
			self::PAYSTACK => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::VISA,
		),
		'GM' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'GN' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'GW' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'KE' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
		),
		'LS' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
		),
		'LR' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'LY' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'MG' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'MW' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
		),
		'ML' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'MR' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'MU' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
		),
		'MA' => array(
			self::PAYONEER => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
		),
		'MZ' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
		),
		'NA' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'NE' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'NG' => array(
			self::PAYSTACK => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::VISA,
		),
		'RE' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
		),
		'RW' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'SH' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'ST' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'SN' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
		),
		'SC' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
		),
		'SL' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'SO' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'ZA' => array(
			self::PAYSTACK => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYFAST,
			self::VISA,
			self::PAYPAL_WALLET,
		),
		'SS' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'TZ' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'TG' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'TN' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'UG' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'EH' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'ZM' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'ZW' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),

		// Middle East.
		'AF' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'AZ' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'BH' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
		),
		'BT' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'GE' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
		),
		'IR' => array(),
		'IQ' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'IL' => array(
			self::AIRWALLEX => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::VISA,
		),
		'JO' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::NGENIUS,
			self::PAYPAL_WALLET,
		),
		'KZ' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
		),
		'KW' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
		),
		'KG' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'LB' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'OM' => array(
			self::PAYMOB => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::PAYPAL_WALLET,
		),
		'PK' => array(
			self::PAYONEER => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYMOB,
			self::VISA,
		),
		'PS' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'QA' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::PAYPAL_WALLET,
		),
		'SA' => array(
			self::PAYMOB => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
			self::PAYPAL_FULL_STACK,
			self::VISA,
			self::NGENIUS,
			self::PAYPAL_WALLET,
		),
		'SD' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'TJ' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'TM' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'AE' => array(
			self::WOOPAYMENTS,
			self::PAYPAL_FULL_STACK,
			self::STRIPE,
			self::PAYONEER,
			self::PAYMOB,
			self::VISA,
			self::NGENIUS,
			self::PAYPAL_WALLET,
		),
		'UZ' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
		'YE' => array(
			self::VISA => array(
				'_append' => array(
					'tags' => array( self::TAG_PREFERRED ),
				),
			),
		),
	);

	/**
	 * The context to incentive type map.
	 *
	 * @var array|string[]
	 */
	private array $context_to_incentive_type_map = array(
		Payments::SUGGESTIONS_CONTEXT => 'wc_settings_payments',
	);

	/**
	 * The suggestion incentives provider.
	 *
	 * @var PaymentsExtensionSuggestionIncentives
	 */
	private PaymentsExtensionSuggestionIncentives $suggestion_incentives;

	/**
	 * Initialize the class instance.
	 *
	 * @param PaymentsExtensionSuggestionIncentives $suggestion_incentives The suggestion incentives provider.
	 *
	 * @internal
	 */
	final public function init( PaymentsExtensionSuggestionIncentives $suggestion_incentives ) {
		$this->suggestion_incentives = $suggestion_incentives;
	}

	/**
	 * Get the list of payment extensions details for a specific country.
	 *
	 * @param string $country_code The two-letter country code.
	 * @param string $context      Optional. The context ID of where these extensions are being used.
	 *
	 * @return array The list of payment extensions (their full details) for the given country.
	 *               Empty array if no extensions are available for the country or the country is not supported.
	 * @throws \Exception If there were malformed or invalid extension details.
	 */
	public function get_country_extensions( string $country_code, string $context = '' ): array {
		$country_code = strtoupper( $country_code );

		if ( empty( $this->country_extensions[ $country_code ] ) ||
			! is_array( $this->country_extensions[ $country_code ] ) ) {

			return array();
		}

		// Process the extensions.
		$processed_extensions = array();
		$priority             = 0;
		foreach ( $this->country_extensions[ $country_code ] as $key => $details ) {
			// Check the formats we support.
			if ( is_int( $key ) && is_string( $details ) ) {
				$extension_id              = $details;
				$extension_country_details = array();
			} elseif ( is_string( $key ) && is_array( $details ) ) {
				$extension_id              = $key;
				$extension_country_details = $details;
			} else {
				// Just ignore the entry as it is malformed.
				continue;
			}

			// Determine if the extension should be included based on the store's state, the provided country and context.
			if ( ! $this->is_extension_allowed( $extension_id, $country_code, $context ) ) {
				continue;
			}

			// Determine the extension details for the given country.
			$extension_base_details = $this->get_extension_base_details( $extension_id ) ?? array();
			$extension_details      = $this->with_country_details( $extension_base_details, $extension_country_details );

			// Apply any changes to the extension details based on the store's state.
			$extension_details = $this->with_store_state_details( $extension_id, $extension_details );

			// Check if there is an incentive for this extension and attach its details.
			$incentive = $this->get_extension_incentive( $extension_id, $country_code, $context );
			if ( is_array( $incentive ) && ! empty( $incentive ) ) {
				$extension_details['_incentive'] = $incentive;
			}

			// Include the extension ID.
			$extension_details['id'] = $extension_id;

			// Lock in the priority for ordering purposes.
			// We respect the order in the country extensions list.
			// We use increments of 10 to allow for easy insertions.
			$priority                      += 10;
			$extension_details['_priority'] = $priority;

			$processed_extensions[] = $this->standardize_extension_details( $extension_details );
		}

		return $processed_extensions;
	}

	/**
	 * Get the base details of a payment extension by its ID.
	 *
	 * @param string $extension_id The extension id.
	 *
	 * @return array|null The extension details for the given ID. Null if not found.
	 */
	public function get_by_id( string $extension_id ): ?array {
		$extension_id = sanitize_title( $extension_id );

		$extensions = $this->get_all_extensions_base_details();
		if ( isset( $extensions[ $extension_id ] ) ) {
			$extension_details              = $extensions[ $extension_id ];
			$extension_details['id']        = $extension_id;
			$extension_details['_priority'] = 0;

			return $this->standardize_extension_details( $extension_details );
		}

		return null;
	}

	/**
	 * Get the base details of a payment extension by its plugin slug.
	 *
	 * If there are multiple extensions with the same plugin slug, the first one found will be returned.
	 *
	 * @param string $plugin_slug  The plugin slug.
	 * @param string $country_code Optional. The two-letter country code for which the extension suggestion should be retrieved.
	 * @param string $context      Optional. The context ID of where this extension suggestion is being used.
	 *
	 * @return array|null The extension details for the given plugin slug. Null if not found or the slug is empty.
	 */
	public function get_by_plugin_slug( string $plugin_slug, string $country_code = '', string $context = '' ): ?array {
		$plugin_slug = sanitize_title( $plugin_slug );
		if ( empty( $plugin_slug ) ) {
			return null;
		}

		// If we have a country code, try to find a fully localized extension suggestion.
		if ( ! empty( $country_code ) ) {
			$extensions = $this->get_country_extensions( $country_code, $context );
			foreach ( $extensions as $extension_details ) {
				if ( isset( $extension_details['plugin']['slug'] ) &&
					$plugin_slug === $extension_details['plugin']['slug']
				) {
					// The extension details are already standardized.
					return $extension_details;
				}
			}
		}

		// Fallback to the base details.
		$extensions = $this->get_all_extensions_base_details();
		foreach ( $extensions as $extension_id => $extension_details ) {
			if ( isset( $extension_details['plugin']['slug'] ) &&
				$plugin_slug === $extension_details['plugin']['slug']
			) {
				$extension_details['id']        = $extension_id;
				$extension_details['_priority'] = 0;

				return $this->standardize_extension_details( $extension_details );
			}
		}

		return null;
	}

	/**
	 * Dismiss an incentive for a specific payment extension suggestion.
	 *
	 * @param string $incentive_id  The incentive ID.
	 * @param string $suggestion_id The suggestion ID.
	 * @param string $context       Optional. The context ID for which the incentive should be dismissed.
	 *                              If not provided, the incentive will be dismissed for all contexts.
	 *
	 * @return bool True if the incentive was not previously dismissed and now it is.
	 *              False if the incentive was already dismissed or could not be dismissed.
	 * @throws \Exception If the incentive could not be dismissed due to an error.
	 */
	public function dismiss_incentive( string $incentive_id, string $suggestion_id, string $context = 'all' ): bool {
		return $this->suggestion_incentives->dismiss_incentive( $incentive_id, $suggestion_id, $context );
	}

	/**
	 * Determine if a payment extension is allowed to be suggested.
	 *
	 * @param string $extension_id The extension ID.
	 * @param string $country_code The two-letter country code.
	 * @param string $context      Optional. The context ID of where the extension is being used.
	 *
	 * @return bool True if the extension is allowed, false otherwise.
	 *              Defaults to true if there is no specific logic for the extension.
	 */
	private function is_extension_allowed( string $extension_id, string $country_code, string $context = '' ): bool { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
		// Add per-extension exclusion logic here.
		// Returning true for now to avoid excluding any extensions.
		return true;
	}

	/**
	 * Merges country-specific details into the base details of a payment extension.
	 *
	 * This function processes special `_append`, `_remove`, and `_merge_on_type` instructions to modify
	 * list-type entries within the base details.
	 *
	 * @param array $base_details    The base details of the payment extension.
	 * @param array $country_details The country-specific details, which may include
	 *                               special `_append` and `_remove` instructions.
	 *
	 * @return array The merged details, with country-specific modifications applied.
	 *
	 * @throws \Exception If the country extension details are malformed or invalid.
	 */
	private function with_country_details( array $base_details, array $country_details ): array {
		// Process any append instructions.
		if ( isset( $country_details['_append'] ) ) {
			if ( ! is_array( $country_details['_append'] ) ) {
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
				throw new \Exception( 'Malformed country extension details _append entry.' );
			}
			foreach ( $country_details['_append'] as $append_key => $append_list ) {
				// Sanity checks.
				if ( ! is_string( $append_key ) ||
					! is_array( $append_list ) ||
					! ArrayUtil::array_is_list( $append_list )
				) {
					// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
					throw new \Exception( 'Malformed country extension details _append details.' );
				}
				// If the target entry doesn't exist, create it as an empty list.
				if ( ! isset( $base_details[ $append_key ] ) ) {
					$base_details[ $append_key ] = array();
				}
				if ( ! is_array( $base_details[ $append_key ] ) ||
					! ArrayUtil::array_is_list( $base_details[ $append_key ] )
				) {
					// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
					throw new \Exception( 'Invalid country extension details _append target.' );
				}

				$base_details[ $append_key ] = array_merge( $base_details[ $append_key ], $append_list );
			}

			// Remove the special entry because we don't need it anymore.
			unset( $country_details['_append'] );
		}

		// Process any remove instructions.
		if ( isset( $country_details['_remove'] ) ) {
			if ( ! is_array( $country_details['_remove'] ) ) {
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
				throw new \Exception( 'Malformed country extension details _remove entry.' );
			}
			foreach ( $country_details['_remove'] as $removal_key => $removal_list ) {
				// Sanity checks.
				if ( ! is_string( $removal_key ) ||
					! is_array( $removal_list ) ||
					! ArrayUtil::array_is_list( $removal_list )
				) {
					// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
					throw new \Exception( 'Malformed country extension details _remove details.' );
				}
				if ( ! isset( $base_details[ $removal_key ] ) ) {
					// If the target entry doesn't exist, we don't need to do anything.
					continue;
				}
				if ( ! is_array( $base_details[ $removal_key ] ) ||
					! ArrayUtil::array_is_list( $base_details[ $removal_key ] )
				) {
					// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
					throw new \Exception( 'Invalid country extension details _remove target.' );
				}

				$base_details[ $removal_key ] = array_diff( $base_details[ $removal_key ], $removal_list );
			}

			// Remove the special entry because we don't need it anymore.
			unset( $country_details['_remove'] );
		}

		// Process any merge on type instructions.
		if ( isset( $country_details['_merge_on_type'] ) ) {
			if ( ! is_array( $country_details['_merge_on_type'] ) ) {
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
				throw new \Exception( 'Malformed country extension details _merge_on_type entry.' );
			}
			foreach ( $country_details['_merge_on_type'] as $merge_key => $merge_list ) {
				// Sanity checks.
				if ( ! is_string( $merge_key ) ||
					! is_array( $merge_list ) ||
					! ArrayUtil::array_is_list( $merge_list ) ||
					count( array_column( $merge_list, '_type' ) ) !== count( $merge_list )
				) {
					// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
					throw new \Exception( 'Malformed country extension details _merge_on_type details.' );
				}
				if ( ! isset( $base_details[ $merge_key ] ) ) {
					// If the target entry doesn't exist, create it.
					$base_details[ $merge_key ] = array();
				}
				if ( ! is_array( $base_details[ $merge_key ] ) ||
					! ArrayUtil::array_is_list( $base_details[ $merge_key ] ) ||
					count( array_column( $base_details[ $merge_key ], '_type' ) ) !== count( $base_details[ $merge_key ] )
				) {
					// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
					throw new \Exception( 'Invalid country extension details _merge_on_type target.' );
				}

				// Merge the lists based on the '_type' values.
				$base_details[ $merge_key ] = ArrayUtil::merge_by_key( $base_details[ $merge_key ], $merge_list, '_type' );
			}

			// Remove the special entry because we don't need it anymore.
			unset( $country_details['_merge_on_type'] );
		}

		// Merge any remaining country details so they overwrite the base details.
		return array_merge( $base_details, $country_details );
	}

	/**
	 * Apply customizations to the extension details based on the store's state.
	 *
	 * The customizations may be general or specific to certain extensions.
	 * The store's state refers to various aspects of the store's configuration, collected data,
	 * store setup/launch process, onboarding task completion, etc.
	 *
	 * @param string $extension_id      The extension ID.
	 * @param array  $extension_details The extension details.
	 *
	 * @return array The modified extension details.
	 */
	private function with_store_state_details( string $extension_id, array $extension_details ): array {
		// For Square, we add the preferred tags if the merchant self-identified as selling offline via the core profiler.
		if ( self::SQUARE === $extension_id && $this->is_merchant_selling_offline() ) {
			if ( empty( $extension_details['tags'] ) ) {
				$extension_details['tags'] = array();
			}
			$extension_details['tags'][] = self::TAG_PREFERRED;
			$extension_details['tags'][] = self::TAG_PREFERRED_OFFLINE;
		}

		return $extension_details;
	}

	/**
	 * Get the incentive details for a given extension and country, if any.
	 *
	 * @param string $extension_id The extension ID.
	 * @param string $country_code The two-letter country code.
	 * @param string $context      Optional. The context ID of where the extension incentive is being used.
	 *
	 * @return array|null The incentive details for the given extension and country. Null if not found.
	 */
	private function get_extension_incentive( string $extension_id, string $country_code, string $context = '' ): ?array {
		// Try to map the context to an incentive type.
		$incentive_type = '';
		if ( isset( $this->context_to_incentive_type_map[ $context ] ) ) {
			$incentive_type = $this->context_to_incentive_type_map[ $context ];
		}

		$incentives = $this->suggestion_incentives->get_incentives( $extension_id, $country_code, $incentive_type );
		if ( empty( $incentives ) ) {
			return null;
		}

		// Use the first incentive, in case there are multiple.
		$incentive = reset( $incentives );

		// Sanitize the incentive details.
		$incentive = $this->sanitize_extension_incentive( $incentive );

		// Enhance the incentive details.
		$incentive['_suggestion_id'] = $extension_id;
		// Add the dismissals list.
		$incentive['_dismissals'] = $this->suggestion_incentives->get_incentive_dismissals( $incentive['id'], $extension_id );

		return $incentive;
	}

	/**
	 * Sanitize the incentive details for a payment extension.
	 *
	 * @param array $incentive The incentive details.
	 *
	 * @return array The sanitized incentive details.
	 */
	private function sanitize_extension_incentive( array $incentive ): array {
		// Apply a very loose sanitization. Stricter sanitization can be applied downstream, if needed.
		return array_map(
			function ( $value ) {
				// Make sure that if we have HTML tags, we only allow a limited set of tags (only stylistic ones).
				if ( is_string( $value ) && preg_match( '/<[^>]+>/', $value ) ) {
						$value = wp_kses( $value, wp_kses_allowed_html( 'data' ) );
				}

				return $value;
			},
			$incentive
		);
	}

	/**
	 * Get the base details of all extensions.
	 *
	 * @return array[] The base details of all extensions.
	 */
	private function get_all_extensions_base_details(): array {
		if ( isset( $this->extensions_base_details_memo ) ) {
			return $this->extensions_base_details_memo;
		}
		$this->extensions_base_details_memo = array(
			self::AIRWALLEX         => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'Airwallex Payments', 'woocommerce' ),
				'description' => esc_html__( 'Boost international sales and save on FX fees. Accept 60+ local payment methods including Apple Pay and Google Pay.', 'woocommerce' ),
				'image'       => plugins_url( 'assets/images/onboarding/airwallex.png', WC_PLUGIN_FILE ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/airwallex.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'airwallex-online-payments-gateway',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://www.airwallex.com/pricing',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/airwallexpayments/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://www.airwallex.com/terms/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://www.airwallex.com/docs/payments__plugins__woocommerce__install-the-woocommerce-plugin',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://help.airwallex.com/',
					),
				),
			),
			self::ANTOM             => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'Antom', 'woocommerce' ),
				'description' => esc_html__( 'Your trusted payments partner in Asia and around the world.', 'woocommerce' ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/antom.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'antom-payments',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/antom-payments/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://global.alipay.com/docs/ac/Platform/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/antom-payment/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://woocommerce.com/my-account/contact-support/?select=antom-payments',
					),
				),
			),
			self::MERCADO_PAGO      => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'Mercado Pago', 'woocommerce' ),
				'description' => esc_html__( 'Set up your payment methods and accept credit and debit cards, cash, bank transfers and money from your Mercado Pago account. Offer safe and secure payments with Latin America’s leading processor.', 'woocommerce' ),
				'image'       => plugins_url( 'assets/images/onboarding/mercadopago.png', WC_PLUGIN_FILE ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/mercadopago.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'woocommerce-mercadopago',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/mercado-pago-checkout/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/mercado-pago/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://woocommerce.com/my-account/contact-support/?select=mercado-pago-checkout',
					),
				),
			),
			self::MOLLIE            => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'Mollie', 'woocommerce' ),
				'description' => esc_html__( 'Effortless payments by Mollie: Offer global and local payment methods, get onboarded in minutes, and supported in your language.', 'woocommerce' ),
				'image'       => plugins_url( 'assets/images/onboarding/mollie.svg', WC_PLUGIN_FILE ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/mollie.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'mollie-payments-for-woocommerce',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://www.mollie.com/pricing',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/mollie-payments-for-woocommerce/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://www.mollie.com/user-agreement',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/mollie-payments-for-woocommerce/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://discord.com/invite/mollie',
					),
				),
			),
			self::PAYFAST           => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'Payfast', 'woocommerce' ),
				'description' => esc_html__( 'The Payfast extension for WooCommerce enables you to accept payments by Credit Card and EFT via one of South Africa\'s most popular payment gateways. No setup fees or monthly subscription costs. Selecting this extension will configure your store to use South African rands as the selected currency.', 'woocommerce' ),
				'image'       => plugins_url( 'assets/images/onboarding/payfast.png', WC_PLUGIN_FILE ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/payfast.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'woocommerce-payfast-gateway',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://payfast.io/fees/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/payfast-payment-gateway/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://payfast.io/legal/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/payfast-payment-gateway/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://woocommerce.com/my-account/contact-support/?select=payfast-payment-gateway',
					),
				),
				'tags'        => array( self::TAG_MADE_IN_WOO ),
			),
			self::PAYMOB            => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'Paymob', 'woocommerce' ),
				'description' => esc_html__( 'Paymob is a leading payment gateway in the Middle East and Africa. Accept payments online and in-store with Paymob.', 'woocommerce' ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/paymob.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'paymob-for-woocommerce',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://paymob.com/en/pricing',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/paymob/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://paymob.com/en/policy',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/paymob-for-woocommerce/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://woocommerce.com/my-account/contact-support/?select=paymob',
					),
				),
			),
			self::PAYPAL_FULL_STACK => array(
				'_type'       => self::TYPE_APM,
				'title'       => esc_html__( 'PayPal Payments', 'woocommerce' ),
				'description' => esc_html__( 'PayPal Payments lets you offer PayPal, Venmo (US only), Pay Later options and more.', 'woocommerce' ),
				'image'       => plugins_url( 'assets/images/onboarding/paypal.png', WC_PLUGIN_FILE ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/paypal.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'woocommerce-paypal-payments',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://www.paypal.com/webapps/mpp/merchant-fees',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/woocommerce-paypal-payments/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://www.paypal.com/legalhub/home',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/woocommerce-paypal-payments/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://woocommerce.com/my-account/contact-support/?select=woocommerce-paypal-payments',
					),
				),
				'tags'        => array( self::TAG_MADE_IN_WOO, self::TAG_PREFERRED ),
			),
			self::PAYPAL_WALLET     => array(
				'_type'       => self::TYPE_EXPRESS_CHECKOUT,
				'title'       => esc_html__( 'PayPal Payments', 'woocommerce' ),
				'description' => esc_html__( 'Safe and secure payments using your customer\'s PayPal account.', 'woocommerce' ),
				'image'       => plugins_url( 'assets/images/onboarding/paypal.png', WC_PLUGIN_FILE ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/paypal.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'woocommerce-paypal-payments',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://www.paypal.com/webapps/mpp/merchant-fees#advanced_cd_payments',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/woocommerce-paypal-payments/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://www.paypal.com/legalhub/home',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/woocommerce-paypal-payments/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://woocommerce.com/my-account/contact-support/?select=woocommerce-paypal-payments',
					),
				),
				'tags'        => array( self::TAG_MADE_IN_WOO ),
			),
			self::PAYONEER          => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'Payoneer Checkout', 'woocommerce' ),
				'description' => esc_html__( 'Payoneer Checkout is the next generation of payment processing platforms, giving merchants around the world the solutions and direction they need to succeed in today\'s hyper-competitive global market.', 'woocommerce' ),
				'image'       => plugins_url( 'assets/images/onboarding/payoneer.png', WC_PLUGIN_FILE ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/payoneer.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'payoneer-checkout',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://www.payoneer.com/about/pricing/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/payoneer-checkout/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://www.payoneer.com/legal-agreements/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://checkoutdocs.payoneer.com/docs/about-woocommerce-integration',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://checkoutdocs.payoneer.com/docs/troubleshoot-woocommerce',
					),
				),
			),
			self::PAYSTACK          => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'Paystack', 'woocommerce' ),
				'description' => esc_html__( 'Paystack helps African merchants accept one-time and recurring payments online with a modern, safe, and secure payment gateway.', 'woocommerce' ),
				'image'       => plugins_url( 'assets/images/onboarding/paystack.png', WC_PLUGIN_FILE ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/paystack.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'woo-paystack',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://paystack.com/pricing',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/paystack/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://paystack.com/terms',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/paystack/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://support.paystack.com/en/articles/2130754',
					),
				),
			),
			self::PAYTRAIL          => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'Paytrail', 'woocommerce' ),
				'description' => esc_html__( 'Accept all popular payment methods for Finnish B2C and B2B customers', 'woocommerce' ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/paytrail.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'paytrail-for-woocommerce',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://www.paytrail.com/en/pricing',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/paytrail/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://www.paytrail.com/en/terms-conditions',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/paytrail-for-woocommerce/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://www.paytrail.com/en/customer-service#merchants',
					),
				),
			),
			self::PAYU_INDIA        => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'PayU India', 'woocommerce' ),
				'description' => esc_html__( 'Enable PayU\'s exclusive plugin for WooCommerce to start accepting payments in 100+ payment methods available in India including credit cards, debit cards, UPI, & more!', 'woocommerce' ),
				'image'       => plugins_url( 'assets/images/onboarding/payu.svg', WC_PLUGIN_FILE ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/payu.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'payu-india',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://payu.in/pricing/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/payu-india/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://payu.in/payu-terms-and-conditions/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://payu.in/plugins/payment-gateway-for-woocommerce-plugin',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://help.payu.in/',
					),
				),
			),
			self::RAZORPAY          => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'Razorpay', 'woocommerce' ),
				'description' => esc_html__( 'The official Razorpay extension for WooCommerce allows you to accept credit cards, debit cards, netbanking, wallet, and UPI payments.', 'woocommerce' ),
				'image'       => plugins_url( 'assets/images/onboarding/razorpay.svg', WC_PLUGIN_FILE ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/razorpay.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'woo-razorpay',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://razorpay.com/pricing/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/razorpay-for-woocommerce/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://razorpay.com/terms/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://razorpay.com/docs/payment-gateway/ecommerce-plugins/woocommerce/woocommerce-pg/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://razorpay.com/support/',
					),
				),
			),
			self::SQUARE            => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'Square', 'woocommerce' ),
				'description' => esc_html__( 'Securely accept credit and debit cards with one low rate, no surprise fees (custom rates available). Sell in store and track sales and inventory in one place.', 'woocommerce' ),
				'image'       => plugins_url( 'assets/images/onboarding/square-black.png', WC_PLUGIN_FILE ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/square.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'woocommerce-square',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://squareup.com/pricing',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/square/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://squareup.com/legal/general/ua',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/woocommerce-square/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://woocommerce.com/my-account/contact-support/?select=square',
					),
				),
				'tags'        => array( self::TAG_MADE_IN_WOO ),
			),
			self::STRIPE            => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'Stripe', 'woocommerce' ),
				'description' => esc_html__( 'Accept debit and credit cards in 135+ currencies, methods such as Alipay, and one-touch checkout with Apple Pay.', 'woocommerce' ),
				'image'       => plugins_url( 'assets/images/onboarding/stripe.png', WC_PLUGIN_FILE ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/stripe.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'woocommerce-gateway-stripe',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://stripe.com/pricing',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/stripe/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://stripe.com/legal/connect-account',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/stripe',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://woocommerce.com/my-account/contact-support/?select=stripe',
					),
				),
				'tags'        => array( self::TAG_MADE_IN_WOO ),
			),
			self::TILOPAY           => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'Tilopay', 'woocommerce' ),
				'description' => esc_html__( 'Accept credit and debit cards on your WooCommerce store with advanced features like partial refunds, full/partial captures, and 3D Secure security.', 'woocommerce' ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/tilopay.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'tilopay',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://tilopay.com/tarifas',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://tilopay.com/tilopay-checkout',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://tilopay.com/terminos-condiciones',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://tilopay.com/documentacion/plataforma-woocommerce',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://cst.support.tilopay.com/servicedesk/customer/portals',
					),
				),
				'tags'        => array( self::TAG_PREFERRED ),
			),
			self::VIVA_WALLET       => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'Viva.com Smart Checkout', 'woocommerce' ),
				'description' => esc_html__( 'A European payments solution that allows you to accept payments in over 25 countries and multiple currencies.', 'woocommerce' ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/vivacom.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'viva-com-smart-for-woocommerce',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://www.viva.com/pricing',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/viva-com-smart-for-woocommerce/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://www.viva.com/terms-portal',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/viva-com-smart-for-woocommerce/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://woocommerce.com/my-account/contact-support/?select=viva-com-smart-for-woocommerce',
					),
				),
			),
			self::WOOPAYMENTS       => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'Accept payments with Woo', 'woocommerce' ),
				'description' => esc_html__( 'Credit/debit cards, Apple Pay, Google Pay, and more.', 'woocommerce' ),
				'image'       => plugins_url( 'assets/images/onboarding/woopayments.svg', WC_PLUGIN_FILE ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/woo.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'woocommerce-payments',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://woocommerce.com/document/woopayments/fees-and-debits/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/payments/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://woocommerce.com/document/woopayments/our-policies/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/woopayments/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://woocommerce.com/my-account/contact-support/?select=woopayments',
					),
				),
				'tags'        => array( self::TAG_MADE_IN_WOO, self::TAG_PREFERRED ),
			),
			self::AMAZON_PAY        => array(
				'_type'       => self::TYPE_EXPRESS_CHECKOUT,
				'title'       => esc_html__( 'Amazon Pay', 'woocommerce' ),
				'description' => esc_html__( 'Enable a familiar, fast checkout for hundreds of millions of active Amazon customers globally.', 'woocommerce' ),
				'image'       => plugins_url( 'assets/images/onboarding/amazonpay.png', WC_PLUGIN_FILE ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/amazonpay.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'woocommerce-gateway-amazon-payments-advanced',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://pay.amazon.com/help/201212280',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/pay-with-amazon/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://pay.amazon.com/help/201212430',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/amazon-payments-advanced/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://woocommerce.com/my-account/contact-support/?select=pay-with-amazon',
					),
				),
				'tags'        => array( self::TAG_MADE_IN_WOO ),
			),
			self::AFFIRM            => array(
				'_type'       => self::TYPE_BNPL,
				'title'       => esc_html__( 'Affirm', 'woocommerce' ),
				'description' => esc_html__( 'Affirm\'s tailored Buy Now Pay Later programs remove price as a barrier, turning browsers into buyers, increasing average order value, and expanding your customer base.', 'woocommerce' ),
				'image'       => plugins_url( 'assets/images/onboarding/affirm.png', WC_PLUGIN_FILE ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/affirm.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'woocommerce-gateway-affirm',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://www.affirm.com/business',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/woocommerce-gateway-affirm/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://www.affirm.com/terms',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/woocommerce-gateway-affirm/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://woocommerce.com/my-account/contact-support/?select=woocommerce-gateway-affirm',
					),
				),
				'tags'        => array( self::TAG_MADE_IN_WOO ),
			),
			self::AFTERPAY          => array(
				'_type'       => self::TYPE_BNPL,
				'title'       => esc_html__( 'Afterpay', 'woocommerce' ),
				'description' => esc_html__( 'Afterpay allows customers to receive products immediately and pay for purchases over four installments, always interest-free.', 'woocommerce' ),
				'image'       => plugins_url( 'assets/images/onboarding/afterpay.png', WC_PLUGIN_FILE ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/afterpay-clearpay.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'afterpay-gateway-for-woocommerce',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://www.afterpay.com/for-retailers',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/afterpay/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://www.afterpay.com/terms-of-service',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/afterpay/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://woocommerce.com/my-account/contact-support/?select=afterpay',
					),
				),
			),
			self::CLEARPAY          => array(
				'_type'       => self::TYPE_BNPL,
				'title'       => esc_html__( 'Clearpay', 'woocommerce' ),
				'description' => esc_html__( 'Clearpay allows customers to receive products immediately and pay for purchases over four installments, always interest-free.', 'woocommerce' ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/afterpay-clearpay.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'clearpay-gateway-for-woocommerce',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://www.clearpay.co.uk/en-GB/for-retailers',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/clearpay/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://www.clearpay.co.uk/terms-of-service',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/clearpay/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://woocommerce.com/my-account/contact-support/?select=clearpay',
					),
				),
			),
			self::KLARNA            => array(
				'_type'       => self::TYPE_BNPL,
				'title'       => esc_html__( 'Klarna Payments', 'woocommerce' ),
				'description' => esc_html__( 'Choose the payment that you want, pay now, pay later or slice it. No credit card numbers, no passwords, no worries.', 'woocommerce' ),
				'image'       => plugins_url( 'assets/images/onboarding/klarna-black.png', WC_PLUGIN_FILE ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/klarna.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'klarna-payments-for-woocommerce',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://www.klarna.com/us/business/payment-methods/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/klarna-payments/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://www.klarna.com/us/legal/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/klarna-payments/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://woocommerce.com/my-account/contact-support/?select=klarna-payments',
					),
				),
			),
			self::KLARNA_CHECKOUT   => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'Klarna Checkout', 'woocommerce' ),
				'description' => esc_html__( 'A full checkout experience embedded on your site that includes all popular payment methods (Pay Now, Pay Later, Financing, Installments).', 'woocommerce' ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/klarna-checkout.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'klarna-checkout-for-woocommerce',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://www.klarna.com/us/business/payment-methods/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/klarna-checkout/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://www.klarna.com/us/legal/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/klarna-checkout/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://woocommerce.com/my-account/contact-support/?select=klarna-checkout',
					),
				),
			),
			self::HELIOPAY          => array(
				'_type'       => self::TYPE_CRYPTO,
				'title'       => esc_html__( 'Helio Pay', 'woocommerce' ),
				'description' => esc_html__( 'Effortlessly accept cryptocurrency payments in your store.', 'woocommerce' ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/heliopay.png', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'helio',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://www.hel.io/pricing',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/helio-pay/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://info.docs.hel.io/terms-of-service',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/helio-pay/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://woocommerce.com/my-account/contact-support/?select=helio-pay',
					),
				),
			),
			self::MONEI             => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'MONEI', 'woocommerce' ),
				'description' => esc_html__( 'Accept Cards, Apple Pay, Google Pay, Bizum, PayPal, and many more payment methods in your store.', 'woocommerce' ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/monei.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'monei',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://monei.com/pricing/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://monei.com/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://monei.com/legal-notice/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://support.monei.com/hc/en-us/articles/360017801677-Get-started-with-MONEI',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://support.monei.com/hc/en-us/requests/new',
					),
				),
			),
			self::EWAY              => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'Eway', 'woocommerce' ),
				'description' => esc_html__( 'Take credit card payments securely via Eway keeping customers on your site.', 'woocommerce' ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/eway.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'woocommerce-gateway-eway',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://www.eway.com.au/online-payments/#pricing',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/eway/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://www.eway.com.au/docs/eWAY-Terms-and-Conditions-AU.pdf',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/eway/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://woocommerce.com/my-account/contact-support/?select=eway',
					),
				),
			),
			self::VISA              => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'Visa Acceptance Solutions', 'woocommerce' ),
				'description' => esc_html__( 'Accept payments on your WooCommerce store securely.', 'woocommerce' ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/visa-acceptance-solutions.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'visa-acceptance-solutions',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/visa-acceptance-solutions/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/visa-acceptance-solutions/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://woocommerce.com/my-account/contact-support/?select=visa-acceptance-solutions',
					),
				),
			),
			self::NGENIUS           => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'N-Genius Online', 'woocommerce' ),
				'description' => esc_html__( 'Power your business with N-Genius Online—smart, secure, and built for the future.', 'woocommerce' ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/ngenius.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'ngenius',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/ngenius/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/ngenius/',
					),
				),
			),
			self::GOCARDLESS        => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'GoCardless', 'woocommerce' ),
				'description' => esc_html__( 'Accept Direct Debit, ACH Pull, and open banking payments.', 'woocommerce' ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/gocardless.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'woocommerce-gateway-gocardless',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_PRICING,
						'url'   => 'https://gocardless.com/pricing/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/gocardless/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://gocardless.com/legal/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/gocardless/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://woocommerce.com/my-account/contact-support/?select=gocardless',
					),
				),
			),
			self::NEXI_CHECKOUT     => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'Nexi Checkout', 'woocommerce' ),
				'description' => esc_html__( 'A fully embedded checkout, with all popular payment methods, for more sales and less abandoned shopping carts.', 'woocommerce' ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/nexi.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'dibs-easy-for-woocommerce',
				),
				'links'       => array(
					array(
						'_type' => PaymentsProviders::LINK_TYPE_ABOUT,
						'url'   => 'https://woocommerce.com/products/nexi-checkout/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_TERMS,
						'url'   => 'https://support.nets.eu/document/nets-easy-general-terms-and-conditions-2022',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_DOCS,
						'url'   => 'https://woocommerce.com/document/nexi-checkout/',
					),
					array(
						'_type' => PaymentsProviders::LINK_TYPE_SUPPORT,
						'url'   => 'https://developer.nexigroup.com/nexi-checkout/en-EU/support/',
					),
				),
			),
			self::COINBASE          => array(
				'_type'  => self::TYPE_CRYPTO,
				'icon'   => plugins_url( 'assets/images/onboarding/icons/coinbase.svg', WC_PLUGIN_FILE ),
				'plugin' => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'coinbase-commerce',
				),
			),
			self::AUTHORIZE_NET     => array(
				'_type'  => self::TYPE_PSP,
				'icon'   => plugins_url( 'assets/images/onboarding/icons/authorize.svg', WC_PLUGIN_FILE ),
				'plugin' => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'woocommerce-gateway-authorize-net-cim',
				),
			),
			self::BILLIE            => array(
				'_type'       => self::TYPE_PSP,
				'title'       => esc_html__( 'Billie', 'woocommerce' ),
				'description' => esc_html__( 'Billie is the leading provider of Buy Now, Pay Later payment methods for B2B stores.', 'woocommerce' ),
				'icon'        => plugins_url( 'assets/images/onboarding/icons/billie.svg', WC_PLUGIN_FILE ),
				'plugin'      => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'billie-for-woocommerce',
				),
			),
			self::BOLT              => array(
				'_type'  => self::TYPE_PSP,
				'icon'   => plugins_url( 'assets/images/onboarding/icons/bolt.svg', WC_PLUGIN_FILE ),
				'plugin' => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'bolt-checkout-woocommerce',
				),
			),
			self::DEPAY             => array(
				'_type'  => self::TYPE_PSP,
				'icon'   => plugins_url( 'assets/images/onboarding/icons/depay.svg', WC_PLUGIN_FILE ),
				'plugin' => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'depay-payments-for-woocommerce',
				),
			),
			self::ELAVON            => array(
				'_type'  => self::TYPE_PSP,
				'icon'   => plugins_url( 'assets/images/onboarding/icons/elavon.svg', WC_PLUGIN_FILE ),
				'plugin' => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'woocommerce-gateway-converge',
				),
			),
			self::FORTISPAY         => array(
				'_type'  => self::TYPE_PSP,
				'icon'   => plugins_url( 'assets/images/onboarding/icons/fortispay.svg', WC_PLUGIN_FILE ),
				'plugin' => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'fortis-for-woocommerce',
				),
			),
			self::PAYPAL_ZETTLE     => array(
				'_type'  => self::TYPE_PSP,
				'icon'   => plugins_url( 'assets/images/onboarding/icons/paypal-zettle.svg', WC_PLUGIN_FILE ),
				'plugin' => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'zettle-pos-integration',
				),
			),
			self::RAPYD             => array(
				'_type'  => self::TYPE_PSP,
				'icon'   => plugins_url( 'assets/images/onboarding/icons/rapyd.svg', WC_PLUGIN_FILE ),
				'plugin' => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'rapyd-payments-plugin-for-woocommerce',
				),
			),
			self::PAYPAL_BRAINTREE  => array(
				'_type'  => self::TYPE_PSP,
				'icon'   => plugins_url( 'assets/images/onboarding/icons/paypal-braintree.svg', WC_PLUGIN_FILE ),
				'plugin' => array(
					'_type' => self::PLUGIN_TYPE_WPORG,
					'slug'  => 'woocommerce-gateway-paypal-powered-by-braintree',
				),
			),
		);

		return $this->extensions_base_details_memo;
	}

	/**
	 * Get the base details for a specific extension.
	 *
	 * @see self::standardize_extension_details() for the supported entries.
	 *
	 * @param string $extension_id The extension ID.
	 *
	 * @return ?array The extension base details.
	 *                Null if the extension is not one we have details for.
	 */
	private function get_extension_base_details( string $extension_id ): ?array {
		$extensions = $this->get_all_extensions_base_details();
		if ( ! isset( $extensions[ $extension_id ] ) ) {
			return null;
		}

		return $extensions[ $extension_id ];
	}

	/**
	 * Standardize the details for an extension.
	 *
	 * Ensures that the details array has all the required fields, and fills in any missing optional fields with defaults.
	 * We also enforce a consistent order for the fields.
	 *
	 * @param array $extension_details The extension details.
	 *
	 * @return array The standardized extension details.
	 */
	private function standardize_extension_details( array $extension_details ): array {
		$standardized = array();

		// Required fields.
		$standardized['id']        = $extension_details['id'];
		$standardized['_priority'] = $extension_details['_priority'];
		$standardized['_type']     = $extension_details['_type'];
		$standardized['plugin']    = $extension_details['plugin'];

		// Optional fields.
		$standardized['title']       = $extension_details['title'] ?? '';
		$standardized['description'] = $extension_details['description'] ?? '';
		$standardized['image']       = $extension_details['image'] ?? '';
		$standardized['icon']        = $extension_details['icon'] ?? '';
		$standardized['links']       = $extension_details['links'] ?? array();
		$standardized['tags']        = $extension_details['tags'] ?? array();
		$standardized['_incentive']  = $extension_details['_incentive'] ?? null;

		return $standardized;
	}

	/**
	 * Based on the WC onboarding profile, determine if the merchant is selling online.
	 *
	 * If the user skipped the profiler (no data points provided), we assume they are selling online.
	 *
	 * @return bool True if the merchant is selling online, false otherwise.
	 */
	private function is_merchant_selling_online(): bool {
		/*
		 * We consider a merchant to be selling online if:
		 * - The profiler was skipped (no data points provided).
		 *   OR
		 * - The merchant answered 'Which one of these best describes you?' with 'I’m already selling' AND:
		 *   - Didn't answer to the 'Are you selling online?' question.
		 *      OR
		 *   - Answered the 'Are you selling online?' question with either:
		 *     - 'Yes, I’m selling online'.
		 *        OR
		 *     - 'I’m selling both online and offline'.
		 *
		 * @see plugins/woocommerce/client/admin/client/core-profiler/pages/UserProfile.tsx for the values.
		 */
		$onboarding_profile = get_option( OnboardingProfile::DATA_OPTION, array() );
		if (
			! isset( $onboarding_profile['business_choice'] ) ||
			(
				'im_already_selling' === $onboarding_profile['business_choice'] &&
				(
					! isset( $onboarding_profile['selling_online_answer'] ) ||
					(
						'yes_im_selling_online' === $onboarding_profile['selling_online_answer'] ||
						'im_selling_both_online_and_offline' === $onboarding_profile['selling_online_answer']
					)
				)
			)
		) {
			return false;
		}

		return true;
	}

	/**
	 * Based on the WC onboarding profile, determine if the merchant is selling offline.
	 *
	 * If the user skipped the profiler (no data points provided), we assume they are NOT selling offline.
	 *
	 * @return bool True if the merchant is selling offline, false otherwise.
	 */
	private function is_merchant_selling_offline(): bool {
		/*
		 * We consider a merchant to be selling offline if:
		 * - The profiler was NOT skipped (data points provided).
		 *   AND
		 * - The merchant answered 'Which one of these best describes you?' with 'I’m already selling' AND:
		 *   - Answered the 'Are you selling online?' question with either:
		 *     - 'No, I’m selling offline'.
		 *        OR
		 *     - 'I’m selling both online and offline'.
		 *
		 * @see plugins/woocommerce/client/admin/client/core-profiler/pages/UserProfile.tsx for the values.
		 */
		$onboarding_profile = get_option( OnboardingProfile::DATA_OPTION, array() );
		if (
			isset( $onboarding_profile['business_choice'] ) &&
			(
				'im_already_selling' === $onboarding_profile['business_choice'] &&
				(
					isset( $onboarding_profile['selling_online_answer'] ) &&
					(
						'no_im_selling_offline' === $onboarding_profile['selling_online_answer'] ||
						'im_selling_both_online_and_offline' === $onboarding_profile['selling_online_answer']
					)
				)
			)
		) {
			return true;
		}

		return false;
	}
}
PK     [1]b0  0  ,  Admin/Suggestions/Incentives/WooPayments.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Suggestions\Incentives;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\WCAdminHelper;
use Automattic\WooCommerce\Enums\OrderInternalStatus;
use WC_Abstract_Order;

/**
 * WooPayments incentives provider class.
 *
 * @internal
 */
class WooPayments extends Incentive {
	/**
	 * The transient name for incentives cache.
	 *
	 * @var string
	 */
	protected string $cache_transient_name;

	/**
	 * The transient name used to store the value for if store has orders.
	 *
	 * @var string
	 */
	protected string $store_has_orders_transient_name;

	/**
	 * The option name used to store the value for if store had WooPayments in use.
	 *
	 * @var string
	 */
	protected string $store_had_woopayments_option_name;

	/**
	 * The memoized incentives to avoid fetching multiple times during a request.
	 *
	 * @var array|null
	 */
	private ?array $incentives_memo = null;

	/**
	 * Constructor.
	 *
	 * @param string $suggestion_id The suggestion ID.
	 */
	public function __construct( string $suggestion_id ) {
		parent::__construct( $suggestion_id );

		$this->cache_transient_name              = self::PREFIX . $suggestion_id . '_cache';
		$this->store_has_orders_transient_name   = self::PREFIX . $suggestion_id . '_store_has_orders';
		$this->store_had_woopayments_option_name = self::PREFIX . $suggestion_id . '_store_had_woopayments';
	}

	/**
	 * Check if an incentive should be visible.
	 *
	 * @param string $id                          The incentive ID to check for visibility.
	 * @param string $country_code                The business location country code to get incentives for.
	 * @param bool   $skip_extension_active_check Whether to skip the check for the extension plugin being active.
	 *
	 * @return boolean Whether the incentive should be visible.
	 */
	public function is_visible( string $id, string $country_code, bool $skip_extension_active_check = false ): bool {
		// Always skip the extension active check since we will check bellow.
		if ( false === parent::is_visible( $id, $country_code, true ) ) {
			return false;
		}

		// Instead of just extension active, we check if WooPayments is active and has an account.
		if ( ! $skip_extension_active_check && $this->is_extension_active() && $this->has_wcpay_account_data() ) {
			return false;
		}

		return true;
	}

	/**
	 * Clear the incentives cache.
	 */
	public function clear_cache() {
		delete_transient( $this->cache_transient_name );
		$this->reset_memo();
	}

	/**
	 * Reset the memoized incentives.
	 *
	 * This is useful for testing purposes.
	 */
	public function reset_memo() {
		$this->incentives_memo = null;
	}

	/**
	 * Check if the extension plugin is active.
	 *
	 * @return boolean Whether the extension plugin is active.
	 */
	protected function is_extension_active(): bool {
		return class_exists( '\WC_Payments' );
	}

	/**
	 * Fetches and caches eligible incentives from the WooPayments API.
	 *
	 * @param string $country_code The business location country code to get incentives for.
	 *
	 * @return array List of eligible incentives.
	 */
	protected function get_incentives( string $country_code ): array {
		if ( isset( $this->incentives_memo ) ) {
			return $this->incentives_memo;
		}

		// Get the cached data.
		$cache = get_transient( $this->cache_transient_name );

		// If the cached data is not expired, and it's a WP_Error,
		// it means there was an API error previously, and we should not retry just yet.
		if ( is_wp_error( $cache ) ) {
			// Initialize the in-memory cache and return it.
			$this->incentives_memo = array();

			return $this->incentives_memo;
		}

		// Gather the store context data.
		$store_context = array(
			'country'      => $country_code,
			// Store locale, e.g. `en_US`.
			'locale'       => get_locale(),
			// WooCommerce store active for duration in seconds.
			'active_for'   => WCAdminHelper::get_wcadmin_active_for_in_seconds(),
			'has_orders'   => $this->has_orders(),
			'has_payments' => $this->has_enabled_payment_gateways(),
			'has_wcpay'    => $this->has_wcpay(),
		);

		// Fingerprint the store context through a hash of certain entries.
		$store_context_hash = $this->generate_context_hash( $store_context );

		// Use the transient cached incentive if it exists, it is not expired,
		// and the store context hasn't changed since we last requested from the WooPayments API (based on context hash).
		if ( false !== $cache
			&& ! empty( $cache['context_hash'] ) && is_string( $cache['context_hash'] )
			&& hash_equals( $store_context_hash, $cache['context_hash'] ) ) {

			// We have a store context hash, and it matches with the current context one.
			// We can use the cached incentive data.
			// Store the incentives in the in-memory cache and return them.
			$this->incentives_memo = $cache['incentives'] ?? array();

			return $this->incentives_memo;
		}

		// By this point, we have an expired transient or the store context has changed.
		// Query for incentives by calling the WooPayments API.
		$url = add_query_arg(
			$store_context,
			'https://public-api.wordpress.com/wpcom/v2/wcpay/incentives',
		);

		$response = wp_remote_get(
			$url,
			array(
				'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ),
			)
		);

		// Return early if there is an error, waiting 6 hours before the next attempt.
		if ( is_wp_error( $response ) ) {
			// Store a trimmed down, lightweight error.
			$error = new \WP_Error(
				$response->get_error_code(),
				$response->get_error_message(),
				wp_remote_retrieve_response_code( $response )
			);
			// Store the error in the transient so we know this is due to an API error.
			set_transient( $this->cache_transient_name, $error, HOUR_IN_SECONDS * 6 );
			// Initialize the in-memory cache and return it.
			$this->incentives_memo = array();

			return $this->incentives_memo;
		}

		$cache_for = wp_remote_retrieve_header( $response, 'cache-for' );
		// Initialize the in-memory cache.
		$this->incentives_memo = array();

		if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
			// Decode the results, falling back to an empty array.
			$results = json_decode( wp_remote_retrieve_body( $response ), true ) ?? array();

			// Store incentives in the in-memory cache.
			$this->incentives_memo = $results;
		}

		// Skip transient cache if `cache-for` header equals zero.
		if ( '0' === $cache_for ) {
			// If we have a transient cache that is not expired, delete it so there are no leftovers.
			if ( false !== $cache ) {
				delete_transient( $this->cache_transient_name );
			}

			return $this->incentives_memo;
		}

		// Store incentive in transient cache (together with the context hash) for the given number of seconds
		// or 1 day in seconds. Also attach a timestamp to the transient data so we know when we last fetched.
		set_transient(
			$this->cache_transient_name,
			array(
				'incentives'   => $this->incentives_memo,
				'context_hash' => $store_context_hash,
				'timestamp'    => time(),
			),
			! empty( $cache_for ) ? (int) $cache_for : DAY_IN_SECONDS
		);

		return $this->incentives_memo;
	}

	/**
	 * Check if the WooPayments payment gateway is active and set up or was at some point,
	 * or there are orders processed with it, at some moment.
	 *
	 * @return boolean Whether the store has WooPayments.
	 */
	private function has_wcpay(): bool {
		// First, get the stored value, if it exists.
		// This way we avoid costly DB queries and API calls.
		// Basically, we only want to know if WooPayments was in use in the past.
		// Since the past can't be changed, neither can this value.
		$had_wcpay = get_option( $this->store_had_woopayments_option_name );
		if ( false !== $had_wcpay ) {
			return filter_var( $had_wcpay, FILTER_VALIDATE_BOOLEAN );
		}

		// We need to determine the value.
		// Start with the assumption that the store didn't have WooPayments in use.
		$had_wcpay = false;

		// We consider the store to have WooPayments if there is meaningful account data in the WooPayments account cache.
		// This implies that WooPayments was active at some point and that it was connected.
		// If WooPayments is active right now, we will not get to this point since the plugin is active check is done first.
		if ( $this->has_wcpay_account_data() ) {
			$had_wcpay = true;
		}

		// If there is at least one order processed with WooPayments, we consider the store to have WooPayments.
		if ( false === $had_wcpay && ! empty(
			wc_get_orders(
				array(
					'payment_method' => 'woocommerce_payments',
					'return'         => 'ids',
					'limit'          => 1,
					'orderby'        => 'none',
				)
			)
		) ) {
			$had_wcpay = true;
		}

		// Store the value for future use.
		update_option( $this->store_had_woopayments_option_name, $had_wcpay ? 'yes' : 'no' );

		return $had_wcpay;
	}

	/**
	 * Check if there is meaningful data in the WooPayments account cache.
	 *
	 * @return boolean
	 */
	private function has_wcpay_account_data(): bool {
		$account_data = get_option( 'wcpay_account_data', array() );
		if ( ! empty( $account_data['data']['account_id'] ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Check if the store has any paid orders.
	 *
	 * Currently, we look at the past 90 days and only consider orders
	 * with status `wc-completed`, `wc-processing`, or `wc-refunded`.
	 *
	 * @return boolean Whether the store has any paid orders.
	 */
	private function has_orders(): bool {
		// First, get the stored value, if it exists.
		// This way we avoid costly DB queries and API calls.
		$has_orders = get_transient( $this->store_has_orders_transient_name );
		if ( false !== $has_orders ) {
			return filter_var( $has_orders, FILTER_VALIDATE_BOOLEAN );
		}

		// We need to determine the value.
		// Start with the assumption that the store doesn't have orders in the timeframe we look at.
		$has_orders = false;
		// By default, we will check for new orders every 6 hours.
		$expiration = 6 * HOUR_IN_SECONDS;

		// Get the latest completed, processing, or refunded order.
		$latest_order = wc_get_orders(
			array(
				'status'  => array( OrderInternalStatus::COMPLETED, OrderInternalStatus::PROCESSING, OrderInternalStatus::REFUNDED ),
				'limit'   => 1,
				'orderby' => 'date',
				'order'   => 'DESC',
			)
		);
		if ( ! empty( $latest_order ) ) {
			$latest_order = reset( $latest_order );
			// If the latest order is within the timeframe we look at, we consider the store to have orders.
			// Otherwise, it clearly doesn't have orders.
			if ( $latest_order instanceof WC_Abstract_Order
				&& strtotime( (string) $latest_order->get_date_created() ) >= strtotime( '-90 days' ) ) {

				$has_orders = true;

				// For ultimate efficiency, we will check again after 90 days from the latest order
				// because in all that time we will consider the store to have orders regardless of new orders.
				$expiration = strtotime( (string) $latest_order->get_date_created() ) + 90 * DAY_IN_SECONDS - time();
			}
		}

		// Store the value for future use.
		set_transient( $this->store_has_orders_transient_name, $has_orders ? 'yes' : 'no', $expiration );

		return $has_orders;
	}

	/**
	 * Check if the store has at least one enabled payment gateway.
	 *
	 * @return boolean Whether the store has any enabled payment gateways.
	 */
	private function has_enabled_payment_gateways(): bool {
		$payment_gateways = WC()->payment_gateways()->payment_gateways;
		if ( empty( $payment_gateways ) || ! is_array( $payment_gateways ) ) {
			return false;
		}

		foreach ( $payment_gateways as $payment_gateway ) {
			if ( filter_var( $payment_gateway->enabled, FILTER_VALIDATE_BOOLEAN ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Generate a hash from the store context data.
	 *
	 * @param array $context The store context data.
	 *
	 * @return string The context hash.
	 */
	private function generate_context_hash( array $context ): string {
		// Include only certain entries in the context hash.
		// We need only discrete, user-interaction dependent data.
		// Entries like `active_for` have no place in the hash generation since they change automatically.
		return md5(
			wp_json_encode(
				array(
					'country'      => $context['country'] ?? '',
					'locale'       => $context['locale'] ?? '',
					'has_orders'   => $context['has_orders'] ?? false,
					'has_payments' => $context['has_payments'] ?? false,
					'has_wcpay'    => $context['has_wcpay'] ?? false,
				)
			)
		);
	}
}
PK     [1]#E*  *  *  Admin/Suggestions/Incentives/Incentive.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Suggestions\Incentives;

/**
 * Abstract class for payment extension suggestion incentive provider classes.
 */
abstract class Incentive {
	const PREFIX = 'woocommerce_admin_pes_incentive_';

	/**
	 * The user meta name for storing dismissed incentives.
	 *
	 * @var string
	 */
	protected string $dismissed_meta_name = self::PREFIX . 'dismissed';

	/**
	 * The suggestion ID this incentive provider is for.
	 *
	 * @var string
	 */
	protected string $suggestion_id;

	/**
	 * Constructor.
	 *
	 * @param string $suggestion_id The suggestion ID this incentive provider is for.
	 */
	public function __construct( string $suggestion_id ) {
		$this->suggestion_id = $suggestion_id;
	}

	/**
	 * Get the details of all the incentives.
	 *
	 * The incentives are filtered based on the country code, incentive type, if provided, and their visibility.
	 *
	 * @param string $country_code   The business location country code to get incentives for.
	 * @param string $incentive_type Optional. The type of incentive to check for.
	 *
	 * @return array The incentives list with details for each incentive.
	 */
	public function get_all( string $country_code, string $incentive_type = '' ): array {
		$incentives = array_filter(
			$this->get_incentives( $country_code ),
			fn( $incentive ) => $this->validate_incentive( $incentive )
		);

		if ( ! empty( $incentive_type ) ) {
			$incentives = array_filter(
				$incentives,
				function ( $incentive ) use ( $incentive_type ) {
					return $incentive['type'] === $incentive_type;
				}
			);
		}

		return array_values( $incentives );
	}

	/**
	 * Get an incentive by promo ID.
	 *
	 * The incentives are filtered based on the country code, incentive type, if provided, and their visibility.
	 *
	 * @param string $promo_id       The incentive promo ID.
	 * @param string $country_code   The business location country code to get incentives for.
	 * @param string $incentive_type Optional. The type of incentive to search for.
	 *
	 * @return ?array The incentive details. Returns null if there is no incentive available.
	 */
	public function get_by_promo_id( string $promo_id, string $country_code, string $incentive_type = '' ): ?array {
		$incentives = array_filter(
			$this->get_all( $country_code, $incentive_type ),
			function ( $incentive ) use ( $promo_id ) {
				return $incentive['promo_id'] === $promo_id;
			}
		);

		if ( empty( $incentives ) ) {
			return null;
		}

		// Get the first found incentive, in the unlikely case there are multiple incentives with the same promo ID.
		return reset( $incentives );
	}

	/**
	 * Get an incentive by ID.
	 *
	 * The incentives are filtered based on the country code, incentive type, if provided, and their visibility.
	 *
	 * @param string $incentive_id The incentive ID.
	 * @param string $country_code The business location country code to get incentives for.
	 *
	 * @return ?array The incentive details. Returns null if there is no incentive available.
	 */
	public function get_by_id( string $incentive_id, string $country_code ): ?array {
		$incentives = array_filter(
			$this->get_all( $country_code ),
			function ( $incentive ) use ( $incentive_id ) {
				return $incentive['id'] === $incentive_id;
			}
		);

		if ( empty( $incentives ) ) {
			return null;
		}

		// Get the first found incentive, in the unlikely case there are multiple incentives with the same ID.
		return reset( $incentives );
	}

	/**
	 * Check if an incentive should be visible.
	 *
	 * @param string $id                          The incentive ID to check for visibility.
	 * @param string $country_code                The business location country code to get incentives for.
	 * @param bool   $skip_extension_active_check Whether to skip the check for the extension plugin being active.
	 *
	 * @return boolean Whether the incentive should be visible.
	 */
	public function is_visible( string $id, string $country_code, bool $skip_extension_active_check = false ): bool {
		// The extension plugin must not be active, unless we are asked to skip the check.
		if ( ! $skip_extension_active_check && $this->is_extension_active() ) {
			return false;
		}

		// The current WP user must have the required capabilities.
		if ( ! $this->user_has_caps() ) {
			return false;
		}

		// An incentive must be available.
		if ( empty( $this->get_by_id( $id, $country_code ) ) ) {
			return false;
		}

		// If the incentive has been dismissed in all contexts, don't show it.
		// We don't know the full list of contexts, so we can't assume anything beyond `all`.
		if ( $this->is_dismissed( $id, 'all' ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Dismiss an incentive.
	 *
	 * @param string $id        The incentive ID to dismiss.
	 * @param string $context   Optional. The context ID in which the incentive is dismissed.
	 *                          This can be used to dismiss the same incentive in different contexts.
	 *                          If no context ID is provided, the incentive will be dismissed for all contexts.
	 * @param ?int   $timestamp Optional The timestamp when the incentive was dismissed.
	 *                          Defaults to the current time.
	 *
	 * @return bool True if the incentive was not previously dismissed and now it is.
	 *              False if the incentive was already dismissed, or we failed to persist the dismissal data.
	 */
	public function dismiss( string $id, string $context = 'all', ?int $timestamp = null ): bool {
		// If it is already dismissed, don't dismiss it again.
		if ( $this->is_dismissed( $id, $context ) ) {
			return false;
		}

		$all_dismissed_incentives = $this->get_all_dismissed_incentives();
		if ( empty( $all_dismissed_incentives[ $this->suggestion_id ] ) ) {
			$all_dismissed_incentives[ $this->suggestion_id ] = array();
			ksort( $all_dismissed_incentives );
		}

		$all_dismissed_incentives[ $this->suggestion_id ][] = array(
			'id'        => $id,
			'context'   => $context,
			'timestamp' => $timestamp ?? time(),
		);

		/**
		 * Fires when a payments extension suggestion incentive is dismissed.
		 *
		 * @param string $id            The incentive ID.
		 * @param string $suggestion_id The suggestion ID the incentive belongs to.
		 * @param string $context       The context ID in which the incentive is dismissed.
		 *                              Defaults to 'all'.
		 *
		 * @since 9.9.0
		 */
		do_action( 'woocommerce_admin_payments_extension_suggestion_incentive_dismissed', $id, $this->suggestion_id, $context );

		return $this->save_all_dismissed_incentives( $all_dismissed_incentives );
	}

	/**
	 * Check if an incentive has been manually dismissed.
	 *
	 * @param string $id      The incentive ID to check for dismissal.
	 * @param string $context Optional. The context ID in which to check for dismissal.
	 *                        If no context ID is provided, we check for dismissal in all contexts.
	 *
	 * @return boolean Whether the incentive has been manually dismissed.
	 */
	public function is_dismissed( string $id, string $context = '' ): bool {
		if ( empty( $id ) ) {
			return false;
		}

		$all_dismissed_incentives = $this->get_all_dismissed_incentives();

		// If there are no dismissed incentives for the suggestion, return early.
		$dismissed_incentives = $all_dismissed_incentives[ $this->suggestion_id ] ?? array();
		if ( empty( $dismissed_incentives ) ) {
			return false;
		}

		// Check if the incentive is dismissed in the given context.
		if ( in_array(
			$id,
			array_column(
				array_filter(
					$dismissed_incentives,
					// All context dismissals are always included.
					fn( $dismissed_incentive ) => 'all' === $dismissed_incentive['context'] || $context === $dismissed_incentive['context']
				),
				'id'
			),
			true
		) ) {
			return true;
		}

		return false;
	}

	/**
	 * Get the dismissals (contexts) for an incentive.
	 *
	 * @param string $id The incentive ID.
	 *
	 * @return array The contexts in which the incentive has been dismissed.
	 */
	public function get_dismissals( string $id ): array {
		$all_dismissed_incentives = $this->get_all_dismissed_incentives();

		// If there are no dismissed incentives for the suggestion, return early.
		$dismissed_incentives = $all_dismissed_incentives[ $this->suggestion_id ] ?? array();
		if ( empty( $dismissed_incentives ) ) {
			return array();
		}

		$dismissals = array_values(
			array_filter(
				$dismissed_incentives,
				fn( $dismissed_incentive ) => $id === $dismissed_incentive['id']
			)
		);

		return array_map(
			fn( $dismissed_incentive ) => array(
				'timestamp' => $dismissed_incentive['timestamp'],
				'context'   => $dismissed_incentive['context'],
			),
			$dismissals
		);
	}

	/**
	 * Get all the dismissed incentives grouped by suggestion.
	 *
	 * @return array The dismissed incentives grouped by suggestion.
	 */
	protected function get_all_dismissed_incentives(): array {
		$all_dismissed_incentives = get_user_meta( get_current_user_id(), $this->dismissed_meta_name, true );
		if ( empty( $all_dismissed_incentives ) ) {
			$all_dismissed_incentives = array();
		}

		return $all_dismissed_incentives;
	}

	/**
	 * Save all the dismissed incentives list.
	 *
	 * @param array $dismissed_incentives The dismissed incentives data.
	 *
	 * @return bool Whether the dismissed incentives were saved successfully.
	 */
	protected function save_all_dismissed_incentives( array $dismissed_incentives ): bool {
		return (bool) update_user_meta( get_current_user_id(), $this->dismissed_meta_name, $dismissed_incentives );
	}

	/**
	 * Check if the current user has the required capabilities to view incentives.
	 *
	 * @return bool Whether the current user has the required capabilities view incentives.
	 */
	protected function user_has_caps(): bool {
		return current_user_can( 'manage_woocommerce' );
	}

	/**
	 * Validate an incentive details.
	 *
	 * It will check if the incentive details have the required keys.
	 *
	 * @param array $incentive The incentive details.
	 *
	 * @return bool Whether the incentive data is valid.
	 */
	protected function validate_incentive( array $incentive ): bool {
		// The incentive must have an ID, a promo ID, and a type.
		$required_keys = array( 'id', 'promo_id', 'type' );
		foreach ( $required_keys as $key ) {
			if ( empty( $incentive[ $key ] ) ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Check if the corresponding extension suggestion plugin is active.
	 *
	 * @return boolean Whether the corresponding extension suggestion plugin is active.
	 */
	abstract protected function is_extension_active(): bool;

	/**
	 * Get eligible incentives.
	 *
	 * @param string $country_code The business location country code to get incentives for.
	 *
	 * @return array List of eligible incentives.
	 */
	abstract protected function get_incentives( string $country_code ): array;
}
PK     [1]	VV  V  ;  Admin/Suggestions/PaymentsExtensionSuggestionIncentives.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Suggestions;

use Automattic\WooCommerce\Internal\Admin\Suggestions\Incentives\Incentive;
use Automattic\WooCommerce\Internal\Admin\Suggestions\Incentives\WooPayments;

defined( 'ABSPATH' ) || exit;

/**
 * Partner payments extension suggestion incentives provider class.
 *
 * @internal
 */
class PaymentsExtensionSuggestionIncentives {
	/**
	 * The map of suggestion IDs to their respective incentives provider classes.
	 *
	 * @var array|\class-string[]
	 */
	private array $suggestion_incentives_class_map = array(
		PaymentsExtensionSuggestions::WOOPAYMENTS => WooPayments::class,
	);

	/**
	 * The instances of the incentives providers.
	 *
	 * @var Incentive[]
	 */
	private array $instances = array();

	/**
	 * Get the first found incentive details for a specific payment extension suggestion.
	 *
	 * @param string $suggestion_id         The suggestion ID.
	 * @param string $country_code          The business location country code to get incentives for.
	 * @param string $incentive_type        Optional. The type of incentive to check for.
	 * @param bool   $skip_visibility_check Optional. Whether to skip the visibility check for the incentives.
	 *
	 * @return ?array The incentive details. Returns null if there is no incentive available.
	 */
	public function get_incentive( string $suggestion_id, string $country_code, string $incentive_type = '', bool $skip_visibility_check = false ): ?array {
		$incentives = $this->get_incentives( $suggestion_id, $country_code, $incentive_type, $skip_visibility_check );
		if ( empty( $incentives ) ) {
			return null;
		}

		return reset( $incentives );
	}

	/**
	 * Get the incentives list for a specific payment extension suggestion.
	 *
	 * @param string $suggestion_id         The suggestion ID.
	 * @param string $country_code          The business location country code to get incentives for.
	 * @param string $incentive_type        Optional. The type of incentive to check for.
	 *                                      If not provided, all incentives for the suggestion will be returned.
	 * @param bool   $skip_visibility_check Optional. Whether to skip the visibility check for the incentives.
	 *
	 * @return array The incentives list.
	 */
	public function get_incentives( string $suggestion_id, string $country_code, string $incentive_type = '', bool $skip_visibility_check = false ): array {
		$provider = $this->get_incentive_instance( $suggestion_id );
		if ( null === $provider ) {
			return array();
		}

		$incentives = $provider->get_all( $country_code, $incentive_type );

		if ( ! $skip_visibility_check ) {
			$incentives = array_filter(
				$incentives,
				fn( $incentive ) => $provider->is_visible( $incentive['id'], $country_code )
			);
		}

		return array_values( $incentives );
	}

	/**
	 * Check if an incentive is visible.
	 *
	 * @param string $incentive_id                The incentive ID.
	 * @param string $suggestion_id               The suggestion ID this incentive is for.
	 * @param string $country_code                The business location country code to get incentives for.
	 * @param bool   $skip_extension_active_check Whether to skip the check for the extension plugin being active.
	 *
	 * @return bool Whether there is a visible incentive for the suggestion.
	 */
	public function is_incentive_visible(
		string $incentive_id,
		string $suggestion_id,
		string $country_code,
		bool $skip_extension_active_check = false
	): bool {
		$provider = $this->get_incentive_instance( $suggestion_id );
		if ( null === $provider ) {
			return false;
		}

		return $provider->is_visible( $incentive_id, $country_code, $skip_extension_active_check );
	}

	/**
	 * Check if an incentive has been dismissed for a specific payment extension suggestion.
	 *
	 * @param string $incentive_id  The incentive ID.
	 * @param string $suggestion_id The suggestion ID.
	 * @param string $context       Optional. The context ID in which the incentive is checked.
	 *
	 * @return bool Whether the incentive has been dismissed for the suggestion.
	 */
	public function is_incentive_dismissed( string $incentive_id, string $suggestion_id, string $context = '' ): bool {
		$provider = $this->get_incentive_instance( $suggestion_id );
		if ( null === $provider ) {
			return false;
		}

		return $provider->is_dismissed( $incentive_id, $context );
	}

	/**
	 * Get the dismissals (contexts) for an incentive.
	 *
	 * @param string $incentive_id The incentive ID.
	 * @param string $suggestion_id The suggestion ID.
	 *
	 * @return string[] The contexts in which the incentive has been dismissed.
	 */
	public function get_incentive_dismissals( string $incentive_id, string $suggestion_id ): array {
		$provider = $this->get_incentive_instance( $suggestion_id );
		if ( null === $provider ) {
			return array();
		}

		return $provider->get_dismissals( $incentive_id );
	}

	/**
	 * Dismiss an incentive for a specific payment extension suggestion.
	 *
	 * @param string $incentive_id  The incentive ID.
	 * @param string $suggestion_id The suggestion ID.
	 * @param string $context       Optional. The context ID for which the incentive should be dismissed.
	 *                              If not provided, the incentive will be dismissed for all contexts.
	 *
	 * @return bool True if the incentive was not previously dismissed and now it is. False otherwise.
	 * @throws \Exception If no incentives provider is available for the suggestion.
	 */
	public function dismiss_incentive( string $incentive_id, string $suggestion_id, string $context = 'all' ): bool {
		$provider = $this->get_incentive_instance( $suggestion_id );
		if ( null === $provider ) {
			throw new \Exception( 'No incentives provider for the suggestion.' );
		}

		return $provider->dismiss( $incentive_id, $context );
	}

	/**
	 * Get the incentive provider instance for a specific payment extension suggestion.
	 *
	 * @param string $suggestion_id The suggestion ID.
	 *
	 * @return ?Incentive The incentives provider instance for the suggestion.
	 *                    Returns null if no provider is available for the suggestion.
	 */
	public function get_incentive_instance( string $suggestion_id ): ?Incentive {
		if ( isset( $this->instances[ $suggestion_id ] ) ) {
			return $this->instances[ $suggestion_id ];
		}

		// If the suggestion ID is not mapped to an incentives provider class, return null.
		if ( ! isset( $this->suggestion_incentives_class_map[ $suggestion_id ] ) ) {
			$this->instances[ $suggestion_id ] = null;

			return null;
		}

		// Create an instance of the incentives provider class.
		$provider_class                    = $this->suggestion_incentives_class_map[ $suggestion_id ];
		$this->instances[ $suggestion_id ] = new $provider_class( $suggestion_id );

		return $this->instances[ $suggestion_id ];
	}

	/**
	 * Check if a specific payment extension suggestion has an incentive provider registered.
	 *
	 * @param string $suggestion_id The suggestion ID.
	 *
	 * @return bool Whether the suggestion has an incentive provider registered.
	 */
	public function has_incentive_provider( string $suggestion_id ): bool {
		return null !== $this->get_incentive_instance( $suggestion_id );
	}
}
PK     [1]"    "  Admin/Notes/OnboardingPayments.phpnu         <?php
/**
 * WooCommerce Admin: Payments reminder note.
 *
 * Adds a notes to complete the payment methods.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;

/**
 * Onboarding_Payments.
 */
class OnboardingPayments {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-onboarding-payments-reminder';

	/**
	 * Get the note.
	 *
	 * @return Note
	 */
	public static function get_note() {
		// We want to show the note after five days.
		if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4', 5 * DAY_IN_SECONDS ) ) {
			return;
		}

		// Check to see if any gateways have been added.
		$gateways         = WC()->payment_gateways->get_available_payment_gateways();
		$enabled_gateways = array_filter(
			$gateways,
			function( $gateway ) {
				return 'yes' === $gateway->enabled;
			}
		);
		if ( ! empty( $enabled_gateways ) ) {
			return;
		}

		$note = new Note();
		$note->set_title( __( 'Start accepting payments on your store!', 'woocommerce' ) );
		$note->set_content( __( 'Take payments with the provider that’s right for you - choose from 100+ payment gateways for WooCommerce.', 'woocommerce' ) );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_content_data( (object) array() );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action(
			'view-payment-gateways',
			__( 'Learn more', 'woocommerce' ),
			'https://woocommerce.com/product-category/woocommerce-extensions/payment-gateways/?utm_medium=product',
			Note::E_WC_ADMIN_NOTE_ACTIONED,
			true
		);
		return $note;
	}
}
PK     [1]
  
  )  Admin/Notes/ScheduledUpdatesPromotion.phpnu         <?php
/**
 * WooCommerce Admin Scheduled Updates Promotion Note Provider.
 *
 * Adds a note to the merchant's inbox promoting scheduled updates for analytics.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;

/**
 * ScheduledUpdatesPromotion
 *
 * @since 10.5.0
 */
class ScheduledUpdatesPromotion {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-scheduled-updates-promotion';

	/**
	 * Name of the option to check.
	 */
	const OPTION_NAME = 'woocommerce_analytics_scheduled_import';

	/**
	 * Constructor - attach action hooks.
	 */
	public function __construct() {
		add_action( 'woocommerce_note_action_scheduled-updates-enable', array( $this, 'enable_scheduled_updates' ) );
	}

	/**
	 * Should this note exist?
	 *
	 * @return bool
	 */
	public static function is_applicable() {
		if ( ! Features::is_enabled( 'analytics-scheduled-import' ) ) {
			return false;
		}

		// Get the current option value.
		// Note: get_option() returns false when option doesn't exist.
		$immediate_import = get_option( self::OPTION_NAME, false );

		// Only show to existing sites (false/not set) that haven't migrated yet.
		// New sites have the option set during onboarding, so they won't see this.
		if ( false !== $immediate_import ) {
			return false;
		}

		return true;
	}

	/**
	 * Get the note.
	 *
	 * @return Note|null
	 */
	public static function get_note() {
		if ( ! self::is_applicable() ) {
			return null;
		}

		$note = new Note();

		$note->set_title( __( 'Analytics now supports scheduled updates', 'woocommerce' ) );
		$note->set_content( __( 'This provides improved performance to your store, enable it in Analytics > Settings.', 'woocommerce' ) );
		$note->set_content_data( (object) array() );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );

		// Add "Enable" action with custom handler.
		$note->add_action(
			'scheduled-updates-enable',
			__( 'Enable', 'woocommerce' ),
			wc_admin_url(),
			Note::E_WC_ADMIN_NOTE_ACTIONED,
			true,
			__( 'Scheduled updates enabled', 'woocommerce' )
		);

		return $note;
	}

	/**
	 * Enable scheduled updates when the action is triggered.
	 *
	 * @param Note $note The note being actioned.
	 * @return void
	 */
	public function enable_scheduled_updates( $note ): void {
		// Verify this is our note.
		if ( self::NOTE_NAME !== $note->get_name() ) {
			return;
		}

		// Update the option to enable scheduled mode.
		update_option( self::OPTION_NAME, 'yes' );
	}
}
PK     [1]NF       Admin/Notes/MarketingJetpack.phpnu         <?php
/**
 * WooCommerce Admin Jetpack Marketing Note Provider.
 *
 * Adds notes to the merchant's inbox concerning Jetpack Backup.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Admin\PluginsHelper;

/**
 * Suggest Jetpack Backup to Woo users.
 *
 * Note: This should probably live in the Jetpack plugin in the future.
 *
 * @see  https://developer.woocommerce.com/2020/10/16/using-the-admin-notes-inbox-in-woocommerce/
 */
class MarketingJetpack {
	// Shared Note Traits.
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-marketing-jetpack-backup';

	/**
	 * Product IDs that include Backup.
	 */
	const BACKUP_IDS = [
		2010,
		2011,
		2012,
		2013,
		2014,
		2015,
		2100,
		2101,
		2102,
		2103,
		2005,
		2006,
		2000,
		2003,
		2001,
		2004,
	];

	/**
	 * Maybe add a note on Jetpack Backups for Jetpack sites older than a week without Backups.
	 */
	public static function possibly_add_note() {
		/**
		 * Check if Jetpack is installed.
		 */
		$installed_plugins = PluginsHelper::get_installed_plugin_slugs();
		if ( ! in_array( 'jetpack', $installed_plugins, true ) ) {
			return;
		}

		$data_store = \WC_Data_Store::load( 'admin-note' );

		// Do we already have this note?
		$note_ids = $data_store->get_notes_with_name( self::NOTE_NAME );
		if ( ! empty( $note_ids ) ) {

			$note_id = array_pop( $note_ids );
			$note    = Notes::get_note( $note_id );
			if ( false === $note ) {
				return;
			}

			// If Jetpack Backups was purchased after the note was created, mark this note as actioned.
			if ( self::has_backups() && Note::E_WC_ADMIN_NOTE_ACTIONED !== $note->get_status() ) {
				$note->set_status( Note::E_WC_ADMIN_NOTE_ACTIONED );
				$note->save();
			}

			return;
		}

		// Check requirements.
		if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4', DAY_IN_SECONDS * 3 ) || ! self::can_be_added() || self::has_backups() ) {
			return;
		}

		// Add note.
		$note = self::get_note();
		$note->save();
	}

	/**
	 * Get the note.
	 */
	public static function get_note() {
		$note = new Note();
		$note->set_title( __( 'Protect your WooCommerce Store with Jetpack Backup.', 'woocommerce' ) );
		$note->set_content( __( 'Store downtime means lost sales. One-click restores get you back online quickly if something goes wrong.', 'woocommerce' ) );
		$note->set_type( Note::E_WC_ADMIN_NOTE_MARKETING );
		$note->set_name( self::NOTE_NAME );
		$note->set_layout( 'thumbnail' );
		$note->set_image(
			WC_ADMIN_IMAGES_FOLDER_URL . '/admin_notes/marketing-jetpack-2x.png'
		);
		$note->set_content_data( (object) array() );
		$note->set_source( 'woocommerce-admin-notes' );
		$note->add_action(
			'jetpack-backup-woocommerce',
			__( 'Get backups', 'woocommerce' ),
			esc_url( 'https://jetpack.com/upgrade/backup-woocommerce/?utm_source=inbox&utm_medium=automattic_referred&utm_campaign=jp_backup_to_woo' ),
			Note::E_WC_ADMIN_NOTE_ACTIONED
		);
		return $note;
	}

	/**
	 * Check if this blog already has a Jetpack Backups product.
	 *
	 * @return boolean  Whether or not this blog has backups.
	 */
	protected static function has_backups() {
		$product_ids = [];

		$plan = get_option( 'jetpack_active_plan' );
		if ( ! empty( $plan ) ) {
			$product_ids[] = $plan['product_id'];
		}

		$products = get_option( 'jetpack_site_products' );
		if ( ! empty( $products ) ) {
			foreach ( $products as $product ) {
				$product_ids[] = $product['product_id'];
			}
		}

		return (bool) array_intersect( self::BACKUP_IDS, $product_ids );
	}

}
PK     [1]p)  )  #  Admin/Notes/ManageOrdersOnTheGo.phpnu         <?php
/**
 * WooCommerce Admin Manage orders on the go note.
 *
 * Adds a note to download the mobile app to manage orders on the go.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;

/**
 * Manage_Orders_On_The_Go
 */
class ManageOrdersOnTheGo {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-manage-orders-on-the-go';

	/**
	 * Get the note.
	 *
	 * @return Note|null
	 */
	public static function get_note() {
		// Only add this note if this store is at least 6 months old.
		if ( ! self::is_wc_admin_active_in_date_range( 'month-6+' ) ) {
			return;
		}

		// Check that the previous mobile app notes have not been actioned.
		if ( MobileApp::has_note_been_actioned() ) {
			return;
		}
		if ( RealTimeOrderAlerts::has_note_been_actioned() ) {
			return;
		}

		$note = new Note();

		$note->set_title( __( 'Manage your orders on the go', 'woocommerce' ) );
		$note->set_content( __( 'Look for orders, customer info, and process refunds in one click with the Woo app.', 'woocommerce' ) );
		$note->set_content_data( (object) array() );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action(
			'learn-more',
			__( 'Learn more', 'woocommerce' ),
			'https://woocommerce.com/mobile/?utm_source=inbox&utm_medium=product'
		);

		return $note;
	}
}
PK     [1]    #  Admin/Notes/GivingFeedbackNotes.phpnu         <?php
/**
 * WooCommerce Admin (Dashboard) Giving feedback notes provider
 *
 * Adds notes to the merchant's inbox about giving feedback.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Internal\Admin\Survey;

/**
 * Giving_Feedback_Notes
 */
class GivingFeedbackNotes {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-store-notice-giving-feedback-2';

	/**
	 * Get the note.
	 *
	 * @return Note
	 */
	public static function get_note() {
		if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4' ) ) {
			return;
		}

		// Otherwise, create our new note.
		$note = new Note();
		$note->set_title( __( 'You\'re invited to share your experience', 'woocommerce' ) );
		$note->set_content( __( 'Now that you’ve chosen us as a partner, our goal is to make sure we\'re providing the right tools to meet your needs. We\'re looking forward to having your feedback on the store setup experience so we can improve it in the future.', 'woocommerce' ) );
		$note->set_content_data( (object) array() );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action(
			'share-feedback',
			__( 'Share feedback', 'woocommerce' ),
			Survey::get_url( '/store-setup-survey' )
		);
		return $note;
	}
}
PK     [1]uo4  4  %  Admin/Notes/WooSubscriptionsNotes.phpnu         <?php
/**
 * WooCommerce Admin (Dashboard) WooCommerce.com Extension Subscriptions Note Provider.
 *
 * Adds notes to the merchant's inbox concerning WooCommerce.com extension subscriptions.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Admin\PageController;

/**
 * Woo_Subscriptions_Notes
 */
class WooSubscriptionsNotes {
	const LAST_REFRESH_OPTION_KEY = 'woocommerce_admin-wc-helper-last-refresh';
	const NOTE_NAME               = 'wc-admin-wc-helper-connection';
	const CONNECTION_NOTE_NAME    = 'wc-admin-wc-helper-connection'; // deprecated.
	const SUBSCRIPTION_NOTE_NAME  = 'wc-admin-wc-helper-subscription';
	const NOTIFY_WHEN_DAYS_LEFT   = 60;
	const BUMP_THRESHOLDS         = array( 60, 45, 20, 7, 1 ); // days.

	/**
	 * Hook all the things.
	 */
	public function __construct() {
		add_action( 'admin_head', array( $this, 'admin_head' ) );
		add_action( 'update_option_woocommerce_helper_data', array( $this, 'update_option_woocommerce_helper_data' ), 10, 2 );
	}

	/**
	 * Reacts to changes in the helper option.
	 *
	 * @param array $old_value The previous value of the option.
	 * @param array $value The new value of the option.
	 */
	public function update_option_woocommerce_helper_data( $old_value, $value ) {
		if ( ! is_array( $old_value ) ) {
			$old_value = array();
		}
		if ( ! is_array( $value ) ) {
			$value = array();
		}

		$old_auth  = array_key_exists( 'auth', $old_value ) ? $old_value['auth'] : array();
		$new_auth  = array_key_exists( 'auth', $value ) ? $value['auth'] : array();
		$old_token = array_key_exists( 'access_token', $old_auth ) ? $old_auth['access_token'] : '';
		$new_token = array_key_exists( 'access_token', $new_auth ) ? $new_auth['access_token'] : '';

		// The site just disconnected.
		if ( ! empty( $old_token ) && empty( $new_token ) ) {
			$this->remove_notes();
			return;
		}

		// The site is connected.
		if ( $this->is_connected() ) {
			$this->remove_notes();
			$this->refresh_subscription_notes();
			return;
		}
	}

	/**
	 * Runs on `admin_head` hook. Checks the connection and refreshes subscription notes on relevant pages.
	 */
	public function admin_head() {
		if ( ! PageController::is_admin_or_embed_page() ) {
			// To avoid unnecessarily calling Helper API, we only want to refresh subscription notes,
			// if the request is initiated from the wc admin dashboard or a WC related page which includes
			// the Activity button in WC header.
			return;
		}

		$this->check_connection();

		if ( $this->is_connected() ) {
			$refresh_notes = false;

			// Did the user just do something on the helper page?.
			if ( isset( $_GET['wc-helper-status'] ) ) { // @codingStandardsIgnoreLine.
				$refresh_notes = true;
			}

			// Has it been more than a day since we last checked?
			// Note: We do it this way and not wp_scheduled_task since WC_Helper_Options is not loaded for cron.
			$time_now_gmt = current_time( 'timestamp', 0 );
			$last_refresh = intval( get_option( self::LAST_REFRESH_OPTION_KEY, 0 ) );
			if ( $last_refresh + DAY_IN_SECONDS <= $time_now_gmt ) {
				update_option( self::LAST_REFRESH_OPTION_KEY, $time_now_gmt );
				$refresh_notes = true;
			}

			if ( $refresh_notes ) {
				$this->refresh_subscription_notes();
			}
		}
	}

	/**
	 * Checks the connection. Adds a note (as necessary) if there is no connection.
	 */
	public function check_connection() {
		if ( ! $this->is_connected() ) {
			$data_store = Notes::load_data_store();
			$note_ids   = $data_store->get_notes_with_name( self::CONNECTION_NOTE_NAME );
			if ( ! empty( $note_ids ) ) {
				// We already have a connection note. Exit early.
				return;
			}

			$this->remove_notes();
		}
	}

	/**
	 * Whether or not we think the site is currently connected to WooCommerce.com.
	 *
	 * @return bool
	 */
	public function is_connected() {
		$auth = \WC_Helper_Options::get( 'auth' );
		return ( ! empty( $auth['access_token'] ) );
	}

	/**
	 * Returns the WooCommerce.com provided site ID for this site.
	 *
	 * @return int|false
	 */
	public function get_connected_site_id() {
		if ( ! $this->is_connected() ) {
			return false;
		}

		$auth = \WC_Helper_Options::get( 'auth' );
		return absint( $auth['site_id'] );
	}

	/**
	 * Returns an array of product_ids whose subscriptions are active on this site.
	 *
	 * @return array
	 */
	public function get_subscription_active_product_ids() {
		$site_id = $this->get_connected_site_id();
		if ( ! $site_id ) {
			return array();
		}

		$product_ids = array();

		if ( $this->is_connected() ) {
			try {
				$subscriptions = \WC_Helper::get_subscriptions();
			} catch ( \Exception $e ) {
				$subscriptions = array();
			}

			foreach ( (array) $subscriptions as $subscription ) {
				if ( in_array( $site_id, $subscription['connections'], true ) ) {
					$product_ids[] = $subscription['product_id'];
				}
			}
		}

		return $product_ids;
	}

	/**
	 * Clears all connection or subscription notes.
	 */
	public function remove_notes() {
		Notes::delete_notes_with_name( self::CONNECTION_NOTE_NAME );
		Notes::delete_notes_with_name( self::SUBSCRIPTION_NOTE_NAME );
	}

	/**
	 * Gets the product_id (if any) associated with a note.
	 *
	 * @param Note $note The note object to interrogate.
	 * @return int|false
	 */
	public function get_product_id_from_subscription_note( &$note ) {
		if ( ! is_object( $note ) ) {
			return false;
		}
		$content_data = $note->get_content_data();

		if ( property_exists( $content_data, 'product_id' ) ) {
			return intval( $content_data->product_id );
		}

		return false;
	}

	/**
	 * Removes notes for product_ids no longer active on this site.
	 */
	public function prune_inactive_subscription_notes() {
		$active_product_ids = $this->get_subscription_active_product_ids();

		$data_store = Notes::load_data_store();
		$note_ids   = $data_store->get_notes_with_name( self::SUBSCRIPTION_NOTE_NAME );

		foreach ( (array) $note_ids as $note_id ) {
			$note       = Notes::get_note( $note_id );
			$product_id = $this->get_product_id_from_subscription_note( $note );
			if ( ! empty( $product_id ) ) {
				if ( ! in_array( $product_id, $active_product_ids, true ) ) {
					$note->delete();
				}
			}
		}
	}

	/**
	 * Finds a note for a given product ID, if the note exists at all.
	 *
	 * @param int $product_id The product ID to search for.
	 * @return Note|false
	 */
	public function find_note_for_product_id( $product_id ) {
		$product_id = intval( $product_id );

		$data_store = Notes::load_data_store();
		$note_ids   = $data_store->get_notes_with_name( self::SUBSCRIPTION_NOTE_NAME );
		foreach ( (array) $note_ids as $note_id ) {
			$note             = Notes::get_note( $note_id );
			$found_product_id = $this->get_product_id_from_subscription_note( $note );

			if ( $product_id === $found_product_id ) {
				return $note;
			}
		}

		return false;
	}

	/**
	 * Deletes a note for a given product ID, if the note exists at all.
	 *
	 * @param int $product_id The product ID to search for.
	 */
	public function delete_any_note_for_product_id( $product_id ) {
		$product_id = intval( $product_id );

		$note = $this->find_note_for_product_id( $product_id );
		if ( $note ) {
			$note->delete();
		}
	}

	/**
	 * Adds or updates a note for an expiring subscription.
	 *
	 * @param array $subscription The subscription to work with.
	 */
	public function add_or_update_subscription_expiring( $subscription ) {
		$product_id            = $subscription['product_id'];
		$product_name          = $subscription['product_name'];
		$expires               = intval( $subscription['expires'] );
		$time_now_gmt          = current_time( 'timestamp', 0 );
		$days_until_expiration = intval( ceil( ( $expires - $time_now_gmt ) / DAY_IN_SECONDS ) );

		$note = $this->find_note_for_product_id( $product_id );

		// Note: There is no reason this property should not exist. This is just defensive programming.
		if ( $note && property_exists( $note->get_content_data(), 'days_until_expiration' ) ) {
			$note_days_until_expiration = intval( $note->get_content_data()->days_until_expiration );
			if ( $days_until_expiration === $note_days_until_expiration ) {
				// Note is already up to date. Bail.
				return;
			}

			// If we have a note and we are at or have crossed a threshold, we should delete
			// the old note and create a new one, thereby "bumping" the note to the top of the inbox.
			foreach ( (array) self::BUMP_THRESHOLDS as $bump_threshold ) {
				if ( ( $note_days_until_expiration > $bump_threshold ) && ( $days_until_expiration <= $bump_threshold ) ) {
					$note->delete();
					$note = false;
					break;
				}
			}
		}

		$note_title = sprintf(
			/* translators: name of the extension subscription expiring soon */
			__( '%s subscription expiring soon', 'woocommerce' ),
			$product_name
		);

		$note_content = sprintf(
			/* translators: number of days until the subscription expires */
			__( 'Your subscription expires in %d days. Enable autorenew to avoid losing updates and access to support.', 'woocommerce' ),
			$days_until_expiration
		);

		$note_content_data = (object) array(
			'product_id'            => $product_id,
			'product_name'          => $product_name,
			'expired'               => false,
			'days_until_expiration' => $days_until_expiration,
		);

		if ( ! $note ) {
			$note = new Note();
		}

		// Reset everything in case we are repurposing an expired note as an expiring note.
		$note->set_title( $note_title );
		$note->set_type( Note::E_WC_ADMIN_NOTE_WARNING );
		$note->set_name( self::SUBSCRIPTION_NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note->clear_actions();
		$note->add_action(
			'enable-autorenew',
			__( 'Enable Autorenew', 'woocommerce' ),
			'https://woocommerce.com/my-account/my-subscriptions/?utm_medium=product'
		);
		$note->set_content( $note_content );
		$note->set_content_data( $note_content_data );
		$note->save();
	}

	/**
	 * Adds a note for an expired subscription, or updates an expiring note to expired.
	 *
	 * @param array $subscription The subscription to work with.
	 */
	public function add_or_update_subscription_expired( $subscription ) {
		$product_id   = $subscription['product_id'];
		$product_name = $subscription['product_name'];
		$product_page = $subscription['product_url'];
		$expires      = intval( $subscription['expires'] );
		$expires_date = gmdate( 'F jS', $expires );

		$note = $this->find_note_for_product_id( $product_id );
		if ( $note ) {
			$note_content_data = $note->get_content_data();
			if ( $note_content_data->expired ) {
				// We've already got a full fledged expired note for this. Bail.
				// Expired notes' content don't change with time.
				return;
			}
		}

		$note_title = sprintf(
			/* translators: name of the extension subscription that expired */
			__( '%s subscription expired', 'woocommerce' ),
			$product_name
		);

		$note_content = sprintf(
			/* translators: date the subscription expired, e.g. Jun 7th 2018 */
			__( 'Your subscription expired on %s. Get a new subscription to continue receiving updates and access to support.', 'woocommerce' ),
			$expires_date
		);

		$note_content_data = (object) array(
			'product_id'   => $product_id,
			'product_name' => $product_name,
			'expired'      => true,
			'expires'      => $expires,
			'expires_date' => $expires_date,
		);

		if ( ! $note ) {
			$note = new Note();
		}

		$note->set_title( $note_title );
		$note->set_content( $note_content );
		$note->set_content_data( $note_content_data );
		$note->set_type( Note::E_WC_ADMIN_NOTE_WARNING );
		$note->set_name( self::SUBSCRIPTION_NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note->clear_actions();
		$note->add_action(
			'renew-subscription',
			__( 'Renew Subscription', 'woocommerce' ),
			$product_page
		);
		$note->save();
	}

	/**
	 * For each active subscription on this site, checks the expiration date and creates/updates/deletes notes.
	 */
	public function refresh_subscription_notes() {
		if ( ! $this->is_connected() ) {
			return;
		}

		$this->prune_inactive_subscription_notes();

		try {
			$subscriptions = \WC_Helper::get_subscriptions();
		} catch ( \Exception $e ) {
			$subscriptions = array();
		}
		$active_product_ids = $this->get_subscription_active_product_ids();

		foreach ( (array) $subscriptions as $subscription ) {
			// Only concern ourselves with active products.
			$product_id = $subscription['product_id'];
			if ( ! in_array( $product_id, $active_product_ids, true ) ) {
				continue;
			}

			// If the subscription will auto-renew, clean up and exit.
			if ( $subscription['autorenew'] ) {
				$this->delete_any_note_for_product_id( $product_id );
				continue;
			}

			// If the subscription is not expiring by the first threshold, clean up and exit.
			$first_threshold = DAY_IN_SECONDS * self::BUMP_THRESHOLDS[0];
			$expires         = intval( $subscription['expires'] );
			$time_now_gmt    = current_time( 'timestamp', 0 );
			if ( $expires > $time_now_gmt + $first_threshold ) {
				$this->delete_any_note_for_product_id( $product_id );
				continue;
			}

			// Otherwise, if the subscription can still have auto-renew enabled, let them know that now.
			if ( $expires > $time_now_gmt ) {
				$this->add_or_update_subscription_expiring( $subscription );
				continue;
			}

			// If we got this far, the subscription has completely expired, let them know.
			$this->add_or_update_subscription_expired( $subscription );
		}
	}
}
PK     [1]4d    %  Admin/Notes/EditProductsOnTheMove.phpnu         <?php
/**
 * WooCommerce Admin Edit products on the move note.
 *
 * Adds a note to download the mobile app.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;

/**
 * Edit_Products_On_The_Move
 */
class EditProductsOnTheMove {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-edit-products-on-the-move';

	/**
	 * Get the note.
	 *
	 * @return Note
	 */
	public static function get_note() {
		// Only add this note if this store is at least a year old.
		$year_in_seconds = 365 * DAY_IN_SECONDS;
		if ( ! self::wc_admin_active_for( $year_in_seconds ) ) {
			return;
		}

		// Check that the previous mobile app notes have not been actioned.
		if ( MobileApp::has_note_been_actioned() ) {
			return;
		}
		if ( RealTimeOrderAlerts::has_note_been_actioned() ) {
			return;
		}
		if ( ManageOrdersOnTheGo::has_note_been_actioned() ) {
			return;
		}
		if ( PerformanceOnMobile::has_note_been_actioned() ) {
			return;
		}

		$note = new Note();

		$note->set_title( __( 'Edit products on the move', 'woocommerce' ) );
		$note->set_content( __( 'Edit and create new products from your mobile devices with the Woo app', 'woocommerce' ) );
		$note->set_content_data( (object) array() );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action(
			'learn-more',
			__( 'Learn more', 'woocommerce' ),
			'https://woocommerce.com/mobile/?utm_source=inbox&utm_medium=product'
		);

		return $note;
	}
}
PK     [1]6p      Admin/Notes/TrackingOptIn.phpnu         <?php
/**
 * WooCommerce Admin Usage Tracking Opt In Note Provider.
 *
 * Adds a Usage Tracking Opt In extension note.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use WC_Tracks;

/**
 * Tracking_Opt_In
 */
class TrackingOptIn {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-usage-tracking-opt-in';

	/**
	 * Attach hooks.
	 */
	public function __construct() {
		add_action( 'woocommerce_note_action_tracking-opt-in', array( $this, 'opt_in_to_tracking' ) );
	}

	/**
	 * Get the note.
	 *
	 * @return Note|null
	 */
	public static function get_note() {
		// Only show this note to stores that are opted out.
		if ( 'yes' === get_option( 'woocommerce_allow_tracking', 'no' ) ) {
			return;
		}

		// We want to show the note after one week.
		if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4' ) ) {
			return;
		}

		/* translators: 1: open link to WooCommerce.com settings, 2: open link to WooCommerce.com tracking documentation, 3: close link tag. */
		$content_format = __(
			'Gathering usage data allows us to improve WooCommerce. Your store will be considered as we evaluate new features, judge the quality of an update, or determine if an improvement makes sense. You can always visit the %1$sSettings%3$s and choose to stop sharing data. %2$sRead more%3$s about what data we collect.',
			'woocommerce'
		);

		$note_content = sprintf(
			$content_format,
			'<a href="' . esc_url( admin_url( 'admin.php?page=wc-settings&tab=advanced&section=woocommerce_com' ) ) . '" target="_blank">',
			'<a href="https://woocommerce.com/usage-tracking?utm_medium=product" target="_blank">',
			'</a>'
		);

		$note = new Note();
		$note->set_title( __( 'Help WooCommerce improve with usage tracking', 'woocommerce' ) );
		$note->set_content( $note_content );
		$note->set_content_data( (object) array() );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action( 'tracking-opt-in', __( 'Activate usage tracking', 'woocommerce' ), false, Note::E_WC_ADMIN_NOTE_ACTIONED, true, __( 'Usage tracking activated', 'woocommerce' ) );
		return $note;
	}

	/**
	 * Opt in to usage tracking when note is actioned.
	 *
	 * @param Note $note Note being acted upon.
	 */
	public function opt_in_to_tracking( $note ) {
		if ( self::NOTE_NAME === $note->get_name() ) {
			// Get the previous value of the tracking.
			$prev_value = get_option( 'woocommerce_allow_tracking', 'no' );

			// Opt in to tracking and schedule the first data update.
			// Same mechanism as in WC_Admin_Setup_Wizard::wc_setup_store_setup_save().
			update_option( 'woocommerce_allow_tracking', 'yes' );

			// Track woocommerce_allow_tracking_toggled in case was set as 'no' before.
			if ( class_exists( 'WC_Tracks' ) && 'no' === $prev_value ) {
				WC_Tracks::track_woocommerce_allow_tracking_toggled( $prev_value, 'yes', 'usage_tracking_note' );
			}

			wp_schedule_single_event( time() + 10, 'woocommerce_tracker_send_event', array( true ) );
		}
	}
}
PK     [1]jF    (  Admin/Notes/WooCommerceSubscriptions.phpnu         <?php
/**
 * WooCommerce Admin: WooCommerce Subscriptions.
 *
 * Adds a note to learn more about WooCommerce Subscriptions.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;

/**
 * WooCommerce_Subscriptions.
 */
class WooCommerceSubscriptions {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-woocommerce-subscriptions';

	/**
	 * Get the note.
	 *
	 * @return Note|null
	 */
	public static function get_note() {
		$onboarding_data = get_option( OnboardingProfile::DATA_OPTION, array() );

		if ( ! isset( $onboarding_data['product_types'] ) || ! in_array( 'subscriptions', $onboarding_data['product_types'], true ) ) {
			return;
		}

		if ( ! self::is_wc_admin_active_in_date_range( 'week-1', DAY_IN_SECONDS ) ) {
			return;
		}

		$note = new Note();
		$note->set_title( __( 'Do you need more info about WooCommerce Subscriptions?', 'woocommerce' ) );
		$note->set_content( __( 'WooCommerce Subscriptions allows you to introduce a variety of subscriptions for physical or virtual products and services. Create product-of-the-month clubs, weekly service subscriptions or even yearly software billing packages. Add sign-up fees, offer free trials, or set expiration periods.', 'woocommerce' ) );
		$note->set_type( Note::E_WC_ADMIN_NOTE_MARKETING );
		$note->set_name( self::NOTE_NAME );
		$note->set_content_data( (object) array() );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action(
			'learn-more',
			__( 'Learn More', 'woocommerce' ),
			'https://woocommerce.com/products/woocommerce-subscriptions/?utm_source=inbox&utm_medium=product',
			Note::E_WC_ADMIN_NOTE_UNACTIONED,
			true
		);
		return $note;
	}
}
PK     [1]$<C  C  !  Admin/Notes/EmailImprovements.phpnu         <?php
/**
 * Adds a note when the email improvements feature is enabled for existing stores
 * or when the feature is not enabled to try the new templates.
 *
 * @since 9.9.0
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Internal\Admin\EmailImprovements\EmailImprovements as EmailImprovementsFeature;
/**
 * EmailImprovements
 */
class EmailImprovements {
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-email-improvements';

	/**
	 * Get the note.
	 *
	 * @return Note|void
	 */
	public static function get_note() {
		if ( EmailImprovementsFeature::is_email_improvements_enabled_for_existing_stores() ) {
			return self::get_email_improvements_enabled_note();
		}

		if ( EmailImprovementsFeature::should_notify_merchant_about_email_improvements() ) {
			return self::get_try_email_improvements_note();
		}
	}

	/**
	 * Get the note for when the email improvements feature is enabled for existing stores.
	 *
	 * @return Note
	 */
	private static function get_email_improvements_enabled_note() {
		$note = new Note();
		$note->set_title( __( 'Your store emails have had an upgrade!', 'woocommerce' ) );
		$note->set_content( __( 'We’ve made some exciting improvements to your email templates, including modern, shopper-friendly designs and new customization options. And if you’re using a block theme, you can automatically sync your theme styles! Head to your email settings to explore the new changes.', 'woocommerce' ) );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action(
			'customize-your-emails',
			__( 'Customize your emails', 'woocommerce' ),
			'?page=wc-settings&tab=email'
		);
		return $note;
	}

	/**
	 * Get the note for when the email improvements feature is disabled.
	 *
	 * @return Note
	 */
	private static function get_try_email_improvements_note() {
		$note = new Note();
		$note->set_title( __( 'Store emails have had an upgrade!', 'woocommerce' ) );
		$note->set_content( __( 'We’ve made some exciting improvements to our email templates, including modern, shopper-friendly designs and new customization options. And if you’re using a block theme, you can automatically sync your theme styles! Head to your email settings to explore the new features.', 'woocommerce' ) );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action(
			'try-the-new-templates',
			__( 'Try the new templates', 'woocommerce' ),
			'?page=wc-settings&tab=email&try-new-templates'
		);
		return $note;
	}
}
PK     [1]Sw    "  Admin/Notes/MigrateFromShopify.phpnu         <?php
/**
 * WooCommerce Admin: Migrate from Shopify to WooCommerce.
 *
 * Adds a note to ask the client if they want to migrate from Shopify to WooCommerce.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;

/**
 * Migrate_From_Shopify.
 */
class MigrateFromShopify {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-migrate-from-shopify';

	/**
	 * Get the note.
	 *
	 * @return Note
	 */
	public static function get_note() {

		// We want to show the note after two days.
		$two_days = 2 * DAY_IN_SECONDS;
		if ( ! self::is_wc_admin_active_in_date_range( 'week-1', $two_days ) ) {
			return;
		}

		$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );
		if (
			! isset( $onboarding_profile['setup_client'] ) ||
			! isset( $onboarding_profile['selling_venues'] ) ||
			! isset( $onboarding_profile['other_platform'] )
		) {
			return;
		}

		// Make sure the client is not setup.
		if ( $onboarding_profile['setup_client'] ) {
			return;
		}

		// We will show the notification when the client already is selling and is using Shopify.
		if (
			'other' !== $onboarding_profile['selling_venues'] ||
			'shopify' !== $onboarding_profile['other_platform']
		) {
			return;
		}

		$note = new Note();
		$note->set_title( __( 'Do you want to migrate from Shopify to WooCommerce?', 'woocommerce' ) );
		$note->set_content( __( 'Changing eCommerce platforms might seem like a big hurdle to overcome, but it is easier than you might think to move your products, customers, and orders to WooCommerce. This article will help you with going through this process.', 'woocommerce' ) );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_content_data( (object) array() );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action(
			'migrate-from-shopify',
			__( 'Learn more', 'woocommerce' ),
			'https://woocommerce.com/posts/migrate-from-shopify-to-woocommerce/?utm_source=inbox&utm_medium=product',
			Note::E_WC_ADMIN_NOTE_ACTIONED
		);
		return $note;
	}
}
PK     [1]&      Admin/Notes/MobileApp.phpnu         <?php
/**
 * WooCommerce Admin Mobile App Note Provider.
 *
 * Adds a note to the merchant's inbox showing the benefits of the mobile app.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;

/**
 * Mobile_App
 */
class MobileApp {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-mobile-app';

	/**
	 * Get the note.
	 *
	 * @return Note
	 */
	public static function get_note() {
		// We want to show the mobile app note after day 2.
		$two_days_in_seconds = 2 * DAY_IN_SECONDS;
		if ( ! self::is_wc_admin_active_in_date_range( 'week-1', $two_days_in_seconds ) ) {
			return;
		}

		$content = __( 'Install the WooCommerce mobile app to manage orders, receive sales notifications, and view key metrics — wherever you are.', 'woocommerce' );

		$note = new Note();
		$note->set_title( __( 'Install Woo mobile app', 'woocommerce' ) );
		$note->set_content( $content );
		$note->set_content_data( (object) array() );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action( 'learn-more', __( 'Learn more', 'woocommerce' ), 'https://woocommerce.com/mobile/?utm_medium=product' );
		return $note;
	}
}
PK     [1]i      #  Admin/Notes/WooCommercePayments.phpnu         <?php
/**
 * WooCommerce Admin WooCommerce Payments Note Provider.
 *
 * Adds a note to the merchant's inbox showing the benefits of the WooCommerce Payments.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;

/**
 * WooCommerce_Payments
 */
class WooCommercePayments {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-woocommerce-payments';

	/**
	 * Name of the note for use in the database.
	 */
	const PLUGIN_SLUG = 'woocommerce-payments';

	/**
	 * Name of the note for use in the database.
	 */
	const PLUGIN_FILE = 'woocommerce-payments/woocommerce-payments.php';

	/**
	 * Attach hooks.
	 */
	public function __construct() {
		add_action( 'init', array( $this, 'install_on_action' ) );
		add_action( 'wc-admin-woocommerce-payments_add_note', array( $this, 'add_note' ) );
	}

	/**
	 * Maybe add a note on WooCommerce Payments for US based sites older than a week without the plugin installed.
	 */
	public static function possibly_add_note() {
		if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4' ) || 'US' !== WC()->countries->get_base_country() ) {
			return;
		}

		$data_store = Notes::load_data_store();

		// We already have this note? Then mark the note as actioned.
		$note_ids = $data_store->get_notes_with_name( self::NOTE_NAME );
		if ( ! empty( $note_ids ) ) {

			$note_id = array_pop( $note_ids );
			$note    = Notes::get_note( $note_id );
			if ( false === $note ) {
				return;
			}

			// If the WooCommerce Payments plugin was installed after the note was created, make sure it's marked as actioned.
			if ( self::is_installed() && Note::E_WC_ADMIN_NOTE_ACTIONED !== $note->get_status() ) {
				$note->set_status( Note::E_WC_ADMIN_NOTE_ACTIONED );
				$note->save();
			}

			return;
		}

		$current_date = new \DateTime();
		$publish_date = new \DateTime( '2020-04-14' );

		if ( $current_date >= $publish_date ) {

			$note = self::get_note();
			if ( self::can_be_added() ) {
				$note->save();
			}

			return;

		} else {

			$hook_name = sprintf( '%s_add_note', self::NOTE_NAME );

			if ( ! WC()->queue()->get_next( $hook_name ) ) {
				WC()->queue()->schedule_single( $publish_date->getTimestamp(), $hook_name );
			}
		}
	}

	/**
	 * Add a note about WooCommerce Payments.
	 *
	 * @return Note
	 */
	public static function get_note() {
		$note = new Note();
		$note->set_title( __( 'Try the new way to get paid', 'woocommerce' ) );
		$note->set_content(
			__( 'Securely accept credit and debit cards on your site. Manage transactions without leaving your WordPress dashboard. Only with <strong>WooPayments</strong>.', 'woocommerce' ) .
			'<br><br>' .
			sprintf(
				/* translators: 1: opening link tag, 2: closing tag */
				__( 'By clicking "Get started", you agree to our %1$sTerms of Service%2$s', 'woocommerce' ),
				'<a href="https://wordpress.com/tos/" target="_blank">',
				'</a>'
			)
		);
		$note->set_content_data( (object) array() );
		$note->set_type( Note::E_WC_ADMIN_NOTE_MARKETING );
		$note->set_name( self::NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action( 'learn-more', __( 'Learn more', 'woocommerce' ), 'https://woocommerce.com/payments/?utm_medium=product', Note::E_WC_ADMIN_NOTE_UNACTIONED );
		$note->add_action( 'get-started', __( 'Get started', 'woocommerce' ), wc_admin_url( '&action=setup-woocommerce-payments' ), Note::E_WC_ADMIN_NOTE_ACTIONED, true );
		$note->add_nonce_to_action( 'get-started', 'setup-woocommerce-payments', '' );

		// Create the note as "actioned" if the plugin is already installed.
		if ( self::is_installed() ) {
			$note->set_status( Note::E_WC_ADMIN_NOTE_ACTIONED );
		}
		return $note;
	}


	/**
	 * Check if the WooCommerce Payments plugin is active or installed.
	 */
	protected static function is_installed() {
		if ( defined( 'WC_Payments' ) ) {
			return true;
		}
		include_once ABSPATH . '/wp-admin/includes/plugin.php';
		return 0 === validate_plugin( self::PLUGIN_FILE );
	}

	/**
	 * Install and activate WooCommerce Payments.
	 *
	 * @return boolean Whether the plugin was successfully activated.
	 */
	private function install_and_activate_wcpay() {
		$install_request = array( 'plugins' => self::PLUGIN_SLUG );
		$installer       = new \Automattic\WooCommerce\Admin\API\Plugins();
		$result          = $installer->install_plugins( $install_request );
		if ( is_wp_error( $result ) ) {
			return false;
		}

		wc_admin_record_tracks_event( 'woocommerce_payments_install', array( 'context' => 'inbox' ) );

		$activate_request = array( 'plugins' => self::PLUGIN_SLUG );
		$result           = $installer->activate_plugins( $activate_request );
		if ( is_wp_error( $result ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Install & activate WooCommerce Payments plugin, and redirect to setup.
	 */
	public function install_on_action() {
		// TODO: Need to validate this request more strictly since we're taking install actions directly?
		if (
			! isset( $_GET['page'] ) ||
			'wc-admin' !== $_GET['page'] ||
			! isset( $_GET['action'] ) ||
			'setup-woocommerce-payments' !== $_GET['action']
		) {
			return;
		}

		$data_store = Notes::load_data_store();

		// We already have this note? Then mark the note as actioned.
		$note_ids = $data_store->get_notes_with_name( self::NOTE_NAME );
		if ( empty( $note_ids ) ) {
			return;
		}

		$note_id = array_pop( $note_ids );
		$note    = Notes::get_note( $note_id );
		if ( false === $note ) {
			return;
		}
		$action = $note->get_action( 'get-started' );
		if ( ! $action ||
			( isset( $action->nonce_action ) &&
				(
					empty( $_GET['_wpnonce'] ) ||
					! wp_verify_nonce( wp_unslash( $_GET['_wpnonce'] ), $action->nonce_action ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
				)
			)
		) {
			return;
		}

		if ( ! current_user_can( 'install_plugins' ) ) {
			return;
		}

		$this->install_and_activate_wcpay();

		// WooCommerce Payments is installed at this point, so link straight into the onboarding flow.
		$connect_url = add_query_arg(
			array(
				'wcpay-connect' => '1',
				'_wpnonce'      => wp_create_nonce( 'wcpay-connect' ),
			),
			admin_url()
		);
		wp_safe_redirect( $connect_url );
		exit;
	}
}
PK     [1]	  	  $  Admin/Notes/SellingOnlineCourses.phpnu         <?php
/**
 * WooCommerce Admin: Selling Online Courses note
 *
 * Adds a note to encourage selling online courses.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;

/**
 * Selling_Online_Courses
 */
class SellingOnlineCourses {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-selling-online-courses';

	/**
	 * Attach hooks.
	 */
	public function __construct() {
		add_action(
			'update_option_' . OnboardingProfile::DATA_OPTION,
			array( $this, 'check_onboarding_profile' ),
			10,
			3
		);
	}

	/**
	 * Check to see if the profiler options match before possibly adding note.
	 *
	 * @param object $old_value The old option value.
	 * @param object $value     The new option value.
	 * @param string $option    The name of the option.
	 */
	public static function check_onboarding_profile( $old_value, $value, $option ) {
		// Skip adding if this store is in the education/learning industry.
		if ( ! isset( $value['industry'] ) ) {
			return;
		}
		$industry_slugs = array_column( $value['industry'], 'slug' );
		if ( ! in_array( 'education-and-learning', $industry_slugs, true ) ) {
			return;
		}

		self::possibly_add_note();
	}

	/**
	 * Get the note.
	 *
	 * @return Note
	 */
	public static function get_note() {
		$note = new Note();

		$note->set_title( __( 'Do you want to sell online courses?', 'woocommerce' ) );
		$note->set_content( __( 'Online courses are a great solution for any business that can teach a new skill. Since courses don’t require physical product development or shipping, they’re affordable, fast to create, and can generate passive income for years to come. In this article, we provide you more information about selling courses using WooCommerce.', 'woocommerce' ) );
		$note->set_content_data( (object) array() );
		$note->set_type( Note::E_WC_ADMIN_NOTE_MARKETING );
		$note->set_name( self::NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action(
			'learn-more',
			__( 'Learn more', 'woocommerce' ),
			'https://woocommerce.com/posts/how-to-sell-online-courses-wordpress/?utm_source=inbox&utm_medium=product',
			Note::E_WC_ADMIN_NOTE_ACTIONED
		);

		return $note;
	}
}
PK     [1]Cz=  =  %  Admin/Notes/PaymentsRemindMeLater.phpnu         <?php
/**
 * WooCommerce Admin Payment Reminder Me later
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Internal\Admin\WcPayWelcomePage;

defined( 'ABSPATH' ) || exit;

/**
 * PaymentsRemindMeLater
 */
class PaymentsRemindMeLater {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-payments-remind-me-later';

	/**
	 * Should this note exist?
	 */
	public static function is_applicable() {
		return self::should_display_note();
	}

	/**
	 * Returns true if we should display the note.
	 *
	 * @return bool
	 */
	public static function should_display_note() {
		// A WooPayments incentive must be visible.
		if ( ! WcPayWelcomePage::instance()->has_incentive() ) {
			return false;
		}

		// Less than 3 days since viewing welcome page.
		$view_timestamp = get_option( 'wcpay_welcome_page_viewed_timestamp', false );
		if ( ! $view_timestamp ||
			( time() - $view_timestamp < 3 * DAY_IN_SECONDS )
		) {
			return false;
		}
		return true;
	}


	/**
	 * Get the note.
	 *
	 * @return Note
	 */
	public static function get_note() {
		if ( ! self::should_display_note() ) {
			return;
		}
		/* translators: 1: Payment provider name. */
		$content = sprintf( __( 'Save up to $800 in fees by managing transactions with %1$s. With %1$s, you can securely accept major cards, Apple Pay, and payments in over 100 currencies.', 'woocommerce' ), 'WooPayments' );

		$note = new Note();
		/* translators: %s: Payment provider name. */
		$note->set_title( sprintf( __( 'Save big with %s', 'woocommerce' ), 'WooPayments' ) );
		$note->set_content( $content );
		$note->set_content_data( (object) array() );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action( 'learn-more', __( 'Learn more', 'woocommerce' ), admin_url( 'admin.php?page=wc-admin&path=/wc-pay-welcome-page' ) );
		return $note;
	}
}
PK     [1]#fH
  
  #  Admin/Notes/OnlineClothingStore.phpnu         <?php
/**
 * WooCommerce Admin: Start your online clothing store.
 *
 * Adds a note to ask the client if they are considering starting an online
 * clothing store.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;

/**
 * Online_Clothing_Store.
 */
class OnlineClothingStore {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-online-clothing-store';

	/**
	 * Returns whether the industries includes fashion-apparel-accessories.
	 *
	 * @param array $industries The industries to search.
	 *
	 * @return bool Whether the industries includes fashion-apparel-accessories.
	 */
	private static function is_in_fashion_industry( $industries ) {
		foreach ( $industries as $industry ) {
			if ( 'fashion-apparel-accessories' === $industry['slug'] ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Get the note.
	 *
	 * @return Note
	 */
	public static function get_note() {
		// We want to show the note after two days.
		if ( ! self::is_wc_admin_active_in_date_range( 'week-1', 2 * DAY_IN_SECONDS ) ) {
			return;
		}

		$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );

		// Confirm that $onboarding_profile is set.
		if ( empty( $onboarding_profile ) ) {
			return;
		}

		// Make sure that the person who filled out the OBW was not setting up
		// the store for their customer/client.
		if (
			! isset( $onboarding_profile['setup_client'] ) ||
			$onboarding_profile['setup_client']
		) {
			return;
		}

		// We need to show the notification when the industry is
		// fashion/apparel/accessories.
		if ( ! isset( $onboarding_profile['industry'] ) ) {
			return;
		}
		if ( ! self::is_in_fashion_industry( $onboarding_profile['industry'] ) ) {
			return;
		}

		$note = new Note();
		$note->set_title( __( 'Start your online clothing store', 'woocommerce' ) );
		$note->set_content( __( 'Starting a fashion website is exciting but it may seem overwhelming as well. In this article, we\'ll walk you through the setup process, teach you to create successful product listings, and show you how to market to your ideal audience.', 'woocommerce' ) );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_content_data( (object) array() );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action(
			'online-clothing-store',
			__( 'Learn more', 'woocommerce' ),
			'https://woocommerce.com/posts/starting-an-online-clothing-store/?utm_source=inbox&utm_medium=product',
			Note::E_WC_ADMIN_NOTE_ACTIONED
		);
		return $note;
	}
}
PK     [1]M|Z;    )  Admin/Notes/CustomizingProductCatalog.phpnu         <?php
/**
 * WooCommerce Admin: How to customize your product catalog note provider
 *
 * Adds a note with a link to the customizer a day after adding the first product
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Enums\ProductStatus;

/**
 * Class CustomizingProductCatalog
 *
 * @package Automattic\WooCommerce\Admin\Notes
 */
class CustomizingProductCatalog {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-customizing-product-catalog';

	/**
	 * Get the note.
	 *
	 * @return Note
	 */
	public static function get_note() {
		$query = new \WC_Product_Query(
			array(
				'limit'    => 1,
				'paginate' => true,
				'status'   => array( ProductStatus::PUBLISH ),
				'orderby'  => 'post_date',
				'order'    => 'DESC',
			)
		);

		$products = $query->get_products();

		// we need at least 1 product.
		if ( 0 === $products->total ) {
			return;
		}

		$product           = $products->products[0];
		$created_timestamp = $product->get_date_created()->getTimestamp();
		$is_a_day_old      = ( time() - $created_timestamp ) >= DAY_IN_SECONDS;

		// the product must be at least 1 day old.
		if ( ! $is_a_day_old ) {
			return;
		}

		// store must not been active more than 14 days.
		if ( self::wc_admin_active_for( DAY_IN_SECONDS * 14 ) ) {
			return;
		}

		$note = new Note();
		$note->set_title( __( 'How to customize your product catalog', 'woocommerce' ) );
		$note->set_content( __( 'You want your product catalog and images to look great and align with your brand. This guide will give you all the tips you need to get your products looking great in your store.', 'woocommerce' ) );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_content_data( (object) array() );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action(
			'day-after-first-product',
			__( 'Learn more', 'woocommerce' ),
			'https://woocommerce.com/document/woocommerce-customizer/?utm_source=inbox&utm_medium=product'
		);

		return $note;
	}
}
PK     [1]~  ~  $  Admin/Notes/UnsecuredReportFiles.phpnu         <?php
/**
 * WooCommerce Admin Unsecured Files Note.
 *
 * Adds a warning about potentially unsecured files.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;

if ( ! class_exists( Note::class ) ) {
	class_alias( WC_Admin_Note::class, Note::class );
}

/**
 * Unsecured_Report_Files
 */
class UnsecuredReportFiles {

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-remove-unsecured-report-files';

	/**
	 * Get the note.
	 *
	 * @return Note|null
	 */
	public static function get_note() {
		$note = new Note();
		$note->set_title( __( 'Potentially unsecured files were found in your uploads directory', 'woocommerce' ) );
		$note->set_content(
			sprintf(
				/* translators: 1: opening analytics docs link tag. 2: closing link tag */
				__( 'Files that may contain %1$sstore analytics%2$s reports were found in your uploads directory - we recommend assessing and deleting any such files.', 'woocommerce' ),
				'<a href="https://woocommerce.com/document/woocommerce-analytics/" target="_blank">',
				'</a>'
			)
		);
		$note->set_content_data( (object) array() );
		$note->set_type( Note::E_WC_ADMIN_NOTE_ERROR );
		$note->set_name( self::NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action(
			'learn-more',
			__( 'Learn more', 'woocommerce' ),
			'https://developer.woocommerce.com/2021/09/22/important-security-patch-released-in-woocommerce/',
			Note::E_WC_ADMIN_NOTE_UNACTIONED,
			true
		);
		$note->add_action(
			'dismiss',
			__( 'Dismiss', 'woocommerce' ),
			wc_admin_url(),
			Note::E_WC_ADMIN_NOTE_ACTIONED,
			false
		);

		return $note;
	}

	/**
	 * Add the note if it passes predefined conditions.
	 */
	public static function possibly_add_note() {
		$note = self::get_note();

		if ( self::note_exists() ) {
			return;
		}

		$note->save();
	}

	/**
	 * Check if the note has been previously added.
	 */
	public static function note_exists() {
		$data_store = \WC_Data_Store::load( 'admin-note' );
		$note_ids   = $data_store->get_notes_with_name( self::NOTE_NAME );
		return ! empty( $note_ids );
	}

}
PK     [1]0$  $    Admin/Notes/OrderMilestones.phpnu         <?php
/**
 * WooCommerce Admin (Dashboard) Order Milestones Note Provider.
 *
 * Adds a note to the merchant's inbox when certain order milestones are reached.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
/**
 * Order_Milestones
 */
class OrderMilestones {
	/**
	 * Name of the "other milestones" note.
	 */
	const NOTE_NAME = 'wc-admin-orders-milestone';

	/**
	 * Option key name to store last order milestone.
	 */
	const LAST_ORDER_MILESTONE_OPTION_KEY = 'woocommerce_admin_last_orders_milestone';

	/**
	 * Hook to process order milestones.
	 */
	const PROCESS_ORDERS_MILESTONE_HOOK = 'wc_admin_process_orders_milestone';

	/**
	 * Allowed order statuses for calculating milestones.
	 *
	 * @var array
	 */
	protected $allowed_statuses = array(
		'pending',
		'processing',
		'completed',
	);

	/**
	 * Orders count cache.
	 *
	 * @var int
	 */
	protected $orders_count = null;

	/**
	 * Further order milestone thresholds.
	 *
	 * @var array
	 */
	protected $milestones = array(
		1,
		10,
		100,
		250,
		500,
		1000,
		5000,
		10000,
		500000,
		1000000,
	);

	/**
	 * Delay hook attachment until after the WC post types have been registered.
	 *
	 * This is required for retrieving the order count.
	 */
	public function __construct() {
		/**
		 * Filter Order statuses that will count towards milestones.
		 *
		 * @since 3.5.0
		 *
		 * @param array $allowed_statuses Order statuses that will count towards milestones.
		 */
		$this->allowed_statuses = apply_filters( 'woocommerce_admin_order_milestone_statuses', $this->allowed_statuses );

		add_action( 'woocommerce_after_register_post_type', array( $this, 'init' ) );
		register_deactivation_hook( WC_PLUGIN_FILE, array( $this, 'clear_scheduled_event' ) );
	}

	/**
	 * Hook everything up.
	 */
	public function init() {
		if ( ! wp_next_scheduled( self::PROCESS_ORDERS_MILESTONE_HOOK ) ) {
			wp_schedule_event( time(), 'hourly', self::PROCESS_ORDERS_MILESTONE_HOOK );
		}

		add_action( 'wc_admin_installed', array( $this, 'backfill_last_milestone' ) );

		add_action( self::PROCESS_ORDERS_MILESTONE_HOOK, array( $this, 'possibly_add_note' ) );
	}

	/**
	 * Clear out our hourly milestone hook upon plugin deactivation.
	 */
	public function clear_scheduled_event() {
		wp_clear_scheduled_hook( self::PROCESS_ORDERS_MILESTONE_HOOK );
	}

	/**
	 * Get the total count of orders (in the allowed statuses).
	 *
	 * @param bool $no_cache Optional. Skip cache.
	 * @return int Total orders count.
	 */
	public function get_orders_count( $no_cache = false ) {
		if ( $no_cache || is_null( $this->orders_count ) ) {
			$status_counts      = array_map( 'wc_orders_count', $this->allowed_statuses );
			$this->orders_count = array_sum( $status_counts );
		}

		return $this->orders_count;
	}

	/**
	 * Backfill the store's current milestone.
	 *
	 * Used to avoid celebrating milestones that were reached before plugin activation.
	 */
	public function backfill_last_milestone() {
		// If the milestone notes have been disabled via filter, bail.
		if ( ! $this->are_milestones_enabled() ) {
			return;
		}

		$this->set_last_milestone( $this->get_current_milestone() );
	}

	/**
	 * Get the store's last milestone.
	 *
	 * @return int Last milestone reached.
	 */
	public function get_last_milestone() {
		return get_option( self::LAST_ORDER_MILESTONE_OPTION_KEY, 0 );
	}

	/**
	 * Update the last reached milestone.
	 *
	 * @param int $milestone Last milestone reached.
	 */
	public function set_last_milestone( $milestone ) {
		update_option( self::LAST_ORDER_MILESTONE_OPTION_KEY, $milestone );
	}

	/**
	 * Calculate the current orders milestone.
	 *
	 * Based on the threshold values in $this->milestones.
	 *
	 * @return int Current orders milestone.
	 */
	public function get_current_milestone() {
		$milestone_reached = 0;
		$orders_count      = $this->get_orders_count();

		foreach ( $this->milestones as $milestone ) {
			if ( $milestone <= $orders_count ) {
				$milestone_reached = $milestone;
			}
		}

		return $milestone_reached;
	}

	/**
	 * Get the appropriate note title for a given milestone.
	 *
	 * @param int $milestone Order milestone.
	 * @return string Note title for the milestone.
	 */
	public static function get_note_title_for_milestone( $milestone ) {
		switch ( $milestone ) {
			case 1:
				return __( 'First order received', 'woocommerce' );
			case 10:
			case 100:
			case 250:
			case 500:
			case 1000:
			case 5000:
			case 10000:
			case 500000:
			case 1000000:
				return sprintf(
					/* translators: Number of orders processed. */
					__( 'Congratulations on processing %s orders!', 'woocommerce' ),
					wc_format_decimal( $milestone )
				);
			default:
				return '';
		}
	}

	/**
	 * Get the appropriate note content for a given milestone.
	 *
	 * @param int $milestone Order milestone.
	 * @return string Note content for the milestone.
	 */
	public static function get_note_content_for_milestone( $milestone ) {
		switch ( $milestone ) {
			case 1:
				return __( 'Congratulations on getting your first order! Now is a great time to learn how to manage your orders.', 'woocommerce' );
			case 10:
				return __( "You've hit the 10 orders milestone! Look at you go. Browse some WooCommerce success stories for inspiration.", 'woocommerce' );
			case 100:
			case 250:
			case 500:
			case 1000:
			case 5000:
			case 10000:
			case 500000:
			case 1000000:
				return __( 'Another order milestone! Take a look at your Orders Report to review your orders to date.', 'woocommerce' );
			default:
				return '';
		}
	}

	/**
	 * Get the appropriate note action for a given milestone.
	 *
	 * @param int $milestone Order milestone.
	 * @return array Note actoion (name, label, query) for the milestone.
	 */
	public static function get_note_action_for_milestone( $milestone ) {
		switch ( $milestone ) {
			case 1:
				return array(
					'name'  => 'learn-more',
					'label' => __( 'Learn more', 'woocommerce' ),
					'query' => 'https://woocommerce.com/document/managing-orders/?utm_source=inbox&utm_medium=product',
				);
			case 10:
				return array(
					'name'  => 'browse',
					'label' => __( 'Browse', 'woocommerce' ),
					'query' => 'https://woocommerce.com/success-stories/?utm_source=inbox&utm_medium=product',
				);
			case 100:
			case 250:
			case 500:
			case 1000:
			case 5000:
			case 10000:
			case 500000:
			case 1000000:
				return array(
					'name'  => 'review-orders',
					'label' => __( 'Review your orders', 'woocommerce' ),
					'query' => '?page=wc-admin&path=/analytics/orders',
				);
			default:
				return array(
					'name'  => '',
					'label' => '',
					'query' => '',
				);
		}
	}

	/**
	 * Convenience method to see if the milestone notes are enabled.
	 *
	 * @return boolean True if milestone notifications are enabled.
	 */
	public function are_milestones_enabled() {
		/**
		 * Filter to allow for disabling order milestones.
		 *
		 * @since 3.7.0
		 *
		 * @param boolean default true
		 */
		$milestone_notes_enabled = apply_filters( 'woocommerce_admin_order_milestones_enabled', true );

		return $milestone_notes_enabled;
	}

	/**
	 * Get the note. This is used for localizing the note.
	 *
	 * @return Note
	 */
	public static function get_note() {
		$note = Notes::get_note_by_name( self::NOTE_NAME );
		if ( ! $note ) {
			return false;
		}
		$content_data = $note->get_content_data();
		if ( ! isset( $content_data->current_milestone ) ) {
			return false;
		}
		return self::get_note_by_milestone(
			$content_data->current_milestone
		);
	}

	/**
	 * Get the note by milestones.
	 *
	 * @param int $current_milestone Current milestone.
	 *
	 * @return Note
	 */
	public static function get_note_by_milestone( $current_milestone ) {
		$content_data = (object) array(
			'current_milestone' => $current_milestone,
		);

		$note = new Note();
		$note->set_title( self::get_note_title_for_milestone( $current_milestone ) );
		$note->set_content( self::get_note_content_for_milestone( $current_milestone ) );
		$note->set_content_data( $content_data );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note_action = self::get_note_action_for_milestone( $current_milestone );
		$note->add_action( $note_action['name'], $note_action['label'], $note_action['query'] );
		return $note;
	}

	/**
	 * Checks if a note can and should be added.
	 *
	 * @return bool
	 */
	public function can_be_added() {
		// If the milestone notes have been disabled via filter, bail.
		if ( ! $this->are_milestones_enabled() ) {
			return false;
		}

		$last_milestone    = $this->get_last_milestone();
		$current_milestone = $this->get_current_milestone();

		if ( $current_milestone <= $last_milestone ) {
			return false;
		}

		return true;
	}

	/**
	 * Add milestone notes for other significant thresholds.
	 */
	public function possibly_add_note() {
		if ( ! self::can_be_added() ) {
			return;
		}
		$current_milestone = $this->get_current_milestone();
		$this->set_last_milestone( $current_milestone );

		// We only want one milestone note at any time.
		Notes::delete_notes_with_name( self::NOTE_NAME );
		$note = $this->get_note_by_milestone( $current_milestone );
		$note->save();
	}
}
PK     [1]t    &  Admin/Notes/PaymentsMoreInfoNeeded.phpnu         <?php
/**
 * WooCommerce Admin Payments More Info Needed Inbox Note Provider
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Internal\Admin\WcPayWelcomePage;

defined( 'ABSPATH' ) || exit;

/**
 * PaymentsMoreInfoNeeded
 */
class PaymentsMoreInfoNeeded {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-payments-more-info-needed';

	/**
	 * Should this note exist?
	 */
	public static function is_applicable() {
		return self::should_display_note();
	}

	/**
	 * Returns true if we should display the note.
	 *
	 * @return bool
	 */
	public static function should_display_note() {
		// A WooPayments incentive must not be visible.
		if ( WcPayWelcomePage::instance()->has_incentive() ) {
			return false;
		}

		// More than 30 days since viewing the welcome page.
		$exit_survey_timestamp = get_option( 'wcpay_welcome_page_exit_survey_more_info_needed_timestamp', false );
		if ( ! $exit_survey_timestamp ||
			( time() - $exit_survey_timestamp < 30 * DAY_IN_SECONDS )
		) {
			return false;
		}

		return true;
	}

	/**
	 * Get the note.
	 *
	 * @return Note
	 */
	public static function get_note() {
		if ( ! self::should_display_note() ) {
			return;
		}
		/* translators: %s: Payment provider name. */
		$content = sprintf( __( 'We recently asked you if you wanted more information about %s. Run your business and manage your payments in one place with the solution built and supported by WooCommerce.', 'woocommerce' ), 'WooPayments' );

		$note = new Note();
		/* translators: %s: Payment provider name. */
		$note->set_title( sprintf( __( 'Payments made simple with %s', 'woocommerce' ), 'WooPayments' ) );
		$note->set_content( $content );
		$note->set_content_data( (object) array() );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action( 'learn-more', __( 'Learn more here', 'woocommerce' ), 'https://woocommerce.com/payments/' );
		return $note;
	}
}
PK     [1]G      Admin/Notes/LaunchChecklist.phpnu         <?php
/**
 * WooCommerce Admin Launch Checklist Note.
 *
 * Adds a note to cover pre-launch checklist items for store owners.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;

/**
 * Launch_Checklist
 */
class LaunchChecklist {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-launch-checklist';

	/**
	 * Get the note.
	 *
	 * @return Note
	 */
	public static function get_note() {
		// Only add this note if completing the task list or completed 3 tasks in 10 days.
		$completed_tasks     = get_option( 'woocommerce_task_list_tracked_completed_tasks', array() );
		$ten_days_in_seconds = 10 * DAY_IN_SECONDS;
		if (
			! get_option( 'woocommerce_task_list_complete' ) &&
			(
				count( $completed_tasks ) < 3 ||
				self::is_wc_admin_active_in_date_range( 'week-1-4', $ten_days_in_seconds )
			)
		) {
			return;
		}

		$content = __( 'To make sure you never get that sinking "what did I forget" feeling, we\'ve put together the essential pre-launch checklist.', 'woocommerce' );

		$note = new Note();
		$note->set_title( __( 'Ready to launch your store?', 'woocommerce' ) );
		$note->set_content( $content );
		$note->set_content_data( (object) array() );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action( 'learn-more', __( 'Learn more', 'woocommerce' ), 'https://woocommerce.com/posts/pre-launch-checklist-the-essentials/?utm_source=inbox&utm_medium=product' );
		return $note;
	}
}
PK     [1]c	  c	  (  Admin/Notes/CustomizeStoreWithBlocks.phpnu         <?php
/**
 * WooCommerce Admin: Customize your online store with WooCommerce blocks.
 *
 * Adds a note to customize the client online store with WooCommerce blocks.
 *
 * @package WooCommerce\Admin
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;

/**
 * Customize_Store_With_Blocks.
 */
class CustomizeStoreWithBlocks {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-customize-store-with-blocks';

	/**
	 * Get the note.
	 *
	 * @return Note
	 */
	public static function get_note() {
		$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );

		// Confirm that $onboarding_profile is set.
		if ( empty( $onboarding_profile ) ) {
			return;
		}

		// Make sure that the person who filled out the OBW was not setting up
		// the store for their customer/client.
		if (
			! isset( $onboarding_profile['setup_client'] ) ||
			$onboarding_profile['setup_client']
		) {
			return;
		}

		// We want to show the note after fourteen days.
		if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4', 14 * DAY_IN_SECONDS ) ) {
			return;
		}

		// Don't show if there aren't products.
		$query    = new \WC_Product_Query(
			array(
				'limit'  => 1,
				'return' => 'ids',
				'status' => array( 'publish' ),
			)
		);
		$products = $query->get_products();
		if ( 0 === count( $products ) ) {
			return;
		}

		$note = new Note();
		$note->set_title( __( 'Customize your online store with WooCommerce blocks', 'woocommerce' ) );
		$note->set_content( __( 'With our blocks, you can select and display products, categories, filters, and more virtually anywhere on your site — no need to use shortcodes or edit lines of code. Learn more about how to use each one of them.', 'woocommerce' ) );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_content_data( (object) array() );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action(
			'customize-store-with-blocks',
			__( 'Learn more', 'woocommerce' ),
			'https://woocommerce.com/posts/how-to-customize-your-online-store-with-woocommerce-blocks/?utm_source=inbox&utm_medium=product',
			Note::E_WC_ADMIN_NOTE_ACTIONED
		);
		return $note;
	}
}
PK     [1]W	
  
  #  Admin/Notes/RealTimeOrderAlerts.phpnu         <?php
/**
 * WooCommerce Admin Real Time Order Alerts Note.
 *
 * Adds a note to download the mobile app to monitor store activity.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;

/**
 * Real_Time_Order_Alerts
 */
class RealTimeOrderAlerts {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-real-time-order-alerts';

	/**
	 * Get the note.
	 *
	 * @return Note
	 */
	public static function get_note() {
		// Only add this note if the store is 3 months old.
		if ( ! self::is_wc_admin_active_in_date_range( 'month-3-6' ) ) {
			return;
		}

		// Check that the previous mobile app note was not actioned.
		if ( MobileApp::has_note_been_actioned() ) {
			return;
		}

		$content = __( 'Get notifications about store activity, including new orders and product reviews directly on your mobile devices with the Woo app.', 'woocommerce' );

		$note = new Note();
		$note->set_title( __( 'Get real-time order alerts anywhere', 'woocommerce' ) );
		$note->set_content( $content );
		$note->set_content_data( (object) array() );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action( 'learn-more', __( 'Learn more', 'woocommerce' ), 'https://woocommerce.com/mobile/?utm_source=inbox&utm_medium=product' );
		return $note;
	}
}
PK     [1]օl      Admin/Notes/FirstProduct.phpnu         <?php
/**
 * WooCommerce Admin: Do you need help with adding your first product?
 *
 * Adds a note to ask the client if they need help adding their first product.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Enums\ProductStatus;

/**
 * First_Product.
 */
class FirstProduct {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-first-product';

	/**
	 * Get the note.
	 *
	 * @return Note
	 */
	public static function get_note() {
		// We want to show the note after seven days.
		if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4' ) ) {
			return;
		}

		$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );

		// Confirm that $onboarding_profile is set.
		if ( empty( $onboarding_profile ) ) {
			return;
		}

		// Make sure that the person who filled out the OBW was not setting up
		// the store for their customer/client.
		if (
			! isset( $onboarding_profile['setup_client'] ) ||
			$onboarding_profile['setup_client']
		) {
			return;
		}

		// Don't show if there are products.
		$query    = new \WC_Product_Query(
			array(
				'limit'    => 1,
				'paginate' => true,
				'return'   => 'ids',
				'status'   => array( ProductStatus::PUBLISH ),
			)
		);
		$products = $query->get_products();
		$count    = $products->total;
		if ( 0 !== $count ) {
			return;
		}

		$note = new Note();
		$note->set_title( __( 'Do you need help with adding your first product?', 'woocommerce' ) );
		$note->set_content( __( 'This video tutorial will help you go through the process of adding your first product in WooCommerce.', 'woocommerce' ) );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_content_data( (object) array() );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action(
			'first-product-watch-tutorial',
			__( 'Watch tutorial', 'woocommerce' ),
			'https://www.youtube.com/watch?v=sFtXa00Jf_o&list=PLHdG8zvZd0E575Ia8Mu3w1h750YLXNfsC&index=24'
		);

		return $note;
	}
}
PK     [1](R  R  &  Admin/Notes/InstallJPAndWCSPlugins.phpnu         <?php
/**
 * WooCommerce Admin Add Install Jetpack and WooCommerce Shipping & Tax Plugin Note Provider.
 *
 * Adds a note to the merchant's inbox prompting them to install the Jetpack
 * and WooCommerce Shipping & Tax plugins after it fails to install during
 * WooCommerce setup.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
use Automattic\WooCommerce\Admin\PluginsHelper;

/**
 * Install_JP_And_WCS_Plugins
 */
class InstallJPAndWCSPlugins {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-install-jp-and-wcs-plugins';

	/**
	 * Constructor.
	 */
	public function __construct() {
		add_action( 'woocommerce_note_action_install-jp-and-wcs-plugins', array( $this, 'install_jp_and_wcs_plugins' ) );
		add_action( 'activated_plugin', array( $this, 'action_note' ) );
		add_action( 'woocommerce_plugins_install_api_error', array( $this, 'on_install_error' ) );
		add_action( 'woocommerce_plugins_install_error', array( $this, 'on_install_error' ) );
		add_action( 'woocommerce_plugins_activate_error', array( $this, 'on_install_error' ) );
	}

	/**
	 * Get the note.
	 *
	 * @return Note
	 */
	public static function get_note() {
		$content = __( 'We noticed that there was a problem during the Jetpack and WooCommerce Shipping & Tax install. Please try again and enjoy all the advantages of having the plugins connected to your store! Sorry for the inconvenience. The "Jetpack" and "WooCommerce Shipping & Tax" plugins will be installed & activated for free.', 'woocommerce' );

		$note = new Note();
		$note->set_title( __( 'Uh oh... There was a problem during the Jetpack and WooCommerce Shipping & Tax install. Please try again.', 'woocommerce' ) );
		$note->set_content( $content );
		$note->set_content_data( (object) array() );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action(
			'install-jp-and-wcs-plugins',
			__( 'Install plugins', 'woocommerce' ),
			false,
			Note::E_WC_ADMIN_NOTE_ACTIONED
		);
		return $note;
	}

	/**
	 * Action the Install Jetpack and WooCommerce Shipping & Tax note, if any exists,
	 * and as long as both the Jetpack and WooCommerce Shipping & Tax plugins have been
	 * activated.
	 */
	public static function action_note() {
		// Make sure that both plugins are active before actioning the note.
		$active_plugin_slugs = PluginsHelper::get_active_plugin_slugs();
		$jp_active           = in_array( 'jetpack', $active_plugin_slugs, true );
		$wcs_active          = in_array( 'woocommerce-services', $active_plugin_slugs, true );

		if ( ! $jp_active || ! $wcs_active ) {
			return;
		}

		// Action any notes with a matching name.
		$data_store = Notes::load_data_store();
		$note_ids   = $data_store->get_notes_with_name( self::NOTE_NAME );

		foreach ( $note_ids as $note_id ) {
			$note = Notes::get_note( $note_id );

			if ( $note ) {
				$note->set_status( Note::E_WC_ADMIN_NOTE_ACTIONED );
				$note->save();
			}
		}
	}

	/**
	 * Install the Jetpack and WooCommerce Shipping & Tax plugins in response to the action
	 * being clicked in the admin note.
	 *
	 * @param Note $note The note being actioned.
	 */
	public function install_jp_and_wcs_plugins( $note ) {
		if ( self::NOTE_NAME !== $note->get_name() ) {
			return;
		}

		$this->install_and_activate_plugin( 'jetpack' );
		$this->install_and_activate_plugin( 'woocommerce-services' );
	}

	/**
	 * Installs and activates the specified plugin.
	 *
	 * @param string $plugin The plugin slug.
	 */
	private function install_and_activate_plugin( $plugin ) {
		$install_request = array( 'plugin' => $plugin );
		$installer       = new \Automattic\WooCommerce\Admin\API\OnboardingPlugins();
		$result          = $installer->install_plugin( $install_request );

		// @todo Use the error statuses to decide whether or not to action the note.
		if ( is_wp_error( $result ) ) {
			return;
		}

		$activate_request = array( 'plugins' => $plugin );

		$installer->activate_plugins( $activate_request );
	}

	/**
	 * Create an alert notification in response to an error installing a plugin.
	 *
	 * @param string $slug The slug of the plugin being installed.
	 */
	public function on_install_error( $slug ) {
		// Exit early if we're not installing the Jetpack or the WooCommerce Shipping & Tax plugins.
		if ( 'jetpack' !== $slug && 'woocommerce-services' !== $slug ) {
			return;
		}

		self::possibly_add_note();
	}
}
PK     [1]      Admin/Notes/EUVATNumber.phpnu         <?php
/**
 * WooCommerce Admin: EU VAT Number Note.
 *
 * Adds a note for EU store to install the EU VAT Number extension.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;

/**
 * EU_VAT_Number
 */
class EUVATNumber {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-eu-vat-number';

	/**
	 * Get the note.
	 *
	 * @return Note
	 */
	public static function get_note() {
		if ( 'yes' !== get_option( 'wc_connect_taxes_enabled', 'no' ) ) {
			return;
		}

		$country_code = WC()->countries->get_base_country();
		$eu_countries = WC()->countries->get_european_union_countries();
		if ( ! in_array( $country_code, $eu_countries, true ) ) {
			return;
		}

		$content = __( "If your store is based in the EU, we recommend using the EU VAT Number extension in addition to automated taxes. It provides your checkout with a field to collect and validate a customer's EU VAT number, if they have one.", 'woocommerce' );

		$note = new Note();
		$note->set_title( __( 'Collect and validate EU VAT numbers at checkout', 'woocommerce' ) );
		$note->set_content( $content );
		$note->set_content_data( (object) array() );
		$note->set_type( Note::E_WC_ADMIN_NOTE_MARKETING );
		$note->set_name( self::NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action(
			'learn-more',
			__( 'Learn more', 'woocommerce' ),
			'https://woocommerce.com/products/eu-vat-number/?utm_medium=product',
			Note::E_WC_ADMIN_NOTE_ACTIONED
		);
		return $note;
	}
}
PK     [1]:`	  	     Admin/Notes/MagentoMigration.phpnu         <?php
/**
 * WooCommerce Admin note on how to migrate from Magento.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Features\Onboarding;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;

/**
 * MagentoMigration
 */
class MagentoMigration {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-magento-migration';

	/**
	 * Attach hooks.
	 */
	public function __construct() {
		add_action( 'update_option_' . OnboardingProfile::DATA_OPTION, array( __CLASS__, 'possibly_add_note' ) );
		add_action( 'woocommerce_admin_magento_migration_note', array( __CLASS__, 'save_note' ) );
	}

	/**
	 * Add the note if it passes predefined conditions.
	 */
	public static function possibly_add_note() {
		$onboarding_profile = get_option( OnboardingProfile::DATA_OPTION, array() );

		if ( empty( $onboarding_profile ) ) {
			return;
		}

		if (
			! isset( $onboarding_profile['other_platform'] ) ||
			'magento' !== $onboarding_profile['other_platform']
		) {
			return;
		}

		if (
			! isset( $onboarding_profile['setup_client'] ) ||
			$onboarding_profile['setup_client']
		) {
			return;
		}

		WC()->queue()->schedule_single( time() + ( 5 * MINUTE_IN_SECONDS ), 'woocommerce_admin_magento_migration_note' );
	}

	/**
	 * Save the note to the database.
	 */
	public static function save_note() {
		$note = self::get_note();

		if ( self::note_exists() ) {
			return;
		}

		$note->save();
	}

	/**
	 * Get the note.
	 *
	 * @return Note
	 */
	public static function get_note() {
		$note = new Note();

		$note->set_title( __( 'How to Migrate from Magento to WooCommerce', 'woocommerce' ) );
		$note->set_content( __( 'Changing platforms might seem like a big hurdle to overcome, but it is easier than you might think to move your products, customers, and orders to WooCommerce. This article will help you with going through this process.', 'woocommerce' ) );
		$note->set_content_data( (object) array() );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action(
			'learn-more',
			__( 'Learn more', 'woocommerce' ),
			'https://woocommerce.com/posts/how-migrate-from-magento-to-woocommerce/?utm_source=inbox'
		);

		return $note;
	}
}
PK     [1]![B    #  Admin/Notes/PerformanceOnMobile.phpnu         <?php
/**
 * WooCommerce Admin Performance on mobile note.
 *
 * Adds a note to download the mobile app, performance on mobile.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;

/**
 * Performance_On_Mobile
 */
class PerformanceOnMobile {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-performance-on-mobile';

	/**
	 * Get the note.
	 *
	 * @return Note
	 */
	public static function get_note() {
		// Only add this note if this store is at least 9 months old.
		$nine_months_in_seconds = MONTH_IN_SECONDS * 9;
		if ( ! self::wc_admin_active_for( $nine_months_in_seconds ) ) {
			return;
		}

		// Check that the previous mobile app notes have not been actioned.
		if ( MobileApp::has_note_been_actioned() ) {
			return;
		}
		if ( RealTimeOrderAlerts::has_note_been_actioned() ) {
			return;
		}
		if ( ManageOrdersOnTheGo::has_note_been_actioned() ) {
			return;
		}

		$note = new Note();

		$note->set_title( __( 'Track your store performance on mobile', 'woocommerce' ) );
		$note->set_content( __( 'Monitor your sales and high performing products with the Woo app.', 'woocommerce' ) );
		$note->set_content_data( (object) array() );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action(
			'learn-more',
			__( 'Learn more', 'woocommerce' ),
			'https://woocommerce.com/mobile/?utm_source=inbox&utm_medium=product'
		);

		return $note;
	}
}
PK     [1]h &B      Admin/Notes/NewSalesRecord.phpnu         <?php
/**
 * WooCommerce Admin (Dashboard) New Sales Record Note Provider.
 *
 * Adds a note to the merchant's inbox when the previous day's sales are a new record.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;

/**
 * New_Sales_Record
 */
class NewSalesRecord {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-new-sales-record';

	/**
	 * Option name for the sales record date in ISO 8601 (YYYY-MM-DD) date.
	 */
	const RECORD_DATE_OPTION_KEY = 'woocommerce_sales_record_date';

	/**
	 * Option name for the sales record amount.
	 */
	const RECORD_AMOUNT_OPTION_KEY = 'woocommerce_sales_record_amount';

	/**
	 * Returns the total of yesterday's sales.
	 *
	 * @param string $date Date for sales to sum (i.e. YYYY-MM-DD).
	 * @return floatval
	 */
	public static function sum_sales_for_date( $date ) {
		$order_query = new \WC_Order_Query( array( 'date_created' => $date ) );
		$orders      = $order_query->get_orders();
		$total       = 0;

		foreach ( (array) $orders as $order ) {
			$total += $order->get_total();
		}

		return $total;
	}

	/**
	 * Possibly add a sales record note.
	 */
	public static function possibly_add_note() {
		/**
		 * Filter to allow for disabling sales record milestones.
		 *
		 * @since 3.7.0
		 *
		 * @param boolean default true
		 */
		$sales_record_notes_enabled = apply_filters( 'woocommerce_admin_sales_record_milestone_enabled', true );

		if ( ! $sales_record_notes_enabled ) {
			return;
		}

		$yesterday = gmdate( 'Y-m-d', current_time( 'timestamp', 0 ) - DAY_IN_SECONDS );
		$total     = self::sum_sales_for_date( $yesterday );

		// No sales yesterday? Bail.
		if ( 0 >= $total ) {
			return;
		}

		$record_date = get_option( self::RECORD_DATE_OPTION_KEY, '' );
		$record_amt  = floatval( get_option( self::RECORD_AMOUNT_OPTION_KEY, 0 ) );

		// No previous entry? Just enter what we have and return without generating a note.
		if ( empty( $record_date ) ) {
			update_option( self::RECORD_DATE_OPTION_KEY, $yesterday );
			update_option( self::RECORD_AMOUNT_OPTION_KEY, $total );
				return;
		}

		// Otherwise, if yesterdays total bested the record, update AND generate a note.
		if ( $total > $record_amt ) {
			update_option( self::RECORD_DATE_OPTION_KEY, $yesterday );
			update_option( self::RECORD_AMOUNT_OPTION_KEY, $total );

			// We only want one sales record note at any time in the inbox, so we delete any other first.
			Notes::delete_notes_with_name( self::NOTE_NAME );

			$note = self::get_note_with_record_data( $record_date, $record_amt, $yesterday, $total );
			$note->save();
		}
	}

	/**
	 * Get the note with record data.
	 *
	 * @param string $record_date record date Y-m-d.
	 * @param float  $record_amt record amount.
	 * @param string $yesterday yesterday's date Y-m-d.
	 * @param string $total total sales for yesterday.
	 *
	 * @return Note
	 */
	public static function get_note_with_record_data( $record_date, $record_amt, $yesterday, $total ) {
		// Use F jS (March 7th) format for English speaking countries.
		if ( substr( get_user_locale(), 0, 2 ) === 'en' ) {
			$date_format = 'F jS';
		} else {
			// otherwise, fallback to the system date format.
			$date_format = get_option( 'date_format' );
		}

		$formatted_yesterday   = date_i18n( $date_format, strtotime( $yesterday ) );
		$formatted_total       = html_entity_decode( wp_strip_all_tags( wc_price( $total ) ) );
		$formatted_record_date = date_i18n( $date_format, strtotime( $record_date ) );
		$formatted_record_amt  = html_entity_decode( wp_strip_all_tags( wc_price( $record_amt ) ) );

		$content = sprintf(
			/* translators: 1 and 4: Date (e.g. October 16th), 2 and 3: Amount (e.g. $160.00) */
			__( 'Woohoo, %1$s was your record day for sales! Net sales was %2$s beating the previous record of %3$s set on %4$s.', 'woocommerce' ),
			$formatted_yesterday,
			$formatted_total,
			$formatted_record_amt,
			$formatted_record_date
		);

		$content_data = (object) array(
			'old_record_date' => $record_date,
			'old_record_amt'  => $record_amt,
			'new_record_date' => $yesterday,
			'new_record_amt'  => $total,
		);

		$report_url = '?page=wc-admin&path=/analytics/revenue&period=custom&compare=previous_year&after=' . $yesterday . '&before=' . $yesterday;

		// And now, create our new note.
		$note = new Note();
		$note->set_title( __( 'New sales record!', 'woocommerce' ) );
		$note->set_content( $content );
		$note->set_content_data( $content_data );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action( 'view-report', __( 'View report', 'woocommerce' ), $report_url );

		return $note;
	}

	/**
	 * Get the note. This is used for localizing the note.
	 *
	 * @return Note
	 */
	public static function get_note() {
		$note = Notes::get_note_by_name( self::NOTE_NAME );
		if ( ! $note ) {
			return false;
		}
		$content_data = $note->get_content_data();
		return self::get_note_with_record_data(
			$content_data->old_record_date,
			$content_data->old_record_amt,
			$content_data->new_record_date,
			$content_data->new_record_amt
		);
	}
}
PK     [1]<)       Admin/Notes/PersonalizeStore.phpnu         <?php
/**
 * WooCommerce Admin Personalize Your Store Note Provider.
 *
 * Adds a note to the merchant's inbox prompting them to personalize their store.
 */

namespace Automattic\WooCommerce\Internal\Admin\Notes;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;

/**
 * Personalize_Store
 */
class PersonalizeStore {
	/**
	 * Note traits.
	 */
	use NoteTraits;

	/**
	 * Name of the note for use in the database.
	 */
	const NOTE_NAME = 'wc-admin-personalize-store';

	/**
	 * Get the note.
	 *
	 * @return Note
	 */
	public static function get_note() {
		// Only show the note to stores with homepage.
		$homepage_id = get_option( 'woocommerce_onboarding_homepage_post_id', false );
		if ( ! $homepage_id ) {
			return;
		}

		// Show the note after task list is done.
		$is_task_list_complete = get_option( 'woocommerce_task_list_complete', false );

		// We want to show the note after day 5.
		$five_days_in_seconds = 5 * DAY_IN_SECONDS;

		if ( ! self::is_wc_admin_active_in_date_range( 'week-1-4', $five_days_in_seconds ) && ! $is_task_list_complete ) {
			return;
		}

		$content = __( 'The homepage is one of the most important entry points in your store. When done right it can lead to higher conversions and engagement. Don\'t forget to personalize the homepage that we created for your store during the onboarding.', 'woocommerce' );

		$note = new Note();
		$note->set_title( __( 'Personalize your store\'s homepage', 'woocommerce' ) );
		$note->set_content( $content );
		$note->set_content_data( (object) array() );
		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
		$note->set_name( self::NOTE_NAME );
		$note->set_source( 'woocommerce-admin' );
		$note->add_action( 'personalize-homepage', __( 'Personalize homepage', 'woocommerce' ), admin_url( 'post.php?post=' . $homepage_id . '&action=edit' ), Note::E_WC_ADMIN_NOTE_ACTIONED );
		return $note;
	}
}
PK     [1](  (    Admin/Marketing.phpnu         <?php
/**
 * WooCommerce Marketing.
 */

namespace Automattic\WooCommerce\Internal\Admin;

use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Marketing\InstalledExtensions;
use Automattic\WooCommerce\Admin\PageController;

/**
 * Contains backend logic for the Marketing feature.
 */
class Marketing {

	use CouponsMovedTrait;

	/**
	 * Constant representing the key for the submenu name value in the global $submenu array.
	 *
	 * @var int
	 */
	const SUBMENU_NAME_KEY = 0;

	/**
	 * Constant representing the key for the submenu location value in the global $submenu array.
	 *
	 * @var int
	 */
	const SUBMENU_LOCATION_KEY = 2;

	/**
	 * Class instance.
	 *
	 * @var Marketing instance
	 */
	protected static $instance = null;

	/**
	 * Get class instance.
	 */
	public static function get_instance() {
		if ( ! self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Hook into WooCommerce.
	 */
	public function __construct() {
		if ( ! is_admin() ) {
			return;
		}

		add_action( 'admin_menu', array( $this, 'register_pages' ), 5 );
		add_action( 'admin_menu', array( $this, 'add_parent_menu_item' ), 6 );

		// Overwrite submenu default ordering for marketing menu. High priority gives plugins the chance to register their own menu items.
		add_action( 'admin_menu', array( $this, 'reorder_marketing_submenu' ), 99 );

		add_filter( 'woocommerce_admin_shared_settings', array( $this, 'component_settings' ), 30 );
	}

	/**
	 * Add main marketing menu item.
	 *
	 * Uses priority of 9 so other items can easily be added at the default priority (10).
	 */
	public function add_parent_menu_item() {
		if ( ! Features::is_enabled( 'navigation' ) ) {
			add_menu_page(
				__( 'Marketing', 'woocommerce' ),
				__( 'Marketing', 'woocommerce' ),
				'manage_woocommerce',
				'woocommerce-marketing',
				null,
				'dashicons-megaphone',
				58
			);
		}

		PageController::get_instance()->connect_page(
			array(
				'id'         => 'woocommerce-marketing',
				'title'      => 'Marketing',
				'capability' => 'manage_woocommerce',
				'path'       => 'wc-admin&path=/marketing',
			)
		);
	}

	/**
	 * Registers report pages.
	 */
	public function register_pages() {
		$this->register_overview_page();

		$controller = PageController::get_instance();
		$defaults   = array(
			'parent'        => 'woocommerce-marketing',
			'existing_page' => false,
		);

		/**
		 * Filters marketing menu items.
		 *
		 * @since 4.1.0
		 * @param array $items Marketing pages.
		 */
		$marketing_pages = apply_filters( 'woocommerce_marketing_menu_items', array() );
		foreach ( $marketing_pages as $marketing_page ) {
			if ( ! is_array( $marketing_page ) ) {
				continue;
			}

			$marketing_page = array_merge( $defaults, $marketing_page );

			if ( $marketing_page['existing_page'] ) {
				$controller->connect_page( $marketing_page );
			} else {
				$controller->register_page( $marketing_page );
			}
		}
	}

	/**
	 * Register the main Marketing page, which is Marketing > Overview.
	 *
	 * This is done separately because we need to ensure the page is registered properly and
	 * that the link is done properly. For some reason the normal page registration process
	 * gives us the wrong menu link.
	 */
	protected function register_overview_page() {
		global $submenu;

		// First register the page.
		PageController::get_instance()->register_page(
			array(
				'id'     => 'woocommerce-marketing-overview',
				'title'  => __( 'Overview', 'woocommerce' ),
				'path'   => 'wc-admin&path=/marketing',
				'parent' => 'woocommerce-marketing',
			)
		);

		// Now fix the path, since register_page() gets it wrong.
		if ( ! isset( $submenu['woocommerce-marketing'] ) ) {
			return;
		}

		foreach ( $submenu['woocommerce-marketing'] as &$item ) {
			// The "slug" (aka the path) is the third item in the array.
			if ( 0 === strpos( $item[2], 'wc-admin' ) ) {
				$item[2] = 'admin.php?page=' . $item[2];
			}
		}
	}

	/**
	 * Order marketing menu items alphabetically.
	 * Overview should be first, and Coupons should be second, followed by other marketing menu items.
	 *
	 * @return  void
	 */
	public function reorder_marketing_submenu() {
		global $submenu;

		if ( ! isset( $submenu['woocommerce-marketing'] ) ) {
			return;
		}

		$marketing_submenu = $submenu['woocommerce-marketing'];
		$new_menu_order    = array();

		// Overview should be first.
		$overview_key = array_search( 'Overview', array_column( $marketing_submenu, self::SUBMENU_NAME_KEY ), true );

		if ( false === $overview_key ) {
			/*
			 * If Overview is not found, we may be on a site with a different language.
			 * We can use a fallback and try to find the overview page by its path.
			 */
			$overview_key = array_search( 'admin.php?page=wc-admin&path=/marketing', array_column( $marketing_submenu, self::SUBMENU_LOCATION_KEY ), true );
		}

		if ( false !== $overview_key ) {
			$new_menu_order[] = $marketing_submenu[ $overview_key ];
			array_splice( $marketing_submenu, $overview_key, 1 );
		}

		// Coupons should be second.
		$coupons_key = array_search( 'Coupons', array_column( $marketing_submenu, self::SUBMENU_NAME_KEY ), true );

		if ( false === $coupons_key ) {
			/*
			 * If Coupons is not found, we may be on a site with a different language.
			 * We can use a fallback and try to find the coupons page by its path.
			 */
			$coupons_key = array_search( 'edit.php?post_type=shop_coupon', array_column( $marketing_submenu, self::SUBMENU_LOCATION_KEY ), true );
		}

		if ( false !== $coupons_key ) {
			$new_menu_order[] = $marketing_submenu[ $coupons_key ];
			array_splice( $marketing_submenu, $coupons_key, 1 );
		}

		// Sort the rest of the items alphabetically.
		usort(
			$marketing_submenu,
			function ( $a, $b ) {
				return strcmp( $a[0], $b[0] );
			}
		);

		$new_menu_order = array_merge( $new_menu_order, $marketing_submenu );

		$submenu['woocommerce-marketing'] = $new_menu_order;  //phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
	}

	/**
	 * Add settings for marketing feature.
	 *
	 * @param array $settings Component settings.
	 * @return array
	 */
	public function component_settings( $settings ) {
		// Bail early if not on a wc-admin powered page.
		if ( ! PageController::is_admin_page() ) {
			return $settings;
		}

		$settings['marketing']['installedExtensions'] = InstalledExtensions::get_data();

		return $settings;
	}
}
PK     [1]J<[      Admin/MobileAppBanner.phpnu         <?php

namespace Automattic\WooCommerce\Internal\Admin;

defined( 'ABSPATH' ) || exit;

/**
 * Determine if the mobile app banner shows on Android devices
 */
class MobileAppBanner {
	/**
	 * Class instance.
	 *
	 * @var Analytics instance
	 */
	protected static $instance = null;

	/**
	 * Get class instance.
	 */
	public static function get_instance() {
		if ( ! self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Hook into WooCommerce.
	 */
	public function __construct() {
		add_filter( 'woocommerce_admin_get_user_data_fields', array( $this, 'add_user_data_fields' ) );
	}

	/**
	 * Adds fields so that we can store user preferences for the mobile app banner
	 *
	 * @param array $user_data_fields User data fields.
	 * @return array
	 */
	public function add_user_data_fields( $user_data_fields ) {
		return array_merge(
			$user_data_fields,
			array(
				'android_app_banner_dismissed',
			)
		);
	}
}
PK     [1]6S*  S*  %  Abilities/REST/RestAbilityFactory.phpnu         <?php
/**
 * REST Ability Factory class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Abilities\REST;

use Automattic\WooCommerce\Internal\MCP\Transport\WooCommerceRestTransport;

defined( 'ABSPATH' ) || exit;

/**
 * Factory class for creating abilities from REST controllers.
 *
 * Handles the conversion of WooCommerce REST API endpoints into WordPress abilities
 * that can be consumed by MCP or other systems.
 */
class RestAbilityFactory {

	/**
	 * Register abilities for a REST controller based on configuration.
	 *
	 * @param array $config Controller configuration containing controller class and abilities array.
	 */
	public static function register_controller_abilities( array $config ): void {
		$controller_class = $config['controller'];

		if ( ! class_exists( $controller_class ) ) {
			return;
		}

		$controller = new $controller_class();

		foreach ( $config['abilities'] as $ability_config ) {
			self::register_single_ability( $controller, $ability_config, $config['route'] );
		}
	}

	/**
	 * Register a single ability.
	 *
	 * @param object $controller REST controller instance.
	 * @param array  $ability_config Ability configuration array.
	 * @param string $route REST route for this controller.
	 */
	private static function register_single_ability( $controller, array $ability_config, string $route ): void {
		// Only proceed if wp_register_ability function exists.
		if ( ! function_exists( 'wp_register_ability' ) ) {
			return;
		}

		try {
			$ability_args = array(
				'label'               => $ability_config['label'],
				'description'         => $ability_config['description'],
				'category'            => 'woocommerce-rest',
				'input_schema'        => self::get_schema_for_operation( $controller, $ability_config['operation'] ),
				'output_schema'       => self::get_output_schema( $controller, $ability_config['operation'] ),
				'execute_callback'    => function ( $input ) use ( $controller, $ability_config, $route ) {
					return self::execute_operation( $controller, $ability_config['operation'], $input, $route );
				},
				'permission_callback' => function () use ( $controller, $ability_config ) {
					return self::check_permission( $controller, $ability_config['operation'] );
				},
				'ability_class'       => RestAbility::class,
				'meta'                => array(
					'show_in_rest' => true,
				),
			);

			// Add readonly annotation for GET operations (list and get).
			if ( in_array( $ability_config['operation'], array( 'list', 'get' ), true ) ) {
				$ability_args['meta']['annotations'] = array(
					'readonly' => true,
				);
			}

			wp_register_ability( $ability_config['id'], $ability_args );
		} catch ( \Throwable $e ) {
			// Log the error for debugging but don't break the registration of other abilities.
			if ( function_exists( 'wc_get_logger' ) ) {
				wc_get_logger()->error(
					"Failed to register ability {$ability_config['id']}: " . $e->getMessage(),
					array( 'source' => 'woocommerce-rest-abilities' )
				);
			}
		}
	}

	/**
	 * Get input schema based on operation type.
	 *
	 * @param object $controller REST controller instance.
	 * @param string $operation Operation type (list, get, create, update, delete).
	 * @return array Input schema array.
	 */
	private static function get_schema_for_operation( $controller, string $operation ): array {
		switch ( $operation ) {
			case 'list':
				// Use controller's collection parameters.
				if ( method_exists( $controller, 'get_collection_params' ) ) {
					return self::sanitize_args_to_schema( $controller->get_collection_params() );
				}
				break;

			case 'create':
				// Use controller's creatable schema.
				if ( method_exists( $controller, 'get_endpoint_args_for_item_schema' ) ) {
					$args = $controller->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE );
					return self::sanitize_args_to_schema( $args );
				}
				break;

			case 'update':
				// Use controller's editable schema + ID.
				if ( method_exists( $controller, 'get_endpoint_args_for_item_schema' ) ) {
					$args   = $controller->get_endpoint_args_for_item_schema( \WP_REST_Server::EDITABLE );
					$schema = self::sanitize_args_to_schema( $args );

					// Add ID field for update operations.
					$schema['properties']['id'] = array(
						'type'        => 'integer',
						'description' => __( 'Unique identifier for the resource', 'woocommerce' ),
					);

					// Ensure ID is required.
					if ( ! isset( $schema['required'] ) ) {
						$schema['required'] = array();
					}
					if ( ! in_array( 'id', $schema['required'], true ) ) {
						$schema['required'][] = 'id';
					}

					return $schema;
				}
				break;

			case 'get':
			case 'delete':
				// Only need ID.
				return array(
					'type'       => 'object',
					'properties' => array(
						'id' => array(
							'type'        => 'integer',
							'description' => __( 'Unique identifier for the resource', 'woocommerce' ),
						),
					),
					'required'   => array( 'id' ),
				);
		}

		// Fallback.
		return array( 'type' => 'object' );
	}

	/**
	 * Sanitize WordPress REST args to valid JSON Schema format.
	 *
	 * Converts WordPress REST API argument arrays to JSON Schema by:
	 * - Removing PHP callbacks (sanitize_callback, validate_callback)
	 * - Converting 'required' from boolean-per-field to array-of-names
	 * - Removing WordPress-specific non-schema fields
	 * - Preserving valid JSON Schema properties
	 *
	 * @param array $args WordPress REST API arguments array.
	 * @return array Valid JSON Schema object.
	 */
	private static function sanitize_args_to_schema( array $args ): array {
		$properties = array();
		$required   = array();

		foreach ( $args as $key => $arg ) {
			$property = array();

			// Copy valid JSON Schema fields.
			if ( isset( $arg['type'] ) ) {
				$property['type'] = $arg['type'];
			}
			if ( isset( $arg['description'] ) ) {
				$property['description'] = $arg['description'];
			}
			if ( isset( $arg['default'] ) ) {
				$property['default'] = $arg['default'];
			}
			if ( isset( $arg['enum'] ) ) {
				$property['enum'] = array_values( $arg['enum'] );
			}
			if ( isset( $arg['items'] ) ) {
				$property['items'] = $arg['items'];
			}
			if ( isset( $arg['minimum'] ) ) {
				$property['minimum'] = $arg['minimum'];
			}
			if ( isset( $arg['maximum'] ) ) {
				$property['maximum'] = $arg['maximum'];
			}
			if ( isset( $arg['format'] ) ) {
				$property['format'] = $arg['format'];
			}
			if ( isset( $arg['properties'] ) ) {
				$property['properties'] = $arg['properties'];
			}

			// Convert readonly to readOnly (JSON Schema format).
			if ( isset( $arg['readonly'] ) && $arg['readonly'] ) {
				$property['readOnly'] = true;
			}

			// Collect required fields.
			if ( isset( $arg['required'] ) && true === $arg['required'] ) {
				$required[] = $key;
			}

			$properties[ $key ] = $property;
		}

		$schema = array(
			'type'       => 'object',
			'properties' => $properties,
		);

		if ( ! empty( $required ) ) {
			$schema['required'] = array_unique( $required );
		}

		return $schema;
	}

	/**
	 * Get output schema for operation.
	 *
	 * @param object $controller REST controller instance.
	 * @param string $operation Operation type.
	 * @return array Output schema array.
	 */
	private static function get_output_schema( $controller, string $operation ): array {
		if ( method_exists( $controller, 'get_item_schema' ) ) {
			$schema = $controller->get_item_schema();

			if ( 'list' === $operation ) {
				// For list operations, return object wrapping array of items.
				// This ensures MCP compatibility while maintaining REST structure.
				return array(
					'type'       => 'object',
					'properties' => array(
						'data' => array(
							'type'  => 'array',
							'items' => $schema,
						),
					),
				);
			} elseif ( 'delete' === $operation ) {
				// For delete operations, return simple confirmation.
				return array(
					'type'       => 'object',
					'properties' => array(
						'deleted'  => array( 'type' => 'boolean' ),
						'previous' => $schema,
					),
				);
			}

			// For get, create, update operations.
			return $schema;
		}

		return array( 'type' => 'object' );
	}

	/**
	 * Execute the REST operation.
	 *
	 * @param object $controller REST controller instance.
	 * @param string $operation Operation type.
	 * @param array  $input Input parameters.
	 * @param string $route REST route for this controller.
	 * @return mixed Operation result.
	 */
	private static function execute_operation( $controller, string $operation, array $input, string $route ) {
		$method = self::get_http_method_for_operation( $operation );

		// Build final route - add ID for single item operations.
		$request_route = $route;
		if ( isset( $input['id'] ) && in_array( $operation, array( 'get', 'update', 'delete' ), true ) ) {
			$request_route .= '/' . intval( $input['id'] );
			unset( $input['id'] );
		}

		// Create REST request.
		$request = new \WP_REST_Request( $method, $request_route );
		foreach ( $input as $key => $value ) {
			$request->set_param( $key, $value );
		}

		// Dispatch through REST API for proper validation and permissions.
		$response = rest_do_request( $request );

		if ( is_wp_error( $response ) ) {
			return $response;
		}

		$data = $response instanceof \WP_REST_Response ? $response->get_data() : $response;

		// For list operations, wrap in data object to match schema.
		if ( 'list' === $operation ) {
			return array( 'data' => $data );
		}

		return $data;
	}

	/**
	 * Get HTTP method for a given operation type.
	 *
	 * @param string $operation Operation type (list, get, create, update, delete).
	 * @return string HTTP method (GET, POST, PUT, DELETE).
	 */
	private static function get_http_method_for_operation( string $operation ): string {
		$method_map = array(
			'list'   => 'GET',
			'get'    => 'GET',
			'create' => 'POST',
			'update' => 'PUT',
			'delete' => 'DELETE',
		);
		return $method_map[ $operation ] ?? 'GET';
	}

	/**
	 * Check permissions for MCP operations.
	 *
	 * @param object $controller REST controller instance.
	 * @param string $operation Operation type.
	 * @return bool Whether permission is granted.
	 */
	private static function check_permission( $controller, string $operation ): bool {
		// Get HTTP method for the operation.
		$method = self::get_http_method_for_operation( $operation );

		/**
		 * Filter to check REST ability permissions for HTTP method.
		 *
		 * @since 10.3.0
		 * @param bool   $allowed    Whether the operation is allowed. Default false.
		 * @param string $method     HTTP method (GET, POST, PUT, DELETE).
		 * @param object $controller REST controller instance.
		 */
		return apply_filters( 'woocommerce_check_rest_ability_permissions_for_method', false, $method, $controller );
	}
}
PK     [1]0]      Abilities/REST/RestAbility.phpnu         <?php
/**
 * REST Ability class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Abilities\REST;

defined( 'ABSPATH' ) || exit;

/**
 * Custom WP_Ability subclass for REST API-based abilities.
 *
 * This class extends the base WP_Ability class but skips output validation
 * to handle the discrepancies between WooCommerce REST API schemas and
 * actual output. This is necessary because WooCommerce schemas are often
 * incomplete or inaccurate regarding nullable fields and type variations.
 */
class RestAbility extends \WP_Ability {

	/**
	 * Skip output validation for REST abilities.
	 *
	 * WooCommerce REST API schemas often don't accurately reflect the actual
	 * output, particularly for nullable fields and type variations. Rather than
	 * trying to fix all schema inconsistencies, we skip output validation for
	 * REST-based abilities while maintaining input validation and permissions.
	 *
	 * @param mixed $output The output to validate.
	 * @return true Always returns true (no validation).
	 */
	protected function validate_output( $output ) {
		// Skip validation - trust that REST controllers return valid data.
		return true;
	}
}
PK     [1]fNʻB  B  !  Abilities/AbilitiesCategories.phpnu         <?php
/**
 * Abilities Categories class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Abilities;

defined( 'ABSPATH' ) || exit;

/**
 * Abilities Categories class for WooCommerce.
 *
 * Registers categories for WooCommerce abilities to improve organization
 * and discoverability in the WordPress Abilities API v0.3.0+.
 */
class AbilitiesCategories {

	/**
	 * Initialize category registration.
	 *
	 * @internal
	 */
	final public static function init(): void {
		/*
		 * Register categories when Abilities API categories are ready.
		 * Support both old (pre-6.9) and new (6.9+) action names.
		 */
		add_action( 'abilities_api_categories_init', array( __CLASS__, 'register_categories' ) );
		add_action( 'wp_abilities_api_categories_init', array( __CLASS__, 'register_categories' ) );
	}

	/**
	 * Register WooCommerce ability categories.
	 */
	public static function register_categories(): void {
		// Only register if the function exists.
		if ( ! function_exists( 'wp_register_ability_category' ) ) {
			return;
		}

		wp_register_ability_category(
			'woocommerce-rest',
			array(
				'label'       => __( 'WooCommerce REST API', 'woocommerce' ),
				'description' => __( 'REST API operations for WooCommerce resources including products, orders, and other store data.', 'woocommerce' ),
			)
		);
	}
}
PK     [1]2'      Abilities/AbilitiesRegistry.phpnu         <?php
/**
 * Abilities Registry class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Abilities;

defined( 'ABSPATH' ) || exit;

/**
 * Abilities Registry class for WooCommerce.
 *
 * Centralized registry that initializes all WooCommerce abilities.
 * These abilities can be consumed by MCP, REST API, or other tools.
 */
class AbilitiesRegistry {

	/**
	 * Initialize the registry.
	 */
	public function __construct() {
		$this->init_abilities();
	}

	/**
	 * Initialize all WooCommerce abilities.
	 */
	private function init_abilities(): void {
		AbilitiesCategories::init();
		AbilitiesRestBridge::init();
	}

	/**
	 * Get all ability IDs from the WordPress Abilities API.
	 *
	 * @return array Array of all ability IDs.
	 */
	public function get_abilities_ids(): array {
		// Check if the abilities API is available.
		if ( ! function_exists( 'wp_get_abilities' ) ) {
			return array();
		}

		$all_abilities = wp_get_abilities();

		return array_keys( $all_abilities );
	}
}
PK     [1]Wb    !  Abilities/AbilitiesRestBridge.phpnu         <?php
/**
 * Abilities REST Bridge class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Abilities;

use Automattic\WooCommerce\Internal\Abilities\REST\RestAbilityFactory;
use Automattic\WooCommerce\Internal\MCP\MCPAdapterProvider;

defined( 'ABSPATH' ) || exit;

/**
 * Abilities REST Bridge class for WooCommerce.
 *
 * Configuration-driven registry that exposes REST endpoints as WordPress abilities.
 * Each ability is explicitly configured with ID, label, description, and operation.
 */
class AbilitiesRestBridge {

	/**
	 * Get REST controller configurations with explicit IDs, labels, and descriptions.
	 *
	 * @return array Controller configurations.
	 */
	private static function get_configurations(): array {
		return array(
			array(
				'controller' => \WC_REST_Products_Controller::class,
				'route'      => '/wc/v3/products',
				'abilities'  => array(
					array(
						'id'          => 'woocommerce/products-list',
						'operation'   => 'list',
						'label'       => __( 'List Products', 'woocommerce' ),
						'description' => __( 'Retrieve a paginated list of products with optional filters for status, category, price range, and other attributes.', 'woocommerce' ),
					),
					array(
						'id'          => 'woocommerce/products-get',
						'operation'   => 'get',
						'label'       => __( 'Get Product', 'woocommerce' ),
						'description' => __( 'Retrieve detailed information about a single product by ID, including price, description, images, and metadata.', 'woocommerce' ),
					),
					array(
						'id'          => 'woocommerce/products-create',
						'operation'   => 'create',
						'label'       => __( 'Create Product', 'woocommerce' ),
						'description' => __( 'Create a new product in WooCommerce with name, price, description, and other product attributes.', 'woocommerce' ),
					),
					array(
						'id'          => 'woocommerce/products-update',
						'operation'   => 'update',
						'label'       => __( 'Update Product', 'woocommerce' ),
						'description' => __( 'Update an existing product by modifying its attributes such as price, stock, description, or metadata.', 'woocommerce' ),
					),
					array(
						'id'          => 'woocommerce/products-delete',
						'operation'   => 'delete',
						'label'       => __( 'Delete Product', 'woocommerce' ),
						'description' => __( 'Permanently delete a product from the store. This action cannot be undone.', 'woocommerce' ),
					),
				),
			),
			array(
				'controller' => \WC_REST_Orders_Controller::class,
				'route'      => '/wc/v3/orders',
				'abilities'  => array(
					array(
						'id'          => 'woocommerce/orders-list',
						'operation'   => 'list',
						'label'       => __( 'List Orders', 'woocommerce' ),
						'description' => __( 'Retrieve a paginated list of orders with optional filters for status, customer, date range, and other criteria.', 'woocommerce' ),
					),
					array(
						'id'          => 'woocommerce/orders-get',
						'operation'   => 'get',
						'label'       => __( 'Get Order', 'woocommerce' ),
						'description' => __( 'Retrieve detailed information about a single order by ID, including line items, customer details, and payment information.', 'woocommerce' ),
					),
					array(
						'id'          => 'woocommerce/orders-create',
						'operation'   => 'create',
						'label'       => __( 'Create Order', 'woocommerce' ),
						'description' => __( 'Create a new order with customer information, line items, shipping details, and payment information.', 'woocommerce' ),
					),
					array(
						'id'          => 'woocommerce/orders-update',
						'operation'   => 'update',
						'label'       => __( 'Update Order', 'woocommerce' ),
						'description' => __( 'Update an existing order by modifying status, customer information, line items, or other order details.', 'woocommerce' ),
					),
				),
			),
		);
	}

	/**
	 * Initialize the ability registration.
	 *
	 * @internal
	 */
	final public static function init(): void {
		/*
		 * Register abilities when Abilities API is ready.
		 * Support both old (pre-6.9) and new (6.9+) action names.
		 */
		add_action( 'abilities_api_init', array( __CLASS__, 'register_abilities' ) );
		add_action( 'wp_abilities_api_init', array( __CLASS__, 'register_abilities' ) );
	}

	/**
	 * Register all configured abilities.
	 */
	public static function register_abilities(): void {
		// Only register abilities if this is an MCP endpoint request.
		// We check here (on abilities_api_init action) rather than earlier
		// because REST request detection requires the WordPress REST infrastructure
		// to be fully initialized.
		if ( ! MCPAdapterProvider::is_mcp_request() ) {
			return;
		}

		foreach ( self::get_configurations() as $config ) {
			RestAbilityFactory::register_controller_abilities( $config );
		}
	}
}
PK     [1]{      Email/EmailFont.phpnu         <?php
/**
 * EmailFont class file
 */

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\Email;

/**
 * Helper class for getting fonts for emails.
 *
 * @internal Just for internal use.
 */
class EmailFont {

	/**
	 * Array of font families supported in email templates
	 *
	 * @var string[]
	 */
	public static $font = array(
		'Arial'           => "Arial, 'Helvetica Neue', Helvetica, sans-serif",
		'Comic Sans MS'   => "'Comic Sans MS', 'Marker Felt-Thin', Arial, sans-serif",
		'Courier New'     => "'Courier New', Courier, 'Lucida Sans Typewriter', 'Lucida Typewriter', monospace",
		'Georgia'         => "Georgia, Times, 'Times New Roman', serif",
		'Helvetica'       => "'Helvetica Neue', Helvetica, Roboto, Arial, sans-serif",
		'Lucida'          => "'Lucida Sans Unicode', 'Lucida Grande', sans-serif",
		'Tahoma'          => 'Tahoma, Verdana, Segoe, sans-serif',
		'Times New Roman' => "'Times New Roman', Times, Baskerville, Georgia, serif",
		'Trebuchet MS'    => "'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif",
		'Verdana'         => 'Verdana, Geneva, sans-serif',
	);
}
PK     [1]      Email/EmailStyleSync.phpnu         <?php
declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\Email;

use Automattic\WooCommerce\Internal\RegisterHooksInterface;

/**
 * Helper class for syncing email styles with theme styles.
 *
 * @internal Just for internal use.
 */
class EmailStyleSync implements RegisterHooksInterface {

	/**
	 * Option name for auto-sync setting.
	 */
	const AUTO_SYNC_OPTION = 'woocommerce_email_auto_sync_with_theme';

	/**
	 * Flag to prevent recursive syncing.
	 *
	 * @var bool
	 */
	private $is_syncing = false;

	/**
	 * Register hooks and filters.
	 */
	public function register() {
		// Hook into theme change events.
		add_action( 'after_switch_theme', array( $this, 'sync_email_styles_with_theme' ) );
		add_action( 'customize_save_after', array( $this, 'sync_email_styles_with_theme' ) );

		// Hook into theme.json and global styles changes.
		add_action( 'wp_theme_json_data_updated', array( $this, 'sync_email_styles_with_theme' ) );
		add_action( 'rest_after_insert_global_styles', array( $this, 'sync_email_styles_with_theme' ) );
		add_action( 'update_option_wp_global_styles', array( $this, 'sync_email_styles_with_theme' ) );
		add_action( 'save_post_wp_global_styles', array( $this, 'sync_email_styles_with_theme' ) );

		// Hook into the theme editor save action.
		add_action( 'wp_ajax_wp_save_styles', array( $this, 'sync_email_styles_with_theme' ), 999 );

		// Hook into auto-sync option update to trigger sync when enabled.
		add_action( 'update_option_' . self::AUTO_SYNC_OPTION, array( $this, 'maybe_sync_on_option_update' ), 10, 3 );
	}

	/**
	 * Trigger sync when auto-sync option is enabled.
	 *
	 * @param mixed  $old_value The old option value.
	 * @param mixed  $new_value The new option value.
	 * @param string $option    The option name.
	 */
	public function maybe_sync_on_option_update( $old_value, $new_value, $option ) {
		if ( 'yes' === $new_value && 'yes' !== $old_value ) {
			// Force sync regardless of current auto-sync setting since we know it's being enabled.
			$this->is_syncing = true;
			try {
				$this->update_email_colors();
			} finally {
				$this->is_syncing = false;
			}
		}
	}

	/**
	 * Check if auto-sync is enabled.
	 *
	 * @return bool Whether auto-sync is enabled.
	 */
	public function is_auto_sync_enabled() {
		return 'yes' === get_option( self::AUTO_SYNC_OPTION, 'no' );
	}

	/**
	 * Set auto-sync enabled status.
	 *
	 * @param bool $enabled Whether auto-sync should be enabled.
	 * @return bool Whether the option was updated.
	 */
	public function set_auto_sync( bool $enabled ) {
		return update_option( self::AUTO_SYNC_OPTION, $enabled ? 'yes' : 'no' );
	}

	/**
	 * Sync email styles with theme styles if auto-sync is enabled.
	 *
	 * Uses a flag to prevent recursive calls.
	 */
	public function sync_email_styles_with_theme() {
		if ( $this->is_syncing || ! $this->is_auto_sync_enabled() || ! wp_theme_has_theme_json() ) {
			return;
		}

		$this->is_syncing = true;

		try {
			$this->update_email_colors();
		} finally {
			$this->is_syncing = false;
		}
	}

	/**
	 * Update email colors from theme colors.
	 */
	protected function update_email_colors() {
		$colors = EmailColors::get_default_colors();
		if ( empty( $colors ) ) {
			return;
		}

		if ( ! empty( $colors['base'] ) ) {
			update_option( 'woocommerce_email_base_color', $colors['base'] );
		}

		if ( ! empty( $colors['bg'] ) ) {
			update_option( 'woocommerce_email_background_color', $colors['bg'] );
		}

		if ( ! empty( $colors['body_bg'] ) ) {
			update_option( 'woocommerce_email_body_background_color', $colors['body_bg'] );
		}

		if ( ! empty( $colors['body_text'] ) ) {
			update_option( 'woocommerce_email_text_color', $colors['body_text'] );
		}

		if ( ! empty( $colors['footer_text'] ) ) {
			update_option( 'woocommerce_email_footer_text_color', $colors['footer_text'] );
		}
	}
}
PK     [1]87Y  Y    Email/EmailColors.phpnu         <?php
/**
 * EmailColors class file
 */

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\Email;

use Automattic\WooCommerce\Utilities\FeaturesUtil;

/**
 * Helper class for email colors.
 *
 * @internal Just for internal use.
 */
class EmailColors {

	/**
	 * Get default colors for emails.
	 *
	 * @param bool|null $email_improvements_enabled Whether the email improvements feature is enabled.
	 * @return array Array of default email colors.
	 */
	public static function get_default_colors( ?bool $email_improvements_enabled = null ) {
		if ( null === $email_improvements_enabled ) {
			$email_improvements_enabled = FeaturesUtil::feature_is_enabled( 'email_improvements' );
		}

		$base        = '#720eec';
		$bg          = '#f7f7f7';
		$body_bg     = '#ffffff';
		$body_text   = '#3c3c3c';
		$footer_text = '#3c3c3c';

		if ( $email_improvements_enabled ) {
			$base        = '#8526ff';
			$bg          = '#ffffff';
			$body_bg     = '#ffffff';
			$body_text   = '#1e1e1e';
			$footer_text = '#787c82';

			$global_colors = static::get_colors_from_global_styles();

			if ( $global_colors ) {
				$base        = $global_colors['base'];
				$bg          = $global_colors['bg'];
				$body_bg     = $global_colors['body_bg'];
				$body_text   = $global_colors['body_text'];
				$footer_text = $global_colors['footer_text'];
			}
		}

		return compact(
			'base',
			'bg',
			'body_bg',
			'body_text',
			'footer_text',
		);
	}

	/**
	 * Get email colors from global styles.
	 *
	 * @return array|null Array of colors or null if global styles are not available or complete.
	 */
	public static function get_colors_from_global_styles() {
		$styles = static::get_global_styles_data();

		if ( ! $styles ) {
			return null;
		}

		$bg          = $styles['color']['background'] ?? null;
		$body_bg     = $styles['color']['background'] ?? null;
		$body_text   = $styles['color']['text'] ?? null;
		$base        = $styles['elements']['button']['color']['background'] ?? null;
		$footer_text = $styles['elements']['caption']['color']['text'] ?? null;

		$bg          = is_string( $bg ) ? sanitize_hex_color( $bg ) : '';
		$body_bg     = is_string( $body_bg ) ? sanitize_hex_color( $body_bg ) : '';
		$body_text   = is_string( $body_text ) ? sanitize_hex_color( $body_text ) : '';
		$base        = is_string( $base ) ? sanitize_hex_color( $base ) : $body_text;
		$footer_text = is_string( $footer_text ) ? sanitize_hex_color( $footer_text ) : $body_text;

		// Only return colors if all are set, otherwise email styles might not match and the email can become unreadable.
		if ( ! $bg || ! $body_bg || ! $body_text || ! $base || ! $footer_text ) {
			return null;
		}

		return compact(
			'base',
			'bg',
			'body_bg',
			'body_text',
			'footer_text',
		);
	}

	/**
	 * Method to retrieve global styles data.
	 *
	 * @return array|null
	 */
	protected static function get_global_styles_data() {
		if ( ! function_exists( 'wp_is_block_theme' ) || ! wp_is_block_theme() || ! function_exists( 'wp_get_global_styles' ) ) {
			return null;
		}
		return wp_get_global_styles( array(), array( 'transforms' => array( 'resolve-variables' ) ) );
	}
}
PK     [1]5Z
  
    Email/OrderPriceFormatter.phpnu         <?php
/**
 * OrderPriceFormatter class file.
 */

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\Email;

use WC_Abstract_Order;
use WC_Order_Item;

/**
 * Helper class for formatting prices in order emails.
 *
 * @internal Just for internal use.
 */
class OrderPriceFormatter {

	/**
	 * Gets item subtotal - formatted for display in emails.
	 *
	 * @param WC_Abstract_Order $order Order instance.
	 * @param WC_Order_Item     $item Item to get unit price from.
	 * @param string            $tax_display 'incl' or 'excl' tax display mode.
	 * @return string Formatted item subtotal.
	 */
	public static function get_formatted_item_subtotal( WC_Abstract_Order $order, WC_Order_Item $item, string $tax_display ): string {
		$includes_tax  = 'excl' !== $tax_display;
		$item_subtotal = $order->get_item_subtotal( $item, $includes_tax );
		return self::format_price( $order, $item_subtotal, $includes_tax );
	}

	/**
	 * Helper method to format price with or without tax.
	 *
	 * @param WC_Abstract_Order $order Order instance.
	 * @param float             $amount The amount to format.
	 * @param bool              $includes_tax Whether to include tax in the formatted price.
	 * @return string Formatted price string.
	 */
	private static function format_price( WC_Abstract_Order $order, float $amount, bool $includes_tax ): string {
		return wc_price(
			$amount,
			array(
				'ex_tax_label' => ( ! $includes_tax && $order->get_prices_include_tax() ) ? 1 : 0,
				'currency'     => $order->get_currency(),
			)
		);
	}
}
PK     [1]̔m:  m:  1  ProductDownloads/ApprovedDirectories/Register.phpnu         <?php

namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories;

use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin\SyncUI;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin\UI;
use Automattic\WooCommerce\Internal\Utilities\URL;
use Automattic\WooCommerce\Internal\Utilities\URLException;

/**
 * Maintains and manages the list of approved directories, within which product downloads can
 * be stored.
 */
class Register {
	/**
	 * Used to indicate the current mode.
	 */
	private const MODES = array(
		self::MODE_DISABLED,
		self::MODE_ENABLED,
	);

	public const MODE_DISABLED  = 'disabled';
	public const MODE_ENABLED   = 'enabled';

	/**
	 * Name of the option used to store the current mode. See self::MODES for a
	 * list of acceptable values for the actual option.
	 *
	 * @var string
	 */
	private $mode_option = 'wc_downloads_approved_directories_mode';

	/**
	 * Internal cache for memoization of valid URLs and parent directories.
	 *
	 * @var array
	 */
	private $cache = array();

	/**
	 * Sets up the approved directories sub-system.
	 *
	 * @internal
	 */
	final public function init() {
		add_action(
			'admin_init',
			function () {
				wc_get_container()->get( SyncUI::class )->init_hooks();
				wc_get_container()->get( UI::class )->init_hooks();
			}
		);

		add_action(
			'before_woocommerce_init',
			function() {
				wc_get_container()->get( Synchronize::class )->init_hooks();
			}
		);
	}

	/**
	 * Supplies the name of the database table used to store approved directories.
	 *
	 * @return string
	 */
	public function get_table(): string {
		global $wpdb;
		return $wpdb->prefix . 'wc_product_download_directories';
	}

	/**
	 * Returns a string indicating the current mode.
	 *
	 * May be one of: 'disabled', 'enabled', 'migrating'.
	 *
	 * @return string
	 */
	public function get_mode(): string {
		$current_mode = get_option( $this->mode_option, self::MODE_DISABLED );
		return in_array( $current_mode, self::MODES, true ) ? $current_mode : self::MODE_DISABLED;
	}

	/**
	 * Sets the mode. This effectively controls if approved directories are enforced or not.
	 *
	 * May be one of: 'disabled', 'enabled', 'migrating'.
	 *
	 * @param string $mode One of the values contained within self::MODES.
	 *
	 * @return bool
	 */
	public function set_mode( string $mode ): bool {
		if ( ! in_array( $mode, self::MODES, true ) ) {
			return false;
		}

		update_option( $this->mode_option, $mode );
		return get_option( $this->mode_option ) === $mode;
	}

	/**
	 * Adds a new URL path.
	 *
	 * On success (or if the URL was already added) returns the URL ID, or else
	 * returns boolean false.
	 *
	 * @throws URLException                 If the URL was invalid.
	 * @throws ApprovedDirectoriesException If the operation could not be performed.
	 *
	 * @param string $url     The URL of the approved directory.
	 * @param bool   $enabled If the rule is enabled.
	 *
	 * @return int
	 */
	public function add_approved_directory( string $url, bool $enabled = true ): int {
		$url      = $this->prepare_url_for_upsert( $url );
		$existing = $this->get_by_url( $url );

		if ( $existing ) {
			return $existing->get_id();
		}

		global $wpdb;
		$insert_fields = array(
			'url'     => $url,
			'enabled' => (int) $enabled,
		);

		if ( false !== $wpdb->insert( $this->get_table(), $insert_fields ) ) {
			unset( $this->cache );
			return $wpdb->insert_id;
		}

		throw new ApprovedDirectoriesException( __( 'URL could not be added (probable database error).', 'woocommerce' ), ApprovedDirectoriesException::DB_ERROR );
	}

	/**
	 * Updates an existing approved directory.
	 *
	 * On success or if there is an existing entry for the same URL, returns true.
	 *
	 * @throws ApprovedDirectoriesException If the operation could not be performed.
	 * @throws URLException                 If the URL was invalid.
	 *
	 * @param int    $id      The ID of the approved directory to be updated.
	 * @param string $url     The new URL for the specified option.
	 * @param bool   $enabled If the rule is enabled.
	 *
	 * @return bool
	 */
	public function update_approved_directory( int $id, string $url, bool $enabled = true ): bool {
		$url           = $this->prepare_url_for_upsert( $url );
		$existing_path = $this->get_by_url( $url );

		// No need to go any further if the URL is already listed and nothing has changed.
		if ( $existing_path && $existing_path->get_url() === $url && $enabled === $existing_path->is_enabled() ) {
			return true;
		}

		global $wpdb;
		$fields = array(
			'url'     => $url,
			'enabled' => (int) $enabled,
		);

		if ( false === $wpdb->update( $this->get_table(), $fields, array( 'url_id' => $id ) ) ) {
			throw new ApprovedDirectoriesException( __( 'URL could not be updated (probable database error).', 'woocommerce' ), ApprovedDirectoriesException::DB_ERROR );
		}

		return true;
	}

	/**
	 * Indicates if the specified URL is already an approved directory.
	 *
	 * @param string $url The URL to check.
	 *
	 * @return bool
	 */
	public function approved_directory_exists( string $url ): bool {
		return (bool) $this->get_by_url( $url );
	}

	/**
	 * Returns the path identified by $id, or false if it does not exist.
	 *
	 * @param int $id The ID of the rule we are looking for.
	 *
	 * @return StoredUrl|false
	 */
	public function get_by_id( int $id ) {
		global $wpdb;

		$table = $this->get_table();

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$result = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE url_id = %d", array( $id ) ) );

		if ( ! $result ) {
			return false;
		}

		return new StoredUrl( $result->url_id, $result->url, $result->enabled );
	}

	/**
	 * Returns the path identified by $url, or false if it does not exist.
	 *
	 * @param string $url The URL of the rule we are looking for.
	 *
	 * @return StoredUrl|false
	 */
	public function get_by_url( string $url ) {
		global $wpdb;

		$table = $this->get_table();
		$url   = trailingslashit( $url );

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$result = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE url = %s", array( $url ) ) );

		if ( ! $result ) {
			return false;
		}

		return new StoredUrl( $result->url_id, $result->url, $result->enabled );
	}

	/**
	 * Indicates if the URL is within an approved directory. The approved directory must be enabled
	 * (it is possible for individual approved directories to be disabled).
	 *
	 * For instance, for 'https://storage.king/12345/ebook.pdf' to be valid then 'https://storage.king/12345'
	 * would need to be within our register.
	 *
	 * If the provided URL is a filepath it can be passed in without the 'file://' scheme.
	 *
	 * @throws URLException If the provided URL is badly formed.
	 *
	 * @param string $download_url The URL to check.
	 *
	 * @return bool
	 */
	public function is_valid_path( string $download_url ): bool {
		global $wpdb;

		$url_cache_key = 'url:' . $download_url;
		if ( isset( $this->cache[ $url_cache_key ] ) ) {
			return $this->cache[ $url_cache_key ];
		}

		$url     = new URL( $this->normalize_url( $download_url ) );
		$parents = $url->get_all_parent_urls();

		if ( ! empty( $parents ) ) {
			sort( $parents );

			$parents_sql       = "'" . implode( "','", array_map( 'esc_sql', $parents ) ) . "'";
			$parents_cache_key = 'parents:' . md5( $parents_sql );

			if ( ! isset( $this->cache[ $parents_cache_key ] ) ) {
				// Look for a rule that matches the start of the download URL being tested. Since rules describe parent
				// directories, we also ensure it ends with a trailing slash.
				//
				// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $parents_sql is already escaped.
				$this->cache[ $parents_cache_key ] = (bool) $wpdb->get_var(
					"SELECT 1
					 FROM `{$this->get_table()}`
					 WHERE enabled = 1 AND url IN ({$parents_sql})"
				);
				// phpcs:enable
			}

			$this->cache[ $url_cache_key ] = $this->cache[ $parents_cache_key ];
		} else {
			$this->cache[ $url_cache_key ] = false;
		}

		return $this->cache[ $url_cache_key ];
	}

	/**
	 * Used when a URL string is prepared before potentially adding it to the database.
	 *
	 * It will be normalized and trailing-slashed; a length check will also be performed.
	 *
	 * @throws ApprovedDirectoriesException If the operation could not be performed.
	 * @throws URLException                 If the URL was invalid.
	 *
	 * @param string $url The string URL to be normalized and trailing-slashed.
	 *
	 * @return string
	 */
	private function prepare_url_for_upsert( string $url ): string {
		$url = trailingslashit( $this->normalize_url( $url ) );

		if ( mb_strlen( $url ) > 256 ) {
			throw new ApprovedDirectoriesException( __( 'Approved directory URLs cannot be longer than 256 characters.', 'woocommerce' ), ApprovedDirectoriesException::INVALID_URL );
		}

		return $url;
	}

	/**
	 * Normalizes the provided URL, by trimming whitespace per normal PHP conventions
	 * and removing any trailing slashes. If it lacks a scheme, the file scheme is
	 * assumed and prepended.
	 *
	 * @throws URLException If the URL is badly formed.
	 *
	 * @param string $url The URL to be normalized.
	 *
	 * @return string
	 */
	private function normalize_url( string $url ): string {
		$url = untrailingslashit( trim( $url ) );
		return ( new URL( $url ) )->get_url();
	}

	/**
	 * Lists currently approved directories.
	 *
	 * Returned array will have the following structure:
	 *
	 *     [
	 *         'total_urls'  => 12345,
	 *         'total_pages' => 123,
	 *         'urls'        => [],  # StoredUrl[]
	 *     ]
	 *
	 * @param array $args {
	 *     Controls pagination and ordering.
	 *
	 *     @type null|bool $enabled  Controls if only enabled (true), disabled (false) or all rules (null) should be listed.
	 *     @type string    $order    Ordering ('ASC' for ascending, 'DESC' for descending).
	 *     @type string    $order_by Field to order by (one of 'url_id' or 'url').
	 *     @type int       $page     The page of results to retrieve.
	 *     @type int       $per_page The number of results to retrieve per page.
	 *     @type string    $search   Term to search for.
	 * }
	 *
	 * @return array
	 */
	public function list( array $args ): array {
		global $wpdb;

		$args = array_merge(
			array(
				'enabled'  => null,
				'order'    => 'ASC',
				'order_by' => 'url',
				'page'     => 1,
				'per_page' => 20,
				'search'   => '',
			),
			$args
		);

		$table    = $this->get_table();
		$paths    = array();
		$order    = in_array( $args['order'], array( 'ASC', 'DESC' ), true ) ? $args['order'] : 'ASC';
		$order_by = in_array( $args['order_by'], array( 'url_id', 'url' ), true ) ? $args['order_by'] : 'url';
		$page     = absint( $args['page'] );
		$per_page = absint( $args['per_page'] );
		$enabled  = is_bool( $args['enabled'] ) ? $args['enabled'] : null;
		$search   = '%' . $wpdb->esc_like( sanitize_text_field( $args['search'] ) ) . '%';

		if ( $page < 1 ) {
			$page = 1;
		}

		if ( $per_page < 1 ) {
			$per_page = 1;
		}

		$where     = array();
		$where_sql = '';

		if ( ! empty( $search ) ) {
			$where[] = $wpdb->prepare( 'url LIKE %s', $search );
		}

		if ( is_bool( $enabled ) ) {
			$where[] = 'enabled = ' . (int) $enabled;
		}

		if ( ! empty( $where ) ) {
			$where_sql = 'WHERE ' . join( ' AND ', $where );
		}

		$limit_sql = $wpdb->prepare( 'LIMIT %d, %d', ( $page - 1 ) * $per_page, $per_page );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$results = $wpdb->get_results(
			"
				SELECT   url_id, url, enabled
				FROM     {$table}
				{$where_sql}
				ORDER BY {$order_by} {$order}
				{$limit_sql}
			"
		);

		$total_rows = (int) $wpdb->get_var( "SELECT COUNT( * ) FROM {$table} {$where_sql}" );
		// phpcs:enable

		foreach ( $results as $single_result ) {
			$paths[] = new StoredUrl( $single_result->url_id, $single_result->url, $single_result->enabled );
		}

		return array(
			'total_urls'           => $total_rows,
			'total_pages'          => (int) ceil( $total_rows / $per_page ),
			'approved_directories' => $paths,
		);
	}

	/**
	 * Delete the approved directory identitied by the supplied ID.
	 *
	 * @param int $id The ID of the rule to be deleted.
	 *
	 * @return bool
	 */
	public function delete_by_id( int $id ): bool {
		global $wpdb;

		if ( ! $wpdb->delete( $this->get_table(), array( 'url_id' => $id ) ) ) {
			return false;
		}

		unset( $this->cache );
		return true;
	}

	/**
	 * Delete the entirev approved directory list.
	 *
	 * @return bool
	 */
	public function delete_all(): bool {
		global $wpdb;

		if ( ! $wpdb->query( "DELETE FROM {$this->get_table()}" ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			return false;
		}

		unset( $this->cache );
		return true;
	}

	/**
	 * Enable the approved directory identitied by the supplied ID.
	 *
	 * @param int $id The ID of the rule to be deleted.
	 *
	 * @return bool
	 */
	public function enable_by_id( int $id ): bool {
		global $wpdb;
		$table = $this->get_table();

		if ( ! $wpdb->update( $table, array( 'enabled' => 1 ), array( 'url_id' => $id ) ) ) {
			return false;
		}

		unset( $this->cache );
		return true;
	}

	/**
	 * Disable the approved directory identitied by the supplied ID.
	 *
	 * @param int $id The ID of the rule to be deleted.
	 *
	 * @return bool
	 */
	public function disable_by_id( int $id ): bool {
		global $wpdb;

		if ( ! $wpdb->update( $this->get_table(), array( 'enabled' => 0 ), array( 'url_id' => $id ) ) ) {
			return false;
		}

		unset( $this->cache );
		return true;
	}

	/**
	 * Enables all Approved Download Directory rules in a single operation.
	 *
	 * @return bool
	 */
	public function enable_all(): bool {
		global $wpdb;

		if ( ! $wpdb->query( "UPDATE {$this->get_table()} SET enabled = 1" ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			return false;
		}

		unset( $this->cache );
		return true;
	}

	/**
	 * Disables all Approved Download Directory rules in a single operation.
	 *
	 * @return bool
	 */
	public function disable_all(): bool {
		global $wpdb;

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		if ( ! $wpdb->query( "UPDATE {$this->get_table()} SET enabled = 0" ) ) {
			return false;
		}

		unset( $this->cache );
		return true;
	}

	/**
	 * Indicates the number of approved directories that are enabled (or disabled, if optional
	 * param $enabled is set to false).
	 *
	 * @param bool $enabled Controls whether enabled or disabled directory rules are counted.
	 *
	 * @return int
	 */
	public function count( bool $enabled = true ): int {
		global $wpdb;
		$table = $this->get_table();

		return (int) $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"SELECT COUNT(*) FROM {$table} WHERE enabled = %d",
				$enabled ? 1 : 0
			)
		);
	}
}
PK     [1][.      4  ProductDownloads/ApprovedDirectories/Synchronize.phpnu         <?php

namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories;

use Exception;
use Automattic\WooCommerce\Internal\Utilities\URL;
use WC_Admin_Notices;
use WC_Product;
use WC_Queue_Interface;

/**
 * Ensures that any downloadable files have a corresponding entry in the Approved Product
 * Download Directories list.
 */
class Synchronize {
	/**
	 * Scheduled action hook used to facilitate scanning the product catalog for downloadable products.
	 */
	public const SYNC_TASK = 'woocommerce_download_dir_sync';

	/**
	 * The group under which synchronization tasks run (our standard 'woocommerce-db-updates' group).
	 */
	public const SYNC_TASK_GROUP = 'woocommerce-db-updates';

	/**
	 * Used to track progress throughout the sync process.
	 */
	public const SYNC_TASK_PAGE = 'wc_product_download_dir_sync_page';

	/**
	 * Used to record an estimation of progress on the current synchronization process. 0 means 0%,
	 * 100 means 100%.
	 *
	 * @param int
	 */
	public const SYNC_TASK_PROGRESS = 'wc_product_download_dir_sync_progress';

	/**
	 * Number of downloadable products to be processed in each atomic sync task.
	 */
	public const SYNC_TASK_BATCH_SIZE = 20;

	/**
	 * WC Queue.
	 *
	 * @var WC_Queue_Interface
	 */
	private $queue;

	/**
	 * Register of approved directories.
	 *
	 * @var Register
	 */
	private $register;

	/**
	 * Sets up our checks and controls for downloadable asset URLs, as appropriate for
	 * the current approved download directory mode.
	 *
	 * @internal
	 * @throws Exception If the WC_Queue instance cannot be obtained.
	 *
	 * @param Register $register The active approved download directories instance in use.
	 */
	final public function init( Register $register ) {
		$this->queue    = WC()->get_instance_of( WC_Queue_Interface::class );
		$this->register = $register;

	}

	/**
	 * Performs any work needed to add hooks and otherwise integrate with the wider system.
	 */
	final public function init_hooks() {
		add_action( self::SYNC_TASK, array( $this, 'run' ) );
	}

	/**
	 * Initializes the Approved Download Directories feature, typically following an update or
	 * during initial installation.
	 *
	 * @param bool $synchronize    Synchronize with existing product downloads. Not needed in a fresh installation.
	 * @param bool $enable_feature Enable (default) or disable the feature.
	 */
	public function init_feature( bool $synchronize = true, bool $enable_feature = true ) {
		try {
			$this->add_default_directories();

			if ( $synchronize ) {
				$this->start();
			}
		} catch ( Exception $e ) {
			wc_get_logger()->log( 'warning', __( 'It was not possible to synchronize download directories following the most recent update.', 'woocommerce' ) );
		}

		$this->register->set_mode(
			$enable_feature ? Register::MODE_ENABLED : Register::MODE_DISABLED
		);
	}

	/**
	 * By default we add the woocommerce_uploads directory (file path plus web URL) to the list
	 * of approved download directories.
	 *
	 * @throws Exception If the default directories cannot be added to the Approved List.
	 */
	public function add_default_directories() {
		$upload_dir = wp_get_upload_dir();
		$this->register->add_approved_directory( $upload_dir['basedir'] . '/woocommerce_uploads' );
		$this->register->add_approved_directory( $upload_dir['baseurl'] . '/woocommerce_uploads' );
	}

	/**
	 * Starts the synchronization process.
	 *
	 * @return bool
	 */
	public function start(): bool {
		if ( null !== $this->queue->get_next( self::SYNC_TASK ) ) {
			wc_get_logger()->log( 'warning', __( 'Synchronization of approved product download directories is already in progress.', 'woocommerce' ) );
			return false;
		}

		update_option( self::SYNC_TASK_PAGE, 1 );
		$this->queue->schedule_single( time(), self::SYNC_TASK, array(), self::SYNC_TASK_GROUP );
		wc_get_logger()->log( 'info', __( 'Approved Download Directories sync: new scan scheduled.', 'woocommerce' ) );
		return true;
	}

	/**
	 * Runs the synchronization task.
	 */
	public function run() {
		$products = $this->get_next_set_of_downloadable_products();

		foreach ( $products as $product ) {
			$this->process_product( $product );
		}

		// Detect if we have reached the end of the task.
		if ( count( $products ) < self::SYNC_TASK_BATCH_SIZE ) {
			wc_get_logger()->log( 'info', __( 'Approved Download Directories sync: scan is complete!', 'woocommerce' ) );
			$this->stop();
		} else {
			wc_get_logger()->log(
				'info',
				sprintf(
				/* translators: %1$d is the current batch in the synchronization task, %2$d is the percent complete. */
					__( 'Approved Download Directories sync: completed batch %1$d (%2$d%% complete).', 'woocommerce' ),
					(int) get_option( self::SYNC_TASK_PAGE, 2 ) - 1,
					$this->get_progress()
				)
			);
			$this->queue->schedule_single( time() + 1, self::SYNC_TASK, array(), self::SYNC_TASK_GROUP );
		}
	}

	/**
	 * Stops/cancels the current synchronization task.
	 */
	public function stop() {
		WC_Admin_Notices::add_notice( 'download_directories_sync_complete', true );
		delete_option( self::SYNC_TASK_PAGE );
		delete_option( self::SYNC_TASK_PROGRESS );
		$this->queue->cancel( self::SYNC_TASK );
	}

	/**
	 * Queries for the next batch of downloadable products, applying logic to ensure we only fetch those that actually
	 * have downloadable files (a downloadable product can be created that does not have downloadable files and/or
	 * downloadable files can be removed from existing downloadable products).
	 *
	 * @return array
	 */
	private function get_next_set_of_downloadable_products(): array {
		$query_filter = function ( array $query ): array {
			$query['meta_query'][] = array(
				'key'     => '_downloadable_files',
				'compare' => 'EXISTS',
			);

			return $query;
		};

		$page = (int) get_option( self::SYNC_TASK_PAGE, 1 );
		add_filter( 'woocommerce_product_data_store_cpt_get_products_query', $query_filter );

		$products = wc_get_products(
			array(
				'limit'    => self::SYNC_TASK_BATCH_SIZE,
				'page'     => $page,
				'paginate' => true,
			)
		);

		remove_filter( 'woocommerce_product_data_store_cpt_get_products_query', $query_filter );
		$progress = $products->max_num_pages > 0 ? (int) ( ( $page / $products->max_num_pages ) * 100 ) : 1;
		update_option( self::SYNC_TASK_PAGE, $page + 1 );
		update_option( self::SYNC_TASK_PROGRESS, $progress );

		return $products->products;
	}

	/**
	 * Processes an individual downloadable product, adding the parent paths for any downloadable files to the
	 * Approved Download Directories list.
	 *
	 * Any such paths will be added with the disabled flag set, because we want a site administrator to review
	 * and approve first.
	 *
	 * @param WC_Product $product The product we wish to examine for downloadable file paths.
	 */
	private function process_product( WC_Product $product ) {
		$downloads = $product->get_downloads();

		foreach ( $downloads as $downloadable ) {
			$parent_url = _x( 'invalid URL', 'Approved product download URLs migration', 'woocommerce' );

			try {
				$download_file = $downloadable->get_file();

				/**
				 * Controls whether shortcodes should be resolved and validated using the Approved Download Directory feature.
				 *
				 * @param bool $should_validate
				 */
				if ( apply_filters( 'woocommerce_product_downloads_approved_directory_validation_for_shortcodes', true ) && 'shortcode' === $downloadable->get_type_of_file_path() ) {
					$download_file = do_shortcode( $download_file );
				}

				$parent_url = ( new URL( $download_file ) )->get_parent_url();
				$this->register->add_approved_directory( $parent_url, false );
			} catch ( Exception $e ) {
				wc_get_logger()->log(
					'error',
					sprintf(
					/* translators: %s is a URL, %d is a product ID. */
						__( 'Product download migration: %1$s (for product %1$d) could not be added to the list of approved download directories.', 'woocommerce' ),
						$parent_url,
						$product->get_id()
					)
				);
			}
		}
	}

	/**
	 * Indicates if a synchronization of product download directories is in progress.
	 *
	 * @return bool
	 */
	public function in_progress(): bool {
		return (bool) get_option( self::SYNC_TASK_PAGE, false );
	}

	/**
	 * Returns a value between 0 and 100 representing the percentage complete of the current sync.
	 *
	 * @return int
	 */
	public function get_progress(): int {
		return min( 100, max( 0, (int) get_option( self::SYNC_TASK_PROGRESS, 0 ) ) );
	}
}
PK     [1]S  S  E  ProductDownloads/ApprovedDirectories/ApprovedDirectoriesException.phpnu         <?php

namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories;

use Exception;

/**
 * Encapsulates a problem encountered while an operation relating to approved directories
 * was performed.
 */
class ApprovedDirectoriesException extends Exception {
	public const INVALID_URL = 1;
	public const DB_ERROR    = 2;
}
PK     [1]dM    2  ProductDownloads/ApprovedDirectories/StoredUrl.phpnu         <?php

namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories;

/**
 * Representation of an approved directory URL, bundling the ID and URL in a single entity.
 */
class StoredUrl {
	/**
	 * The approved directory ID.
	 *
	 * @var int
	 */
	private $id;

	/**
	 * The approved directory URL.
	 *
	 * @var string
	 */
	private $url;

	/**
	 * If the individual rule is enabled or disabled.
	 *
	 * @var bool
	 */
	private $enabled;

	/**
	 * Sets up the approved directory rule.
	 *
	 * @param int    $id      The approved directory ID.
	 * @param string $url     The approved directory URL.
	 * @param bool   $enabled Indicates if the approved directory rule is enabled.
	 */
	public function __construct( int $id, string $url, bool $enabled ) {
		$this->id      = $id;
		$this->url     = $url;
		$this->enabled = $enabled;
	}

	/**
	 * Supplies the ID of the approved directory.
	 *
	 * @return int
	 */
	public function get_id(): int {
		return $this->id;
	}

	/**
	 * Supplies the approved directory URL.
	 *
	 * @return string
	 */
	public function get_url(): string {
		return $this->url;
	}

	/**
	 * Indicates if this rule is enabled or not (rules can be temporarily disabled).
	 *
	 * @return bool
	 */
	public function is_enabled(): bool {
		return $this->enabled;
	}
}
PK     [1]    5  ProductDownloads/ApprovedDirectories/Admin/SyncUI.phpnu         <?php

namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin;

use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Synchronize;
use Automattic\WooCommerce\Internal\Utilities\Users;

/**
 * Adds tools to the Status > Tools page that can be used to (re-)initiate or stop a synchronization process
 * for Approved Download Directories.
 */
class SyncUI {
	/**
	 * The active register of approved directories.
	 *
	 * @var Register
	 */
	private $register;

	/**
	 * Sets up UI controls for product download URLs.
	 *
	 * @internal
	 *
	 * @param Register $register Register of approved directories.
	 */
	final public function init( Register $register ) {
		$this->register = $register;
	}

	/**
	 * Performs any work needed to add hooks and otherwise integrate with the wider system,
	 * except in the case where the current user is not a site administrator, no hooks will
	 * be initialized.
	 */
	final public function init_hooks() {
		if ( ! Users::is_site_administrator() ) {
			return;
		}

		add_filter( 'woocommerce_debug_tools', array( $this, 'add_tools' ) );
	}

	/**
	 * Adds Approved Directory list-related entries to the tools page.
	 *
	 * @param array $tools Admin tool definitions.
	 *
	 * @return array
	 */
	public function add_tools( array $tools ): array {
		$sync = wc_get_container()->get( Synchronize::class );

		if ( ! $sync->in_progress() ) {
			// Provide tools to trigger a fresh scan (migration) and to clear the Approved Directories list.
			$tools['approved_directories_sync'] = array(
				'name'             => __( 'Synchronize approved download directories', 'woocommerce' ),
				'desc'             => __( 'Updates the list of Approved Product Download Directories. Note that triggering this tool does not impact whether the Approved Download Directories list is enabled or not.', 'woocommerce' ),
				'button'           => __( 'Update', 'woocommerce' ),
				'callback'         => array( $this, 'trigger_sync' ),
				'requires_refresh' => true,
			);

			$tools['approved_directories_clear'] = array(
				'name'             => __( 'Empty the approved download directories list', 'woocommerce' ),
				'desc'             => __( 'Removes all existing entries from the Approved Product Download Directories list.', 'woocommerce' ),
				'button'           => __( 'Clear', 'woocommerce' ),
				'callback'         => array( $this, 'clear_existing_entries' ),
				'requires_refresh' => true,
			);
		} else {
			// Or if a scan (migration) is already in progress, offer a means of cancelling it.
			$tools['cancel_directories_scan'] = array(
				'name'     => __( 'Cancel synchronization of approved directories', 'woocommerce' ),
				'desc'     => sprintf(
				/* translators: %d is an integer between 0-100 representing the percentage complete of the current scan. */
					__( 'The Approved Product Download Directories list is currently being synchronized with the product catalog (%d%% complete). If you need to, you can cancel it.', 'woocommerce' ),
					$sync->get_progress()
				),
				'button'   => __( 'Cancel', 'woocommerce' ),
				'callback' => array( $this, 'cancel_sync' ),
			);
		}

		return $tools;
	}

	/**
	 * Triggers a new migration.
	 */
	public function trigger_sync() {
		$this->security_check();
		wc_get_container()->get( Synchronize::class )->start();
	}

	/**
	 * Clears all existing rules from the Approved Directories list.
	 */
	public function clear_existing_entries() {
		$this->security_check();
		$this->register->delete_all();
	}

	/**
	 * If a migration is in progress, this will attempt to cancel it.
	 */
	public function cancel_sync() {
		$this->security_check();
		wc_get_logger()->log( 'info', __( 'Approved Download Directories sync: scan has been cancelled.', 'woocommerce' ) );
		wc_get_container()->get( Synchronize::class )->stop();
	}

	/**
	 * Makes sure the user has appropriate permissions and that we have a valid nonce.
	 */
	private function security_check() {
		if ( ! Users::is_site_administrator() ) {
			wp_die( esc_html__( 'You do not have permission to modify the list of approved directories for product downloads.', 'woocommerce' ) );
		}
	}
}
PK     [1];'  '  4  ProductDownloads/ApprovedDirectories/Admin/Table.phpnu         <?php

namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin;

use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register;
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\StoredUrl;
use WP_List_Table;
use WP_Screen;

/**
 * Admin list table used to render our current list of approved directories.
 */
class Table extends WP_List_Table {
	/**
	 * Initialize the webhook table list.
	 */
	public function __construct() {
		parent::__construct(
			array(
				'singular' => 'url',
				'plural'   => 'urls',
				'ajax'     => false,
			)
		);

		add_filter( 'manage_woocommerce_page_wc-settings_columns', array( $this, 'get_columns' ) );
		$this->items_per_page();
		set_screen_options();
	}

	/**
	 * Sets up an items-per-page control.
	 */
	private function items_per_page() {
		add_screen_option(
			'per_page',
			array(
				'default' => 20,
				'option'  => 'edit_approved_directories_per_page',
			)
		);

		add_filter( 'set_screen_option_edit_approved_directories_per_page', array( $this, 'set_items_per_page' ), 10, 3 );
	}

	/**
	 * Saves the items-per-page setting.
	 *
	 * @param mixed  $default The default value.
	 * @param string $option  The option being configured.
	 * @param int    $value   The submitted option value.
	 *
	 * @return mixed
	 */
	public function set_items_per_page( $default, string $option, int $value ) {
		return 'edit_approved_directories_per_page' === $option ? absint( $value ) : $default;
	}

	/**
	 * No items found text.
	 */
	public function no_items() {
		esc_html_e( 'No approved directory URLs found.', 'woocommerce' );
	}

	/**
	 * Displays the list of views available on this table.
	 */
	public function render_views() {
		$register = wc_get_container()->get( Register::class );

		$enabled_count  = $register->count( true );
		$disabled_count = $register->count( false );
		$all_count      = $enabled_count + $disabled_count;
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$selected_view = isset( $_REQUEST['view'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['view'] ) ) : 'all';

		$all_url   = esc_url( add_query_arg( 'view', 'all', $this->get_base_url() ) );
		$all_class = 'all' === $selected_view ? 'class="current"' : '';
		$all_text  = sprintf(
			/* translators: %s is the count of approved directory list entries. */
			_nx(
				'All <span class="count">(%s)</span>',
				'All <span class="count">(%s)</span>',
				$all_count,
				'Approved product download directory views',
				'woocommerce'
			),
			$all_count
		);

		$enabled_url   = esc_url( add_query_arg( 'view', 'enabled', $this->get_base_url() ) );
		$enabled_class = 'enabled' === $selected_view ? 'class="current"' : '';
		$enabled_text  = sprintf(
			/* translators: %s is the count of enabled approved directory list entries. */
			_nx(
				'Enabled <span class="count">(%s)</span>',
				'Enabled <span class="count">(%s)</span>',
				$enabled_count,
				'Approved product download directory views',
				'woocommerce'
			),
			$enabled_count
		);

		$disabled_url   = esc_url( add_query_arg( 'view', 'disabled', $this->get_base_url() ) );
		$disabled_class = 'disabled' === $selected_view ? 'class="current"' : '';
		$disabled_text  = sprintf(
			/* translators: %s is the count of disabled directory list entries. */
			_nx(
				'Disabled <span class="count">(%s)</span>',
				'Disabled <span class="count">(%s)</span>',
				$disabled_count,
				'Approved product download directory views',
				'woocommerce'
			),
			$disabled_count
		);

		$views = array(
			'all'      => "<a href='{$all_url}' {$all_class}>{$all_text}</a>",
			'enabled'  => "<a href='{$enabled_url}' {$enabled_class}>{$enabled_text}</a>",
			'disabled' => "<a href='{$disabled_url}' {$disabled_class}>{$disabled_text}</a>",
		);

		$this->screen->render_screen_reader_content( 'heading_views' );

		echo '<ul class="subsubsub list-table-filters">';
		foreach ( $views as $slug => $view ) {
			$views[ $slug ] = "<li class='{$slug}'>{$view}";
		}
		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		echo implode( ' | </li>', $views ) . "</li>\n";
		echo '</ul>';
	}

	/**
	 * Get list columns.
	 *
	 * @return array
	 */
	public function get_columns() {
		return array(
			'cb'    => '<input type="checkbox" />',
			'title' => _x( 'URL', 'Approved product download directories', 'woocommerce' ),
			'enabled' => _x( 'Enabled', 'Approved product download directories', 'woocommerce' ),
		);
	}

	/**
	 * Checklist column, used for selecting items for processing by a bulk action.
	 *
	 * @param StoredUrl $item The approved directory information for the current row.
	 *
	 * @return string
	 */
	public function column_cb( $item ) {
		return sprintf( '<input type="checkbox" name="%1$s[]" value="%2$s" />', esc_attr( $this->_args['singular'] ), esc_attr( $item->get_id() ) );
	}

	/**
	 * URL column.
	 *
	 * @param StoredUrl $item The approved directory information for the current row.
	 *
	 * @return string
	 */
	public function column_title( $item ) {
		$id      = (int) $item->get_id();
		$url     = esc_html( $item->get_url() );
		$enabled = $item->is_enabled();

		$edit_url            = esc_url( $this->get_action_url( 'edit', $id ) );
		$enable_disable_url  = esc_url( $enabled ? $this->get_action_url( 'disable', $id ) : $this->get_action_url( 'enable', $id ) );
		$enable_disable_text = esc_html( $enabled ? __( 'Disable', 'woocommerce' ) : __( 'Enable', 'woocommerce' ) );
		$delete_url          = esc_url( $this->get_action_url( 'delete', $id ) );
		$edit_link           = "<a href='{$edit_url}'>" . esc_html_x( 'Edit', 'Product downloads list', 'woocommerce' ) . '</a>';
		$enable_disable_link = "<a href='{$enable_disable_url}'>{$enable_disable_text}</a>";
		$delete_link         = "<a href='{$delete_url}' class='submitdelete wc-confirm-delete'>" . esc_html_x( 'Delete permanently', 'Product downloads list', 'woocommerce' ) . '</a>';
		$url_link            = "<a href='{$edit_url}'>{$url}</a>";

		return "
			<strong>{$url_link}</strong>
			<div class='row-actions'>
				<span class='id'>ID: {$id}</span> |
				<span class='edit'>{$edit_link}</span> |
				<span class='enable-disable'>{$enable_disable_link}</span> |
				<span class='delete'><a class='submitdelete'>{$delete_link}</a></span>
			</div>
		";
	}

	/**
	 * Rule-is-enabled column.
	 *
	 * @param StoredUrl $item The approved directory information for the current row.
	 *
	 * @return string
	 */
	public function column_enabled( StoredUrl $item ): string {
		return $item->is_enabled()
			? '<mark class="yes" title="' . esc_html__( 'Enabled', 'woocommerce' ) . '"><span class="dashicons dashicons-yes"></span></mark>'
			: '<mark class="no" title="' . esc_html__( 'Disabled', 'woocommerce' ) . '">&ndash;</mark>';
	}

	/**
	 * Get bulk actions.
	 *
	 * @return array
	 */
	protected function get_bulk_actions() {
		return array(
			'enable'  => __( 'Enable rule', 'woocommerce' ),
			'disable' => __( 'Disable rule', 'woocommerce' ),
			'delete'  => __( 'Delete permanently', 'woocommerce' ),
		);
	}

	/**
	 * Builds an action URL (ie, to edit or delete a row).
	 *
	 * @param string $action       The action to be created.
	 * @param int    $id           The ID that is the subject of the action.
	 * @param string $nonce_action Action used to add a nonce to the URL.
	 *
	 * @return string
	 */
	public function get_action_url( string $action, int $id, string $nonce_action = 'modify_approved_directories' ): string {
		return add_query_arg(
			array(
				'check'  => wp_create_nonce( $nonce_action ),
				'action' => $action,
				'url'    => $id,
			),
			$this->get_base_url()
		);
	}

	/**
	 * Supplies the 'base' admin URL for this admin table.
	 *
	 * @return string
	 */
	public function get_base_url(): string {
		return add_query_arg(
			array(
				'page'    => 'wc-settings',
				'tab'     => 'products',
				'section' => 'download_urls',
			),
			admin_url( 'admin.php' )
		);
	}

	/**
	 * Generate the table navigation above or below the table.
	 * Included to remove extra nonce input.
	 *
	 * @param string $which The location of the extra table nav markup: 'top' or 'bottom'.
	 */
	protected function display_tablenav( $which ) {
		$directories = wc_get_container()->get( Register::class );
		echo '<div class="tablenav ' . esc_attr( $which ) . '">';

		if ( $this->has_items() ) {
			echo '<div class="alignleft actions bulkactions">';
			$this->bulk_actions( $which );

			if ( $directories->count( false ) > 0 ) {
				echo '<a href="' . esc_url( $this->get_action_url( 'enable-all', 0 ) ) . '" class="wp-core-ui button">' . esc_html_x( 'Enable All', 'Approved product download directories', 'woocommerce' ) . '</a> ';
			}

			if ( $directories->count( true ) > 0 ) {
				echo '<a href="' . esc_url( $this->get_action_url( 'disable-all', 0 ) ) . '" class="wp-core-ui button">' . esc_html_x( 'Disable All', 'Approved product download directories', 'woocommerce' ) . '</a>';
			}

			echo '</div>';
		}

		$this->pagination( $which );
		echo '<br class="clear" />';
		echo '</div>';
	}

	/**
	 * Prepare table list items.
	 */
	public function prepare_items() {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		// phpcs:disable WordPress.Security.NonceVerification.Missing
		$current_page = $this->get_pagenum();
		$per_page     = $this->get_items_per_page( 'edit_approved_directories_per_page' );
		$search       = sanitize_text_field( wp_unslash( $_REQUEST['s'] ?? '' ) );

		switch ( $_REQUEST['view'] ?? '' ) {
			case 'enabled':
				$enabled = true;
				break;

			case 'disabled':
				$enabled = false;
				break;

			default:
				$enabled = null;
				break;
		}
		// phpcs:enable

		$approved_directories = wc_get_container()->get( Register::class )->list(
			array(
				'page'     => $current_page,
				'per_page' => $per_page,
				'search'   => $search,
				'enabled'  => $enabled,
			)
		);

		$this->items = $approved_directories['approved_directories'];

		// Set the pagination.
		$this->set_pagination_args(
			array(
				'total_items' => $approved_directories['total_urls'],
				'total_pages' => $approved_directories['total_pages'],
				'per_page'    => $per_page,
			)
		);
	}
}
PK     [1]x":  :  1  ProductDownloads/ApprovedDirectories/Admin/UI.phpnu         <?php

namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Admin;

use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register;
use Automattic\WooCommerce\Internal\Utilities\Users;
use Exception;
use WC_Admin_Settings;

/**
 * Manages user interactions for product download URL safety.
 */
class UI {
	/**
	 * The active register of approved directories.
	 *
	 * @var Register
	 */
	private $register;

	/**
	 * The WP_List_Table instance used to display approved directories.
	 *
	 * @var Table
	 */
	private $table;

	/**
	 * Sets up UI controls for product download URLs.
	 *
	 * @internal
	 *
	 * @param Register $register Register of approved directories.
	 */
	final public function init( Register $register ) {
		$this->register = $register;
	}

	/**
	 * Performs any work needed to add hooks and otherwise integrate with the wider system,
	 * except in the case where the current user is not a site administrator, no hooks will
	 * be initialized.
	 */
	final public function init_hooks() {
		if ( ! Users::is_site_administrator() ) {
			return;
		}

		add_filter( 'woocommerce_get_sections_products', array( $this, 'add_section' ) );
		add_action( 'load-woocommerce_page_wc-settings', array( $this, 'setup' ) );
		add_action( 'woocommerce_settings_products', array( $this, 'render' ) );
	}

	/**
	 * Injects our new settings section (when approved directory rules are disabled, it will not show).
	 *
	 * @param array $sections Other admin settings sections.
	 *
	 * @return array
	 */
	public function add_section( array $sections ): array {
		$sections['download_urls'] = __( 'Approved download directories', 'woocommerce' );
		return $sections;
	}

	/**
	 * Sets up the table, renders any notices and processes actions as needed.
	 */
	public function setup() {
		if ( ! $this->is_download_urls_screen() ) {
			return;
		}

		$this->table = new Table();
		$this->admin_notices();
		$this->handle_search();
		$this->process_actions();
	}

	/**
	 * Renders the UI.
	 */
	public function render() {
		if ( null === $this->table || ! $this->is_download_urls_screen() ) {
			return;
		}

		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		if ( isset( $_REQUEST['action'] ) && 'edit' === $_REQUEST['action'] && isset( $_REQUEST['url'] ) ) {
			$this->edit_screen( (int) $_REQUEST['url'] );
			return;
		}
		// phpcs:enable

		// Show list table.
		$this->table->prepare_items();
		wp_nonce_field( 'modify_approved_directories', 'check' );
		$this->display_title();
		$this->table->render_views();
		$this->table->search_box( _x( 'Search', 'Approved Directory URLs', 'woocommerce' ), 'download_url_search' );
		$this->table->display();
	}

	/**
	 * Indicates if we are currently on the download URLs admin screen.
	 *
	 * @return bool
	 */
	private function is_download_urls_screen(): bool {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		return isset( $_GET['tab'] )
			&& 'products' === $_GET['tab']
			&& isset( $_GET['section'] )
			&& 'download_urls' === $_GET['section'];
		// phpcs:enable
	}

	/**
	 * Process bulk and single-row actions.
	 */
	private function process_actions() {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		$ids = isset( $_REQUEST['url'] ) ? array_map( 'absint', (array) $_REQUEST['url'] ) : array();

		if ( empty( $ids ) || empty( $_REQUEST['action'] ) ) {
			return;
		}

		$this->security_check();

		$action = sanitize_text_field( wp_unslash( $_REQUEST['action'] ) );

		switch ( $action ) {
			case 'edit':
				$this->process_edits( current( $ids ) );
				break;

			case 'delete':
			case 'enable':
			case 'disable':
				$this->process_bulk_actions( $ids, $action );
				break;

			case 'enable-all':
			case 'disable-all':
				$this->process_all_actions( $action );
				break;

			case 'turn-on':
			case 'turn-off':
				$this->process_on_off( $action );
				break;
		}
		// phpcs:enable
	}

	/**
	 * Support pagination across search results.
	 *
	 * In the context of the WC settings screen, form data is submitted by the post method: that poses
	 * a problem for the default WP_List_Table pagination logic which expects the search value to live
	 * as part of the URL query. This method is a simple shim to bridge the resulting gap.
	 */
	private function handle_search() {
		// phpcs:disable WordPress.Security.NonceVerification.Missing
		// phpcs:disable WordPress.Security.NonceVerification.Recommended

		// If a search value has not been POSTed, or if it was POSTed but is already equal to the
		// same value in the URL query, we need take no further action.
		if ( empty( $_POST['s'] ) || sanitize_text_field( wp_unslash( $_GET['s'] ?? '' ) ) === $_POST['s'] ) {
			return;
		}

		wp_safe_redirect(
			add_query_arg(
				array(
					'paged' => absint( $_GET['paged'] ?? 1 ),
					's'     => sanitize_text_field( wp_unslash( $_POST['s'] ) ),
				),
				$this->table->get_base_url()
			)
		);
		// phpcs:enable

		exit;
	}

	/**
	 * Handles updating or adding a new URL to the list of approved directories.
	 *
	 * @param int $url_id The ID of the rule to be edited/created. Zero if we are creating a new entry.
	 */
	private function process_edits( int $url_id ) {
		// phpcs:disable WordPress.Security.NonceVerification.Missing
		$url     = esc_url_raw( wp_unslash( $_POST['approved_directory_url'] ?? '' ) );
		$enabled = (bool) sanitize_text_field( wp_unslash( $_POST['approved_directory_enabled'] ?? '' ) );

		if ( empty( $url ) ) {
			return;
		}

		$redirect_url = add_query_arg( 'id', $url_id, $this->table->get_action_url( 'edit', $url_id ) );

		try {
			$upserted = 0 === $url_id
				? $this->register->add_approved_directory( $url, $enabled )
				: $this->register->update_approved_directory( $url_id, $url, $enabled );

			if ( is_integer( $upserted ) ) {
				$redirect_url = add_query_arg( 'url', $upserted, $redirect_url );
			}

			$redirect_url = add_query_arg( 'edit-status', 0 === $url_id ? 'added' : 'updated', $redirect_url );
		} catch ( Exception $e ) {
			$redirect_url = add_query_arg(
				array(
					'edit-status'   => 'failure',
					'submitted-url' => $url,
				),
				$redirect_url
			);
		}

		wp_safe_redirect( $redirect_url );
		exit;
		// phpcs:enable WordPress.Security.NonceVerification.Missing
	}

	/**
	 * Processes actions that can be applied in bulk (requests to delete, enable
	 * or disable).
	 *
	 * @param int[]  $ids    The ID(s) to be updates.
	 * @param string $action The action to be applied.
	 */
	private function process_bulk_actions( array $ids, string $action ) {
		$deletes  = 0;
		$enabled  = 0;
		$disabled = 0;
		$register = wc_get_container()->get( Register::class );

		foreach ( $ids as $id ) {
			if ( 'delete' === $action && $register->delete_by_id( $id ) ) {
				$deletes++;
			} elseif ( 'enable' === $action && $register->enable_by_id( $id ) ) {
				$enabled++;
			} elseif ( 'disable' === $action && $register->disable_by_id( $id ) ) {
				$disabled ++;
			}
		}

		$fails    = count( $ids ) - $deletes - $enabled - $disabled;
		$redirect = $this->table->get_base_url();

		if ( $deletes ) {
			$redirect = add_query_arg( 'deleted-ids', $deletes, $redirect );
		} elseif ( $enabled ) {
			$redirect = add_query_arg( 'enabled-ids', $enabled, $redirect );
		} elseif ( $disabled ) {
			$redirect = add_query_arg( 'disabled-ids', $disabled, $redirect );
		}

		if ( $fails ) {
			$redirect = add_query_arg( 'bulk-fails', $fails, $redirect );
		}

		wp_safe_redirect( $redirect );
		exit;
	}

	/**
	 * Handles the enable/disable-all actions.
	 *
	 * @param string $action The action to be applied.
	 */
	private function process_all_actions( string $action ) {
		$register = wc_get_container()->get( Register::class );
		$redirect = $this->table->get_base_url();

		switch ( $action ) {
			case 'enable-all':
				$redirect = add_query_arg( 'enabled-all', (int) $register->enable_all(), $redirect );
				break;

			case 'disable-all':
				$redirect = add_query_arg( 'disabled-all', (int) $register->disable_all(), $redirect );
				break;
		}

		wp_safe_redirect( $redirect );
			exit;
}

	/**
	 * Handles turning on/off the entire approved download directory system (vs enabling
	 * and disabling of individual rules).
	 *
	 * @param string $action Whether the feature should be turned on or off.
	 */
	private function process_on_off( string $action ) {
		switch ( $action ) {
				case 'turn-on':
					$this->register->set_mode( Register::MODE_ENABLED );
					break;

			case 'turn-off':
				$this->register->set_mode( Register::MODE_DISABLED );
				break;
		}
	}

	/**
	 * Displays the screen title, etc.
	 */
	private function display_title() {
		$turn_on_off = $this->register->get_mode() === Register::MODE_ENABLED
			? '<a href="' . esc_url( $this->table->get_action_url( 'turn-off', 0 ) ) . '" class="page-title-action">' . esc_html_x( 'Stop Enforcing Rules', 'Approved product download directories', 'woocommerce' ) . '</a>'
			: '<a href="' . esc_url( $this->table->get_action_url( 'turn-on', 0 ) ) . '" class="page-title-action">' . esc_html_x( 'Start Enforcing Rules', 'Approved product download directories', 'woocommerce' ) . '</a>';

		?>
			<h2 class='wc-table-list-header'>
				<?php esc_html_e( 'Approved Download Directories', 'woocommerce' ); ?>
				<a href='<?php echo esc_url( $this->table->get_action_url( 'edit', 0 ) ); ?>' class='page-title-action'><?php esc_html_e( 'Add New', 'woocommerce' ); ?></a>
				<?php echo $turn_on_off; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
			</h2>
		<?php
	}

	/**
	 * Renders the editor screen for approved directory URLs.
	 *
	 * @param int $url_id The ID of the rule to be edited (may be zero for new rules).
	 */
	private function edit_screen( int $url_id ) {
		$this->security_check();
		$existing = $this->register->get_by_id( $url_id );

		if ( 0 !== $url_id && ! $existing ) {
			WC_Admin_Settings::add_error( _x( 'The provided ID was invalid.', 'Approved product download directories', 'woocommerce' ) );
			WC_Admin_Settings::show_messages();
			return;
		}

		$title = $existing
			? __( 'Edit Approved Directory', 'woocommerce' )
			: __( 'Add New Approved Directory', 'woocommerce' );

		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		$submitted    = sanitize_text_field( wp_unslash( $_GET['submitted-url'] ?? '' ) );
		$existing_url = $existing ? $existing->get_url() : '';
		$enabled      = $existing ? $existing->is_enabled() : true;
		// phpcs:enable

		?>
			<h2 class='wc-table-list-header'>
				<?php echo esc_html( $title ); ?>
				<?php if ( $existing ) : ?>
					<a href="<?php echo esc_url( $this->table->get_action_url( 'edit', 0 ) ); ?>" class="page-title-action"><?php esc_html_e( 'Add New', 'woocommerce' ); ?></a>
				<?php endif; ?>
				<a href="<?php echo esc_url( $this->table->get_base_url() ); ?>" class="page-title-action"><?php esc_html_e( 'Cancel', 'woocommerce' ); ?></a>
			</h2>
			<table class='form-table'>
				<tbody>
					<tr valign='top'>
						<th scope='row' class='titledesc'>
							<label for='approved_directory_url'><?php echo esc_html_x( 'Directory URL', 'Approved product download directories', 'woocommerce' ); ?></label>
						</th>
						<td class='forminp'>
							<input name='approved_directory_url' id='approved_directory_url' type='text' class='input-text regular-input' value='<?php echo esc_attr( empty( $submitted ) ? $existing_url : $submitted ); ?>'>
						</td>
					</tr>
					<tr valign='top'>
						<th scope='row' class='titledesc'>
							<label for='approved_directory_enabled'><?php echo esc_html_x( 'Enabled', 'Approved product download directories', 'woocommerce' ); ?></label>
						</th>
						<td class='forminp'>
							<input name='approved_directory_enabled' id='approved_directory_enabled' type='checkbox' value='1' <?php checked( true, $enabled ); ?>>
						</td>
					</tr>
				</tbody>
			</table>
			<input name='id' id='approved_directory_id' type='hidden' value='{$url_id}'>
		<?php
	}

	/**
	 * Displays any admin notices that might be needed.
	 */
	private function admin_notices() {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		$successfully_deleted  = isset( $_GET['deleted-ids'] ) ? (int) $_GET['deleted-ids'] : 0;
		$successfully_enabled  = isset( $_GET['enabled-ids'] ) ? (int) $_GET['enabled-ids'] : 0;
		$successfully_disabled = isset( $_GET['disabled-ids'] ) ? (int) $_GET['disabled-ids'] : 0;
		$failed_updates        = isset( $_GET['bulk-fails'] ) ? (int) $_GET['bulk-fails'] : 0;
		$edit_status           = sanitize_text_field( wp_unslash( $_GET['edit-status'] ?? '' ) );
		$edit_url              = esc_attr( sanitize_text_field( wp_unslash( $_GET['submitted-url'] ?? '' ) ) );
		// phpcs:enable

		if ( $successfully_deleted ) {
			WC_Admin_Settings::add_message(
				sprintf(
					/* translators: %d: count */
					_n( '%d approved directory URL deleted.', '%d approved directory URLs deleted.', $successfully_deleted, 'woocommerce' ),
					$successfully_deleted
				)
			);
		} elseif ( $successfully_enabled ) {
			WC_Admin_Settings::add_message(
				sprintf(
				/* translators: %d: count */
					_n( '%d approved directory URL enabled.', '%d approved directory URLs enabled.', $successfully_enabled, 'woocommerce' ),
					$successfully_enabled
				)
			);
		} elseif ( $successfully_disabled ) {
			WC_Admin_Settings::add_message(
				sprintf(
				/* translators: %d: count */
					_n( '%d approved directory URL disabled.', '%d approved directory URLs disabled.', $successfully_disabled, 'woocommerce' ),
					$successfully_disabled
				)
			);
		}

		if ( $failed_updates ) {
			WC_Admin_Settings::add_error(
				sprintf(
					/* translators: %d: count */
					_n( '%d URL could not be updated.', '%d URLs could not be updated.', $failed_updates, 'woocommerce' ),
					$failed_updates
				)
			);
		}

		if ( 'added' === $edit_status ) {
			WC_Admin_Settings::add_message( __( 'URL was successfully added.', 'woocommerce' ) );
		}

		if ( 'updated' === $edit_status ) {
			WC_Admin_Settings::add_message( __( 'URL was successfully updated.', 'woocommerce' ) );
		}

		if ( 'failure' === $edit_status && ! empty( $edit_url ) ) {
			WC_Admin_Settings::add_error(
				sprintf(
					/* translators: %s is the submitted URL. */
					__( '"%s" could not be saved. Please review, ensure it is a valid URL and try again.', 'woocommerce' ),
					$edit_url
				)
			);
		}
	}

	/**
	 * Makes sure the user has appropriate permissions and that we have a valid nonce.
	 */
	private function security_check() {
		if ( ! Users::is_site_administrator() || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['check'] ?? '' ) ), 'modify_approved_directories' ) ) {
			wp_die( esc_html__( 'You do not have permission to modify the list of approved directories for product downloads.', 'woocommerce' ) );
		}
	}
}
PK     [1]@Q  Q     RestockRefundedItemsAdjuster.phpnu         <?php
/**
 * RestockRefundedItemsAdjuster class file.
 */

namespace Automattic\WooCommerce\Internal;

use Automattic\WooCommerce\Proxies\LegacyProxy;

defined( 'ABSPATH' ) || exit;

/**
 * Class to adjust or initialize the restock refunded items.
 */
class RestockRefundedItemsAdjuster {
	/**
	 * The order factory to use.
	 *
	 * @var WC_Order_Factory
	 */
	private $order_factory;

	/**
	 * Class initialization, to be executed when the class is resolved by the container.
	 *
	 * @internal
	 */
	final public function init() {
		$this->order_factory = wc_get_container()->get( LegacyProxy::class )->get_instance_of( \WC_Order_Factory::class );
		add_action( 'woocommerce_before_save_order_items', array( $this, 'initialize_restock_refunded_items' ), 10, 2 );
	}

	/**
	 * Initializes the restock refunded items meta for order version less than 5.5.
	 *
	 * @see https://github.com/woocommerce/woocommerce/issues/29502
	 *
	 * @param int   $order_id Order ID.
	 * @param array $items Order items to save.
	 */
	public function initialize_restock_refunded_items( $order_id, $items ) {
		$order         = wc_get_order( $order_id );
		$order_version = $order->get_version();

		if ( version_compare( $order_version, '5.5', '>=' ) ) {
			return;
		}

		// If there are no refund lines, then this migration isn't necessary because restock related meta's wouldn't be set.
		if ( 0 === count( $order->get_refunds() ) ) {
			return;
		}

		if ( isset( $items['order_item_id'] ) ) {
			foreach ( $items['order_item_id'] as $item_id ) {
				$item = $this->order_factory::get_order_item( absint( $item_id ) );

				if ( ! $item ) {
					continue;
				}

				if ( 'line_item' !== $item->get_type() ) {
					continue;
				}

				// There could be code paths in custom code which don't update version number but still update the items.
				if ( '' !== $item->get_meta( '_restock_refunded_items', true ) ) {
					continue;
				}

				$refunded_item_quantity = abs( $order->get_qty_refunded_for_item( $item->get_id() ) );
				$item->add_meta_data( '_restock_refunded_items', $refunded_item_quantity, false );
				$item->save();
			}
		}
	}
}
PK     [1]C.       AbilitiesApi/AbilitiesClient.phpnu         <?php
/**
 * WooCommerce Abilities API Client (Namespaced Version)
 *
 * Simple interface for enabling WordPress Abilities API client scripts.
 * This version uses WooCommerce's PSR-4 namespace structure.
 *
 * @package Automattic\WooCommerce\Internal\AbilitiesApi
 * @version 10.4.0
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\AbilitiesApi;

/**
 * AbilitiesClient class.
 */
class AbilitiesClient {

	/**
	 * Whether the client has been enabled.
	 *
	 * @var bool
	 */
	private static bool $enabled = false;

	/**
	 * Enable the WordPress Abilities API client for admin pages.
	 *
	 * This is the main method external plugins should use to enable
	 * the abilities API JavaScript client.
	 *
	 * @return bool True if successfully enabled, false otherwise.
	 */
	public static function enable(): bool {
		// Only enable once.
		if ( self::$enabled ) {
			return true;
		}

		// Hook into admin_enqueue_scripts to enqueue when needed.
		add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_for_admin' ) );

		self::$enabled = true;
		return true;
	}

	/**
	 * Internal method to handle script enqueueing.
	 */
	public static function enqueue_for_admin(): void {
		// Only enqueue on admin pages.
		if ( ! is_admin() ) {
			return;
		}

		// Enqueue the script if it's registered.
		if ( wp_script_is( 'wp-abilities', 'registered' ) ) {
			wp_enqueue_script( 'wp-abilities' );
		}
	}
}
PK     [1]q  q  !  Caches/ProductCacheController.phpnu         <?php
/**
 * ProductCacheController class file.
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Caches;

use Automattic\WooCommerce\Utilities\FeaturesUtil;

/**
 * Controller for product caching functionality.
 *
 * @since 10.5.0
 */
class ProductCacheController {

	/**
	 * Feature flag name for product instance caching.
	 *
	 * @since 10.5.0
	 *
	 * @var string
	 */
	public const FEATURE_NAME = 'product_instance_caching';

	/**
	 * The product cache instance.
	 *
	 * @since 10.5.0
	 *
	 * @var ProductCache
	 */
	private ProductCache $product_cache;

	/**
	 * Class initialization, invoked by the DI container.
	 *
	 * @since 10.5.0
	 *
	 * @internal
	 *
	 * @param ProductCache $product_cache The product cache instance.
	 *
	 * @return void
	 */
	final public function init( ProductCache $product_cache ): void {
		$this->product_cache = $product_cache;

		// Mark cache group as non-persistent immediately to ensure it's set
		// regardless of when this controller is instantiated relative to other hooks.
		$this->set_product_cache_group_as_non_persistent();

		// Defer feature check to 'init' to avoid triggering translations too early.
		add_action( 'init', array( $this, 'on_init' ), 0 );
	}

	/**
	 * Check feature flag and register hooks on WordPress init.
	 *
	 * @since 10.5.0
	 *
	 * @return void
	 */
	public function on_init(): void {
		if ( ! FeaturesUtil::feature_is_enabled( self::FEATURE_NAME ) ) {
			return;
		}

		$this->register_hooks();
	}

	/**
	 * Register the cache invalidation hooks.
	 *
	 * This method is separated from on_init() to allow tests to call it directly
	 * after enabling the feature flag.
	 *
	 * @since 10.5.0
	 *
	 * @return void
	 */
	public function register_hooks(): void {
		// Handle direct WordPress post updates (bypassing CRUD).
		add_action( 'clean_post_cache', array( $this, 'invalidate_product_cache_on_clean' ), 10, 2 );

		// Handle post meta updates (third-party plugins updating via postmeta API).
		add_action( 'updated_post_meta', array( $this, 'invalidate_product_cache_by_meta' ), 10, 2 );
		add_action( 'added_post_meta', array( $this, 'invalidate_product_cache_by_meta' ), 10, 2 );
		add_action( 'deleted_post_meta', array( $this, 'invalidate_product_cache_by_meta' ), 10, 2 );

		// Handle direct stock/sales updates (which uses direct SQL and cache manipulation, bypassing standard meta hooks)
		// In the future, update WC_Product_Data_Store_CPT::update_product_stock() and
		// update_product_sales() to trigger standard WordPress updated_post_meta hooks instead
		// of requiring specific hooks here.
		add_action( 'woocommerce_updated_product_stock', array( $this, 'invalidate_product_cache' ), 10, 1 );
		add_action( 'woocommerce_updated_product_sales', array( $this, 'invalidate_product_cache' ), 10, 1 );
	}

	/**
	 * Set the `product_objects` cache group as non-persistent.
	 *
	 * With product instance caching enabled, products are cached in-memory during a request
	 * rather than being persisted to external cache backends.  If WC_Data::__sleep()/::__wakeup() methods are eventually
	 * removed or changed so that the entire object is stored instead of just the ID, this should be revisited and evaluated
	 * performance impact.
	 *
	 * @since 10.5.0
	 *
	 * @return void
	 */
	public function set_product_cache_group_as_non_persistent(): void {
		wp_cache_add_non_persistent_groups( array( $this->product_cache->get_object_type() ) );
	}

	/**
	 * Invalidate the product cache when the post cache is cleaned.
	 *
	 * @since 10.5.0
	 *
	 * @param int      $post_id The post ID.
	 * @param \WP_Post $post    The post object.
	 *
	 * @return void
	 */
	public function invalidate_product_cache_on_clean( $post_id, $post ): void {
		$post_id = (int) $post_id;
		/**
		 * It's important not to trigger get_post() during this callback as some extensions may attempt to clean cache
		 * prior to updating the database and a call to get_post() would cause the post to be added back to cache before the update.
		 */
		if ( ! ( $post instanceof \WP_Post ) || ! in_array( $post->post_type, array( 'product', 'product_variation' ), true ) ) {
			return;
		}

		$this->product_cache->remove( $post_id );
	}

	/**
	 * Invalidate the product cache for a given post ID if it's a product or product variation.
	 *
	 * @since 10.5.0
	 *
	 * @param int $post_id The post ID to check and invalidate.
	 *
	 * @return void
	 */
	public function invalidate_product_cache( $post_id ): void {
		$post_id   = (int) $post_id;
		$post_type = get_post_type( $post_id );
		if ( ! $post_type || ! in_array( $post_type, array( 'product', 'product_variation' ), true ) ) {
			return;
		}

		$this->product_cache->remove( $post_id );
	}

	/**
	 * Invalidate the product cache when post meta is updated.
	 *
	 * @since 10.5.0
	 *
	 * @param int $meta_id   The ID of the metadata entry.
	 * @param int $object_id The ID of the object the metadata is for.
	 *
	 * @return void
	 */
	public function invalidate_product_cache_by_meta( $meta_id, $object_id ): void {
		$object_id = (int) $object_id;
		if ( in_array( get_post_type( $object_id ), array( 'product', 'product_variation' ), true ) ) {
			$this->invalidate_product_cache( $object_id );
		}
	}
}
PK     [1]cA  A  *  Caches/ProductVersionStringInvalidator.phpnu         <?php

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Caches;

use Automattic\WooCommerce\Internal\Features\FeaturesController;

/**
 * Product version string invalidation handler.
 *
 * This class provides an 'invalidate' method that will invalidate
 * the version string for a given product, which in turn invalidates
 * any cached REST API responses containing that product.
 */
class ProductVersionStringInvalidator {

	/**
	 * Default cache TTL in seconds for term/taxonomy entity lookups.
	 */
	const DEFAULT_TAXONOMY_LOOKUP_CACHE_TTL = 300;

	/**
	 * Initialize the invalidator and register hooks.
	 *
	 * Hooks are only registered when both conditions are met:
	 * - The REST API caching feature is enabled
	 * - The backend caching setting is active
	 *
	 * @return void
	 *
	 * @since 10.5.0
	 *
	 * @internal
	 */
	final public function init(): void {
		// We can't use FeaturesController::feature_is_enabled at this point
		// (before the 'init' action is triggered) because that would cause
		// "Translation loading for the woocommerce domain was triggered too early" warnings.
		if ( 'yes' !== get_option( 'woocommerce_feature_rest_api_caching_enabled' ) ) {
			return;
		}

		if ( 'yes' === get_option( 'woocommerce_rest_api_enable_backend_caching', 'no' ) ) {
			$this->register_hooks();
		}
	}

	/**
	 * Register all product-related hooks.
	 *
	 * Registers ALL hooks (WordPress and WooCommerce) to ensure comprehensive coverage.
	 * This handles both standard data stores and custom implementations, as well as
	 * third-party plugins that may use direct SQL with manual hook firing.
	 *
	 * @return void
	 */
	private function register_hooks(): void {
		// WordPress post hooks for products.
		add_action( 'save_post_product', array( $this, 'handle_save_post_product' ), 10, 1 );
		add_action( 'delete_post', array( $this, 'handle_delete_post' ), 10, 2 );
		add_action( 'trashed_post', array( $this, 'handle_trashed_post' ), 10, 1 );
		add_action( 'untrashed_post', array( $this, 'handle_untrashed_post' ), 10, 1 );

		// WooCommerce CRUD hooks for products.
		add_action( 'woocommerce_new_product', array( $this, 'handle_woocommerce_new_product' ), 10, 1 );
		add_action( 'woocommerce_update_product', array( $this, 'handle_woocommerce_update_product' ), 10, 1 );
		add_action( 'woocommerce_before_delete_product', array( $this, 'handle_woocommerce_before_delete_product' ), 10, 1 );
		add_action( 'woocommerce_trash_product', array( $this, 'handle_woocommerce_trash_product' ), 10, 1 );

		// WooCommerce CRUD hooks for variations.
		add_action( 'woocommerce_new_product_variation', array( $this, 'handle_woocommerce_new_product_variation' ), 10, 2 );
		add_action( 'woocommerce_update_product_variation', array( $this, 'handle_woocommerce_update_product_variation' ), 10, 2 );
		add_action( 'woocommerce_before_delete_product_variation', array( $this, 'handle_woocommerce_before_delete_product_variation' ), 10, 1 );
		add_action( 'woocommerce_trash_product_variation', array( $this, 'handle_woocommerce_trash_product_variation' ), 10, 1 );

		// SQL-level operation hooks.
		add_action( 'woocommerce_updated_product_stock', array( $this, 'handle_woocommerce_updated_product_stock' ), 10, 1 );
		add_action( 'woocommerce_updated_product_price', array( $this, 'handle_woocommerce_updated_product_price' ), 10, 1 );
		add_action( 'woocommerce_updated_product_sales', array( $this, 'handle_woocommerce_updated_product_sales' ), 10, 1 );

		// Attribute-related hooks (only for CPT data store).
		// These hooks use direct SQL queries that assume CPT storage.
		if ( $this->is_using_cpt_data_store() ) {
			add_action( 'woocommerce_attribute_updated', array( $this, 'handle_woocommerce_attribute_updated' ), 10, 2 );
			add_action( 'woocommerce_attribute_deleted', array( $this, 'handle_woocommerce_attribute_deleted' ), 10, 3 );
			add_action( 'woocommerce_updated_product_attribute_summary', array( $this, 'handle_woocommerce_updated_product_attribute_summary' ), 10, 1 );
			add_action( 'edited_term', array( $this, 'handle_edited_term' ), 10, 3 );
		}
	}

	/**
	 * Check if the product data store is CPT-based.
	 *
	 * @return bool True if using CPT data store, false otherwise.
	 */
	private function is_using_cpt_data_store(): bool {
		$data_store = \WC_Data_Store::load( 'product' );
		return $data_store->get_current_class_name() === 'WC_Product_Data_Store_CPT';
	}

	/**
	 * Handle the save_post_product hook.
	 *
	 * @param int $post_id The post ID.
	 *
	 * @return void
	 *
	 * @since 10.5.0
	 *
	 * @internal
	 */
	public function handle_save_post_product( $post_id ): void {
		$post_id = (int) $post_id;

		if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
			return;
		}

		$this->invalidate( $post_id );
	}

	/**
	 * Handle the delete_post hook.
	 *
	 * @param int           $post_id The post ID.
	 * @param \WP_Post|null $post The post object, or null if not provided.
	 *
	 * @return void
	 *
	 * @since 10.5.0
	 *
	 * @internal
	 */
	public function handle_delete_post( $post_id, $post = null ): void {
		$post_id = (int) $post_id;

		if ( ! $post instanceof \WP_Post ) {
			$post = get_post( $post_id );
		}

		if ( ! $post ) {
			return;
		}

		if ( 'product_variation' === $post->post_type ) {
			$this->invalidate_variation_and_parent( $post_id, (int) $post->post_parent );
		} elseif ( 'product' === $post->post_type ) {
			$this->invalidate( $post_id );
		}
	}

	/**
	 * Handle the trashed_post hook.
	 *
	 * @param int $post_id The post ID.
	 *
	 * @return void
	 *
	 * @since 10.5.0
	 *
	 * @internal
	 */
	public function handle_trashed_post( $post_id ): void {
		$this->handle_trashed_or_untrashed_post( (int) $post_id );
	}

	/**
	 * Handle the untrashed_post hook.
	 *
	 * @param int $post_id The post ID.
	 *
	 * @return void
	 *
	 * @since 10.5.0
	 *
	 * @internal
	 */
	public function handle_untrashed_post( $post_id ): void {
		$this->handle_trashed_or_untrashed_post( (int) $post_id );
	}

	/**
	 * Handle the trashed_post and untrashed_post hooks.
	 *
	 * @param int $post_id The post ID.
	 *
	 * @return void
	 */
	private function handle_trashed_or_untrashed_post( int $post_id ): void {
		$post = get_post( $post_id );

		if ( ! $post ) {
			return;
		}

		if ( 'product_variation' === $post->post_type ) {
			$this->invalidate_variation_and_parent( $post_id, $post->post_parent );
		} elseif ( 'product' === $post->post_type ) {
			$this->invalidate( $post_id );
		}
	}

	/**
	 * Handle the woocommerce_new_product_variation hook.
	 *
	 * @param int         $variation_id The variation ID.
	 * @param \WC_Product $variation The variation object.
	 *
	 * @return void
	 *
	 * @since 10.5.0
	 *
	 * @internal
	 */
	public function handle_woocommerce_new_product_variation( $variation_id, $variation ): void {
		$variation_id = (int) $variation_id;
		$parent_id    = $variation instanceof \WC_Product ? $variation->get_parent_id() : null;
		$this->invalidate_variation_and_parent( $variation_id, $parent_id );
	}

	/**
	 * Handle the woocommerce_update_product_variation hook.
	 *
	 * @param int         $variation_id The variation ID.
	 * @param \WC_Product $variation The variation object.
	 *
	 * @return void
	 *
	 * @since 10.5.0
	 *
	 * @internal
	 */
	public function handle_woocommerce_update_product_variation( $variation_id, $variation ): void {
		$variation_id = (int) $variation_id;
		$parent_id    = $variation instanceof \WC_Product ? $variation->get_parent_id() : null;
		$this->invalidate_variation_and_parent( $variation_id, $parent_id );
	}

	/**
	 * Handle the woocommerce_new_product hook.
	 *
	 * @param int $product_id The product ID.
	 *
	 * @return void
	 *
	 * @since 10.5.0
	 *
	 * @internal
	 */
	public function handle_woocommerce_new_product( $product_id ): void {
		$this->invalidate( (int) $product_id );
	}

	/**
	 * Handle the woocommerce_update_product hook.
	 *
	 * @param int $product_id The product ID.
	 *
	 * @return void
	 *
	 * @since 10.5.0
	 *
	 * @internal
	 */
	public function handle_woocommerce_update_product( $product_id ): void {
		$this->invalidate( (int) $product_id );
	}

	/**
	 * Handle the woocommerce_before_delete_product hook.
	 *
	 * @param int $product_id The product ID.
	 *
	 * @return void
	 *
	 * @since 10.5.0
	 *
	 * @internal
	 */
	public function handle_woocommerce_before_delete_product( $product_id ): void {
		$this->invalidate( (int) $product_id );
	}

	/**
	 * Handle the woocommerce_trash_product hook.
	 *
	 * @param int $product_id The product ID.
	 *
	 * @return void
	 *
	 * @since 10.5.0
	 *
	 * @internal
	 */
	public function handle_woocommerce_trash_product( $product_id ): void {
		$this->invalidate( (int) $product_id );
	}

	/**
	 * Handle the woocommerce_before_delete_product_variation hook.
	 *
	 * @param int $variation_id The variation ID.
	 *
	 * @return void
	 *
	 * @since 10.5.0
	 *
	 * @internal
	 */
	public function handle_woocommerce_before_delete_product_variation( $variation_id ): void {
		$this->invalidate_variation_and_parent( (int) $variation_id );
	}

	/**
	 * Handle the woocommerce_trash_product_variation hook.
	 *
	 * @param int $variation_id The variation ID.
	 *
	 * @return void
	 *
	 * @since 10.5.0
	 *
	 * @internal
	 */
	public function handle_woocommerce_trash_product_variation( $variation_id ): void {
		$this->invalidate_variation_and_parent( (int) $variation_id );
	}

	/**
	 * Handle the woocommerce_updated_product_stock hook.
	 *
	 * @param int $product_id The product ID.
	 *
	 * @return void
	 *
	 * @since 10.5.0
	 *
	 * @internal
	 */
	public function handle_woocommerce_updated_product_stock( $product_id ): void {
		$this->invalidate( (int) $product_id );
	}

	/**
	 * Handle the woocommerce_updated_product_price hook.
	 *
	 * @param int $product_id The product ID.
	 *
	 * @return void
	 *
	 * @since 10.5.0
	 *
	 * @internal
	 */
	public function handle_woocommerce_updated_product_price( $product_id ): void {
		$this->invalidate( (int) $product_id );
	}

	/**
	 * Handle the woocommerce_updated_product_sales hook.
	 *
	 * @param int $product_id The product ID.
	 *
	 * @return void
	 *
	 * @since 10.5.0
	 *
	 * @internal
	 */
	public function handle_woocommerce_updated_product_sales( $product_id ): void {
		$this->invalidate( (int) $product_id );
	}

	/**
	 * Handle the woocommerce_attribute_updated hook.
	 *
	 * @param int   $id The attribute ID.
	 * @param array $data The attribute data.
	 *
	 * @return void
	 *
	 * @since 10.5.0
	 *
	 * @internal
	 */
	public function handle_woocommerce_attribute_updated( $id, $data ): void {
		if ( ! is_array( $data ) || ! isset( $data['attribute_name'] ) ) {
			return;
		}

		$taxonomy = wc_attribute_taxonomy_name( $data['attribute_name'] );
		$this->invalidate_products_with_attribute( $taxonomy );
	}

	/**
	 * Handle the woocommerce_attribute_deleted hook.
	 *
	 * @param int    $id The attribute ID.
	 * @param string $name The attribute name.
	 * @param string $taxonomy The attribute taxonomy.
	 *
	 * @return void
	 *
	 * @since 10.5.0
	 *
	 * @internal
	 */
	public function handle_woocommerce_attribute_deleted( $id, $name, $taxonomy ): void {
		if ( ! is_string( $taxonomy ) || '' === $taxonomy ) {
			return;
		}

		$this->invalidate_products_with_attribute( $taxonomy );
	}

	/**
	 * Handle the woocommerce_updated_product_attribute_summary hook.
	 *
	 * @param int $variation_id The variation ID.
	 *
	 * @return void
	 *
	 * @since 10.5.0
	 *
	 * @internal
	 */
	public function handle_woocommerce_updated_product_attribute_summary( $variation_id ): void {
		$this->invalidate_variation_and_parent( (int) $variation_id );
	}

	/**
	 * Handle the edited_term hook.
	 *
	 * @param int    $term_id The term ID.
	 * @param int    $tt_id The term taxonomy ID.
	 * @param string $taxonomy The taxonomy slug.
	 *
	 * @return void
	 *
	 * @since 10.5.0
	 *
	 * @internal
	 */
	public function handle_edited_term( $term_id, $tt_id, $taxonomy ): void {
		if ( ! is_string( $taxonomy ) ) {
			return;
		}

		// Only handle product attribute taxonomies.
		if ( 0 !== strpos( $taxonomy, 'pa_' ) ) {
			return;
		}

		$this->invalidate_products_with_term( (int) $tt_id );
	}

	/**
	 * Invalidate a variation and its parent product.
	 *
	 * @param int      $variation_id The variation ID.
	 * @param int|null $parent_id Optional parent product ID. If not provided, will be looked up.
	 *
	 * @return void
	 */
	private function invalidate_variation_and_parent( int $variation_id, ?int $parent_id = null ): void {
		$this->invalidate( $variation_id );

		if ( is_null( $parent_id ) ) {
			if ( $this->is_using_cpt_data_store() ) {
				$parent_id = wp_get_post_parent_id( $variation_id );
			} else {
				$variation = wc_get_product( $variation_id );
				$parent_id = $variation ? $variation->get_parent_id() : 0;
			}
		}

		if ( ! $parent_id ) {
			return;
		}

		$this->invalidate( $parent_id );
	}

	/**
	 * Invalidate all products and variations that have a specific term assigned.
	 *
	 * Uses the indexed wp_term_relationships table for efficient lookups.
	 * The list of entities associated with the term is cached for performance;
	 * the TTL can be customized via the 'woocommerce_version_string_invalidator_taxonomy_lookup_ttl' filter.
	 *
	 * @param int $tt_id The term taxonomy ID.
	 *
	 * @return void
	 */
	private function invalidate_products_with_term( int $tt_id ): void {
		global $wpdb;

		$cache_key  = 'wc_cache_inv_term_' . $tt_id;
		$entity_ids = wp_cache_get( $cache_key, 'woocommerce' );

		if ( false === $entity_ids ) {
			$entity_ids = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT tr.object_id
					FROM {$wpdb->term_relationships} tr
					INNER JOIN {$wpdb->posts} p ON tr.object_id = p.ID
					WHERE tr.term_taxonomy_id = %d
					AND p.post_type IN ('product', 'product_variation')",
					$tt_id
				)
			);

			/**
			 * Filters the cache TTL for queries that find entities associated with a term or taxonomy.
			 *
			 * These queries are used during cache invalidation to determine which entities
			 * (e.g., products, variations) need their cache cleared when a term or attribute changes.
			 *
			 * @since 10.5.0
			 *
			 * @param int    $ttl         Cache TTL in seconds. Default 300 (5 minutes).
			 * @param string $entity_type The type of entity being invalidated ('product').
			 */
			$ttl = apply_filters( 'woocommerce_version_string_invalidator_taxonomy_lookup_ttl', self::DEFAULT_TAXONOMY_LOOKUP_CACHE_TTL, 'product' );
			wp_cache_set( $cache_key, $entity_ids, 'woocommerce', $ttl );
		}

		foreach ( $entity_ids as $entity_id ) {
			$post_type = get_post_type( (int) $entity_id );
			if ( 'product_variation' === $post_type ) {
				$this->invalidate_variation_and_parent( (int) $entity_id );
			} else {
				$this->invalidate( (int) $entity_id );
			}
		}
	}

	/**
	 * Invalidate all products using a specific attribute taxonomy.
	 *
	 * The list of entities associated with the taxonomy is cached for performance;
	 * the TTL can be customized via the 'woocommerce_version_string_invalidator_taxonomy_lookup_ttl' filter.
	 *
	 * @param string $taxonomy The attribute taxonomy slug.
	 *
	 * @return void
	 */
	private function invalidate_products_with_attribute( string $taxonomy ): void {
		global $wpdb;

		$cache_key = 'wc_cache_inv_attr_' . $taxonomy;
		$cached    = wp_cache_get( $cache_key, 'woocommerce' );

		if ( false === $cached ) {
			$product_ids = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT DISTINCT post_id FROM {$wpdb->postmeta}
					WHERE meta_key = '_product_attributes'
					AND meta_value LIKE %s",
					'%' . $wpdb->esc_like( 's:' . strlen( $taxonomy ) . ':"' . $taxonomy . '"' ) . '%'
				)
			);

			$variation_ids = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT DISTINCT post_id FROM {$wpdb->postmeta}
					WHERE meta_key = %s",
					'attribute_' . $taxonomy
				)
			);

			$cached = array(
				'product_ids'   => $product_ids,
				'variation_ids' => $variation_ids,
			);

			// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Documented above.
			$ttl = apply_filters( 'woocommerce_version_string_invalidator_taxonomy_lookup_ttl', self::DEFAULT_TAXONOMY_LOOKUP_CACHE_TTL, 'product' );
			wp_cache_set( $cache_key, $cached, 'woocommerce', $ttl );
		}

		foreach ( $cached['product_ids'] as $product_id ) {
			$this->invalidate( (int) $product_id );
		}

		foreach ( $cached['variation_ids'] as $variation_id ) {
			$this->invalidate_variation_and_parent( (int) $variation_id );
		}
	}

	/**
	 * Invalidate a product version string.
	 *
	 * @param int $product_id The product ID.
	 *
	 * @return void
	 *
	 * @since 10.5.0
	 */
	public function invalidate( int $product_id ): void {
		wc_get_container()->get( VersionStringGenerator::class )->delete_version( "product_{$product_id}" );
	}
}
PK     [1]      !  Caches/VersionStringGenerator.phpnu         <?php

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Caches;

use Automattic\WooCommerce\Proxies\LegacyProxy;

/**
 * Version string generator/cache class.
 *
 * Provides a generic mechanism for generating and caching unique version strings
 * for any identifiable item. Each item is identified by a string ID, and has
 * an associated version string (UUID) that can be regenerated to invalidate caches.
 * This is useful for cache invalidation strategies where items change over time.
 * The standard WordPress cache is used to store the version strings.
 */
class VersionStringGenerator {

	/**
	 * Cache group name.
	 */
	private const CACHE_GROUP = 'woocommerce_version_strings';

	/**
	 * Can the version string cache be used?
	 *
	 * @var bool|null
	 */
	private ?bool $can_use = null;

	/**
	 * Legacy proxy instance.
	 *
	 * @var LegacyProxy|null
	 */
	private ?LegacyProxy $legacy_proxy = null;

	/**
	 * Initialize the class dependencies.
	 *
	 * @internal
	 *
	 * @param LegacyProxy $legacy_proxy Legacy proxy instance.
	 */
	final public function init( LegacyProxy $legacy_proxy ) {
		$this->legacy_proxy = $legacy_proxy;
	}

	/**
	 * Tells whether the version string cache can be used or not.
	 *
	 * This will return true only if an external object cache is configured in WordPress,
	 * since otherwise the cached entries will only persist for the current request.
	 *
	 * @return bool
	 */
	public function can_use(): bool {
		if ( ! is_null( $this->can_use ) ) {
			return $this->can_use;
		}

		$this->can_use = $this->legacy_proxy->call_function( 'wp_using_ext_object_cache' ) ?? false;

		return $this->can_use;
	}

	/**
	 * Get the current version string for an ID.
	 *
	 * If no version exists and $generate is true, a new version will be created.
	 * If no version exists and $generate is false, null will be returned.
	 *
	 * @param string $id       The ID to get the version string for.
	 * @param bool   $generate Whether to generate a new version if one doesn't exist. Default true.
	 * @return string|null Version string, or null if not found and $generate is false.
	 * @throws \InvalidArgumentException If id is invalid.
	 *
	 * @since 10.4.0
	 */
	public function get_version( string $id, bool $generate = true ): ?string {
		$this->validate_input( $id );

		$cache_key = $this->get_cache_key( $id );
		$found     = false;
		$version   = wp_cache_get( $cache_key, self::CACHE_GROUP, false, $found );

		if ( ! $found ) {
			if ( ! $generate ) {
				return null;
			}
			$version = $this->generate_version( $id );
		} else {
			// Refresh the cache lifetime.
			$this->store_version( $id, $version );
		}
		return $version;
	}

	/**
	 * Generate and store a new version string for an ID.
	 * The already existing version string, if any, will be replaced.
	 *
	 * @param string $id The ID to generate a version string for.
	 * @return string The new version string.
	 * @throws \InvalidArgumentException If id is invalid.
	 *
	 * @since 10.4.0
	 */
	public function generate_version( string $id ): string {
		$this->validate_input( $id );

		$version = wp_generate_uuid4();
		$this->store_version( $id, $version );
		return $version;
	}

	/**
	 * Store the version string in cache with a filterable TTL.
	 *
	 * @param string $id      The ID to store the version string for.
	 * @param string $version The version string to store.
	 * @return bool True on success, false on failure.
	 */
	protected function store_version( string $id, string $version ): bool {
		$cache_key = $this->get_cache_key( $id );

		/**
		 * Filter the TTL for version string cache.
		 *
		 * @param int    $ttl Time to live in seconds. Default 1 day.
		 * @param string $id  The ID.
		 *
		 * @since 10.4.0
		 */
		$ttl = apply_filters( 'woocommerce_version_string_generator_ttl', DAY_IN_SECONDS, $id );
		$ttl = max( 0, (int) $ttl );

		return wp_cache_set( $cache_key, $version, self::CACHE_GROUP, $ttl );
	}

	/**
	 * Delete the version string for an ID by deleting its cached entry.
	 *
	 * @param string $id The ID to delete the version string for.
	 * @return bool True on success, false on failure.
	 * @throws \InvalidArgumentException If id is invalid.
	 *
	 * @since 10.4.0
	 */
	public function delete_version( string $id ): bool {
		$this->validate_input( $id );

		$cache_key = $this->get_cache_key( $id );
		return wp_cache_delete( $cache_key, self::CACHE_GROUP );
	}

	/**
	 * Get the cache key for an ID.
	 *
	 * The ID is hashed to ensure a consistent key length and avoid issues
	 * with special characters or very long IDs.
	 *
	 * @param string $id The ID to get the cache key for.
	 * @return string The cache key.
	 */
	private function get_cache_key( string $id ): string {
		return 'wc_version_string_' . md5( $id );
	}

	/**
	 * Validate ID input.
	 *
	 * @param string $id The ID to validate.
	 * @return void
	 * @throws \InvalidArgumentException If id is invalid.
	 */
	private function validate_input( string $id ): void {
		if ( '' === $id ) {
			throw new \InvalidArgumentException( 'ID cannot be empty.' );
		}
	}
}
PK     [1]9(=      Caches/ProductCache.phpnu         <?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\Caches;

use Automattic\WooCommerce\Caching\ObjectCache;
use WC_Product;

/**
 * A class to cache Product objects.
 *
 * @since 10.5.0
 */
class ProductCache extends ObjectCache {

	/**
	 * Get the cache key and prefix to use for Products.
	 *
	 * @since 10.5.0
	 *
	 * @return string
	 */
	public function get_object_type(): string {
		return 'product_objects';
	}

	/**
	 * Get the id of an object to be cached.
	 *
	 * @since 10.5.0
	 *
	 * @param WC_Product $product The product to be cached.
	 *
	 * @return int The id of the object.
	 */
	protected function get_object_id( $product ) {
		return $product->get_id();
	}

	/**
	 * Validate an object before caching it.
	 *
	 * @since 10.5.0
	 *
	 * @param WC_Product $product The product to validate.
	 *
	 * @return string[]|null An array of error messages, or null if the object is valid.
	 */
	protected function validate( $product ): ?array {
		if ( ! $product instanceof WC_Product ) {
			return array( 'The supplied product is not an instance of WC_Product' );
		}

		return null;
	}

	/**
	 * Add a product to the cache, or update an already cached product.
	 *
	 * Sets the clone mode to CACHE before storing to ensure meta IDs are preserved
	 * when WordPress object cache clones the object.
	 *
	 * @since 10.5.0
	 *
	 * @param WC_Product      $product The product to be cached.
	 * @param int|string|null $id Id of the product to be cached, if null, get_object_id will be used to get it.
	 * @param int             $expiration Expiration of the cached data in seconds from the current time, or DEFAULT_EXPIRATION to use the default value.
	 *
	 * @return bool True on success, false on error.
	 * @throws \Automattic\WooCommerce\Caching\CacheException Invalid parameter, or null id was passed and get_object_id returns null too.
	 */
	public function set( $product, $id = null, int $expiration = self::DEFAULT_EXPIRATION ): bool {
		if ( null !== $id ) {
			$id = (int) $id;
		}

		$original_mode = $product->get_clone_mode();
		$product->set_clone_mode( \WC_Data::CLONE_MODE_CACHE );
		$result = parent::set( $product, $id, $expiration );
		$product->set_clone_mode( $original_mode );

		return $result;
	}

	/**
	 * Remove a product from the cache.
	 *
	 * @since 10.5.0
	 *
	 * @param int|string $id The id of the product to remove.
	 *
	 * @return bool True if the product is removed successfully, false otherwise.
	 */
	public function remove( $id ): bool {
		return parent::remove( (int) $id );
	}

	/**
	 * Retrieve a cached product, and if no product is cached with the given id,
	 * try to get one via get_from_datastore callback and then cache it.
	 *
	 * After retrieval, resets the clone mode to DUPLICATE to maintain backward compatibility
	 * for code that expects cloning to clear meta IDs.
	 *
	 * @since 10.5.0
	 *
	 * @param int|string    $id The id of the product to retrieve.
	 * @param int           $expiration Expiration of the cached data in seconds from the current time, used if a product is retrieved from datastore and cached.
	 * @param callable|null $get_from_datastore_callback Optional callback to get the product if it's not cached, it must return a WC_Product or null.
	 *
	 * @return WC_Product|null Cached product, or null if it's not cached and can't be retrieved from datastore or via callback.
	 * @throws \Automattic\WooCommerce\Caching\CacheException Invalid id parameter.
	 */
	public function get( $id, int $expiration = self::DEFAULT_EXPIRATION, ?callable $get_from_datastore_callback = null ): ?WC_Product {
		$id      = (int) $id;
		$product = parent::get( $id, $expiration, $get_from_datastore_callback );

		if ( $product instanceof WC_Product ) {
			$product->set_clone_mode( \WC_Data::CLONE_MODE_DUPLICATE );
			return $product;
		}

		return null;
	}
}
PK     [1]_ԼY  Y  '  TransientFiles/TransientFilesEngine.phpnu         <?php

namespace Automattic\WooCommerce\Internal\TransientFiles;

use \DateTime;
use \Exception;
use \InvalidArgumentException;
use Automattic\WooCommerce\Internal\RegisterHooksInterface;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\TimeUtil;

/**
 * Transient files engine class.
 *
 * This class contains methods that allow creating files that have an expiration date.
 *
 * A transient file is created by invoking the create_transient_file method, which accepts the file contents
 * and the expiration date as arguments. Transient file names are composed by concatenating the expiration date
 * encoded in hexadecimal (3 digits for the year, 1 for the month and 2 for the day) and a random string
 * of hexadecimal digits.
 *
 * Transient files are stored in a directory whose default route is
 * wp-content/uploads/woocommerce_transient_files/yyyy-mm-dd, where "yyyy-mm-dd" is the expiration date
 * (year, month and day). The base route (minus the expiration date part) can be changed via a dedicated hook.
 *
 * Transient files that haven't expired (the expiration date is today or in the future) can be obtained remotely
 * via a dedicated URL, <server root>/wc/file/transient/<file name>. This URL is public (no authentication is required).
 * The content type of the response will always be "text/html".
 *
 * Cleanup of expired files is handled by the delete_expired_files method, which can be invoked manually
 * but there's a dedicated scheduled action that will invoke it that can be started and stopped via a dedicated tool
 * available in the WooCommerce tools page. The action runs once per day but this can be customized
 * via a dedicated hook.
 */
class TransientFilesEngine implements RegisterHooksInterface {

	private const CLEANUP_ACTION_NAME  = 'woocommerce_expired_transient_files_cleanup';
	private const CLEANUP_ACTION_GROUP = 'wc_batch_processes';

	/**
	 * The instance of LegacyProxy to use.
	 *
	 * @var LegacyProxy
	 */
	private $legacy_proxy;

	/**
	 * Register hooks.
	 */
	public function register() {
		add_action( self::CLEANUP_ACTION_NAME, array( $this, 'handle_expired_files_cleanup_action' ) );
		add_filter( 'woocommerce_debug_tools', array( $this, 'add_debug_tools_entries' ), 999, 1 );

		add_action( 'init', array( $this, 'add_endpoint' ), 0 );
		add_filter( 'query_vars', array( $this, 'handle_query_vars' ), 0 );
		add_action( 'parse_request', array( $this, 'handle_parse_request' ), 0 );
	}

	/**
	 * Class initialization, to be executed when the class is resolved by the container.
	 *
	 * @internal
	 *
	 * @param LegacyProxy $legacy_proxy The instance of LegacyProxy to use.
	 */
	final public function init( LegacyProxy $legacy_proxy ) {
		$this->legacy_proxy = $legacy_proxy;
	}

	/**
	 * Get the base directory where transient files are stored.
	 *
	 * The default base directory is the WordPress uploads directory plus "woocommerce_transient_files". This can
	 * be changed by using the woocommerce_transient_files_directory filter.
	 *
	 * If the woocommerce_transient_files_directory filter is not used and the default base directory
	 * doesn't exist, it will be created. If the filter is used it's the responsibility of the caller
	 * to ensure that the custom directory exists, otherwise an exception will be thrown.
	 *
	 * The actual directory for each existing file will be the base directory plus the expiration date
	 * of the file formatted as 'yyyy-mm-dd'.
	 *
	 * @return string Effective base directory where transient files are stored.
	 * @throws Exception The custom base directory (as specified via filter) doesn't exist, or the default base directory can't be created.
	 */
	public function get_transient_files_directory(): string {
		$upload_dir_info                   = $this->legacy_proxy->call_function( 'wp_upload_dir' );
		$default_transient_files_directory = untrailingslashit( $upload_dir_info['basedir'] ) . '/woocommerce_transient_files';

		/**
		 * Filters the directory where transient files are stored.
		 *
		 * Note that this is used for both creating new files (with create_file_by_rendering_template)
		 * and retrieving existing files (with get_file_by_*).
		 *
		 * @param string $transient_files_directory The default directory for transient files.
		 * @return string The actual directory to use for storing transient files.
		 *
		 * @since 8.5.0
		 */
		$transient_files_directory = apply_filters( 'woocommerce_transient_files_directory', $default_transient_files_directory );

		$realpathed_transient_files_directory = $this->legacy_proxy->call_function( 'realpath', $transient_files_directory );
		if ( false === $realpathed_transient_files_directory ) {
			if ( $transient_files_directory === $default_transient_files_directory ) {
				if ( ! $this->legacy_proxy->call_function( 'wp_mkdir_p', $transient_files_directory ) ) {
					throw new Exception( "Can't create directory: $transient_files_directory" );
				}

				// Create infrastructure to prevent listing the contents of the transient files directory.
				require_once ABSPATH . 'wp-admin/includes/file.php';
				\WP_Filesystem();
				$wp_filesystem = $this->legacy_proxy->get_global( 'wp_filesystem' );
				$wp_filesystem->put_contents( $transient_files_directory . '/.htaccess', 'deny from all' );
				$wp_filesystem->put_contents( $transient_files_directory . '/index.html', '' );

				$realpathed_transient_files_directory = $this->legacy_proxy->call_function( 'realpath', $transient_files_directory );
			} else {
				throw new Exception( "The base transient files directory doesn't exist: $transient_files_directory" );
			}
		}

		return untrailingslashit( $realpathed_transient_files_directory );
	}

	/**
	 * Create a transient file.
	 *
	 * @param string     $file_contents The contents of the file.
	 * @param string|int $expiration_date A string representing the expiration date formatted as "yyyy-mm-dd", or a number representing the expiration date as a timestamp (the time of day part will be ignored).
	 * @return string The name of the transient file created (without path information).
	 * @throws \InvalidArgumentException Invalid expiration date (wrongly formatted, or it's a date in the past).
	 * @throws \Exception The directory to store the file doesn't exist and can't be created.
	 */
	public function create_transient_file( string $file_contents, $expiration_date ): string {
		if ( is_numeric( $expiration_date ) ) {
			$expiration_date = gmdate( 'Y-m-d', $expiration_date );
		} elseif ( ! is_string( $expiration_date ) || ! TimeUtil::is_valid_date( $expiration_date, 'Y-m-d' ) ) {
			$expiration_date = is_scalar( $expiration_date ) ? $expiration_date : gettype( $expiration_date );
			throw new InvalidArgumentException( "$expiration_date is not a valid date, expected format: YYYY-MM-DD" );
		}

		$expiration_date_object = DateTime::createFromFormat( 'Y-m-d', $expiration_date, TimeUtil::get_utc_date_time_zone() );
		$today_date_object      = new DateTime( $this->legacy_proxy->call_function( 'gmdate', 'Y-m-d' ), TimeUtil::get_utc_date_time_zone() );

		if ( $expiration_date_object < $today_date_object ) {
			throw new InvalidArgumentException( "The supplied expiration date, $expiration_date, is in the past" );
		}

		$filename = bin2hex( $this->legacy_proxy->call_function( 'random_bytes', 16 ) );

		$transient_files_directory  = $this->get_transient_files_directory();
		$transient_files_directory .= '/' . $expiration_date_object->format( 'Y-m-d' );
		if ( ! $this->legacy_proxy->call_function( 'is_dir', $transient_files_directory ) ) {
			if ( ! $this->legacy_proxy->call_function( 'wp_mkdir_p', $transient_files_directory ) ) {
				throw new Exception( "Can't create directory: $transient_files_directory" );
			}
		}
		$filepath = $transient_files_directory . '/' . $filename;

		require_once ABSPATH . 'wp-admin/includes/file.php';
		\WP_Filesystem();
		$wp_filesystem = $this->legacy_proxy->get_global( 'wp_filesystem' );
		if ( false === $wp_filesystem->put_contents( $filepath, $file_contents ) ) {
			throw new Exception( "Can't create file: $filepath" );
		}

		return sprintf(
			'%03x%01x%02x%s',
			$expiration_date_object->format( 'Y' ),
			$expiration_date_object->format( 'm' ),
			$expiration_date_object->format( 'd' ),
			$filename
		);
	}

	/**
	 * Get the full physical path of a transient file given its name.
	 *
	 * @param string $filename The name of the transient file to locate.
	 * @return string|null The full physical path of the file, or null if the files doesn't exist.
	 */
	public function get_transient_file_path( string $filename ): ?string {
		$expiration_date = self::get_expiration_date( $filename );
		if ( is_null( $expiration_date ) ) {
			return null;
		}

		$file_path = $this->get_transient_files_directory() . '/' . $expiration_date . '/' . substr( $filename, 6 );

		return is_file( $file_path ) ? $file_path : null;
	}

	/**
	 * Get the expiration date of a transient file based on its file name. The actual existence of the file is NOT checked.
	 *
	 * @param string $filename The name of the transient file to get the expiration date for.
	 * @return string|null Expiration date formatted as Y-m-d, null if the file name isn't encoding a proper date.
	 */
	public static function get_expiration_date( string $filename ) : ?string {
		if ( strlen( $filename ) < 7 || ! ctype_xdigit( $filename ) ) {
			return null;
		}

		$expiration_date = sprintf(
			'%04d-%02d-%02d',
			hexdec( substr( $filename, 0, 3 ) ),
			hexdec( substr( $filename, 3, 1 ) ),
			hexdec( substr( $filename, 4, 2 ) )
		);

		return TimeUtil::is_valid_date( $expiration_date, 'Y-m-d' ) ? $expiration_date : null;
	}

	/**
	 * Get the public URL of a transient file. The file name is NOT checked for validity or actual existence.
	 *
	 * @param string $filename The name of the transient file to get the public URL for.
	 * @return string The public URL of the file.
	 */
	public function get_public_url( string $filename ) {
		return $this->legacy_proxy->call_function( 'get_site_url', null, '/wc/file/transient/' . $filename );
	}

	/**
	 * Verify if a file has expired, given its full physical file path.
	 *
	 * Given a file name returned by 'create_transient_file', the procedure to check if it has expired is as follows:
	 *
	 * 1. Use 'get_transient_file_path' to obtain the full file path.
	 * 2. If the above returns null, the file doesn't exist anymore (likely it expired and was deleted by the cleanup process).
	 * 3. Otherwise, use 'file_has_expired' passing the obtained full file path.
	 *
	 * @param string $file_path The full file path to check.
	 * @return bool True if the file has expired, false otherwise.
	 * @throws \Exception Thrown by DateTime if a wrong file path is passed.
	 */
	public function file_has_expired( string $file_path ): bool {
		$dirname                = dirname( $file_path );
		$expiration_date        = basename( $dirname );
		$expiration_date_object = new DateTime( $expiration_date, TimeUtil::get_utc_date_time_zone() );
		$today_date_object      = new DateTime( $this->legacy_proxy->call_function( 'gmdate', 'Y-m-d' ), TimeUtil::get_utc_date_time_zone() );
		return $expiration_date_object < $today_date_object;
	}

	/**
	 * Delete an existing transient file.
	 *
	 * @param string $filename The name of the file to delete.
	 * @return bool True if the file has been deleted, false otherwise (the file didn't exist).
	 */
	public function delete_transient_file( string $filename ): bool {
		$file_path = $this->get_transient_file_path( $filename );
		if ( is_null( $file_path ) ) {
			return false;
		}

		$dirname = dirname( $file_path );
		wp_delete_file( $file_path );
		$this->delete_directory_if_not_empty( $dirname );

		return true;
	}

	/**
	 * Delete expired transient files from the filesystem.
	 *
	 * @param int $limit Maximum number of files to delete.
	 * @return array "deleted_count" with the number of files actually deleted, "files_remain" that will be true if there are still files left to delete.
	 * @throws Exception The base directory for transient files (possibly changed via filter) doesn't exist.
	 */
	public function delete_expired_files( int $limit = 1000 ): array {
		$expiration_date_gmt = $this->legacy_proxy->call_function( 'gmdate', 'Y-m-d' );
		$base_dir            = $this->get_transient_files_directory();
		$subdirs             = glob( $base_dir . '/[2-9][0-9][0-9][0-9]-[01][0-9]-[0-3][0-9]', GLOB_ONLYDIR );
		if ( false === $subdirs ) {
			throw new Exception( "Error when getting the list of subdirectories of $base_dir" );
		}

		$subdirs         = array_map( fn( $name ) => substr( $name, strlen( $name ) - 10, 10 ), $subdirs );
		$expired_subdirs = array_filter( $subdirs, fn( $name ) => $name < $expiration_date_gmt );
		asort( $subdirs ); // We want to delete files starting with the oldest expiration month.

		$remaining_limit = $limit;
		$limit_reached   = false;
		foreach ( $expired_subdirs as $subdir ) {
			$full_dir_path   = $base_dir . '/' . $subdir;
			$files_to_delete = glob( $full_dir_path . '/*' );
			if ( count( $files_to_delete ) > $remaining_limit ) {
				$limit_reached   = true;
				$files_to_delete = array_slice( $files_to_delete, 0, $remaining_limit );
			}
			array_map( 'wp_delete_file', $files_to_delete );
			$remaining_limit -= count( $files_to_delete );
			$this->delete_directory_if_not_empty( $full_dir_path );

			if ( $limit_reached ) {
				break;
			}
		}

		return array(
			'deleted_count' => $limit - $remaining_limit,
			'files_remain'  => $limit_reached,
		);
	}

	/**
	 * Is the expired files cleanup action currently scheduled?
	 *
	 * @return bool True if the expired files cleanup action is currently scheduled, false otherwise.
	 */
	public function expired_files_cleanup_is_scheduled(): bool {
		return as_has_scheduled_action( self::CLEANUP_ACTION_NAME, array(), self::CLEANUP_ACTION_GROUP );
	}

	/**
	 * Schedule an action that will do one round of expired files cleanup.
	 * The action is scheduled to run immediately. If a previous pending action exists, it's unscheduled first.
	 */
	public function schedule_expired_files_cleanup(): void {
		$this->unschedule_expired_files_cleanup();
		as_schedule_single_action( time() + 1, self::CLEANUP_ACTION_NAME, array(), self::CLEANUP_ACTION_GROUP );
	}

	/**
	 * Remove the scheduled action that does the expired files cleanup, if it's scheduled.
	 */
	public function unschedule_expired_files_cleanup(): void {
		if ( $this->expired_files_cleanup_is_scheduled() ) {
			as_unschedule_action( self::CLEANUP_ACTION_NAME, array(), self::CLEANUP_ACTION_GROUP );
		}
	}

	/**
	 * Run the expired files cleanup action and schedule a new one.
	 *
	 * If files are actually deleted then we assume that more files are pending deletion and schedule the next
	 * action to run immediately. Otherwise (nothing was deleted) we schedule the next action for one day later
	 * (but this can be changed via the 'woocommerce_delete_expired_transient_files_interval' filter).
	 *
	 * If the actual deletion process fails the next action is scheduled anyway for one day later
	 * or for the interval given by the filter.
	 *
	 * NOTE: If the default interval is changed to something different from DAY_IN_SECONDS, please adjust the
	 * "every 24h" text in add_debug_tools_entries too.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function handle_expired_files_cleanup_action(): void {
		$new_interval = null;

		try {
			$result = $this->delete_expired_files();
			if ( $result['deleted_count'] > 0 ) {
				$new_interval = 1;
			}
		} finally {
			if ( is_null( $new_interval ) ) {

				/**
				 * Filter to alter the interval between the actions that delete expired transient files.
				 *
				 * @param int $interval The default time before the next action run, in seconds.
				 * @return int The time to actually wait before the next action run, in seconds.
				 *
				 * @since 8.5.0
				 */
				$new_interval = apply_filters( 'woocommerce_delete_expired_transient_files_interval', DAY_IN_SECONDS );
			}

			$next_time = $this->legacy_proxy->call_function( 'time' ) + $new_interval;
			$this->legacy_proxy->call_function( 'as_schedule_single_action', $next_time, self::CLEANUP_ACTION_NAME, array(), self::CLEANUP_ACTION_GROUP );
		}
	}

	/**
	 * Add the tools to (re)schedule and un-schedule the expired files cleanup actions in the WooCommerce debug tools page.
	 *
	 * @param array $tools_array Original debug tools array.
	 * @return array Updated debug tools array
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function add_debug_tools_entries( array $tools_array ): array {
		$cleanup_is_scheduled = $this->expired_files_cleanup_is_scheduled();

		$tools_array['schedule_expired_transient_files_cleanup'] = array(
			'name'             => $cleanup_is_scheduled ?
				__( 'Re-schedule expired transient files cleanup', 'woocommerce' ) :
				__( 'Schedule expired transient files cleanup', 'woocommerce' ),
			'desc'             => $cleanup_is_scheduled ?
				__( 'Remove the currently scheduled action to delete expired transient files, then schedule it again for running immediately. Subsequent actions will run once every 24h.', 'woocommerce' ) :
				__( 'Schedule the action to delete expired transient files for running immediately. Subsequent actions will run once every 24h.', 'woocommerce' ),
			'button'           => $cleanup_is_scheduled ?
				__( 'Re-schedule', 'woocommerce' ) :
				__( 'Schedule', 'woocommerce' ),
			'requires_refresh' => true,
			'callback'         => array( $this, 'schedule_expired_files_cleanup' ),
		);

		if ( $cleanup_is_scheduled ) {
			$tools_array['unschedule_expired_transient_files_cleanup'] = array(
				'name'             => __( 'Un-schedule expired transient files cleanup', 'woocommerce' ),
				'desc'             => __( "Remove the currently scheduled action to delete expired transient files. Expired files won't be automatically deleted until the 'Schedule expired transient files cleanup' tool is run again.", 'woocommerce' ),
				'button'           => __( 'Un-schedule', 'woocommerce' ),
				'requires_refresh' => true,
				'callback'         => array( $this, 'unschedule_expired_files_cleanup' ),
			);
		}

		return $tools_array;
	}

	/**
	 * Delete a directory if it isn't empty.
	 *
	 * @param string $directory Full directory path.
	 */
	private function delete_directory_if_not_empty( string $directory ) {
		if ( ! ( new \FilesystemIterator( $directory ) )->valid() ) {
			rmdir( $directory );
		}
	}

	/**
	 * Handle the "init" action, add rewrite rules for the "wc/file" endpoint.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public static function add_endpoint() {
		add_rewrite_rule( '^wc/file/transient/?$', 'index.php?wc-transient-file-name=', 'top' );
		add_rewrite_rule( '^wc/file/transient/(.+)$', 'index.php?wc-transient-file-name=$matches[1]', 'top' );
		add_rewrite_endpoint( 'wc/file/transient', EP_ALL );
	}

	/**
	 * Handle the "query_vars" action, add the "wc-transient-file-name" variable for the "wc/file/transient" endpoint.
	 *
	 * @param array $vars The original query variables.
	 * @return array The updated query variables.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function handle_query_vars( $vars ) {
		$vars[] = 'wc-transient-file-name';
		return $vars;
	}

	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.Missing, WordPress.WP.AlternativeFunctions

	/**
	 * Handle the "parse_request" action for the "wc/file/transient" endpoint.
	 *
	 * If the request is not for "/wc/file/transient/<filename>" or "index.php?wc-transient-file-name=filename",
	 * it returns without doing anything. Otherwise, it will serve the contents of the file with the provided name
	 * if it exists, is public and has not expired; or will return a "Not found" status otherwise.
	 *
	 * The file will be served with a content type header of "text/html".
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function handle_parse_request() {
		global $wp;

		// phpcs:ignore WordPress.Security
		$query_arg = wp_unslash( $_GET['wc-transient-file-name'] ?? null );
		if ( ! is_null( $query_arg ) ) {
			$wp->query_vars['wc-transient-file-name'] = $query_arg;
		}

		if ( is_null( $wp->query_vars['wc-transient-file-name'] ?? null ) ) {
			return;
		}

		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
		if ( 'GET' !== ( $_SERVER['REQUEST_METHOD'] ?? null ) ) {
			status_header( 405 );
			exit();
		}

		$this->serve_file_contents( $wp->query_vars['wc-transient-file-name'] );
	}

	/**
	 * Core method to serve the contents of a transient file.
	 *
	 * @param string $file_name Transient file id or filename.
	 */
	private function serve_file_contents( string $file_name ) {
		$legacy_proxy = wc_get_container()->get( LegacyProxy::class );

		try {
			$file_path = $this->get_transient_file_path( $file_name );
			if ( is_null( $file_path ) ) {
				$legacy_proxy->call_function( 'status_header', 404 );
				$legacy_proxy->exit();
			}

			if ( $this->file_has_expired( $file_path ) ) {
				$legacy_proxy->call_function( 'status_header', 404 );
				$legacy_proxy->exit();
			}

			$file_length = filesize( $file_path );
			if ( false === $file_length ) {
				throw new Exception( "Can't retrieve file size: $file_path" );
			}

			$file_handle = fopen( $file_path, 'r' );
		} catch ( Exception $ex ) {
			$error_message = "Error serving transient file $file_name: {$ex->getMessage()}";
			wc_get_logger()->error( $error_message );

			$legacy_proxy->call_function( 'status_header', 500 );
			$legacy_proxy->exit();
		}

		$legacy_proxy->call_function( 'status_header', 200 );
		$legacy_proxy->call_function( 'header', 'Content-Type: text/html' );
		$legacy_proxy->call_function( 'header', "Content-Length: $file_length" );

		try {
			while ( ! feof( $file_handle ) ) {
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
				echo fread( $file_handle, 1024 );
			}

			/**
			 * Action that fires after a transient file has been successfully served, right before terminating the request.
			 *
			 * @param array $transient_file_info Information about the served file, as returned by get_file_by_name.
			 * @param bool $is_json_rest_api_request True if the request came from the JSON API endpoint, false if it came from the authenticated endpoint.
			 *
			 * @since 8.5.0
			 */
			do_action( 'woocommerce_transient_file_contents_served', $file_name );
		} catch ( Exception $e ) {
			wc_get_logger()->error( "Error serving transient file $file_name: {$e->getMessage()}" );
			// We can't change the response status code at this point.
		} finally {
			fclose( $file_handle );
			$legacy_proxy->exit();
		}
	}
}
PK     [1]6n        RestApiControllerBase.phpnu         <?php

namespace Automattic\WooCommerce\Internal;

use Automattic\WooCommerce\Internal\RegisterHooksInterface;
use Automattic\WooCommerce\Utilities\StringUtil;
use WP_HTTP_Response;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use InvalidArgumentException;
use Exception;

/**
 * Base class for REST API controllers defined inside the 'src' directory.
 *
 * The following must be added at the end of the 'init_hooks' method in the 'WooCommerce' class,
 * otherwise the routes won't be registered:
 * $container->get( <full class name>::class )->register();
 *
 * Minimal controller example:
 *
 * class FoobarsController extends RestApiControllerBase {
 *
 * protected function get_rest_api_namespace(): string {
 *   return 'foobars';
 * }
 *
 * public function register_routes() {
 *   register_rest_route(
 *     $this->route_namespace,
 *     '/foobars/(?P<id>[\d]+)',
 *     array(
 *       array(
 *         'methods'             => \WP_REST_Server::READABLE,
 *         'callback'            => fn( $request ) => $this->run( $request, 'get_foobar' ),
 *         'permission_callback' => fn( $request ) => $this->check_permission( $request, 'read_foobars', $request->get_param( 'id' ) ),
 *         'args'                => $this->get_args_for_get_foobar(),
 *         'schema'              => $this->get_schema_for_get_foobar(),
 *       ),
 *     )
 *   );
 * }
 *
 * protected function get_foobar( \WP_REST_Request $request ) {
 *     return array( 'message' => 'Get foobar with id ' . $request->get_param(' id' ) );
 * }
 *
 * private function get_args_for_get_foobar(): array {
 *   return array(
 *     'id' => array(
 *       'description' => __( 'Unique identifier of the foobar.', 'woocommerce' ),
 *       'type'        => 'integer',
 *       'context'     => array( 'view', 'edit' ),
 *       'readonly'    => true,
 *     ),
 *   );
 * }
 *
 * private function get_schema_for_get_foobar(): array {
 *   $schema               = $this->get_base_schema();
 *   $schema['properties'] = array(
 *     'message'     => array(
 *       'description' => __( 'A message.', 'woocommerce' ),
 *       'type'        => 'string',
 *       'context'     => array( 'view', 'edit' ),
 *       'readonly'    => true,
 *     ),
 *   );
 *   return $schema;
 * }
 *
 * }
 */
abstract class RestApiControllerBase implements RegisterHooksInterface {

	/**
	 * The root namespace for the JSON REST API endpoints.
	 *
	 * @var string
	 */
	protected string $route_namespace = 'wc/v3';

	/**
	 * Register the hooks used by the class.
	 */
	public function register() {
		add_filter( 'woocommerce_rest_api_get_rest_namespaces', array( $this, 'handle_woocommerce_rest_api_get_rest_namespaces' ) );
	}

	/**
	 * Handle the woocommerce_rest_api_get_rest_namespaces filter
	 * to add ourselves to the list of REST API controllers registered by WooCommerce.
	 *
	 * @param array $namespaces The original list of WooCommerce REST API namespaces/controllers.
	 * @return array The updated list of WooCommerce REST API namespaces/controllers.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function handle_woocommerce_rest_api_get_rest_namespaces( array $namespaces ): array {
		$namespaces['wc/v3'][ $this->get_rest_api_namespace() ] = static::class;
		return $namespaces;
	}

	/**
	 * Get the WooCommerce REST API namespace for the class. It must be unique across all other derived classes
	 * and the keys returned by the 'get_vX_controllers' methods in includes/rest-api/Server.php.
	 * Note that this value is NOT related to the route namespace.
	 *
	 * @return string
	 */
	abstract protected function get_rest_api_namespace(): string;

	/**
	 * Register the REST API endpoints handled by this controller.
	 *
	 * Use 'register_rest_route' in the usual way, it's recommended to use the 'run' method for 'callback'
	 * and the 'check_permission' method for 'permission_check', see the example in the class comment.
	 */
	abstract public function register_routes();

	/**
	 * Handle a request for one of the provided REST API endpoints.
	 *
	 * If an exception is thrown, the exception message will be returned as part of the response
	 * if the user has the 'manage_woocommerce' capability.
	 *
	 * Note that the method specified in $method_name must have a 'protected' visibility and accept one argument of type 'WP_REST_Request'.
	 *
	 * @param WP_REST_Request $request The incoming HTTP REST request.
	 * @param string          $method_name The name of the class method to execute. It must be protected and accept one argument of type 'WP_REST_Request'.
	 * @return WP_Error|WP_HTTP_Response|WP_REST_Response The response to send back to the client.
	 */
	protected function run( WP_REST_Request $request, string $method_name ) {
		try {
			return rest_ensure_response( $this->$method_name( $request ) );
		} catch ( InvalidArgumentException $ex ) {
			$message = $ex->getMessage();
			return new WP_Error( 'woocommerce_rest_invalid_argument', $message ? $message : __( 'Internal server error', 'woocommerce' ), array( 'status' => 400 ) );
		} catch ( Exception $ex ) {
			wc_get_logger()->error( StringUtil::class_name_without_namespace( static::class ) . ": when executing method $method_name: {$ex->getMessage()}" );
			return $this->internal_wp_error( $ex );
		}
	}

	/**
	 * Return an WP_Error object for an internal server error, with exception information if the current user is an admin.
	 *
	 * @param Exception $exception The exception to maybe include information from.
	 * @return WP_Error
	 */
	protected function internal_wp_error( Exception $exception ): WP_Error {
		$data = array( 'status' => 500 );
		if ( current_user_can( 'manage_woocommerce' ) ) {
			$data['exception_class']   = get_class( $exception );
			$data['exception_message'] = $exception->getMessage();
			$data['exception_trace']   = (array) $exception->getTrace();
		}
		$data['exception_message'] = $exception->getMessage();

		return new WP_Error( 'woocommerce_rest_internal_error', __( 'Internal server error', 'woocommerce' ), $data );
	}

	/**
	 * Returns an authentication error message for a given HTTP verb.
	 *
	 * @param string $method HTTP method.
	 * @return array|null Error information on success, null otherwise.
	 */
	protected function get_authentication_error_by_method( string $method ) {
		$errors = array(
			'GET'    => array(
				'code'    => 'woocommerce_rest_cannot_view',
				'message' => __( 'Sorry, you cannot view resources.', 'woocommerce' ),
			),
			'POST'   => array(
				'code'    => 'woocommerce_rest_cannot_create',
				'message' => __( 'Sorry, you cannot create resources.', 'woocommerce' ),
			),
			'DELETE' => array(
				'code'    => 'woocommerce_rest_cannot_delete',
				'message' => __( 'Sorry, you cannot delete resources.', 'woocommerce' ),
			),
		);

		return $errors[ $method ] ?? null;
	}

	/**
	 * Permission check for REST API endpoints, given the request method.
	 *
	 * @param WP_REST_Request $request The request for which the permission is checked.
	 * @param string          $required_capability_name The name of the required capability.
	 * @param mixed           ...$extra_args Extra arguments to be used for the permission check.
	 * @return bool|WP_Error True if the current user has the capability, otherwise an "Unauthorized" error or False if no error is available for the request method.
	 */
	protected function check_permission( WP_REST_Request $request, string $required_capability_name, ...$extra_args ) {
		if ( current_user_can( $required_capability_name, ...$extra_args ) ) {
			return true;
		}

		$error_information = $this->get_authentication_error_by_method( $request->get_method() );
		if ( is_null( $error_information ) ) {
			return false;
		}

		return new WP_Error(
			$error_information['code'],
			$error_information['message'],
			array( 'status' => rest_authorization_required_code() )
		);
	}

	/**
	 * Get the base schema for the REST API endpoints.
	 *
	 * @return array
	 */
	protected function get_base_schema(): array {
		return array(
			'$schema' => 'http://json-schema.org/draft-04/schema#',
			'title'   => 'order receipts',
			'type'    => 'object',
		);
	}
}
PK     [1]9,      DownloadPermissionsAdjuster.phpnu         <?php
/**
 * DownloadPermissionsAdjuster class file.
 */

namespace Automattic\WooCommerce\Internal;

use Automattic\WooCommerce\Proxies\LegacyProxy;
use WC_Product;

defined( 'ABSPATH' ) || exit;

/**
 * Class to adjust download permissions on product save.
 */
class DownloadPermissionsAdjuster {

	/**
	 * The downloads data store to use.
	 *
	 * @var WC_Data_Store
	 */
	private $downloads_data_store;

	/**
	 * Class initialization, to be executed when the class is resolved by the container.
	 *
	 * @internal
	 */
	final public function init() {
		$this->downloads_data_store = wc_get_container()->get( LegacyProxy::class )->get_instance_of( \WC_Data_Store::class, 'customer-download' );
		add_action( 'adjust_download_permissions', array( $this, 'adjust_download_permissions' ), 10, 1 );
	}

	/**
	 * Schedule a download permissions adjustment for a product if necessary.
	 * This should be executed whenever a product is saved.
	 *
	 * @param \WC_Product $product The product to schedule a download permission adjustments for.
	 */
	public function maybe_schedule_adjust_download_permissions( \WC_Product $product ) {
		$children_ids = $product->get_children();
		if ( ! $children_ids ) {
			return;
		}

		$are_any_children_downloadable = false;
		foreach ( $children_ids as $child_id ) {
			$child = wc_get_product( $child_id );
			if ( $child && $child->is_downloadable() ) {
				$are_any_children_downloadable = true;
				break;
			}
		}

		if ( ! $product->is_downloadable() && ! $are_any_children_downloadable ) {
			return;
		}

		$scheduled_action_args = array( $product->get_id() );

		$already_scheduled_actions =
			WC()->call_function(
				'as_get_scheduled_actions',
				array(
					'hook'   => 'adjust_download_permissions',
					'args'   => $scheduled_action_args,
					'status' => \ActionScheduler_Store::STATUS_PENDING,
				),
				'ids'
			);

		if ( empty( $already_scheduled_actions ) ) {
			WC()->call_function(
				'as_schedule_single_action',
				WC()->call_function( 'time' ) + 1,
				'adjust_download_permissions',
				$scheduled_action_args
			);
		}
	}

	/**
	 * Create additional download permissions for variations if necessary.
	 *
	 * When a simple downloadable product is converted to a variable product,
	 * existing download permissions are still present in the database but they don't apply anymore.
	 * This method creates additional download permissions for the variations based on
	 * the old existing ones for the main product.
	 *
	 * The procedure is as follows. For each existing download permission for the parent product,
	 * check if there's any variation offering the same file for download (the file URL, not name, is checked).
	 * If that is found, check if an equivalent permission exists (equivalent means for the same file and with
	 * the same order id and customer id). If no equivalent permission exists, create it.
	 *
	 * @param int $product_id The id of the product to check permissions for.
	 */
	public function adjust_download_permissions( int $product_id ) {
		$product = wc_get_product( $product_id );
		if ( ! $product ) {
			return;
		}

		$children_ids = $product->get_children();
		if ( ! $children_ids ) {
			return;
		}

		$parent_downloads = $this->get_download_files_and_permissions( $product );
		if ( ! $parent_downloads ) {
			return;
		}

		$children_with_downloads = array();
		foreach ( $children_ids as $child_id ) {
			$child = wc_get_product( $child_id );

			// Ensure we have a valid child product.
			if ( ! $child instanceof WC_Product ) {
				wc_get_logger()->warning(
					sprintf(
						/* translators: 1: child product ID 2: parent product ID. */
						__( 'Unable to load child product %1$d while adjusting download permissions for product %2$d.', 'woocommerce' ),
						$child_id,
						$product_id
					)
				);
				continue;
			}

			$children_with_downloads[ $child_id ] = $this->get_download_files_and_permissions( $child );
		}

		foreach ( $parent_downloads['permission_data_by_file_order_user'] as $parent_file_order_and_user => $parent_download_data ) {
			foreach ( $children_with_downloads as $child_id => $child_download_data ) {
				$file_url = $parent_download_data['file'];

				$must_create_permission =
					// The variation offers the same file as the parent for download...
					in_array( $file_url, array_keys( $child_download_data['download_ids_by_file_url'] ), true ) &&
					// ...but no equivalent download permission (same file URL, order id and user id) exists.
					! array_key_exists( $parent_file_order_and_user, $child_download_data['permission_data_by_file_order_user'] );

				if ( $must_create_permission ) {
					// The new child download permission is a copy of the parent's,
					// but with the product and download ids changed to match those of the variation.
					$new_download_data                = $parent_download_data['data'];
					$new_download_data['product_id']  = $child_id;
					$new_download_data['download_id'] = $child_download_data['download_ids_by_file_url'][ $file_url ];
					$this->downloads_data_store->create_from_data( $new_download_data );
				}
			}
		}
	}

	/**
	 * Get the existing downloadable files and download permissions for a given product.
	 * The returned value is an array with two keys:
	 *
	 * - download_ids_by_file_url: an associative array of file url => download_id.
	 * - permission_data_by_file_order_user: an associative array where key is "file_url:customer_id:order_id" and value is the full permission data set.
	 *
	 * @param \WC_Product $product The product to get the downloadable files and permissions for.
	 * @return array[] Information about the downloadable files and permissions for the product.
	 */
	private function get_download_files_and_permissions( \WC_Product $product ) {
		$result    = array(
			'permission_data_by_file_order_user' => array(),
			'download_ids_by_file_url'           => array(),
		);
		$downloads = $product->get_downloads();
		foreach ( $downloads as $download ) {
			$result['download_ids_by_file_url'][ $download->get_file() ] = $download->get_id();
		}

		$permissions = $this->downloads_data_store->get_downloads( array( 'product_id' => $product->get_id() ) );
		foreach ( $permissions as $permission ) {
			$permission_data = (array) $permission->data;
			if ( array_key_exists( $permission_data['download_id'], $downloads ) ) {
				$file = $downloads[ $permission_data['download_id'] ]->get_file();
				$data = array(
					'file' => $file,
					'data' => (array) $permission->data,
				);
				$result['permission_data_by_file_order_user'][ "{$file}:{$permission_data['user_id']}:{$permission_data['order_id']}" ] = $data;
			}
		}

		return $result;
	}
}
PK     [1]Z    "  DataStores/CustomMetaDataStore.phpnu         <?php
/**
 * CustomMetaDataStore class file.
 */

namespace Automattic\WooCommerce\Internal\DataStores;

/**
 * Implements functions similar to WP's add_metadata(), get_metadata(), and friends using a custom table.
 *
 * @see WC_Data_Store_WP For an implementation using WP's metadata functions and tables.
 */
abstract class CustomMetaDataStore {

	/**
	 * Returns the name of the table used for storage.
	 *
	 * @return string
	 */
	abstract protected function get_table_name();

	/**
	 * Returns the name of the field/column used for identifiying metadata entries.
	 *
	 * @return string
	 */
	protected function get_meta_id_field() {
		return 'id';
	}

	/**
	 * Returns the name of the field/column used for associating meta with objects.
	 *
	 * @return string
	 */
	protected function get_object_id_field() {
		return 'object_id';
	}

	/**
	 * Describes the structure of the metadata table.
	 *
	 * @return array Array elements: table, object_id_field, meta_id_field.
	 */
	protected function get_db_info() {
		return array(
			'table'           => $this->get_table_name(),
			'meta_id_field'   => $this->get_meta_id_field(),
			'object_id_field' => $this->get_object_id_field(),
		);
	}

	/**
	 * Returns an array of meta for an object.
	 *
	 * @param  \WC_Data $object WC_Data object.
	 * @return array
	 */
	public function read_meta( &$object ) {
		$object_id     = $object->get_id();
		$raw_meta_data = $this->get_meta_data_for_object_ids( array( $object_id ) );

		return isset( $raw_meta_data[ $object_id ] ) ? (array) $raw_meta_data[ $object_id ] : array();
	}

	/**
	 * Deletes meta based on meta ID.
	 *
	 * @param  \WC_Data  $object WC_Data object.
	 * @param  \stdClass $meta (containing at least ->id).
	 *
	 * @return bool
	 */
	public function delete_meta( &$object, $meta ) : bool {
		global $wpdb;

		if ( ! isset( $meta->id ) ) {
			return false;
		}

		$db_info = $this->get_db_info();
		$meta_id = absint( $meta->id );

		return (bool) $wpdb->delete(
			$db_info['table'],
			array(
				$db_info['meta_id_field']   => $meta_id,
				$db_info['object_id_field'] => $object->get_id(),
			),
			'%d'
		);
	}

	/**
	 * Add new piece of meta.
	 *
	 * @param  WC_Data  $object WC_Data object.
	 * @param  stdClass $meta (containing ->key and ->value).
	 *
	 * @return int|false meta ID
	 */
	public function add_meta( &$object, $meta ) {
		global $wpdb;

		$db_info = $this->get_db_info();

		$object_id = $object->get_id();
		if ( ! $object_id ) {
			return false;
		}

		$meta_key   = wp_unslash( wp_slash( $meta->key ) );
		$meta_value = maybe_serialize( is_string( $meta->value ) ? wp_unslash( wp_slash( $meta->value ) ) : $meta->value );

		// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_value,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
		$result = $wpdb->insert(
			$db_info['table'],
			array(
				$db_info['object_id_field'] => $object_id,
				'meta_key'                  => $meta_key,
				'meta_value'                => $meta_value,
			)
		);
		// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_value,WordPress.DB.SlowDBQuery.slow_db_query_meta_key

		return $result ? (int) $wpdb->insert_id : false;
	}

	/**
	 * Update meta.
	 *
	 * @param  \WC_Data  $object WC_Data object.
	 * @param  \stdClass $meta (containing ->id, ->key and ->value).
	 *
	 * @return bool
	 */
	public function update_meta( &$object, $meta ) : bool {
		global $wpdb;

		if ( ! isset( $meta->id ) || empty( $meta->key ) || ! $object->get_id() ) {
			return false;
		}

		// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_value,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
		$data = array(
			'meta_key'   => $meta->key,
			'meta_value' => maybe_serialize( $meta->value ),
		);
		// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_value,WordPress.DB.SlowDBQuery.slow_db_query_meta_key

		$result = $wpdb->update(
			$this->get_table_name(),
			$data,
			array(
				$this->get_meta_id_field()   => $meta->id,
				$this->get_object_id_field() => $object->get_id(),
			),
			'%s',
			'%d'
		);

		return 1 === $result;
	}

	/**
	 * Retrieves metadata by meta ID.
	 *
	 * @param int $meta_id Meta ID.
	 * @return object|bool Metadata object or FALSE if not found.
	 */
	public function get_metadata_by_id( $meta_id ) {
		global $wpdb;

		if ( ! is_numeric( $meta_id ) || floor( $meta_id ) != $meta_id ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
			return false;
		}

		$db_info = $this->get_db_info();

		$meta_id = absint( $meta_id );
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$meta = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT {$db_info['meta_id_field']}, meta_key, meta_value, {$db_info['object_id_field']} FROM {$db_info['table']} WHERE {$db_info['meta_id_field']} = %d",
				$meta_id
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( empty( $meta ) ) {
			return false;
		}

		if ( isset( $meta->meta_value ) ) {
			$meta->meta_value = maybe_unserialize( $meta->meta_value );
		}

		return $meta;
	}

	/**
	 * Retrieves metadata by meta key.
	 *
	 * @param \WC_Data $object Object ID.
	 * @param string   $meta_key Meta key.
	 *
	 * @return \stdClass|bool Metadata object or FALSE if not found.
	 */
	public function get_metadata_by_key( &$object, string $meta_key ) {
		global $wpdb;

		if ( ! $object->get_id() ) {
			return false;
		}

		$db_info = $this->get_db_info();

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$meta = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT {$db_info['meta_id_field']}, meta_key, meta_value, {$db_info['object_id_field']} FROM {$db_info['table']} WHERE meta_key = %s AND {$db_info['object_id_field']} = %d",
				$meta_key,
				$object->get_id(),
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( empty( $meta ) ) {
			return false;
		}

		foreach ( $meta as $row ) {
			if ( isset( $row->meta_value ) ) {
				$row->meta_value = maybe_unserialize( $row->meta_value );
			}
		}

		return $meta;
	}

	/**
	 * Returns distinct meta keys in use.
	 *
	 * @since 8.8.0
	 *
	 * @param int $limit Maximum number of meta keys to return. Defaults to 100.
	 * @return string[]
	 */
	public function get_meta_keys( int $limit = 100 ): array {
		global $wpdb;

		return $wpdb->get_col(
			$wpdb->prepare(
				"SELECT DISTINCT meta_key FROM %i WHERE meta_key != '' AND meta_key NOT BETWEEN '_' AND '_z' AND meta_key NOT LIKE %s ORDER BY meta_key ASC LIMIT %d",
				$this->get_db_info()['table'],
				$wpdb->esc_like( '_' ) . '%',
				$limit
			)
		);
	}

	/**
	 * Return order meta data for multiple IDs.
	 *
	 * @param array $object_ids List of object IDs.
	 *
	 * @return \stdClass[][] An array, keyed by object_ids, containing array of raw meta data records for each object. Objects with no meta data will have an empty array.
	 */
	public function get_meta_data_for_object_ids( array $object_ids ): array {
		global $wpdb;

		if ( empty( $object_ids ) ) {
			return array();
		}

		$id_placeholder   = implode( ', ', array_fill( 0, count( $object_ids ), '%d' ) );
		$meta_table       = $this->get_table_name();
		$object_id_column = $this->get_object_id_field();

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $object_id_column and $meta_table is hardcoded. IDs are prepared above.
		$meta_rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, $object_id_column as object_id, meta_key, meta_value FROM $meta_table WHERE $object_id_column in ( $id_placeholder )",
				$object_ids
			)
		);
		// phpcs:enable

		$meta_data = array_fill_keys( $object_ids, array() );
		foreach ( $meta_rows as $meta_row ) {
			if ( ! isset( $meta_data[ $meta_row->object_id ] ) ) {
				$meta_data[ $meta_row->object_id ] = array();
			}
			$meta_data[ $meta_row->object_id ][] = (object) array(
				'meta_id'    => $meta_row->id,
				'meta_key'   => $meta_row->meta_key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
				'meta_value' => $meta_row->meta_value, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
			);
		}

		return $meta_data;
	}

}
PK     [1]ىB  B  1  DataStores/Fulfillments/FulfillmentsDataStore.phpnu         <?php
/**
 * Class FulfillmentsDataStore file.
 *
 * @package WooCommerce\DataStores
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\DataStores\Fulfillments;

use Automattic\WooCommerce\Internal\Fulfillments\Fulfillment;
use Automattic\WooCommerce\Internal\Fulfillments\FulfillmentUtils;
use WC_Meta_Data;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * WC Order Item Product Data Store
 *
 * @version  9.9.0
 */
class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data_Store_Interface, FulfillmentsDataStoreInterface {

	/**
	 * Method to create a new fulfillment in the database.
	 *
	 * @param Fulfillment $data The fulfillment object to create.
	 *
	 * @return void
	 *
	 * @throws \Exception If the fulfillment data is invalid.
	 * @throws \Exception If the fulfillment can't be created.
	 */
	public function create( &$data ): void {
		// Validate the fulfillment data.
		if ( ! $data->get_entity_type() ) {
			throw new \Exception( esc_html__( 'Invalid entity type.', 'woocommerce' ) );
		}
		if ( ! $data->get_entity_id() ) {
			throw new \Exception( esc_html__( 'Invalid entity ID.', 'woocommerce' ) );
		}
		if ( ! FulfillmentUtils::is_valid_fulfillment_status( $data->get_status() ) ) {
			throw new \Exception( esc_html__( 'Invalid fulfillment status.', 'woocommerce' ) );
		}

		$this->validate_items( $data );

		// Set fulfillment properties.
		$data->set_date_updated( current_time( 'mysql' ) );

		/**
		 * Filter to modify the fulfillment data before it is created.
		 *
		 * @since 10.1.0
		 */
		$data = apply_filters( 'woocommerce_fulfillment_before_create', $data );

		$is_fulfill_action = $data->get_is_fulfilled();
		// If the fulfillment is fulfilled, set the fulfilled date.
		if ( $is_fulfill_action ) {
			$data->set_date_fulfilled( current_time( 'mysql' ) );

			/**
			 * Filter to modify the fulfillment data before it is fulfilled.
			 *
			 * @since 10.1.0
			 */
			$data = apply_filters(
				'woocommerce_fulfillment_before_fulfill',
				$data
			);
		}

		// Save the fulfillment to the database.
		global $wpdb;
		$rows_inserted = $wpdb->insert(
			$wpdb->prefix . 'wc_order_fulfillments',
			array(
				'entity_type'  => $data->get_entity_type(),
				'entity_id'    => $data->get_entity_id(),
				'status'       => $data->get_status() ?? 'unfulfilled',
				'is_fulfilled' => $data->get_is_fulfilled() ? 1 : 0,
				'date_updated' => $data->get_date_updated(),
				'date_deleted' => $data->get_date_deleted(),
			),
			array( '%s', '%s', '%s', '%d', '%s', '%s' )
		);

		// Check for errors.
		if ( false === $rows_inserted ) {
			throw new \Exception( esc_html__( 'Failed to insert fulfillment.', 'woocommerce' ) );
		}

		// Set the ID of the fulfillment object.
		$data_id = $wpdb->insert_id;

		$data->set_id( $data_id );

		// If the fulfillment is fulfilled, set the fulfilled date.
		if ( $data->get_is_fulfilled() ) {
			$data->set_date_fulfilled( current_time( 'mysql' ) );
		}

		// Save the metadata for the fulfillment to the database.
		$data->save_meta_data();

		// Apply changes let's the object know that the current object reflects the database and no "changes" exist between the two.
		$data->apply_changes();
		$data->set_object_read( true );

		if ( ! doing_action( 'woocommerce_fulfillment_after_create' ) ) {
			/**
			* Action to perform after a fulfillment is created.
			*
			* @param Fulfillment $data The fulfillment object that was created.
			*
			* @since 10.1.0
			*/
			do_action( 'woocommerce_fulfillment_after_create', $data );
		}

		if ( $is_fulfill_action && ! doing_action( 'woocommerce_fulfillment_after_fulfill' ) ) {
			/**
			 * Action to perform after a fulfillment is fulfilled.
			 *
			 * @since 10.1.0
			 */
			do_action( 'woocommerce_fulfillment_after_fulfill', $data );
		}
	}

	/**
	 * Method to read a fulfillment from the database.
	 *
	 * @param Fulfillment $data The fulfillment object to read.
	 *
	 * @return void
	 *
	 * @throws \Exception If the fulfillment data can't be read.
	 */
	public function read( &$data ): void {
		// Read the fulfillment from the database.
		global $wpdb;

		$data_id          = $data->get_id();
		$fulfillment_data = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$wpdb->prefix}wc_order_fulfillments WHERE fulfillment_id = %d",
				$data_id
			),
			ARRAY_A
		);

		if ( empty( $fulfillment_data ) ) {
			throw new \Exception( esc_html__( 'Fulfillment not found.', 'woocommerce' ) );
		}

		$data->set_props( array_diff_key( $fulfillment_data, array( 'fulfillment_id' => true ) ) );
		$data->set_id( (int) $fulfillment_data['fulfillment_id'] );
		$data->read_meta_data( true );
		$data->set_object_read( true );
	}

	/**
	 * Method to update an existing fulfillment in the database.
	 *
	 * @param Fulfillment $data The fulfillment object to update.
	 *
	 * @return void
	 *
	 * @throws \Exception If the fulfillment can't be updated.
	 */
	public function update( &$data ): void {
		// If the fulfillment is deleted, do nothing.
		if ( $data->get_date_deleted() ) {
			return;
		}

		// Update the fulfillment in the database.
		$data_id = $data->get_id();

		if ( ! FulfillmentUtils::is_valid_fulfillment_status( $data->get_status() ) ) {
			throw new \Exception( esc_html__( 'Invalid fulfillment status.', 'woocommerce' ) );
		}

		$this->validate_items( $data );

		/**
		 * Filter to modify the fulfillment data before it is updated.
		 *
		 * @param Fulfillment $data The fulfillment object that is being updated.
		 *
		 * @since 10.1.0
		 */
		$data = apply_filters( 'woocommerce_fulfillment_before_update', $data );

		// If the fulfillment is fulfilled, set the fulfilled date.
		$is_fulfill_action = false;
		if ( $data->get_is_fulfilled() && empty( $data->get_date_fulfilled() ) ) {
			$is_fulfill_action = true;
			$data->set_date_fulfilled( current_time( 'mysql' ) );

			/**
			 * Filter to modify the fulfillment data before it is fulfilled.
			 *
			 * @param Fulfillment $data The fulfillment object that is being fulfilled.
			 *
			 * @since 10.1.0
			 */
			$data = apply_filters(
				'woocommerce_fulfillment_before_fulfill',
				$data
			);
		}

		global $wpdb;

		$wpdb->update(
			$wpdb->prefix . 'wc_order_fulfillments',
			array(
				'entity_type'  => $data->get_entity_type(),
				'entity_id'    => $data->get_entity_id(),
				'status'       => $data->get_status(),
				'is_fulfilled' => $data->get_is_fulfilled() ? 1 : 0,
				'date_updated' => current_time( 'mysql' ),
				'date_deleted' => $data->get_date_deleted(),
			),
			array(
				'fulfillment_id' => $data_id,
				'date_deleted'   => null,
			),
			array( '%s', '%s', '%s', '%d', '%s', '%s' ),
			array( '%d' )
		);

		// Check for errors.
		if ( $wpdb->last_error ) {
			throw new \Exception( esc_html__( 'Failed to update fulfillment.', 'woocommerce' ) );
		}

		// If the fulfillment is fulfilled, set the fulfilled date.
		if ( $data->get_is_fulfilled() && ! $data->meta_exists( '_fulfilled_date' ) ) {
			$data->set_date_fulfilled( current_time( 'mysql' ) );
		}

		// Update the metadata for the fulfillment.
		$data->save_meta_data();
		$data->apply_changes();

		$data->set_object_read( true );

		if ( ! doing_action( 'woocommerce_fulfillment_after_update' ) ) {
			/**
			 * Action to perform after a fulfillment is updated.
			 *
			 * @param Fulfillment $data The fulfillment object that was updated.
			 *
			 * @since 10.1.0
			 */
			do_action( 'woocommerce_fulfillment_after_update', $data );
		}

		if ( $is_fulfill_action && ! doing_action( 'woocommerce_fulfillment_after_fulfill' ) ) {
			/**
			 * Action to perform after a fulfillment is fulfilled.
			 *
			 * @param Fulfillment $data The fulfillment object that was fulfilled.
			 *
			 * @since 10.1.0
			 */
			do_action( 'woocommerce_fulfillment_after_fulfill', $data );
		}
	}

	/**
	 * Method to delete a fulfillment from the database.
	 *
	 * @param Fulfillment $data The fulfillment object to delete.
	 * @param array       $args Optional arguments to pass to the delete method.
	 *
	 * @return void
	 *
	 * @throws \Exception If the fulfillment can't be deleted.
	 */
	public function delete( &$data, $args = array() ): void {
		// If the record is already deleted, do nothing.
		if ( $data->get_date_deleted() ) {
			return;
		}

		/**
		 * Filter to modify the fulfillment data before it is updated.
		 *
		 * @since 10.1.0
		 */
		$data = apply_filters( 'woocommerce_fulfillment_before_delete', $data );

		// Soft Delete the fulfillment from the database.
		global $wpdb;

		$data_id       = $data->get_id();
		$deletion_time = current_time( 'mysql' );
		$wpdb->update(
			$wpdb->prefix . 'wc_order_fulfillments',
			array( 'date_deleted' => $deletion_time ),
			array(
				'fulfillment_id' => $data_id,
				'date_deleted'   => null,
			),
			array( '%s' ),
			array( '%d' )
		);

		// Check for errors.
		if ( $wpdb->last_error ) {
			throw new \Exception( esc_html__( 'Failed to delete fulfillment.', 'woocommerce' ) );
		}

		$data->set_date_deleted( $deletion_time );
		$data->apply_changes();
		$data->set_object_read( true );

		if ( ! doing_action( 'woocommerce_fulfillment_after_delete' ) ) {
			/**
			 * Action to perform after a fulfillment is deleted.
			 *
			 * @since 10.1.0
			 */
			do_action( 'woocommerce_fulfillment_after_delete', $data );
		}

		// Set the fulfillment object to a fresh state.
		$data = new Fulfillment();
	}

	/**
	 * Method to read the metadata for a fulfillment.
	 *
	 * @param Fulfillment $data The fulfillment object to read.
	 * @return array
	 *
	 * @throws \Exception If the fulfillment is not saved.
	 */
	public function read_meta( &$data ): array {
		if ( ! $data->get_id() ) {
			throw new \Exception( esc_html__( 'Invalid fulfillment.', 'woocommerce' ) );
		}

		// Read the metadata for the fulfillment.
		global $wpdb;

		$data_id   = $data->get_id();
		$meta_data = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT * FROM {$wpdb->prefix}wc_order_fulfillment_meta WHERE fulfillment_id = %d",
				$data_id
			),
			OBJECT
		);

		return array_map(
			function ( $meta ) {
				$meta->meta_value = json_decode( $meta->meta_value, true ) ?? $meta->meta_value;
				return $meta;
			},
			$meta_data
		);
	}

	/**
	 * Method to delete the metadata for a fulfillment.
	 *
	 * @param Fulfillment  $data The fulfillment object to delete.
	 * @param WC_Meta_Data $meta Meta object (containing at least ->id).
	 *
	 * @return void
	 *
	 * @throws \Exception If the fulfillment or meta is not saved.
	 */
	public function delete_meta( &$data, $meta ): void {
		// Check if the fulfillment and meta are saved.
		$data_id = $data->get_id();

		// Prevent deletion of metadata from a deleted fulfillment.
		if ( $data->get_date_deleted() ) {
			throw new \Exception( esc_html__( 'Cannot delete meta from a deleted fulfillment.', 'woocommerce' ) );
		}

		$meta_id = $meta->id;
		if ( ! is_numeric( $data_id ) || $data_id <= 0 || ! is_numeric( $meta_id ) || $meta_id <= 0 ) {
			throw new \Exception( esc_html__( 'Invalid fulfillment or meta.', 'woocommerce' ) );
		}

		// Delete the metadata for the fulfillment.
		global $wpdb;

		$wpdb->delete(
			$wpdb->prefix . 'wc_order_fulfillment_meta',
			array(
				'fulfillment_id' => $data_id,
				'meta_id'        => $meta_id,
			),
			array(
				'%d',
				'%d',
			)
		);
	}

	/**
	 * Method to add metadata for a fulfillment.
	 *
	 * @param Fulfillment  $data The fulfillment object to save.
	 * @param WC_Meta_Data $meta Meta object (containing at least ->id).
	 * @return int meta ID or WP_Error on failure.
	 *
	 * @throws \Exception If the fulfillment or meta is not saved.
	 */
	public function add_meta( &$data, $meta ): int {
		// Add the metadata for the fulfillment.
		global $wpdb;

		// Prevent adding metadata to a deleted fulfillment.
		if ( $data->get_date_deleted() ) {
			throw new \Exception( esc_html__( 'Cannot add meta to a deleted fulfillment.', 'woocommerce' ) );
		}

		// Data ID can't be something wrong as this function is called after the meta is read.
		// See WC_Data::save_meta_data().
		$data_id = $data->get_id();

		$wpdb->insert(
			$wpdb->prefix . 'wc_order_fulfillment_meta',
			array(
				'fulfillment_id' => $data_id,
				'meta_key'       => $meta->key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
				'meta_value'     => wp_json_encode( $meta->value ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
			),
			array(
				'%d',
				'%s',
				'%s',
			)
		);

		// Note: There is no error check on WC_Data::save_meta_data(), and it expects us to return an ID in all cases.
		// If there's an error, we should return null to indicate we didn't save it.
		if ( $wpdb->last_error ) {
			throw new \Exception( esc_html__( 'Failed to insert fulfillment meta.', 'woocommerce' ) );
		}

		return $wpdb->insert_id;
	}

	/**
	 * Method to save the metadata for a fulfillment.
	 *
	 * @param Fulfillment  $data The fulfillment object to save.
	 * @param WC_Meta_Data $meta Meta object (containing at least ->id).
	 *
	 * @return int Number of rows updated.
	 *
	 * @throws \Exception If the fulfillment or meta is not saved.
	 */
	public function update_meta( &$data, $meta ): int {
		// Update the metadata for the fulfillment.
		global $wpdb;

		$data_id = $data->get_id();

		// Prevent updating metadata for a deleted fulfillment.
		if ( $data->get_date_deleted() ) {
			throw new \Exception( esc_html__( 'Cannot update meta for a deleted fulfillment.', 'woocommerce' ) );
		}

		$rows_updated = $wpdb->update(
			$wpdb->prefix . 'wc_order_fulfillment_meta',
			array(
				'meta_value' => wp_json_encode( $meta->value ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
			),
			array(
				'fulfillment_id' => $data_id,
				'meta_id'        => $meta->id,
				'meta_key'       => $meta->key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
			),
			array(
				'%s',
			),
			array(
				'%d',
				'%d',
				'%s',
			)
		);

		// Check for errors.
		if ( $wpdb->last_error ) {
			throw new \Exception( esc_html__( 'Failed to update fulfillment meta.', 'woocommerce' ) );
		}

		return $rows_updated;
	}

	/**
	 * Method to read the fulfillment data.
	 *
	 * @param string $entity_type The entity type.
	 * @param string $entity_id The entity ID.
	 * @param bool   $with_deleted Whether to include deleted fulfillments in the results.
	 *
	 * @return Fulfillment[] Fulfillment object.
	 *
	 * @throws \Exception If the fulfillment data can't be read.
	 */
	public function read_fulfillments( string $entity_type, string $entity_id, bool $with_deleted = false ): array {
		// Read the fulfillment data from the database.
		global $wpdb;

		if ( ! $with_deleted ) {
			$fulfillment_data = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT * FROM {$wpdb->prefix}wc_order_fulfillments WHERE entity_type = %s AND entity_id = %s AND date_deleted IS NULL",
					$entity_type,
					$entity_id
				),
				ARRAY_A
			);
		} else {
			$fulfillment_data = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT * FROM {$wpdb->prefix}wc_order_fulfillments WHERE entity_type = %s AND entity_id = %s",
					$entity_type,
					$entity_id
				),
				ARRAY_A
			);
		}

		if ( is_wp_error( $fulfillment_data ) ) {
			throw new \Exception( esc_html__( 'Failed to read fulfillment data.', 'woocommerce' ) );
		}

		// Create Fulfillment objects from the data.
		$fulfillments = array();
		foreach ( $fulfillment_data as $data ) {
			// Note: Don't initialize with ID, it will cause a re-read from the database.
			// Set the ID directly after the object is created.
			$fulfillment = new Fulfillment();
			$fulfillment->set_id( $data['fulfillment_id'] );
			$fulfillment->set_props( $data );
			$fulfillment->apply_changes();
			$fulfillment->set_object_read( true );

			// Read the metadata for the fulfillment.
			$fulfillment->read_meta_data( true );

			$fulfillments[] = $fulfillment;
		}

		return $fulfillments;
	}

	/**
	 * Method to validate the items in a fulfillment.
	 *
	 * @param Fulfillment $data The fulfillment object to validate.
	 *
	 * @return void
	 *
	 * @throws \Exception If the fulfillment data is invalid.
	 */
	private function validate_items( Fulfillment $data ): void {
		$items = $data->get_meta( '_items', true );
		if ( empty( $items ) ) {
			throw new \Exception( esc_html__( 'The fulfillment should contain at least one item.', 'woocommerce' ) );
		}

		if ( ! is_array( $items ) ) {
			throw new \Exception( esc_html__( 'The fulfillment items should be an array.', 'woocommerce' ) );
		}

		foreach ( $data->get_items() as $item ) {
			if ( ! isset( $item['item_id'] )
				// The item ID and qty should be set.
				|| ! isset( $item['qty'] )
				// The item ID should be integers.
				|| ! is_int( $item['item_id'] )
				// Allow the qty to be a float too.
				|| ( ! is_int( $item['qty'] ) && ! is_float( $item['qty'] ) )
				// The item ID and qty should be greater than 0.
				|| $item['item_id'] <= 0
				|| $item['qty'] <= 0
				) {
				throw new \Exception( esc_html__( 'Invalid item.', 'woocommerce' ) );
			}
		}
	}
}
PK     [1]M    :  DataStores/Fulfillments/FulfillmentsDataStoreInterface.phpnu         <?php
/**
 * Fulfillments Data Store Interface
 */

declare( strict_types=1 );

namespace Automattic\WooCommerce\Internal\DataStores\Fulfillments;

use Automattic\WooCommerce\Internal\Fulfillments\Fulfillment;

/**
 * Interface FulfillmentsDataStoreInterface
 *
 * @package Automattic\WooCommerce\Internal\DataStores\Fulfillments
 */
interface FulfillmentsDataStoreInterface {
	/**
	 * Read the fulfillment data.
	 *
	 * @param string $entity_type The entity type.
	 * @param string $entity_id The entity ID.
	 *
	 * @return Fulfillment[] Fulfillment object.
	 */
	public function read_fulfillments( string $entity_type, string $entity_id ): array;
}
PK     [1]AN  N  =  DataStores/StockNotifications/StockNotificationsDataStore.phpnu         <?php
/**
 * StockNotificationsDataStore class file.
 */

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\DataStores\StockNotifications;

use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\StockNotifications\Notification;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;

defined( 'ABSPATH' ) || exit;

/**
 * The Stock Notifications Data Store.
 */
class StockNotificationsDataStore implements \WC_Object_Data_Store_Interface {

	/**
	 * The database util object to use.
	 *
	 * @var DatabaseUtil
	 */
	protected DatabaseUtil $database_util;

	/**
	 * Handles custom metadata in the wc_stock_notificationmeta table.
	 *
	 * @var StockNotificationsMetaDataStore
	 */
	protected StockNotificationsMetaDataStore $data_store_meta;

	/**
	 * Initialize.
	 *
	 * @internal
	 *
	 * @param StockNotificationsMetaDataStore $data_store_meta The data store meta instance to use.
	 * @param DatabaseUtil                    $database_util   The database util instance to use.
	 *
	 * @return void
	 */
	final public function init( StockNotificationsMetaDataStore $data_store_meta, DatabaseUtil $database_util ) {
		$this->data_store_meta = $data_store_meta;
		$this->database_util   = $database_util;
	}

	/**
	 * Get the stock notifications table name.
	 *
	 * @return string
	 */
	public function get_table_name(): string {
		global $wpdb;
		return $wpdb->prefix . 'wc_stock_notifications';
	}

	/**
	 * Get the stock notifications meta table name.
	 *
	 * @return string
	 */
	public function get_meta_table_name(): string {
		return $this->data_store_meta->get_table_name();
	}

	/**
	 * Get the database schema.
	 *
	 * @return string
	 */
	public function get_database_schema(): string {

		if ( ! Constants::is_true( 'WOOCOMMERCE_BIS_ALPHA_ENABLED' ) ) {
			return '';
		}

		global $wpdb;

		$collate = $wpdb->has_cap( 'collation' ) ? $wpdb->get_charset_collate() : '';

		$table_name       = $this->get_table_name();
		$meta_table_name  = $this->get_meta_table_name();
		$max_index_length = $this->database_util->get_max_index_length();

		$sql = "
CREATE TABLE $table_name (
	id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
	product_id bigint(20) unsigned NOT NULL,
	user_id bigint(20) unsigned NOT NULL,
	user_email varchar(100) NOT NULL,
	status varchar(20) NOT NULL DEFAULT 'pending',
	date_created_gmt datetime NULL,
	date_modified_gmt datetime NULL,
	date_confirmed_gmt datetime NULL,
	date_last_attempt_gmt datetime NULL,
	date_notified_gmt datetime NULL,
	date_cancelled_gmt datetime NULL,
	cancellation_source varchar(30) NULL,
	PRIMARY KEY  (id),
	KEY product_status_attempt (product_id, status, date_last_attempt_gmt, id),
	KEY user_lookup (user_id, product_id, status),
	KEY email_lookup (user_email, product_id, status)
) $collate;
CREATE TABLE $meta_table_name (
	id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
	notification_id bigint(20) unsigned NOT NULL,
	meta_key varchar(255) NULL,
	meta_value longtext NULL,
	PRIMARY KEY  (id),
	KEY notification_id (notification_id),
	KEY meta_key (meta_key($max_index_length))
) $collate;
		";

		return $sql;
	}

	/**
	 * Filter the raw meta data.
	 *
	 * This is required due to the use of the WC_Data::read_meta_data() method.
	 * It's a post-specific method that used to filter internal meta data.
	 * For custom tables, technically there is no internal meta data,
	 * so this method is a no-op.
	 *
	 * @param Notification $notification  The data object to filter.
	 * @param array        $raw_meta_data The raw meta data to filter.
	 * @return array
	 */
	public function filter_raw_meta_data( &$notification, $raw_meta_data ): array {
		return $raw_meta_data;
	}

	/**
	 * Get the internal meta keys.
	 *
	 * Required for the use of the WC_Data::is_internal_meta_key() method.
	 * It's a no-op for custom tables.
	 *
	 * @return array
	 */
	public function get_internal_meta_keys(): array {
		return array();
	}

	/**
	 * Create a new stock notification.
	 *
	 * @param Notification $notification The data object to create.
	 * @return int|\WP_Error The notification ID on success. WP_Error on failure.
	 */
	public function create( &$notification ) {
		global $wpdb;

		// Fill in created and modified dates.
		if ( ! $notification->get_date_created( 'edit' ) ) {
			$notification->set_date_created( time() );
		}
		if ( ! $notification->get_date_modified( 'edit' ) ) {
			$notification->set_date_modified( time() );
		}

		$insert = $wpdb->insert(
			$this->get_table_name(),
			array(
				'product_id'            => $notification->get_product_id( 'edit' ),
				'user_id'               => $notification->get_user_id( 'edit' ),
				'user_email'            => $notification->get_user_email( 'edit' ),
				'status'                => $notification->get_status( 'edit' ),
				'date_created_gmt'      => gmdate( 'Y-m-d H:i:s', $notification->get_date_created( 'edit' )->getTimestamp() ),
				'date_modified_gmt'     => gmdate( 'Y-m-d H:i:s', $notification->get_date_modified( 'edit' )->getTimestamp() ),
				'date_confirmed_gmt'    => $notification->get_date_confirmed( 'edit' ) ? gmdate( 'Y-m-d H:i:s', $notification->get_date_confirmed( 'edit' )->getTimestamp() ) : null,
				'date_last_attempt_gmt' => $notification->get_date_last_attempt( 'edit' ) ? gmdate( 'Y-m-d H:i:s', $notification->get_date_last_attempt( 'edit' )->getTimestamp() ) : null,
				'date_notified_gmt'     => $notification->get_date_notified( 'edit' ) ? gmdate( 'Y-m-d H:i:s', $notification->get_date_notified( 'edit' )->getTimestamp() ) : null,
				'date_cancelled_gmt'    => $notification->get_date_cancelled( 'edit' ) ? gmdate( 'Y-m-d H:i:s', $notification->get_date_cancelled( 'edit' )->getTimestamp() ) : null,
				'cancellation_source'   => $notification->get_cancellation_source( 'edit' ),
			),
			array(
				'%d',
				'%d',
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
			)
		);

		if ( false === $insert ) {
			return new \WP_Error( 'db_insert_error', 'Could not insert stock notification into the database.' );
		}

		$notification_id = (int) $wpdb->insert_id;
		$notification->set_id( $notification_id );
		$notification->save_meta_data();
		$notification->apply_changes();

		return $notification->get_id();
	}

	/**
	 * Read a stock notification.
	 *
	 * @param Notification $notification The data object to read.
	 *
	 * @throws \Exception If the stock notification is not found.
	 *
	 * @return void
	 */
	public function read( &$notification ) {
		global $wpdb;

		if ( 0 === $notification->get_id() ) {
			throw new \Exception( 'Invalid notification ID.' );
		}

		$data = $wpdb->get_row(
			$wpdb->prepare(
				'SELECT * FROM %i WHERE id = %d',
				$this->get_table_name(),
				$notification->get_id()
			)
		);

		if ( ! $data ) {
			throw new \Exception( 'Stock notification not found' );
		}

		$notification->set_props(
			array(
				'id'                  => $data->id,
				'product_id'          => $data->product_id,
				'user_id'             => $data->user_id,
				'user_email'          => $data->user_email,
				'status'              => $data->status,
				'date_created'        => wc_string_to_timestamp( $data->date_created_gmt ),
				'date_modified'       => wc_string_to_timestamp( $data->date_modified_gmt ),
				'date_confirmed'      => wc_string_to_timestamp( $data->date_confirmed_gmt ),
				'date_last_attempt'   => wc_string_to_timestamp( $data->date_last_attempt_gmt ),
				'date_notified'       => wc_string_to_timestamp( $data->date_notified_gmt ),
				'date_cancelled'      => wc_string_to_timestamp( $data->date_cancelled_gmt ),
				'cancellation_source' => $data->cancellation_source,
			)
		);

		$notification->read_meta_data();
		$notification->set_object_read( true );
	}

	/**
	 * Update a stock notification.
	 *
	 * @param Notification $notification The data object to update.
	 * @return int|\WP_Error The number of rows updated or WP_Error on failure.
	 */
	public function update( &$notification ) {
		global $wpdb;

		if ( 0 === $notification->get_id() ) {
			return new \WP_Error( 'invalid_stock_notification', 'Invalid notification ID.' );
		}

		$changes = $notification->get_changes();
		$result  = 0;

		if ( array_intersect( array( 'product_id', 'user_id', 'user_email', 'status', 'date_modified', 'date_confirmed', 'date_last_attempt', 'date_notified', 'date_cancelled', 'cancellation_source' ), array_keys( $changes ) ) ) {

			if ( ! array_key_exists( 'date_modified', $changes ) ) {
				$notification->set_date_modified( time() );
			}

			$result = $wpdb->update(
				$this->get_table_name(),
				array(
					'product_id'            => $notification->get_product_id( 'edit' ),
					'user_id'               => $notification->get_user_id( 'edit' ),
					'user_email'            => $notification->get_user_email( 'edit' ),
					'status'                => $notification->get_status( 'edit' ),
					'date_created_gmt'      => $notification->get_date_created( 'edit' ) ? gmdate( 'Y-m-d H:i:s', $notification->get_date_created( 'edit' )->getTimestamp() ) : null,
					'date_modified_gmt'     => gmdate( 'Y-m-d H:i:s', $notification->get_date_modified( 'edit' )->getTimestamp() ),
					'date_confirmed_gmt'    => $notification->get_date_confirmed( 'edit' ) ? gmdate( 'Y-m-d H:i:s', $notification->get_date_confirmed( 'edit' )->getTimestamp() ) : null,
					'date_last_attempt_gmt' => $notification->get_date_last_attempt( 'edit' ) ? gmdate( 'Y-m-d H:i:s', $notification->get_date_last_attempt( 'edit' )->getTimestamp() ) : null,
					'date_notified_gmt'     => $notification->get_date_notified( 'edit' ) ? gmdate( 'Y-m-d H:i:s', $notification->get_date_notified( 'edit' )->getTimestamp() ) : null,
					'date_cancelled_gmt'    => $notification->get_date_cancelled( 'edit' ) ? gmdate( 'Y-m-d H:i:s', $notification->get_date_cancelled( 'edit' )->getTimestamp() ) : null,
					'cancellation_source'   => $notification->get_cancellation_source( 'edit' ),
				),
				array( 'id' => $notification->get_id() ),
				array( '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ),
				array( '%d' )
			);

			if ( false === $result ) {
				return new \WP_Error( 'db_update_error', 'Could not update stock notification in the database.' );
			}

			if ( 0 === $result ) {
				return new \WP_Error( 'db_update_error', 'Invalid notification ID.' );
			}
		}

		$notification->save_meta_data();

		if ( $changes ) {
			$notification->apply_changes();
		}

		return $result;
	}

	/**
	 * Delete a stock notification.
	 *
	 * @param Notification $notification The data object to delete.
	 * @param array        $args         Additional arguments.
	 * @return void
	 */
	public function delete( &$notification, $args = array() ) {
		global $wpdb;

		$deleted = $wpdb->delete( $this->get_table_name(), array( 'id' => $notification->get_id() ), array( '%d' ) );

		if ( $deleted > 0 ) {
			$this->data_store_meta->delete_by_notification_id( $notification->get_id() );
		}
	}

	/**
	 * Add meta.
	 *
	 * @param Notification $notification The data object to add.
	 * @param \stdClass    $meta         The meta object to add (containing ->key and ->value).
	 * @return int|false The meta ID or false if the meta was not added.
	 */
	public function add_meta( &$notification, $meta ) {
		$add_meta = $this->data_store_meta->add_meta( $notification, $meta );
		$this->after_meta_change( $notification );
		return $add_meta ? $add_meta : false;
	}

	/**
	 * Read meta.
	 *
	 * @param Notification $notification The data object to read.
	 * @return array
	 */
	public function read_meta( &$notification ): array {
		$raw_meta_data = $this->data_store_meta->read_meta( $notification );
		return $this->filter_raw_meta_data( $notification, $raw_meta_data );
	}

	/**
	 * Update meta.
	 *
	 * @param Notification $notification The data object to update.
	 * @param \stdClass    $meta         The meta object to update (containing ->id, ->key and ->value).
	 * @return bool
	 */
	public function update_meta( &$notification, $meta ): bool {
		$update_meta = $this->data_store_meta->update_meta( $notification, $meta );
		$this->after_meta_change( $notification );
		return $update_meta;
	}

	/**
	 * Delete meta.
	 *
	 * @param Notification $notification The data object to delete.
	 * @param \stdClass    $meta         The meta object to delete (containing at least ->id).
	 * @return bool
	 */
	public function delete_meta( &$notification, $meta ): bool {
		$delete_meta = $this->data_store_meta->delete_meta( $notification, $meta );

		$this->after_meta_change( $notification );
		return $delete_meta;
	}

	/**
	 * Perform after meta change operations.
	 *
	 * @param Notification $notification The notification object.
	 * @return bool True if changes were applied, false otherwise.
	 */
	private function after_meta_change( &$notification ): bool {

		$current_time      = time();
		$current_date_time = new \WC_DateTime( "@$current_time", new \DateTimeZone( 'UTC' ) );

		$should_save =
			$notification->get_id() > 0
			&& $notification->get_date_modified( 'edit' ) < $current_date_time
			&& empty( $notification->get_changes() );

		if ( $should_save ) {
			$notification->set_date_modified( $current_time );
			$saved = $notification->save();
			return ! is_wp_error( $saved );
		}

		return false;
	}

	/**
	 * Query the stock notifications.
	 *
	 * @param array $args The arguments.
	 * @return array<int>|array<Notification>|int An array of notifications or the number of notifications.
	 */
	public function query( array $args ) {
		global $wpdb;

		$args = wp_parse_args(
			$args,
			array(
				'status'             => '',
				'product_id'         => array(),
				'user_id'            => 0,
				'user_email'         => '',
				'last_attempt_limit' => 0,
				'start_date'         => 0,
				'end_date'           => 0,
				'limit'              => -1,
				'offset'             => 0,
				'order_by'           => array( 'id' => 'ASC' ),
				'return'             => 'ids', // i.e. 'count', 'ids', 'objects'.
			)
		);

		$table  = $this->get_table_name();
		$select = 'id';
		if ( 'count' === $args['return'] ) {
			$select = 'COUNT(id)';
		} elseif ( 'objects' === $args['return'] ) {
			$select = '*';
		}

		// WHERE clauses.
		$where        = array();
		$where_values = array();

		if ( $args['status'] ) {
			$where[]        = 'status = %s';
			$where_values[] = esc_sql( $args['status'] );
		}

		if ( ! empty( $args['product_id'] ) ) {
			$product_ids  = array_map( 'absint', (array) $args['product_id'] );
			$where[]      = 'product_id IN (' . implode( ',', array_fill( 0, count( $product_ids ), '%d' ) ) . ')';
			$where_values = array_merge( $where_values, $product_ids );
		}

		if ( $args['user_id'] ) {
			$where[]        = 'user_id = %d';
			$where_values[] = absint( $args['user_id'] );
		}

		if ( $args['user_email'] ) {
			$where[]        = 'user_email = %s';
			$where_values[] = esc_sql( $args['user_email'] );
		}

		if ( $args['last_attempt_limit'] > 0 ) {
			$where[]        = '(date_last_attempt_gmt < %s OR date_last_attempt_gmt IS NULL)';
			$where_values[] = gmdate( 'Y-m-d H:i:s', $args['last_attempt_limit'] );
		}

		if ( $args['start_date'] ) {
			$where[]        = 'date_created_gmt >= %s';
			$where_values[] = esc_sql( $args['start_date'] );
		}

		if ( $args['end_date'] ) {
			$where[]        = 'date_created_gmt < %s';
			$where_values[] = esc_sql( $args['end_date'] );
		}

		// ORDER BY clauses.
		$order_by         = '';
		$order_by_clauses = array();

		if ( $args['order_by'] && is_array( $args['order_by'] ) ) {
			foreach ( $args['order_by'] as $what => $how ) {
				$order_by_clauses[] = $table . '.' . esc_sql( strval( $what ) ) . ' ' . esc_sql( strval( $how ) );
			}
		}

		// Assemble the query.
		$where    = implode( ' AND ', $where );
		$where    = $where ? ' WHERE ' . $where : '';
		$order_by = ! empty( $order_by_clauses ) ? ' ORDER BY ' . implode( ', ', $order_by_clauses ) : '';
		$limit    = $args['limit'] > 0 ? ' LIMIT ' . absint( $args['limit'] ) : '';
		$offset   = $args['offset'] > 0 ? ' OFFSET ' . absint( $args['offset'] ) : '';
		$sql      = "SELECT $select FROM $table $where $order_by $limit $offset";

		// Prepare the query.
		$prepared_sql = empty( $where_values ) ? $sql : $wpdb->prepare( $sql, $where_values ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

		// Execute the query.
		if ( 'count' === $args['return'] ) {
			return (int) $wpdb->get_var( $prepared_sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		}

		$results = $wpdb->get_results( $prepared_sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		if ( empty( $results ) || ! is_array( $results ) ) {
			return array();
		}

		if ( 'objects' === $args['return'] ) {

			return array_map(
				function ( $result ) {
					return new Notification( $result );
				},
				$results
			);
		}

		return array_map(
			function ( $result ) {
				return absint( $result['id'] );
			},
			$results
		);
	}

	/**
	 * Check if the product has active notifications.
	 *
	 * @param array<int> $product_ids The product IDs.
	 * @return bool True if the product has active notifications, false otherwise.
	 */
	public function product_has_active_notifications( array $product_ids ): bool {
		global $wpdb;

		$product_ids = array_filter( array_map( 'absint', $product_ids ) );
		if ( empty( $product_ids ) ) {
			return false;
		}

		$table    = $this->get_table_name();
		$format   = array_fill( 0, count( $product_ids ), '%d' );
		$query_in = '(' . implode( ',', $format ) . ')';
		$sql      = $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
			"SELECT 1 FROM %i WHERE product_id IN $query_in AND status = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			array( $table, ...$product_ids, NotificationStatus::ACTIVE )
		);
		return (int) $wpdb->get_var( $sql ) > 0; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
	}

	/**
	 * Check if a notification exists by email.
	 *
	 * @param int    $product_id The product ID.
	 * @param string $email The email address.
	 * @return bool True if the notification exists, false otherwise.
	 */
	public function notification_exists_by_email( int $product_id, string $email ): bool {

		if ( ! is_email( $email ) ) {
			return false;
		}

		global $wpdb;

		$table = $this->get_table_name();
		$sql   = $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
			'SELECT 1 FROM %i WHERE product_id = %d AND user_email = %s AND status IN (%s, %s) LIMIT 1', // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			array( $table, $product_id, $email, NotificationStatus::ACTIVE, NotificationStatus::PENDING )
		);
		return (int) $wpdb->get_var( $sql ) > 0; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
	}

	/**
	 * Check if a notification exists by user ID.
	 *
	 * @param int $product_id The product ID.
	 * @param int $user_id The user ID.
	 * @return bool True if the notification exists, false otherwise.
	 */
	public function notification_exists_by_user_id( int $product_id, int $user_id ): bool {

		if ( 0 === $user_id ) {
			return false;
		}

		global $wpdb;

		$table = $this->get_table_name();
		$sql   = $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
			'SELECT 1 FROM %i WHERE product_id = %d AND user_id = %d AND status IN (%s, %s) LIMIT 1', // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			array( $table, $product_id, $user_id, NotificationStatus::ACTIVE, NotificationStatus::PENDING )
		);
		return (int) $wpdb->get_var( $sql ) > 0; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
	}

	/**
	 * Get distinct notification creation dates.
	 *
	 * @return array
	 */
	public function get_distinct_dates() {

		global $wpdb;

		$results = $wpdb->get_results(
			$wpdb->prepare(
				'SELECT DISTINCT
					YEAR(date_created_gmt) AS year,
					MONTH(date_created_gmt) AS month
				FROM %i
				ORDER BY year DESC, month DESC',
				$this->get_table_name()
			)
		);

		return $results;
	}
}
PK     [1]    A  DataStores/StockNotifications/StockNotificationsMetaDataStore.phpnu         <?php
/**
 * StockNotificationsMetaDataStore class file.
 */

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\DataStores\StockNotifications;

use Automattic\WooCommerce\Internal\DataStores\CustomMetaDataStore;

defined( 'ABSPATH' ) || exit;

/**
 * Mimics a WP metadata (i.e. add_metadata(), get_metadata() and friends) implementation using a custom table.
 */
class StockNotificationsMetaDataStore extends CustomMetaDataStore {

	/**
	 * Returns the name of the table used for storage.
	 *
	 * @return string
	 */
	public function get_table_name() {
		global $wpdb;
		return $wpdb->prefix . 'wc_stock_notificationmeta';
	}

	/**
	 * Returns the name of the field/column used for identifiying metadata entries.
	 *
	 * @return string
	 */
	protected function get_meta_id_field() {
		return 'id';
	}

	/**
	 * Returns the name of the field/column used for associating meta with objects.
	 *
	 * @return string
	 */
	protected function get_object_id_field() {
		return 'notification_id';
	}

	/**
	 * Delete by notification ID.
	 *
	 * @param int $notification_id The notification ID.
	 * @return bool True if the metadata were deleted, false otherwise.
	 */
	public function delete_by_notification_id( $notification_id ) {
		global $wpdb;

		$table  = $this->get_table_name();
		$result = $wpdb->delete(
			$table,
			array( 'notification_id' => $notification_id ),
			array( '%d' )
		);

		return false === $result ? false : true;
	}
}
PK     [1]T4}    .  DataStores/Orders/OrdersTableDataStoreMeta.phpnu         <?php
/**
 * OrdersTableDataStoreMeta class file.
 */

namespace Automattic\WooCommerce\Internal\DataStores\Orders;

use Automattic\WooCommerce\Caching\WPCacheEngine;
use Automattic\WooCommerce\Internal\DataStores\CustomMetaDataStore;
use Automattic\WooCommerce\Utilities\OrderUtil;

/**
 * Mimics a WP metadata (i.e. add_metadata(), get_metadata() and friends) implementation using a custom table.
 */
class OrdersTableDataStoreMeta extends CustomMetaDataStore {

	/**
	 * Returns the cache group to store cached data in.
	 *
	 * @return string
	 */
	protected function get_cache_group() {
		return 'orders_meta';
	}

	/**
	 * Returns the name of the table used for storage.
	 *
	 * @return string
	 */
	protected function get_table_name() {
		return OrdersTableDataStore::get_meta_table_name();
	}

	/**
	 * Returns the name of the field/column used for associating meta with objects.
	 *
	 * @return string
	 */
	protected function get_object_id_field() {
		return 'order_id';
	}

	// @phpcs:disable Universal.NamingConventions.NoReservedKeywordParameterNames.objectFound

	/**
	 * Deletes meta based on meta ID.
	 *
	 * @param  \WC_Data  $object WC_Data object.
	 * @param  \stdClass $meta (containing at least ->id).
	 *
	 * @return bool
	 */
	public function delete_meta( &$object, $meta ): bool {
		$successful = parent::delete_meta( $object, $meta );
		if ( $successful ) {
			$this->clear_cached_data( array( $object->get_id() ) );
		}

		return $successful;
	}

	/**
	 * Add new piece of meta.
	 *
	 * @param  \WC_Data  $object WC_Data object.
	 * @param  \stdClass $meta (containing ->key and ->value).
	 *
	 * @return int|false meta ID
	 */
	public function add_meta( &$object, $meta ) {
		$insert_id = parent::add_meta( $object, $meta );
		if ( false !== $insert_id ) {
			$this->clear_cached_data( array( $object->get_id() ) );
		}

		return $insert_id;
	}

	/**
	 * Update meta.
	 *
	 * @param  \WC_Data  $object WC_Data object.
	 * @param  \stdClass $meta (containing ->id, ->key and ->value).
	 *
	 * @return bool
	 */
	public function update_meta( &$object, $meta ): bool {
		$is_successful = parent::update_meta( $object, $meta );
		if ( $is_successful ) {
			$this->clear_cached_data( array( $object->get_id() ) );
		}

		return $is_successful;
	}

	// @phpcs:enable Universal.NamingConventions.NoReservedKeywordParameterNames.objectFound

	/**
	 * Return order meta data for multiple IDs. Results are cached.
	 *
	 * @param array $object_ids List of order IDs.
	 *
	 * @return \stdClass[][] An array, keyed by the object IDs, containing arrays of raw meta data for each object.
	 */
	public function get_meta_data_for_object_ids( array $object_ids ): array {
		if ( ! OrderUtil::custom_orders_table_datastore_cache_enabled() ) {
			return parent::get_meta_data_for_object_ids( $object_ids );
		}

		$meta_data  = $this->get_meta_data_for_object_ids_from_cache( $object_ids );
		$object_ids = array_diff( $object_ids, array_keys( $meta_data ) );

		if ( empty( $object_ids ) ) {
			return $meta_data;
		}

		$db_meta_data = parent::get_meta_data_for_object_ids( $object_ids );
		$this->set_meta_data_for_objects_in_cache( $db_meta_data );

		return $db_meta_data + $meta_data;
	}

	/**
	 * Retrieve raw object meta from cache for the given a set of IDs.
	 *
	 * @param int[] $object_ids List of object IDs.
	 *
	 * @return \stdClass[][] An array, keyed by the object IDs, containing arrays of raw meta data for each object.
	 */
	private function get_meta_data_for_object_ids_from_cache( array $object_ids ): array {
		$cache_engine = wc_get_container()->get( WPCacheEngine::class );
		$meta_data    = $cache_engine->get_cached_objects( $object_ids, $this->get_cache_group() );

		return array_filter( $meta_data );
	}

	/**
	 * Store the raw meta data for a set of objects in cache.
	 *
	 * @param \stdClass[][] $meta_data An array, keyed by the object IDs, containing arrays of raw meta data for each object.
	 *
	 * @return void
	 */
	private function set_meta_data_for_objects_in_cache( array $meta_data ) {
		$cache_engine = wc_get_container()->get( WPCacheEngine::class );
		$cache_engine->cache_objects( $meta_data, 0, $this->get_cache_group() );
	}

	/**
	 * Delete cached meta data for the given object_ids.
	 *
	 * @internal This method should only be used by internally and in cases where the CRUD operations of this datastore
	 *           are bypassed for performance purposes. This interface is not guaranteed.
	 *
	 * @param array $object_ids The object_ids to delete cache for.
	 *
	 * @return bool[] Array of return values, grouped by the object_id. Each value is either true on success, or false
	 *                if the contents were not deleted.
	 */
	public function clear_cached_data( array $object_ids ): array {
		if ( ! OrderUtil::custom_orders_table_datastore_cache_enabled() ) {
			return array_fill_keys( $object_ids, true );
		}

		$cache_engine  = wc_get_container()->get( WPCacheEngine::class );
		$return_values = array();
		foreach ( $object_ids as $object_id ) {
			$return_values[ $object_id ] = $cache_engine->delete_cached_object( $object_id, $this->get_cache_group() );
		}
		return $return_values;
	}

	/**
	 * Invalidate all the cache used by this data store.
	 *
	 * @internal This method should only be used by internally and in cases where the CRUD operations of this datastore
	 *           are bypassed for performance purposes. This interface is not guaranteed.
	 *
	 * @return bool Whether the cache as fully invalidated.
	 */
	public function clear_all_cached_data(): bool {
		if ( ! OrderUtil::custom_orders_table_datastore_cache_enabled() ) {
			return true;
		}

		$cache_engine = wc_get_container()->get( WPCacheEngine::class );

		return $cache_engine->delete_cache_group( $this->get_cache_group() );
	}
}
PK     [1]L	8  8  0  DataStores/Orders/OrdersTableRefundDataStore.phpnu         <?php
/**
 * Order refund data store. Refunds are based on orders (essentially negative orders) but there is slight difference in how we save them.
 * For example, order save hooks etc can't be fired when saving refund, so we need to do it a separate datastore.
 */

namespace Automattic\WooCommerce\Internal\DataStores\Orders;

use \WC_Cache_Helper;
use \WC_Meta_Data;

/**
 * Class OrdersTableRefundDataStore.
 */
class OrdersTableRefundDataStore extends OrdersTableDataStore {

	/**
	 * Data stored in meta keys, but not considered "meta" for refund.
	 *
	 * @var string[]
	 */
	protected $internal_meta_keys = array(
		'_refund_amount',
		'_refund_reason',
		'_refunded_by',
		'_refunded_payment',
	);

	/**
	 * We do not have and use all the getters and setters from OrderTableDataStore, so we only select the props we actually need.
	 *
	 * @var \string[][]
	 */
	protected $operational_data_column_mapping = array(
		'id'                        => array( 'type' => 'int' ),
		'order_id'                  => array( 'type' => 'int' ),
		'woocommerce_version'       => array(
			'type' => 'string',
			'name' => 'version',
		),
		'prices_include_tax'        => array(
			'type' => 'bool',
			'name' => 'prices_include_tax',
		),
		'coupon_usages_are_counted' => array(
			'type' => 'bool',
			'name' => 'recorded_coupon_usage_counts',
		),
		'shipping_tax_amount'       => array(
			'type' => 'decimal',
			'name' => 'shipping_tax',
		),
		'shipping_total_amount'     => array(
			'type' => 'decimal',
			'name' => 'shipping_total',
		),
		'discount_tax_amount'       => array(
			'type' => 'decimal',
			'name' => 'discount_tax',
		),
		'discount_total_amount'     => array(
			'type' => 'decimal',
			'name' => 'discount_total',
		),
	);

	/**
	 * Delete a refund order from database.
	 *
	 * @param \WC_Order $refund Refund object to delete.
	 * @param array     $args Array of args to pass to the delete method.
	 *
	 * @return void
	 */
	public function delete( &$refund, $args = array() ) {
		$refund_id = $refund->get_id();
		if ( ! $refund_id ) {
			return;
		}

		$refund_cache_key = WC_Cache_Helper::get_cache_prefix( 'orders' ) . 'refunds' . $refund->get_parent_id();
		wp_cache_delete( $refund_cache_key, 'orders' );

		$this->delete_order_data_from_custom_order_tables( $refund_id );
		$refund->set_id( 0 );

		$orders_table_is_authoritative = $refund->get_data_store()->get_current_class_name() === self::class;

		if ( $orders_table_is_authoritative ) {
			$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
			if ( $data_synchronizer->data_sync_is_enabled() ) {
				// Delete the associated post, which in turn deletes order items, etc. through {@see WC_Post_Data}.
				// Once we stop creating posts for orders, we should do the cleanup here instead.
				wp_delete_post( $refund_id );
			} else {
				$this->handle_order_deletion_with_sync_disabled( $refund_id );
			}
		}
	}

	/**
	 * Helper method to set refund props.
	 *
	 * @param \WC_Order_Refund $refund Refund object.
	 * @param object           $data   DB data object.
	 *
	 * @since 8.0.0
	 */
	protected function set_order_props_from_data( &$refund, $data ) {
		parent::set_order_props_from_data( $refund, $data );
		foreach ( $data->meta_data as $meta ) {
			switch ( $meta->meta_key ) {
				case '_refund_amount':
					$refund->set_amount( $meta->meta_value );
					break;
				case '_refunded_by':
					$refund->set_refunded_by( $meta->meta_value );
					break;
				case '_refunded_payment':
					$refund->set_refunded_payment( wc_string_to_bool( $meta->meta_value ) );
					break;
				case '_refund_reason':
					$refund->set_reason( $meta->meta_value );
					break;
			}
		}
	}

	/**
	 * Method to create a refund in the database.
	 *
	 * @param \WC_Abstract_Order $refund Refund object.
	 */
	public function create( &$refund ) {
		$refund->set_status( 'completed' ); // Refund are always marked completed.
		$this->persist_save( $refund );
	}

	/**
	 * Update refund in database.
	 *
	 * @param \WC_Order $refund Refund object.
	 */
	public function update( &$refund ) {
		$this->persist_updates( $refund );
		$refund->apply_changes();

		// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
		/**
		 * This action is documented in woocommerce/includes/data-stores/class-wc-order-refund-data-store-cpt.php.
		 */
		do_action( 'woocommerce_update_order_refund', $refund->get_id(), $refund );
		// phpcs:enable
	}

	/**
	 * Helper method that updates post meta based on an refund object.
	 * Mostly used for backwards compatibility purposes in this datastore.
	 *
	 * @param \WC_Order $refund Refund object.
	 */
	public function update_order_meta( &$refund ) {
		parent::update_order_meta( $refund );

		// Update additional props.
		$updated_props     = array();
		$meta_key_to_props = array(
			'_refund_amount'    => 'amount',
			'_refunded_by'      => 'refunded_by',
			'_refunded_payment' => 'refunded_payment',
			'_refund_reason'    => 'reason',
		);

		$props_to_update = $this->get_props_to_update( $refund, $meta_key_to_props );
		foreach ( $props_to_update as $meta_key => $prop ) {
			$meta_object        = new WC_Meta_Data();
			$meta_object->key   = $meta_key;
			$meta_object->value = $refund->{"get_$prop"}( 'edit' );
			$existing_meta      = $this->data_store_meta->get_metadata_by_key( $refund, $meta_key );
			if ( $existing_meta ) {
				$existing_meta   = $existing_meta[0];
				$meta_object->id = $existing_meta->id;
				$this->update_meta( $refund, $meta_object );
			} else {
				$this->add_meta( $refund, $meta_object );
			}
			$updated_props[] = $prop;
		}

		/**
		 * Fires after updating meta for a order refund.
		 *
		 * @since 2.7.0
		 */
		do_action( 'woocommerce_order_refund_object_updated_props', $refund, $updated_props );
	}

	/**
	 * Get a title for the new post type.
	 *
	 * @return string
	 */
	protected function get_post_title() {
		return sprintf(
		/* translators: %s: Order date */
			__( 'Refund &ndash; %s', 'woocommerce' ),
			( new \DateTime( 'now' ) )->format( _x( 'M d, Y @ h:i A', 'Order date parsed by DateTime::format', 'woocommerce' ) ) // phpcs:ignore WordPress.WP.I18n.MissingTranslatorsComment, WordPress.WP.I18n.UnorderedPlaceholdersText
		);
	}


	/**
	 * Returns data store object to use backfilling.
	 *
	 * @return \WC_Order_Refund_Data_Store_CPT
	 */
	protected function get_post_data_store_for_backfill() {
		return new \WC_Order_Refund_Data_Store_CPT();
	}

}
PK     [1][_J  J  *  DataStores/Orders/OrdersTableMetaQuery.phpnu         <?php
namespace Automattic\WooCommerce\Internal\DataStores\Orders;

defined( 'ABSPATH' ) || exit;

/**
 * Class used to implement meta queries for the orders table datastore via {@see OrdersTableQuery}.
 * Heavily inspired by WordPress' own `WP_Meta_Query` for backwards compatibility reasons.
 *
 * Parts of the implementation have been adapted from {@link https://core.trac.wordpress.org/browser/tags/6.0.1/src/wp-includes/class-wp-meta-query.php}.
 */
class OrdersTableMetaQuery {

	/**
	 * List of non-numeric SQL operators used for comparisons in meta queries.
	 *
	 * @var array
	 */
	private const NON_NUMERIC_OPERATORS = array(
		'=',
		'!=',
		'LIKE',
		'NOT LIKE',
		'IN',
		'NOT IN',
		'EXISTS',
		'NOT EXISTS',
		'RLIKE',
		'REGEXP',
		'NOT REGEXP',
	);

	/**
	 * List of numeric SQL operators used for comparisons in meta queries.
	 *
	 * @var array
	 */
	private const NUMERIC_OPERATORS = array(
		'>',
		'>=',
		'<',
		'<=',
		'BETWEEN',
		'NOT BETWEEN',

	);

	/**
	 * Prefix used when generating aliases for the metadata table.
	 *
	 * @var string
	 */
	private const ALIAS_PREFIX = 'meta';

	/**
	 * Name of the main orders table.
	 *
	 * @var string
	 */
	private $meta_table = '';

	/**
	 * Name of the metadata table.
	 *
	 * @var string
	 */
	private $orders_table = '';

	/**
	 * Sanitized `meta_query`.
	 *
	 * @var array
	 */
	private $queries = array();

	/**
	 * Flat list of clauses by name.
	 *
	 * @var array
	 */
	private $flattened_clauses = array();

	/**
	 * JOIN clauses to add to the main SQL query.
	 *
	 * @var array
	 */
	private $join = array();

	/**
	 * WHERE clauses to add to the main SQL query.
	 *
	 * @var array
	 */
	private $where = array();

	/**
	 * Table aliases in use by the meta query. Used to optimize JOINs when possible.
	 *
	 * @var array
	 */
	private $table_aliases = array();

	/**
	 * Constructor.
	 *
	 * @param OrdersTableQuery $q The main query being performed.
	 */
	public function __construct( OrdersTableQuery $q ) {
		$meta_query = $q->get( 'meta_query' );

		if ( ! $meta_query ) {
			return;
		}

		$this->queries = $this->sanitize_meta_query( $meta_query );

		$this->meta_table   = $q->get_table_name( 'meta' );
		$this->orders_table = $q->get_table_name( 'orders' );

		$this->build_query();
	}

	/**
	 * Returns JOIN and WHERE clauses to be appended to the main SQL query.
	 *
	 * @return array {
	 *     @type string $join  JOIN clause.
	 *     @type string $where WHERE clause.
	 * }
	 */
	public function get_sql_clauses(): array {
		return array(
			'join'  => $this->sanitize_join( $this->join ),
			'where' => $this->flatten_where_clauses( $this->where ),
		);
	}

	/**
	 * Returns a list of names (corresponding to meta_query clauses) that can be used as an 'orderby' arg.
	 *
	 * @since 7.4
	 *
	 * @return array
	 */
	public function get_orderby_keys(): array {
		if ( ! $this->flattened_clauses ) {
			return array();
		}

		$keys   = array();
		$keys[] = 'meta_value';
		$keys[] = 'meta_value_num';

		$first_clause = reset( $this->flattened_clauses );
		if ( $first_clause && ! empty( $first_clause['key'] ) ) {
			$keys[] = $first_clause['key'];
		}

		$keys = array_merge(
			$keys,
			array_keys( $this->flattened_clauses )
		);

		return $keys;
	}

	/**
	 * Returns an SQL fragment for the given meta_query key that can be used in an ORDER BY clause.
	 * Call {@see 'get_orderby_keys'} to obtain a list of valid keys.
	 *
	 * @since 7.4
	 *
	 * @param string $key The key name.
	 * @return string
	 *
	 * @throws \Exception When an invalid key is passed.
	 */
	public function get_orderby_clause_for_key( string $key ): string {
		$clause = false;

		if ( isset( $this->flattened_clauses[ $key ] ) ) {
			$clause = $this->flattened_clauses[ $key ];
		} else {
			$first_clause = reset( $this->flattened_clauses );

			if ( $first_clause && ! empty( $first_clause['key'] ) ) {
				if ( 'meta_value_num' === $key ) {
					return "{$first_clause['alias']}.meta_value+0";
				}

				if ( 'meta_value' === $key || $first_clause['key'] === $key ) {
					$clause = $first_clause;
				}
			}
		}

		if ( ! $clause ) {
			// translators: %s is a meta_query key.
			throw new \Exception( sprintf( __( 'Invalid meta_query clause key: %s.', 'woocommerce' ), $key ) );
		}

		return "CAST({$clause['alias']}.meta_value AS {$clause['cast']})";
	}

	/**
	 * Checks whether a given meta_query clause is atomic or not (i.e. not nested).
	 *
	 * @param array $arg The meta_query clause.
	 * @return boolean TRUE if atomic, FALSE otherwise.
	 */
	private function is_atomic( array $arg ): bool {
		return isset( $arg['key'] ) || isset( $arg['value'] );
	}

	/**
	 * Sanitizes the meta_query argument.
	 *
	 * @param array $q A meta_query array.
	 * @return array A sanitized meta query array.
	 */
	private function sanitize_meta_query( array $q ): array {
		$sanitized = array();

		foreach ( $q as $key => $arg ) {
			if ( 'relation' === $key ) {
				$relation = $arg;
			} elseif ( ! is_array( $arg ) ) {
				continue;
			} elseif ( $this->is_atomic( $arg ) ) {
				if ( isset( $arg['value'] ) && array() === $arg['value'] ) {
					unset( $arg['value'] );
				}

				$arg['compare']     = isset( $arg['compare'] ) ? strtoupper( $arg['compare'] ) : ( isset( $arg['value'] ) && is_array( $arg['value'] ) ? 'IN' : '=' );
				$arg['compare_key'] = isset( $arg['compare_key'] ) ? strtoupper( $arg['compare_key'] ) : ( isset( $arg['key'] ) && is_array( $arg['key'] ) ? 'IN' : '=' );

				if ( ! in_array( $arg['compare'], self::NON_NUMERIC_OPERATORS, true ) && ! in_array( $arg['compare'], self::NUMERIC_OPERATORS, true ) ) {
					$arg['compare'] = '=';
				}

				if ( ! in_array( $arg['compare_key'], self::NON_NUMERIC_OPERATORS, true ) ) {
					$arg['compare_key'] = '=';
				}

				$sanitized[ $key ]          = $arg;
				$sanitized[ $key ]['index'] = $key;
			} else {
				$sanitized_arg = $this->sanitize_meta_query( $arg );

				if ( $sanitized_arg ) {
					$sanitized[ $key ] = $sanitized_arg;
				}
			}
		}

		if ( $sanitized ) {
			$sanitized['relation'] = 1 === count( $sanitized ) ? 'OR' : $this->sanitize_relation( $relation ?? 'AND' );
		}

		return $sanitized;
	}

	/**
	 * Makes sure we use an AND or OR relation. Defaults to AND.
	 *
	 * @param string $relation An unsanitized relation prop.
	 * @return string
	 */
	private function sanitize_relation( string $relation ): string {
		if ( ! empty( $relation ) && 'OR' === strtoupper( $relation ) ) {
			return 'OR';
		}

		return 'AND';
	}

	/**
	 * Returns the correct type for a given meta type.
	 *
	 * @param string $type MySQL type.
	 * @return string MySQL type.
	 */
	private function sanitize_cast_type( string $type = '' ): string {
		$meta_type = strtoupper( $type );

		if ( ! $meta_type || ! preg_match( '/^(?:BINARY|CHAR|DATE|DATETIME|SIGNED|UNSIGNED|TIME|NUMERIC(?:\(\d+(?:,\s?\d+)?\))?|DECIMAL(?:\(\d+(?:,\s?\d+)?\))?)$/', $meta_type ) ) {
			return 'CHAR';
		}

		if ( 'NUMERIC' === $meta_type ) {
			$meta_type = 'SIGNED';
		}

		return $meta_type;
	}

	/**
	 * Makes sure a JOIN array does not have duplicates.
	 *
	 * @param array $join A JOIN array.
	 * @return array A sanitized JOIN array.
	 */
	private function sanitize_join( array $join ): array {
		return array_filter( array_unique( array_map( 'trim', $join ) ) );
	}

	/**
	 * Flattens a nested WHERE array.
	 *
	 * @param array $where A possibly nested WHERE array with AND/OR operators.
	 * @return string An SQL WHERE clause.
	 */
	private function flatten_where_clauses( $where ): string {
		if ( is_string( $where ) ) {
			return trim( $where );
		}

		$chunks   = array();
		$operator = $this->sanitize_relation( $where['operator'] ?? '' );

		foreach ( $where as $key => $w ) {
			if ( 'operator' === $key ) {
				continue;
			}

			$flattened = $this->flatten_where_clauses( $w );
			if ( $flattened ) {
				$chunks[] = $flattened;
			}
		}

		if ( $chunks ) {
			return '(' . implode( " {$operator} ", $chunks ) . ')';
		} else {
			return '';
		}
	}

	/**
	 * Builds all the required internal bits for this meta query.
	 *
	 * @return void
	 */
	private function build_query(): void {
		if ( ! $this->queries ) {
			return;
		}

		$queries     = $this->queries;
		$sql_where   = $this->process( $queries );
		$this->where = $sql_where;
	}

	/**
	 * Processes meta_query entries and generates the necessary table aliases, JOIN statements and WHERE conditions.
	 *
	 * @param array      $arg    A meta query.
	 * @param null|array $parent The parent of the element being processed.
	 * @return array A nested array of WHERE conditions.
	 */
	private function process( array &$arg, &$parent = null ): array {
		$where = array();

		if ( $this->is_atomic( $arg ) ) {
			$arg['alias'] = $this->find_or_create_table_alias_for_clause( $arg, $parent );
			$arg['cast']  = $this->sanitize_cast_type( $arg['type'] ?? '' );

			$where = array_filter(
				array(
					$this->generate_where_for_clause_key( $arg ),
					$this->generate_where_for_clause_value( $arg ),
				)
			);

			// Store clauses by their key for ORDER BY purposes.
			$flat_clause_key = is_int( $arg['index'] ) ? $arg['alias'] : $arg['index'];

			$unique_flat_key = $flat_clause_key;
			$i               = 1;
			while ( isset( $this->flattened_clauses[ $unique_flat_key ] ) ) {
				$unique_flat_key = $flat_clause_key . '-' . $i;
				++$i;
			}

			$this->flattened_clauses[ $unique_flat_key ] =& $arg;
		} else {
			// Nested.
			$relation = $arg['relation'];
			unset( $arg['relation'] );
			$chunks = array();
			foreach ( $arg as $index => &$clause ) {
				$chunks[] = $this->process( $clause, $arg );
			}

			// Merge chunks of the form OR(m) with the surrounding clause.
			if ( 1 === count( $chunks ) ) {
				$where = $chunks[0];
			} else {
				$where = array_merge(
					array(
						'operator' => $relation,
					),
					$chunks
				);
			}
		}

		return $where;
	}

	/**
	 * Generates a JOIN clause to handle an atomic meta_query clause.
	 *
	 * @param array  $clause An atomic meta_query clause.
	 * @param string $alias  Metadata table alias to use.
	 * @return string An SQL JOIN clause.
	 */
	private function generate_join_for_clause( array $clause, string $alias ): string {
		global $wpdb;

		if ( 'NOT EXISTS' === $clause['compare'] ) {
			if ( 'LIKE' === $clause['compare_key'] ) {
				return $wpdb->prepare(
					"LEFT JOIN {$this->meta_table} AS {$alias} ON ( {$this->orders_table}.id = {$alias}.order_id AND {$alias}.meta_key LIKE %s )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					'%' . $wpdb->esc_like( $clause['key'] ) . '%'
				);
			} else {
				return $wpdb->prepare(
					"LEFT JOIN {$this->meta_table} AS {$alias} ON ( {$this->orders_table}.id = {$alias}.order_id AND {$alias}.meta_key = %s )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					$clause['key']
				);
			}
		}

		return "INNER JOIN {$this->meta_table} AS {$alias} ON ( {$this->orders_table}.id = {$alias}.order_id )";
	}

	/**
	 * Finds a common table alias that the meta_query clause can use, or creates one.
	 *
	 * @param array $clause       An atomic meta_query clause.
	 * @param array $parent_query The parent query this clause is in.
	 * @return string A table alias for use in an SQL JOIN clause.
	 */
	private function find_or_create_table_alias_for_clause( array $clause, array $parent_query ): string {
		if ( ! empty( $clause['alias'] ) ) {
			return $clause['alias'];
		}

		$alias    = false;
		$siblings = array_filter(
			$parent_query,
			array( __CLASS__, 'is_atomic' )
		);

		foreach ( $siblings as $sibling ) {
			if ( empty( $sibling['alias'] ) ) {
				continue;
			}

			if ( $this->is_operator_compatible_with_shared_join( $clause, $sibling, $parent_query['relation'] ?? 'AND' ) ) {
				$alias = $sibling['alias'];
				break;
			}
		}

		if ( ! $alias ) {
			$alias                 = self::ALIAS_PREFIX . count( $this->table_aliases );
			$this->join[]          = $this->generate_join_for_clause( $clause, $alias );
			$this->table_aliases[] = $alias;
		}

		return $alias;
	}

	/**
	 * Checks whether two meta_query clauses can share a JOIN.
	 *
	 * @param array  $clause    An atomic meta_query clause.
	 * @param array  $sibling   An atomic meta_query clause.
	 * @param string $relation The relation involving both clauses.
	 * @return boolean TRUE if the clauses can share a table alias, FALSE otherwise.
	 */
	private function is_operator_compatible_with_shared_join( array $clause, array $sibling, string $relation = 'AND' ): bool {
		if ( ! $this->is_atomic( $clause ) || ! $this->is_atomic( $sibling ) ) {
			return false;
		}

		$valid_operators = array();

		if ( 'OR' === $relation ) {
			$valid_operators = array( '=', 'IN', 'BETWEEN', 'LIKE', 'REGEXP', 'RLIKE', '>', '>=', '<', '<=' );
		} elseif ( isset( $sibling['key'] ) && isset( $clause['key'] ) && $sibling['key'] === $clause['key'] ) {
			$valid_operators = array( '!=', 'NOT IN', 'NOT LIKE' );
		}

		return in_array( strtoupper( $clause['compare'] ), $valid_operators, true ) && in_array( strtoupper( $sibling['compare'] ), $valid_operators, true );
	}

	/**
	 * Generates an SQL WHERE clause for a given meta_query atomic clause based on its meta key.
	 * Adapted from WordPress' `WP_Meta_Query::get_sql_for_clause()` method.
	 *
	 * @param array $clause An atomic meta_query clause.
	 * @return string An SQL WHERE clause or an empty string if $clause is invalid.
	 */
	private function generate_where_for_clause_key( array $clause ): string {
		global $wpdb;

		if ( ! array_key_exists( 'key', $clause ) ) {
			return '';
		}

		if ( 'NOT EXISTS' === $clause['compare'] ) {
			return "{$clause['alias']}.order_id IS NULL";
		}

		$alias = $clause['alias'];

		$meta_compare_string_start = '';
		$meta_compare_string_end   = '';
		$subquery_alias            = '';
		if ( in_array( $clause['compare_key'], array( '!=', 'NOT IN', 'NOT LIKE', 'NOT EXISTS', 'NOT REGEXP' ), true ) ) {
			$i                     = count( $this->table_aliases );
			$subquery_alias        = self::ALIAS_PREFIX . $i;
			$this->table_aliases[] = $subquery_alias;

			$meta_compare_string_start  = 'NOT EXISTS (';
			$meta_compare_string_start .= "SELECT 1 FROM {$this->meta_table} {$subquery_alias} ";
			$meta_compare_string_start .= "WHERE {$subquery_alias}.order_id = {$alias}.order_id ";
			$meta_compare_string_end    = 'LIMIT 1';
			$meta_compare_string_end   .= ')';
		}

		switch ( $clause['compare_key'] ) {
			case '=':
			case 'EXISTS':
				$where = $wpdb->prepare( "$alias.meta_key = %s", trim( $clause['key'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				break;
			case 'LIKE':
				$meta_compare_value = '%' . $wpdb->esc_like( trim( $clause['key'] ) ) . '%';
				$where              = $wpdb->prepare( "$alias.meta_key LIKE %s", $meta_compare_value ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				break;
			case 'IN':
				$meta_compare_string = "$alias.meta_key IN (" . substr( str_repeat( ',%s', count( (array) $clause['key'] ) ), 1 ) . ')';
				$where               = $wpdb->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				break;
			case 'RLIKE':
			case 'REGEXP':
				$operator = $clause['compare_key'];
				if ( isset( $clause['type_key'] ) && 'BINARY' === strtoupper( $clause['type_key'] ) ) {
					$cast = 'BINARY';
				} else {
					$cast = '';
				}
				$where = $wpdb->prepare( "$alias.meta_key $operator $cast %s", trim( $clause['key'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				break;
			case '!=':
			case 'NOT EXISTS':
				$meta_compare_string = $meta_compare_string_start . "AND $subquery_alias.meta_key = %s " . $meta_compare_string_end;
				$where               = $wpdb->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				break;
			case 'NOT LIKE':
				$meta_compare_string = $meta_compare_string_start . "AND $subquery_alias.meta_key LIKE %s " . $meta_compare_string_end;

				$meta_compare_value = '%' . $wpdb->esc_like( trim( $clause['key'] ) ) . '%';
				$where              = $wpdb->prepare( $meta_compare_string, $meta_compare_value ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				break;
			case 'NOT IN':
				$array_subclause     = '(' . substr( str_repeat( ',%s', count( (array) $clause['key'] ) ), 1 ) . ') ';
				$meta_compare_string = $meta_compare_string_start . "AND $subquery_alias.meta_key IN " . $array_subclause . $meta_compare_string_end;
				$where               = $wpdb->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				break;
			case 'NOT REGEXP':
				$operator = $clause['compare_key'];
				if ( isset( $clause['type_key'] ) && 'BINARY' === strtoupper( $clause['type_key'] ) ) {
					$cast = 'BINARY';
				} else {
					$cast = '';
				}

				$meta_compare_string = $meta_compare_string_start . "AND $subquery_alias.meta_key REGEXP $cast %s " . $meta_compare_string_end;
				$where               = $wpdb->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				break;
			default:
				$where = '';
				break;
		}

		return $where;
	}

	/**
	 * Generates an SQL WHERE clause for a given meta_query atomic clause based on its meta value.
	 * Adapted from WordPress' `WP_Meta_Query::get_sql_for_clause()` method.
	 *
	 * @param array $clause An atomic meta_query clause.
	 * @return string An SQL WHERE clause or an empty string if $clause is invalid.
	 */
	private function generate_where_for_clause_value( $clause ): string {
		global $wpdb;

		if ( ! array_key_exists( 'value', $clause ) ) {
			return '';
		}

		$meta_value = $clause['value'];

		if ( in_array( $clause['compare'], array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true ) ) {
			if ( ! is_array( $meta_value ) ) {
				$meta_value = preg_split( '/[,\s]+/', $meta_value );
			}
		} elseif ( is_string( $meta_value ) ) {
			$meta_value = trim( $meta_value );
		}

		$meta_compare = $clause['compare'];

		switch ( $meta_compare ) {
			case 'IN':
			case 'NOT IN':
				$where = $wpdb->prepare( '(' . substr( str_repeat( ',%s', count( (array) $meta_value ) ), 1 ) . ')', $meta_value ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				break;

			case 'BETWEEN':
			case 'NOT BETWEEN':
				$where = $wpdb->prepare( '%s AND %s', $meta_value[0], $meta_value[1] );
				break;

			case 'LIKE':
			case 'NOT LIKE':
				$where = $wpdb->prepare( '%s', '%' . $wpdb->esc_like( $meta_value ) . '%' );
				break;

			// EXISTS with a value is interpreted as '='.
			case 'EXISTS':
				$meta_compare = '=';
				$where        = $wpdb->prepare( '%s', $meta_value );
				break;

			// 'value' is ignored for NOT EXISTS.
			case 'NOT EXISTS':
				$where = '';
				break;

			default:
				$where = $wpdb->prepare( '%s', $meta_value );
				break;
		}

		if ( $where ) {
			if ( 'CHAR' === $clause['cast'] ) {
				return "{$clause['alias']}.meta_value {$meta_compare} {$where}";
			} else {
				return "CAST({$clause['alias']}.meta_value AS {$clause['cast']}) {$meta_compare} {$where}";
			}
		}

		return '';
	}
}
PK     [1]T&^ ^ *  DataStores/Orders/OrdersTableDataStore.phpnu         <?php
/**
 * OrdersTableDataStore class file.
 */

namespace Automattic\WooCommerce\Internal\DataStores\Orders;

use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Caches\OrderCache;
use Automattic\WooCommerce\Caching\WPCacheEngine;
use Automattic\WooCommerce\Enums\OrderInternalStatus;
use Automattic\WooCommerce\Internal\Admin\Orders\EditLock;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Automattic\WooCommerce\Utilities\OrderUtil;
use Exception;
use WC_Abstract_Order;
use WC_Data;
use WC_Order;
use Automattic\WooCommerce\Internal\Fulfillments\FulfillmentUtils;

defined( 'ABSPATH' ) || exit;

/**
 * This class is the standard data store to be used when the custom orders table is in use.
 */
class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements \WC_Object_Data_Store_Interface, \WC_Order_Data_Store_Interface {

	use CogsAwareTrait;

	/**
	 * Order IDs for which we are checking sync on read in the current request. In WooCommerce, using wc_get_order is a very common pattern, to avoid performance issues, we only sync on read once per request per order. This works because we consider out of sync orders to be an anomaly, so we don't recommend running HPOS with incompatible plugins.
	 *
	 * @var array
	 */
	private static $reading_order_ids = array();

	/**
	 * Keep track of order IDs that are actively being backfilled. We use this to prevent further read on sync from add_|update_|delete_postmeta etc hooks. If we allow this, then we would end up syncing the same order multiple times as it is being backfilled.
	 *
	 * @var array
	 */
	private static $backfilling_order_ids = array();

	/**
	 * Keep track of order IDs (as keys) that are being synced on read. This is used to prevent backfilling to posts of an order being updated
	 * from posts.
	 *
	 * @var array
	 */
	private static $sync_on_read_order_ids = array();

	/**
	 * Data stored in meta keys, but not considered "meta" for an order.
	 *
	 * @since 7.0.0
	 * @var array
	 */
	protected $internal_meta_keys = array(
		'_customer_user',
		'_order_key',
		'_order_currency',
		'_billing_first_name',
		'_billing_last_name',
		'_billing_company',
		'_billing_address_1',
		'_billing_address_2',
		'_billing_city',
		'_billing_state',
		'_billing_postcode',
		'_billing_country',
		'_billing_email',
		'_billing_phone',
		'_shipping_first_name',
		'_shipping_last_name',
		'_shipping_company',
		'_shipping_address_1',
		'_shipping_address_2',
		'_shipping_city',
		'_shipping_state',
		'_shipping_postcode',
		'_shipping_country',
		'_shipping_phone',
		'_completed_date',
		'_paid_date',
		'_edit_last',
		'_cart_discount',
		'_cart_discount_tax',
		'_order_shipping',
		'_order_shipping_tax',
		'_order_tax',
		'_order_total',
		'_payment_method',
		'_payment_method_title',
		'_transaction_id',
		'_customer_ip_address',
		'_customer_user_agent',
		'_created_via',
		'_order_version',
		'_prices_include_tax',
		'_date_completed',
		'_date_paid',
		'_payment_tokens',
		'_billing_address_index',
		'_shipping_address_index',
		'_recorded_sales',
		'_recorded_coupon_usage_counts',
		'_download_permissions_granted',
		'_order_stock_reduced',
		'_new_order_email_sent',
		'_cogs_total_value',
	);

	/**
	 * Meta keys that are considered ephemeral and do not trigger a full save (updating modified date) when changed.
	 *
	 * @var string[]
	 */
	protected $ephemeral_meta_keys = array(
		EditLock::META_KEY_NAME,
	);

	/**
	 * Handles custom metadata in the wc_orders_meta table.
	 *
	 * @var OrdersTableDataStoreMeta
	 */
	protected $data_store_meta;

	/**
	 * The database util object to use.
	 *
	 * @var DatabaseUtil
	 */
	protected $database_util;

	/**
	 * The posts data store object to use.
	 *
	 * @var \WC_Order_Data_Store_CPT
	 */
	private $cpt_data_store;

	/**
	 * Logger object to be used to log events.
	 *
	 * @var \WC_Logger
	 */
	private $error_logger;

	/**
	 * The name of the main orders table.
	 *
	 * @var string
	 */
	private $orders_table_name;

	/**
	 * The instance of the LegacyProxy object to use.
	 *
	 * @var LegacyProxy
	 */
	private $legacy_proxy;

	/**
	 * Initialize the object.
	 *
	 * @internal
	 * @param OrdersTableDataStoreMeta $data_store_meta Metadata instance.
	 * @param DatabaseUtil             $database_util   The database util instance to use.
	 * @param LegacyProxy              $legacy_proxy    The legacy proxy instance to use.
	 *
	 * @return void
	 */
	final public function init( OrdersTableDataStoreMeta $data_store_meta, DatabaseUtil $database_util, LegacyProxy $legacy_proxy ) {
		$this->data_store_meta    = $data_store_meta;
		$this->database_util      = $database_util;
		$this->legacy_proxy       = $legacy_proxy;
		$this->error_logger       = $legacy_proxy->call_function( 'wc_get_logger' );
		$this->internal_meta_keys = $this->get_internal_meta_keys();

		$this->orders_table_name = self::get_orders_table_name();
	}

	/**
	 * Get the custom orders table name.
	 *
	 * @return string The custom orders table name.
	 */
	public static function get_orders_table_name() {
		global $wpdb;

		return $wpdb->prefix . 'wc_orders';
	}

	/**
	 * Get the order addresses table name.
	 *
	 * @return string The order addresses table name.
	 */
	public static function get_addresses_table_name() {
		global $wpdb;

		return $wpdb->prefix . 'wc_order_addresses';
	}

	/**
	 * Get the orders operational data table name.
	 *
	 * @return string The orders operational data table name.
	 */
	public static function get_operational_data_table_name() {
		global $wpdb;

		return $wpdb->prefix . 'wc_order_operational_data';
	}

	/**
	 * Get the orders meta data table name.
	 *
	 * @return string Name of order meta data table.
	 */
	public static function get_meta_table_name() {
		global $wpdb;

		return $wpdb->prefix . 'wc_orders_meta';
	}

	/**
	 * Get the names of all the tables involved in the custom orders table feature.
	 *
	 * See also : get_all_table_names_with_id.
	 *
	 * @return string[]
	 */
	public function get_all_table_names() {
		return array(
			$this->get_orders_table_name(),
			$this->get_addresses_table_name(),
			$this->get_operational_data_table_name(),
			$this->get_meta_table_name(),
		);
	}

	/**
	 * Similar to get_all_table_names, but also returns the table name along with the items table.
	 *
	 * @return array Names of the tables.
	 */
	public static function get_all_table_names_with_id() {
		global $wpdb;
		return array(
			'orders'           => self::get_orders_table_name(),
			'addresses'        => self::get_addresses_table_name(),
			'operational_data' => self::get_operational_data_table_name(),
			'meta'             => self::get_meta_table_name(),
			'items'            => $wpdb->prefix . 'woocommerce_order_items',
		);
	}

	/**
	 * Table column to WC_Order mapping for wc_orders table.
	 *
	 * @var \string[][]
	 */
	protected $order_column_mapping = array(
		'id'                   => array(
			'type' => 'int',
			'name' => 'id',
		),
		'status'               => array(
			'type' => 'string',
			'name' => 'status',
		),
		'type'                 => array(
			'type' => 'string',
			'name' => 'type',
		),
		'currency'             => array(
			'type' => 'string',
			'name' => 'currency',
		),
		'tax_amount'           => array(
			'type' => 'decimal',
			'name' => 'cart_tax',
		),
		'total_amount'         => array(
			'type' => 'decimal',
			'name' => 'total',
		),
		'customer_id'          => array(
			'type' => 'int',
			'name' => 'customer_id',
		),
		'billing_email'        => array(
			'type' => 'string',
			'name' => 'billing_email',
		),
		'date_created_gmt'     => array(
			'type' => 'date',
			'name' => 'date_created',
		),
		'date_updated_gmt'     => array(
			'type' => 'date',
			'name' => 'date_modified',
		),
		'parent_order_id'      => array(
			'type' => 'int',
			'name' => 'parent_id',
		),
		'payment_method'       => array(
			'type' => 'string',
			'name' => 'payment_method',
		),
		'payment_method_title' => array(
			'type' => 'string',
			'name' => 'payment_method_title',
		),
		'ip_address'           => array(
			'type' => 'string',
			'name' => 'customer_ip_address',
		),
		'transaction_id'       => array(
			'type' => 'string',
			'name' => 'transaction_id',
		),
		'user_agent'           => array(
			'type' => 'string',
			'name' => 'customer_user_agent',
		),
		'customer_note'        => array(
			'type' => 'string',
			'name' => 'customer_note',
		),
	);

	/**
	 * Table column to WC_Order mapping for billing addresses in wc_address table.
	 *
	 * @var \string[][]
	 */
	protected $billing_address_column_mapping = array(
		'id'           => array( 'type' => 'int' ),
		'order_id'     => array( 'type' => 'int' ),
		'address_type' => array( 'type' => 'string' ),
		'first_name'   => array(
			'type' => 'string',
			'name' => 'billing_first_name',
		),
		'last_name'    => array(
			'type' => 'string',
			'name' => 'billing_last_name',
		),
		'company'      => array(
			'type' => 'string',
			'name' => 'billing_company',
		),
		'address_1'    => array(
			'type' => 'string',
			'name' => 'billing_address_1',
		),
		'address_2'    => array(
			'type' => 'string',
			'name' => 'billing_address_2',
		),
		'city'         => array(
			'type' => 'string',
			'name' => 'billing_city',
		),
		'state'        => array(
			'type' => 'string',
			'name' => 'billing_state',
		),
		'postcode'     => array(
			'type' => 'string',
			'name' => 'billing_postcode',
		),
		'country'      => array(
			'type' => 'string',
			'name' => 'billing_country',
		),
		'email'        => array(
			'type' => 'string',
			'name' => 'billing_email',
		),
		'phone'        => array(
			'type' => 'string',
			'name' => 'billing_phone',
		),
	);

	/**
	 * Table column to WC_Order mapping for shipping addresses in wc_address table.
	 *
	 * @var \string[][]
	 */
	protected $shipping_address_column_mapping = array(
		'id'           => array( 'type' => 'int' ),
		'order_id'     => array( 'type' => 'int' ),
		'address_type' => array( 'type' => 'string' ),
		'first_name'   => array(
			'type' => 'string',
			'name' => 'shipping_first_name',
		),
		'last_name'    => array(
			'type' => 'string',
			'name' => 'shipping_last_name',
		),
		'company'      => array(
			'type' => 'string',
			'name' => 'shipping_company',
		),
		'address_1'    => array(
			'type' => 'string',
			'name' => 'shipping_address_1',
		),
		'address_2'    => array(
			'type' => 'string',
			'name' => 'shipping_address_2',
		),
		'city'         => array(
			'type' => 'string',
			'name' => 'shipping_city',
		),
		'state'        => array(
			'type' => 'string',
			'name' => 'shipping_state',
		),
		'postcode'     => array(
			'type' => 'string',
			'name' => 'shipping_postcode',
		),
		'country'      => array(
			'type' => 'string',
			'name' => 'shipping_country',
		),
		'email'        => array( 'type' => 'string' ),
		'phone'        => array(
			'type' => 'string',
			'name' => 'shipping_phone',
		),
	);

	/**
	 * Table column to WC_Order mapping for wc_operational_data table.
	 *
	 * @var \string[][]
	 */
	protected $operational_data_column_mapping = array(
		'id'                          => array( 'type' => 'int' ),
		'order_id'                    => array( 'type' => 'int' ),
		'created_via'                 => array(
			'type' => 'string',
			'name' => 'created_via',
		),
		'woocommerce_version'         => array(
			'type' => 'string',
			'name' => 'version',
		),
		'prices_include_tax'          => array(
			'type' => 'bool',
			'name' => 'prices_include_tax',
		),
		'coupon_usages_are_counted'   => array(
			'type' => 'bool',
			'name' => 'recorded_coupon_usage_counts',
		),
		'download_permission_granted' => array(
			'type' => 'bool',
			'name' => 'download_permissions_granted',
		),
		'cart_hash'                   => array(
			'type' => 'string',
			'name' => 'cart_hash',
		),
		'new_order_email_sent'        => array(
			'type' => 'bool',
			'name' => 'new_order_email_sent',
		),
		'order_key'                   => array(
			'type' => 'string',
			'name' => 'order_key',
		),
		'order_stock_reduced'         => array(
			'type' => 'bool',
			'name' => 'order_stock_reduced',
		),
		'date_paid_gmt'               => array(
			'type' => 'date',
			'name' => 'date_paid',
		),
		'date_completed_gmt'          => array(
			'type' => 'date',
			'name' => 'date_completed',
		),
		'shipping_tax_amount'         => array(
			'type' => 'decimal',
			'name' => 'shipping_tax',
		),
		'shipping_total_amount'       => array(
			'type' => 'decimal',
			'name' => 'shipping_total',
		),
		'discount_tax_amount'         => array(
			'type' => 'decimal',
			'name' => 'discount_tax',
		),
		'discount_total_amount'       => array(
			'type' => 'decimal',
			'name' => 'discount_total',
		),
		'recorded_sales'              => array(
			'type' => 'bool',
			'name' => 'recorded_sales',
		),
	);

	/**
	 * Cache variable to store combined mapping.
	 *
	 * @var array[][][]
	 */
	private $all_order_column_mapping;

	/**
	 * Return combined mappings for all order tables.
	 *
	 * @return array|\array[][][] Return combined mapping.
	 */
	public function get_all_order_column_mappings() {
		if ( ! isset( $this->all_order_column_mapping ) ) {
			$this->all_order_column_mapping = array(
				'orders'           => $this->order_column_mapping,
				'billing_address'  => $this->billing_address_column_mapping,
				'shipping_address' => $this->shipping_address_column_mapping,
				'operational_data' => $this->operational_data_column_mapping,
			);
		}

		return $this->all_order_column_mapping;
	}

	/**
	 * The group name to use when caching order object data.
	 *
	 * @return string
	 */
	private function get_cache_group(): string {
		return 'orders_data';
	}

	/**
	 * Delete cached order data for the given object_ids.
	 *
	 * @param array $order_ids The IDs of the orders to remove cache.
	 *
	 * @return bool[] Array of return values, grouped by the object_id. Each value is either true on success, or false
	 *                if the contents were not deleted.
	 *
	 * @internal This method should only be used by internally and in cases where the CRUD operations of this datastore
	 *           are bypassed for performance purposes. This interface is not guaranteed.
	 */
	public function clear_cached_data( array $order_ids ): array {
		if ( ! OrderUtil::custom_orders_table_datastore_cache_enabled() ) {
			return array_fill_keys( $order_ids, true );
		}

		$cache_engine  = wc_get_container()->get( WPCacheEngine::class );
		$cache_group   = $this->get_cache_group();
		$return_values = array();

		foreach ( $order_ids as $order_id ) {
			$return_values[ $order_id ] = $cache_engine->delete_cached_object( $order_id, $cache_group );
		}

		if ( is_callable( array( $this->data_store_meta, 'clear_cached_data' ) ) ) {
			$successfully_deleted_cache_order_ids = array_keys( array_filter( $return_values ) );
			$cache_deletion_results               = $this->data_store_meta->clear_cached_data( $successfully_deleted_cache_order_ids );
			foreach ( $cache_deletion_results as $order_id => $meta_cache_was_deleted ) {
				$return_values[ $order_id ] = $return_values[ $order_id ] && $meta_cache_was_deleted;
			}
		}

		return $return_values;
	}

	/**
	 * Invalidate all the cache used by this data store.
	 *
	 * @internal This method should only be used by internally and in cases where the CRUD operations of this datastore
	 *           are bypassed for performance purposes. This interface is not guaranteed.
	 *
	 * @return bool Whether the cache as fully invalidated.
	 */
	public function clear_all_cached_data(): bool {
		if ( ! OrderUtil::custom_orders_table_datastore_cache_enabled() ) {
			return true;
		}

		$cache_engine       = wc_get_container()->get( WPCacheEngine::class );
		$orders_invalidated = $cache_engine->delete_cache_group( $this->get_cache_group() );
		$meta_invalidated   = true;
		if ( is_callable( array( $this->data_store_meta, 'clear_cached_data' ) ) ) {
			$meta_invalidated = $this->data_store_meta->clear_all_cached_data();
		}

		return $orders_invalidated && $meta_invalidated;
	}


	/**
	 * Helper function to get alias for order table, this is used in select query.
	 *
	 * @return string Alias.
	 */
	private function get_order_table_alias(): string {
		return 'o';
	}

	/**
	 * Helper function to get alias for op table, this is used in select query.
	 *
	 * @return string Alias.
	 */
	private function get_op_table_alias(): string {
		return 'p';
	}

	/**
	 * Helper function to get alias for address table, this is used in select query.
	 *
	 * @param string $type Type of address; 'billing' or 'shipping'.
	 *
	 * @return string Alias.
	 */
	private function get_address_table_alias( string $type ): string {
		return 'billing' === $type ? 'b' : 's';
	}

	/**
	 * Helper method to get a CPT data store instance to use.
	 *
	 * @return \WC_Order_Data_Store_CPT Data store instance.
	 */
	public function get_cpt_data_store_instance() {
		if ( ! isset( $this->cpt_data_store ) ) {
			$this->cpt_data_store = $this->get_post_data_store_for_backfill();
		}
		return $this->cpt_data_store;
	}


	/**
	 * Returns data store object to use backfilling.
	 *
	 * @return \Abstract_WC_Order_Data_Store_CPT
	 */
	protected function get_post_data_store_for_backfill() {
		return new \WC_Order_Data_Store_CPT();
	}

	/**
	 * Backfills order details in to WP_Post DB. Uses WC_Order_Data_store_CPT.
	 *
	 * @param \WC_Abstract_Order $order Order object to backfill.
	 */
	public function backfill_post_record( $order ) {
		$cpt_data_store = $this->get_post_data_store_for_backfill();
		if ( is_null( $cpt_data_store ) || ! method_exists( $cpt_data_store, 'update_order_from_object' ) ) {
			return;
		}

		self::$backfilling_order_ids[] = $order->get_id();

		// Attempt to create the backup post if missing.
		if ( $order->get_id() && is_null( get_post( $order->get_id() ) ) ) {
			if ( ! $this->maybe_create_backup_post( $order, 'backfill' ) ) {
				// translators: %d is an order ID.
				$this->error_logger->warning( sprintf( __( 'Unable to create backup post for order %d.', 'woocommerce' ), $order->get_id() ) );
				return;
			}
		}

		$this->update_order_meta_from_object( $order );
		$order_class = get_class( $order );
		$post_order  = new $order_class();
		$post_order->set_id( $order->get_id() );

		if ( $cpt_data_store->order_exists( $order->get_id() ) ) {
			$cpt_data_store->read( $post_order );
		}

		// This compares the order data to the post data and set changes array for props that are changed.
		$post_order->set_props( $order->get_data() );

		$cpt_data_store->update_order_from_object( $post_order );

		foreach ( $cpt_data_store->get_internal_data_store_key_getters() as $key => $getter_name ) {
			if (
				is_callable( array( $cpt_data_store, "set_$getter_name" ) ) &&
				is_callable( array( $this, "get_$getter_name" ) )
			) {
				call_user_func_array(
					array(
						$cpt_data_store,
						"set_$getter_name",
					),
					array(
						$order,
						$this->{"get_$getter_name"}( $order ),
					)
				);
			}
		}
		self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $order->get_id() ) );

		/**
		 * Fired when the backing post record for an HPOS order is backfilled after an order update.
		 *
		 * @since 8.5.0
		 *
		 * @param \WC_Order $order The order object.
		 */
		do_action( 'woocommerce_hpos_post_record_backfilled', $order );
	}

	/**
	 * Updates an order (in this datastore) from another order object.
	 *
	 * @param \WC_Abstract_Order $order Source order.
	 * @return bool Whether the order was updated.
	 */
	public function update_order_from_object( $order ) {
		$hpos_order = new \WC_Order();
		$hpos_order->set_id( $order->get_id() );
		$this->read( $hpos_order );
		$hpos_order->set_props( $order->get_data() );

		// Meta keys.
		foreach ( $hpos_order->get_meta_data() as &$meta ) {
			$hpos_order->delete_meta_data( $meta->key );
		}

		foreach ( $order->get_meta_data() as &$meta ) {
			$hpos_order->add_meta_data( $meta->key, $meta->value );
		}

		add_filter( 'woocommerce_orders_table_datastore_should_save_after_meta_change', '__return_false' );
		$hpos_order->save_meta_data();
		remove_filter( 'woocommerce_orders_table_datastore_should_save_after_meta_change', '__return_false' );

		$db_rows = $this->get_db_rows_for_order( $hpos_order, 'update', true );
		foreach ( $db_rows as $db_update ) {
			ksort( $db_update['data'] );
			ksort( $db_update['format'] );
			$this->persist_db_row( $db_update );
		}

		return true;
	}

	/**
	 * Helper method to persist a DB row to database. Uses insert_or_update when possible.
	 *
	 * @param array $update Data containing atleast `table`, `data` and `format` keys, but also preferably `where` and `where_format` to use `insert_or_update`.
	 *
	 * @return bool|int Number of rows affected, boolean false on error.
	 */
	private function persist_db_row( $update ) {
		if ( isset( $update['where'] ) ) {
			$row_updated = $this->database_util->insert_or_update(
				$update['table'],
				$update['data'],
				$update['where'],
				$update['format'],
				$update['where_format']
			);
			// row_updated can be 0 when there are no changes. So we check for type as well as row count.
			$result = false !== $row_updated;
		} else {
			$result = $this->database_util->insert_on_duplicate_key_update(
				$update['table'],
				$update['data'],
				array_values( $update['format'] ),
			);
		}
		return $result;
	}

	/**
	 * Get information about whether permissions are granted yet.
	 *
	 * @param \WC_Order $order Order object.
	 *
	 * @return bool Whether permissions are granted.
	 */
	public function get_download_permissions_granted( $order ) {
		$order_id = is_int( $order ) ? $order : $order->get_id();
		$order    = wc_get_order( $order_id );
		return $order->get_download_permissions_granted();
	}

	/**
	 * Stores information about whether permissions were generated yet.
	 *
	 * @param \WC_Order $order Order ID or order object.
	 * @param bool      $set True or false.
	 */
	public function set_download_permissions_granted( $order, $set ) {
		if ( is_int( $order ) ) {
			$order = wc_get_order( $order );
		}
		$order->set_download_permissions_granted( $set );
		$order->save();
	}

	/**
	 * Gets information about whether sales were recorded.
	 *
	 * @param \WC_Order $order Order object.
	 *
	 * @return bool Whether sales are recorded.
	 */
	public function get_recorded_sales( $order ) {
		$order_id = is_int( $order ) ? $order : $order->get_id();
		$order    = wc_get_order( $order_id );
		return $order->get_recorded_sales();
	}

	/**
	 * Stores information about whether sales were recorded.
	 *
	 * @param \WC_Order $order Order object.
	 * @param bool      $set True or false.
	 */
	public function set_recorded_sales( $order, $set ) {
		if ( is_int( $order ) ) {
			$order = wc_get_order( $order );
		}
		$order->set_recorded_sales( $set );
		$order->save();
	}

	/**
	 * Gets information about whether coupon counts were updated.
	 *
	 * @param \WC_Order $order Order object.
	 *
	 * @return bool Whether coupon counts were updated.
	 */
	public function get_recorded_coupon_usage_counts( $order ) {
		$order_id = is_int( $order ) ? $order : $order->get_id();
		$order    = wc_get_order( $order_id );
		return $order && $order->get_recorded_coupon_usage_counts();
	}

	/**
	 * Stores information about whether coupon counts were updated.
	 *
	 * @param \WC_Order $order Order object.
	 * @param bool      $set True or false.
	 */
	public function set_recorded_coupon_usage_counts( $order, $set ) {
		if ( is_int( $order ) ) {
			$order = wc_get_order( $order );
		}
		$order->set_recorded_coupon_usage_counts( $set );
		$order->save();
	}

	/**
	 * Whether email have been sent for this order.
	 *
	 * @param \WC_Order|int $order Order object.
	 *
	 * @return bool Whether email is sent.
	 */
	public function get_email_sent( $order ) {
		$order_id = is_int( $order ) ? $order : $order->get_id();
		$order    = wc_get_order( $order_id );
		return $order->get_new_order_email_sent();
	}

	/**
	 * Stores information about whether email was sent.
	 *
	 * @param \WC_Order $order Order object.
	 * @param bool      $set True or false.
	 */
	public function set_email_sent( $order, $set ) {
		if ( is_int( $order ) ) {
			$order = wc_get_order( $order );
		}
		$order->set_new_order_email_sent( $set );
		$order->save();
	}

	/**
	 * Helper setter for email_sent.
	 *
	 * @param \WC_Order $order Order object.
	 *
	 * @return bool Whether email was sent.
	 */
	public function get_new_order_email_sent( $order ) {
		return $this->get_email_sent( $order );
	}

	/**
	 * Helper setter for new order email sent.
	 *
	 * @param \WC_Order $order Order object.
	 * @param bool      $set True or false.
	 */
	public function set_new_order_email_sent( $order, $set ) {
		if ( is_int( $order ) ) {
			$order = wc_get_order( $order );
		}
		$order->set_new_order_email_sent( $set );
		$order->save();
	}

	/**
	 * Gets information about whether stock was reduced.
	 *
	 * @param \WC_Order $order Order object.
	 *
	 * @return bool Whether stock was reduced.
	 */
	public function get_stock_reduced( $order ) {
		$order_id = is_int( $order ) ? $order : $order->get_id();
		$order    = wc_get_order( $order_id );
		return $order->get_order_stock_reduced();
	}

	/**
	 * Stores information about whether stock was reduced.
	 *
	 * @param \WC_Order $order Order ID or order object.
	 * @param bool      $set True or false.
	 */
	public function set_stock_reduced( $order, $set ) {
		if ( is_int( $order ) ) {
			$order = wc_get_order( $order );
		}
		$order->set_order_stock_reduced( $set );
		$order->save();
	}

	/**
	 * Helper getter for `order_stock_reduced`.
	 *
	 * @param \WC_Order $order Order object.
	 * @return bool Whether stock was reduced.
	 */
	public function get_order_stock_reduced( $order ) {
		return $this->get_stock_reduced( $order );
	}

	/**
	 * Helper setter for `order_stock_reduced`.
	 *
	 * @param \WC_Order $order Order ID or order object.
	 * @param bool      $set Whether stock was reduced.
	 */
	public function set_order_stock_reduced( $order, $set ) {
		$this->set_stock_reduced( $order, $set );
	}

	/**
	 * Get token ids for an order.
	 *
	 * @param WC_Order $order Order object.
	 * @return array
	 */
	public function get_payment_token_ids( $order ) {
		/**
		 * We don't store _payment_tokens in props to preserve backward compatibility. In CPT data store, `_payment_tokens` is always fetched directly from DB instead of from prop.
		 */
		$payment_tokens = $this->data_store_meta->get_metadata_by_key( $order, '_payment_tokens' );
		if ( $payment_tokens ) {
			$payment_tokens = $payment_tokens[0]->meta_value;
		}
		if ( ! $payment_tokens && version_compare( $order->get_version(), '8.0.0', '<' ) ) {
			// Before 8.0 we were incorrectly storing payment_tokens in the order meta. So we need to check there too.
			$payment_tokens = get_post_meta( $order->get_id(), '_payment_tokens', true );
		}
		return array_filter( (array) $payment_tokens );
	}

	/**
	 * Update token ids for an order.
	 *
	 * @param WC_Order $order Order object.
	 * @param array    $token_ids Payment token ids.
	 */
	public function update_payment_token_ids( $order, $token_ids ) {
		$meta          = new \WC_Meta_Data();
		$meta->key     = '_payment_tokens';
		$meta->value   = $token_ids;
		$existing_meta = $this->data_store_meta->get_metadata_by_key( $order, '_payment_tokens' );
		if ( $existing_meta ) {
			$existing_meta = $existing_meta[0];
			$meta->id      = $existing_meta->id;
			$this->data_store_meta->update_meta( $order, $meta );
		} else {
			$this->data_store_meta->add_meta( $order, $meta );
		}
	}

	/**
	 * Get amount already refunded.
	 *
	 * @param \WC_Order $order Order object.
	 *
	 * @return float Refunded amount.
	 */
	public function get_total_refunded( $order ) {
		global $wpdb;
		$order_table = self::get_orders_table_name();
		$total       = $wpdb->get_var(
			$wpdb->prepare(
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table is hardcoded.
				"
SELECT SUM( total_amount ) FROM $order_table
WHERE
    type = %s AND
    parent_order_id = %d
;
",
				// phpcs:enable
				'shop_order_refund',
				$order->get_id()
			)
		);
		return -1 * ( isset( $total ) ? $total : 0 );
	}

	/**
	 * Get the total tax refunded.
	 *
	 * @param WC_Order $order Order object.
	 *
	 * @return float
	 */
	public function get_total_tax_refunded( $order ) {
		global $wpdb;

		$order_table = self::get_orders_table_name();

		$total = $wpdb->get_var(
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table is hardcoded.
			$wpdb->prepare(
				"SELECT SUM( order_itemmeta.meta_value )
				FROM {$wpdb->prefix}woocommerce_order_itemmeta AS order_itemmeta
				INNER JOIN $order_table AS orders ON ( orders.type = 'shop_order_refund' AND orders.parent_order_id = %d )
				INNER JOIN {$wpdb->prefix}woocommerce_order_items AS order_items ON ( order_items.order_id = orders.id AND order_items.order_item_type = 'tax' )
				WHERE order_itemmeta.order_item_id = order_items.order_item_id
				AND order_itemmeta.meta_key IN ('tax_amount', 'shipping_tax_amount')",
				$order->get_id(),
			)
		) ?? 0;
		// phpcs:enable

		return abs( $total );
	}

	/**
	 * Get the total shipping tax refunded.
	 *
	 * @param WC_Order $order Order object.
	 *
	 * @since 10.2.0
	 * @return float
	 */
	public function get_total_shipping_tax_refunded( $order ) {
		global $wpdb;

		$order_table = self::get_orders_table_name();

		$total = $wpdb->get_var(
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table is hardcoded.
			$wpdb->prepare(
				"SELECT SUM( order_itemmeta.meta_value )
				FROM {$wpdb->prefix}woocommerce_order_itemmeta AS order_itemmeta
				INNER JOIN $order_table AS orders ON ( orders.type = 'shop_order_refund' AND orders.parent_order_id = %d )
				INNER JOIN {$wpdb->prefix}woocommerce_order_items AS order_items ON ( order_items.order_id = orders.id AND order_items.order_item_type = 'tax' )
				WHERE order_itemmeta.order_item_id = order_items.order_item_id
				AND order_itemmeta.meta_key = 'shipping_tax_amount'",
				$order->get_id()
			)
		) ?? 0;
		// phpcs:enable

		return abs( $total );
	}

	/**
	 * Get the total shipping refunded.
	 *
	 * @param  WC_Order $order Order object.
	 * @return float
	 */
	public function get_total_shipping_refunded( $order ) {
		global $wpdb;

		$order_table = self::get_orders_table_name();

		$total = $wpdb->get_var(
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table is hardcoded.
			$wpdb->prepare(
				"SELECT SUM( order_itemmeta.meta_value )
				FROM {$wpdb->prefix}woocommerce_order_itemmeta AS order_itemmeta
				INNER JOIN $order_table AS orders ON ( orders.type = 'shop_order_refund' AND orders.parent_order_id = %d )
				INNER JOIN {$wpdb->prefix}woocommerce_order_items AS order_items ON ( order_items.order_id = orders.id AND order_items.order_item_type = 'shipping' )
				WHERE order_itemmeta.order_item_id = order_items.order_item_id
				AND order_itemmeta.meta_key IN ('cost')",
				$order->get_id()
			)
		) ?? 0;
		// phpcs:enable

		return abs( $total );
	}

	/**
	 * Finds an Order ID based on an order key.
	 *
	 * @param string $order_key An order key has generated by.
	 * @return int The ID of an order, or 0 if the order could not be found
	 */
	public function get_order_id_by_order_key( $order_key ) {
		global $wpdb;

		$orders_table = self::get_orders_table_name();
		$op_table     = self::get_operational_data_table_name();

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		return (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT {$orders_table}.id FROM {$orders_table}
				INNER JOIN {$op_table} ON {$op_table}.order_id = {$orders_table}.id
				WHERE {$op_table}.order_key = %s AND {$op_table}.order_key != ''",
				$order_key
			)
		);
		// phpcs:enable
	}

	/**
	 * Return count of orders with a specific status.
	 *
	 * @param  string $status Order status. Function wc_get_order_statuses() returns a list of valid statuses.
	 * @return int
	 */
	public function get_order_count( $status ) {
		global $wpdb;

		$orders_table = self::get_orders_table_name();

		return absint( $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$orders_table} WHERE type = %s AND status = %s", 'shop_order', $status ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}

	/**
	 * Get all orders matching the passed in args.
	 *
	 * @deprecated 3.1.0 - Use {@see wc_get_orders} instead.
	 * @param  array $args List of args passed to wc_get_orders().
	 * @return array|object
	 */
	public function get_orders( $args = array() ) {
		wc_deprecated_function( __METHOD__, '3.1.0', 'Use wc_get_orders instead.' );
		return wc_get_orders( $args );
	}

	/**
	 * Get unpaid orders last updated before the specified date.
	 *
	 * @param  int $date This timestamp is expected in the timezone in WordPress settings for legacy reason, even though it's not a good practice.
	 *
	 * @return array Array of order IDs.
	 */
	public function get_unpaid_orders( $date ) {
		$timezone_offset = wc_timezone_offset();
		$gmt_timestamp   = $date - $timezone_offset;
		return $this->get_unpaid_orders_gmt( absint( $gmt_timestamp ) );
	}

	/**
	 * Get unpaid orders last updated before the specified GMT date.
	 *
	 * @param int $gmt_timestamp GMT timestamp.
	 *
	 * @return array Array of order IDs.
	 */
	public function get_unpaid_orders_gmt( $gmt_timestamp ) {
		global $wpdb;

		$orders_table    = self::get_orders_table_name();
		$order_types_sql = "('" . implode( "','", wc_get_order_types() ) . "')";

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		return $wpdb->get_col(
			$wpdb->prepare(
				"SELECT id FROM {$orders_table} WHERE
				{$orders_table}.type IN {$order_types_sql}
				AND {$orders_table}.status = %s
				AND {$orders_table}.date_updated_gmt < %s",
				OrderInternalStatus::PENDING,
				gmdate( 'Y-m-d H:i:s', absint( $gmt_timestamp ) )
			)
		);
		// phpcs:enable
	}

	/**
	 * Search order data for a term and return matching order IDs.
	 *
	 * @param string $term Search term.
	 *
	 * @return int[] Array of order IDs.
	 */
	public function search_orders( $term ) {
		$order_ids = wc_get_orders(
			array(
				's'      => $term,
				'return' => 'ids',
			)
		);

		/**
		 * Provides an opportunity to modify the list of order IDs obtained during an order search.
		 *
		 * This hook is used for Custom Order Table queries. For Custom Post Type order searches, the corresponding hook
		 * is `woocommerce_shop_order_search_results`.
		 *
		 * @since 7.0.0
		 *
		 * @param int[]  $order_ids Search results as an array of order IDs.
		 * @param string $term      The search term.
		 */
		return array_map( 'intval', (array) apply_filters( 'woocommerce_cot_shop_order_search_results', $order_ids, $term ) );
	}

	/**
	 * Fetch order type for orders in bulk.
	 *
	 * @param array $order_ids Order IDs.
	 *
	 * @return array array( $order_id1 => $type1, ... ) Array for all orders.
	 */
	public function get_orders_type( $order_ids ) {
		global $wpdb;

		if ( empty( $order_ids ) ) {
			return array();
		}

		$order_types = array();

		if ( OrderUtil::custom_orders_table_datastore_cache_enabled() ) {
			if ( ! is_array( $order_ids ) ) {
				// self::get_order_data_for_ids() strict types the $order_ids parameter. Temporarily maintain backward compatibility
				// for potential misuse of self::get_orders_type().
				$order_ids = array( (int) $order_ids );
			}
			// If we're using order data caching, preemptively pull all the data and prime the cache as this method is
			// almost exclusively used to determine the order class to later hydrate.
			$orders_data = $this->get_order_data_for_ids( $order_ids );
			foreach ( $orders_data as $order_id => $order_data ) {
				if ( ! empty( $order_data->type ) ) {
					$order_types[ $order_id ] = $order_data->type;
				}
			}

			return $order_types;
		}

		$orders_table          = self::get_orders_table_name();
		$order_ids_placeholder = implode( ', ', array_fill( 0, count( $order_ids ), '%d' ) );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
		$results = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, type FROM {$orders_table} WHERE id IN ( $order_ids_placeholder )",
				$order_ids
			)
		);
		// phpcs:enable
		foreach ( $results as $row ) {
			$order_types[ $row->id ] = $row->type;
		}
		return $order_types;
	}

	/**
	 * Get order type from DB.
	 *
	 * @param int $order_id Order ID.
	 *
	 * @return string Order type.
	 */
	public function get_order_type( $order_id ) {
		$type = $this->get_orders_type( array( $order_id ) );
		return $type[ $order_id ] ?? '';
	}

	/**
	 * Check if an order exists by id.
	 *
	 * @since 8.0.0
	 *
	 * @param int $order_id The order id to check.
	 * @return bool True if an order exists with the given name.
	 */
	public function order_exists( $order_id ): bool {
		global $wpdb;

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$exists = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT EXISTS (SELECT id FROM {$this->orders_table_name} WHERE id=%d)",
				$order_id
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		return (bool) $exists;
	}

	/**
	 * Method to read an order from custom tables.
	 *
	 * @param \WC_Order $order Order object.
	 *
	 * @throws \Exception If passed order is invalid.
	 */
	public function read( &$order ) {
		$orders_array = array( $order->get_id() => $order );
		$this->read_multiple( $orders_array );
	}

	/**
	 * Reads multiple orders from custom tables in one pass.
	 *
	 * @since 6.9.0
	 * @param array[\WC_Order] $orders Order objects.
	 * @throws \Exception If passed an invalid order.
	 */
	public function read_multiple( &$orders ) {
		$order_ids = array_keys( $orders );
		$data      = $this->get_order_data_for_ids( $order_ids );

		if ( count( $data ) !== count( $order_ids ) ) {
			throw new \Exception( esc_html__( 'Invalid order IDs in call to read_multiple()', 'woocommerce' ) );
		}

		$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
		if ( ! $data_synchronizer instanceof DataSynchronizer ) {
			return;
		}

		$data_sync_enabled = $data_synchronizer->data_sync_is_enabled();
		if ( $data_sync_enabled ) {
			// We prefer not syncing-on-read if we are inside a webhook delivery or importing orders, as those events are likely triggered after the order is written
			// and we don't want to possibly create loops of sync-on-read.
			$should_sync_on_read = ! doing_action( 'woocommerce_deliver_webhook_async' ) && ! doing_action( 'wc-admin_import_orders' );

			/**
			 * Allow opportunity to disable sync on read, while keeping sync on write enabled. This adds another step as a large shop progresses from full sync to no sync with HPOS authoritative.
			 * This filter is only executed if data sync is enabled from settings in the first place as it's meant to be a step between full sync -> no sync, rather than be a control for enabling just the sync on read. Sync on read without sync on write is problematic as any update will reset on the next read, but sync on write without sync on read is fine.
			 *
			 * @param bool $read_on_sync_enabled Whether to sync on read.
			 *
			 * @since 8.1.0
			 */
			$data_sync_enabled = apply_filters( 'woocommerce_hpos_enable_sync_on_read', $should_sync_on_read );
		}

		$load_posts_for = array_diff( $order_ids, array_merge( self::$reading_order_ids, self::$backfilling_order_ids ) );

		$post_orders = array();
		if ( $data_sync_enabled ) {
			global $wpdb;

			// Exclude orders that do not exist in the posts table.
			if ( $load_posts_for ) {
				$order_ids_placeholder = implode( ', ', array_fill( 0, count( $load_posts_for ), '%d' ) );
				$load_posts_for        = array_map( 'absint', $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID IN ( $order_ids_placeholder )", ...$load_posts_for ) ) ); // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			}

			$post_orders = $this->get_post_orders_for_ids( array_intersect_key( $orders, array_flip( $load_posts_for ) ) );
		}

		$cogs_is_enabled = $this->cogs_is_enabled();

		foreach ( $data as $order_data ) {
			$order_id = absint( $order_data->id );
			$order    = $orders[ $order_id ];

			$this->init_order_record( $order, $order_id, $order_data );

			if ( $order->has_cogs() && $cogs_is_enabled ) {
				$this->read_cogs_data( $order );
			}

			if ( $data_sync_enabled && isset( $post_orders[ $order_id ] ) && $this->should_sync_order( $order ) ) {
				self::$reading_order_ids[] = $order_id;
				$this->maybe_sync_order( $order, $post_orders[ $order->get_id() ] );
			}
		}
	}

	/**
	 * Read the Cost of Goods Sold value for a given order from the database, if available, and apply it to the order.
	 *
	 * @param \WC_Abstract_Order $order The order to get the COGS value for.
	 */
	private function read_cogs_data( WC_Abstract_Order $order ) {
		$meta_entry = $this->data_store_meta->get_metadata_by_key( $order, '_cogs_total_value' );
		$cogs_value = false === $meta_entry ? 0 : (float) current( $meta_entry )->meta_value;

		/**
		 * Filter to customize the Cost of Goods Sold value that gets loaded for a given order.
		 *
		 * @since 9.5.0
		 *
		 * @param float $cogs_value The value as read from the database.
		 * @param WC_Abstract_Order $product The order for which the value is being loaded.
		 */
		$cogs_value = apply_filters( 'woocommerce_load_order_cogs_value', $cogs_value, $order );

		$order->set_cogs_total_value( (float) $cogs_value );
		$order->apply_changes();
	}

	/**
	 * Helper method to check whether to sync the order.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 *
	 * @return bool Whether the order should be synced.
	 */
	private function should_sync_order( \WC_Abstract_Order $order ): bool {
		$draft_order    = in_array( $order->get_status(), array( 'draft', 'auto-draft' ), true );
		$already_synced = in_array( $order->get_id(), self::$reading_order_ids, true );
		return ! $draft_order && ! $already_synced;
	}

	/**
	 * Helper method to initialize order object from DB data.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 * @param int                $order_id Order ID.
	 * @param \stdClass          $order_data Order data fetched from DB.
	 *
	 * @return void
	 */
	protected function init_order_record( \WC_Abstract_Order &$order, int $order_id, \stdClass $order_data ) {
		$order->set_defaults();
		$order->set_id( $order_id );
		$filtered_meta_data = $this->filter_raw_meta_data( $order, $order_data->meta_data );
		$order->init_meta_data( $filtered_meta_data );
		$this->set_order_props_from_data( $order, $order_data );
		$order->set_object_read( true );
	}

	/**
	 * For post based data stores, this was used to filter internal meta data. For custom tables, technically there is no internal meta data,
	 * (i.e. we store all core data as properties for the order, and not in meta data). So this method is a no-op.
	 *
	 * Except that some meta such as billing_address_index and shipping_address_index are infact stored in meta data, so we need to filter those out.
	 *
	 * However, declaring $internal_meta_keys is still required so that our backfill and other comparison checks works as expected.
	 *
	 * @param \WC_Data $object Object to filter meta data for.
	 * @param array    $raw_meta_data Raw meta data.
	 *
	 * @return array Filtered meta data.
	 */
	public function filter_raw_meta_data( &$object, $raw_meta_data ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.objectFound
		$filtered_meta_data = parent::filter_raw_meta_data( $object, $raw_meta_data );
		$allowed_keys       = array(
			'_billing_address_index',
			'_shipping_address_index',
		);
		$allowed_meta       = array_filter(
			$raw_meta_data,
			function ( $meta ) use ( $allowed_keys ) {
				return in_array( $meta->meta_key, $allowed_keys, true );
			}
		);

		return array_merge( $allowed_meta, $filtered_meta_data );
	}

	/**
	 * Sync order to/from posts tables if we are able to detect difference between order and posts but the sync is enabled.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 * @param \WC_Abstract_Order $post_order Order object initialized from post.
	 *
	 * @return void
	 * @throws \Exception If passed an invalid order.
	 */
	private function maybe_sync_order( \WC_Abstract_Order &$order, \WC_Abstract_Order $post_order ) {
		if ( ! $this->is_post_different_from_order( $order, $post_order ) ) {
			return;
		}

		// Modified dates can be empty when the order is created but never updated again. Fallback to created date in those cases.
		$order_modified_date      = $order->get_date_modified() ?? $order->get_date_created();
		$order_modified_date      = is_null( $order_modified_date ) ? 0 : $order_modified_date->getTimestamp();
		$post_order_modified_date = $post_order->get_date_modified() ?? $post_order->get_date_created();
		$post_order_modified_date = is_null( $post_order_modified_date ) ? 0 : $post_order_modified_date->getTimestamp();

		/**
		 * We are here because there was difference in the post and order data even though sync is enabled. If the modified date in
		 * the post is the same or more recent than the modified date in the order object, we update the order object with the data
		 * from the post. The opposite case is handled in 'backfill_post_record'. This mitigates the case where other plugins write
		 * to the post or postmeta directly.
		 */
		if ( $post_order_modified_date >= $order_modified_date ) {
			$this->migrate_post_record( $order, $post_order );
		}
	}

	/**
	 * Get the post type order representation.
	 *
	 * @param \WP_Post $post Post object.
	 *
	 * @return \WC_Order Order object.
	 */
	private function get_cpt_order( $post ) {
		$cpt_order = new \WC_Order();
		$cpt_order->set_id( $post->ID );
		$cpt_data_store = $this->get_cpt_data_store_instance();
		$cpt_data_store->read( $cpt_order );
		return $cpt_order;
	}

	/**
	 * Helper function to get posts data for an order in bulk. We use to this to compute posts object in bulk so that we can compare it with COT data.
	 *
	 * @param array $orders    List of orders mapped by $order_id.
	 *
	 * @return array List of posts.
	 *
	 * @throws \Exception If no CPT data store is found for an order.
	 */
	private function get_post_orders_for_ids( array $orders ): array {
		$order_ids = array_keys( $orders );
		foreach ( $order_ids as $order_id ) {
			// Exclude orders where the CPT version is a placeholder post.
			$post_type = get_post_type( $order_id );
			if ( ! $post_type || DataSynchronizer::PLACEHOLDER_ORDER_POST_TYPE === $post_type ) {
				unset( $orders[ $order_id ] );
				continue;
			}

			// We have to bust meta cache, otherwise we will just get the meta cached by OrderTableDataStore.
			wp_cache_delete( WC_Order::generate_meta_cache_key( $order_id, 'orders' ), 'orders' );
		}

		$cpt_stores       = array();
		$cpt_store_orders = array();
		foreach ( $orders as $order_id => $order ) {
			$table_data_store = $order->get_data_store();
			$cpt_data_store   = $table_data_store->get_cpt_data_store_instance();

			if ( ! $cpt_data_store ) {
				throw new \Exception( sprintf( 'No CPT data store found for order %d.', absint( $order_id ) ) );
			}

			$cpt_store_class_name = get_class( $cpt_data_store );
			if ( ! isset( $cpt_stores[ $cpt_store_class_name ] ) ) {
				$cpt_stores[ $cpt_store_class_name ]       = $cpt_data_store;
				$cpt_store_orders[ $cpt_store_class_name ] = array();
			}
			$cpt_store_orders[ $cpt_store_class_name ][ $order_id ] = $order;
		}

		$cpt_orders = array();
		foreach ( $cpt_stores as $cpt_store_name => $cpt_store ) {
			// Prime caches if we can.
			if ( method_exists( $cpt_store, 'prime_caches_for_orders' ) ) {
				$cpt_store->prime_caches_for_orders( array_keys( $cpt_store_orders[ $cpt_store_name ] ), array() );
			}

			foreach ( $cpt_store_orders[ $cpt_store_name ] as $order_id => $order ) {
				$cpt_order_class_name = wc_get_order_type( $order->get_type() )['class_name'];
				$cpt_order            = new $cpt_order_class_name();

				try {
					$cpt_order->set_id( $order_id );
					$cpt_store->read( $cpt_order );
					$cpt_orders[ $order_id ] = $cpt_order;
				} catch ( Exception $e ) {
					// If the post record has been deleted (for instance, by direct query) then an exception may be thrown.
					$this->error_logger->warning(
						sprintf(
							/* translators: %1$d order ID. */
							__( 'Unable to load the post record for order %1$d', 'woocommerce' ),
							$order_id
						),
						array(
							'exception_code' => $e->getCode(),
							'exception_msg'  => $e->getMessage(),
							'origin'         => __METHOD__,
						)
					);
				}
			}
		}
		return $cpt_orders;
	}

	/**
	 * Computes whether post has been updated after last order. Tries to do it as efficiently as possible.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 * @param \WC_Abstract_Order $post_order Order object read from posts table.
	 *
	 * @return bool True if post is different than order.
	 */
	private function is_post_different_from_order( $order, $post_order ): bool {
		if ( ArrayUtil::deep_compare_array_diff( $order->get_base_data(), $post_order->get_base_data(), false ) ) {
			return true;
		}

		$meta_diff = $this->get_diff_meta_data_between_orders( $order, $post_order );
		if ( ! empty( $meta_diff ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Migrate meta data from post to order.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 * @param \WC_Abstract_Order $post_order Order object read from posts table.
	 *
	 * @return array List of meta data that was migrated.
	 */
	private function migrate_meta_data_from_post_order( \WC_Abstract_Order &$order, \WC_Abstract_Order $post_order ) {
		$diff = $this->get_diff_meta_data_between_orders( $order, $post_order, true );
		$order->save_meta_data();
		return $diff;
	}

	/**
	 * Helper function to compute diff between metadata of post and cot data for an order.
	 *
	 * Also provides an option to sync the metadata as well, since we are already computing the diff.
	 *
	 * @param \WC_Abstract_Order $order1 Order object read from posts.
	 * @param \WC_Abstract_Order $order2 Order object read from COT.
	 * @param bool               $sync   Whether to also sync the meta data.
	 *
	 * @return array Difference between post and COT meta data.
	 */
	private function get_diff_meta_data_between_orders( \WC_Abstract_Order &$order1, \WC_Abstract_Order $order2, $sync = false ): array {
		$order1_meta        = ArrayUtil::select( $order1->get_meta_data(), 'get_data', ArrayUtil::SELECT_BY_OBJECT_METHOD );
		$order2_meta        = ArrayUtil::select( $order2->get_meta_data(), 'get_data', ArrayUtil::SELECT_BY_OBJECT_METHOD );
		$order1_meta_by_key = ArrayUtil::select_as_assoc( $order1_meta, 'key', ArrayUtil::SELECT_BY_ARRAY_KEY );
		$order2_meta_by_key = ArrayUtil::select_as_assoc( $order2_meta, 'key', ArrayUtil::SELECT_BY_ARRAY_KEY );

		$diff = array();
		foreach ( $order1_meta_by_key as $key => $value ) {
			if ( in_array( $key, $this->internal_meta_keys, true ) ) {
				// These should have already been verified in the base data comparison.
				continue;
			}
			$order1_values = ArrayUtil::select( $value, 'value', ArrayUtil::SELECT_BY_ARRAY_KEY );
			if ( ! array_key_exists( $key, $order2_meta_by_key ) ) {
				$sync && $order1->delete_meta_data( $key );
				$diff[ $key ] = $order1_values;
				unset( $order2_meta_by_key[ $key ] );
				continue;
			}

			$order2_values = ArrayUtil::select( $order2_meta_by_key[ $key ], 'value', ArrayUtil::SELECT_BY_ARRAY_KEY );
			$new_diff      = ArrayUtil::deep_assoc_array_diff( $order1_values, $order2_values );
			if ( ! empty( $new_diff ) && $sync ) {
				if ( count( $order2_values ) > 1 ) {
					$order1->delete_meta_data( $key );
					foreach ( $order2_values as $post_order_value ) {
						$order1->add_meta_data( $key, $post_order_value, false );
					}
				} else {
					$order1->update_meta_data( $key, $order2_values[0] );
				}
				$diff[ $key ] = $new_diff;
				unset( $order2_meta_by_key[ $key ] );
			}
		}

		foreach ( $order2_meta_by_key as $key => $value ) {
			if ( array_key_exists( $key, $order1_meta_by_key ) || in_array( $key, $this->internal_meta_keys, true ) ) {
				continue;
			}
			$order2_values = ArrayUtil::select( $value, 'value', ArrayUtil::SELECT_BY_ARRAY_KEY );
			foreach ( $order2_values as $meta_value ) {
				$sync && $order1->add_meta_data( $key, $meta_value );
			}
			$diff[ $key ] = $order2_values;
		}
		return $diff;
	}

	/**
	 * Migrate post record from a given order object.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 * @param \WC_Abstract_Order $post_order Order object read from posts.
	 *
	 * @return void
	 */
	private function migrate_post_record( \WC_Abstract_Order &$order, \WC_Abstract_Order $post_order ): void {
		self::$sync_on_read_order_ids[ $order->get_id() ] = true;

		$diff                 = $this->migrate_meta_data_from_post_order( $order, $post_order );
		$post_order_base_data = $post_order->get_base_data();
		foreach ( $post_order_base_data as $key => $value ) {
			// Skip migrating cogs_total_value if the HPOS order has a valid value and the CPT order has 0.
			// This prevents overwriting valid COGS data with recalculated zero values during sync-on-read.
			if ( 'cogs_total_value' === $key && $order->has_cogs() && $this->cogs_is_enabled() ) {
				$hpos_cogs = $order->get_cogs_total_value( 'edit' );
				if ( 0.0 !== $hpos_cogs && 0.0 === (float) $value ) {
					continue;
				}
			}
			$this->set_order_prop( $order, $key, $value );
		}
		$this->persist_updates( $order, false );

		unset( self::$sync_on_read_order_ids[ $order->get_id() ] );

		/**
		 * Fired when an HPOS order is updated from its corresponding post record on read due to a difference in the data.
		 *
		 * @since 8.5.0
		 *
		 * @param \WC_Order $order The order object.
		 * @param array     $diff  Difference between HPOS data and post data.
		 */
		do_action( 'woocommerce_hpos_post_record_migrated_on_read', $order, $diff );
	}

	/**
	 * Sets order properties based on a row from the database.
	 *
	 * @param \WC_Abstract_Order $order      The order object.
	 * @param object             $order_data A row of order data from the database.
	 */
	protected function set_order_props_from_data( &$order, $order_data ) {
		foreach ( $this->get_all_order_column_mappings() as $table_name => $column_mapping ) {
			foreach ( $column_mapping as $column_name => $prop_details ) {
				if ( ! isset( $prop_details['name'] ) ) {
					continue;
				}
				$prop_value = $order_data->{$prop_details['name']};
				if ( is_null( $prop_value ) ) {
					continue;
				}

				try {
					if ( 'date' === $prop_details['type'] ) {
						$prop_value = $this->string_to_timestamp( $prop_value );
					}

					$this->set_order_prop( $order, $prop_details['name'], $prop_value );
				} catch ( \Exception $e ) {
					$order_id = $order->get_id();
					$this->error_logger->warning(
						sprintf(
						/* translators: %1$d = peoperty name, %2$d = order ID, %3$s = error message. */
							__( 'Error when setting property \'%1$s\' for order %2$d: %3$s', 'woocommerce' ),
							$prop_details['name'],
							$order_id,
							$e->getMessage()
						),
						array(
							'exception_code' => $e->getCode(),
							'exception_msg'  => $e->getMessage(),
							'origin'         => __METHOD__,
							'order_id'       => $order_id,
							'property_name'  => $prop_details['name'],
						)
					);
				}
			}
		}
	}

	/**
	 * Set order prop if a setter exists in either the order object or in the data store.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 * @param string             $prop_name Property name.
	 * @param mixed              $prop_value Property value.
	 *
	 * @return bool True if the property was set, false otherwise.
	 */
	private function set_order_prop( \WC_Abstract_Order $order, string $prop_name, $prop_value ) {
		$prop_setter_function_name = "set_{$prop_name}";
		if ( is_callable( array( $order, $prop_setter_function_name ) ) ) {
			return $order->{$prop_setter_function_name}( $prop_value );
		} elseif ( is_callable( array( $this, $prop_setter_function_name ) ) ) {
			return $this->{$prop_setter_function_name}( $order, $prop_value, false );
		}
		return false;
	}

	/**
	 * Retrieve raw order data for multiple IDs.
	 *
	 * @param int[] $ids List of order IDs.
	 *
	 * @return \stdClass[] DB Order objects or error.
	 */
	protected function get_order_data_for_ids( array $ids ): array {
		if ( empty( $ids ) ) {
			return array();
		}

		$using_datastore_cache = OrderUtil::custom_orders_table_datastore_cache_enabled();
		$order_data            = array();

		if ( $using_datastore_cache ) {
			$order_data = $this->get_order_data_for_ids_from_cache( $ids );
			$ids        = array_diff( $ids, array_keys( $order_data ) );
		}

		if ( count( $ids ) > 0 ) {
			$db_order_data = $this->get_order_data_for_ids_from_db( $ids );
			$order_data    = $db_order_data + $order_data;
			if ( count( $db_order_data ) > 0 && $using_datastore_cache ) {
				$this->set_order_data_in_cache( $db_order_data );
			}
		}

		$order_data = array_filter( $order_data );

		$meta_data = $this->data_store_meta->get_meta_data_for_object_ids( array_keys( $order_data ) );

		foreach ( $meta_data as $order_id => $order_meta ) {
			$order_data[ $order_id ]->meta_data = $order_meta;
		}

		return $order_data;
	}

	/**
	 * Retrieve raw order data from the database for the given a set of IDs.
	 *
	 * @param int[] $ids List of order IDs.
	 *
	 * @return \stdClass[] Keyed array of objects containing raw order data keyed by the order IDs.
	 */
	private function get_order_data_for_ids_from_db( array $ids ): array {
		global $wpdb;

		if ( ! $ids || empty( $ids ) ) {
			return array();
		}

		$table_aliases     = array(
			'orders'           => $this->get_order_table_alias(),
			'billing_address'  => $this->get_address_table_alias( 'billing' ),
			'shipping_address' => $this->get_address_table_alias( 'shipping' ),
			'operational_data' => $this->get_op_table_alias(),
		);
		$order_table_alias = $table_aliases['orders'];
		$order_table_query = $this->get_order_table_select_statement();
		$id_placeholder    = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_table_query is autogenerated and should already be prepared.
		$table_data = $wpdb->get_results(
			$wpdb->prepare(
				"$order_table_query WHERE $order_table_alias.id in ( $id_placeholder )",
				$ids
			)
		);
		// phpcs:enable

		$order_data = array();
		foreach ( $table_data as $table_datum ) {
			$id                = $table_datum->{"{$order_table_alias}_id"};
			$order_data[ $id ] = new \stdClass();
			foreach ( $this->get_all_order_column_mappings() as $table_name => $column_mappings ) {
				$table_alias = $table_aliases[ $table_name ];
				// This remapping is required to keep the query length small enough to be supported by implementations such as HyperDB (i.e. fetching some tables in join via alias.*, while others via full name). We can revert this commit if HyperDB starts supporting SRTM for query length more than 3076 characters.
				foreach ( $column_mappings as $field => $map ) {
					$field_name = $map['name'] ?? "{$table_name}_$field";
					if ( property_exists( $table_datum, $field_name ) ) {
						$field_value = $table_datum->{$field_name}; // Unique column, field name is different prop name.
					} elseif ( property_exists( $table_datum, "{$table_alias}_$field" ) ) {
						$field_value = $table_datum->{"{$table_alias}_$field"}; // Non-unique column (billing, shipping etc).
					} else {
						$field_value = $table_datum->{$field}; // Unique column, field name is same as prop name.
					}
					$order_data[ $id ]->{$field_name} = $field_value;
				}
			}
			$order_data[ $id ]->id        = $id;
			$order_data[ $id ]->meta_data = array();
		}

		return $order_data;
	}

	/**
	 * Retrieve raw order data from cache for the given a set of IDs.
	 *
	 * @param int[] $ids List of order IDs.
	 *
	 * @return \stdClass[] Keyed array of objects containing raw order data keyed by the order IDs.
	 */
	private function get_order_data_for_ids_from_cache( array $ids ): array {
		$cache_engine = wc_get_container()->get( WPCacheEngine::class );

		return array_filter( $cache_engine->get_cached_objects( $ids, $this->get_cache_group() ) );
	}

	/**
	 * Store the raw data for a set of orders in cache.
	 *
	 * @param \stdClass[][] $order_data An array of raw order records to set in cache keyed by the order IDs.
	 *
	 * @return void
	 */
	private function set_order_data_in_cache( array $order_data ) {
		$cache_engine = wc_get_container()->get( WPCacheEngine::class );
		$cache_engine->cache_objects( $order_data, 0, $this->get_cache_group() );
	}

	/**
	 * Helper method to generate combined select statement.
	 *
	 * @return string Select SQL statement to fetch order.
	 */
	private function get_order_table_select_statement() {
		$order_table                  = $this::get_orders_table_name();
		$order_table_alias            = $this->get_order_table_alias();
		$billing_address_table_alias  = $this->get_address_table_alias( 'billing' );
		$shipping_address_table_alias = $this->get_address_table_alias( 'shipping' );
		$op_data_table_alias          = $this->get_op_table_alias();
		$billing_address_clauses      = $this->join_billing_address_table_to_order_query( $order_table_alias, $billing_address_table_alias );
		$shipping_address_clauses     = $this->join_shipping_address_table_to_order_query( $order_table_alias, $shipping_address_table_alias );
		$operational_data_clauses     = $this->join_operational_data_table_to_order_query( $order_table_alias, $op_data_table_alias );

		/**
		 * We fully spell out address table columns because they have duplicate columns for billing and shipping and would be overwritten if we don't spell them out. There is not such duplication in the operational data table and orders table, so select with `alias`.* is fine.
		 * We do spell ID columns manually, as they are duplicate.
		 */
		return "
SELECT $order_table_alias.id as o_id, $op_data_table_alias.id as p_id, $order_table_alias.*, {$billing_address_clauses['select']}, {$shipping_address_clauses['select']}, $op_data_table_alias.*
FROM $order_table $order_table_alias
LEFT JOIN {$billing_address_clauses['join']}
LEFT JOIN {$shipping_address_clauses['join']}
LEFT JOIN {$operational_data_clauses['join']}
";
	}

	/**
	 * Helper function to generate select statement for fetching metadata in bulk.
	 *
	 * @return string Select SQL statement to fetch order metadata.
	 */
	private function get_order_meta_select_statement() {
		$order_meta_table = self::get_meta_table_name();
		return "
SELECT $order_meta_table.id, $order_meta_table.order_id, $order_meta_table.meta_key, $order_meta_table.meta_value
FROM $order_meta_table
		";
	}

	/**
	 * Helper method to generate join query for billing addresses in wc_address table.
	 *
	 * @param string $order_table_alias Alias for order table to use in join.
	 * @param string $address_table_alias Alias for address table to use in join.
	 *
	 * @return array Select and join statements for billing address table.
	 */
	private function join_billing_address_table_to_order_query( $order_table_alias, $address_table_alias ) {
		return $this->join_address_table_order_query( 'billing', $order_table_alias, $address_table_alias );
	}

	/**
	 * Helper method to generate join query for shipping addresses in wc_address table.
	 *
	 * @param string $order_table_alias Alias for order table to use in join.
	 * @param string $address_table_alias Alias for address table to use in join.
	 *
	 * @return array Select and join statements for shipping address table.
	 */
	private function join_shipping_address_table_to_order_query( $order_table_alias, $address_table_alias ) {
		return $this->join_address_table_order_query( 'shipping', $order_table_alias, $address_table_alias );
	}

	/**
	 * Helper method to generate join and select query for address table.
	 *
	 * @param string $address_type Type of address; 'billing' or 'shipping'.
	 * @param string $order_table_alias Alias of order table to use.
	 * @param string $address_table_alias Alias for address table to use.
	 *
	 * @return array Select and join statements for address table.
	 */
	private function join_address_table_order_query( $address_type, $order_table_alias, $address_table_alias ) {
		global $wpdb;
		$address_table    = $this::get_addresses_table_name();
		$column_props_map = 'billing' === $address_type ? $this->billing_address_column_mapping : $this->shipping_address_column_mapping;
		$clauses          = $this->generate_select_and_join_clauses( $order_table_alias, $address_table, $address_table_alias, $column_props_map );
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $clauses['join'] and $address_table_alias are hardcoded.
		$clauses['join'] = $wpdb->prepare(
			"{$clauses['join']} AND $address_table_alias.address_type = %s",
			$address_type
		);

		// phpcs:enable
		return array(
			'select' => $clauses['select'],
			'join'   => $clauses['join'],
		);
	}

	/**
	 * Helper method to join order operational data table.
	 *
	 * @param string $order_table_alias Alias to use for order table.
	 * @param string $operational_table_alias Alias to use for operational data table.
	 *
	 * @return array Select and join queries for operational data table.
	 */
	private function join_operational_data_table_to_order_query( $order_table_alias, $operational_table_alias ) {
		$operational_data_table = $this::get_operational_data_table_name();

		return $this->generate_select_and_join_clauses(
			$order_table_alias,
			$operational_data_table,
			$operational_table_alias,
			$this->operational_data_column_mapping
		);
	}

	/**
	 * Helper method to generate join and select clauses.
	 *
	 * @param string  $order_table_alias Alias for order table.
	 * @param string  $table Table to join.
	 * @param string  $table_alias Alias for table to join.
	 * @param array[] $column_props_map Column to prop map for table to join.
	 *
	 * @return array Select and join queries.
	 */
	private function generate_select_and_join_clauses( $order_table_alias, $table, $table_alias, $column_props_map ) {
		// Add aliases to column names so they will be unique when fetching.
		$select_clause = $this->generate_select_clause_for_props( $table_alias, $column_props_map );
		$join_clause   = "$table $table_alias ON $table_alias.order_id = $order_table_alias.id";

		return array(
			'select' => $select_clause,
			'join'   => $join_clause,
		);
	}

	/**
	 * Helper method to generate select clause for props.
	 *
	 * @param string  $table_alias Alias for table.
	 * @param array[] $props Props to column mapping for table.
	 *
	 * @return string Select clause.
	 */
	private function generate_select_clause_for_props( $table_alias, $props ) {
		$select_clauses = array();
		foreach ( $props as $column_name => $prop_details ) {
			$select_clauses[] = isset( $prop_details['name'] ) ? "$table_alias.$column_name as {$prop_details['name']}" : "$table_alias.$column_name as {$table_alias}_$column_name";
		}

		return implode( ', ', $select_clauses );
	}

	/**
	 * Persists order changes to the database.
	 *
	 * @param \WC_Abstract_Order $order            The order.
	 * @param bool               $force_all_fields Force saving all fields to DB and just changed.
	 *
	 * @throws \Exception If order data is not valid.
	 *
	 * @since 6.8.0
	 */
	protected function persist_order_to_db( &$order, bool $force_all_fields = false ) {
		$context = ( 0 === absint( $order->get_id() ) ) ? 'create' : 'update';

		if ( 'create' === $context ) {
			$post_id = $this->maybe_create_backup_post( $order, 'create' );
			if ( ! $post_id ) {
				throw new \Exception( esc_html__( 'Could not create order in posts table.', 'woocommerce' ) );
			}

			$order->set_id( $post_id );
		}

		$only_changes = ! $force_all_fields && 'update' === $context;
		// Figure out what needs to be updated in the database.
		$db_updates = $this->get_db_rows_for_order( $order, $context, $only_changes );

		// Persist changes.
		foreach ( $db_updates as $update ) {
			// Make sure 'data' and 'format' entries match before passing to $wpdb.
			ksort( $update['data'] );
			ksort( $update['format'] );

			$result = $this->persist_db_row( $update );
			if ( false === $result ) {
				// translators: %s is a table name.
				throw new \Exception( esc_html( sprintf( __( 'Could not persist order to database table "%s".', 'woocommerce' ), $update['table'] ) ) );
			}
		}

		$changes = $order->get_changes();
		$this->update_address_index_meta( $order, $changes );
		$default_taxonomies = $this->init_default_taxonomies( $order, array() );
		$this->set_custom_taxonomies( $order, $default_taxonomies );

		if ( $order->has_cogs() && $this->cogs_is_enabled() ) {
			$this->save_cogs_data( $order );
		}

		$this->clear_cached_data( array( $order->get_id() ) );
	}

	/**
	 * Save the Cost of Goods Sold value of a given order to the database.
	 *
	 * @param WC_Abstract_Order $order The order to save the COGS value for.
	 */
	private function save_cogs_data( WC_Abstract_Order $order ) {
		$cogs_value = $order->get_cogs_total_value();

		/**
		 * Filter to customize the Cost of Goods Sold value that gets saved for a given order,
		 * or to suppress the saving of the value (so that custom storage can be used).
		 *
		 * @since 9.5.0
		 *
		 * @param float|null $cogs_value The value to be written to the database. If returned as null, nothing will be written.
		 * @param WC_Abstract_Order $item The order for which the value is being saved.
		 */
		$cogs_value = apply_filters( 'woocommerce_save_order_cogs_value', $cogs_value, $order );
		if ( is_null( $cogs_value ) ) {
			return;
		}

		$existing_meta = $this->data_store_meta->get_metadata_by_key( $order, '_cogs_total_value' );

		if ( 0.0 === $cogs_value && $existing_meta ) {
			$existing_meta = current( $existing_meta );
			$this->data_store_meta->delete_meta( $order, $existing_meta );
		} elseif ( $existing_meta ) {
				$existing_meta        = current( $existing_meta );
				$existing_meta->key   = '_cogs_total_value';
				$existing_meta->value = $cogs_value;
				$this->data_store_meta->update_meta( $order, $existing_meta );
		} else {
			$meta        = new \WC_Meta_Data();
			$meta->key   = '_cogs_total_value';
			$meta->value = $cogs_value;
			$this->data_store_meta->add_meta( $order, $meta );
		}
	}

	/**
	 * Takes care of creating the backup post in the posts table (placeholder or actual order post, depending on sync settings).
	 *
	 * @since 8.8.0
	 *
	 * @param \WC_Abstract_Order $order   The order.
	 * @param string             $context The context: either 'create' or 'backfill'.
	 * @return int The new post ID.
	 */
	protected function maybe_create_backup_post( &$order, string $context ): int {
		$data_sync = wc_get_container()->get( DataSynchronizer::class );

		$data = array(
			'post_type'     => $data_sync->data_sync_is_enabled() ? $order->get_type() : $data_sync::PLACEHOLDER_ORDER_POST_TYPE,
			'post_status'   => 'draft',
			'post_parent'   => $order->get_changes()['parent_id'] ?? $order->get_data()['parent_id'] ?? 0,
			'post_date'     => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getOffsetTimestamp() ),
			'post_date_gmt' => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ),
		);

		if ( 'backfill' === $context ) {
			if ( ! $order->get_id() ) {
				return 0;
			}

			$data['import_id'] = $order->get_id();
		}

		return wp_insert_post( $data );
	}

	/**
	 * Set default taxonomies for the order.
	 *
	 * Note: This is re-implementation of part of WP core's `wp_insert_post` function. Since the code block that set default taxonomies is not filterable, we have to re-implement it.
	 *
	 * @param \WC_Abstract_Order $order               Order object.
	 * @param array              $sanitized_tax_input Sanitized taxonomy input.
	 *
	 * @return array Sanitized tax input with default taxonomies.
	 */
	public function init_default_taxonomies( \WC_Abstract_Order $order, array $sanitized_tax_input ) {
		if ( 'auto-draft' === $order->get_status() ) {
			return $sanitized_tax_input;
		}

		foreach ( get_object_taxonomies( $order->get_type(), 'object' ) as $taxonomy => $tax_object ) {
			if ( empty( $tax_object->default_term ) ) {
				return $sanitized_tax_input;
			}

			// Filter out empty terms.
			if ( isset( $sanitized_tax_input[ $taxonomy ] ) && is_array( $sanitized_tax_input[ $taxonomy ] ) ) {
				$sanitized_tax_input[ $taxonomy ] = array_filter( $sanitized_tax_input[ $taxonomy ] );
			}

			// Passed custom taxonomy list overwrites the existing list if not empty.
			$terms = wp_get_object_terms( $order->get_id(), $taxonomy, array( 'fields' => 'ids' ) );
			if ( ! empty( $terms ) && empty( $sanitized_tax_input[ $taxonomy ] ) ) {
				$sanitized_tax_input[ $taxonomy ] = $terms;
			}

			if ( empty( $sanitized_tax_input[ $taxonomy ] ) ) {
				$default_term_id = get_option( 'default_term_' . $taxonomy );
				if ( ! empty( $default_term_id ) ) {
					$sanitized_tax_input[ $taxonomy ] = array( (int) $default_term_id );
				}
			}
		}
		return $sanitized_tax_input;
	}

	/**
	 * Set custom taxonomies for the order.
	 *
	 * Note: This is re-implementation of part of WP core's `wp_insert_post` function. Since the code block that set custom taxonomies is not filterable, we have to re-implement it.
	 *
	 * @param \WC_Abstract_Order $order               Order object.
	 * @param array              $sanitized_tax_input Sanitized taxonomy input.
	 *
	 * @return void
	 */
	public function set_custom_taxonomies( \WC_Abstract_Order $order, array $sanitized_tax_input ) {
		if ( empty( $sanitized_tax_input ) ) {
			return;
		}

		foreach ( $sanitized_tax_input as $taxonomy => $tags ) {
			$taxonomy_obj = get_taxonomy( $taxonomy );

			if ( ! $taxonomy_obj ) {
				/* translators: %s: Taxonomy name. */
				_doing_it_wrong( __FUNCTION__, esc_html( sprintf( __( 'Invalid taxonomy: %s.', 'woocommerce' ), $taxonomy ) ), '7.9.0' );
				continue;
			}

			// array = hierarchical, string = non-hierarchical.
			if ( is_array( $tags ) ) {
				$tags = array_filter( $tags );
			}

			if ( current_user_can( $taxonomy_obj->cap->assign_terms ) ) {
				wp_set_post_terms( $order->get_id(), $tags, $taxonomy );
			}
		}
	}

	/**
	 * Generates an array of rows with all the details required to insert or update an order in the database.
	 *
	 * @param \WC_Abstract_Order $order The order.
	 * @param string             $context The context: 'create' or 'update'.
	 * @param boolean            $only_changes Whether to consider only changes in the order for generating the rows.
	 *
	 * @return array
	 * @throws \Exception When invalid data is found for the given context.
	 *
	 * @since 6.8.0
	 */
	protected function get_db_rows_for_order( \WC_Abstract_Order $order, string $context = 'create', bool $only_changes = false ): array {
		$result = array();

		$row = $this->get_db_row_from_order( $order, $this->order_column_mapping, $only_changes );
		if ( 'create' === $context && ! $row ) {
			throw new \Exception( 'No data for new record.' ); // This shouldn't occur.
		}

		if ( $row ) {
			$result[] = array(
				'table'  => self::get_orders_table_name(),
				'data'   => array_merge(
					$row['data'],
					array(
						'id'   => $order->get_id(),
						'type' => $order->get_type(),
					)
				),
				'format' => array_merge(
					$row['format'],
					array(
						'id'   => '%d',
						'type' => '%s',
					)
				),
			);
		}

		// wc_order_operational_data.
		$row = $this->get_db_row_from_order( $order, $this->operational_data_column_mapping, $only_changes );
		if ( $row ) {
			$result[] = array(
				'table'  => self::get_operational_data_table_name(),
				'data'   => array_merge( $row['data'], array( 'order_id' => $order->get_id() ) ),
				'format' => array_merge( $row['format'], array( 'order_id' => '%d' ) ),
			);
		}

		// wc_order_addresses.
		foreach ( array( 'billing', 'shipping' ) as $address_type ) {
			$row = $this->get_db_row_from_order( $order, $this->{$address_type . '_address_column_mapping'}, $only_changes );

			if ( $row ) {
				$result[] = array(
					'table'        => self::get_addresses_table_name(),
					'data'         => array_merge(
						$row['data'],
						array(
							'order_id'     => $order->get_id(),
							'address_type' => $address_type,
						)
					),
					'format'       => array_merge(
						$row['format'],
						array(
							'order_id'     => '%d',
							'address_type' => '%s',
						)
					),
					'where'        => array(
						'order_id'     => $order->get_id(),
						'address_type' => $address_type,
					),
					'where_format' => array( '%d', '%s' ),
				);
			}
		}

		/**
		 * Allow third parties to include rows that need to be inserted/updated in custom tables when persisting an order.
		 *
		 * @since 6.8.0
		 *
		 * @param array      Array of rows to be inserted/updated when persisting an order. Each entry should be an array with
		 *                   keys 'table', 'data' (the row), 'format' (row format), 'where' and 'where_format'.
		 * @param \WC_Order  The order object.
		 * @param string     The context of the operation: 'create' or 'update'.
		 */
		$ext_rows = apply_filters( 'woocommerce_orders_table_datastore_extra_db_rows_for_order', array(), $order, $context );

		/**
		 * Filters the rows that are going to be inserted or updated during an order save.
		 *
		 * @since 8.8.0
		 * @internal Use 'woocommerce_orders_table_datastore_extra_db_rows_for_order' for adding rows to the database save.
		 *
		 * @param array     $rows    Array of rows to be inserted/updated. See 'woocommerce_orders_table_datastore_extra_db_rows_for_order' for exact format.
		 * @param \WC_Order $order   The order object.
		 * @param string    $context The context of the operation: 'create' or 'update'.
		 */
		$result = apply_filters(
			'woocommerce_orders_table_datastore_db_rows_for_order',
			array_merge( $result, $ext_rows ),
			$order,
			$context
		);

		return $result;
	}

	/**
	 * Produces an array with keys 'row' and 'format' that can be passed to `$wpdb->update()` as the `$data` and
	 * `$format` parameters. Values are taken from the order changes array and properly formatted for inclusion in the
	 * database.
	 *
	 * @param \WC_Abstract_Order $order          Order.
	 * @param array              $column_mapping Table column mapping.
	 * @param bool               $only_changes   Whether to consider only changes in the order object or all fields.
	 * @return array
	 *
	 * @since 6.8.0
	 */
	protected function get_db_row_from_order( $order, $column_mapping, $only_changes = false ) {
		$changes = $only_changes ? $order->get_changes() : array_merge( $order->get_data(), $order->get_changes() );

		// Make sure 'status' is correctly prefixed.
		if ( array_key_exists( 'status', $column_mapping ) && array_key_exists( 'status', $changes ) ) {
			$changes['status'] = $this->get_post_status( $order );
		}

		$row        = array();
		$row_format = array();

		foreach ( $column_mapping as $column => $details ) {
			if ( ! isset( $details['name'] ) || ! array_key_exists( $details['name'], $changes ) ) {
				continue;
			}

			$row[ $column ]        = $this->database_util->format_object_value_for_db( $changes[ $details['name'] ], $details['type'] );
			$row_format[ $column ] = $this->database_util->get_wpdb_format_for_type( $details['type'] );
		}

		if ( ! $row ) {
			return false;
		}

		return array(
			'data'   => $row,
			'format' => $row_format,
		);
	}

	/**
	 * Method to delete an order from the database.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 * @param array              $args Array of args to pass to the delete method.
	 *
	 * @return void
	 */
	public function delete( &$order, $args = array() ) {
		$order_id = $order->get_id();

		if ( ! $order_id ) {
			return;
		}

		$args = wp_parse_args(
			$args,
			array(
				'force_delete'     => false,
				'suppress_filters' => false,
			)
		);

		$do_filters = ! $args['suppress_filters'];

		if ( $args['force_delete'] ) {

			if ( $do_filters ) {
				/**
				 * Fires immediately before an order is deleted from the database.
				 *
				 * @since 7.1.0
				 *
				 * @param int      $order_id ID of the order about to be deleted.
				 * @param WC_Order $order    Instance of the order that is about to be deleted.
				 */
				do_action( 'woocommerce_before_delete_order', $order_id, $order );
			}

			$this->upshift_or_delete_child_orders( $order );
			$this->delete_order_data_from_custom_order_tables( $order_id );
			$this->delete_items( $order );

			$order->set_id( 0 );

			/** We can delete the post data if:
			 * 1. The HPOS table is authoritative and synchronization is enabled.
			 * 2. The post record is of type `shop_order_placehold`, since this is created by the HPOS in the first place.
			 *
			 * In other words, we do not delete the post record when HPOS table is authoritative and synchronization is disabled but post record is a full record and not just a placeholder, because it implies that the order was created before HPOS was enabled.
			 */
			$orders_table_is_authoritative = $order->get_data_store()->get_current_class_name() === self::class;

			if ( $orders_table_is_authoritative ) {
				$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
				if ( $data_synchronizer->data_sync_is_enabled() ) {
					// Delete the associated post, which in turn deletes order items, etc. through {@see WC_Post_Data}.
					// Once we stop creating posts for orders, we should do the cleanup here instead.
					wp_delete_post( $order_id );
				} else {
					$this->handle_order_deletion_with_sync_disabled( $order_id );
				}
			}

			if ( $do_filters ) {
				/**
				 * Fires immediately after an order is deleted.
				 *
				 * @since 2.7.0
				 *
				 * @param int $order_id ID of the order that has been deleted.
				 */
				do_action( 'woocommerce_delete_order', $order_id ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
			}
		} else {
			if ( $do_filters ) {
				/**
				 * Fires immediately before an order is trashed.
				 *
				 * @since 7.1.0
				 *
				 * @param int      $order_id ID of the order about to be trashed.
				 * @param WC_Order $order    Instance of the order that is about to be trashed.
				 */
				do_action( 'woocommerce_before_trash_order', $order_id, $order );
			}

			$this->trash_order( $order );

			if ( $do_filters ) {
				/**
				 * Fires immediately after an order is trashed.
				 *
				 * @since 2.7.0
				 *
				 * @param int $order_id ID of the order that has been trashed.
				 */
				do_action( 'woocommerce_trash_order', $order_id ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
			}
		}
	}

	/**
	 * Handles the deletion of an order from the orders table when sync is disabled:
	 *
	 * If the corresponding row in the posts table is of placeholder type,
	 * it's just deleted; otherwise a "deleted_from" record is created in the meta table
	 * and the sync process will detect these and take care of deleting the appropriate post records.
	 *
	 * @param int $order_id Th id of the order that has been deleted from the orders table.
	 * @return void
	 */
	protected function handle_order_deletion_with_sync_disabled( $order_id ): void {
		global $wpdb;

		$post_type = $wpdb->get_var(
			$wpdb->prepare( "SELECT post_type FROM {$wpdb->posts} WHERE ID=%d", $order_id )
		);

		if ( DataSynchronizer::PLACEHOLDER_ORDER_POST_TYPE === $post_type ) {
			$wpdb->query(
				$wpdb->prepare(
					"DELETE FROM {$wpdb->posts} WHERE ID=%d OR post_parent=%d",
					$order_id,
					$order_id
				)
			);
			clean_post_cache( $order_id );
		} else {
			// phpcs:disable WordPress.DB.SlowDBQuery
			$wpdb->insert(
				self::get_meta_table_name(),
				array(
					'order_id'   => $order_id,
					'meta_key'   => DataSynchronizer::DELETED_RECORD_META_KEY,
					'meta_value' => DataSynchronizer::DELETED_FROM_ORDERS_META_VALUE,
				)
			);
			// phpcs:enable WordPress.DB.SlowDBQuery

			// Note that at this point upshift_or_delete_child_orders will already have been invoked,
			// thus all the child orders either still exist but have a different parent id,
			// or have been deleted and got their own deletion record already.
			// So there's no need to do anything about them.
		}
	}

	/**
	 * Set the parent id of child orders to the parent order's parent if the post type
	 * for the order is hierarchical, just delete the child orders otherwise.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 *
	 * @return void
	 */
	private function upshift_or_delete_child_orders( $order ): void {
		global $wpdb;

		$order_table     = self::get_orders_table_name();
		$order_parent_id = $order->get_parent_id();

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$child_order_ids = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT id FROM $order_table WHERE parent_order_id=%d",
				$order->get_id()
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( empty( $child_order_ids ) ) {
			return;
		}

		if ( $this->legacy_proxy->call_function( 'is_post_type_hierarchical', $order->get_type() ) ) {
			$wpdb->update(
				$order_table,
				array( 'parent_order_id' => $order_parent_id ),
				array( 'parent_order_id' => $order->get_id() ),
				array( '%d' ),
				array( '%d' )
			);

			$this->clear_cached_data( $child_order_ids );
		} else {
			foreach ( $child_order_ids as $child_order_id ) {
				$child_order = wc_get_order( $child_order_id );
				if ( $child_order ) {
					$child_order->delete( true );
				}
			}
		}
	}

	/**
	 * Trashes an order.
	 *
	 * @param  WC_Order $order The order object.
	 *
	 * @return void
	 */
	public function trash_order( $order ) {
		global $wpdb;

		if ( 'trash' === $order->get_status( 'edit' ) ) {
			return;
		}

		$trash_metadata = array(
			'_wp_trash_meta_status' => 'wc-' . $order->get_status( 'edit' ),
			'_wp_trash_meta_time'   => time(),
		);

		$wpdb->update(
			self::get_orders_table_name(),
			array(
				'status'           => 'trash',
				'date_updated_gmt' => current_time( 'Y-m-d H:i:s', true ),
			),
			array( 'id' => $order->get_id() ),
			array( '%s', '%s' ),
			array( '%d' )
		);

		$order->set_status( 'trash' );

		foreach ( $trash_metadata as $meta_key => $meta_value ) {
			$this->add_meta(
				$order,
				(object) array(
					'key'   => $meta_key,
					'value' => $meta_value,
				)
			);
		}

		$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
		if ( $data_synchronizer->data_sync_is_enabled() ) {
			wp_trash_post( $order->get_id() );
		}

		$this->clear_cached_data( array( $order->get_id() ) );
	}

	/**
	 * Attempts to restore the specified order back to its original status (after having been trashed).
	 *
	 * @param WC_Order $order The order to be untrashed.
	 *
	 * @return bool If the operation was successful.
	 */
	public function untrash_order( WC_Order $order ): bool {
		$id     = $order->get_id();
		$status = $order->get_status();

		if ( 'trash' !== $status ) {
			wc_get_logger()->warning(
				sprintf(
					/* translators: 1: order ID, 2: order status */
					__( 'Order %1$d cannot be restored from the trash: it has already been restored to status "%2$s".', 'woocommerce' ),
					$id,
					$status
				)
			);
			return false;
		}

		$previous_status           = $order->get_meta( '_wp_trash_meta_status' );
		$valid_statuses            = wc_get_order_statuses();
		$previous_state_is_invalid = ! array_key_exists( $previous_status, $valid_statuses );
		$pending_is_valid_status   = array_key_exists( OrderInternalStatus::PENDING, $valid_statuses );

		if ( $previous_state_is_invalid && $pending_is_valid_status ) {
			// If the previous status is no longer valid, let's try to restore it to "pending" instead.
			wc_get_logger()->warning(
				sprintf(
					/* translators: 1: order ID, 2: order status */
					__( 'The previous status of order %1$d ("%2$s") is invalid. It has been restored to "pending" status instead.', 'woocommerce' ),
					$id,
					$previous_status
				)
			);

			$previous_status = 'pending';
		} elseif ( $previous_state_is_invalid ) {
			// If we cannot restore to pending, we should probably stand back and let the merchant intervene some other way.
			wc_get_logger()->warning(
				sprintf(
					/* translators: 1: order ID, 2: order status */
					__( 'The previous status of order %1$d ("%2$s") is invalid. It could not be restored.', 'woocommerce' ),
					$id,
					$previous_status
				)
			);

			return false;
		}

		/**
		 * Fires before an order is restored from the trash.
		 *
		 * @since 7.2.0
		 *
		 * @param int    $order_id        Order ID.
		 * @param string $previous_status The status of the order before it was trashed.
		 */
		do_action( 'woocommerce_untrash_order', $order->get_id(), $previous_status );

		$order->set_status( $previous_status );
		$order->save();

		// Was the status successfully restored? Let's clean up the meta and indicate success...
		if ( 'wc-' . $order->get_status() === $previous_status ) {
			$order->delete_meta_data( '_wp_trash_meta_status' );
			$order->delete_meta_data( '_wp_trash_meta_time' );
			$order->delete_meta_data( '_wp_trash_meta_comments_status' );
			$order->save_meta_data();

			return true;
		}

		// ...Or log a warning and bail.
		wc_get_logger()->warning(
			sprintf(
				/* translators: 1: order ID, 2: order status */
				__( 'Something went wrong when trying to restore order %d from the trash. It could not be restored.', 'woocommerce' ),
				$id
			)
		);

		return false;
	}


	/**
	 * Deletes order data from custom order tables.
	 *
	 * @param int $order_id The order ID.
	 * @return void
	 */
	public function delete_order_data_from_custom_order_tables( $order_id ) {
		global $wpdb;
		$order_cache = wc_get_container()->get( OrderCache::class );

		// Delete COT-specific data.
		foreach ( $this->get_all_table_names() as $table ) {
			$wpdb->delete(
				$table,
				( self::get_orders_table_name() === $table )
					? array( 'id' => $order_id )
					: array( 'order_id' => $order_id ),
				array( '%d' )
			);
			$order_cache->remove( $order_id );
		}

		$this->clear_cached_data( array( $order_id ) );
	}

	/**
	 * Method to create an order in the database.
	 *
	 * @param \WC_Order $order Order object.
	 */
	public function create( &$order ) {
		if ( '' === $order->get_order_key() ) {
			$order->set_order_key( wc_generate_order_key() );
		}

		$this->persist_save( $order );

		// Do not fire 'woocommerce_new_order' for draft statuses for backwards compatibility.
		if ( in_array( $order->get_status( 'edit' ), array( 'auto-draft', 'draft', 'checkout-draft' ), true ) ) {
			return;
		}

		/**
		 * Fires when a new order is created.
		 *
		 * @since 2.7.0
		 *
		 * @param int       Order ID.
		 * @param \WC_Order Order object.
		 */
		do_action( 'woocommerce_new_order', $order->get_id(), $order );
	}

	/**
	 * Helper method responsible for persisting new data to order table.
	 *
	 * This should not contain and specific meta or actions, so that it can be used other order types safely.
	 *
	 * @param \WC_Order $order Order object.
	 * @param bool      $force_all_fields Force update all fields, instead of calculating and updating only changed fields.
	 * @param bool      $backfill Whether to backfill data to post datastore.
	 *
	 * @return void
	 *
	 * @throws \Exception When unable to save data.
	 */
	protected function persist_save( &$order, bool $force_all_fields = false, $backfill = true ) {
		$order->set_version( Constants::get_constant( 'WC_VERSION' ) );
		$order->set_currency( $order->get_currency() ? $order->get_currency() : get_woocommerce_currency() );

		if ( ! $order->get_date_created( 'edit' ) ) {
			$order->set_date_created( time() );
		}

		if ( ! $order->get_date_modified( 'edit' ) ) {
			$order->set_date_modified( current_time( 'mysql' ) );
		}

		$this->persist_order_to_db( $order, $force_all_fields );

		$this->update_order_meta( $order );

		$order->save_meta_data();
		$order->apply_changes();

		if ( $backfill ) {
			self::$backfilling_order_ids[] = $order->get_id();
			$r_order                       = wc_get_order( $order->get_id() ); // Refresh order to account for DB changes from post hooks.
			$this->maybe_backfill_post_record( $r_order );
			self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $order->get_id() ) );
		}
		$this->clear_caches( $order );
	}

	/**
	 * Method to update an order in the database.
	 *
	 * @param \WC_Order $order Order object.
	 */
	public function update( &$order ) {
		$previous_status = ArrayUtil::get_value_or_default( $order->get_data(), 'status', 'new' );

		// Before updating, ensure date paid is set if missing.
		if (
			! $order->get_date_paid( 'edit' )
			&& version_compare( $order->get_version( 'edit' ), '3.0', '<' )
			&& $order->has_status( apply_filters( 'woocommerce_payment_complete_order_status', $order->needs_processing() ? 'processing' : 'completed', $order->get_id(), $order ) ) // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
		) {
			$order->set_date_paid( $order->get_date_created( 'edit' ) );
		}

		if ( null === $order->get_date_created( 'edit' ) ) {
			$order->set_date_created( time() );
		}

		$order->set_version( Constants::get_constant( 'WC_VERSION' ) );

		// Fetch changes.
		$changes = $order->get_changes();

		// Does not make much sense to backfill to posts an order being sync-on-read from posts.
		$should_backfill = ! isset( self::$sync_on_read_order_ids[ $order->get_id() ] );

		$this->persist_updates( $order, $should_backfill );

		// Update download permissions if necessary.
		if ( array_key_exists( 'billing_email', $changes ) || array_key_exists( 'customer_id', $changes ) ) {
			$data_store = \WC_Data_Store::load( 'customer-download' );
			$data_store->update_user_by_order_id( $order->get_id(), $order->get_customer_id(), $order->get_billing_email() );
		}

		// Mark user account as active.
		if ( array_key_exists( 'customer_id', $changes ) ) {
			wc_update_user_last_active( $order->get_customer_id() );
		}

		$order->apply_changes();
		$this->clear_caches( $order );

		$draft_statuses = array( 'new', 'auto-draft', 'draft', 'checkout-draft' );

		// For backwards compatibility, this hook should be fired only if the new status is not one of the draft statuses and the previous status was one of the draft statuses.
		if (
			! empty( $changes['status'] )
			&& $changes['status'] !== $previous_status
			&& ! in_array( $changes['status'], $draft_statuses, true )
			&& in_array( $previous_status, $draft_statuses, true )
		) {
			do_action( 'woocommerce_new_order', $order->get_id(), $order ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
			return;
		}

		// For backwards compat with CPT, trashing/untrashing and changing previously datastore-level props does not trigger the update hook.
		if ( ( ! empty( $changes['status'] ) && in_array( 'trash', array( $changes['status'], $previous_status ), true ) )
			|| ( ! empty( $changes ) && ! array_diff_key( $changes, array_flip( $this->get_post_data_store_for_backfill()->get_internal_data_store_key_getters() ) ) ) ) {
			return;
		}

		do_action( 'woocommerce_update_order', $order->get_id(), $order ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
	}

	/**
	 * Proxy to updating order meta. Here for backward compatibility reasons.
	 *
	 * @param \WC_Order $order Order object.
	 *
	 * @return void
	 */
	protected function update_post_meta( &$order ) {
		$this->update_order_meta( $order );
	}

	/**
	 * Helper method that is responsible for persisting order updates to the database.
	 *
	 * This is expected to be reused by other order types, and should not contain any specific metadata updates or actions.
	 *
	 * @param \WC_Order $order Order object.
	 * @param bool      $backfill Whether to backfill data to post tables.
	 *
	 * @return array $changes Array of changes.
	 *
	 * @throws \Exception When unable to persist order.
	 */
	protected function persist_updates( &$order, $backfill = true ) {
		// Fetch changes.
		$changes = $order->get_changes();

		if ( ! isset( $changes['date_modified'] ) ) {
			$order->set_date_modified( current_time( 'mysql' ) );
		}

		$this->persist_order_to_db( $order );

		$this->update_order_meta( $order );

		$order->save_meta_data();

		if ( $backfill ) {
			self::$backfilling_order_ids[] = $order->get_id();
			$this->clear_caches( $order );
			$r_order = wc_get_order( $order->get_id() ); // Refresh order to account for DB changes from post hooks.
			$this->maybe_backfill_post_record( $r_order );
			self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $order->get_id() ) );
		}

		return $changes;
	}

	/**
	 * Helper method to check whether to backfill post record.
	 *
	 * @return bool
	 */
	private function should_backfill_post_record() {
		$data_sync = wc_get_container()->get( DataSynchronizer::class );
		return $data_sync->data_sync_is_enabled();
	}

	/**
	 * Helper function to decide whether to backfill post record.
	 *
	 * @param \WC_Abstract_Order $order Order object.
	 *
	 * @return void
	 */
	private function maybe_backfill_post_record( $order ) {
		if ( $this->should_backfill_post_record() ) {
			$this->backfill_post_record( $order );
		}
	}

	/**
	 * Helper method that updates post meta based on an order object.
	 * Mostly used for backwards compatibility purposes in this datastore.
	 *
	 * @param \WC_Order $order Order object.
	 *
	 * @since 7.0.0
	 */
	public function update_order_meta( &$order ) {
		$changes = $order->get_changes();
		$this->update_address_index_meta( $order, $changes );
	}

	/**
	 * Helper function to update billing and shipping address metadata.
	 *
	 * @param \WC_Abstract_Order $order Order Object.
	 * @param array              $changes Array of changes.
	 *
	 * @return void
	 */
	private function update_address_index_meta( $order, $changes ) {
		// If address changed, store concatenated version to make searches faster.
		foreach ( array( 'billing', 'shipping' ) as $address_type ) {
			$index_meta_key = "_{$address_type}_address_index";

			if ( isset( $changes[ $address_type ] ) || ( is_a( $order, 'WC_Order' ) && empty( $order->get_meta( $index_meta_key ) ) ) ) {
				$order->update_meta_data( $index_meta_key, implode( ' ', $order->get_address( $address_type ) ) );
			}
		}
	}

	/**
	 * Return array of coupon_code => meta_key for coupon which have usage limit and have tentative keys.
	 * Pass $coupon_id if key for only one of the coupon is needed.
	 *
	 * @param WC_Order $order     Order object.
	 * @param int      $coupon_id If passed, will return held key for that coupon.
	 *
	 * @return array|string Key value pair for coupon code and meta key name. If $coupon_id is passed, returns meta_key for only that coupon.
	 */
	public function get_coupon_held_keys( $order, $coupon_id = null ) {
		$held_keys = $order->get_meta( '_coupon_held_keys' );
		if ( $coupon_id ) {
			return isset( $held_keys[ $coupon_id ] ) ? $held_keys[ $coupon_id ] : null;
		}
		return $held_keys;
	}

	/**
	 * Return array of coupon_code => meta_key for coupon which have usage limit per customer and have tentative keys.
	 *
	 * @param WC_Order $order Order object.
	 * @param int      $coupon_id If passed, will return held key for that coupon.
	 *
	 * @return mixed
	 */
	public function get_coupon_held_keys_for_users( $order, $coupon_id = null ) {
		$held_keys_for_user = $order->get_meta( '_coupon_held_keys_for_users' );
		if ( $coupon_id ) {
			return isset( $held_keys_for_user[ $coupon_id ] ) ? $held_keys_for_user[ $coupon_id ] : null;
		}
		return $held_keys_for_user;
	}

	/**
	 * Add/Update list of meta keys that are currently being used by this order to hold a coupon.
	 * This is used to figure out what all meta entries we should delete when order is cancelled/completed.
	 *
	 * @param WC_Order $order              Order object.
	 * @param array    $held_keys          Array of coupon_code => meta_key.
	 * @param array    $held_keys_for_user Array of coupon_code => meta_key for held coupon for user.
	 *
	 * @return mixed
	 */
	public function set_coupon_held_keys( $order, $held_keys, $held_keys_for_user ) {
		if ( is_array( $held_keys ) && 0 < count( $held_keys ) ) {
			$order->update_meta_data( '_coupon_held_keys', $held_keys );
		}
		if ( is_array( $held_keys_for_user ) && 0 < count( $held_keys_for_user ) ) {
			$order->update_meta_data( '_coupon_held_keys_for_users', $held_keys_for_user );
		}
	}

	/**
	 * Release all coupons held by this order.
	 *
	 * @param WC_Order $order Current order object.
	 * @param bool     $save  Whether to delete keys from DB right away. Could be useful to pass `false` if you are building a bulk request.
	 */
	public function release_held_coupons( $order, $save = true ) {
		$coupon_held_keys = $this->get_coupon_held_keys( $order );
		if ( is_array( $coupon_held_keys ) ) {
			foreach ( $coupon_held_keys as $coupon_id => $meta_key ) {
				$coupon = new \WC_Coupon( $coupon_id );
				$coupon->delete_meta_data( $meta_key );
				$coupon->save_meta_data();
			}
		}
		$order->delete_meta_data( '_coupon_held_keys' );

		$coupon_held_keys_for_users = $this->get_coupon_held_keys_for_users( $order );
		if ( is_array( $coupon_held_keys_for_users ) ) {
			foreach ( $coupon_held_keys_for_users as $coupon_id => $meta_key ) {
				$coupon = new \WC_Coupon( $coupon_id );
				$coupon->delete_meta_data( $meta_key );
				$coupon->save_meta_data();
			}
		}
		$order->delete_meta_data( '_coupon_held_keys_for_users' );

		if ( $save ) {
			$order->save_meta_data();
		}
	}

	/**
	 * Performs actual query to get orders. Uses `OrdersTableQuery` to build and generate the query.
	 *
	 * @param array $query_vars Query variables.
	 *
	 * @return array|object List of orders and count of orders.
	 */
	public function query( $query_vars ) {
		if ( ! isset( $query_vars['paginate'] ) || ! $query_vars['paginate'] ) {
			$query_vars['no_found_rows'] = true;
		}

		if ( isset( $query_vars['anonymized'] ) ) {
			$query_vars['meta_query'] = $query_vars['meta_query'] ?? array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query

			if ( $query_vars['anonymized'] ) {
				$query_vars['meta_query'][] = array(
					'key'   => '_anonymized',
					'value' => 'yes',
				);
			} else {
				$query_vars['meta_query'][] = array(
					'key'     => '_anonymized',
					'compare' => 'NOT EXISTS',
				);
			}
		}

		// Handle fulfillment status filtering.
		if ( ! empty( $query_vars['fulfillment_status'] ) ) {
			$query_vars['meta_query'][] = FulfillmentUtils::get_order_fulfillment_status_meta_query( $query_vars['fulfillment_status'] );
		}

		/**
		 * Filter the query args before executing the query.
		 *
		 * @param array $query_vars The query vars.
		 * @return array
		 * @since 10.4.0
		 */
		$query_vars = apply_filters( 'woocommerce_orders_table_datastore_get_orders_query', $query_vars, $this );

		try {
			$query = new OrdersTableQuery( $query_vars );
		} catch ( \Exception $e ) {
			$query = (object) array(
				'orders'        => array(),
				'found_orders'  => 0,
				'max_num_pages' => 0,
			);
		}

		if ( isset( $query_vars['return'] ) && 'ids' === $query_vars['return'] ) {
			$orders = $query->orders;
		} else {
			$orders = WC()->order_factory->get_orders( $query->orders );
		}

		if ( isset( $query_vars['paginate'] ) && $query_vars['paginate'] ) {
			return (object) array(
				'orders'        => $orders,
				'total'         => $query->found_orders,
				'max_num_pages' => $query->max_num_pages,
			);
		}

		return $orders;
	}

	//phpcs:enable Squiz.Commenting, Generic.Commenting

	/**
	 * Get the SQL needed to create all the tables needed for the custom orders table feature.
	 *
	 * @return string
	 */
	public function get_database_schema() {
		global $wpdb;

		$collate = $wpdb->has_cap( 'collation' ) ? $wpdb->get_charset_collate() : '';

		$orders_table_name           = $this->get_orders_table_name();
		$addresses_table_name        = $this->get_addresses_table_name();
		$operational_data_table_name = $this->get_operational_data_table_name();
		$meta_table                  = $this->get_meta_table_name();

		$max_index_length                   = $this->database_util->get_max_index_length();
		$composite_meta_value_index_length  = max( $max_index_length - 8 - 100 - 1, 20 ); // 8 for order_id, 100 for meta_key, 10 minimum for meta_value.
		$composite_customer_id_email_length = max( $max_index_length - 20, 20 ); // 8 for customer_id, 20 minimum for email.

		$sql = "
CREATE TABLE $orders_table_name (
	id bigint(20) unsigned,
	status varchar(20) null,
	currency varchar(10) null,
	type varchar(20) null,
	tax_amount decimal(26,8) null,
	total_amount decimal(26,8) null,
	customer_id bigint(20) unsigned null,
	billing_email varchar(320) null,
	date_created_gmt datetime null,
	date_updated_gmt datetime null,
	parent_order_id bigint(20) unsigned null,
	payment_method varchar(100) null,
	payment_method_title text null,
	transaction_id varchar(100) null,
	ip_address varchar(100) null,
	user_agent text null,
	customer_note text null,
	PRIMARY KEY (id),
	KEY status (status),
	KEY date_created (date_created_gmt),
	KEY customer_id_billing_email (customer_id, billing_email({$composite_customer_id_email_length})),
	KEY billing_email (billing_email($max_index_length)),
	KEY type_status_date (type, status, date_created_gmt),
	KEY parent_order_id (parent_order_id),
	KEY date_updated (date_updated_gmt)
) $collate;
CREATE TABLE $addresses_table_name (
	id bigint(20) unsigned auto_increment primary key,
	order_id bigint(20) unsigned NOT NULL,
	address_type varchar(20) null,
	first_name text null,
	last_name text null,
	company text null,
	address_1 text null,
	address_2 text null,
	city text null,
	state text null,
	postcode text null,
	country text null,
	email varchar(320) null,
	phone varchar(100) null,
	KEY order_id (order_id),
	UNIQUE KEY address_type_order_id (address_type, order_id),
	KEY email (email($max_index_length)),
	KEY phone (phone)
) $collate;
CREATE TABLE $operational_data_table_name (
	id bigint(20) unsigned auto_increment primary key,
	order_id bigint(20) unsigned NULL,
	created_via varchar(100) NULL,
	woocommerce_version varchar(20) NULL,
	prices_include_tax tinyint(1) NULL,
	coupon_usages_are_counted tinyint(1) NULL,
	download_permission_granted tinyint(1) NULL,
	cart_hash varchar(100) NULL,
	new_order_email_sent tinyint(1) NULL,
	order_key varchar(100) NULL,
	order_stock_reduced tinyint(1) NULL,
	date_paid_gmt datetime NULL,
	date_completed_gmt datetime NULL,
	shipping_tax_amount decimal(26,8) NULL,
	shipping_total_amount decimal(26,8) NULL,
	discount_tax_amount decimal(26,8) NULL,
	discount_total_amount decimal(26,8) NULL,
	recorded_sales tinyint(1) NULL,
	UNIQUE KEY order_id (order_id),
	KEY order_key (order_key)
) $collate;
CREATE TABLE $meta_table (
	id bigint(20) unsigned auto_increment primary key,
	order_id bigint(20) unsigned null,
	meta_key varchar(255),
	meta_value text null,
	KEY meta_key_value (meta_key(100), meta_value($composite_meta_value_index_length)),
	KEY order_id_meta_key_meta_value (order_id, meta_key(100), meta_value($composite_meta_value_index_length))
) $collate;
";

		return $sql;
	}

	/**
	 * Returns an array of meta for an object.
	 *
	 * @param  WC_Data $object WC_Data object.
	 * @return array
	 */
	public function read_meta( &$object ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.objectFound
		$raw_meta_data = $this->data_store_meta->read_meta( $object );
		return $this->filter_raw_meta_data( $object, $raw_meta_data );
	}

	/**
	 * Deletes meta based on meta ID.
	 *
	 * @param WC_Data   $object WC_Data object.
	 * @param \stdClass $meta (containing at least ->id).
	 *
	 * @return bool
	 */
	public function delete_meta( &$object, $meta ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.objectFound
		global $wpdb;

		if ( $this->should_backfill_post_record() && isset( $meta->id ) ) {
			// Let's get the actual meta key before its deleted for backfilling. We cannot delete just by ID because meta IDs are different in HPOS and posts tables.
			$db_meta = $this->data_store_meta->get_metadata_by_id( $meta->id );
			if ( $db_meta ) {
				$meta->key   = $db_meta->meta_key;
				$meta->value = $db_meta->meta_value;
			}
		}

		$delete_meta     = $this->data_store_meta->delete_meta( $object, $meta );
		$changes_applied = $this->after_meta_change( $object, $meta );

		if ( ! $changes_applied && $object instanceof WC_Abstract_Order && $this->should_backfill_post_record() && isset( $meta->key ) ) {
			self::$backfilling_order_ids[] = $object->get_id();
			if ( is_object( $meta->value ) && '__PHP_Incomplete_Class' === get_class( $meta->value ) ) {
				$meta_value = maybe_serialize( $meta->value );
				$wpdb->delete(
					_get_meta_table( 'post' ),
					array(
						'post_id'    => $object->get_id(),
						'meta_key'   => $meta->key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
						'meta_value' => $meta_value, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
					),
					array( '%d', '%s', '%s' )
				);
				wp_cache_delete( $object->get_id(), 'post_meta' );
				/** @var \WC_Logger_Interface $logger */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
				$logger = wc_get_container()->get( LegacyProxy::class )->call_function( 'wc_get_logger' );
				$logger->warning( sprintf( 'encountered an order meta value of type __PHP_Incomplete_Class during `delete_meta` in order with ID %d: "%s"', $object->get_id(), var_export( $meta_value, true ) ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export
			} else {
				delete_post_meta( $object->get_id(), $meta->key, $meta->value );
			}
			self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $object->get_id() ) );
		}

		return $delete_meta;
	}

	/**
	 * Add new piece of meta.
	 *
	 * @param WC_Data   $object WC_Data object.
	 * @param \stdClass $meta (containing ->key and ->value).
	 *
	 * @return int|bool  meta ID or false on failure
	 */
	public function add_meta( &$object, $meta ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.objectFound
		$add_meta        = $this->data_store_meta->add_meta( $object, $meta );
		$meta->id        = $add_meta;
		$changes_applied = $this->after_meta_change( $object, $meta );

		if ( ! $changes_applied && $object instanceof WC_Abstract_Order && $this->should_backfill_post_record() ) {
			self::$backfilling_order_ids[] = $object->get_id();
			add_post_meta( $object->get_id(), $meta->key, $meta->value );
			self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $object->get_id() ) );
		}

		return $add_meta;
	}

	/**
	 * Update meta.
	 *
	 * @param WC_Data   $object WC_Data object.
	 * @param \stdClass $meta (containing ->id, ->key and ->value).
	 *
	 * @return bool The number of rows updated, or false on error.
	 */
	public function update_meta( &$object, $meta ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.objectFound
		$update_meta     = $this->data_store_meta->update_meta( $object, $meta );
		$changes_applied = $this->after_meta_change( $object, $meta );

		if ( ! $changes_applied && $object instanceof WC_Abstract_Order && $this->should_backfill_post_record() ) {
			self::$backfilling_order_ids[] = $object->get_id();
			update_post_meta( $object->get_id(), $meta->key, $meta->value );
			self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $object->get_id() ) );
		}

		return $update_meta;
	}

	/**
	 * Perform after meta change operations, including updating the date_modified field, clearing caches and applying changes.
	 *
	 * @param WC_Abstract_Order $order Order object.
	 * @param \WC_Meta_Data     $meta  Metadata object.
	 *
	 * @return bool True if changes were applied, false otherwise.
	 */
	protected function after_meta_change( &$order, $meta ) {
		method_exists( $meta, 'apply_changes' ) && $meta->apply_changes();

		// Prevent this happening multiple time in same request.
		if ( $this->should_save_after_meta_change( $order, $meta ) ) {
			$order->set_date_modified( current_time( 'mysql' ) );
			$order->save();
			return true;
		} else {
			$order_cache = wc_get_container()->get( OrderCache::class );
			$order_cache->remove( $order->get_id() );
			$this->clear_cached_data( array( $order->get_id() ) );
		}

		return false;
	}

	/**
	 * Helper function to check whether the modified date needs to be updated after a meta save.
	 *
	 * This method prevents order->save() call multiple times in the same request after any meta update by checking if:
	 * 1. Order modified date is already the current date, no updates needed in this case.
	 * 2. If there are changes already queued for order object, then we don't need to update the modified date as it will be updated ina subsequent save() call.
	 *
	 * @param WC_Order           $order Order object.
	 * @param \WC_Meta_Data|null $meta  Metadata object.
	 *
	 * @return bool Whether the modified date needs to be updated.
	 */
	private function should_save_after_meta_change( $order, $meta = null ) {
		$current_time      = $this->legacy_proxy->call_function( 'current_time', 'mysql', 1 );
		$current_date_time = new \WC_DateTime( $current_time, new \DateTimeZone( 'GMT' ) );

		$should_save =
			$order->get_id() > 0
			&& ! isset( self::$sync_on_read_order_ids[ $order->get_id() ] )
			&& $order->get_date_modified() < $current_date_time && empty( $order->get_changes() )
			&& ( ! is_object( $meta ) || ! in_array( $meta->key, $this->ephemeral_meta_keys, true ) );

		/**
		 * Allows code to skip a full order save() when metadata is changed.
		 *
		 * @since 8.8.0
		 *
		 * @param bool $should_save Whether to trigger a full save after metadata is changed.
		 */
		return apply_filters( 'woocommerce_orders_table_datastore_should_save_after_meta_change', $should_save );
	}
}
PK     [1]J,"  "  +  DataStores/Orders/OrdersTableFieldQuery.phpnu         <?php
namespace Automattic\WooCommerce\Internal\DataStores\Orders;

defined( 'ABSPATH' ) || exit;

/**
 * Provides the implementation for `field_query` in {@see OrdersTableQuery} used to build
 * complex queries against order fields in the database.
 *
 * @internal
 */
class OrdersTableFieldQuery {

	/**
	 * List of valid SQL operators to use as field_query 'compare' values.
	 *
	 * @var array
	 */
	private const VALID_COMPARISON_OPERATORS = array(
		'=',
		'!=',
		'LIKE',
		'NOT LIKE',
		'IN',
		'NOT IN',
		'EXISTS',
		'NOT EXISTS',
		'RLIKE',
		'REGEXP',
		'NOT REGEXP',
		'>',
		'>=',
		'<',
		'<=',
		'BETWEEN',
		'NOT BETWEEN',
	);

	/**
	 * The original query object.
	 *
	 * @var OrdersTableQuery
	 */
	private $query = null;

	/**
	 * Determines whether the field query should produce no results due to an invalid argument.
	 *
	 * @var boolean
	 */
	private $force_no_results = false;

	/**
	 * Holds a sanitized version of the `field_query`.
	 *
	 * @var array
	 */
	private $queries = array();

	/**
	 * JOIN clauses to add to the main SQL query.
	 *
	 * @var array
	 */
	private $join = array();

	/**
	 * WHERE clauses to add to the main SQL query.
	 *
	 * @var array
	 */
	private $where = array();

	/**
	 * Table aliases in use by the field query. Used to keep track of JOINs and optimize when possible.
	 *
	 * @var array
	 */
	private $table_aliases = array();


	/**
	 * Constructor.
	 *
	 * @param OrdersTableQuery $q The main query being performed.
	 */
	public function __construct( OrdersTableQuery $q ) {
		$field_query = $q->get( 'field_query' );

		if ( ! $field_query || ! is_array( $field_query ) ) {
			return;
		}

		$this->query   = $q;
		$this->queries = $this->sanitize_query( $field_query );
		$this->where   = ( ! $this->force_no_results ) ? $this->process( $this->queries ) : '1=0';
	}

	/**
	 * Sanitizes the field_query argument.
	 *
	 * @param array $q A field_query array.
	 * @return array A sanitized field query array.
	 * @throws \Exception When field table info is missing.
	 */
	private function sanitize_query( array $q ) {
		$sanitized = array();

		foreach ( $q as $key => $arg ) {
			if ( 'relation' === $key ) {
				$relation = $arg;
			} elseif ( ! is_array( $arg ) ) {
				continue;
			} elseif ( $this->is_atomic( $arg ) ) {
				if ( isset( $arg['value'] ) && array() === $arg['value'] ) {
					continue;
				}

				// Sanitize 'compare'.
				$arg['compare'] = strtoupper( $arg['compare'] ?? '=' );
				$arg['compare'] = in_array( $arg['compare'], self::VALID_COMPARISON_OPERATORS, true ) ? $arg['compare'] : '=';

				if ( '=' === $arg['compare'] && isset( $arg['value'] ) && is_array( $arg['value'] ) ) {
					$arg['compare'] = 'IN';
				}

				// Sanitize 'cast'.
				$arg['cast'] = $this->sanitize_cast_type( $arg['type'] ?? '' );

				$field_info = $this->query->get_field_mapping_info( $arg['field'] );
				if ( ! $field_info ) {
					$this->force_no_results = true;
					continue;
				}

				$arg = array_merge( $arg, $field_info );

				$sanitized[ $key ] = $arg;
			} else {
				$sanitized_arg = $this->sanitize_query( $arg );

				if ( $sanitized_arg ) {
					$sanitized[ $key ] = $sanitized_arg;
				}
			}
		}

		if ( $sanitized ) {
			$sanitized['relation'] = 1 === count( $sanitized ) ? 'OR' : $this->sanitize_relation( $relation ?? 'AND' );
		}

		return $sanitized;
	}

	/**
	 * Makes sure we use an AND or OR relation. Defaults to AND.
	 *
	 * @param string $relation An unsanitized relation prop.
	 * @return string
	 */
	private function sanitize_relation( string $relation ): string {
		if ( ! empty( $relation ) && 'OR' === strtoupper( $relation ) ) {
			return 'OR';
		}

		return 'AND';
	}

	/**
	 * Processes field_query entries and generates the necessary table aliases, JOIN statements and WHERE conditions.
	 *
	 * @param array $q A field query.
	 * @return string An SQL WHERE statement.
	 */
	private function process( array $q ) {
		$where = '';

		if ( empty( $q ) ) {
			return $where;
		}

		if ( $this->is_atomic( $q ) ) {
			$q['alias'] = $this->find_or_create_table_alias_for_clause( $q );
			$where      = $this->generate_where_for_clause( $q );
		} else {
			$relation = $q['relation'];
			unset( $q['relation'] );
			$chunks = array();
			foreach ( $q as $query ) {
				$chunks[] = $this->process( $query );
			}

			if ( 1 === count( $chunks ) ) {
				$where = $chunks[0];
			} else {
				$where = '(' . implode( " {$relation} ", $chunks ) . ')';
			}
		}

		return $where;
	}

	/**
	 * Checks whether a given field_query clause is atomic or not (i.e. not nested).
	 *
	 * @param array $q The field_query clause.
	 * @return boolean TRUE if atomic, FALSE otherwise.
	 */
	private function is_atomic( $q ) {
		return isset( $q['field'] );
	}

	/**
	 * Finds a common table alias that the field_query clause can use, or creates one.
	 *
	 * @param array $q       An atomic field_query clause.
	 * @return string A table alias for use in an SQL JOIN clause.
	 * @throws \Exception When table info for clause is missing.
	 */
	private function find_or_create_table_alias_for_clause( $q ) {
		global $wpdb;

		if ( ! empty( $q['alias'] ) ) {
			return $q['alias'];
		}

		if ( empty( $q['table'] ) || empty( $q['column'] ) ) {
			throw new \Exception( __( 'Missing table info for query arg.', 'woocommerce' ) );
		}

		$join = '';

		if ( isset( $q['mapping_id'] ) ) {
			// Re-use JOINs and aliases from OrdersTableQuery for core tables.
			$alias = $this->query->get_core_mapping_alias( $q['mapping_id'] );
			$join  = $this->query->get_core_mapping_join( $q['mapping_id'] );
		} else {
			$alias = $q['table'];
			$join  = '';
		}

		if ( in_array( $alias, $this->table_aliases, true ) ) {
			return $alias;
		}

		$this->table_aliases[] = $alias;

		if ( $join ) {
			$this->join[ $alias ] = $join;
		}

		return $alias;
	}

	/**
	 * Returns the correct type for a given clause 'type'.
	 *
	 * @param string $type MySQL type.
	 * @return string MySQL type.
	 */
	private function sanitize_cast_type( $type ) {
		$clause_type = strtoupper( $type );

		if ( ! $clause_type || ! preg_match( '/^(?:BINARY|CHAR|DATE|DATETIME|SIGNED|UNSIGNED|TIME|NUMERIC(?:\(\d+(?:,\s?\d+)?\))?|DECIMAL(?:\(\d+(?:,\s?\d+)?\))?)$/', $clause_type ) ) {
			return 'CHAR';
		}

		if ( 'NUMERIC' === $clause_type ) {
			$clause_type = 'SIGNED';
		}

		return $clause_type;
	}

	/**
	 * Generates an SQL WHERE clause for a given field_query atomic clause.
	 *
	 * @param array $clause An atomic field_query clause.
	 * @return string An SQL WHERE clause or an empty string if $clause is invalid.
	 */
	private function generate_where_for_clause( $clause ): string {
		global $wpdb;

		$clause_value = $clause['value'] ?? '';

		if ( in_array( $clause['compare'], array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true ) ) {
			if ( ! is_array( $clause_value ) ) {
				$clause_value = preg_split( '/[,\s]+/', $clause_value );
			}
		} elseif ( is_string( $clause_value ) ) {
			$clause_value = trim( $clause_value );
		}

		$clause_compare = $clause['compare'];
		switch ( $clause_compare ) {
			case 'IN':
			case 'NOT IN':
				$where = $wpdb->prepare( '(' . substr( str_repeat( ',%s', count( (array) $clause_value ) ), 1 ) . ')', $clause_value ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				break;
			case 'BETWEEN':
			case 'NOT BETWEEN':
				$where = $wpdb->prepare( '%s AND %s', $clause_value[0], $clause_value[1] ?? $clause_value[0] );
				break;
			case 'LIKE':
			case 'NOT LIKE':
				$where = $wpdb->prepare( '%s', '%' . $wpdb->esc_like( $clause_value ) . '%' );
				break;
			case 'EXISTS':
				// EXISTS with a value is interpreted as '='.
				if ( $clause_value ) {
					$clause_compare = '=';
					$where          = $wpdb->prepare( '%s', $clause_value );
				} else {
					$clause_compare = 'IS NOT';
					$where          = 'NULL';
				}

				break;
			case 'NOT EXISTS':
				// 'value' is ignored for NOT EXISTS.
				$clause_compare = 'IS';
				$where          = 'NULL';
				break;
			default:
				$where = $wpdb->prepare( '%s', $clause_value );
				break;
		}

		if ( ! empty( $where ) ) {
			if ( 'CHAR' === $clause['cast'] ) {
				return "`{$clause['alias']}`.`{$clause['column']}` {$clause_compare} {$where}";
			} else {
				return "CAST(`{$clause['alias']}`.`{$clause['column']}` AS {$clause['cast']}) {$clause_compare} {$where}";
			}
		}

		return '';
	}

	/**
	 * Returns JOIN and WHERE clauses to be appended to the main SQL query.
	 *
	 * @return array {
	 *     @type string $join  JOIN clause.
	 *     @type string $where WHERE clause.
	 * }
	 */
	public function get_sql_clauses() {
		return array(
			'join'  => $this->join,
			'where' => $this->where ? array( $this->where ) : array(),
		);
	}

}
PK     [1]=M;    &  DataStores/Orders/OrdersTableQuery.phpnu         <?php
// phpcs:disable Generic.Commenting.Todo.TaskFound
/**
 * OrdersTableQuery class file.
 */

namespace Automattic\WooCommerce\Internal\DataStores\Orders;

use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;

defined( 'ABSPATH' ) || exit;

/**
 * This class provides a `WP_Query`-like interface to custom order tables.
 *
 * @property-read int   $found_orders  Number of found orders.
 * @property-read int   $found_posts   Alias of the `$found_orders` property.
 * @property-read int   $max_num_pages Max number of pages matching the current query.
 * @property-read array $orders        Order objects, or order IDs.
 * @property-read array $posts         Alias of the $orders property.
 */
class OrdersTableQuery {

	/**
	 * Values to ignore when parsing query arguments.
	 */
	public const SKIPPED_VALUES = array( '', array(), null );

	/**
	 * Regex used to catch "shorthand" comparisons in date-related query args.
	 */
	public const REGEX_SHORTHAND_DATES = '/([^.<>]*)(>=|<=|>|<|\.\.\.)([^.<>]+)/';

	/**
	 * Highest possible unsigned bigint value (unsigned bigints being the type of the `id` column).
	 *
	 * This is deliberately held as a string, rather than a numeric type, for inclusion within queries.
	 */
	private const MYSQL_MAX_UNSIGNED_BIGINT = '18446744073709551615';

	/**
	 * Names of all COT tables (orders, addresses, operational_data, meta) in the form 'table_id' => 'table name'.
	 *
	 * @var array
	 */
	private $tables = array();

	/**
	 * Column mappings for all COT tables.
	 *
	 * @var array
	 */
	private $mappings = array();

	/**
	 * Query vars after processing and sanitization.
	 *
	 * @var array
	 */
	private $args = array();

	/**
	 * Original query vars used to build this query.
	 *
	 * @var array
	 */
	private $query_args = array();

	/**
	 * Columns to be selected in the SELECT clause.
	 *
	 * @var array
	 */
	private $fields = array();

	/**
	 * Array of table aliases and conditions used to compute the JOIN clause of the query.
	 *
	 * @var array
	 */
	private $join = array();

	/**
	 * Array of fields and conditions used to compute the WHERE clause of the query.
	 *
	 * @var array
	 */
	private $where = array();

	/**
	 * Field to be used in the GROUP BY clause of the query.
	 *
	 * @var array
	 */
	private $groupby = array();

	/**
	 * Array of fields used to compute the ORDER BY clause of the query.
	 *
	 * @var array
	 */
	private $orderby = array();

	/**
	 * Limits used to compute the LIMIT clause of the query.
	 *
	 * @var array
	 */
	private $limits = array();

	/**
	 * Results (order IDs) for the current query.
	 *
	 * @var array
	 */
	private $orders = array();

	/**
	 * Final SQL query to run after processing of args.
	 *
	 * @var string
	 */
	private $sql = '';

	/**
	 * Final SQL query to count results after processing of args.
	 *
	 * @var string
	 */
	private $count_sql = '';

	/**
	 * The number of pages (when pagination is enabled).
	 *
	 * @var int
	 */
	private $max_num_pages = 0;

	/**
	 * The number of orders found.
	 *
	 * @var int
	 */
	private $found_orders = 0;

	/**
	 * Field query parser.
	 *
	 * @var OrdersTableFieldQuery
	 */
	private $field_query = null;

	/**
	 * Meta query parser.
	 *
	 * @var OrdersTableMetaQuery
	 */
	private $meta_query = null;

	/**
	 * Search query parser.
	 *
	 * @var OrdersTableSearchQuery?
	 */
	private $search_query = null;

	/**
	 * Date query parser.
	 *
	 * @var WP_Date_Query
	 */
	private $date_query = null;

	/**
	 * Instance of the OrdersTableDataStore class.
	 *
	 * @var OrdersTableDataStore
	 */
	private $order_datastore = null;

	/**
	 * Whether to run filters to modify the query or not.
	 *
	 * @var boolean
	 */
	private $suppress_filters = false;

	/**
	 * Sets up and runs the query after processing arguments.
	 *
	 * @param array $args Array of query vars.
	 */
	public function __construct( $args = array() ) {
		// Note that ideally we would inject this dependency via constructor, but that's not possible since this class needs to be backward compatible with WC_Order_Query class.
		$this->order_datastore = wc_get_container()->get( OrdersTableDataStore::class );

		$this->tables   = $this->order_datastore::get_all_table_names_with_id();
		$this->mappings = $this->order_datastore->get_all_order_column_mappings();

		$this->suppress_filters = array_key_exists( 'suppress_filters', $args ) ? (bool) $args['suppress_filters'] : false;
		unset( $args['suppress_filters'] );

		$this->args       = $args;
		$this->query_args = $args; // Keep a copy of the original vars used to initialize the query.

		// TODO: args to be implemented.
		unset( $this->args['customer_note'], $this->args['name'] );

		$this->build_query();
		if ( ! $this->maybe_override_query() ) {
			$this->run_query();
		}
	}

	/**
	 * Lets the `woocommerce_hpos_pre_query` filter override the query.
	 *
	 * @return boolean Whether the query was overridden or not.
	 */
	private function maybe_override_query(): bool {
		/**
		 * Filters the orders array before the query takes place.
		 *
		 * Return a non-null value to bypass the HPOS default order queries.
		 *
		 * If the query includes limits via the `limit`, `page`, or `offset` arguments, we
		 * encourage the `found_orders` and `max_num_pages` properties to also be set.
		 *
		 * @since 8.2.0
		 *
		 * @param array|null $order_data {
		 *     An array of order data.
		 *     @type int[] $orders        Return an array of order IDs data to short-circuit the HPOS query,
		 *                                or null to allow HPOS to run its normal query.
		 *     @type int   $found_orders  The number of orders found.
		 *     @type int   $max_num_pages The number of pages.
		 * }
		 * @param OrdersTableQuery   $query The OrdersTableQuery instance.
		 * @param string             $sql   Fully built SQL query.
		 */
		$pre_query = apply_filters( 'woocommerce_hpos_pre_query', null, $this, $this->sql );
		if ( ! $pre_query || ! isset( $pre_query[0] ) || ! is_array( $pre_query[0] ) ) {
			return false;
		}

		// If the filter set the orders, make sure the others values are set as well and skip running the query.
		list( $this->orders, $this->found_orders, $this->max_num_pages ) = $pre_query;

		if ( ! is_int( $this->found_orders ) || $this->found_orders < 1 ) {
			$this->found_orders = count( $this->orders );
		}

		if ( ! is_int( $this->max_num_pages ) || $this->max_num_pages < 1 ) {
			if ( ! $this->arg_isset( 'limit' ) || ! is_int( $this->args['limit'] ) || $this->args['limit'] < 1 ) {
				$this->args['limit'] = 10;
			}
			$this->max_num_pages = (int) ceil( $this->found_orders / $this->args['limit'] );
		}

		return true;
	}

	/**
	 * Remaps some legacy and `WP_Query` specific query vars to vars available in the customer order table scheme.
	 *
	 * @return void
	 */
	private function maybe_remap_args(): void {
		$mapping = array(
			// WP_Query legacy.
			'post_date'           => 'date_created',
			'post_date_gmt'       => 'date_created_gmt',
			'post_modified'       => 'date_updated',
			'post_modified_gmt'   => 'date_updated_gmt',
			'post_status'         => 'status',
			'_date_completed'     => 'date_completed',
			'_date_paid'          => 'date_paid',
			'paged'               => 'page',
			'post_parent'         => 'parent_order_id',
			'post_parent__in'     => 'parent_order_id',
			'post_parent__not_in' => 'parent_exclude',
			'post__not_in'        => 'exclude',
			'posts_per_page'      => 'limit',
			'p'                   => 'id',
			'post__in'            => 'id',
			'post_type'           => 'type',
			'fields'              => 'return',

			'customer_user'       => 'customer_id',
			'order_currency'      => 'currency',
			'order_version'       => 'woocommerce_version',
			'cart_discount'       => 'discount_total_amount',
			'cart_discount_tax'   => 'discount_tax_amount',
			'order_shipping'      => 'shipping_total_amount',
			'order_shipping_tax'  => 'shipping_tax_amount',
			'order_tax'           => 'tax_amount',

			// Translate from WC_Order_Query to table structure.
			'version'             => 'woocommerce_version',
			'date_modified'       => 'date_updated',
			'date_modified_gmt'   => 'date_updated_gmt',
			'discount_total'      => 'discount_total_amount',
			'discount_tax'        => 'discount_tax_amount',
			'shipping_total'      => 'shipping_total_amount',
			'shipping_tax'        => 'shipping_tax_amount',
			'cart_tax'            => 'tax_amount',
			'total'               => 'total_amount',
			'customer_ip_address' => 'ip_address',
			'customer_user_agent' => 'user_agent',
			'parent'              => 'parent_order_id',
		);

		foreach ( $mapping as $query_key => $table_field ) {
			if ( isset( $this->args[ $query_key ] ) && '' !== $this->args[ $query_key ] ) {
				$this->args[ $table_field ] = $this->args[ $query_key ];
				unset( $this->args[ $query_key ] );
			}
		}

		// meta_query.
		$this->args['meta_query'] = ( $this->arg_isset( 'meta_query' ) && is_array( $this->args['meta_query'] ) ) ? $this->args['meta_query'] : array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query

		$shortcut_meta_query = array();
		foreach ( array( 'key', 'value', 'compare', 'type', 'compare_key', 'type_key' ) as $key ) {
			if ( $this->arg_isset( "meta_{$key}" ) ) {
				$shortcut_meta_query[ $key ] = $this->args[ "meta_{$key}" ];
			}
		}

		if ( ! empty( $shortcut_meta_query ) ) {
			if ( ! empty( $this->args['meta_query'] ) ) {
				$this->args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
					'relation' => 'AND',
					$shortcut_meta_query,
					$this->args['meta_query'],
				);
			} else {
				$this->args['meta_query'] = array( $shortcut_meta_query ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
			}
		}
	}

	/**
	 * Generates a `WP_Date_Query` compatible query from a given date.
	 * YYYY-MM-DD queries have 'day' precision for backwards compatibility.
	 *
	 * @param mixed $date The date. Can be a {@see \WC_DateTime}, a timestamp or a string.
	 * @return array An array with keys 'year', 'month', 'day' and possibly 'hour', 'minute' and 'second'.
	 */
	private function date_to_date_query_arg( $date ): array {
		$result = array(
			'year'  => '',
			'month' => '',
			'day'   => '',
		);

		$precision = null;
		if ( is_numeric( $date ) ) {
			$date      = new \WC_DateTime( "@{$date}", new \DateTimeZone( 'UTC' ) );
			$precision = 'second';
		} elseif ( ! is_a( $date, 'WC_DateTime' ) ) {
			// For backwards compat (see https://github.com/woocommerce/woocommerce/wiki/wc_get_orders-and-WC_Order_Query#date)
			// only YYYY-MM-DD is considered for date values. Timestamps do support second precision.
			$date      = wc_string_to_datetime( date( 'Y-m-d', strtotime( $date ) ) );
			$precision = 'day';
		}

		$result['year']  = $date->date( 'Y' );
		$result['month'] = $date->date( 'm' );
		$result['day']   = $date->date( 'd' );

		if ( 'second' === $precision ) {
			$result['hour']   = $date->date( 'H' );
			$result['minute'] = $date->date( 'i' );
			$result['second'] = $date->date( 's' );
		}

		return $result;
	}

	/**
	 * Returns UTC-based date query arguments for a combination of local time dates and a date shorthand operator.
	 *
	 * @param  array  $dates_raw Array of dates (in local time) to use in combination with the operator.
	 * @param  string $operator One of the operators supported by date queries (<, <=, =, ..., >, >=).
	 * @return array Partial date query arg with relevant dates now UTC-based.
	 *
	 * @throws \Exception If an invalid date shorthand operator is specified.
	 *
	 * @since 8.2.0
	 */
	private function local_time_to_gmt_date_query( $dates_raw, $operator ) {
		$result = array();

		// Convert YYYY-MM-DD to UTC timestamp. Per https://github.com/woocommerce/woocommerce/wiki/wc_get_orders-and-WC_Order_Query#date only date is relevant (time is ignored).
		foreach ( $dates_raw as &$raw_date ) {
			$raw_date = is_numeric( $raw_date ) ? $raw_date : strtotime( get_gmt_from_date( date( 'Y-m-d', strtotime( $raw_date ) ) ) );
		}

		$date1 = end( $dates_raw );

		switch ( $operator ) {
			case '>':
				$result = array(
					'after'     => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ),
					'inclusive' => true,
				);
				break;
			case '>=':
				$result = array(
					'after'     => $this->date_to_date_query_arg( $date1 ),
					'inclusive' => true,
				);
				break;
			case '=':
				$result = array(
					'relation' => 'AND',
					array(
						'after'     => $this->date_to_date_query_arg( $date1 ),
						'inclusive' => true,
					),
					array(
						'before'    => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ),
						'inclusive' => false,
					),
				);
				break;
			case '<=':
				$result = array(
					'before'    => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ),
					'inclusive' => false,
				);
				break;
			case '<':
				$result = array(
					'before'    => $this->date_to_date_query_arg( $date1 ),
					'inclusive' => false,
				);
				break;
			case '...':
				$result = array(
					'relation' => 'AND',
					$this->local_time_to_gmt_date_query( array( $dates_raw[1] ), '<=' ),
					$this->local_time_to_gmt_date_query( array( $dates_raw[0] ), '>=' ),
				);

				break;
		}

		if ( ! $result ) {
			throw new \Exception( 'Please specify a valid date shorthand operator.' );
		}

		return $result;
	}

	/**
	 * Processes date-related query args and merges the result into 'date_query'.
	 *
	 * @return void
	 * @throws \Exception When date args are invalid.
	 */
	private function process_date_args(): void {
		if ( $this->arg_isset( 'date_query' ) ) {
			// Process already passed date queries args.
			$this->args['date_query'] = $this->map_gmt_and_post_keys_to_hpos_keys( $this->args['date_query'] );
		}

		$valid_operators        = array( '>', '>=', '=', '<=', '<', '...' );
		$date_queries           = array();
		$local_to_gmt_date_keys = array(
			'date_created'   => 'date_created_gmt',
			'date_updated'   => 'date_updated_gmt',
			'date_paid'      => 'date_paid_gmt',
			'date_completed' => 'date_completed_gmt',
		);

		$gmt_date_keys   = array_values( $local_to_gmt_date_keys );
		$local_date_keys = array_keys( $local_to_gmt_date_keys );

		$valid_date_keys = array_merge( $gmt_date_keys, $local_date_keys );
		$date_keys       = array_filter( $valid_date_keys, array( $this, 'arg_isset' ) );

		foreach ( $date_keys as $date_key ) {
			$is_local   = in_array( $date_key, $local_date_keys, true );
			$date_value = $this->args[ $date_key ];
			$operator   = '=';
			$dates_raw  = array();
			$dates      = array();

			if ( is_string( $date_value ) && preg_match( self::REGEX_SHORTHAND_DATES, $date_value, $matches ) ) {
				$operator = in_array( $matches[2], $valid_operators, true ) ? $matches[2] : '';

				if ( ! empty( $matches[1] ) ) {
					$dates_raw[] = $matches[1];
				}

				$dates_raw[] = $matches[3];
			} else {
				$dates_raw[] = $date_value;
			}

			if ( empty( $dates_raw ) || ! $operator || ( '...' === $operator && count( $dates_raw ) < 2 ) ) {
				throw new \Exception( 'Invalid date_query' );
			}

			if ( $is_local ) {
				$date_key = $local_to_gmt_date_keys[ $date_key ];

				if ( ! is_numeric( $dates_raw[0] ) && ( ! isset( $dates_raw[1] ) || ! is_numeric( $dates_raw[1] ) ) ) {
					// Only non-numeric args can be considered local time. Timestamps are assumed to be UTC per https://github.com/woocommerce/woocommerce/wiki/wc_get_orders-and-WC_Order_Query#date.
					$date_queries[] = array_merge(
						array(
							'column' => $date_key,
						),
						$this->local_time_to_gmt_date_query( $dates_raw, $operator )
					);

					continue;
				}
			}

			$operator_to_keys = array();

			if ( in_array( $operator, array( '>', '>=', '...' ), true ) ) {
				$operator_to_keys[] = 'after';
			}

			if ( in_array( $operator, array( '<', '<=', '...' ), true ) ) {
				$operator_to_keys[] = 'before';
			}

			$dates          = array_map( array( $this, 'date_to_date_query_arg' ), $dates_raw );
			$date_queries[] = array_merge(
				array(
					'column'    => $date_key,
					'inclusive' => ! in_array( $operator, array( '<', '>' ), true ),
				),
				'=' === $operator
					? end( $dates )
					: array_combine( $operator_to_keys, $dates )
			);
		}

		// Add top-level date parameters to the date_query.
		$tl_query = array();
		foreach ( array( 'hour', 'minute', 'second', 'year', 'monthnum', 'week', 'day', 'year' ) as $tl_key ) {
			if ( $this->arg_isset( $tl_key ) ) {
				$tl_query[ $tl_key ] = $this->args[ $tl_key ];
				unset( $this->args[ $tl_key ] );
			}
		}

		if ( $tl_query ) {
			$tl_query['column'] = 'date_created_gmt';
			$date_queries[]     = $tl_query;
		}

		if ( $date_queries ) {
			if ( ! $this->arg_isset( 'date_query' ) ) {
				$this->args['date_query'] = array();
			}

			$this->args['date_query'] = array_merge(
				array( 'relation' => 'AND' ),
				$date_queries,
				$this->args['date_query']
			);
		}

		$this->process_date_query_columns();
	}

	/**
	 * Helper function to map posts and gmt based keys to HPOS keys.
	 *
	 * @param array $query Date query argument.
	 *
	 * @return array|mixed Date query argument with modified keys.
	 */
	private function map_gmt_and_post_keys_to_hpos_keys( $query ) {
		if ( ! is_array( $query ) ) {
			return $query;
		}

		$post_to_hpos_mappings = array(
			'post_date'         => 'date_created',
			'post_date_gmt'     => 'date_created_gmt',
			'post_modified'     => 'date_updated',
			'post_modified_gmt' => 'date_updated_gmt',
			'_date_completed'   => 'date_completed',
			'_date_paid'        => 'date_paid',
			'date_modified'     => 'date_updated',
			'date_modified_gmt' => 'date_updated_gmt',
		);

		$local_to_gmt_date_keys = array(
			'date_created'   => 'date_created_gmt',
			'date_updated'   => 'date_updated_gmt',
			'date_paid'      => 'date_paid_gmt',
			'date_completed' => 'date_completed_gmt',
		);

		array_walk(
			$query,
			function ( &$sub_query ) {
				$sub_query = $this->map_gmt_and_post_keys_to_hpos_keys( $sub_query );
			}
		);

		if ( ! isset( $query['column'] ) ) {
			return $query;
		}

		if ( isset( $post_to_hpos_mappings[ $query['column'] ] ) ) {
			$query['column'] = $post_to_hpos_mappings[ $query['column'] ];
		}

		// Convert any local dates to GMT.
		if ( isset( $local_to_gmt_date_keys[ $query['column'] ] ) ) {
			$query['column']  = $local_to_gmt_date_keys[ $query['column'] ];
			$op               = isset( $query['after'] ) ? 'after' : 'before';
			$date_value_local = $query[ $op ];
			$date_value_gmt   = wc_string_to_timestamp( get_gmt_from_date( wc_string_to_datetime( $date_value_local ) ) );
			$query[ $op ]     = $this->date_to_date_query_arg( $date_value_gmt );
		}

		return $query;
	}

	/**
	 * Makes sure all 'date_query' columns are correctly prefixed and their respective tables are being JOIN'ed.
	 *
	 * @return void
	 */
	private function process_date_query_columns() {
		global $wpdb;

		$legacy_columns = array(
			'post_date'         => 'date_created_gmt',
			'post_date_gmt'     => 'date_created_gmt',
			'post_modified'     => 'date_modified_gmt',
			'post_modified_gmt' => 'date_updated_gmt',
		);
		$table_mapping  = array(
			'date_created_gmt'   => $this->tables['orders'],
			'date_updated_gmt'   => $this->tables['orders'],
			'date_paid_gmt'      => $this->tables['operational_data'],
			'date_completed_gmt' => $this->tables['operational_data'],
		);

		if ( empty( $this->args['date_query'] ) ) {
			return;
		}

		array_walk_recursive(
			$this->args['date_query'],
			function ( &$value, $key ) use ( $legacy_columns, $table_mapping, $wpdb ) {
				if ( 'column' !== $key ) {
					return;
				}

				// Translate legacy columns from wp_posts if necessary.
				$value =
					( isset( $legacy_columns[ $value ] ) || isset( $legacy_columns[ "{$wpdb->posts}.{$value}" ] ) )
					? $legacy_columns[ $value ]
					: $value;

				$table = $table_mapping[ $value ] ?? null;

				if ( ! $table ) {
					return;
				}

				$value = "{$table}.{$value}";

				if ( $table !== $this->tables['orders'] ) {
					$this->join( $table, '', '', 'inner', true );
				}
			}
		);
	}

	/**
	 * Sanitizes the 'status' query var.
	 *
	 * @return void
	 */
	private function sanitize_status(): void {
		$valid_statuses = array_keys( wc_get_order_statuses() );

		if ( empty( $this->args['status'] ) ) {
			$this->args['status'] = array();
		}

		if ( ! is_array( $this->args['status'] ) ) {
			$this->args['status'] = array( $this->args['status'] );
		}

		if ( empty( $this->args['status'] ) || in_array( 'any', $this->args['status'], true ) ) {
			// Querying for 'any' status or empty status, filter to valid statuses from wc_get_order_statuses().
			$this->args['status'] = $valid_statuses;
		} elseif ( in_array( 'all', $this->args['status'], true ) ) {
			// Querying for 'all' status does not filter by status at all.
			$this->args['status'] = array();
		}

		foreach ( $this->args['status'] as &$status ) {
			$status = in_array( 'wc-' . $status, $valid_statuses, true ) ? 'wc-' . $status : $status;
		}

		$this->args['status'] = array_unique( array_filter( $this->args['status'] ) );
	}

	/**
	 * Parses and sanitizes the 'orderby' query var.
	 *
	 * @param string|array $orderby The unsanitized orderby param which can be a string or an array of orderby keys and direction (ASC, DESC).
	 * @return string|array The sanitized orderby param which can be a string or an array of orderby keys and direction (ASC, DESC).
	 */
	private function sanitize_order_orderby( $orderby ) {
		// No need to sanitize, will be processed in calling function.
		if ( 'include' === $orderby || 'post__in' === $orderby || 'none' === $orderby ) {
			return $orderby;
		}

		// Translate $orderby to a valid field.
		$mapping = array(
			'ID'            => "{$this->tables['orders']}.id",
			'id'            => "{$this->tables['orders']}.id",
			'type'          => "{$this->tables['orders']}.type",
			'date'          => "{$this->tables['orders']}.date_created_gmt",
			'date_created'  => "{$this->tables['orders']}.date_created_gmt",
			'modified'      => "{$this->tables['orders']}.date_updated_gmt",
			'date_modified' => "{$this->tables['orders']}.date_updated_gmt",
			'parent'        => "{$this->tables['orders']}.parent_order_id",
			'total'         => "{$this->tables['orders']}.total_amount",
			'order_total'   => "{$this->tables['orders']}.total_amount",
		);

		$order           = $this->sanitize_order( $this->args['order'] ?? '' );
		$allowed_orderby = array_merge( array_keys( $mapping ), array_values( $mapping ), $this->meta_query ? $this->meta_query->get_orderby_keys() : array() );

		// Convert string orderby to an array of orderby keys and direction (ASC, DESC).
		if ( is_string( $orderby ) ) {
			$orderby_fields = array_map( 'trim', explode( ' ', $orderby ) );
			$orderby        = array();
			foreach ( $orderby_fields as $field ) {
				$orderby[ $field ] = $order;
			}
		}

		$sanitized_orderby = array();

		foreach ( $orderby as $order_key => $order ) {
			if ( ! in_array( $order_key, $allowed_orderby, true ) ) {
				continue;
			}

			if ( isset( $mapping[ $order_key ] ) ) {
				$order_key = $mapping[ $order_key ];
			}

			$sanitized_orderby[ $order_key ] = $this->sanitize_order( $order );
		}

		return $sanitized_orderby;
	}

	/**
	 * Makes sure the order in an ORDER BY statement is either 'ASC' o 'DESC'.
	 *
	 * @param string $order The unsanitized order.
	 * @return string The sanitized order.
	 */
	private function sanitize_order( string $order ): string {
		$order = strtoupper( $order );

		return in_array( $order, array( 'ASC', 'DESC' ), true ) ? $order : 'DESC';
	}

	/**
	 * Builds the final SQL query to be run.
	 *
	 * @return void
	 */
	private function build_query(): void {
		$this->maybe_remap_args();

		// Field queries.
		if ( ! empty( $this->args['field_query'] ) ) {
			$this->field_query = new OrdersTableFieldQuery( $this );
			$sql               = $this->field_query->get_sql_clauses();
			$this->join        = $sql['join'] ? array_merge( $this->join, $sql['join'] ) : $this->join;
			$this->where       = $sql['where'] ? array_merge( $this->where, $sql['where'] ) : $this->where;
		}

		// Build query.
		$this->process_date_args();
		$this->process_orders_table_query_args();
		$this->process_operational_data_table_query_args();
		$this->process_addresses_table_query_args();

		// Search queries.
		if ( ! empty( $this->args['s'] ) ) {
			$this->search_query = new OrdersTableSearchQuery( $this );
			$sql                = $this->search_query->get_sql_clauses();
			$this->join         = $sql['join'] ? array_merge( $this->join, $sql['join'] ) : $this->join;
			$this->where        = $sql['where'] ? array_merge( $this->where, $sql['where'] ) : $this->where;
		}

		// Meta queries.
		if ( ! empty( $this->args['meta_query'] ) ) {
			$this->meta_query = new OrdersTableMetaQuery( $this );

			$sql = $this->meta_query->get_sql_clauses();

			$this->join  = $sql['join'] ? array_merge( $this->join, $sql['join'] ) : $this->join;
			$this->where = $sql['where'] ? array_merge( $this->where, array( $sql['where'] ) ) : $this->where;

		}

		// Date queries.
		if ( ! empty( $this->args['date_query'] ) ) {
			$this->date_query = new \WP_Date_Query( $this->args['date_query'], "{$this->tables['orders']}.date_created_gmt" );
			$this->where[]    = substr( trim( $this->date_query->get_sql() ), 3 ); // WP_Date_Query includes "AND".
		}

		$this->process_orderby();
		$this->process_limit();

		$orders_table = $this->tables['orders'];

		// Group by is a faster substitute for DISTINCT, as long as we are only selecting IDs. MySQL don't like it when we join tables and use DISTINCT.
		$this->groupby[] = "{$this->tables['orders']}.id";
		$this->fields    = "{$orders_table}.id";
		$fields          = $this->fields;

		// JOIN.
		$join = implode( ' ', array_unique( array_filter( array_map( 'trim', $this->join ) ) ) );

		// WHERE.
		$where = '1=1';
		foreach ( $this->where as $_where ) {
			if ( strlen( $_where ) > 0 ) {
				$where .= " AND ({$_where})";
			}
		}

		// ORDER BY.
		$orderby = $this->orderby ? implode( ', ', $this->orderby ) : '';

		// LIMITS.
		$limits = '';

		if ( ! empty( $this->limits ) && count( $this->limits ) === 2 ) {
			list( $offset, $row_count ) = $this->limits;
			$row_count                  = -1 === $row_count ? self::MYSQL_MAX_UNSIGNED_BIGINT : (int) $row_count;
			$limits                     = 'LIMIT ' . (int) $offset . ', ' . $row_count;
		}

		// GROUP BY.
		$groupby = $this->groupby ? implode( ', ', (array) $this->groupby ) : '';

		$pieces = compact( 'fields', 'join', 'where', 'groupby', 'orderby', 'limits' );

		if ( ! $this->suppress_filters ) {
			/**
			 * Filters all query clauses at once.
			 * Covers the fields (SELECT), JOIN, WHERE, GROUP BY, ORDER BY, and LIMIT clauses.
			 *
			 * @since 7.9.0
			 *
			 * @param string[]         $clauses {
			 *     Associative array of the clauses for the query.
			 *
			 *     @type string $fields  The SELECT clause of the query.
			 *     @type string $join    The JOIN clause of the query.
			 *     @type string $where   The WHERE clause of the query.
			 *     @type string $groupby The GROUP BY clause of the query.
			 *     @type string $orderby The ORDER BY clause of the query.
			 *     @type string $limits  The LIMIT clause of the query.
			 * }
			 * @param OrdersTableQuery $query   The OrdersTableQuery instance (passed by reference).
			 * @param array            $args    Query args.
			 */
			$clauses = (array) apply_filters_ref_array( 'woocommerce_orders_table_query_clauses', array( $pieces, &$this, $this->args ) );

			$fields  = $clauses['fields'] ?? '';
			$join    = $clauses['join'] ?? '';
			$where   = $clauses['where'] ?? '';
			$groupby = $clauses['groupby'] ?? '';
			$orderby = $clauses['orderby'] ?? '';
			$limits  = $clauses['limits'] ?? '';
		}

		$groupby = $groupby ? ( 'GROUP BY ' . $groupby ) : '';
		$orderby = $orderby ? ( 'ORDER BY ' . $orderby ) : '';

		$this->sql = "SELECT $fields FROM $orders_table $join WHERE $where $groupby $orderby $limits";

		if ( ! $this->suppress_filters ) {
			/**
			 * Filters the completed SQL query.
			 *
			 * @since 7.9.0
			 *
			 * @param string           $sql   The complete SQL query.
			 * @param OrdersTableQuery $query The OrdersTableQuery instance (passed by reference).
			 * @param array            $args  Query args.
			 */
			$this->sql = apply_filters_ref_array( 'woocommerce_orders_table_query_sql', array( $this->sql, &$this, $this->args ) );
		}

		$this->build_count_query( $fields, $join, $where, $groupby );
	}

	/**
	 * Build SQL query for counting total number of results.
	 *
	 * @param string $fields Prepared fields for SELECT clause.
	 * @param string $join Prepared JOIN clause.
	 * @param string $where Prepared WHERE clause.
	 * @param string $groupby Prepared GROUP BY clause.
	 */
	private function build_count_query( $fields, $join, $where, $groupby ) {
		if ( ! isset( $this->sql ) || '' === $this->sql ) {
			wc_doing_it_wrong( __FUNCTION__, 'Count query can only be build after main query is built.', '7.3.0' );
		}
		$orders_table = $this->tables['orders'];
		$count_fields = "COUNT(DISTINCT $fields)";
		if ( "{$orders_table}.id" === $fields && '' === $join ) {
			// DISTINCT adds performance overhead, exclude the DISTINCT function when confident it is not needed.
			$count_fields = 'COUNT(*)';
		}
		$this->count_sql = "SELECT $count_fields FROM $orders_table $join WHERE $where";

		if ( ! $this->suppress_filters ) {
			/**
			 * Filters the count SQL query.
			 *
			 * @since 8.6.0
			 *
			 * @param string           $sql   The count SQL query.
			 * @param OrdersTableQuery $query The OrdersTableQuery instance (passed by reference).
			 * @param array            $args  Query args.
			 * @param string           $fields Prepared fields for SELECT clause.
			 * @param string           $join Prepared JOIN clause.
			 * @param string           $where Prepared WHERE clause.
			 * @param string           $groupby Prepared GROUP BY clause.
			 */
			$this->count_sql = apply_filters_ref_array( 'woocommerce_orders_table_query_count_sql', array( $this->count_sql, &$this, $this->args, $fields, $join, $where, $groupby ) );
		}
	}

	/**
	 * Returns the table alias for a given table mapping.
	 *
	 * @param string $mapping_id The mapping name (e.g. 'orders' or 'operational_data').
	 * @return string Table alias.
	 *
	 * @since 7.0.0
	 */
	public function get_core_mapping_alias( string $mapping_id ): string {
		return in_array( $mapping_id, array( 'billing_address', 'shipping_address' ), true )
			? $mapping_id
			: $this->tables[ $mapping_id ];
	}

	/**
	 * Returns an SQL JOIN clause that can be used to join the main orders table with another order table.
	 *
	 * @param string $mapping_id The mapping name (e.g. 'orders' or 'operational_data').
	 * @return string The JOIN clause.
	 *
	 * @since 7.0.0
	 */
	public function get_core_mapping_join( string $mapping_id ): string {
		global $wpdb;

		if ( 'orders' === $mapping_id ) {
			return '';
		}

		$is_address_mapping = in_array( $mapping_id, array( 'billing_address', 'shipping_address' ), true );

		$alias   = $this->get_core_mapping_alias( $mapping_id );
		$table   = $is_address_mapping ? $this->tables['addresses'] : $this->tables[ $mapping_id ];
		$join    = '';
		$join_on = '';

		$join .= "INNER JOIN `{$table}`" . ( $alias !== $table ? " AS `{$alias}`" : '' );

		if ( isset( $this->mappings[ $mapping_id ]['order_id'] ) ) {
			$join_on .= "`{$this->tables['orders']}`.id = `{$alias}`.order_id";
		}

		if ( $is_address_mapping ) {
			$join_on .= $wpdb->prepare( " AND `{$alias}`.address_type = %s", substr( $mapping_id, 0, -8 ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		}

		return $join . ( $join_on ? " ON ( {$join_on} )" : '' );
	}

	/**
	 * JOINs the main orders table with another table.
	 *
	 * @param string  $table      Table name (including prefix).
	 * @param string  $alias      Table alias to use. Defaults to $table.
	 * @param string  $on         ON clause. Defaults to "wc_orders.id = {$alias}.order_id".
	 * @param string  $join_type  JOIN type: LEFT, RIGHT or INNER.
	 * @param boolean $alias_once If TRUE, table won't be JOIN'ed again if already JOIN'ed.
	 * @return void
	 * @throws \Exception When an error occurs, such as trying to re-use an alias with $alias_once = FALSE.
	 */
	private function join( string $table, string $alias = '', string $on = '', string $join_type = 'inner', bool $alias_once = false ) {
		$alias     = empty( $alias ) ? $table : $alias;
		$join_type = strtoupper( trim( $join_type ) );

		if ( $this->tables['orders'] === $alias ) {
			// translators: %s is a table name.
			throw new \Exception( sprintf( __( '%s can not be used as a table alias in OrdersTableQuery', 'woocommerce' ), $alias ) );
		}

		if ( empty( $on ) ) {
			if ( $this->tables['orders'] === $table ) {
				$on = "`{$this->tables['orders']}`.id = `{$alias}`.id";
			} else {
				$on = "`{$this->tables['orders']}`.id = `{$alias}`.order_id";
			}
		}

		if ( isset( $this->join[ $alias ] ) ) {
			if ( ! $alias_once ) {
				// translators: %s is a table name.
				throw new \Exception( sprintf( __( 'Can not re-use table alias "%s" in OrdersTableQuery.', 'woocommerce' ), $alias ) );
			}

			return;
		}

		if ( '' === $join_type || ! in_array( $join_type, array( 'LEFT', 'RIGHT', 'INNER' ), true ) ) {
			$join_type = 'INNER';
		}

		$sql_join  = '';
		$sql_join .= "{$join_type} JOIN `{$table}` ";
		$sql_join .= ( $alias !== $table ) ? "AS `{$alias}` " : '';
		$sql_join .= "ON ( {$on} )";

		$this->join[ $alias ] = $sql_join;
	}

	/**
	 * Generates a properly escaped and sanitized WHERE condition for a given field.
	 *
	 * @param string $table    The table the field belongs to.
	 * @param string $field    The field or column name.
	 * @param string $operator The operator to use in the condition. Defaults to '=' or 'IN' depending on $value.
	 * @param mixed  $value    The value.
	 * @param string $type     The column type as specified in {@see OrdersTableDataStore} column mappings.
	 * @return string The resulting WHERE condition.
	 */
	public function where( string $table, string $field, string $operator, $value, string $type ): string {
		global $wpdb;

		$db_util  = wc_get_container()->get( DatabaseUtil::class );
		$operator = strtoupper( '' !== $operator ? $operator : '=' );

		try {
			$format = $db_util->get_wpdb_format_for_type( $type );
		} catch ( \Exception $e ) {
			$format = '%s';
		}

		// = and != can be shorthands for IN and NOT in for array values.
		if ( is_array( $value ) && '=' === $operator ) {
			$operator = 'IN';
		} elseif ( is_array( $value ) && '!=' === $operator ) {
			$operator = 'NOT IN';
		}

		if ( ! in_array( $operator, array( '=', '!=', 'IN', 'NOT IN', '>', '>=', '<', '<=' ), true ) ) {
			return false;
		}

		if ( is_array( $value ) ) {
			$value = array_map( array( $db_util, 'format_object_value_for_db' ), $value, array_fill( 0, count( $value ), $type ) );
		} else {
			$value = $db_util->format_object_value_for_db( $value, $type );
		}

		if ( is_array( $value ) ) {
			$placeholder = array_fill( 0, count( $value ), $format );
			$placeholder = '(' . implode( ',', $placeholder ) . ')';
		} else {
			$placeholder = $format;
		}

		$sql = $wpdb->prepare( "{$table}.{$field} {$operator} {$placeholder}", $value ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare

		return $sql;
	}

	/**
	 * Processes fields related to the orders table.
	 *
	 * @return void
	 */
	private function process_orders_table_query_args(): void {
		$this->sanitize_status();

		$fields = array_filter(
			array(
				'id',
				'status',
				'type',
				'currency',
				'tax_amount',
				'customer_id',
				'billing_email',
				'parent_order_id',
				'payment_method',
				'payment_method_title',
				'transaction_id',
				'ip_address',
				'user_agent',
			),
			array( $this, 'arg_isset' )
		);

		foreach ( $fields as $arg_key ) {
			$this->where[] = $this->where( $this->tables['orders'], $arg_key, '=', $this->args[ $arg_key ], $this->mappings['orders'][ $arg_key ]['type'] );
		}

		if ( $this->arg_isset( 'parent_exclude' ) ) {
			$this->where[] = $this->where( $this->tables['orders'], 'parent_order_id', '!=', $this->args['parent_exclude'], 'int' );
		}

		if ( $this->arg_isset( 'exclude' ) ) {
			$this->where[] = $this->where( $this->tables['orders'], 'id', '!=', $this->args['exclude'], 'int' );
		}

		// 'customer' is a very special field.
		if ( $this->arg_isset( 'customer' ) ) {
			$customer_query = $this->generate_customer_query( $this->args['customer'] );

			if ( $customer_query ) {
				$this->where[] = $customer_query;
			}
		}

		// Handle total filtering with operators.
		if ( $this->arg_isset( 'total_amount' ) ) {
			$total_param = $this->args['total_amount'];

			// If it's a simple number, convert to array format.
			if ( is_numeric( $total_param ) ) {
				$total_param = array(
					'value'    => $total_param,
					'operator' => '=',
				);
			}

			$total_query = $this->generate_total_query( (array) $total_param );

			if ( $total_query ) {
				$this->where[] = $total_query;
			}
		}
	}

	/**
	 * Generate SQL conditions for the 'customer' query.
	 *
	 * @param array  $values   List of customer ids or emails.
	 * @param string $relation 'OR' or 'AND' relation used to build the customer query.
	 * @return string SQL to be used in a WHERE clause.
	 */
	private function generate_customer_query( $values, string $relation = 'OR' ): string {
		$values = is_array( $values ) ? $values : array( $values );
		$ids    = array();
		$emails = array();
		$pieces = array();
		foreach ( $values as $value ) {
			if ( is_array( $value ) ) {
				$sql      = $this->generate_customer_query( $value, 'AND' );
				$pieces[] = $sql ? '(' . $sql . ')' : '';
			} elseif ( is_numeric( $value ) ) {
				$ids[] = absint( $value );
			} elseif ( is_string( $value ) && is_email( $value ) ) {
				$emails[] = sanitize_email( $value );
			} else {
				// Invalid query.
				$pieces[] = '1=0';
			}
		}

		if ( $ids ) {
			$pieces[] = $this->where( $this->tables['orders'], 'customer_id', '=', $ids, 'int' );
		}

		if ( $emails ) {
			$pieces[] = $this->where( $this->tables['orders'], 'billing_email', '=', $emails, 'string' );
		}

		return $pieces ? implode( " $relation ", $pieces ) : '';
	}

	/**
	 * Generate SQL conditions for the 'total' query with operators.
	 *
	 * @param array $total_params Total query parameters with value, operator.
	 * @return string SQL to be used in a WHERE clause.
	 */
	private function generate_total_query( array $total_params ): string {
		if ( ! isset( $total_params['value'] ) ) {
			return '';
		}

		$operator            = $total_params['operator'] ?? '=';
		$value               = $total_params['value'];
		$supported_operators = array( '=', '!=', '>', '>=', '<', '<=', 'BETWEEN', 'NOT BETWEEN' );

		if ( ! in_array( $operator, $supported_operators, true ) ) {
			return '';
		}

		// Handle between operators.
		if ( 'BETWEEN' === $operator || 'NOT BETWEEN' === $operator ) {
			if ( ! is_array( $value ) || count( $value ) !== 2 ) {
				return '';
			}
			$value1 = wc_format_decimal( $value[0], wc_get_price_decimals() );
			$value2 = wc_format_decimal( $value[1], wc_get_price_decimals() );

			if ( 'BETWEEN' === $operator ) {
				return $this->where( $this->tables['orders'], 'total_amount', '>=', $value1, 'decimal' ) . ' AND ' . $this->where( $this->tables['orders'], 'total_amount', '<=', $value2, 'decimal' );
			} else {
				return '(' . $this->where( $this->tables['orders'], 'total_amount', '<', $value1, 'decimal' ) . ' OR ' . $this->where( $this->tables['orders'], 'total_amount', '>', $value2, 'decimal' ) . ')';
			}
		}

		// Handle other operators - value must be a single number.
		if ( ! is_numeric( $value ) ) {
			return '';
		}

		return $this->where( $this->tables['orders'], 'total_amount', $operator, wc_format_decimal( $value, wc_get_price_decimals() ), 'decimal' );
	}

	/**
	 * Processes fields related to the operational data table.
	 *
	 * @return void
	 */
	private function process_operational_data_table_query_args(): void {
		$fields = array_filter(
			array(
				'created_via',
				'woocommerce_version',
				'prices_include_tax',
				'order_key',
				'discount_total_amount',
				'discount_tax_amount',
				'shipping_total_amount',
				'shipping_tax_amount',
			),
			array( $this, 'arg_isset' )
		);

		if ( ! $fields ) {
			return;
		}

		$this->join(
			$this->tables['operational_data'],
			'',
			'',
			'inner',
			true
		);

		foreach ( $fields as $arg_key ) {
			$this->where[] = $this->where( $this->tables['operational_data'], $arg_key, '=', $this->args[ $arg_key ], $this->mappings['operational_data'][ $arg_key ]['type'] );
		}
	}

	/**
	 * Processes fields related to the addresses table.
	 *
	 * @return void
	 */
	private function process_addresses_table_query_args(): void {
		global $wpdb;

		foreach ( array( 'billing', 'shipping' ) as $address_type ) {
			$fields = array_filter(
				array(
					$address_type . '_first_name',
					$address_type . '_last_name',
					$address_type . '_company',
					$address_type . '_address_1',
					$address_type . '_address_2',
					$address_type . '_city',
					$address_type . '_state',
					$address_type . '_postcode',
					$address_type . '_country',
					$address_type . '_phone',
				),
				array( $this, 'arg_isset' )
			);

			if ( ! $fields ) {
				continue;
			}

			$this->join(
				$this->tables['addresses'],
				$address_type,
				$wpdb->prepare( "{$this->tables['orders']}.id = {$address_type}.order_id AND {$address_type}.address_type = %s", $address_type ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				'inner',
				false
			);

			foreach ( $fields as $arg_key ) {
				$column_name = str_replace( "{$address_type}_", '', $arg_key );

				$this->where[] = $this->where(
					$address_type,
					$column_name,
					'=',
					$this->args[ $arg_key ],
					$this->mappings[ "{$address_type}_address" ][ $column_name ]['type']
				);
			}
		}
	}

	/**
	 * Generates the ORDER BY clause.
	 *
	 * @return void
	 */
	private function process_orderby(): void {
		// 'order' and 'orderby' vars.
		$order   = $this->sanitize_order( $this->args['order'] ?? '' );
		$orderby = $this->sanitize_order_orderby( $this->args['orderby'] ?? 'none' );

		// Set orderby to an empty array by default. This will also be used if sanitize_order_orderby recieved "none".
		$this->orderby = array();

		if ( 'include' === $orderby || 'post__in' === $orderby ) {
			$ids = $this->args['id'] ?? $this->args['includes'];
			if ( empty( $ids ) ) {
				return;
			}
			$ids           = array_map( 'absint', $ids );
			$this->orderby = array( "FIELD( {$this->tables['orders']}.id, " . implode( ',', $ids ) . ' )' );
			return;
		}

		if ( is_array( $orderby ) ) {
			$meta_orderby_keys = $this->meta_query ? $this->meta_query->get_orderby_keys() : array();
			$orderby_array     = array();

			foreach ( $orderby as $_orderby => $order ) {
				if ( in_array( $_orderby, $meta_orderby_keys, true ) ) {
					$_orderby = $this->meta_query->get_orderby_clause_for_key( $_orderby );
				}

				$orderby_array[] = "{$_orderby} {$order}";
			}

			$this->orderby = $orderby_array;
		}
	}

	/**
	 * Generates the limits to be used in the LIMIT clause.
	 *
	 * @return void
	 */
	private function process_limit(): void {
		$row_count = ( $this->arg_isset( 'limit' ) ? (int) $this->args['limit'] : false );
		$page      = ( $this->arg_isset( 'page' ) ? absint( $this->args['page'] ) : 1 );
		$offset    = ( $this->arg_isset( 'offset' ) ? absint( $this->args['offset'] ) : false );

		// Bool false indicates no limit was specified; less than -1 means an invalid value was passed (such as -3).
		if ( false === $row_count || $row_count < -1 ) {
			return;
		}

		if ( false === $offset && $row_count > -1 ) {
			$offset = (int) ( ( $page - 1 ) * $row_count );
		}

		$this->limits = array( $offset, $row_count );
	}

	/**
	 * Checks if a query var is set (i.e. not one of the "skipped values").
	 *
	 * @param string $arg_key Query var.
	 * @return bool TRUE if query var is set.
	 */
	public function arg_isset( string $arg_key ): bool {
		return ( isset( $this->args[ $arg_key ] ) && ! in_array( $this->args[ $arg_key ], self::SKIPPED_VALUES, true ) );
	}

	/**
	 * Runs the SQL query.
	 *
	 * @return void
	 */
	private function run_query(): void {
		global $wpdb;

		// Run query.
		$this->orders = array_map( 'absint', $wpdb->get_col( $this->sql ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

		// Set max_num_pages and found_orders if necessary.
		if ( ( $this->arg_isset( 'no_found_rows' ) && $this->args['no_found_rows'] ) || empty( $this->orders ) ) {
			return;
		}

		if ( $this->limits ) {
			$this->found_orders  = absint( $wpdb->get_var( $this->count_sql ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
			$this->max_num_pages = (int) ceil( $this->found_orders / $this->args['limit'] );
		} else {
			$this->found_orders = count( $this->orders );
		}
	}

	/**
	 * Make some private available for backwards compatibility.
	 *
	 * @param string $name Property to get.
	 * @return mixed
	 */
	public function __get( string $name ) {
		switch ( $name ) {
			case 'found_orders':
			case 'found_posts':
				return $this->found_orders;
			case 'max_num_pages':
				return $this->max_num_pages;
			case 'posts':
			case 'orders':
				return $this->orders;
			case 'request':
				return $this->sql;
			default:
				break;
		}
	}

	/**
	 * Returns the value of one of the query arguments.
	 *
	 * @param string $arg_name Query var.
	 * @return mixed
	 */
	public function get( string $arg_name ) {
		return $this->args[ $arg_name ] ?? null;
	}

	/**
	 * Returns the name of one of the OrdersTableDatastore tables.
	 *
	 * @param string $table_id Table identifier. One of 'orders', 'operational_data', 'addresses', 'meta'.
	 * @return string The prefixed table name.
	 * @throws \Exception When table ID is not found.
	 */
	public function get_table_name( string $table_id = '' ): string {
		if ( ! isset( $this->tables[ $table_id ] ) ) {
			// Translators: %s is a table identifier.
			throw new \Exception( sprintf( __( 'Invalid table id: %s.', 'woocommerce' ), $table_id ) );
		}

		return $this->tables[ $table_id ];
	}

	/**
	 * Finds table and mapping information about a field or column.
	 *
	 * @param string $field Field to look for in `<mapping|field_name>.<column|field_name>` format or just `<field_name>`.
	 * @return false|array {
	 *     @type string $table      Full table name where the field is located.
	 *     @type string $mapping_id Unprefixed table or mapping name.
	 *     @type string $field_name Name of the corresponding order field.
	 *     @type string $column     Column in $table that corresponds to the field.
	 *     @type string $type       Field type.
	 * }
	 */
	public function get_field_mapping_info( $field ) {
		global $wpdb;

		$result = array(
			'table'       => '',
			'mapping_id'  => '',
			'field_name'  => '',
			'column'      => '',
			'column_type' => '',
		);

		$mappings_to_search = array();

		if ( false !== strstr( $field, '.' ) ) {
			list( $mapping_or_table, $field_name_or_col ) = explode( '.', $field );

			$mapping_or_table = substr( $mapping_or_table, 0, strlen( $wpdb->prefix ) ) === $wpdb->prefix ? substr( $mapping_or_table, strlen( $wpdb->prefix ) ) : $mapping_or_table;
			$mapping_or_table = 'wc_' === substr( $mapping_or_table, 0, 3 ) ? substr( $mapping_or_table, 3 ) : $mapping_or_table;

			if ( isset( $this->mappings[ $mapping_or_table ] ) ) {
				if ( isset( $this->mappings[ $mapping_or_table ][ $field_name_or_col ] ) ) {
					$result['mapping_id'] = $mapping_or_table;
					$result['column']     = $field_name_or_col;
				} else {
					$mappings_to_search = array( $mapping_or_table );
				}
			}
		} else {
			$field_name_or_col  = $field;
			$mappings_to_search = array_keys( $this->mappings );
		}

		foreach ( $mappings_to_search as $mapping_id ) {
			foreach ( $this->mappings[ $mapping_id ] as $column_name => $column_data ) {
				if ( isset( $column_data['name'] ) && $column_data['name'] === $field_name_or_col ) {
					$result['mapping_id'] = $mapping_id;
					$result['column']     = $column_name;
					break 2;
				}
			}
		}

		if ( ! $result['mapping_id'] || ! $result['column'] ) {
			return false;
		}

		$field_info = $this->mappings[ $result['mapping_id'] ][ $result['column'] ];

		$result['field_name']  = $field_info['name'];
		$result['column_type'] = $field_info['type'];
		$result['table']       = ( in_array( $result['mapping_id'], array( 'billing_address', 'shipping_address' ), true ) )
								? $this->tables['addresses']
								: $this->tables[ $result['mapping_id'] ];

		return $result;
	}

	/**
	 * Return the query args that were used to initialize the query.
	 *
	 * @since 9.8.0
	 * @return array Query args.
	 */
	public function get_query_args(): array {
		return $this->query_args;
	}
}
PK     [1]uf    '  DataStores/Orders/LegacyDataCleanup.phpnu         <?php
/**
 * LegacyDataCleanup class file.
 */

namespace Automattic\WooCommerce\Internal\DataStores\Orders;

use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessorInterface;

defined( 'ABSPATH' ) || exit;

/**
 * This class handles the background process in charge of cleaning up legacy data for orders when HPOS is authoritative.
 */
class LegacyDataCleanup implements BatchProcessorInterface {

	/**
	 * Option name for this feature.
	 *
	 * @deprecated 9.1.0
	 */
	public const OPTION_NAME = 'woocommerce_hpos_legacy_data_cleanup_in_progress';

	/**
	 * The default number of orders to process per batch.
	 */
	private const BATCH_SIZE = 25;

	/**
	 * The batch processing controller to use.
	 *
	 * @var BatchProcessingController
	 */
	private $batch_processing;

	/**
	 * The legacy handler to use for the actual cleanup.
	 *
	 * @var LegacyHandler
	 */
	private $legacy_handler;

	/**
	 * The data synchronizer object to use.
	 *
	 * @var DataSynchronizer
	 */
	private $data_synchronizer;

	/**
	 * Logger object to be used to log events.
	 *
	 * @var \WC_Logger
	 */
	private $error_logger;

	/**
	 * Class initialization, invoked by the DI container.
	 *
	 * @param BatchProcessingController $batch_processing  The batch processing controller to use.
	 * @param LegacyDataHandler         $legacy_handler    Legacy order data handler instance.
	 * @param DataSynchronizer          $data_synchronizer Data synchronizer instance.
	 * @internal
	 */
	final public function init( BatchProcessingController $batch_processing, LegacyDataHandler $legacy_handler, DataSynchronizer $data_synchronizer ) {
		$this->legacy_handler    = $legacy_handler;
		$this->data_synchronizer = $data_synchronizer;
		$this->batch_processing  = $batch_processing;
		$this->error_logger      = wc_get_logger();
	}

	/**
	 * A user friendly name for this process.
	 *
	 * @return string Name of the process.
	 */
	public function get_name(): string {
		return 'Order legacy data cleanup';
	}

	/**
	 * A user friendly description for this process.
	 *
	 * @return string Description.
	 */
	public function get_description(): string {
		return 'Cleans up order data from legacy tables.';
	}

	/**
	 * Get total number of pending records that require update.
	 *
	 * @return int Number of pending records.
	 */
	public function get_total_pending_count(): int {
		return $this->can_run() ? $this->legacy_handler->count_orders_for_cleanup() : 0;
	}

	/**
	 * Returns the batch with records that needs to be processed for a given size.
	 *
	 * @param int $size Size of the batch.
	 * @return array Batch of records.
	 */
	public function get_next_batch_to_process( int $size ): array {
		return $this->can_run()
			? array_map( 'absint', $this->legacy_handler->get_orders_for_cleanup( array(), $size ) )
			: array();
	}

	/**
	 * Process data for current batch.
	 *
	 * @param array $batch Batch details.
	 */
	public function process_batch( array $batch ): void {
		// This is a destructive operation, so check if we need to bail out just in case.
		if ( ! $this->can_run() ) {
			$this->toggle_flag( false );
			return;
		}

		$batch_failed = true;

		foreach ( $batch as $order_id ) {
			try {
				$this->legacy_handler->cleanup_post_data( absint( $order_id ) );
				$batch_failed = false;
			} catch ( \Exception $e ) {
				$this->error_logger->error(
					sprintf(
						// translators: %1$d is an order ID, %2$s is an error message.
						__( 'Order %1$d legacy data could not be cleaned up during batch process. Error: %2$s', 'woocommerce' ),
						$order_id,
						$e->getMessage()
					)
				);
			}
		}

		if ( $batch_failed ) {
			$this->error_logger->error( __( 'Order legacy cleanup failed for an entire batch of orders. Aborting cleanup.', 'woocommerce' ) );
		}

		if ( ! $this->orders_pending() || $batch_failed ) {
			$this->toggle_flag( false );
		}
	}

	/**
	 * Default batch size to use.
	 *
	 * @return int Default batch size.
	 */
	public function get_default_batch_size(): int {
		return self::BATCH_SIZE;
	}

	/**
	 * Determine whether the cleanup process can be initiated. Legacy data cleanup requires HPOS to be authoritative and
	 * compatibility mode to be disabled.
	 *
	 * @return boolean TRUE if the cleanup process can be enabled, FALSE otherwise.
	 */
	public function can_run() {
		return $this->data_synchronizer->custom_orders_table_is_authoritative() && ! $this->data_synchronizer->data_sync_is_enabled() && ! $this->batch_processing->is_enqueued( get_class( $this->data_synchronizer ) );
	}

	/**
	 * Whether the user has initiated the cleanup process.
	 *
	 * @return boolean TRUE if the user has initiated the cleanup process, FALSE otherwise.
	 */
	public function is_flag_set() {
		return $this->batch_processing->is_enqueued( self::class );
	}

	/**
	 * Sets the flag that indicates that the cleanup process should be initiated.
	 *
	 * @param boolean $enabled TRUE if the process should be initiated, FALSE if it should be canceled.
	 * @return boolean Whether the legacy data cleanup was initiated or not.
	 */
	public function toggle_flag( bool $enabled ): bool {
		if ( $enabled && $this->can_run() ) {
			$this->batch_processing->enqueue_processor( self::class );
			return true;
		} else {
			$this->batch_processing->remove_processor( self::class );
			return $enabled ? false : true;
		}
	}

	/**
	 * Returns an array in format required by 'woocommerce_debug_tools' to register the cleanup tool in WC.
	 *
	 * @return array Tools entries to register with WC.
	 */
	public function get_tools_entries() {
		$orders_for_cleanup_exist = ! empty( $this->legacy_handler->get_orders_for_cleanup( array(), 1 ) );
		$entry_id                 = $this->is_flag_set() ? 'hpos_legacy_cleanup_cancel' : 'hpos_legacy_cleanup';
		$entry                    = array(
			'name'             => __( 'Clean up order data from legacy tables', 'woocommerce' ),
			'desc'             => __( 'This tool will clear the data from legacy order tables in WooCommerce.', 'woocommerce' ),
			'requires_refresh' => true,
			'button'           => __( 'Clear data', 'woocommerce' ),
			'disabled'         => ! ( $this->can_run() && ( $orders_for_cleanup_exist || $this->is_flag_set() ) ),
		);

		if ( ! $this->can_run() ) {
			$entry['desc'] .= '<br />';
			$entry['desc'] .= sprintf(
				'<strong class="red">%1$s</strong> %2$s',
				__( 'Note:', 'woocommerce' ),
				__( 'Only available when HPOS is authoritative and compatibility mode is disabled.', 'woocommerce' )
			);
		} else {
			if ( $this->is_flag_set() ) {
				$entry['status_text'] = sprintf(
					'%1$s %2$s',
					'<span class="dashicons dashicons-update spin"></span>',
					__( 'Clearing data...', 'woocommerce' )
				);
				$entry['button']      = __( 'Cancel', 'woocommerce' );
				$entry['callback']    = function() {
					$this->toggle_flag( false );
					return __( 'Order legacy data cleanup has been canceled.', 'woocommerce' );
				};
			} elseif ( ! $orders_for_cleanup_exist ) {
				$entry['button'] = __( 'No orders in need of cleanup', 'woocommerce' );
			} else {
				$entry['callback'] = function() {
					$this->toggle_flag( true );
					return __( 'Order legacy data cleanup process has been started.', 'woocommerce' );
				};
			}
		}

		return array( $entry_id => $entry );
	}

	/**
	 * Checks whether there are any orders in need of cleanup and cleanup can run.
	 *
	 * @return bool TRUE if there are orders in need of cleanup, FALSE otherwise.
	 */
	private function orders_pending() {
		return ! empty( $this->get_next_batch_to_process( 1 ) );
	}

}
PK     [1]=n9  9  ,  DataStores/Orders/OrdersTableSearchQuery.phpnu         <?php

namespace Automattic\WooCommerce\Internal\DataStores\Orders;

use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Exception;

/**
 * Creates the join and where clauses needed to perform an order search using Custom Order Tables.
 *
 * @internal
 */
class OrdersTableSearchQuery {
	/**
	 * Holds the Orders Table Query object.
	 *
	 * @var OrdersTableQuery
	 */
	private $query;

	/**
	 * Holds the search term to be used in the WHERE clauses.
	 *
	 * @var string
	 */
	private $search_term;

	/**
	 * Limits the search to a specific field.
	 *
	 * @var string[]
	 */
	private $search_filters;

	/**
	 * Alias used for the derived table that holds FTS product hits.
	 */
	private const PRODUCTS_JOIN_ALIAS = 'fts_items';

	/**
	 * Alias used for the derived table that holds FTS customer/address hits.
	 */
	private const CUSTOMERS_JOIN_ALIAS = 'fts_addresses';

	/**
	 * Creates the JOIN and WHERE clauses needed to execute a search of orders.
	 *
	 * @internal
	 *
	 * @param OrdersTableQuery $query The order query object.
	 */
	public function __construct( OrdersTableQuery $query ) {
		$this->query          = $query;
		$this->search_term    = $query->get( 's' );
		$this->search_filters = $this->sanitize_search_filters( $query->get( 'search_filter' ) ?? '' );
	}

	/**
	 * Sanitize search filter param.
	 *
	 * @param string $search_filter Search filter param.
	 *
	 * @return array Array of search filters.
	 */
	private function sanitize_search_filters( string $search_filter ): array {
		$core_filters = array(
			'order_id',
			'transaction_id',
			'customer_email',
			'customers', // customers also searches in meta.
			'products',
		);

		if ( 'all' === $search_filter || '' === $search_filter ) {
			return $core_filters;
		} else {
			return array( $search_filter );
		}
	}

	/**
	 * Supplies an array of clauses to be used in an order query.
	 *
	 * @internal
	 * @throws Exception If unable to generate either the JOIN or WHERE SQL fragments.
	 *
	 * @return array {
	 *     @type string $join  JOIN clause.
	 *     @type string $where WHERE clause.
	 * }
	 */
	public function get_sql_clauses(): array {
		return array(
			'join'  => array( $this->generate_join() ),
			'where' => array( $this->generate_where() ),
		);
	}

	/**
	 * Generates the necessary JOIN clauses for the order search to be performed.
	 *
	 * @throws Exception May be triggered if a table name cannot be determined.
	 *
	 * @return string
	 */
	private function generate_join(): string {
		$join = array();

		foreach ( $this->search_filters as $search_filter ) {
			$join[] = $this->generate_join_for_search_filter( $search_filter );
		}

		return implode( ' ', $join );
	}

	/**
	 * Generate JOIN clause for a given search filter.
	 * Right now we only have the products filter that actually does a JOIN, but in the future we may add more -- for example, custom order fields, payment tokens, and so on. This function makes it easier to add more filters in the future.
	 *
	 * If a search filter needs a JOIN, it will also need a WHERE clause.
	 *
	 * @param string $search_filter Name of the search filter.
	 *
	 * @return string JOIN clause.
	 */
	private function generate_join_for_search_filter( $search_filter ): string {
		$join = '';

		if ( 'products' === $search_filter ) {
			$join = $this->maybe_get_join_for_products();
		}

		if ( 'customers' === $search_filter ) {
			$join = $this->maybe_get_join_for_customers();
		}
		/**
		 * Filter to support adding a custom order search filter.
		 * Provide a JOIN clause for a new search filter. This should be used along with `woocommerce_hpos_admin_search_filters`
		 * to declare a new custom filter, and `woocommerce_hpos_generate_where_for_search_filter` to generate the WHERE
		 * clause.
		 *
		 * Hardcoded JOINS (products) cannot be modified using this filter for consistency.
		 *
		 * @since 8.9.0
		 *
		 * @param string $join The JOIN clause.
		 * @param string $search_term The search term.
		 * @param string $search_filter The search filter. Use this to bail early if this is not filter you are interested in.
		 * @param OrdersTableQuery $query The order query object.
		 */
		return apply_filters(
			'woocommerce_hpos_generate_join_for_search_filter',
			$join,
			$this->search_term,
			$search_filter,
			$this->query
		);
	}

	/**
	 * Returns a prepared JOIN fragment for products when FTS is enabled.
	 *
	 * @since 10.1.0
	 * @return string JOIN clause or empty string if FTS disabled.
	 */
	private function maybe_get_join_for_products(): string {
		global $wpdb;

		$db_util      = wc_get_container()->get( DatabaseUtil::class );
		$items_table  = $this->query->get_table_name( 'items' );
		$orders_table = $this->query->get_table_name( 'orders' );

		$fts_enabled = get_option( CustomOrdersTableController::HPOS_FTS_INDEX_OPTION ) === 'yes'
			&& get_option( CustomOrdersTableController::HPOS_FTS_ORDER_ITEM_INDEX_CREATED_OPTION ) === 'yes';

		if ( ! $fts_enabled ) {
			return '';
		}

		$search_pattern = $wpdb->esc_like( $db_util->sanitise_boolean_fts_search_term( $this->search_term ) );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
		return $wpdb->prepare(
			"LEFT JOIN (
				SELECT DISTINCT order_id
				FROM $items_table
				WHERE MATCH ( order_item_name ) AGAINST ( %s IN BOOLEAN MODE )
			) AS " . self::PRODUCTS_JOIN_ALIAS . ' ON ' . self::PRODUCTS_JOIN_ALIAS . ".order_id = $orders_table.id",
			$search_pattern
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
	}

	/**
	 * Returns a prepared JOIN fragment for customers/addresses when FTS is enabled.
	 *
	 * @since 10.1.0
	 * @return string JOIN clause or empty string if FTS disabled.
	 */
	private function maybe_get_join_for_customers(): string {
		global $wpdb;

		$db_util       = wc_get_container()->get( DatabaseUtil::class );
		$address_table = $this->query->get_table_name( 'addresses' );
		$orders_table  = $this->query->get_table_name( 'orders' );

		$fts_enabled = get_option( CustomOrdersTableController::HPOS_FTS_INDEX_OPTION ) === 'yes'
			&& get_option( CustomOrdersTableController::HPOS_FTS_ADDRESS_INDEX_CREATED_OPTION ) === 'yes';

		if ( ! $fts_enabled ) {
			return '';
		}

		$search_pattern = $wpdb->esc_like( $db_util->sanitise_boolean_fts_search_term( $this->search_term ) );

		// Support for phone was added in 9.4.
		$maybe_phone_field = '';
		if ( version_compare( get_option( 'woocommerce_db_version' ), '9.4.0', '>=' ) ) {
			$maybe_phone_field = ', phone';
		}

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
		return $wpdb->prepare(
			"LEFT JOIN (
				SELECT DISTINCT order_id
				FROM $address_table
				WHERE MATCH (
					first_name, last_name, company,
					address_1,  address_2, city,  state,
					postcode,   country,   email  $maybe_phone_field
				) AGAINST ( %s IN BOOLEAN MODE )
			) AS " . self::CUSTOMERS_JOIN_ALIAS . ' ON ' . self::CUSTOMERS_JOIN_ALIAS . ".order_id = $orders_table.id",
			$search_pattern
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
	}

	/**
	 * Generates the necessary WHERE clauses for the order search to be performed.
	 *
	 * @throws Exception May be triggered if a table name cannot be determined.
	 *
	 * @return string
	 */
	private function generate_where(): string {
		$where             = array();
		$possible_order_id = (string) absint( $this->search_term );
		$order_table       = $this->query->get_table_name( 'orders' );

		// Support the passing of an order ID as the search term.
		if ( (string) $this->query->get( 's' ) === $possible_order_id ) {
			$where[] = "`$order_table`.id = $possible_order_id";
		}

		foreach ( $this->search_filters as $search_filter ) {
			$search_where = trim( $this->generate_where_for_search_filter( $search_filter ) );
			if ( strlen( $search_where ) > 0 ) {
				$where[] = $search_where;
			}
		}

		$where_statement = implode( ' OR ', $where );

		return ( strlen( $where_statement ) > 0 ) ? " ( $where_statement ) " : '';
	}

	/**
	 * Generates WHERE clause for a given search filter. Right now we only have the products and customers filters that actually use WHERE, but in the future we may add more -- for example, custom order fields, payment tokens and so on. This function makes it easier to add more filters in the future.
	 *
	 * @param string $search_filter Name of the search filter.
	 *
	 * @return string WHERE clause.
	 */
	private function generate_where_for_search_filter( string $search_filter ): string {
		global $wpdb;

		$order_table = $this->query->get_table_name( 'orders' );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table is hardcoded.
		if ( 'customer_email' === $search_filter ) {
			return $wpdb->prepare(
				"`$order_table`.billing_email LIKE %s",
				$wpdb->esc_like( $this->search_term ) . '%'
			);
		}

		if ( 'order_id' === $search_filter && is_numeric( $this->search_term ) ) {
			return $wpdb->prepare(
				"`$order_table`.id = %d",
				absint( $this->search_term )
			);
		}

		if ( 'transaction_id' === $search_filter ) {
			return $wpdb->prepare(
				"`$order_table`.transaction_id LIKE %s",
				'%' . $wpdb->esc_like( $this->search_term ) . '%'
			);
		}
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( 'products' === $search_filter ) {
			return $this->get_where_for_products();
		}

		if ( 'customers' === $search_filter ) {
			return $this->get_where_for_customers();
		}

		/**
		 * Filter to support adding a custom order search filter.
		 * Provide a WHERE clause for a custom search filter via this filter. This should be used with the
		 * `woocommerce_hpos_admin_search_filters` to declare a new custom filter, and optionally also with the
		 * `woocommerce_hpos_generate_join_for_search_filter` filter if a join is also needed.
		 *
		 * Hardcoded filters (products, customers, ID and email) cannot be modified using this filter for consistency.
		 *
		 * @since 8.9.0
		 *
		 * @param string $where WHERE clause to add to the search query.
		 * @param string $search_term The search term.
		 * @param string $search_filter Name of the search filter. Use this to bail early if this is not the filter you are looking for.
		 * @param OrdersTableQuery $query The order query object.
		 */
		return apply_filters(
			'woocommerce_hpos_generate_where_for_search_filter',
			'',
			$this->search_term,
			$search_filter,
			$this->query
		);
	}

	/**
	 * Helper function to generate the WHERE clause for products search. Uses FTS when available.
	 *
	 * @return string|null WHERE clause for products search.
	 */
	private function get_where_for_products() {
		global $wpdb;
		$db_util      = wc_get_container()->get( DatabaseUtil::class );
		$items_table  = $this->query->get_table_name( 'items' );
		$orders_table = $this->query->get_table_name( 'orders' );
		$fts_enabled  = get_option( CustomOrdersTableController::HPOS_FTS_INDEX_OPTION ) === 'yes' && get_option( CustomOrdersTableController::HPOS_FTS_ORDER_ITEM_INDEX_CREATED_OPTION ) === 'yes';

		if ( $fts_enabled ) {
			return self::PRODUCTS_JOIN_ALIAS . '.order_id IS NOT NULL';
		}

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $orders_table and $items_table are hardcoded.
		return $wpdb->prepare(
			"
$orders_table.id in (
	SELECT order_id FROM $items_table search_query_items WHERE
	search_query_items.order_item_name LIKE %s
)
",
			'%' . $wpdb->esc_like( $this->search_term ) . '%'
		);
		// phpcs:enable
	}

	/**
	 * Helper function to generate the WHERE clause for customers search. Uses FTS when available.
	 *
	 * @return string|null WHERE clause for customers search.
	 */
	private function get_where_for_customers() {
		global $wpdb;
		$order_table   = $this->query->get_table_name( 'orders' );
		$address_table = $this->query->get_table_name( 'addresses' );

		$db_util = wc_get_container()->get( DatabaseUtil::class );

		$fts_enabled = get_option( CustomOrdersTableController::HPOS_FTS_INDEX_OPTION ) === 'yes' && get_option( CustomOrdersTableController::HPOS_FTS_ADDRESS_INDEX_CREATED_OPTION ) === 'yes';

		if ( $fts_enabled ) {
			return self::CUSTOMERS_JOIN_ALIAS . '.order_id IS NOT NULL';
		}

		$meta_sub_query = $this->generate_where_for_meta_table();
		return "`$order_table`.id IN ( $meta_sub_query ) ";
	}

	/**
	 * Generates where clause for meta table.
	 *
	 * Note we generate the where clause as a subquery to be used by calling function inside the IN clause. This is against the general wisdom for performance, but in this particular case, a subquery is able to use the order_id-meta_key-meta_value index, which is not possible with a join.
	 *
	 * Since it can use the index, which otherwise would not be possible, it is much faster than both LEFT JOIN or SQL_CALC approach that could have been used.
	 *
	 * @return string The where clause for meta table.
	 */
	private function generate_where_for_meta_table(): string {
		global $wpdb;
		$meta_table  = $this->query->get_table_name( 'meta' );
		$meta_fields = $this->get_meta_fields_to_be_searched();

		if ( '' === $meta_fields ) {
			return '-1';
		}

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $meta_fields is already escaped before imploding, $meta_table is hardcoded.
		return $wpdb->prepare(
			"
SELECT search_query_meta.order_id
FROM $meta_table as search_query_meta
WHERE search_query_meta.meta_key IN ( $meta_fields )
AND search_query_meta.meta_value LIKE %s
GROUP BY search_query_meta.order_id
",
			'%' . $wpdb->esc_like( $this->search_term ) . '%'
		);
		// phpcs:enable
	}

	/**
	 * Returns the order meta field keys to be searched.
	 *
	 * These will be returned as a single string, where the meta keys have been escaped, quoted and are
	 * comma-separated (ie, "'abc', 'foo'" - ready for inclusion in a SQL IN() clause).
	 *
	 * @return string
	 */
	private function get_meta_fields_to_be_searched(): string {
		$meta_fields_to_search = array(
			'_billing_address_index',
			'_shipping_address_index',
		);

		/**
		 * Controls the order meta keys to be included in search queries.
		 *
		 * This hook is used when Custom Order Tables are in use: the corresponding hook when CPT-orders are in use
		 * is 'woocommerce_shop_order_search_fields'.
		 *
		 * @since 7.0.0
		 *
		 * @param array
		 */
		$meta_keys = apply_filters(
			'woocommerce_order_table_search_query_meta_keys',
			$meta_fields_to_search
		);

		$meta_keys = (array) array_map(
			function ( string $meta_key ): string {
				return "'" . esc_sql( wc_clean( $meta_key ) ) . "'";
			},
			$meta_keys
		);

		return implode( ',', $meta_keys );
	}
}
PK     [1]    &  DataStores/Orders/DataSynchronizer.phpnu         <?php
/**
 * DataSynchronizer class file.
 */

namespace Automattic\WooCommerce\Internal\DataStores\Orders;

use Automattic\WooCommerce\Caches\OrderCacheController;
use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostsToOrdersMigrationController;
use Automattic\WooCommerce\Internal\Admin\Orders\EditLock;
use Automattic\WooCommerce\Internal\BatchProcessing\{ BatchProcessingController, BatchProcessorInterface };
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Proxies\LegacyProxy;

defined( 'ABSPATH' ) || exit;

/**
 * This class handles the database structure creation and the data synchronization for the custom orders tables. Its responsibilities are:
 *
 * - Providing entry points for creating and deleting the required database tables.
 * - Synchronizing changes between the custom orders tables and the posts table whenever changes in orders happen.
 */
class DataSynchronizer implements BatchProcessorInterface {

	public const ORDERS_DATA_SYNC_ENABLED_OPTION = 'woocommerce_custom_orders_table_data_sync_enabled';
	public const PLACEHOLDER_ORDER_POST_TYPE     = 'shop_order_placehold';

	public const DELETED_RECORD_META_KEY        = '_deleted_from';
	public const DELETED_FROM_POSTS_META_VALUE  = 'posts_table';
	public const DELETED_FROM_ORDERS_META_VALUE = 'orders_table';

	public const ORDERS_TABLE_CREATED = 'woocommerce_custom_orders_table_created';

	private const ORDERS_SYNC_BATCH_SIZE = 250;

	// Allowed values for $type in get_ids_of_orders_pending_sync method.
	public const ID_TYPE_MISSING_IN_ORDERS_TABLE   = 0;
	public const ID_TYPE_MISSING_IN_POSTS_TABLE    = 1;
	public const ID_TYPE_DIFFERENT_UPDATE_DATE     = 2;
	public const ID_TYPE_DELETED_FROM_ORDERS_TABLE = 3;
	public const ID_TYPE_DELETED_FROM_POSTS_TABLE  = 4;

	public const BACKGROUND_SYNC_MODE_OPTION     = 'woocommerce_custom_orders_table_background_sync_mode';
	public const BACKGROUND_SYNC_INTERVAL_OPTION = 'woocommerce_custom_orders_table_background_sync_interval';
	public const BACKGROUND_SYNC_MODE_INTERVAL   = 'interval';
	public const BACKGROUND_SYNC_MODE_CONTINUOUS = 'continuous';
	public const BACKGROUND_SYNC_MODE_OFF        = 'off';
	public const BACKGROUND_SYNC_EVENT_HOOK      = 'woocommerce_custom_orders_table_background_sync';

	/**
	 * The data store object to use.
	 *
	 * @var OrdersTableDataStore
	 */
	private $data_store;

	/**
	 * The database util object to use.
	 *
	 * @var DatabaseUtil
	 */
	private $database_util;

	/**
	 * The posts to COT migrator to use.
	 *
	 * @var PostsToOrdersMigrationController
	 */
	private $posts_to_cot_migrator;

	/**
	 * Logger object to be used to log events.
	 *
	 * @var \WC_Logger
	 */
	private $error_logger;

	/**
	 * The instance of the LegacyProxy object to use.
	 *
	 * @var LegacyProxy
	 */
	private $legacy_proxy;

	/**
	 * The order cache controller.
	 *
	 * @var OrderCacheController
	 */
	private $order_cache_controller;

	/**
	 * The batch processing controller.
	 *
	 * @var BatchProcessingController
	 */
	private $batch_processing_controller;

	/**
	 * Class constructor.
	 */
	public function __construct() {
		add_filter( 'pre_delete_post', array( $this, 'maybe_prevent_deletion_of_post' ), 10, 2 );
		add_action( 'deleted_post', array( $this, 'handle_deleted_post' ), 10, 2 );
		add_action( 'woocommerce_new_order', array( $this, 'handle_updated_order' ), 100 );
		add_action( 'woocommerce_refund_created', array( $this, 'handle_updated_order' ), 100 );
		add_action( 'woocommerce_update_order', array( $this, 'handle_updated_order' ), 100 );
		add_action( 'woocommerce_update_order_refund', array( $this, 'handle_updated_order' ), 100 );
		add_action( 'wp_scheduled_auto_draft_delete', array( $this, 'delete_auto_draft_orders' ), 9 );
		add_action( 'wp_scheduled_delete', array( $this, 'delete_trashed_orders' ), 9 );
		add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 );
		add_filter( 'added_option', array( $this, 'process_added_option' ), 999, 2 );
		add_filter( 'deleted_option', array( $this, 'process_deleted_option' ), 999 );
		add_action( self::BACKGROUND_SYNC_EVENT_HOOK, array( $this, 'handle_interval_background_sync' ) );
		if ( self::BACKGROUND_SYNC_MODE_CONTINUOUS === $this->get_background_sync_mode() ) {
			add_action( 'shutdown', array( $this, 'handle_continuous_background_sync' ) );
		}

		if ( defined( 'WC_PLUGIN_BASENAME' ) ) {
			add_action(
				'deactivate_' . WC_PLUGIN_BASENAME,
				function () {
					$this->unschedule_background_sync();
				}
			);
		}
	}

	/**
	 * Class initialization, invoked by the DI container.
	 *
	 * @param OrdersTableDataStore             $data_store The data store to use.
	 * @param DatabaseUtil                     $database_util The database util class to use.
	 * @param PostsToOrdersMigrationController $posts_to_cot_migrator The posts to COT migration class to use.
	 * @param LegacyProxy                      $legacy_proxy The legacy proxy instance to use.
	 * @param OrderCacheController             $order_cache_controller The order cache controller instance to use.
	 * @param BatchProcessingController        $batch_processing_controller The batch processing controller to use.
	 * @internal
	 */
	final public function init(
		OrdersTableDataStore $data_store,
		DatabaseUtil $database_util,
		PostsToOrdersMigrationController $posts_to_cot_migrator,
		LegacyProxy $legacy_proxy,
		OrderCacheController $order_cache_controller,
		BatchProcessingController $batch_processing_controller
	) {
		$this->data_store                  = $data_store;
		$this->database_util               = $database_util;
		$this->posts_to_cot_migrator       = $posts_to_cot_migrator;
		$this->legacy_proxy                = $legacy_proxy;
		$this->error_logger                = $legacy_proxy->call_function( 'wc_get_logger' );
		$this->order_cache_controller      = $order_cache_controller;
		$this->batch_processing_controller = $batch_processing_controller;
	}

	/**
	 * Does the custom orders tables exist in the database?
	 *
	 * @return bool True if the custom orders tables exist in the database.
	 */
	public function check_orders_table_exists(): bool {
		$missing_tables = $this->database_util->get_missing_tables( $this->data_store->get_database_schema() );

		if ( count( $missing_tables ) === 0 ) {
			update_option( self::ORDERS_TABLE_CREATED, 'yes' );
			return true;
		} else {
			update_option( self::ORDERS_TABLE_CREATED, 'no' );
			return false;
		}
	}

	/**
	 * Returns the value of the orders table created option. If it's not set, then it checks the orders table and set it accordingly.
	 *
	 * @return bool Whether orders table exists.
	 */
	public function get_table_exists(): bool {
		$table_exists = get_option( self::ORDERS_TABLE_CREATED );
		switch ( $table_exists ) {
			case 'no':
			case 'yes':
				return 'yes' === $table_exists;
			default:
				return $this->check_orders_table_exists();
		}
	}

	/**
	 * Create the custom orders database tables and log an error if that's not possible.
	 *
	 * @return bool True if all the tables were successfully created, false otherwise.
	 */
	public function create_database_tables() {
		$this->database_util->dbdelta( $this->data_store->get_database_schema() );
		$success = $this->check_orders_table_exists();
		if ( ! $success ) {
			$missing_tables = $this->database_util->get_missing_tables( $this->data_store->get_database_schema() );
			$missing_tables = implode( ', ', $missing_tables );
			$this->error_logger->error( "HPOS tables are missing in the database and couldn't be created. The missing tables are: $missing_tables" );
		}
		return $success;
	}

	/**
	 * Delete the custom orders database tables.
	 */
	public function delete_database_tables() {
		$table_names = $this->data_store->get_all_table_names();

		foreach ( $table_names as $table_name ) {
			$this->database_util->drop_database_table( $table_name );
		}
		if ( is_callable( array( $this->data_store, 'clear_all_cached_data' ) ) ) {
			$this->data_store->clear_all_cached_data();
		}
		delete_option( self::ORDERS_TABLE_CREATED );
	}

	/**
	 * Is the real-time data sync between old and new tables currently enabled?
	 *
	 * @return bool
	 */
	public function data_sync_is_enabled(): bool {
		return 'yes' === get_option( self::ORDERS_DATA_SYNC_ENABLED_OPTION );
	}

	/**
	 * Get the current background data sync mode.
	 *
	 * @return string
	 */
	public function get_background_sync_mode(): string {
		$default = $this->data_sync_is_enabled() ? self::BACKGROUND_SYNC_MODE_INTERVAL : self::BACKGROUND_SYNC_MODE_OFF;

		return get_option( self::BACKGROUND_SYNC_MODE_OPTION, $default );
	}

	/**
	 * Is the background data sync between old and new tables currently enabled?
	 *
	 * @return bool
	 */
	public function background_sync_is_enabled(): bool {
		$enabled_modes = array( self::BACKGROUND_SYNC_MODE_INTERVAL, self::BACKGROUND_SYNC_MODE_CONTINUOUS );
		$mode          = $this->get_background_sync_mode();

		return in_array( $mode, $enabled_modes, true );
	}

	/**
	 * Process an option change for specific keys.
	 *
	 * @param string $option_key The option key.
	 * @param string $old_value  The previous value.
	 * @param string $new_value  The new value.
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function process_updated_option( $option_key, $old_value, $new_value ) {
		$sync_option_keys = array( self::ORDERS_DATA_SYNC_ENABLED_OPTION, self::BACKGROUND_SYNC_MODE_OPTION );
		if ( ! in_array( $option_key, $sync_option_keys, true ) || $new_value === $old_value ) {
			return;
		}

		if ( self::BACKGROUND_SYNC_MODE_OPTION === $option_key ) {
			$mode = $new_value;
		} else {
			$mode = $this->get_background_sync_mode();
		}
		switch ( $mode ) {
			case self::BACKGROUND_SYNC_MODE_INTERVAL:
				$this->schedule_background_sync();
				break;

			case self::BACKGROUND_SYNC_MODE_CONTINUOUS:
			case self::BACKGROUND_SYNC_MODE_OFF:
			default:
				$this->unschedule_background_sync();
				break;
		}

		if ( self::ORDERS_DATA_SYNC_ENABLED_OPTION === $option_key ) {
			if ( ! $this->check_orders_table_exists() ) {
				$this->create_database_tables();
			}

			if ( $this->data_sync_is_enabled() ) {
				wc_get_container()->get( LegacyDataCleanup::class )->toggle_flag( false );
				$this->batch_processing_controller->enqueue_processor( self::class );
			} else {
				$this->batch_processing_controller->remove_processor( self::class );
			}
		}
	}

	/**
	 * Process an option change when the key didn't exist before.
	 *
	 * @param string $option_key The option key.
	 * @param string $value      The new value.
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function process_added_option( $option_key, $value ) {
		$this->process_updated_option( $option_key, false, $value );
	}

	/**
	 * Process an option deletion for specific keys.
	 *
	 * @param string $option_key The option key.
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function process_deleted_option( $option_key ) {
		if ( self::BACKGROUND_SYNC_MODE_OPTION !== $option_key ) {
			return;
		}

		$this->unschedule_background_sync();
		$this->batch_processing_controller->remove_processor( self::class );
	}

	/**
	 * Get the time interval, in seconds, between background syncs.
	 *
	 * @return int
	 */
	public function get_background_sync_interval(): int {
		$interval = filter_var(
			get_option( self::BACKGROUND_SYNC_INTERVAL_OPTION, HOUR_IN_SECONDS ),
			FILTER_VALIDATE_INT,
			array(
				'options' => array(
					'default' => HOUR_IN_SECONDS,
				),
			)
		);

		return $interval;
	}

	/**
	 * Keys that can be ignored during synchronization or verification.
	 *
	 * @since 8.6.0
	 *
	 * @return string[]
	 */
	public function get_ignored_order_props() {
		/**
		 * Allows modifying the list of order properties that are ignored during HPOS synchronization or verification.
		 *
		 * @param string[] List of order properties or meta keys.
		 * @since 8.6.0
		 */
		$ignored_props = apply_filters( 'woocommerce_hpos_sync_ignored_order_props', array() );
		$ignored_props = array_filter( array_map( 'trim', array_filter( $ignored_props, 'is_string' ) ) );

		return array_merge(
			$ignored_props,
			array(
				'_paid_date', // This has been deprecated and replaced by '_date_paid' in the CPT datastore.
				'_completed_date', // This has been deprecated and replaced by '_date_completed' in the CPT datastore.
				EditLock::META_KEY_NAME,
			)
		);
	}

	/**
	 * Schedule an event to run background sync when the mode is set to interval.
	 *
	 * @return void
	 */
	private function schedule_background_sync() {
		$interval = $this->get_background_sync_interval();

		// Calling Action Scheduler directly because WC_Action_Queue doesn't support the unique parameter yet.
		as_schedule_recurring_action(
			time() + $interval,
			$interval,
			self::BACKGROUND_SYNC_EVENT_HOOK,
			array(),
			'',
			true
		);
	}

	/**
	 * Remove any pending background sync events.
	 *
	 * @return void
	 */
	private function unschedule_background_sync() {
		WC()->queue()->cancel_all( self::BACKGROUND_SYNC_EVENT_HOOK );
	}

	/**
	 * Callback to check for pending syncs and enqueue the background data sync processor when in interval mode.
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function handle_interval_background_sync() {
		if ( self::BACKGROUND_SYNC_MODE_INTERVAL !== $this->get_background_sync_mode() ) {
			$this->unschedule_background_sync();
			return;
		}

		$pending_count = $this->get_total_pending_count();
		if ( $pending_count > 0 ) {
			$this->batch_processing_controller->enqueue_processor( self::class );
		}
	}

	/**
	 * Callback to keep the background data sync processor enqueued when in continuous mode.
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function handle_continuous_background_sync() {
		if ( self::BACKGROUND_SYNC_MODE_CONTINUOUS !== $this->get_background_sync_mode() ) {
			$this->batch_processing_controller->remove_processor( self::class );
			return;
		}

		// This method already checks if a processor is enqueued before adding it to avoid duplication.
		$this->batch_processing_controller->enqueue_processor( self::class );
	}

	/**
	 * Get the current sync process status.
	 * The information is meaningful only if pending_data_sync_is_in_progress return true.
	 *
	 * @return array
	 *
	 * @deprecated 9.0.0
	 */
	public function get_sync_status() {
		wc_deprecated_function(
			__METHOD__,
			'9.0.0',
			'get_current_orders_pending_sync_count()'
		);

		return array(
			'initial_pending_count' => (int) 0,
			'current_pending_count' => $this->get_total_pending_count(),
		);
	}

	/**
	 * Get the total number of orders pending synchronization.
	 *
	 * @return int
	 */
	public function get_current_orders_pending_sync_count_cached(): int {
		return $this->get_current_orders_pending_sync_count( true );
	}

	/**
	 * Calculate how many orders need to be synchronized currently.
	 * A database query is performed to get how many orders match one of the following:
	 *
	 * - Existing in the authoritative table but not in the backup table.
	 * - Existing in both tables, but they have a different update date.
	 *
	 * @param bool $use_cache Whether to use the cached value instead of fetching from database.
	 */
	public function get_current_orders_pending_sync_count( $use_cache = false ): int {
		if ( $use_cache ) {
			$pending_count = wp_cache_get( 'woocommerce_hpos_pending_sync_count', 'counts' );
			if ( false !== $pending_count ) {
				return (int) $pending_count;
			}
		}

		$pending_count = $this->query_orders_pending_sync_count();

		wp_cache_set( 'woocommerce_hpos_pending_sync_count', $pending_count, 'counts' );
		return $pending_count;
	}

	/**
	 * Check if there are orders pending synchronization.
	 *
	 * @param bool $use_cache Whether to use the cached value instead of fetching from database.
	 * @return bool True if there are orders pending synchronization, false otherwise.
	 */
	public function has_orders_pending_sync( $use_cache = false ) {
		if ( $use_cache ) {
			$has_pending_sync = wp_cache_get( 'woocommerce_hpos_has_orders_pending_sync', 'counts' );
			if ( false !== $has_pending_sync ) {
				return (bool) $has_pending_sync;
			}
			$pending_count = wp_cache_get( 'woocommerce_hpos_pending_sync_count', 'counts' );
			if ( false !== $pending_count ) {
				return (int) $pending_count > 0;
			}
		}

		$has_pending_sync = $this->query_orders_pending_sync_count( false ) > 0;

		wp_cache_set( 'woocommerce_hpos_has_orders_pending_sync', $has_pending_sync, 'counts' );

		return $has_pending_sync;
	}

	/**
	 * Query the number of orders pending synchronization.
	 *
	 * @param bool $full_count Whether to return the full count or a single row.
	 * @return int The number of orders pending synchronization.
	 */
	private function query_orders_pending_sync_count( $full_count = true ) {
		global $wpdb;

		$order_post_types = wc_get_order_types( 'cot-migration' );

		$order_post_type_placeholder = implode( ', ', array_fill( 0, count( $order_post_types ), '%s' ) );

		$orders_table = $this->data_store::get_orders_table_name();

		$count_clause = $full_count ? 'COUNT(1)' : '1';

		$limit_clause = $full_count ? '' : 'LIMIT 1';

		if ( empty( $order_post_types ) ) {
			$this->error_logger->debug(
				sprintf(
					/* translators: 1: method name. */
					esc_html__( '%1$s was called but no order types were registered: it may have been called too early.', 'woocommerce' ),
					__METHOD__
				)
			);

			return 0;
		}

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.NotPrepared --
		// -- $order_post_type_placeholder, $orders_table, self::PLACEHOLDER_ORDER_POST_TYPE are all safe to use in queries.
		if ( ! $this->get_table_exists() ) {
			$count = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT $count_clause FROM $wpdb->posts where post_type in ( $order_post_type_placeholder )",
					$order_post_types
				)
			);
			return $count;
		}

		if ( $this->custom_orders_table_is_authoritative() ) {
			$missing_orders_count_sql = $wpdb->prepare(
				"
SELECT $count_clause FROM $wpdb->posts posts
RIGHT JOIN $orders_table orders ON posts.ID=orders.id
WHERE (posts.post_type IS NULL OR posts.post_type = '" . self::PLACEHOLDER_ORDER_POST_TYPE . "')
 AND orders.status NOT IN ( 'auto-draft' )
 AND orders.type IN ($order_post_type_placeholder)
$limit_clause",
				$order_post_types
			);
			$operator                 = '>';
		} else {
			$missing_orders_count_sql = $wpdb->prepare(
				"
SELECT $count_clause FROM $wpdb->posts posts
LEFT JOIN $orders_table orders ON posts.ID=orders.id
WHERE
  posts.post_type in ($order_post_type_placeholder)
  AND posts.post_status != 'auto-draft'
  AND orders.id IS NULL
$limit_clause",
				$order_post_types
			);

			$operator = '<';
		}

		$sql = $wpdb->prepare(
			"
SELECT(
	($missing_orders_count_sql)
	+
	(SELECT COUNT(1) FROM (
		SELECT orders.id FROM $orders_table orders
		JOIN $wpdb->posts posts on posts.ID = orders.id
		WHERE
		  posts.post_type IN ($order_post_type_placeholder)
		  AND orders.date_updated_gmt $operator posts.post_modified_gmt
	) x)
) count",
			$order_post_types
		);
		// phpcs:enable

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		$pending_count = (int) $wpdb->get_var( $sql );

		$deleted_from_table = $this->get_current_deletion_record_meta_value();

		$deleted_count  = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT $count_clause FROM {$wpdb->prefix}wc_orders_meta WHERE meta_key=%s AND meta_value=%s",
				array( self::DELETED_RECORD_META_KEY, $deleted_from_table )
			)
		);
		$pending_count += $deleted_count;

		return $pending_count;
	}

	/**
	 * Get the meta value for order deletion records based on which table is currently authoritative.
	 *
	 * @return string self::DELETED_FROM_ORDERS_META_VALUE if the orders table is authoritative, self::DELETED_FROM_POSTS_META_VALUE otherwise.
	 */
	private function get_current_deletion_record_meta_value() {
		return $this->custom_orders_table_is_authoritative() ?
				self::DELETED_FROM_ORDERS_META_VALUE :
				self::DELETED_FROM_POSTS_META_VALUE;
	}

	/**
	 * Is the custom orders table the authoritative data source for orders currently?
	 *
	 * @return bool Whether the custom orders table the authoritative data source for orders currently.
	 */
	public function custom_orders_table_is_authoritative(): bool {
		return wc_string_to_bool( get_option( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION ) );
	}

	/**
	 * Get a list of ids of orders than are out of sync.
	 *
	 * Valid values for $type are:
	 *
	 * ID_TYPE_MISSING_IN_ORDERS_TABLE: orders that exist in posts table but not in orders table.
	 * ID_TYPE_MISSING_IN_POSTS_TABLE: orders that exist in orders table but not in posts table (the corresponding post entries are placeholders).
	 * ID_TYPE_DIFFERENT_UPDATE_DATE: orders that exist in both tables but have different last update dates.
	 * ID_TYPE_DELETED_FROM_ORDERS_TABLE: orders deleted from the orders table but not yet from the posts table.
	 * ID_TYPE_DELETED_FROM_POSTS_TABLE: orders deleted from the posts table but not yet from the orders table.
	 *
	 * @param int $type One of ID_TYPE_MISSING_IN_ORDERS_TABLE, ID_TYPE_MISSING_IN_POSTS_TABLE, ID_TYPE_DIFFERENT_UPDATE_DATE.
	 * @param int $limit Maximum number of ids to return.
	 * @return array An array of order ids.
	 * @throws \Exception Invalid parameter.
	 */
	public function get_ids_of_orders_pending_sync( int $type, int $limit ) {
		global $wpdb;

		if ( $limit < 1 ) {
			throw new \Exception( '$limit must be at least 1' );
		}

		$orders_table                 = $this->data_store::get_orders_table_name();
		$order_post_types             = wc_get_order_types( 'cot-migration' );
		$order_post_type_placeholders = implode( ', ', array_fill( 0, count( $order_post_types ), '%s' ) );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.NotPrepared
		switch ( $type ) {
			case self::ID_TYPE_MISSING_IN_ORDERS_TABLE:
				$sql = $wpdb->prepare(
					"
SELECT posts.ID FROM $wpdb->posts posts
LEFT JOIN $orders_table orders ON posts.ID = orders.id
WHERE
  posts.post_type IN ($order_post_type_placeholders)
  AND posts.post_status != 'auto-draft'
  AND orders.id IS NULL
ORDER BY posts.ID ASC",
					$order_post_types
				);
				break;
			case self::ID_TYPE_MISSING_IN_POSTS_TABLE:
				$sql = $wpdb->prepare(
					"
SELECT orders.id FROM $wpdb->posts posts
RIGHT JOIN $orders_table orders ON posts.ID=orders.id
WHERE (posts.post_type IS NULL OR posts.post_type = '" . self::PLACEHOLDER_ORDER_POST_TYPE . "')
AND orders.status NOT IN ( 'auto-draft' )
AND orders.type IN ($order_post_type_placeholders)
ORDER BY posts.ID ASC",
					$order_post_types
				);
				break;
			case self::ID_TYPE_DIFFERENT_UPDATE_DATE:
				$operator = $this->custom_orders_table_is_authoritative() ? '>' : '<';

				$sql = $wpdb->prepare(
					"
SELECT orders.id FROM $orders_table orders
JOIN $wpdb->posts posts on posts.ID = orders.id
WHERE
  posts.post_type IN ($order_post_type_placeholders)
  AND orders.date_updated_gmt $operator posts.post_modified_gmt
ORDER BY orders.id ASC
",
					$order_post_types
				);
				break;
			case self::ID_TYPE_DELETED_FROM_ORDERS_TABLE:
				return $this->get_deleted_order_ids( true, $limit );
			case self::ID_TYPE_DELETED_FROM_POSTS_TABLE:
				return $this->get_deleted_order_ids( false, $limit );
			default:
				throw new \Exception( 'Invalid $type, must be one of the ID_TYPE_... constants.' );
		}
		// phpcs:enable

		// phpcs:ignore WordPress.DB
		return array_map( 'intval', $wpdb->get_col( $sql . " LIMIT $limit" ) );
	}

	/**
	 * Get the ids of the orders that are marked as deleted in the orders meta table.
	 *
	 * @param bool $deleted_from_orders_table True to get the ids of the orders deleted from the orders table, false o get the ids of the orders deleted from the posts table.
	 * @param int  $limit The maximum count of orders to return.
	 * @return array An array of order ids.
	 */
	private function get_deleted_order_ids( bool $deleted_from_orders_table, int $limit ) {
		global $wpdb;

		$deleted_from_table = $this->get_current_deletion_record_meta_value();

		$order_ids = $wpdb->get_col(
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$wpdb->prepare(
				"SELECT DISTINCT(order_id) FROM {$wpdb->prefix}wc_orders_meta WHERE meta_key=%s AND meta_value=%s LIMIT {$limit}",
				self::DELETED_RECORD_META_KEY,
				$deleted_from_table
			)
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		);

		return array_map( 'absint', $order_ids );
	}

	/**
	 * Cleanup all the synchronization status information,
	 * because the process has been disabled by the user via settings,
	 * or because there's nothing left to synchronize.
	 */
	public function cleanup_synchronization_state() {
		delete_option( 'woocommerce_initial_orders_pending_sync_count' );
	}

	/**
	 * Process data for current batch.
	 *
	 * @param array $batch Batch details.
	 */
	public function process_batch( array $batch ): void {
		if ( empty( $batch ) ) {
			return;
		}

		$batch = array_map( 'absint', $batch );

		$this->order_cache_controller->temporarily_disable_orders_cache_usage();

		$custom_orders_table_is_authoritative = $this->custom_orders_table_is_authoritative();
		$deleted_order_ids                    = $this->process_deleted_orders( $batch, $custom_orders_table_is_authoritative );
		$batch                                = array_diff( $batch, $deleted_order_ids );

		if ( ! empty( $batch ) ) {
			if ( $custom_orders_table_is_authoritative ) {
				foreach ( $batch as $id ) {
					$order = wc_get_order( $id );
					if ( ! $order ) {
						$this->error_logger->error( "Order $id not found during batch process, skipping." );
						continue;
					}
					$data_store = $order->get_data_store();
					$data_store->backfill_post_record( $order );
				}
			} else {
				$this->posts_to_cot_migrator->migrate_orders( $batch );
			}
		}

		if ( 0 === $this->get_total_pending_count() ) {
			$this->cleanup_synchronization_state();
			$this->order_cache_controller->maybe_restore_orders_cache_usage();
		}
	}

	/**
	 * Take a batch of order ids pending synchronization and process those that were deleted, ignoring the others
	 * (which will be orders that were created or modified) and returning the ids of the orders actually processed.
	 *
	 * @param array $batch Array of ids of order pending synchronization.
	 * @param bool  $custom_orders_table_is_authoritative True if the custom orders table is currently authoritative.
	 * @return array Order ids that have been actually processed.
	 */
	private function process_deleted_orders( array $batch, bool $custom_orders_table_is_authoritative ): array {
		global $wpdb;

		$deleted_from_table_name = $this->get_current_deletion_record_meta_value();

		$data_store_for_deletion =
			$custom_orders_table_is_authoritative ?
			new \WC_Order_Data_Store_CPT() :
			wc_get_container()->get( OrdersTableDataStore::class );

		$order_ids_as_sql_list = '(' . implode( ',', $batch ) . ')';

		$deleted_order_ids  = array();
		$meta_ids_to_delete = array();

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$deletion_data = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, order_id FROM {$wpdb->prefix}wc_orders_meta WHERE meta_key=%s AND meta_value=%s AND order_id IN $order_ids_as_sql_list ORDER BY order_id DESC",
				self::DELETED_RECORD_META_KEY,
				$deleted_from_table_name
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( empty( $deletion_data ) ) {
			return array();
		}

		foreach ( $deletion_data as $item ) {
			$meta_id  = $item['id'];
			$order_id = $item['order_id'];

			if ( isset( $deleted_order_ids[ $order_id ] ) ) {
				$meta_ids_to_delete[] = $meta_id;
				continue;
			}

			if ( ! $data_store_for_deletion->order_exists( $order_id ) ) {
				$this->error_logger->warning( "Order {$order_id} doesn't exist in the backup table, thus it can't be deleted" );
				$deleted_order_ids[]  = $order_id;
				$meta_ids_to_delete[] = $meta_id;
				continue;
			}

			try {
				$order = new \WC_Order();
				$order->set_id( $order_id );
				$data_store_for_deletion->read( $order );

				$data_store_for_deletion->delete(
					$order,
					array(
						'force_delete'     => true,
						'suppress_filters' => true,
					)
				);
			} catch ( \Exception $ex ) {
				$this->error_logger->error( "Couldn't delete order {$order_id} from the backup table: {$ex->getMessage()}" );
				continue;
			}

			$deleted_order_ids[]  = $order_id;
			$meta_ids_to_delete[] = $meta_id;
		}

		if ( ! empty( $meta_ids_to_delete ) ) {
			$order_id_rows_as_sql_list = '(' . implode( ',', $meta_ids_to_delete ) . ')';
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$wpdb->query( "DELETE FROM {$wpdb->prefix}wc_orders_meta WHERE id IN {$order_id_rows_as_sql_list}" );
		}

		return $deleted_order_ids;
	}

	/**
	 * Get total number of pending records that require update.
	 *
	 * @return int Number of pending records.
	 */
	public function get_total_pending_count(): int {
		return $this->get_current_orders_pending_sync_count();
	}

	/**
	 * Returns the batch with records that needs to be processed for a given size.
	 *
	 * @param int $size Size of the batch.
	 *
	 * @return array Batch of records.
	 */
	public function get_next_batch_to_process( int $size ): array {
		$orders_table_is_authoritative = $this->custom_orders_table_is_authoritative();

		$order_ids = $this->get_ids_of_orders_pending_sync(
			$orders_table_is_authoritative ? self::ID_TYPE_MISSING_IN_POSTS_TABLE : self::ID_TYPE_MISSING_IN_ORDERS_TABLE,
			$size
		);
		if ( count( $order_ids ) >= $size ) {
			return $order_ids;
		}

		$updated_order_ids = $this->get_ids_of_orders_pending_sync( self::ID_TYPE_DIFFERENT_UPDATE_DATE, $size - count( $order_ids ) );
		$order_ids         = array_merge( $order_ids, $updated_order_ids );
		if ( count( $order_ids ) >= $size ) {
			return $order_ids;
		}

		$deleted_order_ids = $this->get_ids_of_orders_pending_sync(
			$orders_table_is_authoritative ? self::ID_TYPE_DELETED_FROM_ORDERS_TABLE : self::ID_TYPE_DELETED_FROM_POSTS_TABLE,
			$size - count( $order_ids )
		);
		$order_ids         = array_merge( $order_ids, $deleted_order_ids );

		return array_map( 'absint', $order_ids );
	}

	/**
	 * Default batch size to use.
	 *
	 * @return int Default batch size.
	 */
	public function get_default_batch_size(): int {
		$batch_size = self::ORDERS_SYNC_BATCH_SIZE;

		if ( $this->custom_orders_table_is_authoritative() ) {
			// Back-filling is slower than migration.
			$batch_size = absint( self::ORDERS_SYNC_BATCH_SIZE / 10 ) + 1;
		}
		/**
		 * Filter to customize the count of orders that will be synchronized in each step of the custom orders table to/from posts table synchronization process.
		 *
		 * @since 6.6.0
		 *
		 * @param int Default value for the count.
		 */
		return apply_filters( 'woocommerce_orders_cot_and_posts_sync_step_size', $batch_size );
	}

	/**
	 * A user friendly name for this process.
	 *
	 * @return string Name of the process.
	 */
	public function get_name(): string {
		return 'Order synchronizer';
	}

	/**
	 * A user friendly description for this process.
	 *
	 * @return string Description.
	 */
	public function get_description(): string {
		return 'Synchronizes orders between posts and custom order tables.';
	}

	/**
	 * Prevents deletion of order backup posts (regardless of sync setting) when HPOS is authoritative and the order
	 * still exists in HPOS.
	 * This should help with edge cases where wp_delete_post() would delete the HPOS record too or backfill would sync
	 * incorrect data from an order with no metadata from the posts table.
	 *
	 * @since 8.8.0
	 *
	 * @param WP_Post|false|null $delete Whether to go forward with deletion.
	 * @param WP_Post            $post   Post object.
	 * @return WP_Post|false|null
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function maybe_prevent_deletion_of_post( $delete, $post ) {
		if ( self::PLACEHOLDER_ORDER_POST_TYPE !== $post->post_type && $this->custom_orders_table_is_authoritative() && $this->data_store->order_exists( $post->ID ) ) {
			$delete = false;
		}

		return $delete;
	}

	/**
	 * Handle the 'deleted_post' action.
	 *
	 * When posts is authoritative and sync is enabled, deleting a post also deletes COT data.
	 *
	 * @param int     $postid The post id.
	 * @param WP_Post $post The deleted post.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function handle_deleted_post( $postid, $post ): void {
		global $wpdb;

		$order_post_types = wc_get_order_types( 'cot-migration' );
		if ( ! in_array( $post->post_type, $order_post_types, true ) ) {
			return;
		}

		if ( ! $this->get_table_exists() ) {
			return;
		}

		if ( $this->data_sync_is_enabled() ) {
			$this->data_store->delete_order_data_from_custom_order_tables( $postid );
		} elseif ( $this->custom_orders_table_is_authoritative() ) {
			return;
		}

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.SlowDBQuery
		if ( $wpdb->get_var(
			$wpdb->prepare(
				"SELECT EXISTS (SELECT id FROM {$this->data_store::get_orders_table_name()} WHERE ID=%d)
						AND NOT EXISTS (SELECT order_id FROM {$this->data_store::get_meta_table_name()} WHERE order_id=%d AND meta_key=%s AND meta_value=%s)",
				$postid,
				$postid,
				self::DELETED_RECORD_META_KEY,
				self::DELETED_FROM_POSTS_META_VALUE
			)
		)
		) {
			$wpdb->insert(
				$this->data_store::get_meta_table_name(),
				array(
					'order_id'   => $postid,
					'meta_key'   => self::DELETED_RECORD_META_KEY,
					'meta_value' => self::DELETED_FROM_POSTS_META_VALUE,
				)
			);
		}
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.SlowDBQuery
	}

	/**
	 * Handle the 'woocommerce_update_order' action.
	 *
	 * When posts is authoritative and sync is enabled, updating a post triggers a corresponding change in the COT table.
	 *
	 * @param int $order_id The order id.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function handle_updated_order( $order_id ): void {
		if ( ! $this->custom_orders_table_is_authoritative() && $this->data_sync_is_enabled() ) {
			$this->posts_to_cot_migrator->migrate_orders( array( $order_id ) );
		}
	}

	/**
	 * Handles deletion of auto-draft orders in sync with WP's own auto-draft deletion.
	 *
	 * @since 7.7.0
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function delete_auto_draft_orders() {
		if ( ! $this->custom_orders_table_is_authoritative() ) {
			return;
		}

		// Fetch auto-draft orders older than 1 week.
		$to_delete = wc_get_orders(
			array(
				'date_query' => array(
					array(
						'column' => 'date_created',
						'before' => '-1 week',
					),
				),
				'orderby'    => 'date',
				'order'      => 'ASC',
				'status'     => 'auto-draft',
			)
		);

		foreach ( $to_delete as $order ) {
			$order->delete( true );
		}

		/**
		 * Fires after schedueld deletion of auto-draft orders has been completed.
		 *
		 * @since 7.7.0
		 */
		do_action( 'woocommerce_scheduled_auto_draft_delete' );
	}

	/**
	 * Handles deletion of trashed orders after `EMPTY_TRASH_DAYS` as defined by WordPress.
	 *
	 * @since 8.5.0
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function delete_trashed_orders() {
		if ( ! $this->custom_orders_table_is_authoritative() ) {
			return;
		}

		$delete_timestamp = $this->legacy_proxy->call_function( 'time' ) - ( DAY_IN_SECONDS * EMPTY_TRASH_DAYS );
		$args             = array(
			'status'        => 'trash',
			'limit'         => self::ORDERS_SYNC_BATCH_SIZE,
			'date_modified' => '<' . $delete_timestamp,
		);

		$orders = wc_get_orders( $args );
		if ( ! $orders || ! is_array( $orders ) ) {
			return;
		}

		foreach ( $orders as $order ) {
			if ( $order->get_status() !== 'trash' ) {
				continue;
			}
			if ( $order->get_date_modified()->getTimestamp() >= $delete_timestamp ) {
				continue;
			}
			$order->delete( true );
		}
	}
}
PK     [1]I(S  S  '  DataStores/Orders/LegacyDataHandler.phpnu         <?php
/**
 * LegacyDataHandler class file.
 */

namespace Automattic\WooCommerce\Internal\DataStores\Orders;

use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostsToOrdersMigrationController;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use WC_Abstract_Order;

defined( 'ABSPATH' ) || exit;

/**
 * This class provides functionality to clean up post data from the posts table when HPOS is authoritative.
 */
class LegacyDataHandler {

	/**
	 * Instance of the HPOS datastore.
	 *
	 * @var OrdersTableDataStore
	 */
	private OrdersTableDataStore $data_store;

	/**
	 * Instance of the DataSynchronizer class.
	 *
	 * @var DataSynchronizer
	 */
	private DataSynchronizer $data_synchronizer;

	/**
	 * Instance of the PostsToOrdersMigrationController.
	 *
	 * @var PostsToOrdersMigrationController
	 */
	private PostsToOrdersMigrationController $posts_to_cot_migrator;

	/**
	 * Class initialization, invoked by the DI container.
	 *
	 * @param OrdersTableDataStore             $data_store            HPOS datastore instance to use.
	 * @param DataSynchronizer                 $data_synchronizer     DataSynchronizer instance to use.
	 * @param PostsToOrdersMigrationController $posts_to_cot_migrator Posts to HPOS migration controller instance to use.
	 *
	 * @internal
	 */
	final public function init( OrdersTableDataStore $data_store, DataSynchronizer $data_synchronizer, PostsToOrdersMigrationController $posts_to_cot_migrator ) {
		$this->data_store            = $data_store;
		$this->data_synchronizer     = $data_synchronizer;
		$this->posts_to_cot_migrator = $posts_to_cot_migrator;
	}

	/**
	 * Returns the total number of orders for which legacy post data can be removed.
	 *
	 * @param array $order_ids If provided, total is computed only among IDs in this array, which can be either individual IDs or ranges like "100-200".
	 * @return int Number of orders.
	 */
	public function count_orders_for_cleanup( $order_ids = array() ): int {
		global $wpdb;
		return (int) $wpdb->get_var( $this->build_sql_query_for_cleanup( $order_ids, 'count' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepared in build_sql_query_for_cleanup().
	}

	/**
	 * Returns a set of orders for which legacy post data can be removed.
	 *
	 * @param array $order_ids If provided, result is a subset of the order IDs in this array, which can contain either individual order IDs or ranges like "100-200".
	 * @param int   $limit     Limit the number of results.
	 * @return array[int] Order IDs.
	 */
	public function get_orders_for_cleanup( $order_ids = array(), int $limit = 0 ): array {
		global $wpdb;

		return array_map(
			'absint',
			$wpdb->get_col( $this->build_sql_query_for_cleanup( $order_ids, 'ids', $limit ) ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepared in build_sql_query_for_cleanup().
		);
	}

	/**
	 * Builds a SQL statement to either count or obtain IDs for orders in need of cleanup.
	 *
	 * @param array   $order_ids If provided, the query will only include orders in this set of order IDs or ID ranges (like "10-100").
	 * @param string  $result    Use 'count' to build a query that returns a count. Otherwise, the query will return order IDs.
	 * @param integer $limit     If provided, the query will be limited to this number of results. Does not apply when $result is 'count'.
	 * @return string SQL query.
	 */
	private function build_sql_query_for_cleanup( array $order_ids = array(), string $result = 'ids', int $limit = 0 ): string {
		global $wpdb;

		$hpos_orders_table = $this->data_store->get_orders_table_name();

		$sql_where = '';

		if ( $order_ids ) {
			// Expand ranges in $order_ids as needed to build the WHERE clause.
			$where_ids    = array();
			$where_ranges = array();

			foreach ( $order_ids as &$arg ) {
				if ( is_numeric( $arg ) ) {
					$where_ids[] = absint( $arg );
				} elseif ( preg_match( '/^(\d+)-(\d+)$/', $arg, $matches ) ) {
					$where_ranges[] = $wpdb->prepare( "({$wpdb->posts}.ID >= %d AND {$wpdb->posts}.ID <= %d)", absint( $matches[1] ), absint( $matches[2] ) );
				}
			}

			if ( $where_ids ) {
				$where_ranges[] = "{$wpdb->posts}.ID IN (" . implode( ',', $where_ids ) . ')';
			}

			if ( ! $where_ranges ) {
				$sql_where .= '1=0';
			} else {
				$sql_where .= '(' . implode( ' OR ', $where_ranges ) . ')';
			}
		}

		$sql_where .= $sql_where ? ' AND ' : '';

		// Post type handling.
		$sql_where .= '(';
		$sql_where .= "{$wpdb->posts}.post_type IN ('" . implode( "', '", esc_sql( wc_get_order_types( 'cot-migration' ) ) ) . "')";
		$sql_where .= $wpdb->prepare(
			" OR (post_type = %s AND ( {$hpos_orders_table}.id IS NULL OR EXISTS(SELECT 1 FROM {$wpdb->postmeta} WHERE post_id = {$wpdb->posts}.ID)) )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$this->data_synchronizer::PLACEHOLDER_ORDER_POST_TYPE
		);
		$sql_where .= ')';

		// Exclude 'auto-draft' since those go away on their own.
		$sql_where .= $wpdb->prepare( " AND {$wpdb->posts}.post_status != %s", 'auto-draft' );

		if ( 'count' === $result ) {
			$sql_fields = 'COUNT(*)';
			$sql_limit  = '';
		} else {
			$sql_fields = "{$wpdb->posts}.ID";
			$sql_limit  = $limit > 0 ? $wpdb->prepare( 'LIMIT %d', $limit ) : '';
		}

		$sql = "SELECT {$sql_fields} FROM {$wpdb->posts} LEFT JOIN {$hpos_orders_table} ON {$wpdb->posts}.ID = {$hpos_orders_table}.id WHERE {$sql_where} {$sql_limit}";
		return $sql;
	}

	/**
	 * Performs a cleanup of post data for a given order and also converts the post to the placeholder type in the backup table.
	 *
	 * @param int  $order_id    Order ID.
	 * @param bool $skip_checks Whether to skip the checks that happen before the order is cleaned up.
	 * @return void
	 * @throws \Exception When an error occurs.
	 */
	public function cleanup_post_data( int $order_id, bool $skip_checks = false ): void {
		global $wpdb;

		$post_type = get_post_type( $order_id );
		if ( ! in_array( $post_type, array_merge( wc_get_order_types( 'cot-migration' ), array( $this->data_synchronizer::PLACEHOLDER_ORDER_POST_TYPE ) ), true ) ) {
			// translators: %d is an order ID.
			throw new \Exception( esc_html( sprintf( __( '%d is not of a valid order type.', 'woocommerce' ), $order_id ) ) );
		}

		$order_exists = $this->data_store->order_exists( $order_id );
		if ( $order_exists ) {
			$order = wc_get_order( $order_id );
			if ( ! $order ) {
				// translators: %d is an order ID.
				throw new \Exception( esc_html( sprintf( __( '%d is not a valid order ID.', 'woocommerce' ), $order_id ) ) );
			}

			if ( ! $skip_checks && ! $this->is_order_newer_than_post( $order ) ) {
				// translators: %1 is an order ID.
				throw new \Exception( esc_html( sprintf( __( 'Data in posts table appears to be more recent than in HPOS tables. Compare order data with `wp wc hpos diff %1$d` and use `wp wc hpos backfill %1$d --from=posts --to=hpos` to fix.', 'woocommerce' ), $order_id ) ) );
			}
		}

		$wpdb->delete( $wpdb->postmeta, array( 'post_id' => $order_id ), array( '%d' ) ); // Delete all metadata.

		if ( $order_exists ) {
			// wp_update_post() changes the post modified date, so we do this manually.
			// Also, we suspect using wp_update_post() could lead to integrations mistakenly updating the entity.
			$wpdb->update(
				$wpdb->posts,
				array(
					'post_type'   => $this->data_synchronizer::PLACEHOLDER_ORDER_POST_TYPE,
					'post_status' => 'draft',
				),
				array( 'ID' => $order_id ),
				array( '%s', '%s' ),
				array( '%d' )
			);
		} else {
			$wpdb->delete( $wpdb->posts, array( 'ID' => $order_id ), array( '%d' ) );
		}

		clean_post_cache( $order_id );
	}

	/**
	 * Checks whether an HPOS-backed order is newer than the corresponding post.
	 *
	 * @param \WC_Abstract_Order $order An HPOS order.
	 * @return bool TRUE if the order is up to date with the corresponding post.
	 * @throws \Exception When the order is not an HPOS order.
	 */
	private function is_order_newer_than_post( \WC_Abstract_Order $order ): bool {
		if ( ! is_a( $order->get_data_store()->get_current_class_name(), OrdersTableDataStore::class, true ) ) {
			throw new \Exception( esc_html__( 'Order is not an HPOS order.', 'woocommerce' ) );
		}

		$post = get_post( $order->get_id() );
		if ( ! $post || $this->data_synchronizer::PLACEHOLDER_ORDER_POST_TYPE === $post->post_type ) {
			return true;
		}

		$order_modified_gmt = $order->get_date_modified() ?? $order->get_date_created();
		$order_modified_gmt = $order_modified_gmt ? $order_modified_gmt->getTimestamp() : 0;
		$post_modified_gmt  = $post->post_modified_gmt ?? $post->post_date_gmt;
		$post_modified_gmt  = ( $post_modified_gmt && '0000-00-00 00:00:00' !== $post_modified_gmt ) ? wc_string_to_timestamp( $post_modified_gmt ) : 0;

		return $order_modified_gmt >= $post_modified_gmt;
	}

	/**
	 * Builds an array with properties and metadata for which HPOS and post record have different values.
	 * Given it's mostly informative nature, it doesn't perform any deep or recursive searches and operates only on top-level properties/metadata.
	 *
	 * @since 8.6.0
	 *
	 * @param int $order_id Order ID.
	 * @return array Array of [HPOS value, post value] keyed by property, for all properties where HPOS and post value differ.
	 */
	public function get_diff_for_order( int $order_id ): array {
		$diff = array();

		$hpos_order = $this->get_order_from_datastore( $order_id, 'hpos' );
		$cpt_order  = $this->get_order_from_datastore( $order_id, 'posts' );

		if ( $hpos_order->get_type() !== $cpt_order->get_type() ) {
			$diff['type'] = array( $hpos_order->get_type(), $cpt_order->get_type() );
		}

		$hpos_meta = $this->order_meta_to_array( $hpos_order );
		$cpt_meta  = $this->order_meta_to_array( $cpt_order );

		// Consider only keys for which we actually have a corresponding HPOS column or are meta.
		$all_keys = array_unique(
			array_diff(
				array_merge(
					$this->get_order_base_props(),
					array_keys( $hpos_meta ),
					array_keys( $cpt_meta )
				),
				$this->data_synchronizer->get_ignored_order_props()
			)
		);

		foreach ( $all_keys as $key ) {
			$val1 = in_array( $key, $this->get_order_base_props(), true ) ? $hpos_order->{"get_$key"}() : ( $hpos_meta[ $key ] ?? null );
			$val2 = in_array( $key, $this->get_order_base_props(), true ) ? $cpt_order->{"get_$key"}() : ( $cpt_meta[ $key ] ?? null );

			// Workaround for https://github.com/woocommerce/woocommerce/issues/43126.
			if ( ! $val2 && in_array( $key, array( '_billing_address_index', '_shipping_address_index' ), true ) ) {
				$val2 = get_post_meta( $order_id, $key, true );
			}

			if ( $val1 != $val2 ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison,Universal.Operators.StrictComparisons.LooseNotEqual
				$diff[ $key ] = array( $val1, $val2 );
			}
		}

		return $diff;
	}

	/**
	 * Returns an order object as seen by either the HPOS or CPT datastores.
	 *
	 * @since 8.6.0
	 *
	 * @param int    $order_id      Order ID.
	 * @param string $data_store_id Datastore to use. Should be either 'hpos' or 'posts'. Defaults to 'hpos'.
	 * @return \WC_Order Order instance.
	 * @throws \Exception When an error occurs.
	 */
	public function get_order_from_datastore( int $order_id, string $data_store_id = 'hpos' ) {
		$data_store = ( 'hpos' === $data_store_id ) ? $this->data_store : $this->data_store->get_cpt_data_store_instance();

		wp_cache_delete( \WC_Order::generate_meta_cache_key( $order_id, 'orders' ), 'orders' );

		// Prime caches if we can.
		if ( method_exists( $data_store, 'prime_caches_for_orders' ) ) {
			$data_store->prime_caches_for_orders( array( $order_id ), array() );
		}

		$order_type = wc_get_order_type( $data_store->get_order_type( $order_id ) );

		if ( ! $order_type ) {
			// translators: %d is an order ID.
			throw new \Exception( esc_html( sprintf( __( '%d is not an order or has an invalid order type.', 'woocommerce' ), $order_id ) ) );
		}

		$classname = $order_type['class_name'];
		/** @var \WC_Order $order */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
		$order = new $classname();
		$order->set_id( $order_id );

		// Switch datastore if necessary.
		$update_data_store_func = function ( $data_store ) {
			/** @var \WC_Order $this */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
			// Each order object contains a reference to its data store, but this reference is itself
			// held inside of an instance of WC_Data_Store, so we create that first.
			$data_store_wrapper = \WC_Data_Store::load( 'order' );

			// Bind $data_store to our WC_Data_Store.
			( function ( $data_store ) {
				/** @var \WC_Data_Store $this */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
				$this->current_class_name = (string) get_class( $data_store );
				$this->instance           = $data_store;
			} )->call( $data_store_wrapper, $data_store );

			// Finally, update the $order object with our WC_Data_Store( $data_store ) instance.
			$this->data_store = $data_store_wrapper;
		};
		$update_data_store_func->call( $order, $data_store );

		// Read order (without triggering sync) -- we create our own callback instead of using `__return_false` to
		// prevent `remove_filter()` from removing it in cases where it was already hooked by 3rd party code.
		$prevent_sync_on_read = fn() => false;

		add_filter( 'woocommerce_hpos_enable_sync_on_read', $prevent_sync_on_read, 999 );
		try {
			$data_store->read( $order );
		} finally {
			remove_filter( 'woocommerce_hpos_enable_sync_on_read', $prevent_sync_on_read, 999 );
		}

		return $order;
	}

	/**
	 * Backfills an order from/to the CPT or HPOS datastore.
	 *
	 * @since 8.7.0
	 *
	 * @param int    $order_id               Order ID.
	 * @param string $source_data_store      Datastore to use as source. Should be either 'hpos' or 'posts'.
	 * @param string $destination_data_store Datastore to use as destination. Should be either 'hpos' or 'posts'.
	 * @param array  $fields                 List of metakeys or order properties to limit the backfill to.
	 * @return void
	 * @throws \Exception When an error occurs.
	 */
	public function backfill_order_to_datastore( int $order_id, string $source_data_store, string $destination_data_store, array $fields = array() ) {
		$valid_data_stores = array( 'posts', 'hpos' );

		if ( ! in_array( $source_data_store, $valid_data_stores, true ) || ! in_array( $destination_data_store, $valid_data_stores, true ) || $destination_data_store === $source_data_store ) {
			throw new \Exception( esc_html( sprintf( 'Invalid datastore arguments: %1$s -> %2$s.', $source_data_store, $destination_data_store ) ) );
		}

		$fields    = array_filter( $fields );
		$src_order = $this->get_order_from_datastore( $order_id, $source_data_store );

		// Backfill entire orders.
		if ( ! $fields ) {
			if ( 'posts' === $destination_data_store ) {
				$src_order->get_data_store()->backfill_post_record( $src_order );
			} elseif ( 'hpos' === $destination_data_store ) {
				$this->posts_to_cot_migrator->migrate_orders( array( $src_order->get_id() ) );
			}

			return;
		}

		$this->validate_backfill_fields( $fields, $src_order );

		$dest_order = $this->get_order_from_datastore( $src_order->get_id(), $destination_data_store );

		if ( 'posts' === $destination_data_store ) {
			$datastore = $this->data_store->get_cpt_data_store_instance();
		} elseif ( 'hpos' === $destination_data_store ) {
			$datastore = $this->data_store;
		}

		if ( ! $datastore || ! method_exists( $datastore, 'update_order_from_object' ) ) {
			throw new \Exception( esc_html__( 'The backup datastore does not support updating orders.', 'woocommerce' ) );
		}

		// Backfill meta.
		if ( ! empty( $fields['meta_keys'] ) ) {
			foreach ( $fields['meta_keys'] as $meta_key ) {
				$dest_order->delete_meta_data( $meta_key );

				foreach ( $src_order->get_meta( $meta_key, false, 'edit' ) as $meta ) {
					$dest_order->add_meta_data( $meta_key, $meta->value );
				}
			}
		}

		// Backfill props.
		if ( ! empty( $fields['props'] ) ) {
			$new_values = array_combine(
				$fields['props'],
				array_map(
					fn( $prop_name ) => $src_order->{"get_{$prop_name}"}(),
					$fields['props']
				)
			);

			$dest_order->set_props( $new_values );

			if ( 'hpos' === $destination_data_store ) {
				$dest_order->apply_changes();
				$limit_cb = function ( $rows, $order ) use ( $dest_order, $fields ) {
					if ( $dest_order->get_id() === $order->get_id() ) {
						$rows = $this->limit_hpos_update_to_props( $rows, $fields['props'] );
					}

					return $rows;
				};
				add_filter( 'woocommerce_orders_table_datastore_db_rows_for_order', $limit_cb, 10, 2 );
			}
		}

		$datastore->update_order_from_object( $dest_order );

		if ( 'hpos' === $destination_data_store && isset( $limit_cb ) ) {
			remove_filter( 'woocommerce_orders_table_datastore_db_rows_for_order', $limit_cb );
		}
	}

	/**
	 * Returns all metadata in an order object as an array.
	 *
	 * @param \WC_Order $order Order instance.
	 * @return array Array of metadata grouped by meta key.
	 */
	private function order_meta_to_array( \WC_Order &$order ): array {
		$result = array();

		foreach ( ArrayUtil::select( $order->get_meta_data(), 'get_data', ArrayUtil::SELECT_BY_OBJECT_METHOD ) as &$meta ) {
			if ( array_key_exists( $meta['key'], $result ) ) {
				$result[ $meta['key'] ]   = array( $result[ $meta['key'] ] );
				$result[ $meta['key'] ][] = $meta['value'];
			} else {
				$result[ $meta['key'] ] = $meta['value'];
			}
		}

		return $result;
	}

	/**
	 * Returns names of all order base properties supported by HPOS.
	 *
	 * @return string[] Property names.
	 */
	private function get_order_base_props(): array {
		$base_props = array();

		foreach ( $this->data_store->get_all_order_column_mappings() as $mapping ) {
			$base_props = array_merge( $base_props, array_column( $mapping, 'name' ) );
		}

		return $base_props;
	}

	/**
	 * Filters a set of HPOS row updates to those matching a specific set of order properties.
	 * Called via the `woocommerce_orders_table_datastore_db_rows_for_order` filter in `backfill_order_to_datastore`.
	 *
	 * @param array    $rows  Details for the db update.
	 * @param string[] $props Order property names.
	 * @return array
	 * @see OrdersTableDataStore::get_db_rows_for_order()
	 */
	private function limit_hpos_update_to_props( array $rows, array $props ) {
		// Determine HPOS columns corresponding to the props in the $props array.
		$allowed_columns = array();
		foreach ( $this->data_store->get_all_order_column_mappings() as &$mapping ) {
			foreach ( $mapping as $column_name => &$column_data ) {
				if ( ! isset( $column_data['name'] ) || ! in_array( $column_data['name'], $props, true ) ) {
					continue;
				}

				$allowed_columns[ $column_data['name'] ] = $column_name;
			}
		}

		foreach ( $rows as $i => &$db_update ) {
			// Prevent accidental update of another prop by limiting columns to explicitly requested props.
			if ( ! array_intersect_key( $db_update['data'], array_flip( $allowed_columns ) ) ) {
				unset( $rows[ $i ] );
				continue;
			}

			$allowed_column_names_with_ids = array_merge(
				$allowed_columns,
				array( 'id', 'order_id', 'address_type' )
			);

			$db_update['data']   = array_intersect_key( $db_update['data'], array_flip( $allowed_column_names_with_ids ) );
			$db_update['format'] = array_intersect_key( $db_update['format'], array_flip( $allowed_column_names_with_ids ) );
		}

		return $rows;
	}

	/**
	 * Validates meta_keys and property names for a partial order backfill.
	 *
	 * @param array              $fields An array possibly having entries with index 'meta_keys' and/or 'props',
	 *                                   corresponding to an array of order meta keys and/or order properties.
	 * @param \WC_Abstract_Order $order  The order being validated.
	 * @throws \Exception When a validation error occurs.
	 * @return void
	 */
	private function validate_backfill_fields( array $fields, \WC_Abstract_Order $order ) {
		if ( ! $fields ) {
			return;
		}

		if ( ! empty( $fields['meta_keys'] ) ) {
			$internal_meta_keys = array_unique(
				array_merge(
					$this->data_store->get_internal_meta_keys(),
					$this->data_store->get_cpt_data_store_instance()->get_internal_meta_keys()
				)
			);

			$possibly_internal_keys = array_intersect( $internal_meta_keys, $fields['meta_keys'] );
			if ( ! empty( $possibly_internal_keys ) ) {
				throw new \Exception(
					esc_html(
						sprintf(
							// translators: %s is a comma separated list of metakey names.
							_n(
								'%s is an internal meta key. Use --props to set it.',
								'%s are internal meta keys. Use --props to set them.',
								count( $possibly_internal_keys ),
								'woocommerce'
							),
							implode( ', ', $possibly_internal_keys )
						)
					)
				);
			}
		}

		if ( ! empty( $fields['props'] ) ) {
			$invalid_props = array_filter(
				$fields['props'],
				function ( $prop_name ) use ( $order ) {
					return ! method_exists( $order, "get_{$prop_name}" );
				}
			);

			if ( ! empty( $invalid_props ) ) {
				throw new \Exception(
					esc_html(
						sprintf(
							// translators: %s is a list of order property names.
							_n(
								'%s is not a valid order property.',
								'%s are not valid order properties.',
								count( $invalid_props ),
								'woocommerce'
							),
							implode( ', ', $invalid_props )
						)
					)
				);
			}
		}
	}
}
PK     [1],mt  mt  1  DataStores/Orders/CustomOrdersTableController.phpnu         <?php
/**
 * CustomOrdersTableController class file.
 */

namespace Automattic\WooCommerce\Internal\DataStores\Orders;

use Automattic\WooCommerce\Caches\OrderCache;
use Automattic\WooCommerce\Caches\OrderCacheController;
use Automattic\WooCommerce\Enums\FeaturePluginCompatibility;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Utilities\OrderUtil;
use Automattic\WooCommerce\Utilities\PluginUtil;
use WC_Admin_Settings;

defined( 'ABSPATH' ) || exit;

/**
 * This is the main class that controls the custom orders tables feature. Its responsibilities are:
 *
 * - Displaying UI components (entries in the tools page and in settings)
 * - Providing the proper data store for orders via 'woocommerce_order_data_store' hook
 *
 * ...and in general, any functionality that doesn't imply database access.
 */
class CustomOrdersTableController {

	private const SYNC_QUERY_ARG = 'wc_hpos_sync_now';

	private const STOP_SYNC_QUERY_ARG = 'wc_hpos_stop_sync';

	/**
	 * The name of the option for enabling the usage of the custom orders tables
	 */
	public const CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION = 'woocommerce_custom_orders_table_enabled';

	/**
	 * The name of the option that tells whether database transactions are to be used or not for data synchronization.
	 */
	public const USE_DB_TRANSACTIONS_OPTION = 'woocommerce_use_db_transactions_for_custom_orders_table_data_sync';

	/**
	 * The name of the option to store the transaction isolation level to use when database transactions are enabled.
	 */
	public const DB_TRANSACTIONS_ISOLATION_LEVEL_OPTION = 'woocommerce_db_transactions_isolation_level_for_custom_orders_table_data_sync';

	public const DEFAULT_DB_TRANSACTIONS_ISOLATION_LEVEL = 'READ UNCOMMITTED';

	public const HPOS_FTS_INDEX_OPTION = 'woocommerce_hpos_fts_index_enabled';

	public const HPOS_FTS_ADDRESS_INDEX_CREATED_OPTION = 'woocommerce_hpos_address_fts_index_created';

	public const HPOS_FTS_ORDER_ITEM_INDEX_CREATED_OPTION = 'woocommerce_hpos_order_item_fts_index_created';

	public const HPOS_DATASTORE_CACHING_ENABLED_OPTION = 'woocommerce_hpos_datastore_caching_enabled';

	/**
	 * The data store object to use.
	 *
	 * @var OrdersTableDataStore
	 */
	private $data_store;

	/**
	 * Refunds data store object to use.
	 *
	 * @var OrdersTableRefundDataStore
	 */
	private $refund_data_store;

	/**
	 * The data synchronizer object to use.
	 *
	 * @var DataSynchronizer
	 */
	private $data_synchronizer;

	/**
	 * The data cleanup instance to use.
	 *
	 * @var LegacyDataCleanup
	 */
	private $data_cleanup;

	/**
	 * The batch processing controller to use.
	 *
	 * @var BatchProcessingController
	 */
	private $batch_processing_controller;

	/**
	 * The features controller to use.
	 *
	 * @var FeaturesController
	 */
	private $features_controller;

	/**
	 * The orders cache object to use.
	 *
	 * @var OrderCache
	 */
	private $order_cache;

	/**
	 * The orders cache controller object to use.
	 *
	 * @var OrderCacheController
	 */
	private $order_cache_controller;

	/**
	 * The plugin util object to use.
	 *
	 * @var PluginUtil
	 */
	private $plugin_util;

	/**
	 * The db util object to use.
	 *
	 * @var DatabaseUtil;
	 */
	private $db_util;

	/**
	 * Class constructor.
	 */
	public function __construct() {
		$this->init_hooks();
	}

	/**
	 * Initialize the hooks used by the class.
	 */
	private function init_hooks() {
		add_filter( 'woocommerce_order_data_store', array( $this, 'get_orders_data_store' ), 999, 1 );
		add_filter( 'woocommerce_order-refund_data_store', array( $this, 'get_refunds_data_store' ), 999, 1 );
		add_filter( 'woocommerce_debug_tools', array( $this, 'add_hpos_tools' ), 999 );
		add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 );
		add_filter( 'updated_option', array( $this, 'process_updated_option_fts_index' ), 999, 3 );
		add_filter( 'pre_update_option', array( $this, 'process_pre_update_option' ), 999, 3 );
		add_action( 'woocommerce_after_register_post_type', array( $this, 'register_post_type_for_order_placeholders' ), 10, 0 );
		add_action( 'woocommerce_sections_advanced', array( $this, 'sync_now' ) );
		add_filter( 'removable_query_args', array( $this, 'register_removable_query_arg' ) );
		add_filter( 'get_edit_post_link', array( $this, 'maybe_rewrite_order_edit_link' ), 10, 2 );
		add_action( 'before_woocommerce_init', array( $this, 'maybe_set_order_cache_group_as_non_persistent' ) );
	}

	/**
	 * Class initialization, invoked by the DI container.
	 *
	 * @internal
	 * @param OrdersTableDataStore       $data_store The data store to use.
	 * @param DataSynchronizer           $data_synchronizer The data synchronizer to use.
	 * @param LegacyDataCleanup          $data_cleanup The legacy data cleanup instance to use.
	 * @param OrdersTableRefundDataStore $refund_data_store The refund data store to use.
	 * @param BatchProcessingController  $batch_processing_controller The batch processing controller to use.
	 * @param FeaturesController         $features_controller The features controller instance to use.
	 * @param OrderCache                 $order_cache The order cache engine to use.
	 * @param OrderCacheController       $order_cache_controller The order cache controller to use.
	 * @param PluginUtil                 $plugin_util The plugin util to use.
	 * @param DatabaseUtil               $db_util The database util to use.
	 */
	final public function init(
		OrdersTableDataStore $data_store,
		DataSynchronizer $data_synchronizer,
		LegacyDataCleanup $data_cleanup,
		OrdersTableRefundDataStore $refund_data_store,
		BatchProcessingController $batch_processing_controller,
		FeaturesController $features_controller,
		OrderCache $order_cache,
		OrderCacheController $order_cache_controller,
		PluginUtil $plugin_util,
		DatabaseUtil $db_util
	) {
		$this->data_store                  = $data_store;
		$this->data_synchronizer           = $data_synchronizer;
		$this->data_cleanup                = $data_cleanup;
		$this->batch_processing_controller = $batch_processing_controller;
		$this->refund_data_store           = $refund_data_store;
		$this->features_controller         = $features_controller;
		$this->order_cache                 = $order_cache;
		$this->order_cache_controller      = $order_cache_controller;
		$this->plugin_util                 = $plugin_util;
		$this->db_util                     = $db_util;
	}

	/**
	 * Is the custom orders table usage enabled via settings?
	 * This can be true only if the feature is enabled and a table regeneration has been completed.
	 *
	 * @return bool True if the custom orders table usage is enabled
	 */
	public function custom_orders_table_usage_is_enabled(): bool {
		return get_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION ) === 'yes';
	}

	/**
	 * Is caching of data within the CustomerOrdersTable datastores enabled?
	 *
	 * @return bool True if the caching is enabled within the CustomeOrderTable Datastores.
	 */
	public function hpos_data_caching_is_enabled(): bool {
		return get_option( self::HPOS_DATASTORE_CACHING_ENABLED_OPTION ) === 'yes' &&
			$this->custom_orders_table_usage_is_enabled();
	}

	/**
	 * Gets the instance of the orders data store to use.
	 *
	 * @param \WC_Object_Data_Store_Interface|string $default_data_store The default data store (as received via the woocommerce_order_data_store hook).
	 *
	 * @return \WC_Object_Data_Store_Interface|string The actual data store to use.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function get_orders_data_store( $default_data_store ) {
		return $this->get_data_store_instance( $default_data_store, 'order' );
	}

	/**
	 * Gets the instance of the refunds data store to use.
	 *
	 * @param \WC_Object_Data_Store_Interface|string $default_data_store The default data store (as received via the woocommerce_order-refund_data_store hook).
	 *
	 * @return \WC_Object_Data_Store_Interface|string The actual data store to use.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function get_refunds_data_store( $default_data_store ) {
		return $this->get_data_store_instance( $default_data_store, 'order_refund' );
	}

	/**
	 * Gets the instance of a given data store.
	 *
	 * @param \WC_Object_Data_Store_Interface|string $default_data_store The default data store (as received via the appropriate hooks).
	 * @param string                                 $type               The type of the data store to get.
	 *
	 * @return \WC_Object_Data_Store_Interface|string The actual data store to use.
	 */
	private function get_data_store_instance( $default_data_store, string $type ) {
		if ( $this->custom_orders_table_usage_is_enabled() ) {
			switch ( $type ) {
				case 'order_refund':
					return $this->refund_data_store;
				default:
					return $this->data_store;
			}
		} else {
			return $default_data_store;
		}
	}

	/**
	 * Add an entry to Status - Tools to create or regenerate the custom orders table,
	 * and also an entry to delete the table as appropriate.
	 *
	 * @param array $tools_array The array of tools to add the tool to.
	 * @return array The updated array of tools.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function add_hpos_tools( array $tools_array ): array {
		if ( ! $this->data_synchronizer->check_orders_table_exists() ) {
			return $tools_array;
		}

		// Cleanup tool.
		$tools_array = array_merge( $tools_array, $this->data_cleanup->get_tools_entries() );

		// Delete HPOS tables tool.
		if ( $this->custom_orders_table_usage_is_enabled() || $this->data_synchronizer->data_sync_is_enabled() || $this->batch_processing_controller->is_enqueued( get_class( $this->data_synchronizer ) ) ) {
			$disabled = true;
			$message  = __( 'This will delete the custom orders tables. The tables can be deleted only if the "High-Performance order storage" is not authoritative and sync is disabled (via Settings > Advanced > Features).', 'woocommerce' );
		} else {
			$disabled = false;
			$message  = __( 'This will delete the custom orders tables. To create them again enable the "High-Performance order storage" feature (via Settings > Advanced > Features).', 'woocommerce' );
		}

		$tools_array['delete_custom_orders_table'] = array(
			'name'             => __( 'Delete the custom orders tables', 'woocommerce' ),
			'desc'             => sprintf(
				'<strong class="red">%1$s</strong> %2$s',
				__( 'Note:', 'woocommerce' ),
				$message
			),
			'requires_refresh' => true,
			'callback'         => function () use ( $disabled ) {
				if ( $disabled ) {
					return;
				}

				$this->features_controller->change_feature_enable( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, false );
				$this->delete_custom_orders_tables();
				return __( 'Custom orders tables have been deleted.', 'woocommerce' );
			},
			'button'           => __( 'Delete', 'woocommerce' ),
			'disabled'         => $disabled,
		);

		return $tools_array;
	}

	/**
	 * Delete the custom orders tables and any related options and data in response to the user pressing the tool button.
	 *
	 * @throws \Exception Can't delete the tables.
	 */
	private function delete_custom_orders_tables() {
		if ( $this->custom_orders_table_usage_is_enabled() ) {
			throw new \Exception( "Can't delete the custom orders tables: they are currently in use (via Settings > Advanced > Features)." );
		}

		delete_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION );
		$this->data_synchronizer->delete_database_tables();
	}

	/**
	 * Handler for the individual setting updated hook.
	 *
	 * @param string $option Setting name.
	 * @param mixed  $old_value Old value of the setting.
	 * @param mixed  $value New value of the setting.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function process_updated_option( $option, $old_value, $value ) {
		if ( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION === $option && 'no' === $value ) {
			$this->data_synchronizer->cleanup_synchronization_state();
		}
		if ( self::HPOS_DATASTORE_CACHING_ENABLED_OPTION === $option && $old_value !== $value && 'yes' === $value ) {
			$this->data_store->clear_all_cached_data();
		}
	}

	/**
	 * Process option that enables FTS index on orders table. Tries to create an FTS index when option is enabled.
	 *
	 * @param string $option Option name.
	 * @param string $old_value Old value of the option.
	 * @param string $value New value of the option.
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function process_updated_option_fts_index( $option, $old_value, $value ) {
		if ( self::HPOS_FTS_INDEX_OPTION !== $option ) {
			return;
		}

		if ( 'yes' !== $value ) {
			return;
		}

		if ( ! $this->custom_orders_table_usage_is_enabled() ) {
			update_option( self::HPOS_FTS_INDEX_OPTION, 'no', true );
			if ( class_exists( 'WC_Admin_Settings' ) ) {
				WC_Admin_Settings::add_error( __( 'Failed to create FTS index on orders table. This feature is only available when High-performance order storage is enabled.', 'woocommerce' ) );
			}
			return;
		}

		if ( ! $this->db_util->fts_index_on_order_address_table_exists() ) {
			$this->db_util->create_fts_index_order_address_table();
		}

		// Check again to see if index was actually created.
		if ( $this->db_util->fts_index_on_order_address_table_exists() ) {
			update_option( self::HPOS_FTS_ADDRESS_INDEX_CREATED_OPTION, 'yes', false );
		} else {
			update_option( self::HPOS_FTS_ADDRESS_INDEX_CREATED_OPTION, 'no', false );
			if ( class_exists( 'WC_Admin_Settings ' ) ) {
				WC_Admin_Settings::add_error( __( 'Failed to create FTS index on address table', 'woocommerce' ) );
			}
		}

		if ( ! $this->db_util->fts_index_on_order_item_table_exists() ) {
			$this->db_util->create_fts_index_order_item_table();
		}

		// Check again to see if index was actually created.
		if ( $this->db_util->fts_index_on_order_item_table_exists() ) {
			update_option( self::HPOS_FTS_ORDER_ITEM_INDEX_CREATED_OPTION, 'yes', false );
		} else {
			update_option( self::HPOS_FTS_ORDER_ITEM_INDEX_CREATED_OPTION, 'no', false );
			if ( class_exists( 'WC_Admin_Settings ' ) ) {
				WC_Admin_Settings::add_error( __( 'Failed to create FTS index on order item table', 'woocommerce' ) );
			}
		}
	}

	/**
	 * Recreate order addresses FTS index. Useful when updating to 9.4 when phone number was added to index, or when other recreating index is needed.
	 *
	 * @since 9.4.0.
	 *
	 * @return array Array with keys status (bool) and message (string).
	 */
	public function recreate_order_address_fts_index(): array {
		$this->db_util->drop_fts_index_order_address_table();
		if ( $this->db_util->fts_index_on_order_address_table_exists() ) {
			return array(
				'status'  => false,
				'message' => __( 'Failed to modify existing FTS index. Please go to WooCommerce > Status > Tools and run the "Re-create Order Address FTS index" tool.', 'woocommerce' ),
			);
		} else {
			update_option( self::HPOS_FTS_ADDRESS_INDEX_CREATED_OPTION, 'no', false );
		}

		$this->db_util->create_fts_index_order_address_table();
		if ( ! $this->db_util->fts_index_on_order_address_table_exists() ) {
			return array(
				'status'  => false,
				'message' => __( 'Failed to create FTS index on order address table. Please go to WooCommerce > Status > Tools and run the "Re-create Order Address FTS index" tool.', 'woocommerce' ),
			);
		} else {
			update_option( self::HPOS_FTS_ADDRESS_INDEX_CREATED_OPTION, 'yes', false );
			return array(
				'status'  => true,
				'message' => __( 'FTS index recreated.', 'woocommerce' ),
			);
		}
	}

	/**
	 * Handler for the setting pre-update hook.
	 * We use it to verify that authoritative orders table switch doesn't happen while sync is pending.
	 *
	 * @param mixed  $value New value of the setting.
	 * @param string $option Setting name.
	 * @param mixed  $old_value Old value of the setting.
	 *
	 * @throws \Exception Attempt to change the authoritative orders table while orders sync is pending.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function process_pre_update_option( $value, $option, $old_value ) {
		if ( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION === $option && $value !== $old_value ) {
			$this->order_cache->flush();
			return $value;
		}

		if ( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION !== $option ) {
			return $value;
		}

		if ( $old_value === $value ) {
			return $value;
		}

		$this->order_cache->flush();
		if ( ! $this->data_synchronizer->check_orders_table_exists() ) {
			$this->data_synchronizer->create_database_tables();
		}

		$tables_created = get_option( DataSynchronizer::ORDERS_TABLE_CREATED ) === 'yes';
		if ( ! $tables_created ) {
			return 'no';
		}

		if ( ! $this->changing_data_source_with_sync_pending_is_allowed() && $this->data_synchronizer->has_orders_pending_sync() ) {
			throw new \Exception( "The authoritative table for orders storage can't be changed while there are orders out of sync" );
		}

		return $value;
	}

	/**
	 * Callback to trigger a sync immediately by clicking a button on the Features screen.
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function sync_now() {
		$section = filter_input( INPUT_GET, 'section' );
		if ( 'features' !== $section ) {
			return;
		}

		if ( filter_input( INPUT_GET, self::SYNC_QUERY_ARG, FILTER_VALIDATE_BOOLEAN ) ) {
			$action = 'sync-now';
		} elseif ( filter_input( INPUT_GET, self::STOP_SYNC_QUERY_ARG, FILTER_VALIDATE_BOOLEAN ) ) {
			$action = 'stop-sync';
		} else {
			return;
		}

		if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), "hpos-{$action}" ) ) {
			WC_Admin_Settings::add_error(
				'sync-now' === $action ?
					esc_html__( 'Unable to start synchronization. The link you followed may have expired.', 'woocommerce' )
					: esc_html__( 'Unable to stop synchronization. The link you followed may have expired.', 'woocommerce' )
			);
			return;
		}

		$this->data_cleanup->toggle_flag( false );

		if ( 'sync-now' === $action ) {
			if ( ! $this->data_synchronizer->check_orders_table_exists() && ! $this->data_synchronizer->create_database_tables() ) {
				WC_Admin_Settings::add_error(
					__( 'Unable to create HPOS tables for synchronization.', 'woocommerce' )
				);
				return;
			}

			$this->batch_processing_controller->enqueue_processor( DataSynchronizer::class );
		} else {
			$this->batch_processing_controller->remove_processor( DataSynchronizer::class );
		}
	}

	/**
	 * Tell WP Admin to remove the sync query arg from the URL.
	 *
	 * @param array $query_args The query args that are removable.
	 *
	 * @return array
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function register_removable_query_arg( $query_args ) {
		$query_args[] = self::SYNC_QUERY_ARG;
		$query_args[] = self::STOP_SYNC_QUERY_ARG;

		return $query_args;
	}

	/**
	 * Handler for the woocommerce_after_register_post_type post,
	 * registers the post type for placeholder orders.
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function register_post_type_for_order_placeholders(): void {
		wc_register_order_type(
			DataSynchronizer::PLACEHOLDER_ORDER_POST_TYPE,
			array(
				'public'                           => false,
				'exclude_from_search'              => true,
				'publicly_queryable'               => false,
				'show_ui'                          => false,
				'show_in_menu'                     => false,
				'show_in_nav_menus'                => false,
				'show_in_admin_bar'                => false,
				'show_in_rest'                     => false,
				'rewrite'                          => false,
				'query_var'                        => false,
				'can_export'                       => false,
				'supports'                         => array(),
				'capabilities'                     => array(),
				'exclude_from_order_count'         => true,
				'exclude_from_order_views'         => true,
				'exclude_from_order_reports'       => true,
				'exclude_from_order_sales_reports' => true,
			)
		);
	}

	/**
	 * Add the definition for the HPOS feature.
	 *
	 * @param FeaturesController $features_controller The instance of FeaturesController.
	 *
	 * @return void
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function add_feature_definition( $features_controller ) {
		$definition = array(
			'option_key'                   => self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
			'is_experimental'              => false,
			'enabled_by_default'           => false,
			'order'                        => 50,
			'setting'                      => $this->get_hpos_setting_for_feature(),
			'default_plugin_compatibility' => FeaturePluginCompatibility::INCOMPATIBLE,
			'additional_settings'          => array(
				$this->get_hpos_setting_for_sync(),
			),
		);

		$features_controller->add_feature_definition(
			'custom_order_tables',
			__( 'High-Performance order storage', 'woocommerce' ),
			$definition
		);
	}

	/**
	 * Returns the HPOS setting for rendering HPOS vs Post setting block in Features section of the settings page.
	 *
	 * @return array Feature setting object.
	 */
	private function get_hpos_setting_for_feature() {
		if ( 'yes' === get_transient( 'wc_installing' ) ) {
			return array();
		}

		$get_value = function () {
			return $this->custom_orders_table_usage_is_enabled() ? 'yes' : 'no';
		};

		/**
		 * ⚠️The FeaturesController instance must only be accessed from within the callback functions. Otherwise it
		 * gets called while it's still being instantiated and creates and endless loop.
		 */

		$get_desc = function () {
			$plugin_compatibility = $this->features_controller->get_compatible_plugins_for_feature( 'custom_order_tables', true );

			return $this->plugin_util->generate_incompatible_plugin_feature_warning( 'custom_order_tables', $plugin_compatibility );
		};

		$get_disabled = function () {
			$compatibility_info = $this->features_controller->get_compatible_plugins_for_feature( 'custom_order_tables', true );
			$sync_complete      = ! $this->data_synchronizer->has_orders_pending_sync();
			$disabled           = array();
			// Changing something here? You might also want to look at `enable|disable` functions in Automattic\WooCommerce\Database\Migrations\CustomOrderTable\CLIRunner.
			$incompatible_plugins = $this->plugin_util->get_items_considered_incompatible( 'custom_order_tables', $compatibility_info );
			$incompatible_plugins = array_diff( $incompatible_plugins, $this->plugin_util->get_plugins_excluded_from_compatibility_ui() );
			if ( count( $incompatible_plugins ) > 0 ) {
				$disabled = array( 'yes' );
			}
			if ( ! $sync_complete && ! $this->changing_data_source_with_sync_pending_is_allowed() ) {
				$disabled = array( 'yes', 'no' );
			}

			return $disabled;
		};

		return array(
			'id'          => self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
			'title'       => __( 'Order data storage', 'woocommerce' ),
			'type'        => 'radio',
			'options'     => array(
				'no'  => __( 'WordPress posts storage (legacy)', 'woocommerce' ),
				'yes' => __( 'High-performance order storage (recommended)', 'woocommerce' ),
			),
			'value'       => $get_value,
			'disabled'    => $get_disabled,
			'desc'        => $get_desc,
			'desc_at_end' => true,
			'row_class'   => self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
		);
	}

	/**
	 * Returns the setting for rendering sync enabling setting block in Features section of the settings page.
	 *
	 * @return array Feature setting object.
	 */
	private function get_hpos_setting_for_sync() {
		if ( 'yes' === get_transient( 'wc_installing' ) ) {
			return array();
		}

		$get_value = function () {
			return get_option( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION );
		};

		$get_sync_message = function () {
			$sync_in_progress = $this->batch_processing_controller->is_enqueued( get_class( $this->data_synchronizer ) );
			$sync_enabled     = $this->data_synchronizer->data_sync_is_enabled();
			$sync_is_pending  = $this->data_synchronizer->has_orders_pending_sync( true );
			$sync_message     = array();
			$is_dangerous     = $sync_is_pending && $this->changing_data_source_with_sync_pending_is_allowed();

			if ( $is_dangerous ) {
				$sync_message[] = wp_kses_data(
					__( "There are orders pending sync.", 'woocommerce' )
					. '<strong>'
					. __( 'Switching data storage while sync is incomplete is dangerous and can lead to order data corruption or loss!', 'woocommerce' )
					. '</strong>'
				);
			}

			if ( ! $sync_enabled && $this->data_synchronizer->background_sync_is_enabled() ) {
				$sync_message[] = __( 'Background sync is enabled.', 'woocommerce' );
			}

			if ( $sync_in_progress && $sync_is_pending ) {
				$orders_pending_sync_count = $this->data_synchronizer->get_current_orders_pending_sync_count( true );

				$sync_message[] = sprintf(
					// translators: %s: number of pending orders.
					__( 'Currently syncing orders... %s pending', 'woocommerce' ),
					number_format_i18n( $orders_pending_sync_count )
				);

				if ( ! $sync_enabled ) {
					$stop_sync_url = wp_nonce_url(
						add_query_arg(
							array(
								self::STOP_SYNC_QUERY_ARG => true,
							),
							wc_get_container()->get( FeaturesController::class )->get_features_page_url()
						),
						'hpos-stop-sync'
					);

					$sync_message[] = sprintf(
						'<a href="%1$s" class="button button-link">%2$s</a>',
						esc_url( $stop_sync_url ),
						__( 'Stop sync', 'woocommerce' )
					);
				}
			} elseif ( $sync_is_pending ) {
				$sync_now_url = wp_nonce_url(
					add_query_arg(
						array(
							self::SYNC_QUERY_ARG => true,
						),
						wc_get_container()->get( FeaturesController::class )->get_features_page_url()
					),
					'hpos-sync-now'
				);

				if ( ! $is_dangerous ) {
					$sync_message[] = wp_kses_data(
						__( "You can switch order data storage <strong>only when the posts and orders tables are in sync</strong>. There are currently orders out of sync.", 'woocommerce' ),
					);
				}

				$sync_message[] = sprintf(
					'<a href="%1$s" class="button button-link">%2$s</a>',
					esc_url( $sync_now_url ),
					__( 'Sync orders now', 'woocommerce' )
				);
			}

			return implode( '<br />', $sync_message );
		};

		$get_description_is_error = function () {
			$sync_is_pending = $this->data_synchronizer->has_orders_pending_sync();

			return $sync_is_pending && $this->changing_data_source_with_sync_pending_is_allowed();
		};

		return array(
			'id'                   => DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION,
			'title'                => '',
			'type'                 => 'checkbox',
			'desc'                 => __( 'Enable compatibility mode (Synchronize orders between High-performance order storage and WordPress posts storage).', 'woocommerce' ),
			'value'                => $get_value,
			'desc_tip'             => $get_sync_message,
			'description_is_error' => $get_description_is_error,
			'row_class'            => DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION,
		);
	}

	/**
	 * Returns a value indicating if changing the authoritative data source for orders while there are orders pending synchronization is allowed.
	 *
	 * @return bool
	 */
	private function changing_data_source_with_sync_pending_is_allowed(): bool {
		/**
		 * Filter to allow changing where order data is stored, even when there are orders pending synchronization.
		 *
		 * DANGER! This filter is intended for usage when doing manual and automated testing in development environments only,
		 * it should NEVER be used in production environments. Order data corruption or loss can happen!
		 *
		 * @param bool $allow True to allow changing order storage when there are orders pending synchronization, false to disallow.
		 * @returns bool
		 *
		 * @since 8.3.0
		 */
		return apply_filters( 'wc_allow_changing_orders_storage_while_sync_is_pending', false );
	}

	/**
	 * Rewrites post edit links for HPOS placeholder posts so that they go to the HPOS order itself.
	 * Hooked onto `get_edit_post_link`.
	 *
	 * @since 9.0.0
	 *
	 * @param string $link    The edit link.
	 * @param int    $post_id Post ID.
	 * @return string
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function maybe_rewrite_order_edit_link( $link, $post_id ) {
		if ( DataSynchronizer::PLACEHOLDER_ORDER_POST_TYPE === get_post_type( $post_id ) ) {
			$link = OrderUtil::get_order_admin_edit_url( $post_id );
		}

		return $link;
	}

	/**
	 * Set the `order_objects` cache group as non-persistent if Custom Order data caching is enabled.
	 *
	 * With order datastore cache enabled, caching of raw data is now handled by the datastore, rather than full object
	 * being stored in persistent cache.
	 *
	 * @return void
	 */
	public function maybe_set_order_cache_group_as_non_persistent() {
		if ( OrderUtil::custom_orders_table_datastore_cache_enabled() ) {
			// If we're using datastore cache, we don't want to persist the order objects in cache. It should be in-memory only.
			wp_cache_add_non_persistent_groups( array( $this->order_cache->get_object_type() ) );
		}
	}
}
PK     [1]i      RestApiParameterUtil.phpnu         <?php
/**
 * ApiUtil class file.
 */

namespace Automattic\WooCommerce\Internal;

/**
 * Helper methods for the REST API.
 *
 * Class ApiUtil
 *
 * @package Automattic\WooCommerce\Internal
 */
class RestApiParameterUtil {

	/**
	 * Converts a create refund request from the public API format:
	 *
	 * [
	 *   "reason" => "",
	 *   "api_refund" => "x",
	 *   "api_restock" => "x",
	 *   "line_items" => [
	 *     "id" => "111",
	 *     "quantity" => 222,
	 *     "refund_total" => 333,
	 *     "refund_tax" => [
	 *       [
	 *          "id": "444",
	 *          "refund_total": 555
	 *       ],...
	 *   ],...
	 * ]
	 *
	 * ...to the internally used format:
	 *
	 * [
	 *   "reason" => null,      (if it's missing or any empty value, set as null)
	 *   "api_refund" => true,  (if it's missing or non-bool, set as "true")
	 *   "api_restock" => true, (if it's missing or non-bool, set as "true")
	 *   "line_items" => [      (convert sequential array to associative based on "id")
	 *     "111" => [
	 *       "qty" => 222,      (rename "quantity" to "qty")
	 *       "refund_total" => 333,
	 *       "refund_tax" => [  (convert sequential array to associative based on "id" and "refund_total)
	 *         "444" => 555,...
	 *       ],...
	 *   ]
	 * ]
	 *
	 * It also calculates the amount if missing and whenever possible, see maybe_calculate_refund_amount_from_line_items.
	 *
	 * The conversion is done in a way that if the request is already in the internal format,
	 * then nothing is changed for compatibility. For example, if the line items array
	 * is already an associative array or any of its elements
	 * is missing the "id" key, then the entire array is left unchanged.
	 * Same for the "refund_tax" array inside each line item.
	 *
	 * @param \WP_REST_Request $request The request to adjust.
	 */
	public static function adjust_create_refund_request_parameters( \WP_REST_Request &$request ) {
		if ( empty( $request['reason'] ) ) {
			$request['reason'] = null;
		}

		if ( ! is_bool( $request['api_refund'] ) ) {
			$request['api_refund'] = true;
		}

		if ( ! is_bool( $request['api_restock'] ) ) {
			$request['api_restock'] = true;
		}

		if ( empty( $request['line_items'] ) ) {
			$request['line_items'] = array();
		} else {
			$request['line_items'] = self::adjust_line_items_for_create_refund_request( $request['line_items'] );
		}

		if ( ! isset( $request['amount'] ) ) {
			$amount = self::calculate_refund_amount_from_line_items( $request );
			if ( null !== $amount ) {
				$request['amount'] = strval( $amount );
			}
		}
	}

	/**
	 * Calculate the "amount" parameter for the request based on the amounts found in line items.
	 * This will ONLY be possible if ALL of the following is true:
	 *
	 * - "line_items" in the request is a non-empty array.
	 * - All line items have a "refund_total" field with a numeric value.
	 * - All values inside "refund_tax" in all line items are a numeric value.
	 *
	 * The request is assumed to be in internal format already.
	 *
	 * @param \WP_REST_Request $request The request to maybe calculate the total amount for.
	 * @return number|null The calculated amount, or null if it can't be calculated.
	 */
	private static function calculate_refund_amount_from_line_items( $request ) {
		$line_items = $request['line_items'];

		if ( ! is_array( $line_items ) || empty( $line_items ) ) {
			return null;
		}

		$amount = 0;

		foreach ( $line_items as $item ) {
			if ( ! isset( $item['refund_total'] ) || ! is_numeric( $item['refund_total'] ) ) {
				return null;
			}

			$amount += $item['refund_total'];

			if ( ! isset( $item['refund_tax'] ) ) {
				continue;
			}

			foreach ( $item['refund_tax'] as $tax ) {
				if ( ! is_numeric( $tax ) ) {
					return null;
				}
				$amount += $tax;
			}
		}

		return $amount;
	}

	/**
	 * Convert the line items of a refund request to internal format (see adjust_create_refund_request_parameters).
	 *
	 * @param array $line_items The line items to convert.
	 * @return array The converted line items.
	 */
	private static function adjust_line_items_for_create_refund_request( $line_items ) {
		if ( ! is_array( $line_items ) || empty( $line_items ) || self::is_associative( $line_items ) ) {
			return $line_items;
		}

		$new_array = array();
		foreach ( $line_items as $item ) {
			if ( ! isset( $item['id'] ) ) {
				return $line_items;
			}

			if ( isset( $item['quantity'] ) && ! isset( $item['qty'] ) ) {
				$item['qty'] = $item['quantity'];
			}
			unset( $item['quantity'] );

			if ( isset( $item['refund_tax'] ) ) {
				$item['refund_tax'] = self::adjust_taxes_for_create_refund_request_line_item( $item['refund_tax'] );
			}

			$id               = $item['id'];
			$new_array[ $id ] = $item;

			unset( $new_array[ $id ]['id'] );
		}

		return $new_array;
	}

	/**
	 * Adjust the taxes array from a line item in a refund request, see adjust_create_refund_parameters.
	 *
	 * @param array $taxes_array The array to adjust.
	 * @return array The adjusted array.
	 */
	private static function adjust_taxes_for_create_refund_request_line_item( $taxes_array ) {
		if ( ! is_array( $taxes_array ) || empty( $taxes_array ) || self::is_associative( $taxes_array ) ) {
			return $taxes_array;
		}

		$new_array = array();
		foreach ( $taxes_array as $item ) {
			if ( ! isset( $item['id'] ) || ! isset( $item['refund_total'] ) ) {
				return $taxes_array;
			}

			$id               = $item['id'];
			$refund_total     = $item['refund_total'];
			$new_array[ $id ] = $refund_total;
		}

		return $new_array;
	}

	/**
	 * Is an array sequential or associative?
	 *
	 * @param array $the_array The array to check.
	 * @return bool True if the array is associative, false if it's sequential.
	 */
	private static function is_associative( array $the_array ) {
		return array_keys( $the_array ) !== range( 0, count( $the_array ) - 1 );
	}
}
PK     [1]7b"  "  (  PushNotifications/Entities/PushToken.phpnu         <?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\PushNotifications\Entities;

defined( 'ABSPATH' ) || exit;

use InvalidArgumentException;

/**
 * Object representation of a push token.
 *
 * @since 10.4.0
 */
class PushToken {
	/**
	 * WordPress post type for storing push tokens.
	 */
	const POST_TYPE = 'wc_push_token';

	/**
	 * Platform identifier for Apple devices.
	 */
	const PLATFORM_APPLE = 'apple';

	/**
	 * Platform identifier for Android devices.
	 */
	const PLATFORM_ANDROID = 'android';

	/**
	 * Platform identifier for web browsers.
	 */
	const PLATFORM_BROWSER = 'browser';

	/**
	 * Origin identifier for WooCommerce Android app.
	 */
	const ORIGIN_WOOCOMMERCE_ANDROID = 'com.woocommerce.android';

	/**
	 * Origin identifier for WooCommerce Android app development builds.
	 */
	const ORIGIN_WOOCOMMERCE_ANDROID_DEV = 'com.woocommerce.android:dev';

	/**
	 * Origin identifier for WooCommerce iOS app.
	 */
	const ORIGIN_WOOCOMMERCE_IOS = 'com.automattic.woocommerce';

	/**
	 * Origin identifier for WooCommerce iOS app development builds.
	 */
	const ORIGIN_WOOCOMMERCE_IOS_DEV = 'com.automattic.woocommerce:dev';

	/**
	 * Origin identifier for browsers.
	 */
	const ORIGIN_BROWSER = 'browser';

	/**
	 * List of valid platforms.
	 */
	const PLATFORMS = array(
		self::PLATFORM_APPLE,
		self::PLATFORM_ANDROID,
		self::PLATFORM_BROWSER,
	);

	/**
	 * List of valid origins.
	 */
	const ORIGINS = array(
		self::ORIGIN_BROWSER,
		self::ORIGIN_WOOCOMMERCE_ANDROID,
		self::ORIGIN_WOOCOMMERCE_ANDROID_DEV,
		self::ORIGIN_WOOCOMMERCE_IOS,
		self::ORIGIN_WOOCOMMERCE_IOS_DEV,
	);

	/**
	 * Maximum length for push notification tokens.
	 */
	const MAX_TOKEN_LENGTH = 4096;

	/**
	 * The id of the token post.
	 *
	 * @var int|null
	 */
	private ?int $id = null;

	/**
	 * The id of the user who owns the token.
	 *
	 * @var int|null
	 */
	private ?int $user_id = null;

	/**
	 * The token representing a device we can send a push notification to.
	 *
	 * @var string|null
	 */
	private ?string $token = null;

	/**
	 * The UUID of the device that generated the token.
	 *
	 * @var string|null
	 */
	private ?string $device_uuid = null;

	/**
	 * The platform the token was generated by.
	 *
	 * @var string|null
	 */
	private ?string $platform = null;

	/**
	 * The origin the token belongs to.
	 *
	 * @var string|null
	 */
	private ?string $origin = null;

	/**
	 * Creates a new PushToken instance with the specified properties.
	 *
	 * This is a utility method that provides a one-liner to create an instance
	 * with all the data you want to specify upfront. Using this method doesn't
	 * imply that the instance obtained is valid, complete, or usable in all
	 * contexts - validity is still determined by the internal validation logic
	 * of the class.
	 *
	 * @param int|null    $id          The ID of the token post.
	 * @param int|null    $user_id     The ID of the user who owns the token.
	 * @param string|null $token       The token representing a device we can send a push notification to.
	 * @param string|null $device_uuid The UUID of the device that generated the token.
	 * @param string|null $platform    The platform the token was generated by.
	 * @param string|null $origin      The origin the token belongs to.
	 * @throws InvalidArgumentException If any of the provided values fail validation.
	 * @return PushToken
	 *
	 * @since 10.4.0
	 */
	public static function get_new_instance(
		?int $id = null,
		?int $user_id = null,
		?string $token = null,
		?string $device_uuid = null,
		?string $platform = null,
		?string $origin = null
	): PushToken {
		$instance = new self();

		if ( null !== $id ) {
			$instance->set_id( $id );
		}

		if ( null !== $user_id ) {
			$instance->set_user_id( $user_id );
		}

		if ( null !== $token ) {
			$instance->set_token( $token );
		}

		if ( null !== $device_uuid ) {
			$instance->set_device_uuid( $device_uuid );
		}

		if ( null !== $platform ) {
			$instance->set_platform( $platform );
		}

		if ( null !== $origin ) {
			$instance->set_origin( $origin );
		}

		return $instance;
	}

	/**
	 * Sets the ID.
	 *
	 * @param int $id The id of the token post.
	 * @throws InvalidArgumentException If ID is <= 0.
	 * @return void
	 *
	 * @since 10.4.0
	 */
	public function set_id( int $id ): void {
		if ( $id <= 0 ) {
			throw new InvalidArgumentException( 'ID must be a positive integer.' );
		}

		$this->id = $id;
	}

	/**
	 * Sets the user ID.
	 *
	 * @param int $user_id The id of the user who owns the token.
	 * @throws InvalidArgumentException If ID is <= 0.
	 * @return void
	 *
	 * @since 10.4.0
	 */
	public function set_user_id( int $user_id ): void {
		if ( $user_id <= 0 ) {
			throw new InvalidArgumentException( 'User ID must be a positive integer.' );
		}

		$this->user_id = $user_id;
	}

	/**
	 * Sets the token.
	 *
	 * @param string $token The token representing a device we can send a push notification to.
	 * @throws InvalidArgumentException If token is empty or exceeds maximum length.
	 * @return void
	 *
	 * @since 10.4.0
	 */
	public function set_token( string $token ): void {
		$token = trim( $token );

		if ( '' === $token ) {
			throw new InvalidArgumentException( 'Token cannot be empty.' );
		}

		if ( strlen( $token ) > self::MAX_TOKEN_LENGTH ) {
			throw new InvalidArgumentException(
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
				sprintf( 'Token exceeds maximum length of %s.', self::MAX_TOKEN_LENGTH )
			);
		}

		$this->token = $token;
	}

	/**
	 * Sets the device UUID, normalize empty (non-null) values to null.
	 *
	 * @param string|null $device_uuid The UUID of the device that generated the token.
	 * @return void
	 *
	 * @since 10.4.0
	 */
	public function set_device_uuid( ?string $device_uuid ): void {
		if ( null !== $device_uuid ) {
			$device_uuid = trim( $device_uuid );
		}

		$this->device_uuid = ( '' === $device_uuid ) ? null : $device_uuid;
	}

	/**
	 * Sets the platform.
	 *
	 * @param string $platform The platform the token was generated by.
	 * @throws InvalidArgumentException If the platform is invalid.
	 * @return void
	 *
	 * @since 10.4.0
	 */
	public function set_platform( string $platform ): void {
		if ( ! in_array( $platform, self::PLATFORMS, true ) ) {
			throw new InvalidArgumentException( 'Platform for PushToken is invalid.' );
		}

		$this->platform = $platform;
	}

	/**
	 * Sets the origin.
	 *
	 * @param string $origin The origin of the token, e.g. the app it came from.
	 * @throws InvalidArgumentException If the origin is invalid.
	 * @return void
	 *
	 * @since 10.4.0
	 */
	public function set_origin( string $origin ): void {
		if ( ! in_array( $origin, self::ORIGINS, true ) ) {
			throw new InvalidArgumentException( 'Origin for PushToken is invalid.' );
		}

		$this->origin = $origin;
	}

	/**
	 * Gets the ID.
	 *
	 * @return int|null
	 *
	 * @since 10.4.0
	 */
	public function get_id(): ?int {
		return $this->id;
	}

	/**
	 * Gets the user ID.
	 *
	 * @return int|null
	 *
	 * @since 10.4.0
	 */
	public function get_user_id(): ?int {
		return $this->user_id;
	}

	/**
	 * Gets the token.
	 *
	 * @return string|null
	 *
	 * @since 10.4.0
	 */
	public function get_token(): ?string {
		return $this->token;
	}

	/**
	 * Gets the device UUID.
	 *
	 * @return string|null
	 *
	 * @since 10.4.0
	 */
	public function get_device_uuid(): ?string {
		return $this->device_uuid;
	}

	/**
	 * Gets the platform.
	 *
	 * @return string|null
	 *
	 * @since 10.4.0
	 */
	public function get_platform(): ?string {
		return $this->platform;
	}

	/**
	 * Gets the origin.
	 *
	 * @return string|null
	 *
	 * @since 10.4.0
	 */
	public function get_origin(): ?string {
		return $this->origin;
	}

	/**
	 * Determines whether this token can be created.
	 *
	 * @return bool
	 *
	 * @since 10.4.0
	 */
	public function can_be_created(): bool {
		return ! $this->get_id() && $this->has_required_parameters();
	}

	/**
	 * Determines whether this token can be updated.
	 *
	 * @return bool
	 *
	 * @since 10.4.0
	 */
	public function can_be_updated(): bool {
		return $this->get_id() && $this->has_required_parameters();
	}

	/**
	 * Determines whether this token can be read.
	 *
	 * @return bool
	 *
	 * @since 10.4.0
	 */
	public function can_be_read(): bool {
		return (bool) $this->get_id();
	}

	/**
	 * Determines whether this token can be deleted.
	 *
	 * @return bool
	 *
	 * @since 10.4.0
	 */
	public function can_be_deleted(): bool {
		return (bool) $this->get_id();
	}

	/**
	 * Determines whether all the required non-ID parameters are filled.
	 *
	 * @return bool
	 *
	 * @since 10.4.0
	 */
	private function has_required_parameters(): bool {
		return $this->get_user_id()
			&& $this->get_token()
			&& $this->get_platform()
			&& $this->get_origin()
			&& (
				$this->get_device_uuid()
				|| $this->get_platform() === self::PLATFORM_BROWSER
			);
	}
}
PK     [1]O=e  e  ;  PushNotifications/Exceptions/PushTokenNotFoundException.phpnu         <?php
/**
 * PushTokenNotFoundException class file.
 */

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\PushNotifications\Exceptions;

defined( 'ABSPATH' ) || exit;

use Exception;
use WP_Http;

/**
 * Exception thrown when a push token cannot be found.
 *
 * @since 10.5.0
 */
class PushTokenNotFoundException extends Exception {}
PK     [1]&    '  PushNotifications/PushNotifications.phpnu         <?php

declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\PushNotifications;

defined( 'ABSPATH' ) || exit;

use Automattic\Jetpack\Connection\Manager as JetpackConnectionManager;
use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
use WC_Logger;
use Exception;

/**
 * WC Push Notifications
 *
 * Class for setting up the WooCommerce-driven push notifications.
 *
 * @since 10.4.0
 */
class PushNotifications {
	/**
	 * Feature name for the push notifications feature.
	 */
	const FEATURE_NAME = 'push_notifications';

	/**
	 * Roles that can receive push notifications.
	 *
	 * This will be used to gate functionality access to just these roles.
	 */
	const ROLES_WITH_PUSH_NOTIFICATIONS_ENABLED = array(
		'administrator',
		'shop_manager',
	);

	/**
	 * 'Memoized' enablement flag.
	 *
	 * @var bool|null
	 */
	private ?bool $enabled = null;

	/**
	 * Loads the push notifications class.
	 *
	 * @return void
	 *
	 * @since 10.4.0
	 */
	public function register(): void {
		if ( ! $this->should_be_enabled() ) {
			return;
		}

		add_action( 'init', array( $this, 'register_post_types' ) );

		// Library endpoints and scheduled tasks will be registered here.
	}

	/**
	 * Registers the push token custom post type.
	 *
	 * @since 10.5.0
	 * @return void
	 */
	public function register_post_types(): void {
		register_post_type(
			PushToken::POST_TYPE,
			array(
				'labels'             => array(
					'name'          => __( 'Push Tokens', 'woocommerce' ),
					'singular_name' => __( 'Push Token', 'woocommerce' ),
				),
				'public'             => false,
				'publicly_queryable' => false,
				'show_ui'            => false,
				'show_in_menu'       => false,
				'query_var'          => false,
				'rewrite'            => false,
				'capability_type'    => 'post',
				'has_archive'        => false,
				'hierarchical'       => false,
				'supports'           => array( 'author' ),
				'can_export'         => false,
				'delete_with_user'   => true,
			)
		);
	}

	/**
	 * Determines if local push notification functionality should be enabled.
	 * Push notifications require both the feature flag to be enabled and
	 * Jetpack to be connected. Memoize the value so we only check once per
	 * request.
	 *
	 * @return bool
	 *
	 * @since 10.4.0
	 */
	public function should_be_enabled(): bool {
		if ( null !== $this->enabled ) {
			return $this->enabled;
		}

		if ( ! FeaturesUtil::feature_is_enabled( self::FEATURE_NAME ) ) {
			$this->enabled = false;
			return $this->enabled;
		}

		try {
			$proxy = wc_get_container()->get( LegacyProxy::class );

			$this->enabled = (
				class_exists( JetpackConnectionManager::class )
				&& $proxy->get_instance_of( JetpackConnectionManager::class )->is_connected()
			);
		} catch ( Exception $e ) {
			$logger = wc_get_container()->get( LegacyProxy::class )->call_function( 'wc_get_logger' );

			if ( $logger instanceof WC_Logger ) {
				$logger->error(
					'Error determining if PushNotifications feature should be enabled: ' . $e->getMessage()
				);
			}

			$this->enabled = false;
		}

		return $this->enabled;
	}
}
PK     [1]]h$  h$  4  PushNotifications/DataStores/PushTokensDataStore.phpnu         <?php
/**
 * PushTokensDataStore class file.
 */

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\PushNotifications\DataStores;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenNotFoundException;
use Exception;
use InvalidArgumentException;
use WP_Query;

/**
 * Data store class for push tokens.
 *
 * @since 10.5.0
 */
class PushTokensDataStore {
	const SUPPORTED_META = array(
		'origin',
		'device_uuid',
		'token',
		'platform',
	);

	/**
	 * Creates a post representing the push token.
	 *
	 * @since 10.5.0
	 * @param PushToken $push_token An instance of PushToken.
	 * @throws InvalidArgumentException If the token can't be created.
	 * @throws Exception If the token creation fails.
	 * @return void
	 */
	public function create( PushToken &$push_token ): void {
		if ( ! $push_token->can_be_created() ) {
			throw new InvalidArgumentException(
				'Can\'t create push token because the push token data provided is invalid.'
			);
		}

		$id = wp_insert_post(
			array(
				'post_author' => (int) $push_token->get_user_id(),
				'post_type'   => PushToken::POST_TYPE,
				'post_status' => 'private',
				'meta_input'  => $this->build_meta_array_from_token( $push_token ),
			),
			true
		);

		if ( is_wp_error( $id ) ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
			throw new Exception( $id->get_error_message() );
		}

		$push_token->set_id( $id );
	}

	/**
	 * Gets post representing a push token.
	 *
	 * @since 10.5.0
	 * @param PushToken $push_token An instance of PushToken.
	 * @throws InvalidArgumentException If the token can't be read.
	 * @throws PushTokenNotFoundException If the token can't be found.
	 * @return void
	 */
	public function read( PushToken &$push_token ): void {
		if ( ! $push_token->can_be_read() ) {
			throw new InvalidArgumentException(
				'Can\'t read push token because the push token data provided is invalid.'
			);
		}

		$post = get_post( $push_token->get_id() );

		if ( ! $post || PushToken::POST_TYPE !== $post->post_type ) {
			throw new PushTokenNotFoundException( 'Push token could not be found.' );
		}

		$meta = $this->build_meta_array_from_database( $push_token );

		if (
			empty( $meta['token'] )
			|| empty( $meta['platform'] )
			|| empty( $meta['origin'] )
			|| (
				empty( $meta['device_uuid'] )
				&& PushToken::PLATFORM_BROWSER !== $meta['platform']
			)
		) {
			throw new InvalidArgumentException(
				'Can\'t read push token because the push token record is malformed.'
			);
		}

		$push_token->set_user_id( (int) $post->post_author );
		$push_token->set_token( $meta['token'] );
		$push_token->set_platform( $meta['platform'] );
		$push_token->set_device_uuid( $meta['device_uuid'] ?? null );
		$push_token->set_origin( $meta['origin'] );
	}

	/**
	 * Updates a post representing the push token.
	 *
	 * @since 10.5.0
	 * @param PushToken $push_token An instance of PushToken.
	 * @throws InvalidArgumentException If the token can't be updated.
	 * @throws PushTokenNotFoundException If the token can't be found.
	 * @throws Exception If the token update fails.
	 * @return void
	 */
	public function update( PushToken &$push_token ): void {
		if ( ! $push_token->can_be_updated() ) {
			throw new InvalidArgumentException(
				'Can\'t update push token because the push token data provided is invalid.'
			);
		}

		$post = get_post( $push_token->get_id() );

		if ( ! $post || PushToken::POST_TYPE !== $post->post_type ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
			throw new PushTokenNotFoundException( 'Push token could not be found.' );
		}

		$result = wp_update_post(
			array(
				'ID'          => (int) $push_token->get_id(),
				'post_author' => (int) $push_token->get_user_id(),
				'post_type'   => PushToken::POST_TYPE,
				'post_status' => 'private',
				'meta_input'  => $this->build_meta_array_from_token( $push_token ),
			),
			true
		);

		if ( is_wp_error( $result ) ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
			throw new Exception( $result->get_error_message() );
		}

		if ( null === $push_token->get_device_uuid() ) {
			delete_post_meta( (int) $push_token->get_id(), 'device_uuid' );
		}
	}

	/**
	 * Deletes a push token.
	 *
	 * @since 10.5.0
	 * @param PushToken $push_token An instance of PushToken.
	 * @throws InvalidArgumentException If the token can't be deleted.
	 * @throws PushTokenNotFoundException If the token can't be found.
	 * @return void
	 */
	public function delete( PushToken &$push_token ): void {
		if ( ! $push_token->can_be_deleted() ) {
			throw new InvalidArgumentException(
				'Can\'t delete push token because the push token data provided is invalid.'
			);
		}

		$post = get_post( $push_token->get_id() );

		if ( ! $post || PushToken::POST_TYPE !== $post->post_type ) {
			throw new PushTokenNotFoundException( 'Push token could not be found.' );
		}

		wp_delete_post( (int) $push_token->get_id(), true );
	}

	/**
	 * Find tokens for this user and platform that match either the token
	 * or device UUID. We check the token value to avoid creating a duplicate.
	 * We check the device UUID value because only one token should be issued
	 * per device, therefore if we already have one then we can update it to
	 * avoid creating a duplicate.
	 *
	 * @since 10.5.0
	 * @param PushToken $push_token An instance of PushToken.
	 * @return null|PushToken
	 * @throws InvalidArgumentException If push token is missing data.
	 */
	public function get_by_token_or_device_id( PushToken &$push_token ): ?PushToken {
		if (
			! $push_token->get_user_id()
			|| ! $push_token->get_platform()
			|| ! $push_token->get_origin()
			|| (
				/**
				 * Platforms iOS and Android require token OR device UUID.
				 */
				$push_token->get_platform() !== PushToken::PLATFORM_BROWSER
				&& ! $push_token->get_token()
				&& ! $push_token->get_device_uuid()
			)
			|| (
				/**
				 * Browsers don't have device UUIDs, so require token.
				 */
				$push_token->get_platform() === PushToken::PLATFORM_BROWSER
				&& ! $push_token->get_token()
			)
		) {
			throw new InvalidArgumentException(
				'Can\'t retrieve push token because the push token data provided is invalid.'
			);
		}

		$query = new WP_Query(
			array(
				'post_type'      => PushToken::POST_TYPE,
				'post_status'    => 'private',
				'author'         => $push_token->get_user_id(),
				'posts_per_page' => -1,
				'orderby'        => 'ID',
				'order'          => 'DESC',
				'fields'         => 'ids',
			)
		);

		$post_ids = $query->posts;

		if ( empty( $post_ids ) ) {
			return null;
		}

		update_meta_cache( 'post', $post_ids );

		foreach ( $post_ids as $post_id ) {
			$candidate = new PushToken();
			$candidate->set_id( $post_id );

			try {
				$meta = $this->build_meta_array_from_database( $candidate );
			} catch ( Exception $e ) {
				wc_get_logger()->warning(
					'Failed to load meta for push token.',
					array(
						'token_id' => $post_id,
						'error'    => $e->getMessage(),
					)
				);

				continue;
			}

			if (
				$meta['platform'] === $push_token->get_platform()
				&& $meta['origin'] === $push_token->get_origin()
				&& (
					( $push_token->get_token() && $push_token->get_token() === $meta['token'] )
					|| ( $push_token->get_device_uuid() && $push_token->get_device_uuid() === $meta['device_uuid'] )
				)
			) {
				$push_token->set_id( $post_id );
				$push_token->set_token( $meta['token'] );
				$push_token->set_device_uuid( $meta['device_uuid'] );
				return $push_token;
			}
		}

		return null;
	}

	/**
	 * Returns an associative array of post meta as key => value pairs for the
	 * keys defined in SUPPORTED_META; missing keys return null.
	 *
	 * @since 10.5.0
	 * @param PushToken $push_token An instance of PushToken.
	 * @return array
	 * @throws InvalidArgumentException If the token can't be read.
	 */
	private function build_meta_array_from_database( PushToken &$push_token ) {
		if ( ! $push_token->can_be_read() ) {
			throw new InvalidArgumentException(
				'Can\'t read meta for push token because the push token data provided is invalid.'
			);
		}

		$meta        = (array) get_post_meta( (int) $push_token->get_id() );
		$meta_by_key = (array) array_combine( static::SUPPORTED_META, static::SUPPORTED_META );

		foreach ( static::SUPPORTED_META as $key ) {
			if ( ! isset( $meta[ $key ] ) ) {
				$meta_by_key[ $key ] = null;
			} elseif ( is_array( $meta[ $key ] ) ) {
				$meta_by_key[ $key ] = $meta[ $key ][0];
			} else {
				$meta_by_key[ $key ] = $meta[ $key ];
			}
		}

		return $meta_by_key;
	}

	/**
	 * Returns an associative array of post meta as key => value pairs, built
	 * using push token properties.
	 *
	 * @since 10.5.0
	 * @param PushToken $push_token An instance of PushToken.
	 * @return array
	 * @throws InvalidArgumentException If the token can't be read.
	 */
	private function build_meta_array_from_token( PushToken &$push_token ) {
		return array_filter(
			array(
				'platform'    => $push_token->get_platform(),
				'token'       => $push_token->get_token(),
				'device_uuid' => $push_token->get_device_uuid(),
				'origin'      => $push_token->get_origin(),
			)
		);
	}
}
PK       [1]L.	  	                  Integrations/WPConsentAPI.phpnu         PK       [1]E~<                 
  Integrations/WPPostsImporter.phpnu         PK       [1]|                    Utilities/URLException.phpnu         PK       [1]_F8  8                Utilities/PluginInstaller.phpnu         PK       [1]R`                ^K  Utilities/FilesystemUtil.phpnu         PK       [1],~                `a  Utilities/LegacyRestApiStub.phpnu         PK       [1]=k4  k4              F|  Utilities/URL.phpnu         PK       [1])aP%  P%                Utilities/Users.phpnu         PK       [1]                  Utilities/Types.phpnu         PK       [1]kc@  c@                Utilities/DatabaseUtil.phpnu         PK       [1]ަC  C              Y Utilities/ArrayUtil.phpnu         PK       [1]W"o	  o	              + Utilities/BlocksUtil.phpnu         PK       [1]c  c              5 Utilities/HtmlSanitizer.phpnu         PK       [1]^&  &              HB Utilities/COTMigrationUtil.phpnu         PK       [1]ZD                Z Utilities/ProductUtil.phpnu         PK       [1]Йy  y              _ Utilities/WebhookUtil.phpnu         PK       [1]u&P!  P!              ou OrderCouponDataMigrator.phpnu         PK       [1]jMr0  0  +            
 FraudProtection/SessionClearanceManager.phpnu         PK       [1]U  U  (             FraudProtection/SessionDataCollector.phpnu         PK       [1]|  |  ,             FraudProtection/JetpackConnectionManager.phpnu         PK       [1]!    (            z FraudProtection/AdminSettingsHandler.phpnu         PK       [1]ّ    *            Q# FraudProtection/FraudProtectionTracker.phpnu         PK       [1]U                -, FraudProtection/ApiClient.phpnu         PK       [1]B7    -            E FraudProtection/FraudProtectionController.phpnu         PK       [1]*I=  =  #            rX FraudProtection/DecisionHandler.phpnu         PK       [1]2.Z    -            l FraudProtection/FraudProtectionDispatcher.phpnu         PK       [1]
gj"  j"  (            m~ FraudProtection/CheckoutEventTracker.phpnu         PK       [1]z	P  P  (            / FraudProtection/BlockedSessionNotice.phpnu         PK       [1]    $            ײ FraudProtection/CartEventTracker.phpnu         PK       [1]b    -             FraudProtection/PaymentMethodEventTracker.phpnu         PK       [1]`:  :              v Traits/ScriptDebug.phpnu         PK       [1]
	V  V               Traits/RestApiCache.phpnu         PK       [1]    #             Traits/AccessiblePrivateMethods.phpnu         PK       [1]'  '              w Traits/OrderAttributionMeta.phpnu         PK       [1]LF                  Settings/OptionSanitizer.phpnu         PK       [1]m    '             Settings/PointOfSaleDefaultSettings.phpnu         PK       [1]̫                 F ProductImage/MatchImageBySKU.phpnu         PK       [1]TS}                A AssignDefaultCategory.phpnu         PK       [1]M\    
            [ Brands.phpnu         PK       [1]V    #             Logging/SafeGlobalFunctionProxy.phpnu         PK       [1][["  ["  &             Logging/OrderLogsDeletionProcessor.phpnu         PK       [1]OTS  S               Logging/RemoteLogger.phpnu         PK       [1]!N*                g StockNotifications/Config.phpnu         PK       [1]˩RJ  J  *            6{ StockNotifications/StockSyncController.phpnu         PK       [1]܆;!  ;!  B            ڐ StockNotifications/Emails/CustomerStockNotificationVerifyEmail.phpnu         PK       [1]&    3             StockNotifications/Emails/EmailActionController.phpnu         PK       [1]@u    6            n StockNotifications/Emails/EmailTemplatesController.phpnu         PK       [1]6    D             StockNotifications/Emails/CustomerStockNotificationVerifiedEmail.phpnu         PK       [1]i:!  :!  <            7 StockNotifications/Emails/CustomerStockNotificationEmail.phpnu         PK       [1]S   S   *             StockNotifications/Emails/EmailManager.phpnu         PK       [1] 
  
  ,            : StockNotifications/Privacy/PrivacyEraser.phpnu         PK       [1]yҳ    ,            E StockNotifications/Admin/MenusController.phpnu         PK       [1]8F
  
  .            Q StockNotifications/Admin/NotificationsPage.phpnu         PK       [1]JQ+N$  N$  /            \ StockNotifications/Admin/SettingsController.phpnu         PK       [1]5b    1             StockNotifications/Admin/NotificationEditPage.phpnu         PK       [1]1&MM  M  )            Ǔ StockNotifications/Admin/AdminManager.phpnu         PK       [1]ޣSZ  Z  3            m StockNotifications/Admin/NotificationCreatePage.phpnu         PK       [1]9Ҍg  g  &            * StockNotifications/Admin/ListTable.phpnu         PK       [1]i1  1  >             StockNotifications/Admin/Templates/html-product-data-admin.phpnu         PK       [1]Z2q  q  E             StockNotifications/Admin/Templates/html-admin-notification-create.phpnu         PK       [1]7$%(  %(  C            2 StockNotifications/Admin/Templates/html-admin-notification-edit.phpnu         PK       [1]Αv  v  ?            )[ StockNotifications/Admin/Templates/html-admin-notifications.phpnu         PK       [1]=F    /            d StockNotifications/Enums/NotificationStatus.phpnu         PK       [1]Ͱ    ;            Bj StockNotifications/Enums/NotificationCancellationSource.phpnu         PK       [1]"¸    ,            ]m StockNotifications/AsyncTasks/JobManager.phpnu         PK       [1]    3            q} StockNotifications/AsyncTasks/CycleStateService.phpnu         PK       [1]?^f    8             StockNotifications/AsyncTasks/NotificationsProcessor.phpnu         PK       [1]ڻB4  4  #            ժ StockNotifications/Notification.phpnu         PK       [1]YC    (             StockNotifications/NotificationQuery.phpnu         PK       [1]ar                 StockNotifications/Factory.phpnu         PK       [1]=qf    6             StockNotifications/Frontend/ProductPageIntegration.phpnu         PK       [1]a^Y    =            ?	 StockNotifications/Frontend/NotificationManagementService.phpnu         PK       [1]TX'    ,             StockNotifications/Frontend/SignupResult.phpnu         PK       [1]6[  [  2             StockNotifications/Frontend/FormHandlerService.phpnu         PK       [1]]_G  _G  -             StockNotifications/Frontend/SignupService.phpnu         PK       [1]CR	  	  )            te StockNotifications/StockNotifications.phpnu         PK       [1]i/-b  b  .            o StockNotifications/DataRetentionController.phpnu         PK       [1]?Z`  `  3            n| StockNotifications/Utilities/EligibilityService.phpnu         PK       [1])v    -            1 StockNotifications/Utilities/HasherHelper.phpnu         PK       [1]W    6             StockNotifications/Utilities/StockManagementHelper.phpnu         PK       [1]F)6  6               EmailEditor/Integration.phpnu         PK       [1]cf    "            t EmailEditor/BlockEmailRenderer.phpnu         PK       [1]},G    8            V EmailEditor/PersonalizationTags/CustomerTagsProvider.phpnu         PK       [1]f1NZ'  '  4              EmailEditor/PersonalizationTags/SiteTagsProvider.phpnu         PK       [1]1TKa!  a!  5            < EmailEditor/PersonalizationTags/OrderTagsProvider.phpnu         PK       [1]ʺSM  M  5            * EmailEditor/PersonalizationTags/StoreTagsProvider.phpnu         PK       [1],G99  9  7            6 EmailEditor/PersonalizationTags/AbstractTagProvider.phpnu         PK       [1]sn8"  8"  "            T9 EmailEditor/EmailApiController.phpnu         PK       [1]w5jaF	  F	  4            [ EmailEditor/EmailTemplates/TemplateApiController.phpnu         PK       [1]+(  (  /            e EmailEditor/EmailTemplates/WooEmailTemplate.phpnu         PK       [1]8    2            t EmailEditor/EmailTemplates/TemplatesController.phpnu         PK       [1]8l=[R  R  #             EmailEditor/WooContentProcessor.phpnu         PK       [1]^U<    )             EmailEditor/PersonalizationTagManager.phpnu         PK       [1] "  "  H            ݔ EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.phpnu         PK       [1](  (  F            R EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsManager.phpnu         PK       [1]bG    ;            t EmailEditor/WCTransactionalEmails/WCTransactionalEmails.phpnu         PK       [1]&cEl
  l
  4             EmailEditor/EmailPatterns/WooEmailContentPattern.phpnu         PK       [1]Bm    0             EmailEditor/EmailPatterns/PatternsController.phpnu         PK       [1]Yx  x               EmailEditor/PageRenderer.phpnu         PK       [1]N6                	 EmailEditor/Package.phpnu         PK       [1]"3    .            	 EmailEditor/TransactionalEmailPersonalizer.phpnu         PK       [1]^Pi  i              !	 EmailEditor/Logger.phpnu         PK       [1]9B  B  +            V2	 ReceiptRendering/ReceiptRenderingEngine.phpnu         PK       [1]70ݟ    3            u	 ReceiptRendering/ReceiptRenderingRestController.phpnu         PK       [1].5    0            	 ReceiptRendering/Templates/order-receipt-css.phpnu         PK       [1]a
  
  ,            ϖ	 ReceiptRendering/Templates/order-receipt.phpnu         PK       [1]K
  
  I            1	 Features/ProductBlockEditor/ProductTemplates/DownloadableProductTrait.phpnu         PK       [1]а    F            /	 Features/ProductBlockEditor/ProductTemplates/SimpleProductTemplate.phpnu         PK       [1]    ;            YA
 Features/ProductBlockEditor/ProductTemplates/Subsection.phpnu         PK       [1]QM͈	  	  8            uH
 Features/ProductBlockEditor/ProductTemplates/Section.phpnu         PK       [1]Vؚ    L            eR
 Features/ProductBlockEditor/ProductTemplates/AbstractProductFormTemplate.phpnu         PK       [1]P;V  V  =            ]
 Features/ProductBlockEditor/ProductTemplates/ProductBlock.phpnu         PK       [1]MeA  A  I            a
 Features/ProductBlockEditor/ProductTemplates/ProductVariationTemplate.phpnu         PK       [1]xB	  	  6            
 Features/ProductBlockEditor/ProductTemplates/Group.phpnu         PK       [1][P P             ͭ
 Features/FeaturesController.phpnu         PK       [1]p"  "  )             DependencyManagement/RuntimeContainer.phpnu         PK       [1]w    +            ! DependencyManagement/ContainerException.phpnu         PK       [1]}V'^  ^              $ Jetpack/JetpackConnection.phpnu         PK       [1]MQH    -            2 RestApi/Routes/V4/AbstractCollectionQuery.phpnu         PK       [1];  ;  =            : RestApi/Routes/V4/ShippingZoneMethod/ShippingMethodSchema.phpnu         PK       [1]q-  -  3            J RestApi/Routes/V4/ShippingZoneMethod/Controller.phpnu         PK       [1]:    B            2x RestApi/Routes/V4/ShippingZoneMethod/ShippingZoneMethodService.phpnu         PK       [1]u3 3 )             RestApi/Routes/V4/Products/Controller.phpnu         PK       [1]XG  G  7             RestApi/Routes/V4/OrderNotes/Schema/OrderNoteSchema.phpnu         PK       [1]Y1-  1-  +            B RestApi/Routes/V4/OrderNotes/Controller.phpnu         PK       [1]Y	  	  0             RestApi/Routes/V4/OrderNotes/CollectionQuery.phpnu         PK       [1]P4C|  |  -             RestApi/Routes/V4/Orders/ActionController.phpnu         PK       [1]5aB  aB  '             RestApi/Routes/V4/Orders/Controller.phpnu         PK       [1]og)0  )0  ,            y] RestApi/Routes/V4/Orders/CollectionQuery.phpnu         PK       [1]T.E  .E  (             RestApi/Routes/V4/Orders/UpdateUtils.phpnu         PK       [1]e  e  :             RestApi/Routes/V4/Orders/Schema/AbstractLineItemSchema.phpnu         PK       [1]>Cn  n  /            S RestApi/Routes/V4/Orders/Schema/OrderSchema.phpnu         PK       [1]j^    2            S RestApi/Routes/V4/Orders/Schema/OrderTaxSchema.phpnu         PK       [1]`'  `'  3            a RestApi/Routes/V4/Orders/Schema/OrderItemSchema.phpnu         PK       [1]u^  ^  2             RestApi/Routes/V4/Orders/Schema/OrderFeeSchema.phpnu         PK       [1]N    7            e RestApi/Routes/V4/Orders/Schema/OrderShippingSchema.phpnu         PK       [1]L%  %  5            [ RestApi/Routes/V4/Orders/Schema/OrderCouponSchema.phpnu         PK       [1]	_	  	  $             RestApi/Routes/V4/AbstractSchema.phpnu         PK       [1]\Q    7             RestApi/Routes/V4/ShippingZones/ShippingZoneService.phpnu         PK       [1]W5E    6            ; RestApi/Routes/V4/ShippingZones/ShippingZoneSchema.phpnu         PK       [1])(Z%  Z%  .            i RestApi/Routes/V4/ShippingZones/Controller.phpnu         PK       [1]}(#  #  9            ! RestApi/Routes/V4/Settings/PaymentGateways/Controller.phpnu         PK       [1]#LQ  Q  Z            L7 RestApi/Routes/V4/Settings/PaymentGateways/Schema/AbstractPaymentGatewaySettingsSchema.phpnu         PK       [1]/1b  b  N             RestApi/Routes/V4/Settings/PaymentGateways/Schema/CodGatewaySettingsSchema.phpnu         PK       [1]TPV  V  R             RestApi/Routes/V4/Settings/PaymentGateways/Schema/PaymentGatewaySettingsSchema.phpnu         PK       [1]    O            Y RestApi/Routes/V4/Settings/PaymentGateways/Schema/BacsGatewaySettingsSchema.phpnu         PK       [1]3Cn#  #  0             RestApi/Routes/V4/Settings/Emails/Controller.phpnu         PK       [1]A
M  
M  A            7 RestApi/Routes/V4/Settings/Emails/Schema/EmailsSettingsSchema.phpnu         PK       [1]ICS3  S3  1             RestApi/Routes/V4/Settings/General/Controller.phpnu         PK       [1]D(  (  C            fN RestApi/Routes/V4/Settings/General/Schema/GeneralSettingsSchema.phpnu         PK       [1]+d;vw#  w#  C            w RestApi/Routes/V4/Settings/Account/Schema/AccountSettingsSchema.phpnu         PK       [1]U(  (  1             RestApi/Routes/V4/Settings/Account/Controller.phpnu         PK       [1]<m  m  V            " RestApi/Routes/V4/Settings/OfflinePaymentMethods/Schema/OfflinePaymentMethodSchema.phpnu         PK       [1]^Z!  Z!  ?             RestApi/Routes/V4/Settings/OfflinePaymentMethods/Controller.phpnu         PK       [1],sA1.  1.  /             RestApi/Routes/V4/Settings/Email/Controller.phpnu         PK       [1]̦C    ?            n* RestApi/Routes/V4/Settings/Email/Schema/EmailSettingsSchema.phpnu         PK       [1]ɥg)*  )*  2            jJ RestApi/Routes/V4/Settings/Products/Controller.phpnu         PK       [1]x&  &  D            t RestApi/Routes/V4/Settings/Products/Schema/ProductSettingsSchema.phpnu         PK       [1]K?"  ?"  ;             RestApi/Routes/V4/Settings/Tax/Schema/TaxSettingsSchema.phpnu         PK       [1]S'  '  -            0 RestApi/Routes/V4/Settings/Tax/Controller.phpnu         PK       [1]֧(  (  (            0 RestApi/Routes/V4/AbstractController.phpnu         PK       [1](o{J  {J  -            ^ RestApi/Routes/V4/Fulfillments/Controller.phpnu         PK       [1]SB    ;            6Z RestApi/Routes/V4/Fulfillments/Schema/FulfillmentSchema.phpnu         PK       [1]`B6i@  i@  *            m RestApi/Routes/V4/Customers/Controller.phpnu         PK       [1]ݲ*  *  .             RestApi/Routes/V4/Customers/CustomerSchema.phpnu         PK       [1](     /             RestApi/Routes/V4/Customers/CollectionQuery.phpnu         PK       [1]2\    +             RestApi/Routes/V4/Customers/UpdateUtils.phpnu         PK       [1]-=  =  1             RestApi/Routes/V4/Refunds/Schema/RefundSchema.phpnu         PK       [1]֐9  9  (            KD RestApi/Routes/V4/Refunds/Controller.phpnu         PK       [1]~'8K  K  -            3~ RestApi/Routes/V4/Refunds/CollectionQuery.phpnu         PK       [1]i[      '            ۑ RestApi/Routes/V4/Refunds/DataUtils.phpnu         PK       [1]3JVO%  O%  '             ComingSoon/ComingSoonRequestHandler.phpnu         PK       [1].KŌ    )             ComingSoon/ComingSoonCacheInvalidator.phpnu         PK       [1];    &            s ComingSoon/ComingSoonAdminBarBadge.phpnu         PK       [1]f  f              J ComingSoon/ComingSoonHelper.phpnu         PK       [1]cOb%  %  5             AddressProvider/AbstractAutomatticAddressProvider.phpnu         PK       [1]*    -            E AddressProvider/AddressProviderController.phpnu         PK       [1]lɭ                - Customers/SearchService.phpnu         PK       [1]ypr    #            4 Agentic/Enums/Specs/MessageType.phpnu         PK       [1]t?  ?  '            6 Agentic/Enums/Specs/FulfillmentType.phpnu         PK       [1]ܙR  R  *            48 Agentic/Enums/Specs/MessageContentType.phpnu         PK       [1]    !            9 Agentic/Enums/Specs/TotalType.phpnu         PK       [1]99                 -= Agentic/Enums/Specs/LinkType.phpnu         PK       [1]C    #             ? Agentic/Enums/Specs/OrderStatus.phpnu         PK       [1]-i      %            D Agentic/Enums/Specs/PaymentMethod.phpnu         PK       [1]qJZm  m  -            RE Agentic/Enums/Specs/CheckoutSessionStatus.phpnu         PK       [1]#
s    !            I Agentic/Enums/Specs/ErrorCode.phpnu         PK       [1]
  
  !            L Agentic/Enums/Specs/ErrorType.phpnu         PK       [1]]    '            mN Agentic/Enums/Specs/PaymentProvider.phpnu         PK       [1]ie  e  "            O Agentic/Enums/Specs/RefundType.phpnu         PK       [1]U6
  6
  $            Q Orders/OrderStatusRestController.phpnu         PK       [1]9                \ Orders/CardIcons/jcb.svgnu         PK       [1]D8n0  n0              Zs Orders/CardIcons/mastercard.svgnu         PK       [1]B                 Orders/CardIcons/diners.svgnu         PK       [1]z:{  {              X Orders/CardIcons/unknown.svgnu         PK       [1]d                 Orders/CardIcons/visa.svgnu         PK       [1]b}                5 Orders/CardIcons/interac.svgnu         PK       [1](  (              < Orders/CardIcons/amex.svgnu         PK       [1]P  P              D Orders/CardIcons/discover.svgnu         PK       [1]1K                 Orders/IppFunctions.phpnu         PK       [1]x  x  +             Orders/OrderAttributionBlocksController.phpnu         PK       [1]+    !            ' Orders/MobileMessagingHandler.phpnu         PK       [1]Uu35B  5B  %            > Orders/OrderAttributionController.phpnu         PK       [1]3Ŗ                 Orders/OrderNoteGroup.phpnu         PK       [1]2  2              z Orders/TaxesController.phpnu         PK       [1](HZ                 Orders/PointOfSaleOrderUtil.phpnu         PK       [1]QuB                ) Orders/PaymentInfo.phpnu         PK       [1]_:4I  I              ! Orders/CouponsController.phpnu         PK       [1]e+T  +T  %             Orders/OrderActionsRestController.phpnu         PK       [1]5  5  $            6 ProductAttributesLookup/Filterer.phpnu         PK       [1]=!    +            R= ProductAttributesLookup/LookupDataStore.phpnu         PK       [1]C]S  ]S  %             ProductAttributesLookup/CLIRunner.phpnu         PK       [1]ZY  Y  +            `) ProductAttributesLookup/DataRegenerator.phpnu         PK       [1]:	  	               ProductFeed/ProductFeed.phpnu         PK       [1]h    +             ProductFeed/Feed/ProductMapperInterface.phpnu         PK       [1]    "            1 ProductFeed/Feed/ProductLoader.phpnu         PK       [1]B    "            P ProductFeed/Feed/ProductWalker.phpnu         PK       [1]H(&    #            " ProductFeed/Feed/WalkerProgress.phpnu         PK       [1]SbdZ  Z  +            a ProductFeed/Feed/FeedValidatorInterface.phpnu         PK       [1]Xc    "             ProductFeed/Feed/FeedInterface.phpnu         PK       [1]^̚
  
  6            C ProductFeed/Integrations/POSCatalog/POSIntegration.phpnu         PK       [1]?    5            C ProductFeed/Integrations/POSCatalog/ApiController.phpnu         PK       [1]L9+  +  6            r ProductFeed/Integrations/POSCatalog/AsyncGenerator.phpnu         PK       [1]GW  W  5             ProductFeed/Integrations/POSCatalog/FeedValidator.phpnu         PK       [1]i_    5            o ProductFeed/Integrations/POSCatalog/ProductMapper.phpnu         PK       [1]     @             ProductFeed/Integrations/POSCatalog/POSProductVisibilitySync.phpnu         PK       [1]0#%=  =  1            j) ProductFeed/Integrations/IntegrationInterface.phpnu         PK       [1]Z5  5  0            2 ProductFeed/Integrations/IntegrationRegistry.phpnu         PK       [1]7$  $  $            7 ProductFeed/Storage/JsonFileFeed.phpnu         PK       [1]2SC    #            R ProductFeed/Utils/MemoryManager.phpnu         PK       [1]q;    "            ^ ProductFeed/Utils/StringHelper.phpnu         PK       [1][!
  
  3            c CLI/Migrator/Interfaces/PlatformMapperInterface.phpnu         PK       [1]e    4            f CLI/Migrator/Interfaces/PlatformFetcherInterface.phpnu         PK       [1]A|l    (            l CLI/Migrator/Core/ProductsController.phpnu         PK       [1]6]9&  &  &            o CLI/Migrator/Core/PlatformRegistry.phpnu         PK       [1]m)  )  '            } CLI/Migrator/Core/CredentialManager.phpnu         PK       [1]3~6  6  0            & CLI/Migrator/Core/WooCommerceProductImporter.phpnu         PK       [1] .  .  %             CLI/Migrator/Core/MigratorTracker.phpnu         PK       [1]s                 CLI/Migrator/Runner.phpnu         PK       [1]$}J  }J  "             CLI/Migrator/Lib/ImportSession.phpnu         PK       [1]|      0            P CLI/Migrator/Platforms/Shopify/ShopifyClient.phpnu         PK       [1]N[?m  m  0            p CLI/Migrator/Platforms/Shopify/ShopifyMapper.phpnu         PK       [1]=~:  :  2            c CLI/Migrator/Platforms/Shopify/ShopifyPlatform.phpnu         PK       [1]܉f$  $  1             CLI/Migrator/Platforms/Shopify/ShopifyFetcher.phpnu         PK       [1]M    &            	 CLI/Migrator/Commands/ResetCommand.phpnu         PK       [1]Vi_    )             CLI/Migrator/Commands/ProductsCommand.phpnu         PK       [1]    %            +, CLI/Migrator/Commands/ListCommand.phpnu         PK       [1]q1  1  &            4 CLI/Migrator/Commands/SetupCommand.phpnu         PK       [1]f{                = WCCom/ConnectionHelper.phpnu         PK       [1]G)    '            @ Fulfillments/FulfillmentsController.phpnu         PK       [1]O2=  =  $            6X Fulfillments/FulfillmentsManager.phpnu         PK       [1];                k Fulfillments/Fulfillment.phpnu         PK       [1]x    %            P Fulfillments/FulfillmentsSettings.phpnu         PK       [1]B[  [  !             Fulfillments/FulfillmentUtils.phpnu         PK       [1]}&p  p  0            ~' Fulfillments/OrderFulfillmentsRestController.phpnu         PK       [1]SRE{    7            N Fulfillments/Providers/DeutschePostShippingProvider.phpnu         PK       [1]~#e    4            м Fulfillments/Providers/StarTrackShippingProvider.phpnu         PK       [1]v      8              Fulfillments/Providers/AustraliaPostShippingProvider.phpnu         PK       [1]2    2            1 Fulfillments/Providers/HayPostShippingProvider.phpnu         PK       [1]    9            r Fulfillments/Providers/NewZealandPostShippingProvider.phpnu         PK       [1]Y`
  
  7             Fulfillments/Providers/YurticiKargoShippingProvider.phpnu         PK       [1]r    /            d Fulfillments/Providers/SeurShippingProvider.phpnu         PK       [1]ٿ>    5             Fulfillments/Providers/NovaPoshtaShippingProvider.phpnu         PK       [1]}	-  -  ;             Fulfillments/Providers/LaPosteColissimoShippingProvider.phpnu         PK       [1]&o
    8             Fulfillments/Providers/PostaMoldoveiShippingProvider.phpnu         PK       [1]<g    .              Fulfillments/Providers/SDAShippingProvider.phpnu         PK       [1]z"d    4             Fulfillments/Providers/MaltaPostShippingProvider.phpnu         PK       [1]vn    3            Y	 Fulfillments/Providers/HelthjemShippingProvider.phpnu         PK       [1]]A
    :             Fulfillments/Providers/MakedonskaPostaShippingProvider.phpnu         PK       [1]    4             Fulfillments/Providers/SwissPostShippingProvider.phpnu         PK       [1]Zn    0            t Fulfillments/Providers/BpostShippingProvider.phpnu         PK       [1]y+)    3             Fulfillments/Providers/AbstractShippingProvider.phpnu         PK       [1]    9            ' Fulfillments/Providers/PostLuxembourgShippingProvider.phpnu         PK       [1]j    5            T, Fulfillments/Providers/CyprusPostShippingProvider.phpnu         PK       [1]tP    5            0 Fulfillments/Providers/CeskaPostaShippingProvider.phpnu         PK       [1]؝    6            5 Fulfillments/Providers/ParcelForceShippingProvider.phpnu         PK       [1]\[    6            v9 Fulfillments/Providers/RussianPostShippingProvider.phpnu         PK       [1]]m    5            = Fulfillments/Providers/ZasilkovnaShippingProvider.phpnu         PK       [1]7    7            &B Fulfillments/Providers/PocztaPolskaShippingProvider.phpnu         PK       [1]K{+  +  A            F Fulfillments/Providers/LiechtensteinischePostShippingProvider.phpnu         PK       [1]j+    :            0K Fulfillments/Providers/SpeeDeeDeliveryShippingProvider.phpnu         PK       [1]S    3            O Fulfillments/Providers/PostNordShippingProvider.phpnu         PK       [1]m+    5            S Fulfillments/Providers/FanCourierShippingProvider.phpnu         PK       [1]5    :            XX Fulfillments/Providers/AmazonLogisticsShippingProvider.phpnu         PK       [1]N=    7            q Fulfillments/Providers/UrgentCargusShippingProvider.phpnu         PK       [1]j    7            6v Fulfillments/Providers/MondialRelayShippingProvider.phpnu         PK       [1]GXhZ    5            z Fulfillments/Providers/ChronopostShippingProvider.phpnu         PK       [1]9    2            ~ Fulfillments/Providers/CorreosShippingProvider.phpnu         PK       [1]~    5            o Fulfillments/Providers/ACSCourierShippingProvider.phpnu         PK       [1]LH    .            և Fulfillments/Providers/MRWShippingProvider.phpnu         PK       [1]h%  %  9             Fulfillments/Providers/BulgarianPostsShippingProvider.phpnu         PK       [1]ƻ    6             Fulfillments/Providers/PostaRomanaShippingProvider.phpnu         PK       [1]8Y    3             Fulfillments/Providers/AzerpostShippingProvider.phpnu         PK       [1]:/
    1            = Fulfillments/Providers/AnPostShippingProvider.phpnu         PK       [1]vY    4             Fulfillments/Providers/PurolatorShippingProvider.phpnu         PK       [1]V4    0            ߡ Fulfillments/Providers/FedExShippingProvider.phpnu         PK       [1]M	    7            2 Fulfillments/Providers/BartoliniBRTShippingProvider.phpnu         PK       [1]|    /             Fulfillments/Providers/CDEKShippingProvider.phpnu         PK       [1]!4    ;             Fulfillments/Providers/PostenNorgeBringShippingProvider.phpnu         PK       [1]5'_    <            _ Fulfillments/Providers/GenikiTaxydromikiShippingProvider.phpnu         PK       [1]M2PӨ    .             Fulfillments/Providers/UPSShippingProvider.phpnu         PK       [1]-    4             Fulfillments/Providers/BelpochtaShippingProvider.phpnu         PK       [1]{C    2            5 Fulfillments/Providers/EimskipShippingProvider.phpnu         PK       [1]    0            s Fulfillments/Providers/EcontShippingProvider.phpnu         PK       [1]_    .             Fulfillments/Providers/CTTShippingProvider.phpnu         PK       [1])    1             Fulfillments/Providers/OmnivaShippingProvider.phpnu         PK       [1]JrT"  T"  5            . Fulfillments/Providers/CanadaPostShippingProvider.phpnu         PK       [1]s"/    2            # Fulfillments/Providers/FastwayShippingProvider.phpnu         PK       [1]h    /            %( Fulfillments/Providers/ELTAShippingProvider.phpnu         PK       [1](~0  0  .            M, Fulfillments/Providers/DPDShippingProvider.phpnu         PK       [1]&I-    9            X] Fulfillments/Providers/PosteSanMarinoShippingProvider.phpnu         PK       [1]\[  [  5            a Fulfillments/Providers/EvriHermesShippingProvider.phpnu         PK       [1]e_J    8            y Fulfillments/Providers/PosteItalianeShippingProvider.phpnu         PK       [1]g(    9            ~ Fulfillments/Providers/SlovenskaPostaShippingProvider.phpnu         PK       [1]S    2            n Fulfillments/Providers/KazpostShippingProvider.phpnu         PK       [1]̌    >             Fulfillments/Providers/OsterreichischePostShippingProvider.phpnu         PK       [1]Sه    .            7 Fulfillments/Providers/MPLShippingProvider.phpnu         PK       [1]J݈    /            ] Fulfillments/Providers/USPSShippingProvider.phpnu         PK       [1]	      6            D Fulfillments/Providers/MatkahuoltoShippingProvider.phpnu         PK       [1]SD'    /            ʬ Fulfillments/Providers/TollShippingProvider.phpnu         PK       [1]#    4             Fulfillments/Providers/UkrposhtaShippingProvider.phpnu         PK       [1]ϥ    8            E Fulfillments/Providers/HrvatskaPostaShippingProvider.phpnu         PK       [1]{(h    1             Fulfillments/Providers/InPostShippingProvider.phpnu         PK       [1]u    .             Fulfillments/Providers/GLSShippingProvider.phpnu         PK       [1]V&p$  $  4             Fulfillments/Providers/RoyalMailShippingProvider.phpnu         PK       [1] y    1             Fulfillments/Providers/PostNLShippingProvider.phpnu         PK       [1]?XG    6             Fulfillments/Providers/MagyarPostaShippingProvider.phpnu         PK       [1]!c:  :  8             Fulfillments/Providers/IslandsposturShippingProvider.phpnu         PK       [1]s@  @  .             Fulfillments/Providers/DHLShippingProvider.phpnu         PK       [1]-3	  	  4            \  Fulfillments/Providers/ArasKargoShippingProvider.phpnu         PK       [1]5    8              Fulfillments/Providers/LatvijasPastsShippingProvider.phpnu         PK       [1]i_    :            0  Fulfillments/Providers/LasershipOntracShippingProvider.phpnu         PK       [1]Va    1              Fulfillments/Providers/ItellaShippingProvider.phpnu         PK       [1];A  A  %            !  Fulfillments/FulfillmentException.phpnu         PK       [1]I!4P  4P  %            &  Fulfillments/FulfillmentsRenderer.phpnu         PK       [1]uW  W  "            w  Fulfillments/ShippingProviders.phpnu         PK       [1]Z$Id  d  *              MCP/Transport/WooCommerceRestTransport.phpnu         PK       [1]SO                s  MCP/MCPAdapterProvider.phpnu         PK       [1]&e$O  $O              c  ProductFilters/QueryClauses.phpnu         PK       [1]26uh  h  %            ! ProductFilters/FilterDataProvider.phpnu         PK       [1]kt
  
              ! ProductFilters/Params.phpnu         PK       [1]LmK  K              #! ProductFilters/FilterData.phpnu         PK       [1]0M@  @  (            o! ProductFilters/TaxonomyHierarchyData.phpnu         PK       [1])    "            b! ProductFilters/CacheController.phpnu         PK       [1]Q    ,            m! ProductFilters/Interfaces/FilterUrlParam.phpnu         PK       [1]( 	    3            ښ! ProductFilters/Interfaces/QueryClausesGenerator.phpnu         PK       [1]D    7            ! ProductFilters/Interfaces/MainQueryClausesGenerator.phpnu         PK       [1]U׭    &            Ϡ! ProductFilters/MainQueryController.phpnu         PK       [1]=ke  e              ! McStats.phpnu         PK       [1]	+  +  /            D! CostOfGoodsSold/CogsAwareUnitTestSuiteTrait.phpnu         PK       [1]m<  <  "            ε! CostOfGoodsSold/CogsAwareTrait.phpnu         PK       [1]zgp  p  -            \! CostOfGoodsSold/CostOfGoodsSoldController.phpnu         PK       [1]sN?  ?  0            )! CostOfGoodsSold/CogsAwareRestControllerTrait.phpnu         PK       [1]:7    +            ! BatchProcessing/BatchProcessorInterface.phpnu         PK       [1]iW  W  -            (! BatchProcessing/BatchProcessingController.phpnu         PK       [1]Բa                JH" RegisterHooksInterface.phpnu         PK       [1][    $            J" Admin/ProductForm/ComponentTrait.phpnu         PK       [1]rH>  >  !            M" Admin/ProductForm/FormFactory.phpnu         PK       [1]^V                <k" Admin/ProductForm/Tab.phpnu         PK       [1]KMa                Wp" Admin/ProductForm/Component.phpnu         PK       [1]ua                (|" Admin/ProductForm/Section.phpnu         PK       [1]:i                   " Admin/ProductForm/Subsection.phpnu         PK       [1](č                %" Admin/ProductForm/Field.phpnu         PK       [1]/^rPF  PF              " Admin/WCAdminAssets.phpnu         PK       [1]!    #            " Admin/Agentic/AgenticController.phpnu         PK       [1]^A"q  q  ,            " Admin/Agentic/AgenticCommerceIntegration.phpnu         PK       [1].A    .            " Admin/Agentic/AgenticWebhookPayloadBuilder.phpnu         PK       [1]VOx%  x%  %            " Admin/Agentic/AgenticSettingsPage.phpnu         PK       [1]ƯKb      '            # Admin/Agentic/AgenticWebhookManager.phpnu         PK       [1]e    )            9# Admin/ShippingLabelBannerDisplayRules.phpnu         PK       [1])}ξ    +            H# Admin/Emails/EmailListingRestController.phpnu         PK       [1]Mш"  "              _# Admin/Homescreen.phpnu         PK       [1]    &            # Admin/Onboarding/OnboardingProfile.phpnu         PK       [1]    #            # Admin/Onboarding/OnboardingSync.phpnu         PK       [1]\  \  '            b# Admin/Onboarding/OnboardingProducts.phpnu         PK       [1]hȣ*  *  *            # Admin/Onboarding/OnboardingSetupWizard.phpnu         PK       [1]O    )            # Admin/Onboarding/OnboardingIndustries.phpnu         PK       [1]	t  t  %            # Admin/Onboarding/OnboardingHelper.phpnu         PK       [1]i@&N    (            # Admin/Onboarding/OnboardingMailchimp.phpnu         PK       [1]2)\b  b               $ Admin/Onboarding/Onboarding.phpnu         PK       [1]4  4  &            $ Admin/Onboarding/OnboardingJetpack.phpnu         PK       [1] J      &            $ Admin/BlockTemplates/BlockTemplate.phpnu         PK       [1]n                j$ Admin/BlockTemplates/Block.phpnu         PK       [1]j7    .            $ Admin/BlockTemplates/AbstractBlockTemplate.phpnu         PK       [1] j8  8  ,            $ Admin/BlockTemplates/BlockTemplateLogger.phpnu         PK       [1]    4            $Y$ Admin/BlockTemplates/BlockFormattedTemplateTrait.phpnu         PK       [1]rR(  R(  ,            e`$ Admin/BlockTemplates/BlockContainerTrait.phpnu         PK       [1]8#>#  #  &            $ Admin/BlockTemplates/AbstractBlock.phpnu         PK       [1]ym
  
              =$ Admin/WCPayPromotion/Init.phpnu         PK       [1]4e}R\
  \
  *            $ Admin/WCPayPromotion/DefaultPromotions.phpnu         PK       [1]:8    A            J$ Admin/WCPayPromotion/WCPaymentGatewayPreInstallWCPayPromotion.phpnu         PK       [1]=#w  w  7            $ Admin/WCPayPromotion/WCPayPromotionDataSourcePoller.phpnu         PK       [1]#nD\	  	              b$ Admin/CouponsMovedTrait.phpnu         PK       [1]B	  B	              S$ Admin/SiteHealth.phpnu         PK       [1]l#/  #/              $ Admin/Analytics.phpnu         PK       [1]&
  
  "            ?%% Admin/Marketing/MarketingSpecs.phpnu         PK       [1]\O4p  p              /% Admin/Coupons.phpnu         PK       [1]0>^    -            a;% Admin/EmailImprovements/EmailImprovements.phpnu         PK       [1][q:  :              LZ% Admin/Settings.phpnu         PK       [1]A8f  f              % Admin/SystemStatusReport.phpnu         PK       [1]^ڨ                X% Admin/WCAdminUser.phpnu         PK       [1]/DQ  Q              % Admin/WcPayWelcomePage.phpnu         PK       [1]ZOP  P              F% Admin/WCAdminSharedSettings.phpnu         PK       [1]kh]L  L              % Admin/Loader.phpnu         PK       [1]    $            0& Admin/Schedulers/ImportInterface.phpnu         PK       [1];׹-  -  '            3& Admin/Schedulers/CustomersScheduler.phpnu         PK       [1]Iav  v  '            ~B& Admin/Schedulers/MailchimpScheduler.phpnu         PK       [1]h.  .  $            KS& Admin/Schedulers/ImportScheduler.phpnu         PK       [1]Sz]  ]  $            f& Admin/Schedulers/OrdersScheduler.phpnu         PK       [1]"h  h  "            & Admin/Logging/LogHandlerFileV2.phpnu         PK       [1]8~{  {  (            & Admin/Logging/FileV2/SearchListTable.phpnu         PK       [1]t(d    &            & Admin/Logging/FileV2/FileListTable.phpnu         PK       [1]t+K  +K  '            ' Admin/Logging/FileV2/FileController.phpnu         PK       [1]A|5  5              Le' Admin/Logging/FileV2/File.phpnu         PK       [1],]9  9  %            Q' Admin/Logging/FileV2/FileExporter.phpnu         PK       [1]"-X  X               ߪ' Admin/Logging/PageController.phpnu         PK       [1]wA  A              ( Admin/Logging/Settings.phpnu         PK       [1]7E                F( Admin/CategoryLookup.phpnu         PK       [1]9b.  .              ?f( Admin/Translations.phpnu         PK       [1]_%  %  1            +( Admin/EmailPreview/EmailPreviewRestController.phpnu         PK       [1]?e9#b  b  #            x( Admin/EmailPreview/EmailPreview.phpnu         PK       [1]+    +            ) Admin/Orders/PostsRedirectionController.phpnu         PK       [1]?[A  [A              8) Admin/Orders/PageController.phpnu         PK       [1]PF                bz) Admin/Orders/ListTable.phpnu         PK       [1]b  b  *            a* Admin/Orders/MetaBoxes/CustomerHistory.phpnu         PK       [1]    +            h* Admin/Orders/MetaBoxes/OrderAttribution.phpnu         PK       [1]AB  AB  (            p* Admin/Orders/MetaBoxes/CustomMetaBox.phpnu         PK       [1]BP    ,            H* Admin/Orders/MetaBoxes/TaxonomiesMetaBox.phpnu         PK       [1]$
  
  )            * Admin/Orders/COTRedirectionController.phpnu         PK       [1]I3=?  =?              * Admin/Orders/Edit.phpnu         PK       [1]8Z  Z              + Admin/Orders/EditLock.phpnu         PK       [1]_    1            6.+ Admin/Settings/PaymentsProviders/NexiCheckout.phpnu         PK       [1]ʄ}  }  +            >+ Admin/Settings/PaymentsProviders/Mollie.phpnu         PK       [1]!    +            pY+ Admin/Settings/PaymentsProviders/Affirm.phpnu         PK       [1]bܒ3    +            ^+ Admin/Settings/PaymentsProviders/PayPal.phpnu         PK       [1]	  	  -            +y+ Admin/Settings/PaymentsProviders/Razorpay.phpnu         PK       [1]|b    *            5+ Admin/Settings/PaymentsProviders/Antom.phpnu         PK       [1]
  
  3            + Admin/Settings/PaymentsProviders/KlarnaCheckout.phpnu         PK       [1]l
  
  ,            + Admin/Settings/PaymentsProviders/Vivacom.phpnu         PK       [1].U    -            ث+ Admin/Settings/PaymentsProviders/Payoneer.phpnu         PK       [1]Y?!  ?!  +            + Admin/Settings/PaymentsProviders/Stripe.phpnu         PK       [1]F4  4  ,            |+ Admin/Settings/PaymentsProviders/Tilopay.phpnu         PK       [1]$    )            + Admin/Settings/PaymentsProviders/Visa.phpnu         PK       [1]t)  )  +            O+ Admin/Settings/PaymentsProviders/Klarna.phpnu         PK       [1]heI    5            + Admin/Settings/PaymentsProviders/AfterpayClearpay.phpnu         PK       [1]I    .            , Admin/Settings/PaymentsProviders/PayUIndia.phpnu         PK       [1]Vq    3            , Admin/Settings/PaymentsProviders/PaymentGateway.phpnu         PK       [1].T    -            9, Admin/Settings/PaymentsProviders/Paytrail.phpnu         PK       [1]x\׬    .            D, Admin/Settings/PaymentsProviders/AmazonPay.phpnu         PK       [1]*a  a  ;            N, Admin/Settings/PaymentsProviders/PseudoWCPaymentGateway.phpnu         PK       [1]l;  ;  +            - Admin/Settings/PaymentsProviders/WCCore.phpnu         PK       [1]!    *            - Admin/Settings/PaymentsProviders/Monei.phpnu         PK       [1]ނ7  7  -            - Admin/Settings/PaymentsProviders/Paystack.phpnu         PK       [1]h!    .            ,(- Admin/Settings/PaymentsProviders/Airwallex.phpnu         PK       [1][    ,            28- Admin/Settings/PaymentsProviders/Payfast.phpnu         PK       [1]?    J            \A- Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsRestController.phpnu         PK       [1]8+4Ϙ Ϙ C            . Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsService.phpnu         PK       [1]9O    F            / Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsController.phpnu         PK       [1]pwMdo  do  0            / Admin/Settings/PaymentsProviders/WooPayments.phpnu         PK       [1][Qm    )            b0 Admin/Settings/PaymentsProviders/Eway.phpnu         PK       [1]ϡ    -            E'0 Admin/Settings/PaymentsProviders/HelioPay.phpnu         PK       [1]    /            y50 Admin/Settings/PaymentsProviders/GoCardless.phpnu         PK       [1]"W\    +            =0 Admin/Settings/PaymentsProviders/Paymob.phpnu         PK       [1]}e  e  0            M0 Admin/Settings/PaymentsProviders/MercadoPago.phpnu         PK       [1] =!  !  *            j0 Admin/Settings/Exceptions/ApiException.phpnu         PK       [1]0_      2            )q0 Admin/Settings/Exceptions/ApiArgumentException.phpnu         PK       [1]IDt    )            Mr0 Admin/Settings/PaymentsRestController.phpnu         PK       [1]R1m  m              @;1 Admin/Settings/Payments.phpnu         PK       [1]C>̬;  ;              q1 Admin/Settings/Utils.phpnu         PK       [1]V1  1  %            e1 Admin/Settings/PaymentsController.phpnu         PK       [1]ic    $            R2 Admin/Settings/PaymentsProviders.phpnu         PK       [1]GH  H  &            3 Admin/ImportExport/CSVUploadHelper.phpnu         PK       [1]3ls
  s
  #            613 Admin/RemoteFreeExtensions/Init.phpnu         PK       [1]J[  J[  4            ;3 Admin/RemoteFreeExtensions/DefaultFreeExtensions.phpnu         PK       [1]9    F            3 Admin/RemoteFreeExtensions/ProcessCoreProfilerPluginInstallOptions.phpnu         PK       [1]0&    0            53 Admin/RemoteFreeExtensions/EvaluateExtension.phpnu         PK       [1]ǫJ'D  D  C            73 Admin/RemoteFreeExtensions/RemoteFreeExtensionsDataSourcePoller.phpnu         PK       [1]lGQF  F  #            3 Admin/CustomerEffortScoreTracks.phpnu         PK       [1]#UȨ"  "              4 Admin/Events.phpnu         PK       [1]	    "            &4 Admin/RemoteInboxNotifications.phpnu         PK       [1]<                 *4 Admin/Marketplace.phpnu         PK       [1]H^    )            84 Admin/ProductReviews/ReviewsListTable.phpnu         PK       [1]&aÓ    1            ;4 Admin/ProductReviews/ReviewsCommentsOverrides.phpnu         PK       [1]Y%aS  aS               /5 Admin/ProductReviews/Reviews.phpnu         PK       [1]N}    $            X5 Admin/ProductReviews/ReviewsUtil.phpnu         PK       [1]z`£P  P              e5 Admin/ActivityPanels.phpnu         PK       [1]ѓg                  l5 Admin/Survey.phpnu         PK       [1]fT                o5 Admin/FeaturePlugin.phpnu         PK       [1]1                5 Admin/ShippingLabelBanner.phpnu         PK       [1]E7Yb b 2            5 Admin/Suggestions/PaymentsExtensionSuggestions.phpnu         PK       [1]b0  0  ,            S7 Admin/Suggestions/Incentives/WooPayments.phpnu         PK       [1]#E*  *  *            7 Admin/Suggestions/Incentives/Incentive.phpnu         PK       [1]	VV  V  ;            7 Admin/Suggestions/PaymentsExtensionSuggestionIncentives.phpnu         PK       [1]"    "            S8 Admin/Notes/OnboardingPayments.phpnu         PK       [1]
  
  )            8 Admin/Notes/ScheduledUpdatesPromotion.phpnu         PK       [1]NF                 8 Admin/Notes/MarketingJetpack.phpnu         PK       [1]p)  )  #            '8 Admin/Notes/ManageOrdersOnTheGo.phpnu         PK       [1]    #            o.8 Admin/Notes/GivingFeedbackNotes.phpnu         PK       [1]uo4  4  %            48 Admin/Notes/WooSubscriptionsNotes.phpnu         PK       [1]4d    %            j8 Admin/Notes/EditProductsOnTheMove.phpnu         PK       [1]6p                q8 Admin/Notes/TrackingOptIn.phpnu         PK       [1]jF    (            ~8 Admin/Notes/WooCommerceSubscriptions.phpnu         PK       [1]$<C  C  !            8 Admin/Notes/EmailImprovements.phpnu         PK       [1]Sw    "            8 Admin/Notes/MigrateFromShopify.phpnu         PK       [1]&                8 Admin/Notes/MobileApp.phpnu         PK       [1]i      #            8 Admin/Notes/WooCommercePayments.phpnu         PK       [1]	  	  $            ߹8 Admin/Notes/SellingOnlineCourses.phpnu         PK       [1]Cz=  =  %            8 Admin/Notes/PaymentsRemindMeLater.phpnu         PK       [1]#fH
  
  #            E8 Admin/Notes/OnlineClothingStore.phpnu         PK       [1]M|Z;    )            Q8 Admin/Notes/CustomizingProductCatalog.phpnu         PK       [1]~  ~  $            t8 Admin/Notes/UnsecuredReportFiles.phpnu         PK       [1]0$  $              F8 Admin/Notes/OrderMilestones.phpnu         PK       [1]t    &            +9 Admin/Notes/PaymentsMoreInfoNeeded.phpnu         PK       [1]G                9 Admin/Notes/LaunchChecklist.phpnu         PK       [1]c	  c	  (            9 Admin/Notes/CustomizeStoreWithBlocks.phpnu         PK       [1]W	
  
  #            '9 Admin/Notes/RealTimeOrderAlerts.phpnu         PK       [1]օl                -.9 Admin/Notes/FirstProduct.phpnu         PK       [1](R  R  &            379 Admin/Notes/InstallJPAndWCSPlugins.phpnu         PK       [1]                I9 Admin/Notes/EUVATNumber.phpnu         PK       [1]:`	  	               P9 Admin/Notes/MagentoMigration.phpnu         PK       [1]![B    #            Z9 Admin/Notes/PerformanceOnMobile.phpnu         PK       [1]h &B                a9 Admin/Notes/NewSalesRecord.phpnu         PK       [1]<)                 v9 Admin/Notes/PersonalizeStore.phpnu         PK       [1](  (              ~9 Admin/Marketing.phpnu         PK       [1]J<[                _9 Admin/MobileAppBanner.phpnu         PK       [1]6S*  S*  %            d9 Abilities/REST/RestAbilityFactory.phpnu         PK       [1]0]                9 Abilities/REST/RestAbility.phpnu         PK       [1]fNʻB  B  !            9 Abilities/AbilitiesCategories.phpnu         PK       [1]2'                9 Abilities/AbilitiesRegistry.phpnu         PK       [1]Wb    !            9 Abilities/AbilitiesRestBridge.phpnu         PK       [1]{                9 Email/EmailFont.phpnu         PK       [1]                9 Email/EmailStyleSync.phpnu         PK       [1]87Y  Y              /9 Email/EmailColors.phpnu         PK       [1]5Z
  
              	: Email/OrderPriceFormatter.phpnu         PK       [1]̔m:  m:  1            $: ProductDownloads/ApprovedDirectories/Register.phpnu         PK       [1][.      4            J: ProductDownloads/ApprovedDirectories/Synchronize.phpnu         PK       [1]S  S  E            Sl: ProductDownloads/ApprovedDirectories/ApprovedDirectoriesException.phpnu         PK       [1]dM    2            n: ProductDownloads/ApprovedDirectories/StoredUrl.phpnu         PK       [1]    5            s: ProductDownloads/ApprovedDirectories/Admin/SyncUI.phpnu         PK       [1];'  '  4            : ProductDownloads/ApprovedDirectories/Admin/Table.phpnu         PK       [1]x":  :  1            : ProductDownloads/ApprovedDirectories/Admin/UI.phpnu         PK       [1]@Q  Q               t: RestockRefundedItemsAdjuster.phpnu         PK       [1]C.                 : AbilitiesApi/AbilitiesClient.phpnu         PK       [1]q  q  !            : Caches/ProductCacheController.phpnu         PK       [1]cA  A  *            
; Caches/ProductVersionStringInvalidator.phpnu         PK       [1]      !            L; Caches/VersionStringGenerator.phpnu         PK       [1]9(=                `; Caches/ProductCache.phpnu         PK       [1]_ԼY  Y  '            p; TransientFiles/TransientFilesEngine.phpnu         PK       [1]6n                  .; RestApiControllerBase.phpnu         PK       [1]9,                ; DownloadPermissionsAdjuster.phpnu         PK       [1]Z    "            < DataStores/CustomMetaDataStore.phpnu         PK       [1]ىB  B  1            3%< DataStores/Fulfillments/FulfillmentsDataStore.phpnu         PK       [1]M    :            Uh< DataStores/Fulfillments/FulfillmentsDataStoreInterface.phpnu         PK       [1]AN  N  =            Jk< DataStores/StockNotifications/StockNotificationsDataStore.phpnu         PK       [1]    A            p< DataStores/StockNotifications/StockNotificationsMetaDataStore.phpnu         PK       [1]T4}    .            < DataStores/Orders/OrdersTableDataStoreMeta.phpnu         PK       [1]L	8  8  0            < DataStores/Orders/OrdersTableRefundDataStore.phpnu         PK       [1][_J  J  *            < DataStores/Orders/OrdersTableMetaQuery.phpnu         PK       [1]T&^ ^ *            9<= DataStores/Orders/OrdersTableDataStore.phpnu         PK       [1]J,"  "  +            > DataStores/Orders/OrdersTableFieldQuery.phpnu         PK       [1]=M;    &            S? DataStores/Orders/OrdersTableQuery.phpnu         PK       [1]uf    '            ? DataStores/Orders/LegacyDataCleanup.phpnu         PK       [1]=n9  9  ,            ? DataStores/Orders/OrdersTableSearchQuery.phpnu         PK       [1]    &            7@ DataStores/Orders/DataSynchronizer.phpnu         PK       [1]I(S  S  '            g@ DataStores/Orders/LegacyDataHandler.phpnu         PK       [1],mt  mt  1            {A DataStores/Orders/CustomOrdersTableController.phpnu         PK       [1]i                IA RestApiParameterUtil.phpnu         PK       [1]7b"  "  (            oA PushNotifications/Entities/PushToken.phpnu         PK       [1]O=e  e  ;            A PushNotifications/Exceptions/PushTokenNotFoundException.phpnu         PK       [1]&    '            ^A PushNotifications/PushNotifications.phpnu         PK       [1]]h$  h$  4            GA PushNotifications/DataStores/PushTokensDataStore.phpnu         PK    ;;  A