Файловый менеджер - Редактировать - /home/tuudkjt/globeasy/wp-includes/ID3/Admin.tar
Назад
ParentPageAbstract.php 0000777 00000021604 15252174534 0011011 0 ustar 00 <?php namespace EasyWPSMTP\Admin; use EasyWPSMTP\WP; /** * Class ParentPageAbstract. * * @since 2.0.0 */ abstract class ParentPageAbstract implements PageInterface { /** * Slug of a page. * * @since 2.0.0 * * @var string */ protected $slug; /** * Page tabs. * * @since 2.0.0 * * @var PageAbstract[] */ protected $tabs = []; /** * Page default tab slug. * * @since 2.0.0 * * @var string */ protected $default_tab = ''; /** * Constructor. * * @since 2.0.0 * * @param array $tabs Page tabs. */ public function __construct( $tabs = [] ) { /** * Filters parent page tabs. * * @since 2.0.0 * * @param string[] $tabs Parent page tabs. */ $tabs = apply_filters( 'easy_wp_smtp_admin_page_' . $this->slug . '_tabs', $tabs ); if ( easy_wp_smtp()->get_admin()->is_admin_page( $this->slug ) ) { $this->init_tabs( $tabs ); $this->hooks(); } if ( WP::is_doing_self_ajax() ) { $this->init_ajax( $tabs ); } } /** * Hooks. * * @since 2.0.0 */ protected function hooks() { add_action( 'admin_init', [ $this, 'process_actions' ] ); // Register tab related hooks. if ( isset( $this->tabs[ $this->get_current_tab() ] ) ) { $this->tabs[ $this->get_current_tab() ]->hooks(); } } /** * Initialize ajax actions. * * @since 2.0.0 * * @param array $tabs Page tabs. */ private function init_ajax( $tabs ) { foreach ( $tabs as $tab ) { if ( $this->is_valid_tab( $tab ) ) { ( new $tab( $this ) )->ajax(); } } } /** * Get the page slug. * * @since 2.0.0 * * @return string */ public function get_slug() { return $this->slug; } /** * Get the page tabs. * * @since 2.0.0 * * @return PageAbstract[] */ public function get_tabs() { return $this->tabs; } /** * Get the page tabs slugs. * * @since 2.0.0 * * @return string[] */ public function get_tabs_slugs() { return array_map( function ( $tab ) { return $tab->get_slug(); }, $this->tabs ); } /** * Get the page/tab link. * * @since 2.0.0 * * @param string $tab Tab to generate a link to. * * @return string */ public function get_link( $tab = '' ) { return add_query_arg( 'tab', $this->get_defined_tab( $tab ), WP::admin_url( 'admin.php?page=' . Area::SLUG . '-' . $this->slug ) ); } /** * Get the current tab. * * @since 2.0.0 * * @return string */ public function get_current_tab() { $tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended return $this->get_defined_tab( $tab ); } /** * Get the tab label. * * @since 2.0.0 * * @param string $tab Tab key. * * @return string */ public function get_tab_label( $tab ) { $tabs = $this->get_tabs(); return isset( $tabs[ $tab ] ) ? $tabs[ $tab ]->get_label() : ''; } /** * Get the tab title. * * @since 2.0.0 * * @param string $tab Tab key. * * @return string */ public function get_tab_title( $tab ) { $tabs = $this->get_tabs(); return isset( $tabs[ $tab ] ) ? $tabs[ $tab ]->get_title() : ''; } /** * Get the defined or default tab. * * @since 2.0.0 * * @param string $tab Tab to check. * * @return string Defined tab. Fallback to default one if it doesn't exist. */ protected function get_defined_tab( $tab ) { $tab = sanitize_key( $tab ); return in_array( $tab, $this->get_tabs_slugs(), true ) ? $tab : $this->get_default_tab(); } /** * Get the default tab. * * @since 2.7.0 * * @return string Default tab. */ protected function get_default_tab() { /** * Filters this page's default tab. * * @since 2.7.0 * * @param string $default_tab Default tab. */ return apply_filters( 'easy_wp_smtp_admin_page_' . $this->slug . '_default_tab', $this->default_tab ); } /** * Initialize tabs. * * @since 2.0.0 * * @param array $tabs Page tabs. */ public function init_tabs( $tabs ) { foreach ( $tabs as $key => $tab ) { if ( ! $this->is_valid_tab( $tab ) ) { continue; } $this->tabs[ $key ] = new $tab( $this ); } // Sort tabs by priority. $this->sort_tabs(); } /** * All possible plugin forms manipulation and hooks registration will be done here. * * @since 2.0.0 */ public function process_actions() { $tabs = $this->get_tabs_slugs(); // Allow to process only own tabs. if ( ! array_key_exists( $this->get_current_tab(), $tabs ) ) { return; } // Process POST only if it exists. // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash if ( ! empty( $_POST ) && isset( $_POST['easy-wp-smtp-post'] ) ) { if ( ! empty( $_POST['easy-wp-smtp'] ) ) { $post = $_POST['easy-wp-smtp']; } else { $post = []; } $this->tabs[ $this->get_current_tab() ]->process_post( $post ); } // phpcs:enable // This won't do anything for most pages. // Works for plugin page only, when GET params are allowed. $this->tabs[ $this->get_current_tab() ]->process_auth(); } /** * Display page content based on the current tab. * * @since 2.0.0 */ public function display() { //phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh $current_tab = $this->get_current_tab(); $page_slug = $this->slug; ?> <?php if ( count( $this->tabs ) <= 1 ) : ?> <div class="easy-wp-smtp-subheader"> <div class="easy-wp-smtp-subheader__inner easy-wp-smtp-container"> <div class="easy-wp-smtp-page-title"> <?php echo esc_html( $this->get_tab_title( $current_tab ) ); ?> </div> <?php $this->display_after_page_title(); ?> <?php if ( count( $this->tabs ) === 1 ) { /** * Fires after page title. * * @since 2.7.0 * * @param ParentPageAbstract $page Current page. */ do_action( "easy_wp_smtp_admin_page_{$page_slug}_{$current_tab}_display_header", $this ); } ?> </div> </div> <?php endif; ?> <div class="easy-wp-smtp-container"> <?php if ( count( $this->tabs ) > 1 ) : ?> <div class="easy-wp-smtp-nav-menu"> <div class="easy-wp-smtp-nav-menu__inner"> <?php foreach ( $this->tabs as $tab ) : ?> <a href="<?php echo esc_url( $tab->get_link() ); ?>" class="easy-wp-smtp-nav-menu__item <?php echo $current_tab === $tab->get_slug() ? 'easy-wp-smtp-nav-menu__item--active' : ''; ?>"> <?php echo esc_html( $tab->get_label() ); ?> </a> <?php endforeach; ?> <?php /** * Fires after page title. * * @since 2.7.0 * * @param ParentPageAbstract $page Current page. */ do_action( "easy_wp_smtp_admin_page_{$page_slug}_{$current_tab}_display_header", $this ); ?> </div> </div> <?php endif; ?> <div class="easy-wp-smtp-page-content"> <?php foreach ( $this->tabs as $tab ) { if ( $tab->get_slug() === $current_tab ) { printf( '<h1 class="screen-reader-text">%s</h1>', esc_html( $tab->get_title() ) ); /** * Fires before tab content. * * @since 2.0.0 * * @param PageAbstract $tab Current tab. */ do_action( 'easy_wp_smtp_admin_pages_before_content', $tab ); /** * Fires before tab content. * * @since 2.0.0 * * @param PageAbstract $tab Current tab. */ do_action( "easy_wp_smtp_admin_page_{$page_slug}_{$current_tab}_display_before", $tab ); $tab->display(); /** * Fires after tab content. * * @since 2.0.0 * * @param PageAbstract $tab Current tab. */ do_action( "easy_wp_smtp_admin_page_{$page_slug}_{$current_tab}_display_after", $tab ); break; } } ?> </div> </div> <?php } /** * Display some elements after page title. * * @since 2.1.0 */ private function display_after_page_title() { if ( easy_wp_smtp()->is_pro() || ! in_array( $this->get_current_tab(), [ 'reports' ], true ) ) { return; } $button_upgrade_link = easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'email-reports', 'content' => 'upgrade-to-easy-wp-smtp-pro-button-link', ] ); ?> <a href="<?php echo esc_url( $button_upgrade_link ); ?>" target="_blank" rel="noopener noreferrer" class="easy-wp-smtp-btn easy-wp-smtp-btn--sm easy-wp-smtp-btn--green"> <?php esc_html_e( 'Upgrade to Pro', 'easy-wp-smtp' ); ?> </a> <?php } /** * Sort tabs by priority. * * @since 2.0.0 */ protected function sort_tabs() { uasort( $this->tabs, function ( $a, $b ) { return ( $a->get_priority() < $b->get_priority() ) ? - 1 : 1; } ); } /** * Whether tab is valid. * * @since 2.0.0 * * @param array $tab Page tab. * * @return bool */ private function is_valid_tab( $tab ) { return is_subclass_of( $tab, '\EasyWPSMTP\Admin\PageAbstract' ); } } DashboardWidget.php 0000777 00000050101 15252174534 0010324 0 ustar 00 <?php namespace EasyWPSMTP\Admin; use EasyWPSMTP\Admin\DebugEvents\DebugEvents; use EasyWPSMTP\Helpers\Helpers; use EasyWPSMTP\Options; use EasyWPSMTP\WP; use EasyWPSMTP\Reports\Reports; use EasyWPSMTP\Reports\Emails\Summary as SummaryReportEmail; /** * Dashboard Widget shows the number of sent emails in WP Dashboard. * * @since 2.1.0 */ class DashboardWidget { /** * Instance slug. * * @since 2.1.0 * * @const string */ const SLUG = 'dash_widget_lite'; /** * Constructor. * * @since 2.1.0 */ public function __construct() {} /** * Init class. * * @since 2.1.0 */ public function init() { // Prevent the class initialization, if the dashboard widget hidden setting is enabled. if ( Options::init()->get( 'general', 'dashboard_widget_hidden' ) ) { return; } add_action( 'admin_init', function() { // This widget should be displayed for certain high-level users only. if ( ! current_user_can( easy_wp_smtp()->get_capability_manage_options() ) ) { return; } /** * Filters whether the initialization of the dashboard widget should be allowed. * * @since 2.1.0 * * @param bool $var If the dashboard widget should be initialized. */ if ( ! apply_filters( 'easy_wp_smtp_admin_dashboard_widget', '__return_true' ) ) { return; } $this->hooks(); } ); } /** * Widget hooks. * * @since 2.1.0 */ public function hooks() { add_action( 'admin_enqueue_scripts', [ $this, 'widget_scripts' ] ); add_action( 'wp_dashboard_setup', [ $this, 'widget_register' ] ); add_action( 'wp_ajax_easy_wp_smtp_' . static::SLUG . '_save_widget_meta', [ $this, 'save_widget_meta_ajax' ] ); add_action( 'wp_ajax_easy_wp_smtp_' . static::SLUG . '_enable_summary_report_email', [ $this, 'enable_summary_report_email_ajax', ] ); } /** * Load widget-specific scripts. * Load them only on the admin dashboard page. * * @since 2.1.0 */ public function widget_scripts() { $screen = get_current_screen(); if ( ! isset( $screen->id ) || 'dashboard' !== $screen->id ) { return; } $min = WP::asset_min(); wp_enqueue_style( 'easy-wp-smtp-dashboard-widget', easy_wp_smtp()->assets_url . '/css/dashboard-widget.min.css', [], EasyWPSMTP_PLUGIN_VERSION ); wp_enqueue_style( 'easy-wp-smtp-chart', easy_wp_smtp()->assets_url . '/css/vendor/apexcharts.css', [], '3.35.1' ); wp_enqueue_script( 'easy-wp-smtp-chart', easy_wp_smtp()->assets_url . '/js/vendor/apexcharts.min.js', [], '3.35.1', true ); wp_enqueue_script( 'easy-wp-smtp-dashboard-widget', easy_wp_smtp()->assets_url . "/js/smtp-dashboard-widget{$min}.js", [ 'jquery', 'moment', 'easy-wp-smtp-chart' ], EasyWPSMTP_PLUGIN_VERSION, true ); wp_localize_script( 'easy-wp-smtp-dashboard-widget', 'easy_wp_smtp_dashboard_widget', [ 'slug' => static::SLUG, 'nonce' => wp_create_nonce( 'easy_wp_smtp_' . static::SLUG . '_nonce' ), ] ); } /** * Register the widget. * * @since 2.1.0 */ public function widget_register() { global $wp_meta_boxes; $widget_key = 'easy_wp_smtp_reports_widget_lite'; wp_add_dashboard_widget( $widget_key, esc_html__( 'Easy WP SMTP', 'easy-wp-smtp' ), [ $this, 'widget_content' ] ); // Attempt to place the widget at the top. $normal_dashboard = $wp_meta_boxes['dashboard']['normal']['core']; if ( isset( $normal_dashboard[ $widget_key ] ) ) { $widget_instance = [ $widget_key => $normal_dashboard[ $widget_key ] ]; unset( $normal_dashboard[ $widget_key ] ); $sorted_dashboard = array_merge( $widget_instance, $normal_dashboard ); //phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited $wp_meta_boxes['dashboard']['normal']['core'] = $sorted_dashboard; } } /** * Save a widget meta for a current user using AJAX. * * @since 2.1.0 */ public function save_widget_meta_ajax() { check_admin_referer( 'easy_wp_smtp_' . static::SLUG . '_nonce' ); if ( ! current_user_can( easy_wp_smtp()->get_capability_manage_options() ) ) { wp_send_json_error(); } $meta = ! empty( $_POST['meta'] ) ? sanitize_key( $_POST['meta'] ) : ''; $value = ! empty( $_POST['value'] ) ? sanitize_key( $_POST['value'] ) : 0; $this->widget_meta( 'set', $meta, $value ); wp_send_json_success(); } /** * Enable summary report email using AJAX. * * @since 2.1.0 */ public function enable_summary_report_email_ajax() { check_admin_referer( 'easy_wp_smtp_' . static::SLUG . '_nonce' ); if ( ! current_user_can( easy_wp_smtp()->get_capability_manage_options() ) ) { wp_send_json_error(); } $options = Options::init(); $data = [ 'general' => [ SummaryReportEmail::SETTINGS_SLUG => false, ], ]; $options->set( $data, false, false ); wp_send_json_success(); } /** * Load widget content. * * @since 2.1.0 */ public function widget_content() { echo '<div class="easy-wp-smtp-dash-widget easy-wp-smtp-dash-widget--lite">'; $this->widget_content_html(); echo '</div>'; } /** * Widget content HTML. * * @since 2.1.0 */ private function widget_content_html() { $hide_graph = (bool) $this->widget_meta( 'get', 'hide_graph' ); ?> <?php if ( ! $hide_graph ) : ?> <div class="easy-wp-smtp-dash-widget-chart-block-container"> <div class="easy-wp-smtp-dash-widget-block easy-wp-smtp-dash-widget-chart-block"> <div class="easy-wp-smtp-apexcharts" id="easy-wp-smtp-dash-widget-chart"></div> <div class="easy-wp-smtp-dash-widget-chart-upgrade"> <div class="easy-wp-smtp-dash-widget-modal"> <a href="#" class="easy-wp-smtp-dash-widget-dismiss-chart-upgrade"> <span class="dashicons dashicons-no-alt"></span> </a> <h2><?php esc_html_e( 'View Detailed Email Stats', 'easy-wp-smtp' ); ?></h2> <p><?php esc_html_e( 'Automatically keep track of every email sent from your WordPress site and view valuable statistics right here in your dashboard.', 'easy-wp-smtp' ); ?></p> <p> <a href="<?php echo esc_url( easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'dashboard-widget', 'content' => 'upgrade-to-easy-wp-smtp-pro' ] ) ); // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound ?>" target="_blank" rel="noopener noreferrer" class="button button-primary button-hero"> <?php esc_html_e( 'Upgrade to Easy WP SMTP Pro', 'easy-wp-smtp' ); ?> </a> </p> </div> </div> <div class="easy-wp-smtp-dash-widget-overlay"></div> </div> </div> <?php endif; ?> <div class="easy-wp-smtp-dash-widget-block easy-wp-smtp-dash-widget-block-settings"> <div> <?php $this->timespan_select_html(); ?> </div> </div> <div id="easy-wp-smtp-dash-widget-email-stats-block" class="easy-wp-smtp-dash-widget-block easy-wp-smtp-dash-widget-email-stats-block"> <?php $this->email_stats_block(); ?> </div> <?php $this->display_after_email_stats_block_content(); } /** * Display the content after the email stats block. * * @since 2.4.0 * * @return void */ private function display_after_email_stats_block_content() { if ( empty( $this->widget_meta( 'get', 'hide_email_alerts_banner' ) ) ) { // Check if we have error debug events. $error_debug_events_count = DebugEvents::get_error_debug_events_count(); if ( ! is_wp_error( $error_debug_events_count ) && ! empty( $error_debug_events_count ) ) { $this->show_email_alerts_banner( $error_debug_events_count ); return; } } $hide_summary_report_email_block = (bool) $this->widget_meta( 'get', 'hide_summary_report_email_block' ); if ( SummaryReportEmail::is_disabled() && ! $hide_summary_report_email_block ) { $this->show_summary_report_email_block(); } $this->show_upgrade_footer(); } /** * Display the email alerts banner. * * @since 2.4.0 * * @param int $error_count The number of debug events error. * * @return void */ private function show_email_alerts_banner( $error_count ) { ?> <div id="easy-wp-smtp-dash-widget-email-alerts-education" class="easy-wp-smtp-dash-widget-block easy-wp-smtp-dash-widget-email-alerts-education"> <div class="easy-wp-smtp-dash-widget-email-alerts-education-error-icon"> <?php printf( '<img src="%s" alt="%s"/>', esc_url( easy_wp_smtp()->assets_url . '/images/icons/exclamation-circle-red.svg' ), esc_attr__( 'Error icon', 'easy-wp-smtp' ) ); ?> </div> <div class="easy-wp-smtp-dash-widget-email-alerts-education-content"> <?php if ( $error_count === 1 ) { $error_title = __( 'We detected a failed email in the last 30 days.', 'easy-wp-smtp' ); } else { $error_title = sprintf( /* translators: %d - number of failed emails. */ __( 'We detected %d failed emails in the last 30 days.', 'easy-wp-smtp' ), $error_count ); } $content = sprintf( /* translators: %s - URL to EasyWPSMTP.com. */ __( '<a href="%s" target="_blank" rel="noopener noreferrer">Upgrade to Pro</a> and get instant alert notifications when they fail.', 'easy-wp-smtp' ), esc_url( easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'dashboard-widget-alerts', 'content' => 'upgrade-to-pro' ] ) ) // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound ); ?> <p> <strong><?php echo esc_html( $error_title ); ?></strong><br /> <?php echo wp_kses( $content, [ 'a' => [ 'href' => [], 'target' => [], 'rel' => [], ], ] ); ?> </p> </div> <button type="button" id="easy-wp-smtp-dash-widget-dismiss-email-alert-block" class="easy-wp-smtp-dash-widget-dismiss-email-alert-block" title="<?php esc_attr_e( 'Dismiss email alert block', 'easy-wp-smtp' ); ?>"> <span class="dashicons dashicons-no-alt"></span> </button> </div> <?php } /** * Show the summary report email block. * * @since 2.4.0 * * @return void */ private function show_summary_report_email_block() { ?> <div id="easy-wp-smtp-dash-widget-summary-report-email-block" class="easy-wp-smtp-dash-widget-block easy-wp-smtp-dash-widget-summary-report-email-block"> <div> <div class="easy-wp-smtp-dash-widget-summary-report-email-block-setting"> <label for="easy-wp-smtp-dash-widget-summary-report-email-enable"> <input type="checkbox" id="easy-wp-smtp-dash-widget-summary-report-email-enable"> <i class="easy-wp-smtp-dash-widget-loader"></i> <span> <?php echo wp_kses( __( '<b>NEW!</b> Enable Weekly Email Summaries', 'easy-wp-smtp' ), [ 'b' => [], ] ); ?> </span> </label> <a href="<?php echo esc_url( SummaryReportEmail::get_preview_link() ); ?>" target="_blank"> <?php esc_html_e( 'View Example', 'easy-wp-smtp' ); ?> </a> <i class="dashicons dashicons-dismiss easy-wp-smtp-dash-widget-summary-report-email-dismiss"></i> </div> <div class="easy-wp-smtp-dash-widget-summary-report-email-block-applied hidden"> <i class="easy-wp-smtp-dashicons-yes-alt-green"></i> <span><?php esc_attr_e( 'Weekly Email Summaries have been enabled', 'easy-wp-smtp' ); ?></span> </div> </div> </div> <?php } /** * Show the upgrade footer. * * @since 2.4.0 * * @return void */ private function show_upgrade_footer() { $hide_graph = (bool) $this->widget_meta( 'get', 'hide_graph' ); ?> <div id="easy-wp-smtp-dash-widget-upgrade-footer" class="easy-wp-smtp-dash-widget-block easy-wp-smtp-dash-widget-upgrade-footer easy-wp-smtp-dash-widget-upgrade-footer--<?php echo ! $hide_graph ? 'hide' : 'show'; ?>"> <p> <?php printf( wp_kses( /* translators: %s - URL to EasyWPSMTP.com. */ __( '<a href="%s" target="_blank" rel="noopener noreferrer">Upgrade to Pro</a> for detailed stats, email logs, and more!', 'easy-wp-smtp' ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], ] ), // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound esc_url( easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'dashboard-widget', 'content' => 'upgrade-to-pro' ] ) ) ); ?> </p> </div> <?php } /** * Timespan select HTML. * * @since 2.1.0 */ private function timespan_select_html() { ?> <select id="easy-wp-smtp-dash-widget-timespan" class="easy-wp-smtp-dash-widget-select-timespan" title="<?php esc_attr_e( 'Select timespan', 'easy-wp-smtp' ); ?>"> <option value="all"> <?php esc_html_e( 'All Time', 'easy-wp-smtp' ); ?> </option> <?php foreach ( [ 7, 14, 30 ] as $option ) : ?> <option value="<?php echo absint( $option ); ?>" disabled> <?php /* translators: %d - Number of days. */ ?> <?php echo esc_html( sprintf( _n( 'Last %d day', 'Last %d days', absint( $option ), 'easy-wp-smtp' ), absint( $option ) ) ); ?> </option> <?php endforeach; ?> </select> <?php } /** * Email statistics block. * * @since 2.1.0 */ private function email_stats_block() { $output_data = $this->get_email_stats_data(); ?> <table id="easy-wp-smtp-dash-widget-email-stats-table" cellspacing="0"> <tr> <?php $count = 0; $per_row = 2; foreach ( array_values( $output_data ) as $stats ) : if ( ! is_array( $stats ) ) { continue; } if ( ! isset( $stats['icon'], $stats['title'] ) ) { continue; } // Make some exceptions for mailers without send confirmation functionality. if ( Helpers::mailer_without_send_confirmation() ) { $per_row = 3; } // Create new row after every $per_row cells. if ( $count !== 0 && $count % $per_row === 0 ) { echo '</tr><tr>'; } $count++; ?> <td class="easy-wp-smtp-dash-widget-email-stats-table-cell easy-wp-smtp-dash-widget-email-stats-table-cell--<?php echo esc_attr( $stats['type'] ); ?> easy-wp-smtp-dash-widget-email-stats-table-cell--3"> <div class="easy-wp-smtp-dash-widget-email-stats-table-cell-container"> <div class="easy-wp-smtp-dash-widget-email-stats-table-cell-heading"> <?php echo wp_kses( $stats['icon'], [ 'svg' => [ 'xmlns' => [], 'viewbox' => [], 'fill' => [], 'width' => [], 'height' => [], ], 'path' => [ 'd' => [], 'fill' => [], ], ] ); ?> <h6><?php echo esc_html( $stats['title'] ); ?></h6> </div> <div class="easy-wp-smtp-dash-widget-email-stats-table-cell-value"> <?php echo esc_html( $stats['count'] ); ?> </div> </div> </td> <?php endforeach; ?> </tr> </table> <?php } /** * Prepare the email stats data. * The text and counts of the email stats. * * @since 2.1.0 * * @return array[] */ private function get_email_stats_data() { $reports = new Reports(); $total_sent = $reports->get_total_emails_sent(); $output_data = [ 'all' => [ 'type' => 'all', 'icon' => '<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4.26552 3.99976H16.7345C17.9115 3.99976 18.5 4.56374 18.5 5.69171V13.6365C18.5 14.7645 17.9115 15.3285 16.7345 15.3285H4.26552C3.08851 15.3285 2.5 14.7645 2.5 13.6365V5.69171C2.5 4.56374 3.08851 3.99976 4.26552 3.99976ZM10.5184 12.1285L16.8448 6.9055C16.9674 6.80742 17.0533 6.66029 17.1023 6.46412C17.1513 6.26796 17.1023 6.08405 16.9552 5.9124C16.8326 5.71623 16.6609 5.60589 16.4402 5.58137C16.2441 5.55684 16.0479 5.60589 15.8517 5.72849L10.5184 9.36987L5.14828 5.72849C4.97663 5.60589 4.78046 5.55684 4.55977 5.58137C4.33908 5.60589 4.16743 5.71623 4.04483 5.9124C3.92222 6.08405 3.87318 6.26796 3.8977 6.46412C3.94674 6.66029 4.03257 6.80742 4.15517 6.9055L10.5184 12.1285Z" fill="#211FA6"/></svg>', 'title' => esc_html__( 'Total Emails', 'easy-wp-smtp' ), 'count' => $total_sent, ], 'delivered' => [ 'type' => 'delivered', 'icon' => '<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18.25 10C18.25 5.75 14.75 2.25 10.5 2.25C6.21875 2.25 2.75 5.75 2.75 10C2.75 14.2812 6.21875 17.75 10.5 17.75C14.75 17.75 18.25 14.2812 18.25 10ZM9.59375 14.125C9.40625 14.3125 9.0625 14.3125 8.875 14.125L5.625 10.875C5.4375 10.6875 5.4375 10.3438 5.625 10.1562L6.34375 9.46875C6.53125 9.25 6.84375 9.25 7.03125 9.46875L9.25 11.6562L13.9375 6.96875C14.125 6.75 14.4375 6.75 14.625 6.96875L15.3438 7.65625C15.5312 7.84375 15.5312 8.1875 15.3438 8.375L9.59375 14.125Z" fill="#0F8A56"/></svg>', 'title' => esc_html__( 'Confirmed', 'easy-wp-smtp' ), 'count' => 'N/A', ], 'sent' => [ 'type' => 'sent', 'icon' => '<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18.25 10C18.25 5.75 14.75 2.25 10.5 2.25C6.21875 2.25 2.75 5.75 2.75 10C2.75 14.2812 6.21875 17.75 10.5 17.75C14.75 17.75 18.25 14.2812 18.25 10ZM9.59375 14.125C9.40625 14.3125 9.0625 14.3125 8.875 14.125L5.625 10.875C5.4375 10.6875 5.4375 10.3438 5.625 10.1562L6.34375 9.46875C6.53125 9.25 6.84375 9.25 7.03125 9.46875L9.25 11.6562L13.9375 6.96875C14.125 6.75 14.4375 6.75 14.625 6.96875L15.3438 7.65625C15.5312 7.84375 15.5312 8.1875 15.3438 8.375L9.59375 14.125Z" fill="#8B8B9D"/></svg>', 'title' => esc_html__( 'Unconfirmed', 'easy-wp-smtp' ), 'count' => 'N/A', ], 'unsent' => [ 'type' => 'unsent', 'icon' => '<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10.5 2.25C6.21875 2.25 2.75 5.71875 2.75 10C2.75 14.2812 6.21875 17.75 10.5 17.75C14.7812 17.75 18.25 14.2812 18.25 10C18.25 5.71875 14.7812 2.25 10.5 2.25ZM14.2812 12.0625C14.4375 12.1875 14.4375 12.4375 14.2812 12.5938L13.0625 13.8125C12.9062 13.9688 12.6562 13.9688 12.5312 13.8125L10.5 11.75L8.4375 13.8125C8.3125 13.9688 8.0625 13.9688 7.90625 13.8125L6.6875 12.5625C6.53125 12.4375 6.53125 12.1875 6.6875 12.0312L8.75 10L6.6875 7.96875C6.53125 7.84375 6.53125 7.59375 6.6875 7.4375L7.9375 6.21875C8.0625 6.0625 8.3125 6.0625 8.46875 6.21875L10.5 8.25L12.5312 6.21875C12.6562 6.0625 12.9062 6.0625 13.0625 6.21875L14.2812 7.4375C14.4375 7.59375 14.4375 7.84375 14.2812 7.96875L12.25 10L14.2812 12.0625Z" fill="#DF2A4A"/></svg>', 'title' => esc_html__( 'Failed', 'easy-wp-smtp' ), 'count' => 'N/A', ], ]; if ( Helpers::mailer_without_send_confirmation() ) { // Skip the 'unconfirmed sent' section. unset( $output_data['sent'] ); // Change the 'confirmed sent' section into a general 'sent' section. $output_data['delivered']['title'] = esc_html__( 'Sent', 'easy-wp-smtp' ); } return $output_data; } /** * Get/set a widget meta. * * @since 2.1.0 * * @param string $action Possible value: 'get' or 'set'. * @param string $meta Meta name. * @param int $value Value to set. * * @return mixed */ protected function widget_meta( $action, $meta, $value = 0 ) { $allowed_actions = [ 'get', 'set' ]; if ( ! in_array( $action, $allowed_actions, true ) ) { return false; } if ( $action === 'get' ) { return $this->get_widget_meta( $meta ); } $meta_key = $this->get_widget_meta_key( $meta ); $value = sanitize_key( $value ); if ( 'set' === $action && ! empty( $value ) ) { return update_user_meta( get_current_user_id(), $meta_key, $value ); } if ( 'set' === $action && empty( $value ) ) { return delete_user_meta( get_current_user_id(), $meta_key ); } return false; } /** * Get the widget meta value. * * @since 2.4.0 * * @param string $meta Meta name. * * @return mixed */ private function get_widget_meta( $meta ) { $defaults = [ 'hide_graph' => 0, 'hide_summary_report_email_block' => 0, 'hide_email_alerts_banner' => 0, ]; $meta_value = get_user_meta( get_current_user_id(), $this->get_widget_meta_key( $meta ), true ); return empty( $meta_value ) ? $defaults[ $meta ] : $meta_value; } /** * Retrieve the meta key. * * @since 2.4.0 * * @param string $meta Meta name. * * @return string */ private function get_widget_meta_key( $meta ) { return 'easy_wp_smtp_' . static::SLUG . '_' . $meta; } } PluginsInstallSkin.php 0000777 00000002144 15252174534 0011072 0 ustar 00 <?php namespace EasyWPSMTP\Admin; use Automatic_Upgrader_Skin; /** * WordPress class extended for on-the-fly plugin installations. * * @since 2.1.0 * @since 2.3.0 Updated to extend Automatic_Upgrader_Skin. */ class PluginsInstallSkin extends Automatic_Upgrader_Skin { /** * Empty out the header of its HTML content and only check to see if it has * been performed or not. * * @since 2.1.0 */ public function header() { } /** * Empty out the footer of its HTML contents. * * @since 2.1.0 */ public function footer() { } /** * Instead of outputting HTML for errors, json_encode the errors and send them * back to the Ajax script for processing. * * @since 2.1.0 * * @param array $errors Array of errors with the install process. */ public function error( $errors ) { if ( ! empty( $errors ) ) { wp_send_json_error( $errors ); } } /** * Empty out JavaScript output that calls function to decrement the update counts. * * @since 2.1.0 * * @param string $type Type of update count to decrement. */ public function decrement_update_count( $type ) { } } PageAbstract.php 0000777 00000006075 15252174534 0007644 0 ustar 00 <?php namespace EasyWPSMTP\Admin; use EasyWPSMTP\WP; /** * Class PageAbstract. * * @since 2.0.0 */ abstract class PageAbstract implements PageInterface { /** * @var string Slug of a tab. */ protected $slug; /** * Tab priority. * * @since 2.0.0 * * @var int */ protected $priority = 999; /** * Tab parent page. * * @since 2.0.0 * * @var ParentPageAbstract */ protected $parent_page = null; /** * Constructor. * * @since 2.0.0 * * @param ParentPageAbstract $parent_page Tab parent page. */ public function __construct( $parent_page = null ) { $this->parent_page = $parent_page; } /** * @inheritdoc */ public function get_link() { $page = Area::SLUG; if ( $this->parent_page !== null ) { $page .= '-' . $this->parent_page->get_slug(); } return add_query_arg( 'tab', $this->slug, WP::admin_url( 'admin.php?page=' . $page ) ); } /** * Title of a tab. * * @since 2.0.0 * * @return string */ public function get_title() { return $this->get_label(); } /** * Get tab slug. * * @since 2.0.0 * * @return string */ public function get_slug() { return $this->slug; } /** * Get tab priority. * * @since 2.0.0 * * @return int */ public function get_priority() { return $this->priority; } /** * Get parent page. * * @since 2.0.0 * * @return ParentPageAbstract */ public function get_parent_page() { return $this->parent_page; } /** * Get parent page slug. * * @since 2.0.0 * * @return string */ public function get_parent_slug() { if ( is_null( $this->parent_page ) ) { return ''; } return $this->parent_page->get_slug(); } /** * Register tab related hooks. * * @since 2.0.0 */ public function hooks() {} /** * Register tab related ajax hooks. * * @since 2.0.0 */ public function ajax() {} /** * Process tab form submission ($_POST ). * * @since 2.0.0 * * @param array $data $_POST data specific for the plugin. */ public function process_post( $data ) {} /** * Process tab & mailer specific Auth actions. * * @since 2.0.0 */ public function process_auth() {} /** * Print the nonce field for a specific tab. * * @since 2.0.0 */ public function wp_nonce_field() { wp_nonce_field( Area::SLUG . '-' . $this->slug ); } /** * Make sure that a user was referred from plugin admin page. * To avoid security problems. * * @since 2.0.0 */ public function check_admin_referer() { check_admin_referer( Area::SLUG . '-' . $this->slug ); } /** * Save button to be reused on other tabs. * * @since 2.0.0 */ public function display_save_btn() { ?> <button type="submit" class="easy-wp-smtp-btn easy-wp-smtp-btn--primary easy-wp-smtp-btn--lg"> <?php esc_html_e( 'Save Settings', 'easy-wp-smtp' ); ?> </button> <?php $this->post_form_hidden_field(); } /** * Form hidden field for identifying plugin POST requests. * * @since 2.0.0 */ public function post_form_hidden_field() { echo '<input type="hidden" name="easy-wp-smtp-post" value="1">'; } } Area.php 0000777 00000143345 15252174534 0006156 0 ustar 00 <?php namespace EasyWPSMTP\Admin; use EasyWPSMTP\Options; use EasyWPSMTP\WP; /** * Class Area registers and process all wp-admin display functionality. * * @since 2.0.0 */ class Area { /** * Slug of the admin area page. * * @since 2.0.0 * * @var string */ const SLUG = 'easy-wp-smtp'; /** * Admin page unique hook. * * @since 2.0.0 * * @var string */ public $hook; /** * List of admin area pages. * * @since 2.0.0 * * @var PageAbstract[] */ private $pages; /** * List of official registered pages. * * @since 2.0.0 * * @var array */ public static $pages_registered = [ 'general', 'logs', 'tools', 'reports' ]; /** * Area constructor. * * @since 2.0.0 */ public function __construct() {} /** * Assign all hooks to proper places. * * @since 2.0.0 * @since 2.3.0 Changed visibility to public. */ public function hooks() { // Redirect from deprecated settings page. if ( isset( $_GET['page'] ) && $_GET['page'] === 'swpsmtp_settings' && WP::in_wp_admin() ) { wp_safe_redirect( $this->get_admin_page_url() ); exit(); } // Add the Settings link to a plugin on Plugins page. add_filter( 'plugin_action_links_' . plugin_basename( EasyWPSMTP_PLUGIN_FILE ), [ $this, 'add_plugin_action_link' ], 10, 1 ); // Add the options page. add_action( 'admin_menu', [ $this, 'add_admin_options_page' ] ); // Add inline styles for "Upgrade to Pro" left sidebar menu item. add_action( 'admin_head', [ $this, 'style_upgrade_pro_link' ] ); // Add network-wide setting page for product education. add_action( 'network_admin_menu', [ $this, 'add_network_wide_setting_product_education_page' ] ); // Register on load Email Log admin menu hook. add_action( 'load-' . $this->get_admin_page_hook( 'logs' ), [ $this, 'maybe_redirect_email_log_menu_to_email_log_settings_tab' ] ); // Enqueue admin area scripts and styles. add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] ); // Process the admin page forms actions. add_action( 'admin_init', [ $this, 'process_actions' ] ); // Display custom notices based on the error/success codes. add_action( 'admin_init', [ $this, 'display_custom_auth_notices' ] ); // Display notice instructing the user to complete plugin setup. add_action( 'admin_init', [ $this, 'display_setup_notice' ] ); // Outputs the plugin admin header. add_action( 'in_admin_header', [ $this, 'display_admin_header' ], 100 ); // Admin footer text. add_filter( 'admin_footer_text', [ $this, 'get_admin_footer' ], 1, 2 ); // Outputs the plugin promotional admin footer. add_action( 'in_admin_footer', [ $this, 'display_admin_footer' ] ); // Outputs the plugin version in the admin footer. add_filter( 'update_footer', [ $this, 'display_update_footer' ], PHP_INT_MAX ); // Hide all unrelated to the plugin notices on the plugin admin pages. add_action( 'admin_print_scripts', [ $this, 'hide_unrelated_notices' ] ); // Process all AJAX requests. add_action( 'wp_ajax_easy_wp_smtp_ajax', [ $this, 'process_ajax' ] ); // Init parent admin pages. if ( WP::in_wp_admin() || WP::is_doing_self_ajax() ) { add_action( 'init', [ $this, 'get_parent_pages' ] ); } // Manage other admin notices. add_action( 'admin_init', [ $this, 'manage_other_admin_notices' ] ); ( new UserFeedback() )->init(); ( new SetupWizard() )->hooks(); // Enable "Compact Mode" menu view. if ( $this->is_top_level_menu_hidden() ) { if ( $this->is_admin_page() ) { global $pagenow; // Redirect from `options-general.php`. if ( WP::in_wp_admin() && $pagenow === 'options-general.php' && $this->get_current_tab() !== 'auth' ) { /** * Filter the default redirect URL for the * main menu entry while in compact mode. * * @since 2.7.0 * * @param string $url Redirect URL. */ $redirect_url = apply_filters( 'easy_wp_smtp_compact_mode_redirect_url', $this->get_admin_page_url() ); wp_safe_redirect( $redirect_url ); exit(); } // Highlight "Settings -> Easy WP SMTP" menu item on any plugin admin page. add_filter( 'submenu_file', function () { return self::SLUG; } ); } // Hide all top level pages from "Settings" submenu. add_action( 'admin_head', function () { global $submenu; if ( isset( $submenu['options-general.php'] ) && is_array( $submenu['options-general.php'] ) ) { foreach ( $submenu['options-general.php'] as $key => $menu_item ) { if ( isset( $menu_item[2] ) && strpos( $menu_item[2], self::SLUG . '-' ) !== false ) { unset( $submenu['options-general.php'][ $key ] ); } } } } ); } } /** * Display custom notices based on the error/success codes. * * @since 2.1.0 */ public function display_custom_auth_notices() { $error = isset( $_GET['error'] ) ? sanitize_key( $_GET['error'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $success = isset( $_GET['success'] ) ? sanitize_key( $_GET['success'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( empty( $error ) && empty( $success ) ) { return; } if ( ! current_user_can( easy_wp_smtp()->get_capability_manage_options() ) ) { return; } switch ( $error ) { case 'oauth_invalid_state': WP::add_admin_notice( esc_html__( 'There was an error while processing the authentication request. The state key is invalid. Please try again.', 'easy-wp-smtp' ), WP::ADMIN_NOTICE_ERROR ); break; } } /** * Display notice instructing the user to complete plugin setup. * * @since 2.0.0 */ public function display_setup_notice() { // Bail if we're not on a plugin page. if ( ! $this->is_admin_page( 'general' ) ) { return; } $default_options = wp_json_encode( Options::get_defaults() ); $current_options = wp_json_encode( Options::init()->get_all() ); // Check if the current settings are the same as the default settings. if ( $current_options !== $default_options ) { return; } // Display notice informing user further action is needed. WP::add_admin_notice( sprintf( wp_kses( /* translators: %s - Mailer anchor link. */ __( 'Thanks for using Easy WP SMTP! To complete the plugin setup and start sending emails, <strong>please select and configure your <a href="%s">Mailer</a></strong>.', 'easy-wp-smtp' ), [ 'a' => [ 'href' => [], ], 'strong' => [], ] ), easy_wp_smtp()->get_admin()->get_admin_page_url( self::SLUG . '#easy-wp-smtp-setting-row-mailer' ) ), WP::ADMIN_NOTICE_INFO ); } /** * Get menu item position. * * @since 2.0.0 * * @return int */ public function get_menu_item_position() { /** * Filters menu item position. * * @since 2.0.0 * * @param int $position Position number. */ return apply_filters( 'easy_wp_smtp_admin_area_get_menu_item_position', 98 ); } /** * Add admin area menu item. * * @since 2.0.0 */ public function add_admin_options_page() { // Options pages access capability. $access_capability = easy_wp_smtp()->get_capability_manage_options(); if ( $this->is_top_level_menu_hidden() ) { $this->hook = add_options_page( esc_html__( 'Easy WP SMTP', 'easy-wp-smtp' ), esc_html__( 'Easy WP SMTP', 'easy-wp-smtp' ), $access_capability, self::SLUG, [ $this, 'display' ] ); } else { $this->hook = add_menu_page( esc_html__( 'Easy WP SMTP', 'easy-wp-smtp' ), esc_html__( 'Easy WP SMTP', 'easy-wp-smtp' ), $access_capability, self::SLUG, [ $this, 'display' ], 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMTMiIHZpZXdCb3g9IjAgMCAyMCAxMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTUuODgyMTEgMTEuMzI4NkM2LjAxMzM2IDExLjI1ODggNi4xNjAxMiAxMS4yMjIyIDYuMzA5MjIgMTEuMjIyMkwxMy42OTA4IDExLjIyMjJDMTMuODM5OSAxMS4yMjIyIDEzLjk4NjYgMTEuMjU4OCAxNC4xMTc5IDExLjMyODZDMTQuOTQxMiAxMS43NjY2IDE0LjYyNiAxMyAxMy42OTA4IDEzTDYuMzA5MjEgMTNDNS4zNzQwMSAxMyA1LjA1ODgzIDExLjc2NjYgNS44ODIxMSAxMS4zMjg2WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTMuMTMzNTQgOC4yMTc0OUMzLjI2MjYzIDguMTQ3NjcgMy40MDY5OCA4LjExMTExIDMuNTUzNjIgOC4xMTExMUwxNi40NDY0IDguMTExMTFDMTYuNTkzIDguMTExMTEgMTYuNzM3NCA4LjE0NzY3IDE2Ljg2NjUgOC4yMTc0OUMxNy42NzYyIDguNjU1NSAxNy4zNjYyIDkuODg4ODkgMTYuNDQ2NCA5Ljg4ODg5TDMuNTUzNjIgOS44ODg4OUMyLjYzMzc5IDkuODg4ODkgMi4zMjM4IDguNjU1NSAzLjEzMzU0IDguMjE3NDlaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMi4yNjMzNCAwLjUzNjY5M0MyLjEyMTQyIDAuNjY4NzY5IDEuOTk5MDIgMC44MjQwMzMgMS45MDIzNSAwLjk5ODgyM0wwLjIzNTM4MiA0LjAxMjg3Qy0wLjQ1MTQ3OCA1LjI1NDc4IDAuNDQ3MTA2IDYuNzc3NzggMS44NjY3MSA2Ljc3Nzc4TDE4LjEzMzMgNi43Nzc3OEMxOS41NTI5IDYuNzc3NzggMjAuNDUxNSA1LjI1NDc4IDE5Ljc2NDYgNC4wMTI4N0wxOC4wOTc2IDAuOTk4ODIyQzE3Ljk5MjMgMC44MDgzNTQgMTcuODU2NCAwLjY0MTA3MiAxNy42OTggMC41MDE3MTRDMTYuMDk1OSAxLjUwNTg5IDEyLjA5MTQgMy44NzM3NyA5Ljk1MjcyIDMuODczNzdDNy44Mzg0OSAzLjg3Mzc3IDMuOTAwODcgMS41NTk3MyAyLjI2MzM0IDAuNTM2NjkzWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTIuOTYwNjMgMC4xMjcyMzNDNC43MjU2NiAxLjE5ODU5IDguMDk5MzEgMy4wODY3NyA5Ljk1MjcyIDMuMDg2NzdDMTEuODE3MiAzLjA4Njc3IDE1LjIyMDIgMS4xNzU5MiAxNi45NzYzIDAuMTA4MDlDMTYuODEyNyAwLjA2MTU0MjMgMTYuNjQxMyAwLjAzNzAzOSAxNi40NjYzIDAuMDM3MDM5TDMuNTMzNjggMC4wMzcwMzc4QzMuMzM2MTIgMC4wMzcwMzc5IDMuMTQzMSAwLjA2ODI4MjcgMi45NjA2MyAwLjEyNzIzM1oiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=', $this->get_menu_item_position() ); add_submenu_page( self::SLUG, $this->get_current_tab_title() . ' ‹ ' . esc_html__( 'Settings', 'easy-wp-smtp' ), esc_html__( 'Settings', 'easy-wp-smtp' ), $access_capability, self::SLUG, [ $this, 'display' ] ); add_submenu_page( self::SLUG, esc_html__( 'Send a Test', 'easy-wp-smtp' ), esc_html__( 'Send a Test', 'easy-wp-smtp' ), $access_capability, self::SLUG . '-tools&tab=test', [ $this, 'display' ] ); } $parent_slug = $this->is_top_level_menu_hidden() ? 'options-general.php' : self::SLUG; add_submenu_page( $parent_slug, esc_html__( 'Email Log', 'easy-wp-smtp' ), esc_html__( 'Email Log', 'easy-wp-smtp' ), $this->get_logs_access_capability(), self::SLUG . '-logs', [ $this, 'display' ] ); foreach ( $this->get_parent_pages() as $page ) { add_submenu_page( $parent_slug, esc_html( $page->get_title() ), esc_html( $page->get_label() ), $access_capability, self::SLUG . '-' . $page->get_slug(), [ $this, 'display' ] ); } if ( ! easy_wp_smtp()->is_pro() ) { add_submenu_page( self::SLUG, esc_html__( 'Upgrade to Pro', 'easy-wp-smtp' ), esc_html__( 'Upgrade to Pro', 'easy-wp-smtp' ), $access_capability, // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound esc_url( easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'admin-menu', 'content' => 'Upgrade to Pro' ] ) ) ); } } /** * Redirect the "Email Log" WP menu link to the "Email Log" setting tab for lite version of the plugin. * * @since 2.1.0 */ public function maybe_redirect_email_log_menu_to_email_log_settings_tab() { /** * The Email Logs object to be used for loading the Email Log page. * * @var \EasyWPSMTP\Admin\PageAbstract $logs */ $logs = $this->generate_display_logs_object(); if ( $logs instanceof \EasyWPSMTP\Admin\Pages\Logs ) { wp_safe_redirect( $logs->get_link() ); exit; } } /** * Enqueue admin area scripts and styles. * * @since 2.0.0 * * @param string $hook Current hook. */ public function enqueue_assets( $hook ) { if ( strpos( $hook, self::SLUG ) === false ) { return; } // Set general body class. add_filter( 'admin_body_class', function ( $classes ) { $classes .= ' easy-wp-smtp-admin-page-body'; if ( easy_wp_smtp()->is_pro() ) { $classes .= ' easy-wp-smtp-pro'; } else { $classes .= ' easy-wp-smtp-lite'; } if ( apply_filters( 'easy_wp_smtp_admin_area_full_width_page', false ) ) { $classes .= ' easy-wp-smtp-full-width-page'; } return $classes; } ); // General styles and js. wp_enqueue_style( 'easy-wp-smtp-admin', easy_wp_smtp()->assets_url . '/css/smtp-admin.min.css', false, EasyWPSMTP_PLUGIN_VERSION ); wp_enqueue_script( 'underscore' ); wp_enqueue_script( 'easy-wp-smtp-admin', easy_wp_smtp()->assets_url . '/js/smtp-admin' . WP::asset_min() . '.js', [ 'jquery', 'underscore' ], EasyWPSMTP_PLUGIN_VERSION, false ); $script_data = [ 'text_provider_remove' => esc_html__( 'Are you sure you want to reset the current provider connection? You will need to immediately create a new one to be able to send emails.', 'easy-wp-smtp' ), 'text_settings_not_saved' => esc_html__( 'Changes that you made to the settings are not saved!', 'easy-wp-smtp' ), 'default_mailer_notice' => [ 'title' => esc_html__( 'Heads up!', 'easy-wp-smtp' ), 'content' => wp_kses( __( '<p>The Default (PHP) mailer is currently selected, but is not recommended because in most cases it does not resolve email delivery issues.</p><p>Please consider selecting and configuring one of the other mailers.</p>', 'easy-wp-smtp' ), [ 'p' => [] ] ), 'save_button' => esc_html__( 'Save Settings', 'easy-wp-smtp' ), 'cancel_button' => esc_html__( 'Cancel', 'easy-wp-smtp' ), 'icon_alt' => esc_html__( 'Warning icon', 'easy-wp-smtp' ), ], 'plugin_url' => easy_wp_smtp()->plugin_url, 'education' => [ 'upgrade_icon_lock' => '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="lock" class="svg-inline--fa fa-lock fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M400 224h-24v-72C376 68.2 307.8 0 224 0S72 68.2 72 152v72H48c-26.5 0-48 21.5-48 48v192c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V272c0-26.5-21.5-48-48-48zm-104 0H152v-72c0-39.7 32.3-72 72-72s72 32.3 72 72v72z"></path></svg>', 'upgrade_title' => esc_html__( '%name% is a PRO Feature', 'easy-wp-smtp' ), 'upgrade_content' => esc_html__( 'Sorry, but the %name% mailer isn’t available in the lite version. Please upgrade to PRO to unlock this mailer and much more.', 'easy-wp-smtp' ), 'upgrade_button' => esc_html__( 'Upgrade to Pro', 'easy-wp-smtp' ), 'upgrade_url' => add_query_arg( 'discount', 'SMTPLITEUPGRADE', easy_wp_smtp()->get_upgrade_link( '' ) ), 'upgrade_bonus' => '<div class="easy-wp-smtp-upgrade-bonus-badge"><span>' . sprintf( wp_kses( /* Translators: %s - discount value 50%. */ __( '<strong>%s OFF</strong> for Easy WP SMTP users, applied at checkout.', 'easy-wp-smtp' ), [ 'strong' => [], ] ), '50%' ) . '</span></div>', 'upgrade_doc' => sprintf( '<a href="%1$s" target="_blank" rel="noopener noreferrer">%2$s</a>', // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound esc_url( easy_wp_smtp()->get_utm_url( 'https://easywpsmtp.com/docs/how-to-upgrade-easy-wp-smtp-to-pro-version/', [ 'medium' => 'plugin-settings', 'content' => 'Pro Mailer Popup - Already purchased' ] ) ), esc_html__( 'Already purchased?', 'easy-wp-smtp' ) ), 'rate_limit' => [ 'upgrade_title' => esc_html__( 'Email Rate Limiting is a Pro Feature', 'easy-wp-smtp' ), 'upgrade_content' => esc_html__( 'We\'re sorry, Email Rate Limiting is not available on your plan. Please upgrade to the Pro plan to unlock all these awesome features.', 'easy-wp-smtp' ), ], ], 'all_mailers_supports' => easy_wp_smtp()->get_providers()->get_supports_all(), 'nonce' => wp_create_nonce( 'easy-wp-smtp-admin' ), 'is_network_admin' => is_network_admin(), 'ajax_url' => admin_url( 'admin-ajax.php' ), 'icon' => esc_html__( 'Icon', 'easy-wp-smtp' ), 'heads_up_title' => esc_html__( 'Heads up!', 'easy-wp-smtp' ), 'yes_text' => esc_html__( 'Yes', 'easy-wp-smtp' ), 'cancel_text' => esc_html__( 'Cancel', 'easy-wp-smtp' ), 'ok_text' => esc_html__( 'OK', 'easy-wp-smtp' ), 'error_occurred' => esc_html__( 'An error occurred!', 'easy-wp-smtp' ), 'lang_code' => sanitize_key( WP::get_language_code() ), 'clear_debug_log' => esc_html__( 'Are you sure want to clear log?', 'easy-wp-smtp' ), 'debug_log_cleared' => esc_html__( 'Log cleared.', 'easy-wp-smtp' ), ]; /** * Filters plugin script data. * * @since 2.0.0 * * @param array $script_data Data. * @param string $hook Current hook. */ $script_data = apply_filters( 'easy_wp_smtp_admin_area_enqueue_assets_scripts_data', $script_data, $hook ); wp_localize_script( 'easy-wp-smtp-admin', 'easy_wp_smtp', $script_data ); /* * jQuery Confirm library v3.3.4. */ wp_enqueue_style( 'easy-wp-smtp-admin-jconfirm', easy_wp_smtp()->assets_url . '/css/vendor/jquery-confirm.min.css', [ 'easy-wp-smtp-admin' ], '3.3.4' ); wp_enqueue_script( 'easy-wp-smtp-admin-jconfirm', easy_wp_smtp()->assets_url . '/js/vendor/jquery-confirm.min.js', [ 'easy-wp-smtp-admin' ], '3.3.4', false ); /* * Logs page. */ if ( $this->is_admin_page( 'logs' ) ) { wp_enqueue_style( 'easy-wp-smtp-admin-logs', apply_filters( 'easy_wp_smtp_admin_enqueue_assets_logs_css', '' ), [ 'easy-wp-smtp-admin' ], EasyWPSMTP_PLUGIN_VERSION ); wp_enqueue_script( 'easy-wp-smtp-admin-logs', apply_filters( 'easy_wp_smtp_admin_enqueue_assets_logs_js', '' ), [ 'easy-wp-smtp-admin' ], EasyWPSMTP_PLUGIN_VERSION, false ); } /** * Fires after enqueue plugin assets. * * @since 2.0.0 * * @param string $hook Current hook. */ do_action( 'easy_wp_smtp_admin_area_enqueue_assets', $hook ); } /** * Whether a page is visible while in Compact Mode. * * @since 2.7.0 * * @param string $page Page slug. * @param string $tab Tab slug. * * @return bool */ private function compact_mode_can_access_page( $page = '', $tab = '' ) { /** * Filters whether a page is visible while in Compact Mode. * * @since 2.7.0 * * @param bool $visible Whether the page is visible. Default true. * @param string $page Page slug. * @param string $tab Tab slug. * * @return bool */ return apply_filters( 'easy_wp_smtp_compact_mode_can_access_page', true, $page, $tab ); } /** * Outputs the plugin admin header. * * @since 2.0.0 */ public function display_admin_header() { // Bail if we're not on a plugin page. if ( ! $this->is_admin_page() ) { return; } do_action( 'easy_wp_smtp_admin_header_before' ); ?> <div id="easy-wp-smtp-header-temp"></div> <div class="easy-wp-smtp-header"> <div class="easy-wp-smtp-header__inner easy-wp-smtp-container"> <img class="easy-wp-smtp-header__logo" src="<?php echo esc_url( easy_wp_smtp()->assets_url ); ?>/images/logo.svg" alt="Easy WP SMTP"/> <?php if ( $this->is_top_level_menu_hidden() ) : ?> <div class="easy-wp-smtp-header-menu easy-wp-smtp-header__menu"> <?php if ( $this->compact_mode_can_access_page( self::SLUG ) ) : ?> <a href="<?php echo esc_url( $this->get_admin_page_url() ); ?>" class="easy-wp-smtp-header-menu__link<?php echo $this->is_admin_page( 'general' ) ? ' easy-wp-smtp-header-menu__link--active' : ''; ?>"><?php esc_html_e( 'General', 'easy-wp-smtp' ); ?></a> <?php endif; ?> <?php if ( $this->compact_mode_can_access_page( self::SLUG . '-tools', 'test' ) ) : ?> <a href="<?php echo esc_url( $this->get_admin_page_url( self::SLUG . '-tools', 'test' ) ); ?>" class="easy-wp-smtp-header-menu__link"><?php esc_html_e( 'Send a Test', 'easy-wp-smtp' ); ?></a> <?php endif; ?> <?php if ( $this->compact_mode_can_access_page( self::SLUG . '-logs' ) ) : ?> <a href="<?php echo esc_url( $this->get_admin_page_url( self::SLUG . '-logs' ) ); ?>" class="easy-wp-smtp-header-menu__link<?php echo $this->is_admin_page( 'logs' ) ? ' easy-wp-smtp-header-menu__link--active' : ''; ?>"><?php esc_html_e( 'Email Log', 'easy-wp-smtp' ); ?></a> <?php endif; ?> <?php foreach ( $this->get_parent_pages() as $parent_page ) : ?> <?php if ( $this->compact_mode_can_access_page( self::SLUG . '-' . $parent_page->get_slug() ) ) : ?> <a href="<?php echo esc_url( $parent_page->get_link() ); ?>" class="easy-wp-smtp-header-menu__link<?php echo $this->is_admin_page( $parent_page->get_slug() ) ? ' easy-wp-smtp-header-menu__link--active' : ''; ?>"><?php echo esc_html( $parent_page->get_label() ); ?></a> <?php endif; ?> <?php endforeach; ?> </div> <?php endif; ?> <a class="easy-wp-smtp-header__help-link" href="<?php echo esc_url( easy_wp_smtp()->get_utm_url( 'https://easywpsmtp.com/docs/', [ 'medium' => 'Top Header', 'content' => 'Help Link' ] ) ); ?>" target="_blank" rel="noopener noreferrer"> <svg width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#a)" fill="currentColor"><path d="M8 14.222A6.222 6.222 0 1 1 8 1.778a6.222 6.222 0 0 1 0 12.444zm0 .89A7.111 7.111 0 1 0 8 .888 7.111 7.111 0 0 0 8 15.11z" stroke="#53536B" stroke-width=".444"/><path d="M5.56 6.032a.21.21 0 0 0 .214.22h.734c.122 0 .22-.1.236-.223.08-.583.48-1.008 1.193-1.008.61 0 1.168.305 1.168 1.039 0 .564-.333.824-.858 1.218-.598.435-1.072.942-1.038 1.766l.003.193a.222.222 0 0 0 .222.219h.72a.222.222 0 0 0 .223-.222V9.14c0-.638.243-.824.898-1.32.541-.412 1.105-.869 1.105-1.828C10.38 4.649 9.246 4 8.004 4c-1.126 0-2.36.524-2.444 2.032zm1.384 5.123c0 .473.378.824.898.824.541 0 .914-.35.914-.824 0-.491-.374-.836-.915-.836-.52 0-.897.345-.897.836z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg> <?php esc_html_e( 'Help', 'easy-wp-smtp' ); ?> </a> </div> </div> <?php } /** * Display a text to ask users to review the plugin on WP.org. * * @since 2.0.0 * * @param string $text The default text to display in admin plugin page footer. * * @return string */ public function get_admin_footer( $text ) { if ( $this->is_admin_page() ) { $url = 'https://wordpress.org/support/plugin/easy-wp-smtp/reviews/#new-post'; $text = sprintf( wp_kses( /* translators: %1$s - WP.org link; %2$s - same WP.org link. */ __( 'Please rate <strong>Easy WP SMTP</strong> <a href="%1$s" target="_blank" rel="noopener noreferrer">★★★★★</a> on <a href="%2$s" target="_blank" rel="noopener noreferrer">WordPress.org</a> to help us spread the word.', 'easy-wp-smtp' ), [ 'strong' => [], 'a' => [ 'href' => [], 'target' => [], 'rel' => [], ], ] ), $url, $url ); } return $text; } /** * Display content of the admin area page. * * @since 2.0.0 */ public function display() { // phpcs:ignore Generic.Metrics.NestingLevel.MaxExceeded // Bail if we're not on a plugin page. if ( ! $this->is_admin_page() ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended $page = ! empty( $_GET['page'] ) ? sanitize_key( $_GET['page'] ) : ''; ?> <div class="wrap" id="easy-wp-smtp"> <?php switch ( $page ) { case self::SLUG: ?> <div class="easy-wp-smtp-page easy-wp-smtp-page-general easy-wp-smtp-tab-<?php echo esc_attr( $this->get_current_tab() ); ?>"> <?php $this->display_tabs(); ?> </div> <?php break; case self::SLUG . '-logs': /** * The Email Logs object to be used for loading the Email Log page. * * @var \EasyWPSMTP\Admin\PageAbstract $logs */ $logs = $this->generate_display_logs_object(); $is_archive = easy_wp_smtp()->is_pro() && easy_wp_smtp()->pro->get_logs()->is_archive(); ?> <div class="easy-wp-smtp-page easy-wp-smtp-page-logs <?php echo $is_archive ? 'easy-wp-smtp-page-logs-archive' : 'easy-wp-smtp-page-logs-single'; ?>"> <?php $logs->display(); ?> </div> <?php break; default: foreach ( $this->get_parent_pages() as $parent_page ) { if ( $page === self::SLUG . '-' . $parent_page->get_slug() ) { ?> <div class="easy-wp-smtp-page easy-wp-smtp-page-<?php echo esc_attr( $parent_page->get_slug() ); ?> easy-wp-smtp-tab-<?php echo esc_attr( $parent_page->get_slug() ); ?>-<?php echo esc_attr( $parent_page->get_current_tab() ); ?>"> <?php $parent_page->display(); ?> </div> <?php break; } } } ?> </div> <?php } /** * Generate the appropriate Email Log page object used for displaying the Email Log page. * * @since 2.1.0 * * @return \EasyWPSMTP\Admin\PageAbstract */ public function generate_display_logs_object() { // Store generated object to make sure that it's created only once. static $logs_object = null; $logs_class = apply_filters( 'easy_wp_smtp_admin_display_get_logs_fqcn', \EasyWPSMTP\Admin\Pages\Logs::class ); if ( $logs_object === null ) { $logs_object = new $logs_class(); } return $logs_object; } /** * Get email logs access capability. * * @since 2.1.0 * * @return string */ public function get_logs_access_capability() { /** * Filter email logs access capability. * * @since 2.1.0 * * @param string $capability Email logs access capability. */ return apply_filters( 'easy_wp_smtp_admin_area_get_logs_access_capability', easy_wp_smtp()->get_capability_manage_options() ); } /** * Display General page tabs. * * @since 2.0.0 */ protected function display_tabs() { ?> <div class="easy-wp-smtp-container"> <div class="easy-wp-smtp-nav-menu"> <div class="easy-wp-smtp-nav-menu__inner"> <?php foreach ( $this->get_pages() as $page_slug => $page ) : $label = $page->get_label(); if ( empty( $label ) ) { continue; } $class = $page_slug === $this->get_current_tab() ? 'easy-wp-smtp-nav-menu__item--active' : ''; ?> <a href="<?php echo esc_url( $page->get_link() ); ?>" class="easy-wp-smtp-nav-menu__item <?php echo esc_attr( $class ); ?>"> <?php echo esc_html( $label ); ?> </a> <?php endforeach; ?> </div> </div> <div class="easy-wp-smtp-page-content"> <h1 class="screen-reader-text"> <?php echo esc_html( $this->get_current_tab_title() ); ?> </h1> <?php do_action( 'easy_wp_smtp_admin_pages_before_content' ); ?> <?php $this->display_current_tab_content(); ?> </div> </div> <?php } /** * Get the current tab content. * * @since 2.0.0 */ public function display_current_tab_content() { $pages = $this->get_pages(); if ( ! array_key_exists( $this->get_current_tab(), $pages ) ) { return; } $pages[ $this->get_current_tab() ]->display(); } /** * Get the current admin area tab. * * @since 2.0.0 * * @return string */ public function get_current_tab() { $current = ''; if ( $this->is_admin_page( 'general' ) ) { $current = ! empty( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'settings'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended } return $current; } /** * Get admin parent pages. * * @since 2.0.0 * * @return ParentPageAbstract[] */ public function get_parent_pages() { static $pages = null; if ( $pages === null ) { $pages = [ 'reports' => new Pages\EmailReports( [ 'reports' => Pages\EmailReportsTab::class, ] ), 'tools' => new Pages\Tools( [ 'test' => Pages\TestTab::class, 'export' => Pages\ExportTab::class, 'action-scheduler' => Pages\ActionSchedulerTab::class, 'debug-events' => Pages\DebugEventsTab::class, ] ), ]; } /** * Filters admin parent pages. * * @since 2.0.0 * * @param ParentPageAbstract[] $pages Parent pages. */ return apply_filters( 'easy_wp_smtp_admin_area_get_parent_pages', $pages ); } /** * Get the array of default registered tabs for General page admin area. * * @since 2.0.0 * * @return PageAbstract[] */ public function get_pages() { if ( empty( $this->pages ) ) { $this->pages = [ 'settings' => new Pages\SettingsTab(), 'logs' => new Pages\LogsTab(), 'alerts' => new Pages\AlertsTab(), 'connections' => new Pages\AdditionalConnectionsTab(), 'routing' => new Pages\SmartRoutingTab(), 'control' => new Pages\ControlTab(), 'misc' => new Pages\MiscTab(), 'auth' => new Pages\AuthTab(), ]; } return apply_filters( 'easy_wp_smtp_admin_get_pages', $this->pages ); } /** * Get the current tab title. * * @since 2.0.0 * * @return string */ public function get_current_tab_title() { $pages = $this->get_pages(); if ( ! array_key_exists( $this->get_current_tab(), $pages ) ) { return ''; } return $pages[ $this->get_current_tab() ]->get_title(); } /** * Check whether we are on an admin page. * * @since 2.0.0 * * @param array|string $slug ID(s) of a plugin page. Possible values: 'general', 'logs', 'about' or array of them. * * @return bool */ public function is_admin_page( $slug = array() ) { // phpcs:ignore Generic.Metrics.NestingLevel.MaxExceeded // phpcs:ignore WordPress.Security.NonceVerification.Recommended $cur_page = isset( $_GET['page'] ) ? sanitize_key( $_GET['page'] ) : ''; $check = self::SLUG; $pages_equal = false; if ( is_string( $slug ) ) { $slug = sanitize_key( $slug ); if ( in_array( $slug, self::$pages_registered, true ) && $slug !== 'general' ) { $check = self::SLUG . '-' . $slug; } $pages_equal = $cur_page === $check; } elseif ( is_array( $slug ) ) { if ( empty( $slug ) ) { $slug = array_map( function ( $v ) { if ( $v === 'general' ) { return Area::SLUG; } return Area::SLUG . '-' . $v; }, self::$pages_registered ); } else { $slug = array_map( function ( $v ) { if ( $v === 'general' ) { return Area::SLUG; } return Area::SLUG . '-' . sanitize_key( $v ); }, $slug ); } $pages_equal = in_array( $cur_page, $slug, true ); } return is_admin() && $pages_equal; } /** * Give ability to use either admin area option or a filter to hide error notices about failed email delivery. * Filter has higher priority and overrides an option. * * @since 2.0.0 * * @return bool */ public function is_error_delivery_notice_enabled() { $is_hard_enabled = (bool) apply_filters( 'easy_wp_smtp_admin_is_error_delivery_notice_enabled', true ); // If someone changed the value to false using a filter - disable completely. if ( ! $is_hard_enabled ) { return false; } return ! (bool) Options::init()->get( 'general', 'email_delivery_errors_hidden' ); } /** * All possible plugin forms manipulation will be done here. * * @since 2.0.0 */ public function process_actions() { // Bail if we're not on a plugin General page. if ( ! $this->is_admin_page( 'general' ) ) { return; } $pages = $this->get_pages(); // Allow to process only own tabs. if ( ! array_key_exists( $this->get_current_tab(), $pages ) ) { return; } // Process POST only if it exists. // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash if ( ! empty( $_POST ) && isset( $_POST['easy-wp-smtp-post'] ) ) { if ( ! empty( $_POST['easy-wp-smtp'] ) ) { $post = $_POST['easy-wp-smtp']; } else { $post = []; } /** * Before process post. * * @since 2.0.0 * * @param array $post POST data. * @param string $page_slug Current page slug. */ do_action( 'easy_wp_smtp_admin_area_process_actions_process_post_before', $post, $pages[ $this->get_current_tab() ]->get_slug() ); $pages[ $this->get_current_tab() ]->process_post( $post ); } // phpcs:enable // This won't do anything for most pages. // Works for plugin page only, when GET params are allowed. $pages[ $this->get_current_tab() ]->process_auth(); } /** * Process all AJAX requests. * * @since 2.0.0 */ public function process_ajax() { $data = []; // Only admins can fire these ajax requests. if ( ! current_user_can( easy_wp_smtp()->get_capability_manage_options() ) ) { wp_send_json_error( $data ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( empty( $_POST['task'] ) ) { wp_send_json_error( $data ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing $task = sanitize_key( $_POST['task'] ); switch ( $task ) { case 'pro_banner_dismiss': if ( ! check_ajax_referer( 'easy-wp-smtp-admin', 'nonce', false ) ) { break; } update_user_meta( get_current_user_id(), 'easy_wp_smtp_pro_banner_dismissed', true ); $data['message'] = esc_html__( 'Easy WP SMTP Pro related message was successfully dismissed.', 'easy-wp-smtp' ); break; case 'notice_dismiss': $dismissal_response = $this->dismiss_notice_via_ajax(); if ( empty( $dismissal_response ) ) { break; } $data['message'] = $dismissal_response; break; default: // Allow custom tasks data processing being added here. $data = apply_filters( 'easy_wp_smtp_admin_process_ajax_' . $task . '_data', $data ); } // Final ability to rewrite all the data, just in case. $data = (array) apply_filters( 'easy_wp_smtp_admin_process_ajax_data', $data, $task ); if ( empty( $data ) ) { wp_send_json_error( $data ); } wp_send_json_success( $data ); } /** * Process the notice dismissal via AJAX call (Post request). * * @since 2.0.0 * * @return false|string */ private function dismiss_notice_via_ajax() { if ( ! check_ajax_referer( 'easy-wp-smtp-admin', 'nonce', false ) ) { return false; } if ( empty( $_POST['notice'] ) ) { return false; } $notice = sanitize_key( $_POST['notice'] ); if ( ! empty( $_POST['mailer'] ) ) { $mailer = sanitize_key( $_POST['mailer'] ); update_user_meta( get_current_user_id(), "easy_wp_smtp_notice_{$notice}_for_{$mailer}_dismissed", true ); return esc_html__( 'Educational notice for this mailer was successfully dismissed.', 'easy-wp-smtp' ); } else { update_user_meta( get_current_user_id(), "easy_wp_smtp_notice_{$notice}_dismissed", true ); return esc_html__( 'Notice was successfully dismissed.', 'easy-wp-smtp' ); } } /** * Add plugin action links on Plugins page (lite version only). * * @since 2.0.0 * * @param array $links Existing plugin action links. * * @return array */ public function add_plugin_action_link( $links ) { // Do not register lite plugin action links if on pro version. if ( easy_wp_smtp()->is_pro() ) { return $links; } $custom['easy-wp-smtp-pro'] = sprintf( '<a href="%1$s" aria-label="%2$s" target="_blank" rel="noopener noreferrer" style="color: #00a32a; font-weight: 700;" onmouseover="this.style.color=\'#008a20\';" onmouseout="this.style.color=\'#00a32a\';" >%3$s</a>', // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound esc_url( easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'all-plugins', 'content' => 'Get Easy WP SMTP Pro' ] ) ), esc_attr__( 'Upgrade to Easy WP SMTP Pro', 'easy-wp-smtp' ), esc_html__( 'Get Easy WP SMTP Pro', 'easy-wp-smtp' ) ); $custom['easy-wp-smtp-settings'] = sprintf( '<a href="%s" aria-label="%s">%s</a>', esc_url( $this->get_admin_page_url() ), esc_attr__( 'Go to Easy WP SMTP Settings page', 'easy-wp-smtp' ), esc_html__( 'Settings', 'easy-wp-smtp' ) ); $custom['easy-wp-smtp-docs'] = sprintf( '<a href="%1$s" target="_blank" aria-label="%2$s" rel="noopener noreferrer">%3$s</a>', // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound esc_url( easy_wp_smtp()->get_utm_url( 'https://easywpsmtp.com/docs/', [ 'medium' => 'all-plugins', 'content' => 'Documentation' ] ) ), esc_attr__( 'Go to EasyWPSMTP.com documentation page', 'easy-wp-smtp' ), esc_html__( 'Docs', 'easy-wp-smtp' ) ); return array_merge( $custom, (array) $links ); } /** * Get plugin admin area page URL. * * @since 2.0.0 * * @param string $page The page slug to add as the page query parameter. * * @return string */ public function get_admin_page_url( $page = '', $tab = '' ) { if ( empty( $page ) ) { $page = self::SLUG; } $args = [ 'page' => $page, ]; if ( ! empty( $tab ) ) { $args['tab'] = $tab; } return add_query_arg( $args, WP::admin_url( 'admin.php' ) ); } /** * Remove all non-Easy WP SMTP plugin notices from our plugin pages. * * @since 2.0.0 */ public function hide_unrelated_notices() { // Bail if we're not on our screen or page. if ( ! $this->is_admin_page() ) { return; } $this->remove_unrelated_actions( 'user_admin_notices' ); $this->remove_unrelated_actions( 'admin_notices' ); $this->remove_unrelated_actions( 'all_admin_notices' ); $this->remove_unrelated_actions( 'network_admin_notices' ); } /** * Whether top level menu is hidden. * * @since 2.0.1 * * @return bool */ public function is_top_level_menu_hidden() { if ( is_multisite() && is_network_admin() ) { return false; } // Apply changes after settings update. if ( isset( $_POST['easy-wp-smtp-post'] ) && isset( $_GET['tab'] ) && $_GET['tab'] === 'misc' ) { return ! empty( $_POST['easy-wp-smtp']['general']['top_level_menu_hidden'] ); } return Options::init()->get( 'general', 'top_level_menu_hidden' ); } /** * Remove all non-Easy WP SMTP notices from the our plugin pages based on the provided action hook. * * @since 2.0.0 * * @param string $action The name of the action. */ private function remove_unrelated_actions( $action ) { global $wp_filter; if ( empty( $wp_filter[ $action ]->callbacks ) || ! is_array( $wp_filter[ $action ]->callbacks ) ) { return; } foreach ( $wp_filter[ $action ]->callbacks as $priority => $hooks ) { foreach ( $hooks as $name => $arr ) { if ( ( // Cover object method callback case. is_array( $arr['function'] ) && isset( $arr['function'][0] ) && is_object( $arr['function'][0] ) && strpos( strtolower( get_class( $arr['function'][0] ) ), 'easywpsmtp' ) !== false ) || ( // Cover class static method callback case. ! empty( $name ) && strpos( strtolower( $name ), 'easywpsmtp' ) !== false ) ) { continue; } unset( $wp_filter[ $action ]->callbacks[ $priority ][ $name ] ); } } } /** * Get admin page hook. * * @since 2.1.0 * * @param string $tab Tab slug. * * @return string */ public function get_admin_page_hook( $tab = '' ) { if ( $this->is_top_level_menu_hidden() ) { $hook = 'settings_page_' . self::SLUG; } elseif ( ! empty( $tab ) ) { $hook = self::SLUG . '_page_' . self::SLUG; } else { $hook = 'toplevel_page_' . self::SLUG; } if ( ! empty( $tab ) ) { $hook .= '-' . $tab; } return $hook; } /** * Display the promotional footer in our plugin pages. * * @since 2.4.0 */ public function display_admin_footer() { //phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh // Bail early on non-plugin pages. if ( ! $this->is_admin_page() ) { return; } $title = esc_html__( 'Made with ♥ by the Easy WP SMTP team', 'easy-wp-smtp' ); $links = [ [ 'url' => easy_wp_smtp()->is_pro() ? easy_wp_smtp()->get_utm_url( 'https://easywpsmtp.com/account/support/', [ 'medium' => 'Plugin Footer', 'content' => 'Contact Support', ] ) : 'https://wordpress.org/support/plugin/easy-wp-smtp/', 'text' => esc_html__( 'Support', 'easy-wp-smtp' ), 'target' => '_blank', ], [ 'url' => easy_wp_smtp()->get_utm_url( 'https://easywpsmtp.com/docs/', [ 'medium' => 'Plugin Footer', 'content' => 'Plugin Documentation', ] ), 'text' => esc_html__( 'Docs', 'easy-wp-smtp' ), 'target' => '_blank', ], ]; $links_count = count( $links ); ?> <div class="easy-wp-smtp-footer-promotion"> <p><?php echo esc_html( $title ); ?></p> <ul class="easy-wp-smtp-footer-promotion-links"> <?php foreach ( $links as $key => $item ) : ?> <li> <?php $attrs = 'href="' . esc_url( $item['url'] ) . '"'; if ( isset( $item['target'] ) ) { $attrs .= ' target="' . esc_attr( $item['target'] ) . '"'; $attrs .= ' rel="noopener noreferrer"'; } $text = esc_html( $item['text'] ); $divider = $links_count !== $key + 1 ? '<span>/</span>' : ''; printf( '<a %1$s>%2$s</a>%3$s', // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped $attrs, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped $text, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped $divider ); ?> </li> <?php endforeach; ?> </ul> </div> <?php } /** * Display the plugin version in the footer of our plugin pages. * * @since 2.4.0 * * @param string $text Text of the footer. */ public function display_update_footer( $text ) { if ( $this->is_admin_page() ) { return 'Easy WP SMTP ' . EasyWPSMTP_PLUGIN_VERSION; } return $text; } /** * Define inline styles for "Upgrade to Pro" left sidebar menu item. * * @since 2.4.0 */ public function style_upgrade_pro_link() { global $submenu; // Bail if plugin menu is not registered. if ( ! isset( $submenu[ self::SLUG ] ) ) { return; } $upgrade_link_position = key( array_filter( $submenu[ self::SLUG ], function ( $item ) { return strpos( urldecode( $item[2] ), 'easywpsmtp.com/lite-upgrade' ) !== false; } ) ); // Bail if "Upgrade to Pro" menu item is not registered. if ( is_null( $upgrade_link_position ) ) { return; } // Prepare a HTML class. // phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited if ( isset( $submenu[ self::SLUG ][ $upgrade_link_position ][4] ) ) { $submenu[ self::SLUG ][ $upgrade_link_position ][4] .= ' easy-wp-smtp-sidebar-upgrade-pro'; } else { $submenu[ self::SLUG ][ $upgrade_link_position ][] = 'easy-wp-smtp-sidebar-upgrade-pro'; } $current_screen = get_current_screen(); $upgrade_utm_content = $current_screen === null ? 'Upgrade to Pro' : 'Upgrade to Pro - ' . $current_screen->base; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $upgrade_utm_content = empty( $_GET['tab'] ) ? $upgrade_utm_content : $upgrade_utm_content . ' -- ' . sanitize_key( $_GET['tab'] ); // Add the correct utm_content to the menu item. $submenu[ self::SLUG ][ $upgrade_link_position ][2] = esc_url( easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'admin-menu', 'content' => $upgrade_utm_content ] ) ); // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound // phpcs:enable WordPress.WP.GlobalVariablesOverride.Prohibited // Output inline styles. echo '<style>a.easy-wp-smtp-sidebar-upgrade-pro { background-color: #0f8a56 !important; color: #fff !important; font-weight: 600 !important; }</style>'; } /** * Add network admin settings page for product education. * * @since 2.7.0 */ public function add_network_wide_setting_product_education_page() { add_menu_page( esc_html__( 'Easy WP SMTP', 'easy-wp-smtp' ), esc_html__( 'Easy WP SMTP', 'easy-wp-smtp' ), easy_wp_smtp()->get_capability_manage_options(), self::SLUG, [ $this, 'display_network_product_education_page' ], 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMTMiIHZpZXdCb3g9IjAgMCAyMCAxMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTUuODgyMTEgMTEuMzI4NkM2LjAxMzM2IDExLjI1ODggNi4xNjAxMiAxMS4yMjIyIDYuMzA5MjIgMTEuMjIyMkwxMy42OTA4IDExLjIyMjJDMTMuODM5OSAxMS4yMjIyIDEzLjk4NjYgMTEuMjU4OCAxNC4xMTc5IDExLjMyODZDMTQuOTQxMiAxMS43NjY2IDE0LjYyNiAxMyAxMy42OTA4IDEzTDYuMzA5MjEgMTNDNS4zNzQwMSAxMyA1LjA1ODgzIDExLjc2NjYgNS44ODIxMSAxMS4zMjg2WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTMuMTMzNTQgOC4yMTc0OUMzLjI2MjYzIDguMTQ3NjcgMy40MDY5OCA4LjExMTExIDMuNTUzNjIgOC4xMTExMUwxNi40NDY0IDguMTExMTFDMTYuNTkzIDguMTExMTEgMTYuNzM3NCA4LjE0NzY3IDE2Ljg2NjUgOC4yMTc0OUMxNy42NzYyIDguNjU1NSAxNy4zNjYyIDkuODg4ODkgMTYuNDQ2NCA5Ljg4ODg5TDMuNTUzNjIgOS44ODg4OUMyLjYzMzc5IDkuODg4ODkgMi4zMjM4IDguNjU1NSAzLjEzMzU0IDguMjE3NDlaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMi4yNjMzNCAwLjUzNjY5M0MyLjEyMTQyIDAuNjY4NzY5IDEuOTk5MDIgMC44MjQwMzMgMS45MDIzNSAwLjk5ODgyM0wwLjIzNTM4MiA0LjAxMjg3Qy0wLjQ1MTQ3OCA1LjI1NDc4IDAuNDQ3MTA2IDYuNzc3NzggMS44NjY3MSA2Ljc3Nzc4TDE4LjEzMzMgNi43Nzc3OEMxOS41NTI5IDYuNzc3NzggMjAuNDUxNSA1LjI1NDc4IDE5Ljc2NDYgNC4wMTI4N0wxOC4wOTc2IDAuOTk4ODIyQzE3Ljk5MjMgMC44MDgzNTQgMTcuODU2NCAwLjY0MTA3MiAxNy42OTggMC41MDE3MTRDMTYuMDk1OSAxLjUwNTg5IDEyLjA5MTQgMy44NzM3NyA5Ljk1MjcyIDMuODczNzdDNy44Mzg0OSAzLjg3Mzc3IDMuOTAwODcgMS41NTk3MyAyLjI2MzM0IDAuNTM2NjkzWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTIuOTYwNjMgMC4xMjcyMzNDNC43MjU2NiAxLjE5ODU5IDguMDk5MzEgMy4wODY3NyA5Ljk1MjcyIDMuMDg2NzdDMTEuODE3MiAzLjA4Njc3IDE1LjIyMDIgMS4xNzU5MiAxNi45NzYzIDAuMTA4MDlDMTYuODEyNyAwLjA2MTU0MjMgMTYuNjQxMyAwLjAzNzAzOSAxNi40NjYzIDAuMDM3MDM5TDMuNTMzNjggMC4wMzcwMzc4QzMuMzM2MTIgMC4wMzcwMzc5IDMuMTQzMSAwLjA2ODI4MjcgMi45NjA2MyAwLjEyNzIzM1oiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=', $this->get_menu_item_position() ); } /** * HTML output for the network admin settings page product education. * * @since 2.7.0 */ public function display_network_product_education_page() { // Skip if not on multisite and not on network admin site. if ( ! is_multisite() || ! is_network_admin() ) { return; } $upgrade_link_url = easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'Multisite', 'content' => 'Upgrade to Easy WP SMTP Pro Link', ] ); $upgrade_button_url = easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'Multisite', 'content' => 'Upgrade to Easy WP SMTP Pro Button', ] ); ?> <div class="wrap" id="easy-wp-smtp"> <div class="easy-wp-smtp-page easy-wp-smtp-page-general easy-wp-smtp-tab-multisite"> <div class="easy-wp-smtp-container"> <div class="easy-wp-smtp-nav-menu"> <div class="easy-wp-smtp-nav-menu__inner"> <a href="#" class="easy-wp-smtp-nav-menu__item easy-wp-smtp-nav-menu__item--active"> <?php esc_html_e( 'Settings', 'easy-wp-smtp' ); ?> </a> </div> </div> <div class="easy-wp-smtp-page-content"> <h1 class="screen-reader-text"> <?php esc_html_e( 'Settings', 'easy-wp-smtp' ); ?> </h1> <?php do_action( 'easy_wp_smtp_admin_pages_before_content' ); ?> <div class="easy-wp-smtp-meta-box"> <div class="easy-wp-smtp-meta-box__header"> <div class="easy-wp-smtp-meta-box__heading"> <?php esc_html_e( 'Multisite', 'easy-wp-smtp' ); ?> </div> <a href="<?php echo esc_url( $upgrade_button_url ); ?>" target="_blank" rel="noopener noreferrer" class="easy-wp-smtp-btn easy-wp-smtp-btn--sm easy-wp-smtp-btn--green"> <?php esc_html_e( 'Upgrade to Pro', 'easy-wp-smtp' ); ?> </a> </div> <div class="easy-wp-smtp-meta-box__content"> <!-- Multisite Section Title --> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-row__desc"> <?php echo wp_kses( sprintf( /* translators: %s - EasyWPSMTP.com Upgrade page URL. */ __( 'Just activate the network-wide settings, and all sites on your network will automatically use the same SMTP configuration. This allows you to set up your SMTP provider only once, saving valuable time. <a href="%s" target="_blank" rel="noopener noreferrer">Upgrade to Easy WP SMTP Pro!</a>', 'easy-wp-smtp' ), esc_url( $upgrade_link_url ) ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], ] ); ?> </div> </div> <!-- Network wide setting --> <div class="easy-wp-smtp-row easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-license_key"> <?php esc_html_e( 'Settings Control', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <label for="easy-wp-smtp-setting-from_name_force" class="easy-wp-smtp-toggle"> <input type="checkbox" value="true" id="easy-wp-smtp-setting-from_name_force" disabled/> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--static"> <?php esc_html_e( 'Make the plugin settings global network-wide', 'easy-wp-smtp' ); ?> </span> </label> <p class="desc"> <?php esc_html_e( 'When disabled, each subsite of the multisite will need to configure its Easy WP SMTP settings separately.', 'easy-wp-smtp' ); ?> <br> <?php esc_html_e( 'When enabled, the global settings will control email sending for all subsites in the multisite network.', 'easy-wp-smtp' ); ?> </p> </div> </div> </div> </div> <a href="<?php echo esc_url( $upgrade_button_url ); ?>" target="_blank" rel="noopener noreferrer" class="easy-wp-smtp-btn easy-wp-smtp-btn--lg easy-wp-smtp-btn--green"> <?php esc_html_e( 'Upgrade to Easy WP SMTP Pro', 'easy-wp-smtp' ); ?> </a> </div> </div> </div> </div> <?php } /** * Manage other admin notices. * * @since 2.12.0 */ public function manage_other_admin_notices() { $user_id = get_current_user_id(); if ( ! $user_id ) { return; } $meta_key = strrev( 'rotnemele' ) . '_admin_notices'; $user_meta = get_user_meta( $user_id, $meta_key, true ); if ( is_array( $user_meta ) && isset( $user_meta['site_mailer_promotion'] ) ) { return; } if ( ! is_array( $user_meta ) ) { $user_meta = []; } $user_meta['site_mailer_promotion'] = 'true'; update_user_meta( $user_id, $meta_key, $user_meta ); } } DomainChecker.php 0000777 00000011317 15252174534 0007773 0 ustar 00 <?php namespace EasyWPSMTP\Admin; use EasyWPSMTP\Helpers\Helpers; /** * Class for interacting with the Domain Checker API. * * @since 2.1.0 */ class DomainChecker { /** * The domain checker API endpoint. * * @since 2.1.0 */ const ENDPOINT = 'https://connect.easywpsmtp.com/domain-check/'; /** * The API results. * * @since 2.1.0 * * @var array */ private $results; /** * The plugin mailer slug. * * @since 2.1.0 * * @var string */ protected $mailer; /** * Verify the domain for the provided mailer and email address and save the API results. * * @since 2.1.0 * * @param string $mailer The plugin mailer. * @param string $email The email address from which the domain will be extracted. * @param string $sending_domain The optional sending domain to check the domain records for. */ public function __construct( $mailer, $email, $sending_domain = '' ) { $this->mailer = $mailer; $params = [ 'mailer' => $mailer, 'email' => base64_encode( $email ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode 'domain' => $sending_domain, ]; $response = wp_remote_get( add_query_arg( $params, self::ENDPOINT ), [ 'user-agent' => Helpers::get_default_user_agent(), ] ); if ( is_wp_error( $response ) ) { $this->results = [ 'success' => false, 'message' => method_exists( $response, 'get_error_message' ) ? $response->get_error_message() : esc_html__( 'Something went wrong. Please try again later.', 'easy-wp-smtp' ), 'checks' => [], ]; } else { $this->results = json_decode( wp_remote_retrieve_body( $response ), true ); } } /** * Simple getter for the API results. * * @since 2.1.0 * * @return array */ public function get_results() { return $this->results; } /** * Check if the domain checker has found any errors. * * @since 2.1.0 * * @return bool */ public function has_errors() { if ( empty( $this->results['success'] ) ) { return true; } if ( empty( $this->results['checks'] ) ) { return false; } $has_error = false; foreach ( $this->results['checks'] as $check ) { if ( $check['state'] === 'error' ) { $has_error = true; break; } } return $has_error; } /** * Check if the domain checker has not found any errors or warnings. * * @since 2.1.0 * * @return bool */ public function no_issues() { if ( empty( $this->results['success'] ) ) { return false; } $no_issues = true; foreach ( $this->results['checks'] as $check ) { if ( in_array( $check['state'], [ 'error', 'warning' ], true ) ) { $no_issues = false; break; } } return $no_issues; } /** * Check if the domain checker support mailer. * * @since 2.1.0 * * @return bool */ public function is_supported_mailer() { return ! in_array( $this->mailer, [ 'mail' ], true ); } /** * Get the domain checker results html. * * @since 2.1.0 * * @return string */ public function get_results_html() { $results = $this->get_results(); $allowed_html = [ 'b' => [], 'i' => [], 'a' => [ 'href' => [], 'target' => [], 'rel' => [], ], ]; $icons = [ 'error' => 'times-circle-red', 'success' => 'check-circle-green', 'warning' => 'exclamation-triangle-orange', ]; ob_start(); ?> <div id="easy-wp-smtp-domain-check-details"> <h2><?php esc_html_e( 'Domain Check Results', 'easy-wp-smtp' ); ?></h2> <?php if ( empty( $results['success'] ) ) : ?> <div class="easy-wp-smtp-notice notice-inline <?php echo $this->is_supported_mailer() ? 'notice-error' : 'notice-warning'; ?>"> <p><?php echo wp_kses( $results['message'], $allowed_html ); ?></p> </div> <?php endif; ?> <?php if ( ! empty( $results['checks'] ) ) : ?> <div class="easy-wp-smtp-domain-check-details-check-list"> <?php foreach ( $results['checks'] as $check ) : ?> <div class="easy-wp-smtp-domain-check-details-check-list-item"> <img src="<?php echo esc_url( easy_wp_smtp()->assets_url . '/images/icons/' . esc_attr( isset( $icons[ $check['state'] ] ) ? $icons[ $check['state'] ] : 'exclamation-triangle-orange' ) . '.svg' ); ?>" class="easy-wp-smtp-domain-check-details-check-list-item-icon" alt="<?php printf( /* translators: %s - item state name. */ esc_attr__( '%s icon', 'easy-wp-smtp' ), esc_attr( $check['state'] ) ); ?>"> <div class="easy-wp-smtp-domain-check-details-check-list-item-content"> <h3><?php echo esc_html( $check['type'] ); ?></h3> <p><?php echo wp_kses( $check['message'], $allowed_html ); ?></p> </div> </div> <?php endforeach; ?> </div> <?php endif; ?> </div> <?php return ob_get_clean(); } } DebugEvents/Table.php 0000777 00000033665 15252174534 0010553 0 ustar 00 <?php namespace EasyWPSMTP\Admin\DebugEvents; use EasyWPSMTP\Helpers\Helpers; if ( ! class_exists( 'WP_List_Table', false ) ) { require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php'; } /** * Class Table that displays the list of debug events. * * @since 2.0.0 */ class Table extends \WP_List_Table { /** * Number of debug events by different types. * * @since 2.0.0 * * @var array */ public $counts; /** * Set up a constructor that references the parent constructor. * Using the parent reference to set some default configs. * * @since 2.0.0 */ public function __construct() { // Set parent defaults. parent::__construct( [ 'singular' => 'event', 'plural' => 'events', 'ajax' => false, ] ); // Include polyfill if mbstring PHP extension is not enabled. if ( ! function_exists( 'mb_substr' ) || ! function_exists( 'mb_strlen' ) ) { Helpers::include_mbstring_polyfill(); } } /** * Get the debug event types for filtering purpose. * * @since 2.0.0 * * @return array Associative array of debug event types StatusCode=>Name. */ public function get_types() { return Event::get_types(); } /** * Get the items counts for various types of debug logs. * * @since 2.0.0 */ public function get_counts() { $this->counts = []; // Base params with applied filters. $base_params = $this->get_filters_query_params(); $total_params = $base_params; unset( $total_params['type'] ); $this->counts['total'] = ( new EventsCollection( $total_params ) )->get_count(); foreach ( $this->get_types() as $type => $name ) { $collection = new EventsCollection( array_merge( $base_params, [ 'type' => $type ] ) ); $this->counts[ 'type_' . $type ] = $collection->get_count(); } /** * Filters items counts by various types of debug events. * * @since 2.0.0 * * @param array $counts { * Items counts by types. * * @type integer $total Total items count. * @type integer $status_{$type_key} Items count by type. * } */ $this->counts = apply_filters( 'easy_wp_smtp_admin_debug_events_table_get_counts', $this->counts ); } /** * Retrieve the view types. * * @since 2.0.0 */ public function get_views() { $base_url = $this->get_filters_base_url(); $current_type = $this->get_filtered_types(); $views = []; $views['all'] = sprintf( '<a href="%1$s" %2$s>%3$s <span class="count">(%4$d)</span></a>', esc_url( remove_query_arg( 'type', $base_url ) ), $current_type === false ? 'class="current"' : '', esc_html__( 'All', 'easy-wp-smtp' ), intval( $this->counts['total'] ) ); foreach ( $this->get_types() as $type => $type_label ) { $count = intval( $this->counts[ 'type_' . $type ] ); // Skipping types with no events. if ( $count === 0 && $current_type !== $type ) { continue; } $views[ $type ] = sprintf( '<a href="%1$s" %2$s>%3$s <span class="count">(%4$d)</span></a>', esc_url( add_query_arg( 'type', $type, $base_url ) ), $current_type === $type ? 'class="current"' : '', esc_html( $type_label ), $count ); } /** * Filters debug event item views. * * @since 2.0.0 * * @param array $views { * Debug event items views by types. * * @type string $all Total items view. * @type integer $status_key Items views by type. * } * @param array $counts { * Items counts by types. * * @type integer $total Total items count. * @type integer $status_{$status_key} Items count by types. * } */ return apply_filters( 'easy_wp_smtp_admin_debug_events_table_get_views', $views, $this->counts ); } /** * Define the table columns. * * @since 2.0.0 * * @return array Associative array of slug=>Name columns data. */ public function get_columns() { return [ 'event' => esc_html__( 'Name', 'easy-wp-smtp' ), 'type' => esc_html__( 'Type', 'easy-wp-smtp' ), 'content' => esc_html__( 'Content', 'easy-wp-smtp' ), 'initiator' => esc_html__( 'Source', 'easy-wp-smtp' ), 'created_at' => esc_html__( 'Date', 'easy-wp-smtp' ), ]; } /** * Display the main event title with a link to open event details. * * @since 2.0.0 * * @param Event $item Event object. * * @return string */ public function column_event( $item ) { return '<strong>' . '<a href="#" data-event-id="' . esc_attr( $item->get_id() ) . '"' . ' class="js-easy-wp-smtp-debug-event-preview row-title event-preview" title="' . esc_attr( $item->get_title() ) . '">' . esc_html( $item->get_title() ) . '</a>' . '</strong>'; } /** * Display event's type. * * @since 2.0.0 * * @param Event $item Event object. * * @return string */ public function column_type( $item ) { return esc_html( $item->get_type_name() ); } /** * Display event's content. * * @since 2.0.0 * * @param Event $item Event object. * * @return string */ public function column_content( $item ) { $content = $item->get_content(); if ( mb_strlen( $content ) > 100 ) { $content = mb_substr( $content, 0, 100 ) . '...'; } return wp_kses_post( $content ); } /** * Display event's wp_mail initiator. * * @since 2.0.0 * * @param Event $item Event object. * * @return string */ public function column_initiator( $item ) { return esc_html( $item->get_initiator() ); } /** * Display event's created date. * * @since 2.0.0 * * @param Event $item Event object. * * @return string */ public function column_created_at( $item ) { return $item->get_created_at_formatted(); } /** * Return type filter value or FALSE. * * @since 2.0.0 * * @return bool|integer */ public function get_filtered_types() { if ( ! isset( $_REQUEST['type'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return false; } return intval( $_REQUEST['type'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended } /** * Return date filter value or FALSE. * * @since 2.0.0 * * @return bool|array */ public function get_filtered_dates() { if ( empty( $_REQUEST['date'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return false; } $dates = (array) explode( ' - ', sanitize_text_field( wp_unslash( $_REQUEST['date'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended return array_map( 'sanitize_text_field', $dates ); } /** * Return search filter values or FALSE. * * @since 2.0.0 * * @return bool|array */ public function get_filtered_search() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( empty( $_REQUEST['search'] ) ) { return false; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended return sanitize_text_field( wp_unslash( $_REQUEST['search'] ) ); } /** * Whether the event log is filtered or not. * * @since 2.0.0 * * @return bool */ public function is_filtered() { $is_filtered = false; if ( $this->get_filtered_search() !== false || $this->get_filtered_dates() !== false || $this->get_filtered_types() !== false ) { $is_filtered = true; } return $is_filtered; } /** * Get current filters query parameters. * * @since 2.0.0 * * @return array */ public function get_filters_query_params() { $params = [ 'search' => $this->get_filtered_search(), 'type' => $this->get_filtered_types(), 'date' => $this->get_filtered_dates(), ]; return array_filter( $params, function ( $v ) { return $v !== false; } ); } /** * Get current filters base url. * * @since 2.0.0 * * @return string */ public function get_filters_base_url() { $base_url = DebugEvents::get_page_url(); $filters_params = $this->get_filters_query_params(); if ( isset( $filters_params['search'] ) ) { $base_url = add_query_arg( 'search', $filters_params['search'], $base_url ); } if ( isset( $filters_params['type'] ) ) { $base_url = add_query_arg( 'type', $filters_params['type'], $base_url ); } if ( isset( $filters_params['date'] ) ) { $base_url = add_query_arg( 'date', implode( ' - ', $filters_params['date'] ), $base_url ); } return $base_url; } /** * Get the data, prepare pagination, process bulk actions. * Prepare columns for display. * * @since 2.0.0 */ public function prepare_items() { // Retrieve count. $this->get_counts(); // Prepare all the params to pass to our Collection. All sanitization is done in that class. $params = $this->get_filters_query_params(); // Total amount for pagination with WHERE clause - super quick count DB request. $total_items = ( new EventsCollection( $params ) )->get_count(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! empty( $_REQUEST['orderby'] ) && in_array( $_REQUEST['orderby'], [ 'event', 'type', 'content', 'initiator', 'created_at' ], true ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $params['orderby'] = sanitize_key( $_REQUEST['orderby'] ); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! empty( $_REQUEST['order'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $params['order'] = strtoupper( sanitize_text_field( wp_unslash( $_REQUEST['order'] ) ) ) === 'DESC' ? 'DESC' : 'ASC'; } $params['offset'] = ( $this->get_pagenum() - 1 ) * EventsCollection::$per_page; // Get the data from the DB using parameters defined above. $collection = new EventsCollection( $params ); $this->items = $collection->get(); /* * Register our pagination options & calculations. */ $this->set_pagination_args( [ 'total_items' => $total_items, 'per_page' => EventsCollection::$per_page, ] ); } /** * Display the search box. * * @since 2.0.0 * * @param string $text The 'submit' button label. * @param string $input_id ID attribute value for the search input field. */ public function search_box( $text, $input_id ) { if ( ! $this->is_filtered() && ! $this->has_items() ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended $search = ! empty( $_REQUEST['search'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['search'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! empty( $_REQUEST['orderby'] ) && in_array( $_REQUEST['orderby'], [ 'event', 'type', 'content', 'initiator', 'created_at' ], true ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $order_by = sanitize_text_field( wp_unslash( $_REQUEST['orderby'] ) ); echo '<input type="hidden" name="orderby" value="' . esc_attr( $order_by ) . '" />'; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! empty( $_REQUEST['order'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $order = strtoupper( sanitize_text_field( wp_unslash( $_REQUEST['order'] ) ) ) === 'DESC' ? 'DESC' : 'ASC'; echo '<input type="hidden" name="order" value="' . esc_attr( $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="search" value="<?php echo esc_attr( $search ); ?>" /> <?php submit_button( $text, '', '', false, [ 'id' => 'search-submit' ] ); ?> </p> <?php } /** * Whether the table has items to display or not. * * @since 2.0.0 * * @return bool */ public function has_items() { return count( $this->items ) > 0; } /** * Message to be displayed when there are no items. * * @since 2.0.0 */ public function no_items() { if ( $this->is_filtered() ) { esc_html_e( 'No events found.', 'easy-wp-smtp' ); } else { esc_html_e( 'No events have been logged for now.', 'easy-wp-smtp' ); } } /** * Displays the table. * * @since 2.0.0 */ public function display() { $this->_column_headers = [ $this->get_columns(), [], [] ]; parent::display(); } /** * Hide the tablenav if there are no items in the table. * And remove the bulk action nonce and code. * * @since 2.0.0 * * @param string $which Which tablenav: top or bottom. */ protected function display_tablenav( $which ) { if ( ! $this->has_items() ) { return; } ?> <div class="tablenav <?php echo esc_attr( $which ); ?>"> <?php $this->extra_tablenav( $which ); $this->pagination( $which ); ?> <br class="clear" /> </div> <?php } /** * Extra controls to be displayed between bulk actions and pagination. * * @since 2.0.0 * * @param string $which Which tablenav: top or bottom. */ protected function extra_tablenav( $which ) { if ( $which !== 'top' || ! $this->has_items() ) { return; } $date = $this->get_filtered_dates() !== false ? implode( ' - ', $this->get_filtered_dates() ) : ''; ?> <div class="alignleft actions easy-wp-smtp-filter-date"> <input type="text" name="date" class="regular-text easy-wp-smtp-filter-date-selector easy-wp-smtp-filter-date__control" placeholder="<?php esc_attr_e( 'Select a date range', 'easy-wp-smtp' ); ?>" value="<?php echo esc_attr( $date ); ?>"> <button type="submit" name="action" value="filter_date" class="button easy-wp-smtp-filter-date__btn"> <?php esc_html_e( 'Filter', 'easy-wp-smtp' ); ?> </button> </div> <?php if ( current_user_can( easy_wp_smtp()->get_capability_manage_options() ) ) { wp_nonce_field( 'easy_wp_smtp_debug_events', 'easy-wp-smtp-debug-events-nonce', false ); printf( '<button id="easy-wp-smtp-delete-all-debug-events-button" type="button" class="button">%s</button>', esc_html__( 'Delete All Events', 'easy-wp-smtp' ) ); } } /** * Get the name of the primary column. * Important for the mobile view. * * @since 2.0.0 * * @return string The name of the primary column. */ protected function get_primary_column_name() { return 'event'; } } DebugEvents/Migration.php 0000777 00000003071 15252174534 0011441 0 ustar 00 <?php namespace EasyWPSMTP\Admin\DebugEvents; use EasyWPSMTP\Migrations\MigrationAbstract; /** * Debug Events Migration Class. * * @since 2.0.0 */ class Migration extends MigrationAbstract { /** * Version of the debug events database table. * * @since 2.0.0 */ const DB_VERSION = 1; /** * Option key where we save the current debug events DB version. * * @since 2.0.0 */ const OPTION_NAME = 'easy_wp_smtp_debug_events_db_version'; /** * Option key where we save any errors while creating the debug events DB table. * * @since 2.0.0 */ const ERROR_OPTION_NAME = 'easy_wp_smtp_debug_events_db_error'; /** * Create the debug events DB table structure. * * @since 2.0.0 */ protected function migrate_to_1() { global $wpdb; $table = DebugEvents::get_table_name(); $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE IF NOT EXISTS `$table` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `content` TEXT DEFAULT NULL, `initiator` TEXT DEFAULT NULL, `event_type` TINYINT UNSIGNED NOT NULL DEFAULT '0', `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINE='InnoDB' {$charset_collate};"; $result = $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared if ( ! empty( $wpdb->last_error ) ) { update_option( self::ERROR_OPTION_NAME, $wpdb->last_error, false ); } // Save the current version to DB. if ( $result !== false ) { $this->update_db_ver( 1 ); } } } DebugEvents/Event.php 0000777 00000031716 15252174534 0010600 0 ustar 00 <?php namespace EasyWPSMTP\Admin\DebugEvents; use EasyWPSMTP\WP; /** * Debug Event class. * * @since 2.0.0 */ class Event { /** * This is an error event. * * @since 2.0.0 */ const TYPE_ERROR = 0; /** * This is a debug event. * * @since 2.0.0 */ const TYPE_DEBUG = 1; /** * The event's ID. * * @since 2.0.0 * * @var int */ protected $id = 0; /** * The event's content. * * @since 2.0.0 * * @var string */ protected $content = ''; /** * The event's initiator - who called the `wp_mail` function? * JSON encoded string. * * @since 2.0.0 * * @var string */ protected $initiator = ''; /** * The event's type. * * @since 2.0.0 * * @var int */ protected $event_type = 0; /** * The date and time when this event was created. * * @since 2.0.0 * * @var \DateTime */ protected $created_at; /** * Retrieve a particular event when constructing the object. * * @since 2.0.0 * * @param int|object $id_or_row The event ID or object with event attributes. */ public function __construct( $id_or_row = null ) { $this->populate_event( $id_or_row ); } /** * Get and prepare the event data. * * @since 2.0.0 * * @param int|object $id_or_row The event ID or object with event attributes. */ private function populate_event( $id_or_row ) { $event = null; if ( is_numeric( $id_or_row ) ) { // Get by ID. $collection = new EventsCollection( [ 'id' => (int) $id_or_row ] ); $events = $collection->get(); if ( $events->valid() ) { $event = $events->current(); } } elseif ( is_object( $id_or_row ) && isset( $id_or_row->id, $id_or_row->content, $id_or_row->initiator, $id_or_row->event_type, $id_or_row->created_at ) ) { $event = $id_or_row; } if ( $event !== null ) { foreach ( get_object_vars( $event ) as $key => $value ) { $this->{$key} = $value; } } } /** * Event ID as per our DB table. * * @since 2.0.0 * * @return int */ public function get_id() { return (int) $this->id; } /** * Get the event title. * * @since 2.0.0 * * @return string */ public function get_title() { /* translators: %d the event ID. */ return sprintf( esc_html__( 'Event #%d', 'easy-wp-smtp' ), $this->get_id() ); } /** * Get the content of the event. * * @since 2.0.0 * * @return string */ public function get_content() { return $this->content; } /** * Get the event's type. * * @since 2.0.0 * * @return int */ public function get_type() { return (int) $this->event_type; } /** * Get the list of all event types. * * @since 2.0.0 * * @return array */ public static function get_types() { return [ self::TYPE_ERROR => esc_html__( 'Error', 'easy-wp-smtp' ), self::TYPE_DEBUG => esc_html__( 'Debug', 'easy-wp-smtp' ), ]; } /** * Get human readable type name. * * @since 2.0.0 * * @return string */ public function get_type_name() { $types = self::get_types(); return isset( $types[ $this->get_type() ] ) ? $types[ $this->get_type() ] : ''; } /** * Get the date/time when this event was created. * * @since 2.0.0 * * @throws \Exception Emits exception on incorrect date. * * @return \DateTime */ public function get_created_at() { $timezone = new \DateTimeZone( 'UTC' ); $date = false; if ( ! empty( $this->created_at ) ) { $date = \DateTime::createFromFormat( WP::datetime_mysql_format(), $this->created_at, $timezone ); } if ( $date === false ) { $date = new \DateTime( 'now', $timezone ); } return $date; } /** * Get the date/time when this event was created in a nicely formatted string. * * @since 2.0.0 * * @return string */ public function get_created_at_formatted() { try { $date = $this->get_created_at(); } catch ( \Exception $e ) { $date = null; } if ( empty( $date ) ) { return esc_html__( 'N/A', 'easy-wp-smtp' ); } return esc_html( date_i18n( WP::datetime_format(), strtotime( get_date_from_gmt( $date->format( WP::datetime_mysql_format() ) ) ) ) ); } /** * Get the event's initiator raw data. * Who called the `wp_mail` function? * * @since 2.0.0 * * @return array */ public function get_initiator_raw() { return json_decode( $this->initiator, true ); } /** * Get the event's initiator name. * Which plugin/theme (or WP core) called the `wp_mail` function? * * @since 2.0.0 * * @return string */ public function get_initiator() { $initiator = (array) $this->get_initiator_raw(); if ( empty( $initiator['file'] ) ) { return ''; } return WP::get_initiator_name( $initiator['file'] ); } /** * Get the event's initiator type. * * @since 2.0.0 * * @return string */ private function get_initiator_type() { $initiator = (array) $this->get_initiator_raw(); if ( empty( $initiator['file'] ) ) { return ''; } $initiator = WP::get_initiator( $initiator['file'] ); return $initiator['type']; } /** * Get the event's initiator file path. * * @since 2.0.0 * * @return string */ public function get_initiator_file_path() { $initiator = (array) $this->get_initiator_raw(); if ( empty( $initiator['file'] ) ) { return ''; } return $initiator['file']; } /** * Get the event's initiator file line. * * @since 2.0.0 * * @return string */ public function get_initiator_file_line() { $initiator = (array) $this->get_initiator_raw(); if ( empty( $initiator['line'] ) ) { return ''; } return $initiator['line']; } /** * Get the event's initiator backtrace. * * @since 2.0.0 * * @return array */ private function get_initiator_backtrace() { $initiator = (array) $this->get_initiator_raw(); if ( empty( $initiator['backtrace'] ) ) { return []; } return $initiator['backtrace']; } /** * Get the event preview HTML. * * @since 2.0.0 * * @return string */ public function get_details_html() { $initiator = $this->get_initiator(); $initiator_type = $this->get_initiator_type(); $initiator_backtrace = $this->get_initiator_backtrace(); ob_start(); ?> <div class="easy-wp-smtp-debug-event-preview"> <div class="easy-wp-smtp-debug-event-preview-subtitle"> <span><?php esc_html_e( 'Debug Event Details', 'easy-wp-smtp' ); ?></span> </div> <div class="easy-wp-smtp-debug-event-preview-table"> <div class="easy-wp-smtp-debug-event-row easy-wp-smtp-debug-event-preview-type"> <span class="debug-event-label"><?php esc_html_e( 'Type', 'easy-wp-smtp' ); ?></span> <span class="debug-event-value"><?php echo esc_html( $this->get_type_name() ); ?></span> </div> <div class="easy-wp-smtp-debug-event-row easy-wp-smtp-debug-event-preview-date"> <span class="debug-event-label"><?php esc_html_e( 'Date', 'easy-wp-smtp' ); ?></span> <span class="debug-event-value"><?php echo esc_html( $this->get_created_at_formatted() ); ?></span> </div> <div class="easy-wp-smtp-debug-event-row easy-wp-smtp-debug-event-preview-content"> <span class="debug-event-label"><?php esc_html_e( 'Content', 'easy-wp-smtp' ); ?></span> <div class="debug-event-value"> <?php echo wp_kses( str_replace( [ "\r\n", "\r", "\n" ], '<br>', $this->get_content() ), [ 'br' => [] ] ); ?> </div> </div> <?php if ( ! empty( $initiator ) ) : ?> <div class="easy-wp-smtp-debug-event-row easy-wp-smtp-debug-event-preview-caller"> <span class="debug-event-label"><?php esc_html_e( 'Caller', 'easy-wp-smtp' ); ?></span> <div class="debug-event-value"> <span class="debug-event-initiator"> <?php if ( $initiator_type === 'plugin' || $initiator_type === 'mu-plugin' ) { printf( /* translators: %s - caller plugin name. */ esc_html__( 'Plugin: %s', 'easy-wp-smtp' ), esc_html( $initiator ) ); } else if ( $initiator_type === 'theme' ) { printf( /* translators: %s - caller theme name. */ esc_html__( 'Theme: %s', 'easy-wp-smtp' ), esc_html( $initiator ) ); } else { echo esc_html( $initiator ); } ?> </span> <p class="debug-event-code"> <?php printf( /* Translators: %1$s the path of a file, %2$s the line number in the file. */ esc_html__( '%1$s (line: %2$s)', 'easy-wp-smtp' ), esc_html( $this->get_initiator_file_path() ), esc_html( $this->get_initiator_file_line() ) ); ?> <?php if ( ! empty( $initiator_backtrace ) ) : ?> <br><br> <b><?php esc_html_e( 'Backtrace:', 'easy-wp-smtp' ); ?></b> <br> <?php foreach ( $initiator_backtrace as $i => $item ) { printf( /* translators: %1$d - index number; %2$s - function name; %3$s - file path; %4$s - line number. */ esc_html__( '[%1$d] %2$s called at [%3$s:%4$s]', 'easy-wp-smtp' ), $i, isset( $item['class'] ) ? esc_html( $item['class'] . $item['type'] . $item['function'] ) : esc_html( $item['function'] ), isset( $item['file'] ) ? esc_html( $item['file'] ) : '', // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped isset( $item['line'] ) ? esc_html( $item['line'] ) : '' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ); echo '<br>'; } ?> <?php endif; ?> </p> </div> </div> <?php endif; ?> </div> </div> <?php return ob_get_clean(); } /** * Get the short details about this event (event content and the initiator's name). * * @since 2.0.0 * * @return string */ public function get_short_details() { $result = []; if ( ! empty( $this->get_initiator() ) ) { $result[] = sprintf( /* Translators: %s - Email initiator/source name. */ esc_html__( 'Email Source: %s', 'easy-wp-smtp' ), esc_html( $this->get_initiator() ) ); } $result[] = esc_html( $this->get_content() ); return implode( WP::EOL, $result ); } /** * Save a new or modified event in DB. * * @since 2.0.0 * * @throws \Exception When event init fails. * * @return Event New or updated event class instance. */ public function save() { global $wpdb; $table = DebugEvents::get_table_name(); if ( (bool) $this->get_id() ) { // Update the existing DB table record. $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching $table, [ 'content' => $this->content, 'initiator' => $this->initiator, 'event_type' => $this->event_type, 'created_at' => $this->get_created_at()->format( WP::datetime_mysql_format() ), ], [ 'id' => $this->get_id(), ], [ '%s', // content. '%s', // initiator. '%s', // type. '%s', // created_at. ], [ '%d', ] ); $event_id = $this->get_id(); } else { // Create a new DB table record. $wpdb->insert( $table, [ 'content' => $this->content, 'initiator' => $this->initiator, 'event_type' => $this->event_type, 'created_at' => $this->get_created_at()->format( WP::datetime_mysql_format() ), ], [ '%s', // content. '%s', // initiator. '%s', // type. '%s', // created_at. ] ); $event_id = $wpdb->insert_id; } try { $event = new Event( $event_id ); } catch ( \Exception $e ) { $event = new Event(); } return $event; } /** * Set the content of this event. * * @since 2.0.0 * * @param string|array $content The event's content. */ public function set_content( $content ) { if ( ! is_string( $content ) ) { $this->content = wp_json_encode( $content ); } else { $this->content = wp_strip_all_tags( str_replace( '<br>', "\r\n", $content ), false ); } } /** * Set the initiator by checking the backtrace for the wp_mail function call. * * @since 2.0.0 */ public function set_initiator() { $initiator = easy_wp_smtp()->get_wp_mail_initiator(); if ( empty( $initiator->get_file() ) ) { return; } $data['file'] = $initiator->get_file(); if ( ! empty( $initiator->get_line() ) ) { $data['line'] = $initiator->get_line(); } if ( DebugEvents::is_debug_enabled() ) { $data['backtrace'] = $initiator->get_backtrace(); } $this->initiator = wp_json_encode( $data ); } /** * Set the type of this event. * * @since 2.0.0 * * @param int $type The event's type. */ public function set_type( $type ) { $this->event_type = (int) $type; } /** * Whether the event instance is a valid entity to work with. * * @since 2.0.0 */ public function is_valid() { return ! ( empty( $this->id ) || empty( $this->created_at ) ); } /** * Whether this is an error event. * * @since 2.0.0 * * @return bool */ public function is_error() { return self::TYPE_ERROR === $this->get_type(); } /** * Whether this is a debug event. * * @since 2.0.0 * * @return bool */ public function is_debug() { return self::TYPE_DEBUG === $this->get_type(); } } DebugEvents/DebugEvents.php 0000777 00000025705 15252174534 0011733 0 ustar 00 <?php namespace EasyWPSMTP\Admin\DebugEvents; use EasyWPSMTP\Admin\Area; use EasyWPSMTP\Options; use EasyWPSMTP\Tasks\DebugEventsCleanupTask; use EasyWPSMTP\WP; use WP_Error; /** * Debug Events class. * * @since 2.0.0 */ class DebugEvents { /** * Transient name for the error debug events. * * @since 2.4.0 * * @var string */ const ERROR_DEBUG_EVENTS_TRANSIENT = 'easy_wp_smtp_error_debug_events_transient'; /** * Register hooks. * * @since 2.0.0 */ public function hooks() { // Process AJAX requests. add_action( 'wp_ajax_easy_wp_smtp_debug_event_preview', [ $this, 'process_ajax_debug_event_preview' ] ); add_action( 'wp_ajax_easy_wp_smtp_delete_all_debug_events', [ $this, 'process_ajax_delete_all_debug_events' ] ); // Initialize screen options for the Debug Events page. add_action( 'load-' . easy_wp_smtp()->get_admin()->get_admin_page_hook( 'tools' ), [ $this, 'screen_options' ] ); add_filter( 'set-screen-option', [ $this, 'set_screen_options' ], 10, 3 ); add_filter( 'set_screen_option_easy_wp_smtp_debug_events_per_page', [ $this, 'set_screen_options' ], 10, 3 ); // Cancel previous debug events cleanup task if retention period option was changed. add_filter( 'easy_wp_smtp_options_set', [ $this, 'maybe_cancel_debug_events_cleanup_task' ] ); // Detect debug events log retention period constant change. if ( Options::init()->is_const_defined( 'debug_events', 'retention_period' ) ) { add_action( 'admin_init', [ $this, 'detect_debug_events_retention_period_constant_change' ] ); } } /** * Detect debug events retention period constant change. * * @since 2.0.0 */ public function detect_debug_events_retention_period_constant_change() { if ( ! WP::in_wp_admin() ) { return; } if ( Options::init()->is_const_changed( 'debug_events', 'retention_period' ) ) { ( new DebugEventsCleanupTask() )->cancel(); } } /** * Cancel previous debug events cleanup task if retention period option was changed. * * @since 2.0.0 * * @param array $options Currently processed options passed to a filter hook. * * @return array */ public function maybe_cancel_debug_events_cleanup_task( $options ) { if ( isset( $options['debug_events']['retention_period'] ) ) { // If this option has changed, cancel the recurring cleanup task and init again. if ( Options::init()->is_option_changed( $options['debug_events']['retention_period'], 'debug_events', 'retention_period' ) ) { ( new DebugEventsCleanupTask() )->cancel(); } } return $options; } /** * Process AJAX request for deleting all debug event entries. * * @since 2.0.0 */ public function process_ajax_delete_all_debug_events() { if ( empty( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['nonce'] ), 'easy_wp_smtp_debug_events' ) ) { wp_send_json_error( esc_html__( 'Access rejected.', 'easy-wp-smtp' ) ); } if ( ! current_user_can( easy_wp_smtp()->get_capability_manage_options() ) ) { wp_send_json_error( esc_html__( 'You don\'t have the capability to perform this action.', 'easy-wp-smtp' ) ); } if ( ! self::is_valid_db() ) { wp_send_json_error( esc_html__( 'For some reason the database table was not installed correctly. Please contact plugin support team to diagnose and fix the issue.', 'easy-wp-smtp' ) ); } global $wpdb; $table = self::get_table_name(); $sql = "TRUNCATE TABLE `$table`;"; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $result = $wpdb->query( $sql ); if ( $result !== false ) { wp_send_json_success( esc_html__( 'All debug event entries were deleted successfully.', 'easy-wp-smtp' ) ); } wp_send_json_error( sprintf( /* translators: %s - WPDB error message. */ esc_html__( 'There was an issue while trying to delete all debug event entries. Error message: %s', 'easy-wp-smtp' ), $wpdb->last_error ) ); } /** * Process AJAX request for debug event preview. * * @since 2.0.0 */ public function process_ajax_debug_event_preview() { if ( empty( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['nonce'] ), 'easy_wp_smtp_debug_events' ) ) { wp_send_json_error( esc_html__( 'Access rejected.', 'easy-wp-smtp' ) ); } if ( ! current_user_can( easy_wp_smtp()->get_capability_manage_options() ) ) { wp_send_json_error( esc_html__( 'You don\'t have the capability to perform this action.', 'easy-wp-smtp' ) ); } if ( ! self::is_valid_db() ) { wp_send_json_error( esc_html__( 'For some reason the database table was not installed correctly. Please contact plugin support team to diagnose and fix the issue.', 'easy-wp-smtp' ) ); } $event_id = isset( $_POST['id'] ) ? intval( $_POST['id'] ) : false; if ( empty( $event_id ) ) { wp_send_json_error( esc_html__( 'No Debug Event ID provided!', 'easy-wp-smtp' ) ); } $event = new Event( $event_id ); wp_send_json_success( [ 'title' => $event->get_title(), 'content' => $event->get_details_html(), ] ); } /** * Add the debug event to the DB. * * @since 2.0.0 * * @param string $message The event's message. * @param int $type The event's type. * * @return bool|int */ public static function add( $message = '', $type = 0 ) { if ( ! self::is_valid_db() ) { return false; } if ( ! in_array( $type, array_keys( Event::get_types() ), true ) ) { return false; } if ( $type === Event::TYPE_DEBUG && ! self::is_debug_enabled() ) { return false; } try { $event = new Event(); $event->set_type( $type ); $event->set_content( $message ); $event->set_initiator(); return $event->save()->get_id(); } catch ( \Exception $exception ) { return false; } } /** * Save the debug message. * * @since 2.0.0 * * @param string $message The debug message. * * @return bool|int */ public static function add_debug( $message = '' ) { return self::add( $message, Event::TYPE_DEBUG ); } /** * Get the debug message from the provided debug event IDs. * * @since 2.0.0 * * @param array|string|int $ids A single or a list of debug event IDs. * * @return array */ public static function get_debug_messages( $ids ) { global $wpdb; if ( empty( $ids ) ) { return []; } if ( ! self::is_valid_db() ) { return []; } // Convert to a string. if ( is_array( $ids ) ) { $ids = implode( ',', $ids ); } $ids = explode( ',', (string) $ids ); $ids = array_map( 'intval', $ids ); $placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) ); $table = self::get_table_name(); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare $events_data = $wpdb->get_results( $wpdb->prepare( "SELECT id, content, initiator, event_type, created_at FROM {$table} WHERE id IN ( {$placeholders} )", $ids ) ); // phpcs:enable if ( empty( $events_data ) ) { return []; } return array_map( function ( $event_item ) { $event = new Event( $event_item ); return $event->get_short_details(); }, $events_data ); } /** * Returns the number of error debug events in a given time span. * * By default it returns the number of error debug events in the last 30 days. * * @since 2.4.0 * * @param string $span_of_time The time span to count the events for. Default '-30 days'. * * @return int|WP_Error The number of error debug events or WP_Error on failure. */ public static function get_error_debug_events_count( $span_of_time = '-30 days' ) { $timestamp = strtotime( $span_of_time ); if ( ! $timestamp || $timestamp > time() ) { return new WP_Error( 'easy_wp_smtp_admin_debug_events_get_error_debug_events_count_invalid_time', 'Invalid time span.' ); } $transient_key = self::ERROR_DEBUG_EVENTS_TRANSIENT . '_' . sanitize_title_with_dashes( $span_of_time ); $cached_error_events_count = get_transient( $transient_key ); if ( $cached_error_events_count !== false ) { return (int) $cached_error_events_count; } global $wpdb; // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder $sql = $wpdb->prepare( 'SELECT COUNT(*) FROM `%1$s` WHERE event_type = %2$d AND created_at >= "%3$s"', self::get_table_name(), Event::TYPE_ERROR, gmdate( WP::datetime_mysql_format(), $timestamp ) ); // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared $error_events_count = (int) $wpdb->get_var( $sql ); set_transient( $transient_key, $error_events_count, HOUR_IN_SECONDS ); return $error_events_count; } /** * Register the screen options for the debug events page. * * @since 2.0.0 */ public function screen_options() { $screen = get_current_screen(); if ( ! is_object( $screen ) || strpos( $screen->id, easy_wp_smtp()->get_admin()->get_admin_page_hook( 'tools' ) ) === false || // phpcs:ignore WordPress.Security.NonceVerification.Recommended ! isset( $_GET['tab'] ) || $_GET['tab'] !== 'debug-events' ) { return; } add_screen_option( 'per_page', [ 'label' => esc_html__( 'Number of events per page:', 'easy-wp-smtp' ), 'option' => 'easy_wp_smtp_debug_events_per_page', 'default' => EventsCollection::PER_PAGE, ] ); } /** * Set the screen options for the debug events page. * * @since 2.0.0 * * @param bool $keep Whether to save or skip saving the screen option value. * @param string $option The option name. * @param int $value The number of items to use. * * @return bool|int */ public function set_screen_options( $keep, $option, $value ) { if ( 'easy_wp_smtp_debug_events_per_page' === $option ) { return (int) $value; } return $keep; } /** * Whether the email debug for debug events is enabled or not. * * @since 2.0.0 * * @return bool */ public static function is_debug_enabled() { return (bool) Options::init()->get( 'debug_events', 'email_debug' ); } /** * Get the debug events page URL. * * @since 2.0.0 * * @return string */ public static function get_page_url() { return add_query_arg( [ 'tab' => 'debug-events', ], easy_wp_smtp()->get_admin()->get_admin_page_url( Area::SLUG . '-tools' ) ); } /** * Get the DB table name. * * @since 2.0.0 * * @return string Table name, prefixed. */ public static function get_table_name() { global $wpdb; return $wpdb->prefix . 'easywpsmtp_debug_events'; } /** * Whether the DB table exists. * * @since 2.0.0 * * @return bool */ public static function is_valid_db() { global $wpdb; static $is_valid = null; // Return cached value only if table already exists. if ( $is_valid === true ) { return true; } $table = self::get_table_name(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching $is_valid = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s;', $table ) ); return $is_valid; } } DebugEvents/EventsCollection.php 0000777 00000021364 15252174534 0012775 0 ustar 00 <?php namespace EasyWPSMTP\Admin\DebugEvents; use EasyWPSMTP\WP; /** * Debug Events Collection. * * @since 2.0.0 */ class EventsCollection implements \Countable, \Iterator { /** * Default number of log entries per page. * * @since 2.0.0 * * @var int */ const PER_PAGE = 10; /** * Number of log entries per page. * * @since 2.0.0 * * @var int */ public static $per_page; /** * List of all Event instances. * * @since 2.0.0 * * @var array */ private $list = []; /** * List of current collection instance parameters. * * @since 2.0.0 * * @var array */ private $params; /** * Used for \Iterator when iterating through Queue in loops. * * @since 2.0.0 * * @var int */ private $iterator_position = 0; /** * Collection constructor. * $events = new EventsCollection( [ 'type' => 0 ] ); * * @since 2.0.0 * * @param array $params The events collection parameters. */ public function __construct( array $params = [] ) { $this->set_per_page(); $this->params = $this->process_params( $params ); } /** * Set the per page attribute to the screen options value. * * @since 2.0.0 */ protected function set_per_page() { $per_page = (int) get_user_meta( get_current_user_id(), 'easy_wp_smtp_debug_events_per_page', true ); if ( $per_page < 1 ) { $per_page = self::PER_PAGE; } self::$per_page = $per_page; } /** * Verify, sanitize, and populate with default values * all the passed parameters, which participate in DB queries. * * @since 2.0.0 * * @param array $params The events collection parameters. * * @return array */ public function process_params( $params ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded $params = (array) $params; $processed = []; /* * WHERE. */ // Single ID. if ( ! empty( $params['id'] ) ) { $processed['id'] = (int) $params['id']; } // Multiple IDs. if ( ! empty( $params['ids'] ) && is_array( $params['ids'] ) ) { $processed['ids'] = array_unique( array_filter( array_map( 'intval', array_values( $params['ids'] ) ) ) ); } // Type. if ( isset( $params['type'] ) && in_array( $params['type'], array_keys( Event::get_types() ), true ) ) { $processed['type'] = (int) $params['type']; } // Search. if ( ! empty( $params['search'] ) ) { $processed['search'] = sanitize_text_field( $params['search'] ); } /* * LIMIT. */ if ( ! empty( $params['offset'] ) ) { $processed['offset'] = (int) $params['offset']; } if ( ! empty( $params['per_page'] ) ) { $processed['per_page'] = (int) $params['per_page']; } /* * Sent date. */ if ( ! empty( $params['date'] ) ) { if ( is_string( $params['date'] ) ) { $params['date'] = array_fill( 0, 2, $params['date'] ); } elseif ( is_array( $params['date'] ) && count( $params['date'] ) === 1 ) { $params['date'] = array_fill( 0, 2, $params['date'][0] ); } // We pass array and treat it as a range from:to. if ( is_array( $params['date'] ) && count( $params['date'] ) === 2 ) { $date_start = WP::get_day_period_date( 'start_of_day', strtotime( $params['date'][0] ), 'Y-m-d H:i:s', true ); $date_end = WP::get_day_period_date( 'end_of_day', strtotime( $params['date'][1] ), 'Y-m-d H:i:s', true ); if ( ! empty( $date_start ) && ! empty( $date_end ) ) { $processed['date'] = [ $date_start, $date_end ]; } } } // Merge missing values with defaults. return wp_parse_args( $processed, $this->get_default_params() ); } /** * Get the list of default params for a usual query. * * @since 2.0.0 * * @return array */ protected function get_default_params() { return [ 'offset' => 0, 'per_page' => self::$per_page, 'order' => 'DESC', 'orderby' => 'id', 'search' => '', ]; } /** * Get the SQL-ready string of WHERE part for a query. * * @since 2.0.0 * * @return string */ private function build_where() { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh global $wpdb; $where = [ '1=1' ]; // Shortcut single ID or multiple IDs. if ( ! empty( $this->params['id'] ) || ! empty( $this->params['ids'] ) ) { if ( ! empty( $this->params['id'] ) ) { $where[] = $wpdb->prepare( 'id = %d', $this->params['id'] ); } elseif ( ! empty( $this->params['ids'] ) ) { $where[] = 'id IN (' . implode( ',', $this->params['ids'] ) . ')'; } // When some ID(s) defined - we should ignore all other possible filtering options. return implode( ' AND ', $where ); } // Type. if ( isset( $this->params['type'] ) ) { $where[] = $wpdb->prepare( 'event_type = %d', $this->params['type'] ); } // Search. if ( ! empty( $this->params['search'] ) ) { $where[] = '(' . $wpdb->prepare( 'content LIKE %s', '%' . $wpdb->esc_like( $this->params['search'] ) . '%' ) . ' OR ' . $wpdb->prepare( 'initiator LIKE %s', '%' . $wpdb->esc_like( $this->params['search'] ) . '%' ) . ')'; } // Sent date. if ( ! empty( $this->params['date'] ) && is_array( $this->params['date'] ) && count( $this->params['date'] ) === 2 ) { $where[] = $wpdb->prepare( '( created_at >= %s AND created_at <= %s )', $this->params['date'][0], $this->params['date'][1] ); } return implode( ' AND ', $where ); } /** * Get the SQL-ready string of ORDER part for a query. * Order is always in the params, as per our defaults. * * @since 2.0.0 * * @return string */ private function build_order() { return 'ORDER BY ' . $this->params['orderby'] . ' ' . $this->params['order']; } /** * Get the SQL-ready string of LIMIT part for a query. * Limit is always in the params, as per our defaults. * * @since 2.0.0 * * @return string */ private function build_limit() { return 'LIMIT ' . $this->params['offset'] . ', ' . $this->params['per_page']; } /** * Count the number of DB records according to filters. * Do not retrieve actual records. * * @since 2.0.0 * * @return int */ public function get_count() { $table = DebugEvents::get_table_name(); $where = $this->build_where(); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared return (int) WP::wpdb()->get_var( "SELECT COUNT(id) FROM $table WHERE {$where}" ); // phpcs:enable } /** * Get the list of DB records. * You can either use array returned there OR iterate over the whole object, * as it implements Iterator interface. * * @since 2.0.0 * * @return EventsCollection */ public function get() { $table = DebugEvents::get_table_name(); $where = $this->build_where(); $limit = $this->build_limit(); $order = $this->build_order(); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $data = WP::wpdb()->get_results( "SELECT * FROM $table WHERE {$where} {$order} {$limit}" ); // phpcs:enable if ( ! empty( $data ) ) { // As we got raw data we need to convert each row to Event. foreach ( $data as $row ) { $this->list[] = new Event( $row ); } } return $this; } /********************************************************************************************* * ****************************** \Counter interface method. ********************************* *********************************************************************************************/ /** * Count number of Record in a Queue. * * @since 2.0.0 * * @return int */ #[\ReturnTypeWillChange] public function count() { return count( $this->list ); } /********************************************************************************************* * ****************************** \Iterator interface methods. ******************************* *********************************************************************************************/ /** * Rewind the Iterator to the first element. * * @since 2.0.0 */ #[\ReturnTypeWillChange] public function rewind() { $this->iterator_position = 0; } /** * Return the current element. * * @since 2.0.0 * * @return Event|null Return null when no items in collection. */ #[\ReturnTypeWillChange] public function current() { return $this->valid() ? $this->list[ $this->iterator_position ] : null; } /** * Return the key of the current element. * * @since 2.0.0 * * @return int */ #[\ReturnTypeWillChange] public function key() { return $this->iterator_position; } /** * Move forward to next element. * * @since 2.0.0 */ #[\ReturnTypeWillChange] public function next() { ++ $this->iterator_position; } /** * Checks if current position is valid. * * @since 2.0.0 * * @return bool */ #[\ReturnTypeWillChange] public function valid() { return isset( $this->list[ $this->iterator_position ] ); } } Pages/DebugEventsTab.php 0000777 00000043344 15252174534 0011205 0 ustar 00 <?php namespace EasyWPSMTP\Admin\Pages; use EasyWPSMTP\Admin\Area; use EasyWPSMTP\Admin\DebugEvents\DebugEvents; use EasyWPSMTP\Admin\DebugEvents\Migration; use EasyWPSMTP\Admin\DebugEvents\Table; use EasyWPSMTP\Admin\PageAbstract; use EasyWPSMTP\Admin\ParentPageAbstract; use EasyWPSMTP\Options; use EasyWPSMTP\WP; /** * Debug Events settings page. * * @since 2.0.0 */ class DebugEventsTab extends PageAbstract { /** * Part of the slug of a tab. * * @since 2.0.0 * * @var string */ protected $slug = 'debug-events'; /** * Tab priority. * * @since 2.0.0 * * @var int */ protected $priority = 40; /** * Debug events list table. * * @since 2.0.0 * * @var Table */ protected $table = null; /** * Plugin options. * * @since 2.0.0 * * @var Options */ protected $options; /** * Constructor. * * @since 2.0.0 * * @param ParentPageAbstract $parent_page Tab parent page. */ public function __construct( $parent_page = null ) { $this->options = Options::init(); parent::__construct( $parent_page ); // Remove unnecessary $_GET parameters and prevent url duplications in _wp_http_referer input. $this->remove_get_parameters(); } /** * Link label of a tab. * * @since 2.0.0 * * @return string */ public function get_label() { return esc_html__( 'Debug Events', 'easy-wp-smtp' ); } /** * Title of a tab. * * @since 2.0.0 * * @return string */ public function get_title() { return $this->get_label(); } /** * Register hooks. * * @since 2.0.0 */ public function hooks() { add_action( 'easy_wp_smtp_admin_area_enqueue_assets', [ $this, 'enqueue_assets' ] ); } /** * Enqueue required JS and CSS. * * @since 2.0.0 */ public function enqueue_assets() { $min = WP::asset_min(); wp_enqueue_style( 'easy-wp-smtp-flatpickr', easy_wp_smtp()->assets_url . '/css/vendor/flatpickr.min.css', [], '4.6.9' ); wp_enqueue_script( 'easy-wp-smtp-flatpickr', easy_wp_smtp()->assets_url . '/js/vendor/flatpickr.min.js', [ 'jquery' ], '4.6.9', true ); wp_enqueue_script( 'easy-wp-smtp-tools-debug-events', easy_wp_smtp()->assets_url . "/js/smtp-tools-debug-events{$min}.js", [ 'jquery', 'easy-wp-smtp-flatpickr' ], EasyWPSMTP_PLUGIN_VERSION, true ); wp_localize_script( 'easy-wp-smtp-tools-debug-events', 'easy_wp_smtp_tools_debug_events', [ 'lang_code' => sanitize_key( WP::get_language_code() ), 'plugin_url' => easy_wp_smtp()->plugin_url, 'loader' => easy_wp_smtp()->prepare_loader(), 'texts' => [ 'delete_all_notice' => esc_html__( 'Are you sure you want to permanently delete all debug events?', 'easy-wp-smtp' ), 'cancel' => esc_html__( 'Cancel', 'easy-wp-smtp' ), 'close' => esc_html__( 'Close', 'easy-wp-smtp' ), 'yes' => esc_html__( 'Yes', 'easy-wp-smtp' ), 'ok' => esc_html__( 'OK', 'easy-wp-smtp' ), 'notice_title' => esc_html__( 'Heads up!', 'easy-wp-smtp' ), 'error_occurred' => esc_html__( 'An error occurred!', 'easy-wp-smtp' ), ], ] ); } /** * Get email logs list table. * * @since 2.0.0 * * @return Table */ public function get_table() { if ( $this->table === null ) { $this->table = new Table(); } return $this->table; } /** * Display scheduled actions table. * * @since 2.0.0 */ public function display() { $can_manage_settings = ( ! is_multisite() || ( ! is_network_admin() && ! WP::use_global_plugin_settings() ) || ( is_network_admin() && current_user_can( 'manage_network_options' ) && WP::use_global_plugin_settings() ) ); ?> <?php if ( $can_manage_settings ) : ?> <form method="POST" action="<?php echo esc_url( $this->get_link() ); ?>"> <?php $this->wp_nonce_field(); ?> <?php endif; ?> <div class="easy-wp-smtp-meta-box"> <div class="easy-wp-smtp-meta-box__header"> <div class="easy-wp-smtp-meta-box__heading"> <?php esc_html_e( 'Debug Events', 'easy-wp-smtp' ); ?> </div> <?php /** * Fires after export Debug Events metabox title. * * @since 2.7.0 */ do_action( 'easy_wp_smtp_admin_page_tools_debug_events_metabox_heading_after' ); ?> </div> <div class="easy-wp-smtp-meta-box__content"> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-row__desc"> <p> <?php esc_html_e( 'Here, you can view and configure plugin debugging events to find and resolve email sending issues. You’ll also see any email sending errors that occur.', 'easy-wp-smtp' ); ?> </p> <?php if ( ! $can_manage_settings && is_network_admin() && current_user_can( 'manage_network_options' ) ) : ?> <p> <?php echo wp_kses( sprintf( /* translators: %1$s - create missing tables link; %2$s - contact support link. */ __( 'To configure debugging events for the whole network, <a href="%1$s">activate network-wide Settings Control</a>.', 'easy-wp-smtp' ), esc_url( easy_wp_smtp()->get_admin()->get_admin_page_url() ) ), [ 'a' => [ 'href' => [], 'target' => [], 'rel' => [], ], ] ); ?> </p> <?php endif; ?> </div> </div> <?php if ( $can_manage_settings ) : ?> <!-- Debug Events --> <div id="easy-wp-smtp-setting-row-debug_event_types" class="easy-wp-smtp-row easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-debug_event_types"> <?php esc_html_e( 'Event Types', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <div class="easy-wp-smtp-setting-row__sub-row"> <label class="easy-wp-smtp-toggle" for="easy-wp-smtp-setting-debug_events_email_errors"> <input name="easy-wp-smtp[debug_events][email_errors]" type="checkbox" value="true" checked disabled id="easy-wp-smtp-setting-debug_events_email_errors" /> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--static"><?php esc_html_e( 'Email Sending Errors', 'easy-wp-smtp' ); ?></span> </label> <p class="desc"> <?php esc_html_e( 'The Email Sending Errors debug event is always enabled and records any email sending errors in the table below.', 'easy-wp-smtp' ); ?> </p> </div> <div class="easy-wp-smtp-setting-row__sub-row"> <label class="easy-wp-smtp-toggle" for="easy-wp-smtp-setting-debug_events_email_debug"> <input name="easy-wp-smtp[debug_events][email_debug]" type="checkbox" value="true" <?php checked( true, $this->options->get( 'debug_events', 'email_debug' ) ); ?> id="easy-wp-smtp-setting-debug_events_email_debug" /> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--static"><?php esc_html_e( 'Debug Email Sending', 'easy-wp-smtp' ); ?></span> </label> <p class="desc"> <?php esc_html_e( 'Enable this setting to debug the email sending process. All debug events will be logged in the table below. This setting is recommended only for shorter debugging periods. Please disable it once you’re done troubleshooting.', 'easy-wp-smtp' ); ?> </p> </div> </div> </div> <div id="easy-wp-smtp-setting-row-debug_events_retention_period" class="easy-wp-smtp-row easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-debug_events_retention_period"> <?php esc_html_e( 'Events Retention Period', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <select name="easy-wp-smtp[debug_events][retention_period]" id="easy-wp-smtp-setting-debug_events_retention_period" <?php disabled( $this->options->is_const_defined( 'debug_events', 'retention_period' ) ); ?>> <option value=""><?php esc_html_e( 'Forever', 'easy-wp-smtp' ); ?></option> <?php foreach ( $this->get_debug_events_retention_period_options() as $value => $label ) : ?> <option value="<?php echo esc_attr( $value ); ?>" <?php selected( $this->options->get( 'debug_events', 'retention_period' ), $value ); ?>> <?php echo esc_html( $label ); ?> </option> <?php endforeach; ?> </select> <p class="desc"> <?php esc_html_e( 'Debug events that fall outside the chosen period will be permanently deleted from the database.', 'easy-wp-smtp' ); if ( $this->options->is_const_defined( 'debug_events', 'retention_period' ) ) { //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo '<br>' . $this->options->get_const_set_message( 'EASY_WP_SMTP_DEBUG_EVENTS_RETENTION_PERIOD' ); } ?> </p> </div> </div> <?php endif; ?> </div> </div> <?php if ( $can_manage_settings ) : ?> <?php $this->display_save_btn(); ?> </form> <?php endif; ?> <?php if ( ! DebugEvents::is_valid_db() ) { $this->display_debug_events_not_installed(); } else { $table = $this->get_table(); $table->prepare_items(); ?> <form action="<?php echo esc_url( $this->get_link() ); ?>" method="get" class="easy-wp-smtp-debug-events-table easy-wp-smtp-wp-list-table"> <input type="hidden" name="page" value="<?php echo esc_attr( Area::SLUG . '-tools' ); ?>" /> <input type="hidden" name="tab" value="<?php echo esc_attr( $this->get_slug() ); ?>" /> <?php // State of status filter for submission with other filters. if ( $table->get_filtered_types() !== false ) { printf( '<input type="hidden" name="type" value="%s">', esc_attr( $table->get_filtered_types() ) ); } if ( $this->get_filters_html() ) { ?> <div id="easy-wp-smtp-reset-filter"> <?php $type = $table->get_filtered_types(); echo wp_kses( sprintf( /* translators: %1$s - number of debug events found; %2$s - filtered type. */ _n( 'Found <strong>%1$s %2$s event</strong>', 'Found <strong>%1$s %2$s events</strong>', absint( $table->get_pagination_arg( 'total_items' ) ), 'easy-wp-smtp' ), absint( $table->get_pagination_arg( 'total_items' ) ), $type !== false && isset( $table->get_types()[ $type ] ) ? $table->get_types()[ $type ] : '' ), [ 'strong' => [], ] ); ?> <?php foreach ( $this->get_filters_html() as $id => $html ) : ?> <?php echo wp_kses( $html, [ 'em' => [] ] ); ?> <i class="reset dashicons dashicons-dismiss" data-scope="<?php echo esc_attr( $id ); ?>"></i> <?php endforeach; ?> </div> <?php } $table->search_box( esc_html__( 'Search Events', 'easy-wp-smtp' ), Area::SLUG . '-debug-events-search-input' ); $table->views(); $table->display(); ?> </form> <?php } } /** * Process tab form submission ($_POST ). * * @since 2.0.0 * * @param array $data Post data specific for the plugin. */ public function process_post( $data ) { $this->check_admin_referer(); if ( WP::use_global_plugin_settings() && ! current_user_can( 'manage_network_options' ) ) { wp_die( esc_html__( 'You don\'t have the capability to perform this action.', 'easy-wp-smtp' ) ); } // Unchecked checkboxes doesn't exist in $_POST, so we need to ensure we actually have them in data to save. if ( empty( $data['debug_events']['email_debug'] ) ) { $data['debug_events']['email_debug'] = false; } // All the sanitization is done there. $this->options->set( $data, false, false ); WP::add_admin_notice( esc_html__( 'Settings were successfully saved.', 'easy-wp-smtp' ), WP::ADMIN_NOTICE_SUCCESS ); } /** * Return an array with information (HTML and id) for each filter for this current view. * * @since 2.0.0 * * @return array */ private function get_filters_html() { $filters = [ '.search-box' => $this->get_filter_search_html(), '.easy-wp-smtp-filter-date' => $this->get_filter_date_html(), ]; return array_filter( $filters ); } /** * Return HTML with information about the search filter. * * @since 2.0.0 * * @return string */ private function get_filter_search_html() { $table = $this->get_table(); $term = $table->get_filtered_search(); if ( $term === false ) { return ''; } return sprintf( /* translators: %s The searched term. */ __( 'where event contains "%s"', 'easy-wp-smtp' ), '<em>' . esc_html( $term ) . '</em>' ); } /** * Return HTML with information about the date filter. * * @since 2.0.0 * * @return string */ private function get_filter_date_html() { $table = $this->get_table(); $dates = $table->get_filtered_dates(); if ( $dates === false ) { return ''; } $dates = array_map( function ( $date ) { return date_i18n( 'M j, Y', strtotime( $date ) ); }, $dates ); $html = ''; switch ( count( $dates ) ) { case 1: $html = sprintf( /* translators: %s - Date. */ esc_html__( 'on %s', 'easy-wp-smtp' ), '<em>' . $dates[0] . '</em>' ); break; case 2: $html = sprintf( /* translators: %1$s - Date. %2$s - Date. */ esc_html__( 'between %1$s and %2$s', 'easy-wp-smtp' ), '<em>' . $dates[0] . '</em>', '<em>' . $dates[1] . '</em>' ); break; } return $html; } /** * Display a message when debug events DB table is missing. * * @since 2.0.0 */ private function display_debug_events_not_installed() { $error_message = get_option( Migration::ERROR_OPTION_NAME ); $create_missing_tables_url = wp_nonce_url( add_query_arg( [ 'create-missing-db-tables' => 1, ], $this->get_link() ), Area::SLUG . '-create-missing-db-tables' ); $contact_support_url = easy_wp_smtp()->get_utm_url( 'https://easywpsmtp.com/account/support/', [ 'medium' => 'debug-events', 'content' => 'Debug Events not installed correctly', ] ); ?> <div class="notice-inline notice-error" style="margin-top: 32px;"> <h3><?php esc_html_e( 'Debug Events are Not Installed Correctly', 'easy-wp-smtp' ); ?></h3> <p> <?php if ( ! empty( $error_message ) ) { echo wp_kses( sprintf( /* translators: %1$s - create missing tables link; %2$s - contact support link. */ __( 'Easy WP SMTP is using custom database tables for some of its features. In order to work properly, the custom tables should be created, and it seems they are missing. Please try to <a href="%1$s">create the missing DB tables by clicking on this link</a>. If this issue persists, please <a href="%2$s" target="_blank" rel="noopener noreferrer">contact our support</a> and provide the error message below:', 'easy-wp-smtp' ), esc_url( $create_missing_tables_url ), esc_url( $contact_support_url ) ), [ 'a' => [ 'href' => [], 'target' => [], 'rel' => [], ], ] ); echo '<br><br>'; echo '<code>' . esc_html( $error_message ) . '</code>'; } else { echo wp_kses( sprintf( /* translators: %1$s - create missing tables link; %2$s - contact support link. */ __( 'Easy WP SMTP is using custom database tables for some of its features. In order to work properly, the custom tables should be created, and it seems they are missing. Please try to <a href="%1$s">create the missing DB tables by clicking on this link</a>. If this issue persists, please <a href="%2$s" target="_blank" rel="noopener noreferrer">contact our support</a>.', 'easy-wp-smtp' ), esc_url( $create_missing_tables_url ), esc_url( $contact_support_url ) ), [ 'a' => [ 'href' => [], 'target' => [], 'rel' => [], ], ] ); } ?> </p> </div> <?php } /** * Remove unnecessary $_GET parameters for shorter URL. * * @since 2.0.0 */ protected function remove_get_parameters() { if ( isset( $_SERVER['REQUEST_URI'] ) ) { $_SERVER['REQUEST_URI'] = remove_query_arg( [ '_wp_http_referer', '_wpnonce', 'easy-wp-smtp-debug-events-nonce', ], $_SERVER['REQUEST_URI'] // phpcs:ignore WordPress.Security.ValidatedSanitizedInput ); } } /** * Get debug events retention period options. * * @since 2.0.0 * * @return array */ protected function get_debug_events_retention_period_options() { $options = [ 604800 => esc_html__( '1 Week', 'easy-wp-smtp' ), 2628000 => esc_html__( '1 Month', 'easy-wp-smtp' ), 7885000 => esc_html__( '3 Months', 'easy-wp-smtp' ), 15770000 => esc_html__( '6 Months', 'easy-wp-smtp' ), 31540000 => esc_html__( '1 Year', 'easy-wp-smtp' ), ]; $debug_event_retention_period = $this->options->get( 'debug_events', 'retention_period' ); // Check if defined value already in list and add it if not. if ( ! empty( $debug_event_retention_period ) && ! isset( $options[ $debug_event_retention_period ] ) ) { $debug_event_retention_period_days = floor( $debug_event_retention_period / DAY_IN_SECONDS ); $options[ $debug_event_retention_period ] = sprintf( /* translators: %d - days count. */ _n( '%d Day', '%d Days', $debug_event_retention_period_days, 'easy-wp-smtp' ), $debug_event_retention_period_days ); ksort( $options ); } /** * Filter debug events retention period options. * * @since 2.0.0 * * @param array $options Debug Events retention period options. * Option key in seconds. */ return apply_filters( 'easy_wp_smtp_admin_pages_debug_events_tab_get_debug_events_retention_period_options', $options ); } } Pages/MiscTab.php 0000777 00000070353 15252174534 0007665 0 ustar 00 <?php namespace EasyWPSMTP\Admin\Pages; use EasyWPSMTP\Admin\Area; use EasyWPSMTP\Admin\PageAbstract; use EasyWPSMTP\OptimizedEmailSending; use EasyWPSMTP\Options; use EasyWPSMTP\Reports\Emails\Summary as SummaryReportEmail; use EasyWPSMTP\Tasks\Reports\SummaryEmailTask as SummaryReportEmailTask; use EasyWPSMTP\UsageTracking\UsageTracking; use EasyWPSMTP\WP; /** * Class MiscTab is part of Area, displays different plugin-related settings of the plugin (not related to emails). * * @since 2.0.0 */ class MiscTab extends PageAbstract { /** * Slug of a tab. * * @since 2.0.0 * * @var string */ protected $slug = 'misc'; /** * Link label of a tab. * * @since 2.0.0 * * @return string */ public function get_label() { return esc_html__( 'Misc', 'easy-wp-smtp' ); } /** * Title of a tab. * * @since 2.0.0 * * @return string */ public function get_title() { return esc_html__( 'Miscellaneous', 'easy-wp-smtp' ); } /** * Output HTML of the misc settings. * * @since 2.0.0 */ public function display() { $options = Options::init(); /** * Filters whether to show Debug Log settings. * * @since 2.0.0 * * @param bool $show_debug_log_setting Whether to show Debug Log settings. */ $show_debug_log_settings = apply_filters( 'easy_wp_smtp_admin_pages_misc_tab_show_debug_log_settings', false ); ?> <form method="POST" action=""> <?php $this->wp_nonce_field(); ?> <div class="easy-wp-smtp-meta-box"> <div class="easy-wp-smtp-meta-box__header"> <div class="easy-wp-smtp-meta-box__heading"> <?php echo esc_html( $this->get_title() ); ?> </div> </div> <div class="easy-wp-smtp-meta-box__content"> <!-- Domain check --> <div class="easy-wp-smtp-row easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-domain_check"> <?php esc_html_e( 'Enable Domain Check', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <div class="easy-wp-smtp-setting-row__sub-row"> <label class="easy-wp-smtp-toggle" for="easy-wp-smtp-setting-domain_check"> <input name="easy-wp-smtp[general][domain_check]" type="checkbox" value="true" id="easy-wp-smtp-setting-domain_check" <?php echo $options->is_const_defined( 'general', 'domain_check' ) ? 'disabled' : ''; ?> <?php checked( true, $options->get( 'general', 'domain_check' ) ); ?> /> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'On', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Off', 'easy-wp-smtp' ); ?></span> </label> <p class="desc"> <?php esc_html_e( 'Easy WP SMTP settings will be used only if the site is running on following domain(s):', 'easy-wp-smtp' ); ?> </p> </div> <div class="easy-wp-smtp-setting-row__sub-row"> <input name="easy-wp-smtp[general][domain_check_allowed_domains]" type="text" value="<?php echo esc_attr( $options->get( 'general', 'domain_check_allowed_domains' ) ); ?>" id="easy-wp-smtp-setting-domain_check_allowed_domains" spellcheck="false" <?php echo $options->is_const_defined( 'general', 'domain_check_allowed_domains' ) || ! $options->get( 'general', 'domain_check' ) ? 'disabled' : ''; ?> /> <p class="desc"> <?php esc_html_e( 'Comma separated domains list. (Example: domain1.com, domain2.com)', 'easy-wp-smtp' ); ?> </p> </div> <div class="easy-wp-smtp-setting-row__sub-row"> <label class="easy-wp-smtp-toggle" for="easy-wp-smtp-setting-domain_check_do_not_send"> <input name="easy-wp-smtp[general][domain_check_do_not_send]" type="checkbox" value="true" id="easy-wp-smtp-setting-domain_check_do_not_send" <?php echo $options->is_const_defined( 'general', 'domain_check_do_not_send' ) || ! $options->get( 'general', 'domain_check' ) ? 'disabled' : ''; ?> <?php checked( true, $options->get( 'general', 'domain_check_do_not_send' ) ); ?> /> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--static"><?php esc_html_e( 'Block all emails', 'easy-wp-smtp' ); ?></span> </label> <p class="desc"> <?php esc_html_e( 'When enabled, the plugin will attempt to block ALL emails from being sent out if a domain mismatch occurs.', 'easy-wp-smtp' ); ?> </p> </div> </div> </div> <!-- Do not send --> <div id="easy-wp-smtp-setting-row-do_not_send" class="easy-wp-smtp-row easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-do_not_send"> <?php esc_html_e( 'Do Not Send', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <div class="easy-wp-smtp-setting-row__sub-row"> <label class="easy-wp-smtp-toggle" for="easy-wp-smtp-setting-do_not_send"> <input name="easy-wp-smtp[general][do_not_send]" type="checkbox" value="true" id="easy-wp-smtp-setting-do_not_send" <?php echo $options->is_const_defined( 'general', 'do_not_send' ) ? 'disabled' : ''; ?> <?php checked( true, $options->get( 'general', 'do_not_send' ) ); ?> /> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--static"><?php esc_html_e( 'Stop sending all emails', 'easy-wp-smtp' ); ?></span> </label> <p class="desc"> <?php esc_html_e( 'Enable to stop your site from sending emails. Test emails are allowed to be sent, regardless of whether this option is enabl.', 'easy-wp-smtp' ); ?> </p> <p class="desc"> <?php esc_html_e( 'Some plugins, like BuddyPress and Events Manager, use their own email delivery solutions. By default, this option does not block their emails, as those plugins do not use the default wp_mail() function to send emails. You will need to consult the documentation of any such plugins to switch them to use default WordPress email delivery for this setting to have an effect. ', 'easy-wp-smtp' ); ?>, </p> </div> <?php $this->display_log_blocked_emails_settings(); ?> </div> </div> <!-- Allow Insecure SSL Certificates --> <div id="easy-wp-smtp-setting-row-allow_smtp_insecure_ssl" class="easy-wp-smtp-row easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-allow_smtp_insecure_ssl"> <?php esc_html_e( 'Allow Insecure SSL Certificates', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <label class="easy-wp-smtp-toggle" for="easy-wp-smtp-setting-allow_smtp_insecure_ssl"> <input name="easy-wp-smtp[general][allow_smtp_insecure_ssl]" type="checkbox" value="true" id="easy-wp-smtp-setting-allow_smtp_insecure_ssl" <?php echo $options->is_const_defined( 'general', 'allow_smtp_insecure_ssl' ) ? 'disabled' : ''; ?> <?php checked( true, $options->get( 'general', 'allow_smtp_insecure_ssl' ) ); ?> /> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'On', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Off', 'easy-wp-smtp' ); ?></span> </label> <p class="desc"> <?php esc_html_e( 'Allow insecure and self-signed SSL certificates on SMTP server. It\'s highly recommended to keep this option disabled.', 'easy-wp-smtp' ); ?> </p> </div> </div> <?php if ( ! empty( $options->get( 'deprecated', 'debug_log_enabled' ) ) || $show_debug_log_settings ) : ?> <!-- Debug Log --> <div id="easy-wp-smtp-setting-row-debug_log_enabled" class="easy-wp-smtp-row easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-debug_log_enabled"> <?php esc_html_e( 'Enable Debug Log', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <label class="easy-wp-smtp-toggle" for="easy-wp-smtp-setting-debug_log_enabled"> <input name="easy-wp-smtp[deprecated][debug_log_enabled]" type="checkbox" value="true" <?php checked( true, $options->get( 'deprecated', 'debug_log_enabled' ) ); ?> id="easy-wp-smtp-setting-debug_log_enabled" /> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'On', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Off', 'easy-wp-smtp' ); ?></span> </label> <p class="desc"> <?php echo wp_kses( __( '<b>Note:</b> The debug log is reset when the plugin is activated, deactivated, or updated.', 'easy-wp-smtp' ), [ 'b' => [], ] ); ?> </p> <p class="easy-wp-smtp-btn-group desc"> <a href="<?php echo esc_url( add_query_arg( 'swpsmtp_action', 'view_log', easy_wp_smtp()->get_admin()->get_admin_page_url() ) ); ?>" target="_blank" class="easy-wp-smtp-btn easy-wp-smtp-btn--secondary easy-wp-smtp-btn--sm"><?php esc_html_e( 'View Log', 'easy-wp-smtp' ); ?></a> <a id="easy-wp-smtp-clean-debug-log" href="#0" class="easy-wp-smtp-btn easy-wp-smtp-btn--tertiary easy-wp-smtp-btn--sm"><?php esc_html_e( 'Clear Log', 'easy-wp-smtp' ); ?></a> </p> </div> </div> <?php endif; ?> <!-- Hide Announcements --> <div id="easy-wp-smtp-setting-row-am_notifications_hidden" class="easy-wp-smtp-row easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-am_notifications_hidden"> <?php esc_html_e( 'Announcements', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <label class="easy-wp-smtp-toggle" for="easy-wp-smtp-setting-am_notifications_hidden"> <input name="easy-wp-smtp[general][am_notifications_hidden]" type="checkbox" value="true" <?php checked( true, ! $options->get( 'general', 'am_notifications_hidden' ) ); ?> id="easy-wp-smtp-setting-am_notifications_hidden" /> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'On', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Off', 'easy-wp-smtp' ); ?></span> </label> <div class="desc"> <?php esc_html_e( 'Show plugin announcements and update details in the WordPress dashboard.', 'easy-wp-smtp' ); ?> </div> </div> </div> <!-- Hide Email Delivery Errors --> <div id="easy-wp-smtp-setting-row-email_delivery_errors_hidden" class="easy-wp-smtp-row easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-email_delivery_errors_hidden"> <?php esc_html_e( 'Email Delivery Errors', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <?php $is_hard_disabled = has_filter( 'easy_wp_smtp_admin_is_error_delivery_notice_enabled' ) && ! easy_wp_smtp()->get_admin()->is_error_delivery_notice_enabled(); ?> <label class="easy-wp-smtp-toggle" for="easy-wp-smtp-setting-email_delivery_errors_hidden"> <?php if ( $is_hard_disabled ) : ?> <input type="checkbox" disabled id="easy-wp-smtp-setting-email_delivery_errors_hidden"> <?php else : ?> <input name="easy-wp-smtp[general][email_delivery_errors_hidden]" type="checkbox" value="true" <?php checked( true, ! $options->get( 'general', 'email_delivery_errors_hidden' ) ); ?> id="easy-wp-smtp-setting-email_delivery_errors_hidden" /> <?php endif; ?> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'On', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Off', 'easy-wp-smtp' ); ?></span> </label> <p class="desc"> <?php esc_html_e( 'Show email delivery errors, warnings, and alerts in the WordPress dashboard.', 'easy-wp-smtp' ); ?> </p> <?php if ( $is_hard_disabled ) : ?> <p class="desc"> <?php printf( /* translators: %s - filter that was used to disabled. */ esc_html__( 'Email Delivery Errors were disabled using a %s filter.', 'easy-wp-smtp' ), '<code>easy_wp_smtp_admin_is_error_delivery_notice_enabled</code>' ); ?> </p> <?php else : ?> <p class="desc"> <?php esc_html_e( 'Disabling this setting is not recommended and should only be done for staging or development sites.', 'easy-wp-smtp' ); ?> </p> <?php endif; ?> </div> </div> <!-- Hide Top Level Menu --> <div id="easy-wp-smtp-setting-row-top_level_menu_hidden" class="easy-wp-smtp-row easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-top_level_menu_hidden"> <?php esc_html_e( 'Compact Mode', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <label class="easy-wp-smtp-toggle" for="easy-wp-smtp-setting-top_level_menu_hidden"> <input name="easy-wp-smtp[general][top_level_menu_hidden]" type="checkbox" value="true" <?php checked( true, $options->get( 'general', 'top_level_menu_hidden' ) ); ?> id="easy-wp-smtp-setting-top_level_menu_hidden" /> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'On', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Off', 'easy-wp-smtp' ); ?></span> </label> <div class="desc"> <?php esc_html_e( 'Enabling this will condense navigation and move Easy WP SMTP under the WordPress Settings menu.', 'easy-wp-smtp' ); ?> <?php if ( is_network_admin() ) : ?> <?php esc_html_e( 'This setting will be applied only to subsites.', 'easy-wp-smtp' ); ?> <?php endif; ?> </div> </div> </div> <?php if ( apply_filters( 'easy_wp_smtp_admin_pages_misc_tab_show_usage_tracking_setting', true ) ) : ?> <!-- Usage Tracking --> <div id="easy-wp-smtp-setting-row-usage-tracking" class="easy-wp-smtp-row easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-usage-tracking"> <?php esc_html_e( 'Allow Usage Tracking', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <label class="easy-wp-smtp-toggle" for="easy-wp-smtp-setting-usage-tracking"> <input name="easy-wp-smtp[general][<?php echo esc_attr( UsageTracking::SETTINGS_SLUG ); ?>]" type="checkbox" value="true" id="easy-wp-smtp-setting-usage-tracking" <?php checked( true, $options->get( 'general', UsageTracking::SETTINGS_SLUG ) ); ?> /> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'On', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Off', 'easy-wp-smtp' ); ?></span> </label> <p class="desc"> <?php esc_html_e( 'By allowing us to track usage data we can better help you because we know with which WordPress configurations, themes and plugins we should test.', 'easy-wp-smtp' ); ?> </p> </div> </div> <?php endif; ?> <!-- Hide Dashboard Widget --> <div id="easy-wp-smtp-setting-row-dashboard_widget_hidden" class="easy-wp-smtp-row easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-dashboard_widget_hidden"> <?php esc_html_e( 'Hide Dashboard Widget', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <label class="easy-wp-smtp-toggle" for="easy-wp-smtp-setting-dashboard_widget_hidden"> <input name="easy-wp-smtp[general][dashboard_widget_hidden]" type="checkbox" value="true" <?php checked( true, $options->get( 'general', 'dashboard_widget_hidden' ) ); ?> id="easy-wp-smtp-setting-dashboard_widget_hidden" /> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'On', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Off', 'easy-wp-smtp' ); ?></span> </label> <p class="desc"> <?php esc_html_e( 'Hide the Easy WP SMTP Dashboard Widget.', 'easy-wp-smtp' ); ?> </p> </div> </div> <!-- Summary Report Email --> <div id="easy-wp-smtp-setting-row-summary-report-email" class="easy-wp-smtp-row easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-summary-report-email"> <?php esc_html_e( 'Disable Email Summaries', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <label class="easy-wp-smtp-toggle" for="easy-wp-smtp-setting-summary-report-email"> <input name="easy-wp-smtp[general][<?php echo esc_attr( SummaryReportEmail::SETTINGS_SLUG ); ?>]" type="checkbox" id="easy-wp-smtp-setting-summary-report-email" value="true" <?php checked( true, SummaryReportEmail::is_disabled() ); ?> <?php disabled( $options->is_const_defined( 'general', SummaryReportEmail::SETTINGS_SLUG ) || ( easy_wp_smtp()->is_pro() && empty( Options::init()->get( 'logs', 'enabled' ) ) ) ); ?> /> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'On', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Off', 'easy-wp-smtp' ); ?></span> </label> <p class="desc"> <?php esc_html_e( 'Disable Email Summaries weekly delivery.', 'easy-wp-smtp' ); ?> </p> <p class="desc"> <?php if ( easy_wp_smtp()->is_pro() && empty( Options::init()->get( 'logs', 'enabled' ) ) ) { echo wp_kses( sprintf( /* translators: %s - Email Log settings url. */ __( 'Please enable <a href="%s">Email Logging</a> first, before this setting can be configured.', 'easy-wp-smtp' ), esc_url( easy_wp_smtp()->get_admin()->get_admin_page_url( Area::SLUG . '&tab=logs' ) ) ), [ 'a' => [ 'href' => [], ], ] ); } else { printf( '<a href="%1$s" target="_blank">%2$s</a>', esc_url( SummaryReportEmail::get_preview_link() ), esc_html__( 'View Email Summary Example', 'easy-wp-smtp' ) ); } if ( $options->is_const_defined( 'general', SummaryReportEmail::SETTINGS_SLUG ) ) { echo '<br>' . $options->get_const_set_message( 'EASY_WP_SMTP_SUMMARY_REPORT_EMAIL_DISABLED' ); //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } ?> </p> </div> </div> <!-- Optimize email sending --> <div id="easy-wp-smtp-setting-row-optimize-email-sending" class="easy-wp-smtp-row easy-wp-smtp-setting-row easy-wp-smtp-row--has-divider"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-optimize-email-sending"> <?php esc_html_e( 'Optimize Email Sending', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <label class="easy-wp-smtp-toggle" for="easy-wp-smtp-setting-optimize-email-sending"> <input name="easy-wp-smtp[general][<?php echo esc_attr( OptimizedEmailSending::SETTINGS_SLUG ); ?>]" type="checkbox" value="true" id="easy-wp-smtp-setting-optimize-email-sending" <?php checked( true, OptimizedEmailSending::is_enabled() ); ?> <?php disabled( $options->is_const_defined( 'general', OptimizedEmailSending::SETTINGS_SLUG ) ); ?>/> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'On', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Off', 'easy-wp-smtp' ); ?></span> </label> <p class="desc"> <?php printf( wp_kses( /* translators: %1$s - Documentation URL. */ __( 'Send emails asynchronously, which will make pages with email requests load faster, but may delay email delivery by a minute or two. <a href="%1$s" target="_blank" rel="noopener noreferrer">Learn More</a>', 'easy-wp-smtp' ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], ] ), esc_url( easy_wp_smtp()->get_utm_url( 'https://easywpsmtp.com/docs/a-complete-guide-to-miscellaneous-settings/#optimize-email-sending', [ 'medium' => 'misc-settings', 'content' => 'Optimize Email Sending - support article', ] ) ) ); if ( $options->is_const_defined( 'general', OptimizedEmailSending::SETTINGS_SLUG ) ) { echo '<br>' . $options->get_const_set_message( 'EASY_WP_SMTP_OPTIMIZED_EMAIL_SENDING_ENABLED' ); //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } ?> </p> </div> </div> <!-- Rate limit --> <?php $this->display_rate_limit_settings(); ?> <!-- Uninstall --> <div id="easy-wp-smtp-setting-row-uninstall" class="easy-wp-smtp-row easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-uninstall"> <?php esc_html_e( 'Uninstall Easy WP SMTP', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <label class="easy-wp-smtp-toggle" for="easy-wp-smtp-setting-uninstall"> <input name="easy-wp-smtp[general][uninstall]" type="checkbox" value="true" <?php checked( true, $options->get( 'general', 'uninstall' ) ); ?> id="easy-wp-smtp-setting-uninstall" /> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'On', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Off', 'easy-wp-smtp' ); ?></span> </label> <p class="desc"> <?php esc_html_e( 'Enabling this will REMOVE ALL Easy WP SMTP data upon plugin deletion. All settings will be unrecoverable.', 'easy-wp-smtp' ); ?> </p> </div> </div> </div> </div> <?php $this->display_save_btn(); ?> </form> <?php } /** * Display rate limit settings. * * @since 2.6.0 */ protected function display_rate_limit_settings() { $upgrade_link_url = add_query_arg( [ 'discount' => 'LITEUPGRADE' ], easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'Email Rate Limit', 'content' => 'Upgrade to Pro Link', ] ) ); ?> <div id="easy-wp-smtp-setting-row-rate_limit-lite" class="easy-wp-smtp-row easy-wp-smtp-row--has-divider"> <div class="easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-rate_limit-lite"> <?php esc_html_e( 'Email Rate Limiting', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <label class="easy-wp-smtp-toggle" for="easy-wp-smtp-setting-rate_limit-lite" data-disabled-text="<?php esc_html_e( 'Pro', 'easy-wp-smtp' ); ?>"> <input type="checkbox" id="easy-wp-smtp-setting-rate_limit-lite"/> <span class=" easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'On', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Off', 'easy-wp-smtp' ); ?></span> </label> <p class="desc"> <?php echo wp_kses( sprintf( /* translators: %s - EasyWPSMTP.com Upgrade page URL. */ __( 'Limit the number of emails this site will send in each time interval (per minute, hour, day, week and month). Emails that will cross those set limits will be queued and sent as soon as your limits allow. <a href="%s" target="_blank" rel="noopener noreferrer">Learn More</a>.', 'easy-wp-smtp' ), esc_url( easy_wp_smtp()->get_utm_url( 'https://easywpsmtp.com/docs/a-complete-guide-to-miscellaneous-settings/#email-rate-limiting', [ 'medium' => 'misc-settings', 'content' => 'Optimize Email Sending - support article', ] ) ) ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], ] ); ?> </p> </div> </div> </div> <?php } /** * Display "Log Blocked Emails" control. * * @since 2.12.0 */ protected function display_log_blocked_emails_settings() {} /** * Process tab form submission ($_POST). * * @since 2.0.0 * * @param array $data Tab data specific for the plugin ($_POST). */ public function process_post( $data ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh $this->check_admin_referer(); $options = Options::init(); $bool_options = [ 'domain_check', 'domain_check_do_not_send', 'do_not_send', 'allow_smtp_insecure_ssl', 'top_level_menu_hidden', 'uninstall', UsageTracking::SETTINGS_SLUG, SummaryReportEmail::SETTINGS_SLUG, OptimizedEmailSending::SETTINGS_SLUG, 'dashboard_widget_hidden', ]; // Unchecked checkboxes doesn't exist in $_POST, so we need to ensure we actually have them in data to save. foreach ( $bool_options as $option_key ) { if ( empty( $data['general'][ $option_key ] ) ) { $data['general'][ $option_key ] = false; } } $data['general']['am_notifications_hidden'] = empty( $data['general']['am_notifications_hidden'] ); $data['general']['email_delivery_errors_hidden'] = empty( $data['general']['email_delivery_errors_hidden'] ); if ( empty( $data['deprecated']['debug_log_enabled'] ) ) { $data['deprecated']['debug_log_enabled'] = false; } $is_summary_report_email_opt_changed = $options->is_option_changed( $options->parse_boolean( $data['general'][ SummaryReportEmail::SETTINGS_SLUG ] ), 'general', SummaryReportEmail::SETTINGS_SLUG ); // If this option was changed, cancel summary report email task. if ( $is_summary_report_email_opt_changed ) { ( new SummaryReportEmailTask() )->cancel(); } // All the sanitization is done there. $options->set( $data, false, false ); WP::add_admin_notice( esc_html__( 'Settings were successfully saved.', 'easy-wp-smtp' ), WP::ADMIN_NOTICE_SUCCESS ); } } Pages/AuthTab.php 0000777 00000002436 15252174534 0007670 0 ustar 00 <?php namespace EasyWPSMTP\Admin\Pages; use EasyWPSMTP\ConnectionInterface; use EasyWPSMTP\Providers\AuthAbstract; /** * Class AuthTab. * * @since 2.1.0 */ class AuthTab { /** * @var string Slug of a tab. */ protected $slug = 'auth'; /** * Launch mailer specific Auth logic. * * @since 2.1.0 */ public function process_auth() { $connection = easy_wp_smtp()->get_connections_manager()->get_primary_connection(); /** * Filters auth connection object. * * @since 2.1.0 * * @param ConnectionInterface $connection The Connection object. */ $connection = apply_filters( 'easy_wp_smtp_admin_pages_auth_tab_process_auth_connection', $connection ); $auth = easy_wp_smtp()->get_providers()->get_auth( $connection->get_mailer_slug(), $connection ); if ( $auth && $auth instanceof AuthAbstract && method_exists( $auth, 'process' ) ) { $auth->process(); } } /** * Return nothing, as we don't need this functionality. * * @since 2.1.0 */ public function get_label() { return ''; } /** * Return nothing, as we don't need this functionality. * * @since 2.1.0 */ public function get_title() { return ''; } /** * Do nothing, as we don't need this functionality. * * @since 2.1.0 */ public function display() { } } Pages/TestTab.php 0000777 00000217667 15252174534 0007724 0 ustar 00 <?php namespace EasyWPSMTP\Admin\Pages; use EasyWPSMTP\Admin\DomainChecker; use EasyWPSMTP\Conflicts; use EasyWPSMTP\ConnectionInterface; use EasyWPSMTP\Debug; use EasyWPSMTP\MailCatcherInterface; use EasyWPSMTP\Options; use EasyWPSMTP\WP; use EasyWPSMTP\Admin\PageAbstract; /** * Class TestTab is part of Area, displays email testing page of the plugin. * * @since 2.0.0 */ class TestTab extends PageAbstract { /** * @var string Slug of a tab. */ protected $slug = 'test'; /** * Tab priority. * * @since 2.0.0 * * @var int */ protected $priority = 10; /** * Mailer debug error data. * * @since 2.0.0 * * @var array */ private $debug = []; /** * Domain Checker API object. * * @since 2.1.0 * * @var DomainChecker|null */ private $domain_checker; /** * Test email sending failed. * * @since 2.1.0 * * @const int */ const FAILED = 0; /** * Test email sent successfully. * * @since 2.1.0 * * @const int */ const SUCCESS = 1; /** * Test email domain check failed. * * @since 2.1.0 * * @const int */ const FAILED_DOMAIN_CHECK = 2; /** * Test email result. * * @since 2.1.0 * * @var int */ private $result = null; /** * Test email POST data. * * @since 2.0.0 * * @var array */ private $post_data = []; /** * Test email connection. * * @since 2.0.0 * * @var ConnectionInterface */ private $connection; /** * @inheritdoc */ public function get_label() { return esc_html__( 'Email Test', 'easy-wp-smtp' ); } /** * @inheritdoc */ public function get_title() { return $this->get_label(); } /** * Display test email form. * * @since 2.0.0 */ public function display() { $test_email_options = array_merge( [ 'to' => '', 'subject' => '', 'message' => '', ], get_option( 'easy_wp_smtp_test_email', [] ) ); if ( empty( $test_email_options['to'] ) ) { $test_email_options['to'] = wp_get_current_user()->user_email; } ?> <form id="easy-wp-smtp-email-test-form" method="POST" action="<?php echo esc_url( $this->get_link() ); ?>"> <?php $this->wp_nonce_field(); ?> <div class="easy-wp-smtp-meta-box"> <div class="easy-wp-smtp-meta-box__header"> <div class="easy-wp-smtp-meta-box__heading"> <?php esc_html_e( 'Send a Test', 'easy-wp-smtp' ); ?> </div> </div> <div class="easy-wp-smtp-meta-box__content"> <!-- Test Email --> <div id="easy-wp-smtp-setting-row-test_email" class="easy-wp-smtp-row easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-test_email"><?php esc_html_e( 'Send To', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"> <input name="easy-wp-smtp[test][email]" value="<?php echo esc_attr( $test_email_options['to'] ); ?>" type="email" id="easy-wp-smtp-setting-test_email" spellcheck="false" placeholder="yourmail@example.com" required /> <p class="desc"> <?php esc_html_e( 'Enter the email address you want to send the test email to.', 'easy-wp-smtp' ); ?> </p> </div> </div> <?php /** * Fires after "Send To" section on the test email page. * * @since 2.0.0 */ do_action( 'easy_wp_smtp_admin_pages_test_tab_display_form_send_to_after' ); ?> <!-- HTML/Plain --> <div id="easy-wp-smtp-setting-row-test_email_html" class="easy-wp-smtp-row easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-test_email_html"><?php esc_html_e( 'HTML', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"> <label class="easy-wp-smtp-toggle" for="easy-wp-smtp-setting-test_email_html"> <input type="checkbox" id="easy-wp-smtp-setting-test_email_html" name="easy-wp-smtp[test][html]" value="yes" checked/> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'On', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Off', 'easy-wp-smtp' ); ?></span> </label> <p class="desc"> <?php esc_html_e( 'Enable to send this email in HTML format. Disable to send it in plain text format.', 'easy-wp-smtp' ); ?> </p> </div> </div> <!-- Custom Email --> <div id="easy-wp-smtp-setting-row-test_email_custom" class="easy-wp-smtp-row easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-test_email_custom"><?php esc_html_e( 'Custom Email', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"> <label class="easy-wp-smtp-toggle" for="easy-wp-smtp-setting-test_email_custom"> <input type="checkbox" id="easy-wp-smtp-setting-test_email_custom" name="easy-wp-smtp[test][custom]" value="yes"/> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'On', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Off', 'easy-wp-smtp' ); ?></span> </label> <p class="desc"> <?php esc_html_e( 'Replace the predefined email template with your own content.', 'easy-wp-smtp' ); ?> </p> </div> </div> <!-- Subject --> <div id="easy-wp-smtp-setting-row-test_email_subject" class="easy-wp-smtp-row easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text" style="display: none;"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-test_email_subject"><?php esc_html_e( 'Subject', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"> <input name="easy-wp-smtp[test][subject]" type="text" id="easy-wp-smtp-setting-test_email_subject" value="<?php echo esc_attr( $test_email_options['subject'] ); ?>" spellcheck="false"> <p class="desc"> <?php esc_html_e( 'Enter a custom subject for your message.', 'easy-wp-smtp' ); ?> </p> </div> </div> <!-- Message --> <div id="easy-wp-smtp-setting-row-test_email_message" class="easy-wp-smtp-row easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text" style="display: none;"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-test_email_message"><?php esc_html_e( 'Message', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"> <textarea name="easy-wp-smtp[test][message]" id="easy-wp-smtp-setting-test_email_message" spellcheck="false" rows="9"><?php echo esc_textarea( stripslashes( $test_email_options['message'] ) ); ?></textarea> <p class="desc"> <?php esc_html_e( 'Write your custom email message.', 'easy-wp-smtp' ); ?> </p> </div> </div> </div> </div> <?php $btn = 'easy-wp-smtp-btn--primary'; $disabled = ''; $help_text = ''; $mailer = easy_wp_smtp()->get_providers()->get_mailer( Options::init()->get( 'mail', 'mailer' ), easy_wp_smtp()->get_processor()->get_phpmailer() ); if ( ! $mailer || ! $mailer->is_mailer_complete() ) { $btn = 'easy-wp-smtp-btn--primary easy-wp-smtp-btn--primary--disabled'; $disabled = 'disabled'; $help_text = '<div class="easy-wp-smtp-test-email-submit__text">' . esc_html__( 'You cannot send an email. Mailer is not properly configured. Please check your settings.', 'easy-wp-smtp' ) . '</div>'; } ?> <div class="easy-wp-smtp-test-email-submit"> <button type="submit" class="easy-wp-smtp-btn easy-wp-smtp-btn--lg <?php echo esc_attr( $btn ); ?>" <?php echo esc_attr( $disabled ); ?>> <?php esc_html_e( 'Send Test Email', 'easy-wp-smtp' ); ?> </button> <?php echo $help_text; ?> </div> <?php $this->post_form_hidden_field(); ?> </form> <?php if ( ! empty( $mailer ) && $mailer->is_mailer_complete() && isset( $_GET['auto-start'] ) ) : // phpcs:ignore ?> <script> (function( $ ) { var $button = $( '.easy-wp-smtp-tab-tools-test #easy-wp-smtp-email-test-form .easy-wp-smtp-btn' ); $button.addClass( 'easy-wp-smtp-btn--loading' ); $( '#easy-wp-smtp-email-test-form' ).submit(); }( jQuery )); </script> <?php endif; if ( ! is_null( $this->result ) && $this->result !== self::SUCCESS ) { echo '<div class="easy-wp-smtp-test-email-result">'; if ( $this->result === self::FAILED_DOMAIN_CHECK ) { $this->display_domain_check_details(); } elseif ( $this->result === self::FAILED ) { $this->display_debug_details(); } echo '</div>'; ?> <!-- Scroll to the error container. --> <script> jQuery( function( $ ) { $( 'html, body' ).animate( { scrollTop: $( ".easy-wp-smtp-test-email-result" ).offset().top - 50 }, 500 ); } ); </script> <?php } } /** * @inheritdoc */ public function process_post( $data ) { $this->check_admin_referer(); $this->post_data = $data; $connection = easy_wp_smtp()->get_connections_manager()->get_primary_connection(); /** * Filters test email connection object. * * @since 2.0.0 * * @param ConnectionInterface $connection The Connection object. * @param array $data Post data. */ $this->connection = apply_filters( 'easy_wp_smtp_admin_pages_test_tab_process_post_connection', $connection, $data ); if ( ! empty( $data['test']['email'] ) ) { $data['test']['email'] = wp_unslash( $data['test']['email'] ); $data['test']['email'] = filter_var( $data['test']['email'], FILTER_VALIDATE_EMAIL ); } $is_html = true; if ( empty( $data['test']['html'] ) ) { $is_html = false; } if ( empty( $data['test']['email'] ) ) { WP::add_admin_notice( esc_html__( 'Test failed. Please use a valid email address and try to resend the test email.', 'easy-wp-smtp' ), WP::ADMIN_NOTICE_WARNING ); return; } $phpmailer = easy_wp_smtp()->get_processor()->get_phpmailer(); // Set SMTPDebug level, default is 3 (commands + data + connection status). $phpmailer->SMTPDebug = apply_filters( 'easy_wp_smtp_admin_test_email_smtp_debug', 3 ); if ( $is_html ) { add_filter( 'wp_mail_content_type', array( __CLASS__, 'set_test_html_content_type' ) ); } $to = $data['test']['email']; if ( ! empty( $data['test']['custom'] ) && ! empty( $data['test']['subject'] ) ) { $subject = $data['test']['subject']; } else { if ( $is_html ) { /* translators: %s - email address a test email will be sent to. */ $subject = 'Easy WP SMTP: HTML ' . sprintf( esc_html__( 'Test email to %s', 'easy-wp-smtp' ), $data['test']['email'] ); } else { /* translators: %s - email address a test email will be sent to. */ $subject = 'Easy WP SMTP: ' . sprintf( esc_html__( 'Test email to %s', 'easy-wp-smtp' ), $data['test']['email'] ); } } $headers = [ 'X-Mailer-Type:EasyWPSMTP/Admin/Test' ]; if ( $is_html ) { $headers[] = 'Content-Type: text/html'; } // Clear debug before send test email. Debug::clear(); // Force processing for test email even if email sending is blocked. easy_wp_smtp()->get_processor()->set_force_processing( true ); // Start output buffering to grab smtp debugging output. ob_start(); // Send the test mail. $result = wp_mail( $to, $subject, $this->get_email_message( $is_html ), $headers ); $smtp_debug = ob_get_clean(); easy_wp_smtp()->get_processor()->set_force_processing( false ); if ( $is_html ) { remove_filter( 'wp_mail_content_type', array( __NAMESPACE__, 'set_test_html_content_type' ) ); } /* * Notify a user about the results. */ if ( $result ) { $connection_options = $this->connection->get_options(); $mailer = $connection_options->get( 'mail', 'mailer' ); $email = $connection_options->get( 'mail', 'from_email' ); $domain = ''; // Add the optional sending domain parameter. if ( in_array( $mailer, [ 'mailgun', 'sendinblue', 'sendgrid' ], true ) ) { $domain = $connection_options->get( $mailer, 'domain' ); } $this->domain_checker = new DomainChecker( $mailer, $email, $domain ); $this->result = $this->domain_checker->no_issues() ? self::SUCCESS : self::FAILED_DOMAIN_CHECK; if ( $this->result === self::SUCCESS ) { $result_message = esc_html__( 'Test plain text email was sent successfully!', 'easy-wp-smtp' ); if ( $is_html ) { $result_message = sprintf( /* translators: %s - "HTML" in bold. */ esc_html__( 'Test %s email was sent successfully! Please check your inbox to make sure it is delivered.', 'easy-wp-smtp' ), '<strong>HTML</strong>' ); } WP::add_admin_notice( $result_message, WP::ADMIN_NOTICE_SUCCESS ); } } else { // Grab the smtp debugging output. $this->debug['smtp_debug'] = $smtp_debug; $this->debug['smtp_error'] = wp_strip_all_tags( $phpmailer->ErrorInfo ); $this->debug['error_log'] = $this->get_debug_messages( $phpmailer, $smtp_debug ); $this->result = self::FAILED; } // Update test email data. $test_email_options = get_option( 'easy_wp_smtp_test_email', [] ); $test_email_options['to'] = filter_var( $to, FILTER_SANITIZE_EMAIL ); if ( ! empty( $data['test']['custom'] ) ) { $test_email_options['subject'] = sanitize_text_field( $subject ); if ( ! empty( $data['test']['message'] ) ) { $test_email_options['message'] = sanitize_textarea_field( $data['test']['message'] ); } } update_option( 'easy_wp_smtp_test_email', $test_email_options, false ); } /** * Get the email message that should be sent. * * @since 2.0.0 * * @param bool $is_html Whether to send an HTML email or plain text. * * @return string */ private function get_email_message( $is_html = true ) { if ( ! empty( $this->post_data['test']['custom'] ) && ! empty( $this->post_data['test']['message'] ) ) { return $this->post_data['test']['message']; } // Default plain text version of the email. $message = self::get_email_message_text(); if ( $is_html ) { $message = $this->get_email_message_html(); } return $message; } /** * Get the HTML prepared message for test email. * * @since 2.0.0 * * @return string */ private function get_email_message_html() { ob_start(); ?> <!doctype html> <html lang="en"> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width"> <title>Easy WP SMTP Test Email</title> <style type="text/css">@media only screen and (max-width: 599px) {table.body .container {width: 95% !important;}.header {padding: 30px 15px 30px 15px !important;}.content, .education-main {padding: 40px 30px !important;} .education-footer {padding: 20px 30px !important;}.guaranty-badge {display: none !important;}.education-footer p {text-align: left !important;}}</style> </head> <body style="height: 100% !important; width: 100% !important; min-width: 100%; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; -webkit-font-smoothing: antialiased !important; -moz-osx-font-smoothing: grayscale !important; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; color: #3A3A56; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: normal; padding: 0; margin: 0; Margin: 0; font-size: 14px; mso-line-height-rule: exactly; line-height: 140%; background-color: #F2F2F4; text-align: center;"> <table border="0" cellpadding="0" cellspacing="0" width="100%" height="100%" class="body" style="border-collapse: collapse; border-spacing: 0; vertical-align: top; mso-table-lspace: 0pt; mso-table-rspace: 0pt; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; height: 100% !important; width: 100% !important; min-width: 100%; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; -webkit-font-smoothing: antialiased !important; -moz-osx-font-smoothing: grayscale !important; background-color: #F2F2F4; color: #3A3A56; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: normal; padding: 0; margin: 0; Margin: 0; text-align: left; font-size: 14px; mso-line-height-rule: exactly; line-height: 140%;"> <tr style="padding: 0; vertical-align: top; text-align: left;"> <td align="center" valign="top" class="body-inner easy-wp-smtp" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; border-collapse: collapse !important; vertical-align: top; mso-table-lspace: 0pt; mso-table-rspace: 0pt; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; color: #3A3A56; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: normal; padding: 0; margin: 0; Margin: 0; font-size: 14px; mso-line-height-rule: exactly; line-height: 140%; text-align: center;"> <!-- Container --> <table border="0" cellpadding="0" cellspacing="0" class="container" style="border-collapse: collapse; border-spacing: 0; padding: 0; vertical-align: top; mso-table-lspace: 0pt; mso-table-rspace: 0pt; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; width: 600px; margin: 0 auto 30px auto; Margin: 0 auto 30px auto; text-align: inherit;"> <!-- Header --> <tr style="padding: 0; vertical-align: top; text-align: left;"> <td align="center" valign="middle" class="header" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; border-collapse: collapse !important; vertical-align: top; mso-table-lspace: 0pt; mso-table-rspace: 0pt; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; color: #3A3A56; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: normal; margin: 0; Margin: 0; font-size: 14px; mso-line-height-rule: exactly; line-height: 140%; text-align: center; padding: 30px 30px 30px 30px;"> <img src="<?php echo esc_url( easy_wp_smtp()->plugin_url . '/assets/images/email/easy-wp-smtp.png' ); ?>" width="308" alt="Easy WP SMTP Logo" style="outline: none; text-decoration: none; max-width: 100%; clear: both; -ms-interpolation-mode: bicubic; display: inline-block !important; width: 308px;"> </td> </tr> <!-- Content --> <tr style="padding: 0; vertical-align: top; text-align: left;"> <td align="left" valign="top" class="content" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; border-collapse: collapse !important; vertical-align: top; mso-table-lspace: 0pt; mso-table-rspace: 0pt; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; color: #3A3A56; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: normal; margin: 0; Margin: 0; text-align: left; font-size: 14px; mso-line-height-rule: exactly; line-height: 140%; background-color: #ffffff; padding-top: 60px;padding-bottom: 60px;padding-left: 60px;padding-right: 60px;"> <div class="success" style="text-align: center;"> <p class="check" style="-ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; color: #3A3A56; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: normal; padding: 0; font-size: 14px; mso-line-height-rule: exactly; line-height: 140%; margin: 0 auto 40px auto; Margin: 0 auto 40px auto; text-align: center;"> <img src="<?php echo esc_url( easy_wp_smtp()->plugin_url . '/assets/images/email/icon-check.png' ); ?>" width="64" alt="Success" style="outline: none; text-decoration: none; max-width: 100%; clear: both; -ms-interpolation-mode: bicubic; display: block; margin: 0 auto 0 auto; Margin: 0 auto 0 auto; width: 64px;"> </p> <p class="text-extra-large text-center congrats" style="-ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; color: #09092C; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; padding: 0; mso-line-height-rule: exactly; line-height: 140%; font-size: 20px; text-align: center; margin: 0 0 40px 0; Margin: 0 0 40px 0;"> Congrats, test email was sent successfully! </p> <p class="text-large" style="-ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; color: #3A3A56; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: normal; padding: 0; text-align: left; mso-line-height-rule: exactly; line-height: 140%; margin: 0 0 40px 0; Margin: 0 0 40px 0; font-size: 16px;"> Thank you for using Easy WP SMTP. We're on a mission to make sure your emails actually get delivered. </p> <p class="signature" style="-ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; color: #3A3A56; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: normal; padding: 0; font-size: 14px; mso-line-height-rule: exactly; line-height: 140%; text-align: left; margin: 0 0 10px 0; Margin: 0 0 10px 0;"> <img src="<?php echo esc_url( easy_wp_smtp()->plugin_url . '/assets/images/email/signature.png' ); ?>" width="180" alt="Signature" style="outline: none; text-decoration: none; max-width: 100%; clear: both; -ms-interpolation-mode: bicubic; width: 180px; display: block; margin: 0 0 0 0; Margin: 0 0 0 0;"> </p> <p style="-ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; color: #6F6F84; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: normal; padding: 0; text-align: left; font-size: 14px; mso-line-height-rule: exactly; line-height: 140%; margin: 0 0 0px 0; Margin: 0 0 0px 0;"> <strong>Jared Atchison</strong><br> CEO, SendLayer </p> </div> </td> </tr> <?php if ( ! easy_wp_smtp()->is_pro() ) : ?> <tr style="padding: 0; vertical-align: top; text-align: left;"> <td align="left" valign="top" class="education-main" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; border-collapse: collapse !important; vertical-align: top; mso-table-lspace: 0pt; mso-table-rspace: 0pt; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; color: #444; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: normal; margin: 0; Margin: 0; font-size: 14px; mso-line-height-rule: exactly; line-height: 140%; background-color: #DBEDE6; text-align: left !important; padding-top: 60px;padding-bottom: 60px;padding-left: 60px;padding-right: 60px;"> <h6 style="padding: 0; color: #02150D; word-wrap: normal; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: bold; mso-line-height-rule: exactly; line-height: 130%; font-size: 17px; text-align: left; margin: 0 0 20px 0; Margin: 0 0 20px 0;"> Unlock Powerful Features with Easy WP SMTP Pro </h6> <table style="border-collapse: collapse; border-spacing: 0; padding: 0; vertical-align: top; text-align: left; mso-table-lspace: 0pt; mso-table-rspace: 0pt; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; width: 100% !important;"> <tr style="padding: 0; vertical-align: top; text-align: left;"> <td style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; border-collapse: collapse !important; vertical-align: top; mso-table-lspace: 0pt; mso-table-rspace: 0pt; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; color: #444; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: normal; margin: 0; Margin: 0; mso-line-height-rule: exactly; text-align: left; padding: 0 0 0 0; line-height: 100%;width: 67%;"> <table style="border-collapse: collapse; border-spacing: 0; padding: 0; vertical-align: top; text-align: left; mso-table-lspace: 0pt; mso-table-rspace: 0pt; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; width: 100% !important; margin-bottom: 5px;"> <tr style="padding: 0; vertical-align: top; text-align: left;"> <td style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; border-collapse: collapse !important; vertical-align: top; mso-table-lspace: 0pt; mso-table-rspace: 0pt; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; color: #444; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: normal; margin: 0; Margin: 0; mso-line-height-rule: exactly; text-align: left; line-height: 140%; padding-bottom: 15px; padding-top: 0; padding-right: 10px;padding-left: 0; width: 16px;"> <img src="<?php echo esc_url( easy_wp_smtp()->plugin_url . '/assets/images/email/check.png' ); ?>" width="16" alt="Check" style="outline: none; text-decoration: none; max-width: 100%; clear: both; -ms-interpolation-mode: bicubic; width: 16px; vertical-align: middle;"> </td> <td style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; border-collapse: collapse !important; vertical-align: top; mso-table-lspace: 0pt; mso-table-rspace: 0pt; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; color: #444; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: normal; margin: 0; Margin: 0; mso-line-height-rule: exactly; text-align: left; line-height: 140%; padding-bottom: 15px; padding-top: 0; padding-right: 0;padding-left: 0;font-size: 15px;"> Detailed Email Logs </td> </tr> <tr style="padding: 0; vertical-align: top; text-align: left;"> <td style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; border-collapse: collapse !important; vertical-align: top; mso-table-lspace: 0pt; mso-table-rspace: 0pt; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; color: #444; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: normal; margin: 0; Margin: 0; mso-line-height-rule: exactly; text-align: left; line-height: 140%; padding-bottom: 15px; padding-top: 0; padding-right: 10px;padding-left: 0;width: 16px;"> <img src="<?php echo esc_url( easy_wp_smtp()->plugin_url . '/assets/images/email/check.png' ); ?>" width="16" alt="Check" style="outline: none; text-decoration: none; max-width: 100%; clear: both; -ms-interpolation-mode: bicubic; width: 16px;vertical-align: middle;"> </td> <td style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; border-collapse: collapse !important; vertical-align: top; mso-table-lspace: 0pt; mso-table-rspace: 0pt; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; color: #444; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: normal; margin: 0; Margin: 0; mso-line-height-rule: exactly; text-align: left; line-height: 140%; padding-bottom: 15px; padding-top: 0; padding-right: 0;padding-left: 0;font-size: 15px;"> Complete Email Reports </td> </tr> <tr style="padding: 0; vertical-align: top; text-align: left;"> <td style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; border-collapse: collapse !important; vertical-align: top; mso-table-lspace: 0pt; mso-table-rspace: 0pt; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; color: #444; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: normal; margin: 0; Margin: 0; mso-line-height-rule: exactly; text-align: left; line-height: 140%; padding-bottom: 15px; padding-top: 0; padding-right: 10px;padding-left: 0;width: 16px;"> <img src="<?php echo esc_url( easy_wp_smtp()->plugin_url . '/assets/images/email/check.png' ); ?>" width="16" alt="Check" style="outline: none; text-decoration: none; max-width: 100%; clear: both; -ms-interpolation-mode: bicubic; width: 16px;vertical-align: middle;"> </td> <td style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; border-collapse: collapse !important; vertical-align: top; mso-table-lspace: 0pt; mso-table-rspace: 0pt; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; color: #444; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: normal; margin: 0; Margin: 0; mso-line-height-rule: exactly; text-align: left; line-height: 140%; padding-bottom: 15px; padding-top: 0; padding-right: 0;padding-left: 0;font-size: 15px;"> Enhanced Weekly Email Summary </td> </tr> </table> <table class="button" style="border-collapse: collapse; border-spacing: 0; padding: 0; vertical-align: top; text-align: left; mso-table-lspace: 0pt; mso-table-rspace: 0pt; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; width: 100%; max-width: 202px;"> <tr style="padding: 0; vertical-align: top; text-align: left;"> <td class="button-inner" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; border-collapse: collapse !important; vertical-align: top; mso-table-lspace: 0pt; mso-table-rspace: 0pt; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; color: #444; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: normal; margin: 0; Margin: 0; text-align: left; font-size: 14px; mso-line-height-rule: exactly; line-height: 100%; padding: 0 0 0 0;"> <table style="border-collapse: collapse; border-spacing: 0; padding: 0; vertical-align: top; text-align: left; mso-table-lspace: 0pt; mso-table-rspace: 0pt; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; width: 100% !important;"> <tr style="padding: 0; vertical-align: top; text-align: left;"> <td style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; border-collapse: collapse !important; vertical-align: top; mso-table-lspace: 0pt; mso-table-rspace: 0pt; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: normal; padding: 0; margin: 0; Margin: 0; font-size: 16px; text-align: center; color: #ffffff; background: #0F8A56; border-radius: 4px; mso-line-height-rule: exactly; line-height: 100%;"> <a href="<?php echo esc_url( easy_wp_smtp()->get_upgrade_link( 'email-test' ) ); ?>" style="-ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; margin: 0; Margin: 0; font-family: Helvetica, Arial, sans-serif; font-weight: bold; color: #ffffff; text-decoration: none; display: inline-block; border: 0 solid #0F8A56; mso-line-height-rule: exactly; line-height: 100%; padding: 12px 20px 12px 20px; font-size: 16px; text-align: center; width: 100%; padding-left: 0; padding-right: 0;"> Upgrade to Pro Today </a> </td> </tr> </table> </td> </tr> </table> </td> <td class="guaranty-badge" align="right" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; border-collapse: collapse !important; vertical-align: top; mso-table-lspace: 0pt; mso-table-rspace: 0pt; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; color: #444; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: normal; margin: 0; Margin: 0; mso-line-height-rule: exactly; text-align: right; padding: 0 0 0 0; line-height: 140%; width: 33%;"> <img src="<?php echo esc_url( easy_wp_smtp()->plugin_url . '/assets/images/email/14days-badge.png' ); ?>" width="155" alt="Check" style="outline: none; text-decoration: none; max-width: 100%; clear: both; -ms-interpolation-mode: bicubic; width: 155px;"> </td> </tr> </table> </td> </tr> <tr style="padding: 0; vertical-align: top; text-align: left;"> <td align="left" valign="top" class="education-footer" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; border-collapse: collapse !important; vertical-align: top; mso-table-lspace: 0pt; mso-table-rspace: 0pt; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; color: #444; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: normal; margin: 0; Margin: 0; font-size: 14px; mso-line-height-rule: exactly; line-height: 140%; background-color: #B7DCCC; text-align: left !important; padding-top: 20px;padding-bottom: 20px;padding-left: 55px;padding-right: 55px;"> <p class="text-large last" style="-ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; color: #042315; font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight: normal; padding: 0; mso-line-height-rule: exactly; line-height: 140%; font-size: 14px; text-align: center; margin: 0 0 0 0; Margin: 0 0 0 0;"> Upgrade to the Pro and <span style="font-weight:bold;color:#0B613C;text-transform: uppercase;">save 50% today</span>, automatically applied at checkout. </p> </td> </tr> <?php endif; ?> </table> </td> </tr> </table> </body> </html> <?php $message = ob_get_clean(); return $message; } /** * Get the plain text prepared message for test email. * * @since 2.0.0 * * @return string */ public static function get_email_message_text() { // phpcs:disable if ( easy_wp_smtp()->is_pro() ) { // Easy WP SMTP Pro paid installed. $message = 'Congrats, test email was sent successfully! Thank you for using Easy WP SMTP. We\'re on a mission to make sure your emails actually get delivered. - Jared Atchison CEO, SendLayer'; } else { // Free Easy WP SMTP is installed. $message = 'Congrats, test email was sent successfully! Thank you for trying out Easy WP SMTP. We are on a mission to make sure your emails actually get delivered. If you find this free plugin useful, please consider giving Easy WP SMTP Pro a try! https://easywpsmtp.com/lite-upgrade/ Unlock These Powerful Features with Easy WP SMTP Pro: + Log all emails and export your email logs in different formats + Send emails with Amazon SES / Microsoft 365/ Zoho Mail + Track opens and clicks to measure engagement + Resend failed emails from your email log + Create email reports and graphs + Get help from our world-class support team - Jared Atchison CEO, SendLayer'; } // phpcs:enable return $message; } /** * Set the HTML content type for a test email. * * @since 2.0.0 * * @return string */ public static function set_test_html_content_type() { return 'text/html'; } /** * Prepare debug information, that will help users to identify the error. * * @since 2.0.0 * * @param MailCatcherInterface $phpmailer The MailCatcher object. * @param string $smtp_debug The SMTP debug message. * * @return string */ protected function get_debug_messages( $phpmailer, $smtp_debug ) { $connection_options = $this->connection->get_options(); $conflicts = new Conflicts(); $this->debug['mailer'] = $connection_options->get( 'mail', 'mailer' ); /* * Versions Debug. */ $versions_text = '<strong>Versions:</strong><br>'; $versions_text .= '<strong>WordPress:</strong> ' . get_bloginfo( 'version' ) . '<br>'; $versions_text .= '<strong>WordPress MS:</strong> ' . ( is_multisite() ? 'Yes' : 'No' ) . '<br>'; $versions_text .= '<strong>PHP:</strong> ' . PHP_VERSION . '<br>'; $versions_text .= '<strong>Easy WP SMTP:</strong> ' . EasyWPSMTP_PLUGIN_VERSION . '<br>'; /* * Mailer Debug. */ $mailer_text = '<strong>Params:</strong><br>'; $mailer_text .= '<strong>Mailer:</strong> ' . $this->debug['mailer'] . '<br>'; $mailer_text .= '<strong>Constants:</strong> ' . ( $connection_options->is_const_enabled() ? 'Yes' : 'No' ) . '<br>'; if ( $conflicts->is_detected() ) { $conflict_plugin_names = implode( ', ', $conflicts->get_all_conflict_names() ); $mailer_text .= '<strong>Conflicts:</strong> ' . esc_html( $conflict_plugin_names ) . '<br>'; } // Display different debug info based on the mailer. $mailer = easy_wp_smtp()->get_providers()->get_mailer( $this->debug['mailer'], $phpmailer, $this->connection ); if ( $mailer ) { $mailer_text .= $mailer->get_debug_info(); } $phpmailer_error = $phpmailer->ErrorInfo; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase // Append any PHPMailer errors to the mailer debug (except SMTP mailer, which has the full error output below). if ( ! empty( $phpmailer_error ) && ! $connection_options->is_mailer_smtp() ) { $mailer_text .= '<br><br><strong>PHPMailer Debug:</strong><br>' . wp_strip_all_tags( $phpmailer_error ) . '<br>'; } /* * General Debug. */ $debug_text = implode( '<br>', Debug::get() ); Debug::clear(); if ( ! empty( $debug_text ) ) { $debug_text = '<br><strong>Debug:</strong><br>' . $debug_text . '<br>'; } /* * SMTP Debug. */ $smtp_text = ''; if ( $connection_options->is_mailer_smtp() ) { $smtp_text = '<strong>SMTP Debug:</strong><br>'; if ( ! empty( $smtp_debug ) ) { $smtp_text .= $smtp_debug; } else { $smtp_text .= '[empty]'; } } $errors = apply_filters( 'easy_wp_smtp_admin_test_get_debug_messages', array( $versions_text, $mailer_text, $debug_text, $smtp_text, ) ); return '<pre>' . implode( '<br>', array_filter( $errors ) ) . '</pre>'; } /** * Returns debug information for detection, processing, and display. * * @since 2.0.0 * * @return array */ protected function get_debug_details() { $connection_options = $this->connection->get_options(); $smtp_host = $connection_options->get( 'smtp', 'host' ); $smtp_port = $connection_options->get( 'smtp', 'port' ); $smtp_encryption = $connection_options->get( 'smtp', 'encryption' ); $details = [ // [any] - cURL error 60/77. [ 'mailer' => 'any', 'errors' => [ [ 'cURL error 60' ], [ 'cURL error 77' ], ], 'title' => esc_html__( 'SSL certificate issue.', 'easy-wp-smtp' ), 'description' => [ esc_html__( 'This means your web server cannot reliably make secure connections (make requests to HTTPS sites).', 'easy-wp-smtp' ), esc_html__( 'Typically this error is returned when web server is not configured properly.', 'easy-wp-smtp' ), ], 'steps' => [ esc_html__( 'Contact your web hosting provider and inform them your site has an issue with SSL certificates.', 'easy-wp-smtp' ), esc_html__( 'The exact error you can provide them is in the Error log, available at the bottom of this page.', 'easy-wp-smtp' ), esc_html__( 'Ask them to resolve the issue then try again.', 'easy-wp-smtp' ), ], ], // [any] - cURL error 6/7. [ 'mailer' => 'any', 'errors' => [ [ 'cURL error 6' ], [ 'cURL error 7' ], ], 'title' => esc_html__( 'Could not connect to host.', 'easy-wp-smtp' ), 'description' => [ ! empty( $smtp_host ) ? sprintf( /* translators: %s - SMTP host address. */ esc_html__( 'This means your web server was unable to connect to %s.', 'easy-wp-smtp' ), $smtp_host ) : esc_html__( 'This means your web server was unable to connect to the host server.', 'easy-wp-smtp' ), esc_html__( 'Typically this error is returned your web server is blocking the connections or the SMTP host denying the request.', 'easy-wp-smtp' ), ], 'steps' => [ sprintf( /* translators: %s - SMTP host address. */ esc_html__( 'Contact your web hosting provider and ask them to verify your server can connect to %s. Additionally, ask them if a firewall or security policy may be preventing the connection.', 'easy-wp-smtp' ), $smtp_host ), esc_html__( 'If using "Other SMTP" Mailer, triple check your SMTP settings including host address, email, and password.', 'easy-wp-smtp' ), esc_html__( 'If using "Other SMTP" Mailer, contact your SMTP host to confirm they are accepting outside connections with the settings you have configured (address, username, port, security, etc).', 'easy-wp-smtp' ), ], ], // [sendgrid] - cURL error 18 - potential incorrect API key. [ 'mailer' => 'sendgrid', 'errors' => [ [ 'cURL error 18' ], ], 'title' => esc_html__( 'Invalid SendGrid API key', 'easy-wp-smtp' ), 'description' => [ esc_html__( 'It looks like your SendGrid API Key is invalid.', 'easy-wp-smtp' ), ], 'steps' => [ esc_html__( 'Go to Easy WP SMTP plugin Settings page.', 'easy-wp-smtp' ), esc_html__( 'Make sure your API Key in the SendGrid mailer settings is correct and valid.', 'easy-wp-smtp' ), esc_html__( 'Save the plugin settings.', 'easy-wp-smtp' ), esc_html__( 'If updating the API Key doesn\'t resolve this issue, please contact our support.', 'easy-wp-smtp' ), ], ], // [any] - cURL error XX (other). [ 'mailer' => 'any', 'errors' => [ [ 'cURL error' ], ], 'title' => esc_html__( 'Could not connect to your host.', 'easy-wp-smtp' ), 'description' => [ ! empty( $smtp_host ) ? sprintf( /* translators: %s - SMTP host address. */ esc_html__( 'This means your web server was unable to connect to %s.', 'easy-wp-smtp' ), $smtp_host ) : esc_html__( 'This means your web server was unable to connect to the host server.', 'easy-wp-smtp' ), esc_html__( 'Typically this error is returned when web server is not configured properly.', 'easy-wp-smtp' ), ], 'steps' => [ esc_html__( 'Contact your web hosting provider and inform them you are having issues making outbound connections.', 'easy-wp-smtp' ), esc_html__( 'The exact error you can provide them is in the Error log, available at the bottom of this page.', 'easy-wp-smtp' ), esc_html__( 'Ask them to resolve the issue then try again.', 'easy-wp-smtp' ), ], ], // [smtp] - SMTP Error: Count not authenticate. [ 'mailer' => 'smtp', 'errors' => [ [ 'SMTP Error: Could not authenticate.' ], ], 'title' => esc_html__( 'Could not authenticate your SMTP account.', 'easy-wp-smtp' ), 'description' => [ esc_html__( 'This means we were able to connect to your SMTP host, but were not able to proceed using the email/password in the settings.', 'easy-wp-smtp' ), esc_html__( 'Typically this error is returned when the email or password is not correct or is not what the SMTP host is expecting.', 'easy-wp-smtp' ), ], 'steps' => [ esc_html__( 'Triple check your SMTP settings including host address, email, and password. If you have recently reset your password you will need to update the settings.', 'easy-wp-smtp' ), esc_html__( 'Contact your SMTP host to confirm you are using the correct username and password.', 'easy-wp-smtp' ), esc_html__( 'Verify with your SMTP host that your account has permissions to send emails using outside connections.', 'easy-wp-smtp' ), sprintf( wp_kses( /* translators: %s - URL to the easywpsmtp.com doc page. */ __( 'Visit <a href="%s" target="_blank" rel="noopener noreferrer">our documentation</a> for additional tips on how to resolve this error.', 'easy-wp-smtp' ), [ 'a' => [ 'href' => [], 'target' => [], 'rel' => [], ], ] ), // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound esc_url( easy_wp_smtp()->get_utm_url( 'https://easywpsmtp.com/docs/setting-up-the-other-smtp-mailer/#auth-type', [ 'medium' => 'email-test', 'content' => 'Other SMTP auth debug - our documentation' ] ) ) ), ], ], // [smtp] - Sending bulk email, hitting rate limit. [ 'mailer' => 'smtp', 'errors' => [ [ 'We do not authorize the use of this system to transport unsolicited' ], ], 'title' => esc_html__( 'Error due to unsolicited and/or bulk e-mail.', 'easy-wp-smtp' ), 'description' => [ esc_html__( 'This means the connection to your SMTP host was made successfully, but the host rejected the email.', 'easy-wp-smtp' ), esc_html__( 'Typically this error is returned when you are sending too many e-mails or e-mails that have been identified as spam.', 'easy-wp-smtp' ), ], 'steps' => [ esc_html__( 'Make sure you are not sending emails with too many recipients. Example: single email should not have 10+ recipients. You can install any WordPress e-mail logging plugin to check your recipients (TO, CC and BCC).', 'easy-wp-smtp' ), esc_html__( 'Contact your SMTP host to ask about sending/rate limits.', 'easy-wp-smtp' ), esc_html__( 'Verify with them your SMTP account is in good standing and your account has not been flagged.', 'easy-wp-smtp' ), ], ], // [smtp] - Unauthenticated senders not allowed. [ 'mailer' => 'smtp', 'errors' => [ [ 'Unauthenticated senders not allowed' ], ], 'title' => esc_html__( 'Unauthenticated senders are not allowed.', 'easy-wp-smtp' ), 'description' => [ esc_html__( 'This means the connection to your SMTP host was made successfully, but you should enable Authentication and provide correct Username and Password.', 'easy-wp-smtp' ), ], 'steps' => [ esc_html__( 'Go to Easy WP SMTP plugin Settings page.', 'easy-wp-smtp' ), esc_html__( 'Enable Authentication', 'easy-wp-smtp' ), esc_html__( 'Enter correct SMTP Username (usually this is an email address) and Password in the appropriate fields.', 'easy-wp-smtp' ), ], ], // [smtp] - certificate verify failed. // Has to be defined before "SMTP connect() failed" error, since this is a more specific error, // which contains the "SMTP connect() failed" error message as well. [ 'mailer' => 'smtp', 'errors' => [ [ 'certificate verify failed' ], ], 'title' => esc_html__( 'Misconfigured server certificate.', 'easy-wp-smtp' ), 'description' => [ esc_html__( 'This means OpenSSL on your server isn\'t able to verify the host certificate.', 'easy-wp-smtp' ), esc_html__( 'There are a few reasons why this is happening. It could be that the host certificate is misconfigured, or this server\'s OpenSSL is using an outdated CA bundle.', 'easy-wp-smtp' ), ], 'steps' => [ esc_html__( 'Verify that the host\'s SSL certificate is valid.', 'easy-wp-smtp' ), sprintf( wp_kses( /* translators: %s - URL to the PHP openssl manual */ __( 'Contact your hosting support, show them the "full Error Log for debugging" below and share this <a href="%s" target="_blank" rel="noopener noreferrer">link</a> with them.', 'easy-wp-smtp' ), [ 'a' => [ 'href' => [], 'target' => [], 'rel' => [], ], ] ), 'https://www.php.net/manual/en/migration56.openssl.php' ), ], ], // [smtp] - SMTP connect() failed. [ 'mailer' => 'smtp', 'errors' => [ [ 'SMTP connect() failed' ], ], 'title' => esc_html__( 'Could not connect to the SMTP host.', 'easy-wp-smtp' ), 'description' => [ ! empty( $smtp_host ) ? sprintf( /* translators: %s - SMTP host address. */ esc_html__( 'This means your web server was unable to connect to %s.', 'easy-wp-smtp' ), $smtp_host ) : esc_html__( 'This means your web server was unable to connect to the host server.', 'easy-wp-smtp' ), esc_html__( 'Typically this error is returned for one of the following reasons:', 'easy-wp-smtp' ), '<ul>' . '<li>' . esc_html__( 'SMTP settings are incorrect (wrong port, security setting, incorrect host).', 'easy-wp-smtp' ) . '</li>' . '<li>' . esc_html__( 'Your web server is blocking the connection.', 'easy-wp-smtp' ) . '</li>' . '<li>' . esc_html__( 'Your SMTP host is rejecting the connection.', 'easy-wp-smtp' ) . '</li>' . '</ul>', ], 'steps' => [ esc_html__( 'Triple check your SMTP settings including host address, email, and password, port, and security.', 'easy-wp-smtp' ), sprintf( wp_kses( /* translators: %1$s - SMTP host address, %2$s - SMTP port, %3$s - SMTP encryption. */ __( 'Contact your web hosting provider and ask them to verify your server can connect to %1$s on port %2$s using %3$s encryption. Additionally, ask them if a firewall or security policy may be preventing the connection - many shared hosts block certain ports.<br><strong>Note: this is the most common cause of this issue.</strong>', 'easy-wp-smtp' ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], 'strong' => [], 'br' => [], ] ), $smtp_host, $smtp_port, 'none' === $smtp_encryption ? esc_html__( 'no', 'easy-wp-smtp' ) : $smtp_encryption ), esc_html__( 'Contact your SMTP host to confirm you are using the correct username and password.', 'easy-wp-smtp' ), esc_html__( 'Verify with your SMTP host that your account has permissions to send emails using outside connections.', 'easy-wp-smtp' ), ], ], // [mailgun] - Please activate your Mailgun account. [ 'mailer' => 'mailgun', 'errors' => [ [ 'Please activate your Mailgun account' ], ], 'title' => esc_html__( 'Mailgun failed.', 'easy-wp-smtp' ), 'description' => [ esc_html__( 'It seems that you forgot to activate your Mailgun account.', 'easy-wp-smtp' ), ], 'steps' => [ esc_html__( 'Check your inbox you used to create a Mailgun account. Click the activation link in an email from Mailgun.', 'easy-wp-smtp' ), esc_html__( 'If you do not see activation email, go to your Mailgun control panel and resend the activation email.', 'easy-wp-smtp' ), ], ], // [mailgun] - Forbidden. [ 'mailer' => 'mailgun', 'errors' => [ [ 'Forbidden' ], ], 'title' => esc_html__( 'Mailgun failed.', 'easy-wp-smtp' ), 'description' => [ esc_html__( 'Typically this error occurs because there is an issue with your Mailgun settings, in many cases Mailgun API Key, Domain Name, or Region is incorrect.', 'easy-wp-smtp' ), ], 'steps' => [ sprintf( wp_kses( /* translators: %1$s - Mailgun API Key area URL. */ __( 'Go to your Mailgun account and verify that your <a href="%1$s" target="_blank" rel="noopener noreferrer">Mailgun API Key</a> is correct.', 'easy-wp-smtp' ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], ] ), 'https://app.mailgun.com/settings/api_security' ), sprintf( wp_kses( /* translators: %1$s - Mailgun domains area URL. */ __( 'Verify your <a href="%1$s" target="_blank" rel="noopener noreferrer">Domain Name</a> is correct.', 'easy-wp-smtp' ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], ] ), 'https://app.mailgun.com/mg/sending/domains' ), esc_html__( 'Verify your domain Region is correct.', 'easy-wp-smtp' ), ], ], // [mailgun] - Free accounts are for test purposes only. [ 'mailer' => 'mailgun', 'errors' => [ [ 'Free accounts are for test purposes only' ], ], 'title' => esc_html__( 'Mailgun failed.', 'easy-wp-smtp' ), 'description' => [ esc_html__( 'Your Mailgun account does not have access to send emails.', 'easy-wp-smtp' ), esc_html__( 'Typically this error occurs because you have not set up and/or complete domain name verification for your Mailgun account.', 'easy-wp-smtp' ), ], 'steps' => [ sprintf( wp_kses( /* translators: %s - Mailgun documentation URL. */ __( 'Go to our how-to guide for setting up <a href="%s" target="_blank" rel="noopener noreferrer">Mailgun with Easy WP SMTP</a>.', 'easy-wp-smtp' ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], ] ), // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound esc_url( easy_wp_smtp()->get_utm_url( 'https://easywpsmtp.com/docs/setting-up-the-mailgun-mailer/', [ 'medium' => 'email-test', 'content' => 'Mailgun with Easy WP SMTP' ] ) ) ), esc_html__( 'Complete the steps in section "2. Verify Your Domain".', 'easy-wp-smtp' ), ], ], // [gmail] - 401: Login Required. [ 'mailer' => 'gmail', 'errors' => [ [ '401', 'Login Required' ], ], 'title' => esc_html__( 'Google API Error.', 'easy-wp-smtp' ), 'description' => [ esc_html__( 'You have not properly configured Gmail mailer.', 'easy-wp-smtp' ), esc_html__( 'Make sure that you have clicked the "Allow plugin to send emails using your Google account" button under Gmail settings.', 'easy-wp-smtp' ), ], 'steps' => [ esc_html__( 'Go to plugin Settings page and click the "Allow plugin to send emails using your Google account" button.', 'easy-wp-smtp' ), esc_html__( 'After the click you should be redirected to a Gmail authorization screen, where you will be asked a permission to send emails on your behalf.', 'easy-wp-smtp' ), esc_html__( 'Please click "Agree", if you see that button. If not - you will need to enable less secure apps first:', 'easy-wp-smtp' ) . '<ul>' . '<li>' . sprintf( wp_kses( /* translators: %s - Google support article URL. */ __( 'if you are using regular Gmail account, please <a href="%s" target="_blank" rel="noopener noreferrer">read this article</a> to proceed.', 'easy-wp-smtp' ), [ 'a' => [ 'href' => [], 'target' => [], 'rel' => [], ], ] ), 'https://support.google.com/accounts/answer/6010255?hl=en' ) . '</li>' . '<li>' . sprintf( wp_kses( /* translators: %s - Google support article URL. */ __( 'if you are using Google Workspace, please <a href="%s" target="_blank" rel="noopener noreferrer">read this article</a> to proceed.', 'easy-wp-smtp' ), [ 'a' => [ 'href' => [], 'target' => [], 'rel' => [], ], ] ), 'https://support.google.com/cloudidentity/answer/6260879?hl=en' ) . '</li>' . '</ul>', ], ], // [gmail] - 400: Recipient address required. [ 'mailer' => 'gmail', 'errors' => [ [ '400', 'Recipient address required' ], ], 'title' => esc_html__( 'Google API Error.', 'easy-wp-smtp' ), 'description' => [ esc_html__( 'Typically this error occurs because the address to which the email was sent to is invalid or was empty.', 'easy-wp-smtp' ), ], 'steps' => [ esc_html__( 'Check the "Send To" email address used and confirm it is a valid email and was not empty.', 'easy-wp-smtp' ), sprintf( /* translators: 1 - correct email address example. 2 - incorrect email address example. */ esc_html__( 'It should be something like this: %1$s. These are incorrect values: %2$s.', 'easy-wp-smtp' ), '<code>info@example.com</code>', '<code>info@localhost</code>, <code>info@192.168.1.1</code>' ), esc_html__( 'Make sure that the generated email has a TO header, useful when you are responsible for email creation.', 'easy-wp-smtp' ), ], ], // [gmail] - Token has been expired or revoked. [ 'mailer' => 'gmail', 'errors' => [ [ 'invalid_grant', 'Token has been expired or revoked' ], ], 'title' => esc_html__( 'Google API Error.', 'easy-wp-smtp' ), 'description' => [ esc_html__( 'Unfortunately, this error can be due to many different reasons.', 'easy-wp-smtp' ), sprintf( wp_kses( /* translators: %s - Blog article URL. */ __( 'Please <a href="%s" target="_blank" rel="noopener noreferrer">read this article</a> to learn more about what can cause this error and follow the steps below.', 'easy-wp-smtp' ), [ 'a' => [ 'href' => [], 'target' => [], 'rel' => [], ], ] ), 'https://blog.timekit.io/google-oauth-invalid-grant-nightmare-and-how-to-fix-it-9f4efaf1da35' ), ], 'steps' => [ esc_html__( 'Go to Easy WP SMTP plugin settings page. Click the “Remove OAuth Connection” button.', 'easy-wp-smtp' ), esc_html__( 'Then click the “Allow plugin to send emails using your Google account” button and re-enable access.', 'easy-wp-smtp' ), ], ], // [gmail] - Code was already redeemed. [ 'mailer' => 'gmail', 'errors' => [ [ 'invalid_grant', 'Code was already redeemed' ], ], 'title' => esc_html__( 'Google API Error.', 'easy-wp-smtp' ), 'description' => [ esc_html__( 'Authentication code that Google returned to you has already been used on your previous auth attempt.', 'easy-wp-smtp' ), ], 'steps' => [ esc_html__( 'Make sure that you are not trying to manually clean up the plugin options to retry the "Allow..." step.', 'easy-wp-smtp' ), esc_html__( 'Reinstall the plugin with clean plugin data turned on on Misc page. This will remove all the plugin options and you will be safe to retry.', 'easy-wp-smtp' ), esc_html__( 'Make sure there is no aggressive caching on site admin area pages or try to clean cache between attempts.', 'easy-wp-smtp' ), ], ], // [gmail] - 400: Mail service not enabled. [ 'mailer' => 'gmail', 'errors' => [ [ '400', 'Mail service not enabled' ], ], 'title' => esc_html__( 'Google API Error.', 'easy-wp-smtp' ), 'description' => [ esc_html__( 'There are various reasons for that, please review the steps below.', 'easy-wp-smtp' ), ], 'steps' => [ sprintf( wp_kses( /* translators: %s - Google Google Workspace Admin area URL. */ __( 'Make sure that your Google Workspace trial period has not expired. You can check the status <a href="%s" target="_blank" rel="noopener noreferrer">here</a>.', 'easy-wp-smtp' ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], ] ), 'https://admin.google.com' ), sprintf( wp_kses( /* translators: %s - Google Google Workspace Admin area URL. */ __( 'Make sure that Gmail app in your Google Workspace is actually enabled. You can check that in Apps list in <a href="%s" target="_blank" rel="noopener noreferrer">Google Workspace Admin</a> area.', 'easy-wp-smtp' ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], ] ), 'https://admin.google.com' ), sprintf( wp_kses( /* translators: %s - Google Developers Console URL. */ __( 'Make sure that you have Gmail API enabled, and you can do that <a href="%s" target="_blank" rel="noopener noreferrer">here</a>.', 'easy-wp-smtp' ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], ] ), 'https://console.developers.google.com/' ), ], ], // [gmail] - 403: Project X is not found and cannot be used for API calls. [ 'mailer' => 'gmail', 'errors' => [ [ '403', 'is not found and cannot be used for API calls' ], ], 'title' => esc_html__( 'Google API Error.', 'easy-wp-smtp' ), 'description' => [], 'steps' => [ esc_html__( 'Make sure that the used Client ID/Secret correspond to a proper project that has Gmail API enabled.', 'easy-wp-smtp' ), sprintf( wp_kses( /* translators: %s - Gmail documentation URL. */ esc_html__( 'Please follow our <a href="%s" target="_blank" rel="noopener noreferrer">Gmail tutorial</a> to be sure that all the correct project and data is applied.', 'easy-wp-smtp' ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], ] ), // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound esc_url( easy_wp_smtp()->get_utm_url( 'https://easywpsmtp.com/docs/setting-up-the-gmail-mailer/', [ 'medium' => 'email-test', 'content' => 'Gmail tutorial' ] ) ) ), ], ], // [gmail] - The OAuth client was disabled. [ 'mailer' => 'gmail', 'errors' => [ [ 'disabled_client', 'The OAuth client was disabled' ], ], 'title' => esc_html__( 'Google API Error.', 'easy-wp-smtp' ), 'description' => [ esc_html__( 'You may have added a new API to a project', 'easy-wp-smtp' ), ], 'steps' => [ esc_html__( 'Make sure that the used Client ID/Secret correspond to a proper project that has Gmail API enabled.', 'easy-wp-smtp' ), esc_html__( 'Try to use a separate project for your emails, so the project has only 1 Gmail API in it enabled. You will need to remove the old project and create a new one from scratch.', 'easy-wp-smtp' ), ], ], // [SMTP.com] - The "channel - not found" issue. [ 'mailer' => 'smtpcom', 'errors' => [ [ 'channel - not found' ], ], 'title' => esc_html__( 'SMTP.com API Error.', 'easy-wp-smtp' ), 'description' => [ esc_html__( 'Your Sender Name option is incorrect.', 'easy-wp-smtp' ), ], 'steps' => [ esc_html__( 'Please make sure you entered an accurate Sender Name in Easy WP SMTP plugin settings.', 'easy-wp-smtp' ), ], ], // [sparkpost] - Forbidden. [ 'mailer' => 'sparkpost', 'errors' => [ [ 'Forbidden' ], ], 'title' => esc_html__( 'SparkPost API failed.', 'easy-wp-smtp' ), 'description' => [ esc_html__( 'Typically this error occurs because there is an issue with your SparkPost settings, in many cases an incorrect API key.', 'easy-wp-smtp' ), ], 'steps' => [ sprintf( wp_kses( /* translators: %1$s - SparkPost API Keys area URL, %1$s - SparkPost EU API Keys area URL. */ __( 'Go to your <a href="%1$s" target="_blank" rel="noopener noreferrer">SparkPost account</a> or <a href="%2$s" target="_blank" rel="noopener noreferrer">SparkPost EU account</a> and verify that your API key is correct.', 'easy-wp-smtp' ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], 'b' => [], ] ), 'https://app.sparkpost.com/account/api-keys', 'https://app.eu.sparkpost.com/account/api-keys' ), esc_html__( 'Verify that your API key has "Transmissions: Read/Write" permission.', 'easy-wp-smtp' ), ], ], // [sparkpost] - Unauthorized. [ 'mailer' => 'sparkpost', 'errors' => [ [ 'Unauthorized' ], ], 'title' => esc_html__( 'SparkPost API failed.', 'easy-wp-smtp' ), 'description' => [ esc_html__( 'Typically this error occurs because there is an issue with your SparkPost settings, in many cases an incorrect region.', 'easy-wp-smtp' ), ], 'steps' => [ esc_html__( 'Verify that your SparkPost account region is selected in Easy WP SMTP settings.', 'easy-wp-smtp' ), ], ], ]; /** * [any] - PHP 7.4.x and PCRE library issues. */ if ( version_compare( phpversion(), '7.4', '>=' ) && defined( 'PCRE_VERSION' ) && version_compare( PCRE_VERSION, '10.0', '>' ) && version_compare( PCRE_VERSION, '10.32', '<=' ) ) { $details[] = [ 'mailer' => 'any', 'errors' => [ [ 'Invalid address: (setFrom)' ], ], 'title' => esc_html__( 'PCRE library issue', 'easy-wp-smtp' ), 'description' => [ esc_html__( 'It looks like your server is running PHP version 7.4.x with an outdated PCRE library (libpcre2) that has a known issue with email address validation.', 'easy-wp-smtp' ), esc_html__( 'There is a known issue with PHP version 7.4.x, when using libpcre2 library version lower than 10.33.', 'easy-wp-smtp' ), ], 'steps' => [ esc_html__( 'Contact your web hosting provider and inform them you are having issues with libpcre2 library on PHP 7.4.', 'easy-wp-smtp' ), esc_html__( 'They should be able to resolve this issue for you.', 'easy-wp-smtp' ), esc_html__( 'For a quick fix, until your web hosting resolves this, you can downgrade to PHP version 7.3 on your server.', 'easy-wp-smtp' ), ], ]; } // Error detection logic. foreach ( $details as $data ) { // Check for appropriate mailer. if ( 'any' !== $data['mailer'] && $this->debug['mailer'] !== $data['mailer'] ) { continue; } $match = false; // Attempt to detect errors. foreach ( $data['errors'] as $error_group ) { foreach ( $error_group as $error_message ) { $match = false !== strpos( $this->debug['error_log'], $error_message ); if ( ! $match ) { break; } } if ( $match ) { break; } } if ( $match ) { return $data; } } // Return defaults. return [ 'title' => esc_html__( 'An issue was detected.', 'easy-wp-smtp' ), 'description' => [ esc_html__( 'This means your test email was unable to be sent.', 'easy-wp-smtp' ), esc_html__( 'Typically this error is returned for one of the following reasons:', 'easy-wp-smtp' ), '<ul>' . '<li>' . esc_html__( 'Plugin settings are incorrect (wrong SMTP settings, invalid Mailer configuration, etc).', 'easy-wp-smtp' ) . '</li>' . '<li>' . esc_html__( 'Your web server is blocking the connection.', 'easy-wp-smtp' ) . '</li>' . '<li>' . esc_html__( 'Your host is rejecting the connection.', 'easy-wp-smtp' ) . '</li>' . '</ul>', ], 'steps' => [ esc_html__( 'Triple-check the plugin settings and consider reconfiguring to make sure everything is correct. Maybe there was an issue with copy&pasting.', 'easy-wp-smtp' ), wp_kses( __( 'Contact your web hosting provider and ask them to verify your server can make outside connections. Additionally, ask them if a firewall or security policy may be preventing the connection - many shared hosts block certain ports.<br><strong>Note: this is the most common cause of this issue.</strong>', 'easy-wp-smtp' ), [ 'strong' => [], 'br' => [], ] ), esc_html__( 'Try using a different mailer.', 'easy-wp-smtp' ), ], ]; } /** * Displays all the various error and debug details. * * @since 2.0.0 */ protected function display_debug_details() { if ( empty( $this->debug ) ) { return; } $debug = $this->get_debug_details(); $allowed_tags = [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], 'p' => [], 'strong' => [], 'b' => [], 'i' => [], 'br' => [], 'code' => [], 'ul' => [], 'ol' => [], 'li' => [], 'pre' => [], ]; ?> <div id="message" class="notice-error notice-inline"> <p><?php esc_html_e( 'There was a problem while sending the test email.', 'easy-wp-smtp' ); ?></p> </div> <div class="easy-wp-smtp-test-email-debug"> <h2><?php echo esc_html( $debug['title'] ); ?></h2> <?php foreach ( $debug['description'] as $description ) { $description = wp_kses( $description, $allowed_tags ); if ( substr( $description, 0, 1 ) !== '<' ) { echo '<p>' . $description . '</p>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } else { echo $description; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } } ?> <h2><?php esc_html_e( 'Recommended next steps:', 'easy-wp-smtp' ); ?></h2> <ol> <?php foreach ( $debug['steps'] as $step ) : ?> <li><?php echo wp_kses( $step, $allowed_tags ); ?></li> <?php endforeach; ?> </ol> <h2><?php esc_html_e( 'Need support?', 'easy-wp-smtp' ); ?></h2> <?php if ( easy_wp_smtp()->is_pro() ) : ?> <p> <?php printf( wp_kses( /* translators: %s - EasyWPSMTP.com account area link. */ __( 'As a Easy WP SMTP Pro user you have access to Easy WP SMTP priority support. Please log in to your EasyWPSMTP.com account and <a href="%s" target="_blank" rel="noopener noreferrer">submit a support ticket</a>.', 'easy-wp-smtp' ), array( 'a' => array( 'href' => array(), 'rel' => array(), 'target' => array(), ), ) ), // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound esc_url( easy_wp_smtp()->get_utm_url( 'https://easywpsmtp.com/account/support/', [ 'medium' => 'email-test', 'content' => 'submit a support ticket' ] ) ) ); ?> </p> <?php else : ?> <p> <?php esc_html_e( 'Easy WP SMTP is a free plugin, and the team behind SendLayer maintains it to give back to the WordPress community.', 'easy-wp-smtp' ); ?> </p> <p> <?php printf( wp_kses( /* translators: %s - EasyWPSMTP.com URL. */ __( 'To access our world class support, please <a href="%s" target="_blank" rel="noopener noreferrer">upgrade to Easy WP SMTP Pro</a>. Along with getting expert support, you will also get Notification controls, Email Logging, and integrations for Amazon SES, Office 365, and Outlook.com.', 'easy-wp-smtp' ), array( 'a' => array( 'href' => array(), 'target' => array(), 'rel' => array(), ), ) ), esc_url( easy_wp_smtp()->get_upgrade_link( 'email-test-fail' ) ) ) ?> </p> <p> <?php printf( wp_kses( /* Translators: %s - discount value 50% */ __( 'As a valued Easy WP SMTP user, you will get <span class="price-off">%s off regular pricing</span>, automatically applied at checkout!', 'easy-wp-smtp' ), array( 'span' => array( 'class' => array(), ), ) ), '50%' ); ?> </p> <p> <?php printf( wp_kses( /* translators: %1$s - Easy WP SMTP support forum URL, %2$s - EasyWPSMTP.com URL. */ __( 'Alternatively, we also offer limited support on the WordPress.org support forums. You can <a href="%1$s" target="_blank" rel="noopener noreferrer">create a support thread</a> there, but please understand that free support is not guaranteed and is limited to simple issues. If you have an urgent or complex issue, then please consider <a href="%2$s" target="_blank" rel="noopener noreferrer">upgrading to Easy WP SMTP Pro</a> to access our priority support ticket system.', 'easy-wp-smtp' ), array( 'a' => array( 'href' => array(), 'rel' => array(), 'target' => array(), ), ) ), 'https://wordpress.org/support/plugin/easy-wp-smtp/', esc_url( easy_wp_smtp()->get_upgrade_link( 'email-test-fail' ) ) ); ?> </p> <?php endif; ?> <p> <em><?php esc_html_e( 'Please copy the error log message below into the support ticket.', 'easy-wp-smtp' ); ?></em> </p> <p class="easy-wp-smtp-btn-group"> <button type="button" class="easy-wp-smtp-error-log-toggle easy-wp-smtp-btn easy-wp-smtp-btn--secondary"> <?php esc_html_e( 'View Full Error Log', 'easy-wp-smtp' ); ?> </button> <button type="button" class="easy-wp-smtp-error-log-copy easy-wp-smtp-btn easy-wp-smtp-btn--tertiary"> <span class="easy-wp-smtp-error-log-copy-front"> <?php esc_html_e( 'Copy Error Log', 'easy-wp-smtp' ); ?> </span> <span class="easy-wp-smtp-error-log-copy-back"> <?php esc_html_e( 'Copied', 'easy-wp-smtp' ); ?> </span> </button> </p> <div class="easy-wp-smtp-error-log notice-error notice-inline"> <blockquote> <?php echo wp_kses( $this->debug['error_log'], $allowed_tags ); ?> </blockquote> </div> </div> <?php } /** * Display the domain check details. * * @since 2.1.0 */ protected function display_domain_check_details() { if ( empty( $this->domain_checker ) || $this->domain_checker->no_issues() ) { return; } ?> <?php if ( $this->domain_checker->is_supported_mailer() ) : ?> <div class="notice-warning notice-inline easy-wp-smtp-notice"> <p><?php esc_html_e( 'The test email might have sent, but its deliverability should be improved.', 'easy-wp-smtp' ); ?></p> </div> <?php endif; ?> <?php echo $this->domain_checker->get_results_html(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> <?php } } Pages/AdditionalConnectionsTab.php 0000777 00000013716 15252174534 0013245 0 ustar 00 <?php namespace EasyWPSMTP\Admin\Pages; use EasyWPSMTP\Admin\PageAbstract; /** * Class AdditionalConnectionsTab is a placeholder for Pro additional connections feature. * Displays product education. * * @since 2.5.0 */ class AdditionalConnectionsTab extends PageAbstract { /** * Part of the slug of a tab. * * @since 2.5.0 * * @var string */ protected $slug = 'connections'; /** * Constructor. * * @since 2.5.0 * * @param PageAbstract $parent_page Parent page object. */ public function __construct( $parent_page = null ) { parent::__construct( $parent_page ); if ( easy_wp_smtp()->get_admin()->get_current_tab() === $this->slug && ! easy_wp_smtp()->is_pro() ) { $this->hooks(); } } /** * Link label of a tab. * * @since 2.5.0 * * @return string */ public function get_label() { return esc_html__( 'Additional Connections', 'easy-wp-smtp' ); } /** * Register hooks. * * @since 2.5.0 */ public function hooks() { add_action( 'easy_wp_smtp_admin_area_enqueue_assets', [ $this, 'enqueue_assets' ] ); } /** * Enqueue required JS and CSS. * * @since 2.5.0 */ public function enqueue_assets() { wp_enqueue_style( 'easy-wp-smtp-admin-lity', easy_wp_smtp()->assets_url . '/css/vendor/lity.min.css', [], '2.4.1' ); wp_enqueue_script( 'easy-wp-smtp-admin-lity', easy_wp_smtp()->assets_url . '/js/vendor/lity.min.js', [], '2.4.1' ); } /** * Output HTML of additional connections' education. * * @since 2.5.0 */ public function display() { $button_upgrade_link = add_query_arg( [ 'discount' => 'LITEUPGRADE' ], easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'additional-connections', 'content' => 'Upgrade to Pro Button', ] ) ); $link_upgrade_link = add_query_arg( [ 'discount' => 'LITEUPGRADE' ], easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'additional-connections', 'content' => 'upgrade-to-easy-wp-smtp-pro-text-link', ] ) ); ?> <div class="easy-wp-smtp-meta-box"> <div class="easy-wp-smtp-meta-box__header"> <div class="easy-wp-smtp-meta-box__heading"> <?php echo esc_html( $this->get_title() ); ?> </div> <a href="<?php echo esc_url( $button_upgrade_link ); ?>" target="_blank" rel="noopener noreferrer" class="easy-wp-smtp-btn easy-wp-smtp-btn--sm easy-wp-smtp-btn--green"> <?php esc_html_e( 'Upgrade to Pro', 'easy-wp-smtp' ); ?> </a> </div> <div class="easy-wp-smtp-meta-box__content"> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-row__desc"> <?php echo wp_kses( sprintf( /* translators: %s - EasyWPSMTP.com page URL. */ __( 'Set up additional connections to ensure a backup for your Primary Connection or to enable Smart Routing. <a href="%s" target="_blank" rel="noopener noreferrer">Upgrade to Easy WP SMTP Pro</a> to start taking advantage of additional connections.', 'easy-wp-smtp' ), esc_url( $link_upgrade_link ) ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], ] ); ?> </div> </div> <?php $this->display_education_screenshots(); $this->display_education_features_list(); ?> </div> </div> <?php } /** * Output HTML of additional connections' education screenshots. * * @since 2.5.0 */ protected function display_education_screenshots() { $assets_url = easy_wp_smtp()->assets_url . '/images/additional-connections/'; $screenshots = [ [ 'url' => $assets_url . 'screenshot-01.png', 'url_thumbnail' => $assets_url . 'thumbnail-01.png', 'title' => __( 'Backup Connection', 'easy-wp-smtp' ), ], [ 'url' => $assets_url . 'screenshot-02.png', 'url_thumbnail' => $assets_url . 'thumbnail-02.png', 'title' => __( 'Smart Routing', 'easy-wp-smtp' ), ], ]; ?> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-product-education-screenshots easy-wp-smtp-product-education-screenshots--two"> <?php foreach ( $screenshots as $screenshot ) : ?> <div> <a href="<?php echo esc_url( $screenshot['url'] ); ?>" data-lity data-lity-desc="<?php echo esc_attr( $screenshot['title'] ); ?>"> <img src="<?php echo esc_url( $screenshot['url_thumbnail'] ); ?>" alt="<?php esc_attr( $screenshot['title'] ); ?>"> </a> <span><?php echo esc_html( $screenshot['title'] ); ?></span> </div> <?php endforeach; ?> </div> </div> <?php } /** * Output HTML of additional connections' education features list. * * @since 2.5.0 */ protected function display_education_features_list() { ?> <div class="easy-wp-smtp-row easy-wp-smtp-row--has-bg-color easy-wp-smtp-product-education-cta-row"> <div class="easy-wp-smtp-row__heading easy-wp-smtp-settings-heading"> <?php esc_html_e( 'Using additional connections, you are able to:', 'easy-wp-smtp' ); ?> </div> <div class="easy-wp-smtp-product-education-list"> <ul> <li><?php esc_html_e( 'Configure a Backup Connection', 'easy-wp-smtp' ); ?></li> </ul> <ul> <li><?php esc_html_e( 'Utilize different mailers for specific tasks', 'easy-wp-smtp' ); ?></li> </ul> <ul> <li><?php esc_html_e( 'Implement advanced routing rules', 'easy-wp-smtp' ); ?></li> </ul> </div> <?php $this->display_action_button(); ?> </div> <?php } /** * Output the action button. * * @since 2.6.0 */ protected function display_action_button() { $button_upgrade_link = add_query_arg( [ 'discount' => 'LITEUPGRADE' ], easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'additional-connections', 'content' => 'Upgrade to Pro Button', ] ) ); ?> <a href="<?php echo esc_url( $button_upgrade_link ); ?>" target="_blank" rel="noopener noreferrer" class="easy-wp-smtp-btn easy-wp-smtp-btn--lg easy-wp-smtp-btn--green"> <?php esc_html_e( 'Upgrade to Easy WP SMTP Pro', 'easy-wp-smtp' ); ?> </a> <?php } } Pages/Tools.php 0000777 00000001257 15252174534 0007440 0 ustar 00 <?php namespace EasyWPSMTP\Admin\Pages; use EasyWPSMTP\Admin\ParentPageAbstract; /** * Class Tools. * * @since 2.0.0 */ class Tools extends ParentPageAbstract { /** * Slug of a page. * * @since 2.0.0 * * @var string */ protected $slug = 'tools'; /** * Page default tab slug. * * @since 2.0.0 * * @var string */ protected $default_tab = 'test'; /** * Link label of a page. * * @since 2.0.0 * * @return string */ public function get_label() { return esc_html__( 'Tools', 'easy-wp-smtp' ); } /** * Title of a page. * * @since 2.0.0 * * @return string */ public function get_title() { return $this->get_label(); } } Pages/EmailReportsTab.php 0000777 00000012446 15252174534 0011377 0 ustar 00 <?php namespace EasyWPSMTP\Admin\Pages; use EasyWPSMTP\Admin\PageAbstract; /** * Class EmailTrackingReportsTab is a placeholder for Pro email tracking reports. * Displays product education. * * @since 2.1.0 */ class EmailReportsTab extends PageAbstract { /** * Part of the slug of a tab. * * @since 2.1.0 * * @var string */ protected $slug = 'reports'; /** * Tab priority. * * @since 2.1.0 * * @var int */ protected $priority = 10; /** * Link label of a tab. * * @since 2.1.0 * * @return string */ public function get_label() { return esc_html__( 'Email Reports', 'easy-wp-smtp' ); } /** * Title of a tab. * * @since 2.1.0 * * @return string */ public function get_title() { return $this->get_label(); } /** * Register hooks. * * @since 2.1.0 */ public function hooks() { add_action( 'easy_wp_smtp_admin_area_enqueue_assets', [ $this, 'enqueue_assets' ] ); } /** * Enqueue required JS and CSS. * * @since 2.1.0 */ public function enqueue_assets() { wp_enqueue_style( 'easy-wp-smtp-admin-lity', easy_wp_smtp()->assets_url . '/css/vendor/lity.min.css', [], '2.4.1' ); wp_enqueue_script( 'easy-wp-smtp-admin-lity', easy_wp_smtp()->assets_url . '/js/vendor/lity.min.js', [], '2.4.1', false ); } /** * Output HTML of the email reports education. * * @since 2.1.0 */ public function display() { $button_upgrade_link = easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'email-reports', 'content' => 'upgrade-to-easy-wp-smtp-pro-button-link', ] ); $assets_url = easy_wp_smtp()->assets_url . '/images/education/reports/'; $screenshots = [ [ 'url' => $assets_url . 'screenshot-01.png', 'url_thumbnail' => $assets_url . 'thumbnail-01.png', 'title' => __( 'Stats at a Glance', 'easy-wp-smtp' ), ], [ 'url' => $assets_url . 'screenshot-02.png', 'url_thumbnail' => $assets_url . 'thumbnail-02.png', 'title' => __( 'Detailed Stats by Subject Line', 'easy-wp-smtp' ), ], [ 'url' => $assets_url . 'screenshot-03.png', 'url_thumbnail' => $assets_url . 'thumbnail-03.png', 'title' => __( 'Weekly Email Report', 'easy-wp-smtp' ), ], ]; ?> <div class="easy-wp-smtp-meta-box"> <div class="easy-wp-smtp-meta-box__content"> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-row__desc"> <?php echo wp_kses( sprintf( /* translators: %s - EasyWPSMTP.com page URL. */ __( 'With Email Reports, you can track email deliverability and engagement from your WordPress dashboard. Open and click-through rates are grouped by subject line for quick and simple campaign performance analysis. The report will also show how many emails you successfully sent and how many emails failed to send each week so you can find and resolve problems with ease. <a href="%s" target="_blank" rel="noopener noreferrer">Upgrade to Easy WP SMTP Pro</a> now and we’ll add your email report to your WordPress dashboard.', 'easy-wp-smtp' ), esc_url( easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'email-reports', 'content' => 'upgrade-to-easy-wp-smtp-pro-text-link', ] ) ) ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], ] ); ?> </div> </div> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-product-education-screenshots easy-wp-smtp-product-education-screenshots--three"> <?php foreach ( $screenshots as $screenshot ) : ?> <div> <a href="<?php echo esc_url( $screenshot['url'] ); ?>" data-lity data-lity-desc="<?php echo esc_attr( $screenshot['title'] ); ?>"> <img src="<?php echo esc_url( $screenshot['url_thumbnail'] ); ?>" alt="<?php esc_attr( $screenshot['title'] ); ?>"> </a> <span><?php echo esc_html( $screenshot['title'] ); ?></span> </div> <?php endforeach; ?> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-row--has-bg-color easy-wp-smtp-product-education-cta-row"> <div class="easy-wp-smtp-row__heading easy-wp-smtp-settings-heading"> <?php esc_html_e( 'Unlock these awesome reporting features:', 'easy-wp-smtp' ); ?> </div> <div class="easy-wp-smtp-product-education-list"> <ul> <li><?php esc_html_e( 'Receive weekly deliverability reports', 'easy-wp-smtp' ); ?></li> <li><?php esc_html_e( 'See stats grouped by subject line', 'easy-wp-smtp' ); ?></li> </ul> <ul> <li><?php esc_html_e( 'Track total sent emails each week', 'easy-wp-smtp' ); ?></li> <li><?php esc_html_e( 'Monitor open and click-through rates', 'easy-wp-smtp' ); ?></li> </ul> <ul> <li><?php esc_html_e( 'Identify failed emails quickly', 'easy-wp-smtp' ); ?></li> <li><?php esc_html_e( 'View email report charts in WordPress', 'easy-wp-smtp' ); ?></li> </ul> </div> <a href="<?php echo esc_url( $button_upgrade_link ); ?>" target="_blank" rel="noopener noreferrer" class="easy-wp-smtp-btn easy-wp-smtp-btn--lg easy-wp-smtp-btn--green"> <?php esc_html_e( 'Upgrade to Easy WP SMTP Pro', 'easy-wp-smtp' ); ?> </a> </div> </div> </div> <?php } } Pages/ExportTab.php 0000777 00000026173 15252174534 0010254 0 ustar 00 <?php namespace EasyWPSMTP\Admin\Pages; use EasyWPSMTP\Admin\PageAbstract; /** * Class ExportTab is a placeholder for Pro email logs export. * Displays product education. * * @since 2.1.0 */ class ExportTab extends PageAbstract { /** * Part of the slug of a tab. * * @since 2.1.0 * * @var string */ protected $slug = 'export'; /** * Tab priority. * * @since 2.1.0 * * @var int */ protected $priority = 20; /** * Link label of a tab. * * @since 2.1.0 * * @return string */ public function get_label() { return esc_html__( 'Export', 'easy-wp-smtp' ); } /** * Title of a tab. * * @since 2.1.0 * * @return string */ public function get_title() { return $this->get_label(); } /** * Output HTML of the email logs export form preview. * * @since 2.1.0 */ public function display() { $button_upgrade_link = easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'tools-export', 'content' => 'upgrade-to-easy-wp-smtp-pro-button', ] ); ?> <div class="easy-wp-smtp-meta-box"> <div class="easy-wp-smtp-meta-box__header"> <div class="easy-wp-smtp-meta-box__heading"> <?php esc_html_e( 'Export Email Logs', 'easy-wp-smtp' ); ?> </div> <a href="<?php echo esc_url( $button_upgrade_link ); ?>" target="_blank" rel="noopener noreferrer" class="easy-wp-smtp-btn easy-wp-smtp-btn--sm easy-wp-smtp-btn--green"> <?php esc_html_e( 'Upgrade to Pro', 'easy-wp-smtp' ); ?> </a> </div> <div class="easy-wp-smtp-meta-box__content"> <div class="easy-wp-smtp-row easy-wp-smtp-row--has-divider"> <div class="easy-wp-smtp-row__desc"> <?php echo wp_kses( sprintf( /* translators: %s - EasyWPSMTP.com Upgrade page URL. */ __( 'Easily export your logs to CSV or Excel. Filter the logs before you export and only download the data you need. This feature lets you easily create your own deliverability reports. You can also use the data in 3rd party dashboards to track deliverability along with your other website statistics. <a href="%s" target="_blank" rel="noopener noreferrer">Upgrade to Easy WP SMTP Pro!</a>', 'easy-wp-smtp' ), esc_url( easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'tools-export', 'content' => 'upgrade-to-easy-wp-smtp-pro-text-link', ] ) ) ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], ] ); ?> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-setting-row--inactive easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <?php esc_html_e( 'Export Type', 'easy-wp-smtp' ); ?> </div> <div class="easy-wp-smtp-setting-row__field"> <div class="easy-wp-smtp-radio-group"> <label class="easy-wp-smtp-radio"> <input type="radio" checked> <span class="easy-wp-smtp-radio__checkmark"></span> <span class="easy-wp-smtp-radio__label"> <?php esc_html_e( 'Export in CSV (.csv)', 'easy-wp-smtp' ); ?> </span> </label> <label class="easy-wp-smtp-radio"> <span class="easy-wp-smtp-radio__checkmark"></span> <span class="easy-wp-smtp-radio__label"> <?php esc_html_e( 'Export in Microsoft Excel (.xlsx)', 'easy-wp-smtp' ); ?> </span> </label> <label class="easy-wp-smtp-radio"> <span class="easy-wp-smtp-radio__checkmark"></span> <span class="easy-wp-smtp-radio__label"> <?php esc_html_e( 'Export in EML (.eml)', 'easy-wp-smtp' ); ?> </span> </label> </div> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-setting-row--inactive easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text"> <div class="easy-wp-smtp-setting-row__label"> <?php esc_html_e( 'Custom Date Range', 'easy-wp-smtp' ); ?> </div> <div class="easy-wp-smtp-setting-row__field"> <input class="easy-wp-smtp-date-selector form-control input" placeholder="Select a date range" tabindex="0" type="text"> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-setting-row--inactive easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <?php esc_html_e( 'Search', 'easy-wp-smtp' ); ?> </div> <div class="easy-wp-smtp-setting-row__field"> <div class="easy-wp-smtp-setting-row__sub-row easy-wp-smtp-radio-group"> <label class="easy-wp-smtp-radio"> <input type="radio" checked> <span class="easy-wp-smtp-radio__checkmark"></span> <span class="easy-wp-smtp-radio__label"> <?php esc_html_e( 'Email Addresses', 'easy-wp-smtp' ); ?> </span> </label> <label class="easy-wp-smtp-radio"> <span class="easy-wp-smtp-radio__checkmark"></span> <span class="easy-wp-smtp-radio__label"> <?php esc_html_e( 'Subject & Headers', 'easy-wp-smtp' ); ?> </span> </label> <label class="easy-wp-smtp-radio"> <span class="easy-wp-smtp-radio__checkmark"></span> <span class="easy-wp-smtp-radio__label"> <?php esc_html_e( 'Content', 'easy-wp-smtp' ); ?> </span> </label> </div> <div class="easy-wp-smtp-setting-row__sub-row"> <input type="text" class="easy-wp-smtp-search-box-term"> </div> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-setting-row--inactive easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <?php esc_html_e( 'Common Information', 'easy-wp-smtp' ); ?> </div> <div class="easy-wp-smtp-setting-row__field"> <div class="easy-wp-smtp-checkbox-group"> <label class="easy-wp-smtp-checkbox"> <input type="checkbox" checked> <span class="easy-wp-smtp-checkbox__checkmark"></span> <span class="easy-wp-smtp-checkbox__label"> <?php esc_html_e( 'To Address', 'easy-wp-smtp' ); ?> </span> </label> <label class="easy-wp-smtp-checkbox"> <input type="checkbox" checked> <span class="easy-wp-smtp-checkbox__checkmark"></span> <span class="easy-wp-smtp-checkbox__label"> <?php esc_html_e( 'From Address', 'easy-wp-smtp' ); ?> </span> </label> <label class="easy-wp-smtp-checkbox"> <input type="checkbox" checked> <span class="easy-wp-smtp-checkbox__checkmark"></span> <span class="easy-wp-smtp-checkbox__label"> <?php esc_html_e( 'From Name', 'easy-wp-smtp' ); ?> </span> </label> <label class="easy-wp-smtp-checkbox"> <input type="checkbox" checked> <span class="easy-wp-smtp-checkbox__checkmark"></span> <span class="easy-wp-smtp-checkbox__label"> <?php esc_html_e( 'Subject', 'easy-wp-smtp' ); ?> </span> </label> <label class="easy-wp-smtp-checkbox"> <input type="checkbox" checked> <span class="easy-wp-smtp-checkbox__checkmark"></span> <span class="easy-wp-smtp-checkbox__label"> <?php esc_html_e( 'Body', 'easy-wp-smtp' ); ?> </span> </label> <label class="easy-wp-smtp-checkbox"> <input type="checkbox" checked> <span class="easy-wp-smtp-checkbox__checkmark"></span> <span class="easy-wp-smtp-checkbox__label"> <?php esc_html_e( 'Created Date', 'easy-wp-smtp' ); ?> </span> </label> <label class="easy-wp-smtp-checkbox"> <input type="checkbox" checked> <span class="easy-wp-smtp-checkbox__checkmark"></span> <span class="easy-wp-smtp-checkbox__label"> <?php esc_html_e( 'Number of Attachments', 'easy-wp-smtp' ); ?> </span> </label> <label class="easy-wp-smtp-checkbox"> <input type="checkbox" checked> <span class="easy-wp-smtp-checkbox__checkmark"></span> <span class="easy-wp-smtp-checkbox__label"> <?php esc_html_e( 'Attachments', 'easy-wp-smtp' ); ?> </span> </label> </div> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-setting-row--inactive easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <?php esc_html_e( 'Additional Information', 'easy-wp-smtp' ); ?> </div> <div class="easy-wp-smtp-setting-row__field"> <div class="easy-wp-smtp-checkbox-group"> <label class="easy-wp-smtp-checkbox"> <span class="easy-wp-smtp-checkbox__checkmark"></span> <span class="easy-wp-smtp-checkbox__label"> <?php esc_html_e( 'Status', 'easy-wp-smtp' ); ?> </span> </label> <label class="easy-wp-smtp-checkbox"> <span class="easy-wp-smtp-checkbox__checkmark"></span> <span class="easy-wp-smtp-checkbox__label"> <?php esc_html_e( 'Carbon Copy (CC)', 'easy-wp-smtp' ); ?> </span> </label> <label class="easy-wp-smtp-checkbox"> <span class="easy-wp-smtp-checkbox__checkmark"></span> <span class="easy-wp-smtp-checkbox__label"> <?php esc_html_e( 'Blind Carbon Copy (BCC)', 'easy-wp-smtp' ); ?> </span> </label> <label class="easy-wp-smtp-checkbox"> <span class="easy-wp-smtp-checkbox__checkmark"></span> <span class="easy-wp-smtp-checkbox__label"> <?php esc_html_e( 'Headers', 'easy-wp-smtp' ); ?> </span> </label> <label class="easy-wp-smtp-checkbox"> <span class="easy-wp-smtp-checkbox__checkmark"></span> <span class="easy-wp-smtp-checkbox__label"> <?php esc_html_e( 'Mailer', 'easy-wp-smtp' ); ?> </span> </label> <label class="easy-wp-smtp-checkbox"> <span class="easy-wp-smtp-checkbox__checkmark"></span> <span class="easy-wp-smtp-checkbox__label"> <?php esc_html_e( 'Error Details', 'easy-wp-smtp' ); ?> </span> </label> <label class="easy-wp-smtp-checkbox"> <span class="easy-wp-smtp-checkbox__checkmark"></span> <span class="easy-wp-smtp-checkbox__label"> <?php esc_html_e( 'Email log ID', 'easy-wp-smtp' ); ?> </span> </label> <label class="easy-wp-smtp-checkbox"> <span class="easy-wp-smtp-checkbox__checkmark"></span> <span class="easy-wp-smtp-checkbox__label"> <?php esc_html_e( 'Opened', 'easy-wp-smtp' ); ?> </span> </label> <label class="easy-wp-smtp-checkbox"> <span class="easy-wp-smtp-checkbox__checkmark"></span> <span class="easy-wp-smtp-checkbox__label"> <?php esc_html_e( 'Clicked', 'easy-wp-smtp' ); ?> </span> </label> <label class="easy-wp-smtp-checkbox"> <span class="easy-wp-smtp-checkbox__checkmark"></span> <span class="easy-wp-smtp-checkbox__label"> <?php esc_html_e( 'Source', 'easy-wp-smtp' ); ?> </span> </label> </div> </div> </div> </div> </div> <a href="<?php echo esc_url( $button_upgrade_link ); ?>" target="_blank" rel="noopener noreferrer" class="easy-wp-smtp-btn easy-wp-smtp-btn--lg easy-wp-smtp-btn--green"> <?php esc_html_e( 'Upgrade to Easy WP SMTP Pro', 'easy-wp-smtp' ); ?> </a> <?php } } Pages/SmartRoutingTab.php 0000777 00000027735 15252174534 0011436 0 ustar 00 <?php namespace EasyWPSMTP\Admin\Pages; use EasyWPSMTP\Admin\PageAbstract; /** * Class SmartRoutingTab is a placeholder for Pro smart routing feature. * Displays product education. * * @since 2.5.0 */ class SmartRoutingTab extends PageAbstract { /** * Part of the slug of a tab. * * @since 2.5.0 * * @var string */ protected $slug = 'routing'; /** * Constructor. * * @since 2.5.0 * * @param PageAbstract $parent_page Parent page object. */ public function __construct( $parent_page = null ) { parent::__construct( $parent_page ); if ( easy_wp_smtp()->get_admin()->get_current_tab() === $this->slug && ! easy_wp_smtp()->is_pro() ) { $this->hooks(); } } /** * Link label of a tab. * * @since 2.5.0 * * @return string */ public function get_label() { return esc_html__( 'Smart Routing', 'easy-wp-smtp' ); } /** * Register hooks. * * @since 2.5.0 */ public function hooks() { add_action( 'easy_wp_smtp_admin_area_enqueue_assets', [ $this, 'enqueue_assets' ] ); } /** * Enqueue required JS and CSS. * * @since 2.5.0 */ public function enqueue_assets() { wp_enqueue_style( 'easy-wp-smtp-smart-routing', easy_wp_smtp()->plugin_url . '/assets/css/smtp-smart-routing.min.css', [], EasyWPSMTP_PLUGIN_VERSION ); } /** * Output HTML of smart routing education. * * @since 2.5.0 */ public function display() { $upgrade_button_url = easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'Smart Routing Settings', 'content' => 'Upgrade to Easy WP SMTP Pro Button Top', ] ); ?> <div class="easy-wp-smtp-meta-box"> <div class="easy-wp-smtp-meta-box__header"> <div class="easy-wp-smtp-meta-box__heading"> <?php esc_html_e( 'Smart Routing', 'easy-wp-smtp' ); ?> </div> <a href="<?php echo esc_url( $upgrade_button_url ); ?>" target="_blank" rel="noopener noreferrer" class="easy-wp-smtp-btn easy-wp-smtp-btn--sm easy-wp-smtp-btn--green"> <?php esc_html_e( 'Upgrade to Pro', 'easy-wp-smtp' ); ?> </a> </div> <div class="easy-wp-smtp-meta-box__content"> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-row__desc"> <?php echo wp_kses( sprintf( /* translators: %s - EasyWPSMTP.com page URL. */ __( 'Route emails through different additional connections depending on your set conditions. Any emails that don\'t meet these conditions will be sent through your Primary Connection. <a href="%s" target="_blank" rel="noopener noreferrer">Upgrade to Easy WP SMTP Pro</a>.', 'easy-wp-smtp' ), esc_url( $upgrade_button_url ) ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], ] ); ?> </div> </div> <div class="easy-wp-smtp-row"> <label for="easy-wp-smtp-setting-from_name_force" class="easy-wp-smtp-toggle"> <input type="checkbox" value="true" id="easy-wp-smtp-setting-from_name_force" disabled/> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--static"> <?php esc_html_e( 'Enable Smart Routing', 'easy-wp-smtp' ); ?> </span> </label> </div> </div> </div> <div class="easy-wp-smtp-meta-box"> <div class="easy-wp-smtp-meta-box__header"> <div class="easy-wp-smtp-meta-box__heading"> <?php esc_html_e( 'Conditions', 'easy-wp-smtp' ); ?> </div> </div> <div class="easy-wp-smtp-meta-box__content"> <div class="easy-wp-smtp-smart-routing-routes"> <div class="easy-wp-smtp-setting-row easy-wp-smtp-row--inactive easy-wp-smtp-smart-routing-route"> <div class="easy-wp-smtp-smart-routing-route__header"> <span><?php esc_html_e( 'Send with', 'easy-wp-smtp' ); ?></span> <select class="easy-wp-smtp-smart-routing-route__connection"> <option><?php esc_html_e( 'WooCommerce Emails (SendLayer)', 'easy-wp-smtp' ); ?></option> </select> <span><?php esc_html_e( 'if the following conditions are met...', 'easy-wp-smtp' ); ?></span> <div class="easy-wp-smtp-smart-routing-route__actions"> <div class="easy-wp-smtp-smart-routing-route__order"> <button class="easy-wp-smtp-smart-routing-route__order-btn easy-wp-smtp-smart-routing-route__order-btn--up"></button> <button class="easy-wp-smtp-smart-routing-route__order-btn easy-wp-smtp-smart-routing-route__order-btn--down"></button> </div> <button class="easy-wp-smtp-smart-routing-route__delete"> <i class="dashicons dashicons-trash"></i> </button> </div> </div> <div class="easy-wp-smtp-smart-routing-route__main"> <div class="easy-wp-smtp-conditional"> <div class="easy-wp-smtp-conditional__group"> <table> <tbody> <tr class="easy-wp-smtp-conditional__row"> <td class="easy-wp-smtp-conditional__property-col"> <select> <option><?php esc_html_e( 'Subject', 'easy-wp-smtp' ); ?></option> </select> </td> <td class="easy-wp-smtp-conditional__operator-col"> <select class="easy-wp-smtp-conditional__operator"> <option><?php esc_html_e( 'Contains', 'easy-wp-smtp' ); ?></option> </select> </td> <td class="easy-wp-smtp-conditional__value-col"> <input type="text" value="<?php esc_html_e( 'Order', 'easy-wp-smtp' ); ?>" class="easy-wp-smt-conditional__value"> </td> <td class="easy-wp-smtp-conditional__actions"> <button class="easy-wp-smtp-conditional__add-rule easy-wp-smtp-btn easy-wp-smtp-btn easy-wp-smtp-btn--secondary"> <?php esc_html_e( 'And', 'easy-wp-smtp' ); ?> </button> <button class="easy-wp-smtp-conditional__delete-rule"> <i class="dashicons dashicons-trash" aria-hidden="true"></i> </button> </td> </tr> <tr class="easy-wp-smtp-conditional__row"> <td class="easy-wp-smtp-conditional__property-col"> <select class="easy-wp-smtp-conditional__property"> <option><?php esc_html_e( 'From Email', 'easy-wp-smtp' ); ?></option> </select> </td> <td class="easy-wp-smtp-conditional__operator-col"> <select class="easy-wp-smtp-conditional__operator"> <option><?php esc_html_e( 'Is', 'easy-wp-smtp' ); ?></option> </select> </td> <td class="easy-wp-smtp-conditional__value-col"> <input type="text" value="shop@easywpsmtp.com" class="easy-wp-smtp-conditional__value"> </td> <td class="easy-wp-smtp-conditional__actions"> <button class="easy-wp-smtp-conditional__add-rule easy-wp-smtp-btn easy-wp-smtp-btn easy-wp-smtp-btn--secondary"> <?php esc_html_e( 'And', 'easy-wp-smtp' ); ?> </button> <button class="easy-wp-smtp-conditional__delete-rule"> <i class="dashicons dashicons-trash" aria-hidden="true"></i> </button> </td> </tr> </tbody> </table> <div class="easy-wp-smtp-conditional__group-delimiter"><?php esc_html_e( 'or', 'easy-wp-smtp' ); ?></div> </div> <div class="easy-wp-smtp-conditional__group"> <table> <tbody> <tr class="easy-wp-smtp-conditional__row"> <td class="easy-wp-smtp-conditional__property-col"> <select class="easy-wp-smtp-conditional__property"> <option><?php esc_html_e( 'From Email', 'easy-wp-smtp' ); ?></option> </select> </td> <td class="easy-wp-smtp-conditional__operator-col"> <select class="easy-wp-smtp-conditional__operator"> <option><?php esc_html_e( 'Is', 'easy-wp-smtp' ); ?></option> </select> </td> <td class="easy-wp-smtp-conditional__value-col"> <input type="text" value="returns@easywpsmtp.com" class="easy-wp-smtp-conditional__value"> </td> <td class="easy-wp-smtp-conditional__actions"> <button class="easy-wp-smtp-conditional__add-rule easy-wp-smtp-btn easy-wp-smtp-btn easy-wp-smtp-btn--secondary"> <?php esc_html_e( 'And', 'easy-wp-smtp' ); ?> </button> <button class="easy-wp-smtp-conditional__delete-rule"> <i class="dashicons dashicons-trash" aria-hidden="true"></i> </button> </td> </tr> </tbody> </table> <div class="easy-wp-smtp-conditional__group-delimiter"><?php esc_html_e( 'or', 'easy-wp-smtp' ); ?></div> </div> <button class="easy-wp-smtp-conditional__add-group easy-wp-smtp-btn easy-wp-smtp-btn easy-wp-smtp-btn--secondary"> <?php esc_html_e( 'Add New Group', 'easy-wp-smtp' ); ?> </button> </div> </div> </div> <div class="easy-wp-smtp-setting-row easy-wp-smtp-row--inactive easy-wp-smtp-smart-routing-route"> <div class="easy-wp-smtp-smart-routing-route__header"> <span><?php esc_html_e( 'Send with', 'easy-wp-smtp' ); ?></span> <select class="easy-wp-smtp-smart-routing-route__connection"> <option><?php esc_html_e( 'Contact Emails (SMTP.com)', 'easy-wp-smtp' ); ?></option> </select> <span><?php esc_html_e( 'if the following conditions are met...', 'easy-wp-smtp' ); ?></span> <div class="easy-wp-smtp-smart-routing-route__actions"> <div class="easy-wp-smtp-smart-routing-route__order"> <button class="easy-wp-smtp-smart-routing-route__order-btn easy-wp-smtp-smart-routing-route__order-btn--up"></button> <button class="easy-wp-smtp-smart-routing-route__order-btn easy-wp-smtp-smart-routing-route__order-btn--down"></button> </div> <button class="easy-wp-smtp-smart-routing-route__delete"> <i class="dashicons dashicons-trash"></i> </button> </div> </div> <div class="easy-wp-smtp-smart-routing-route__main"> <div class="easy-wp-smtp-conditional"> <div class="easy-wp-smtp-conditional__group"> <table> <tbody> <tr class="easy-wp-smtp-conditional__row"> <td class="easy-wp-smtp-conditional__property-col"> <select> <option><?php esc_html_e( 'Initiator', 'easy-wp-smtp' ); ?></option> </select> </td> <td class="easy-wp-smtp-conditional__operator-col"> <select class="easy-wp-smtp-conditional__operator"> <option><?php esc_html_e( 'Is', 'easy-wp-smtp' ); ?></option> </select> </td> <td class="easy-wp-smtp-conditional__value-col"> <input type="text" value="<?php esc_html_e( 'WPForms', 'easy-wp-smtp' ); ?>" class="easy-wp-smtp-conditional__value"> </td> <td class="easy-wp-smtp-conditional__actions"> <button class="easy-wp-smtp-conditional__add-rule easy-wp-smtp-btn easy-wp-smtp-btn easy-wp-smtp-btn--secondary"> <?php esc_html_e( 'And', 'easy-wp-smtp' ); ?> </button> <button class="easy-wp-smtp-conditional__delete-rule"> <i class="dashicons dashicons-trash" aria-hidden="true"></i> </button> </td> </tr> </tbody> </table> <div class="easy-wp-smtp-conditional__group-delimiter"><?php esc_html_e( 'or', 'easy-wp-smtp' ); ?></div> </div> <button class="easy-wp-smtp-conditional__add-group easy-wp-smtp-btn easy-wp-smtp-btn easy-wp-smtp-btn--secondary"> <?php esc_html_e( 'Add New Group', 'easy-wp-smtp' ); ?> </button> </div> </div> </div> </div> </div> </div> <div class="easy-wp-smtp-row"> <a href="<?php echo esc_url( $upgrade_button_url ); ?>" target="_blank" rel="noopener noreferrer" class="easy-wp-smtp-btn easy-wp-smtp-btn--lg easy-wp-smtp-btn--green"> <?php esc_html_e( 'Upgrade to Easy WP SMTP Pro', 'easy-wp-smtp' ); ?> </a> </div> <?php } } Pages/AlertsTab.php 0000777 00000052467 15252174534 0010232 0 ustar 00 <?php namespace EasyWPSMTP\Admin\Pages; use EasyWPSMTP\Admin\PageAbstract; /** * Class AlertsTab is a placeholder for Pro alerts feature. * Displays product education. * * @since 2.4.0 */ class AlertsTab extends PageAbstract { /** * Part of the slug of a tab. * * @since 2.4.0 * * @var string */ protected $slug = 'alerts'; /** * Tab priority. * * @since 2.4.0 * * @var int */ protected $priority = 20; /** * Link label of a tab. * * @since 2.4.0 * * @return string */ public function get_label() { return esc_html__( 'Alerts', 'easy-wp-smtp' ); } /** * Title of a tab. * * @since 2.4.0 * * @return string */ public function get_title() { return $this->get_label(); } /** * Output HTML of the alerts settings preview. * * @since 2.4.0 */ public function display() { $upgrade_link_url = easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'Alerts Settings', 'content' => 'Upgrade to Easy WP SMTP Pro Link', ] ); $upgrade_button_url = easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'Alerts Settings', 'content' => 'Upgrade to Easy WP SMTP Pro Button', ] ); ?> <div class="easy-wp-smtp-meta-box"> <div class="easy-wp-smtp-meta-box__header"> <div class="easy-wp-smtp-meta-box__heading"> <?php esc_html_e( 'Alerts', 'easy-wp-smtp' ); ?> </div> <a href="<?php echo esc_url( $upgrade_button_url ); ?>" target="_blank" rel="noopener noreferrer" class="easy-wp-smtp-btn easy-wp-smtp-btn--sm easy-wp-smtp-btn--green"> <?php esc_html_e( 'Upgrade to Pro', 'easy-wp-smtp' ); ?> </a> </div> <div class="easy-wp-smtp-meta-box__content"> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-row__desc"> <?php echo wp_kses( sprintf( /* translators: %s - EasyWPSMTP.com Upgrade page URL. */ __( 'Configure these alert options to receive notifications when email fails to send from your site. Alert notifications will contain the following important data: email subject, email Send To address, the error message, and helpful links to help you fix the issue. <a href="%s" target="_blank" rel="noopener noreferrer">Upgrade to Easy WP SMTP Pro!</a>', 'easy-wp-smtp' ), esc_url( $upgrade_link_url ) ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], ] ); ?> </div> </div> <div id="easy-wp-smtp-setting-row-alert_event_types" class="easy-wp-smtp-row easy-wp-smtp-setting-row easy-wp-smtp-row--has-divider"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-alert_event_types"> <?php esc_html_e( 'Notify when', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <div class="easy-wp-smtp-setting-row__sub-row"> <label class="easy-wp-smtp-toggle" for="easy-wp-smtp-setting-alert_events_email_fails"> <input name="easy-wp-smtp[alert_events][email_fails]" type="checkbox" value="true" checked disabled id="easy-wp-smtp-setting-alert_events_email_fails" /> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--static"><?php esc_html_e( 'The initial email sending request fails', 'easy-wp-smtp' ); ?></span> </label> <p class="desc"> <?php esc_html_e( 'This option is always enabled and will notify you about instant email sending failures.', 'easy-wp-smtp' ); ?> </p> </div> <div class="easy-wp-smtp-setting-row__sub-row"> <label class="easy-wp-smtp-toggle" for="easy-wp-smtp-setting-alerts_hard_bounced"> <input name="easy-wp-smtp[alert_events][email_hard_bounced]" type="checkbox" value="true" disabled id="easy-wp-smtp-setting-alert_events_email_hard_bounced" /> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--static"><?php esc_html_e( 'The deliverability verification process detects a hard bounce', 'easy-wp-smtp' ); ?></span> </label> <p class="desc"> <?php esc_html_e( 'Get notified about emails that were successfully sent, but have hard bounced on delivery attempt. A hard bounce is an email that has failed to deliver for permanent reasons, such as the recipient\'s email address being invalid.', 'easy-wp-smtp' ); ?> </p> </div> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-row--has-divider easy-wp-smtp-row--inactive easy-wp-smtp-alert-setting-row"> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-row__heading"> <?php esc_html_e( 'Email', 'easy-wp-smtp' ); ?> </div> <div class="easy-wp-smtp-row__desc"> <?php esc_html_e( 'Enter the email addresses (3 max) you’d like to use to receive alerts when email sending fails. Read our documentation on setting up email alerts.', 'easy-wp-smtp' ); ?> </div> </div> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label><?php esc_html_e( 'Email Alerts', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"> <label class="easy-wp-smtp-toggle"> <input type="checkbox"/> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'On', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Off', 'easy-wp-smtp' ); ?></span> </label> </div> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-alert-setting-row-options"> <div class="easy-wp-smtp-row easy-wp-smtp-alert-setting-row-connection-options"> <div class="easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text"> <div class="easy-wp-smtp-setting-row__label"> <label><?php esc_html_e( 'Send To', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"><input type="text"></div> </div> </div> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-row--has-divider easy-wp-smtp-row--inactive easy-wp-smtp-alert-setting-row"> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-row__heading"> <?php esc_html_e( 'Slack', 'easy-wp-smtp' ); ?> </div> <div class="easy-wp-smtp-row__desc"> <?php esc_html_e( 'Paste in the Slack webhook URL you’d like to use to receive alerts when email sending fails. Read our documentation on setting up Slack alerts.', 'easy-wp-smtp' ); ?> </div> </div> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label><?php esc_html_e( 'Slack Alerts', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"> <label class="easy-wp-smtp-toggle"> <input type="checkbox"/> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'On', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Off', 'easy-wp-smtp' ); ?></span> </label> </div> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-alert-setting-row-options"> <div class="easy-wp-smtp-row easy-wp-smtp-alert-setting-row-connection-options"> <div class="easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text"> <div class="easy-wp-smtp-setting-row__label"> <label><?php esc_html_e( 'Webhook URL', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"><input type="text"></div> </div> </div> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-row--has-divider easy-wp-smtp-row--inactive easy-wp-smtp-alert-setting-row"> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-row__heading"> <?php esc_html_e( 'Discord', 'easy-wp-smtp' ); ?> </div> <div class="easy-wp-smtp-row__desc"> <?php esc_html_e( 'Paste in the Discord webhook URL you’d like to use to receive alerts when email sending fails. Read our documentation on setting up Discord alerts.', 'easy-wp-smtp' ); ?> </div> </div> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label><?php esc_html_e( 'Discord', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"> <label class="easy-wp-smtp-toggle"> <input type="checkbox"/> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'On', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Off', 'easy-wp-smtp' ); ?></span> </label> </div> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-alert-setting-row-options"> <div class="easy-wp-smtp-row easy-wp-smtp-alert-setting-row-connection-options"> <div class="easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text"> <div class="easy-wp-smtp-setting-row__label"> <label><?php esc_html_e( 'Webhook URL', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"><input type="text"></div> </div> </div> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-row--has-divider easy-wp-smtp-row--inactive easy-wp-smtp-alert-setting-row"> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-row__heading"> <?php esc_html_e( 'Microsoft Teams', 'easy-wp-smtp' ); ?> </div> <div class="easy-wp-smtp-row__desc"> <?php esc_html_e( 'Paste in the Microsoft Teams webhook URL you’d like to use to receive alerts when email sending fails. Read our documentation on setting up Microsoft Teams alerts.', 'easy-wp-smtp' ); ?> </div> </div> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label><?php esc_html_e( 'Microsoft Teams Alerts', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"> <label class="easy-wp-smtp-toggle"> <input type="checkbox"/> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'On', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Off', 'easy-wp-smtp' ); ?></span> </label> </div> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-alert-setting-row-options"> <div class="easy-wp-smtp-row easy-wp-smtp-alert-setting-row-connection-options"> <div class="easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text"> <div class="easy-wp-smtp-setting-row__label"> <label><?php esc_html_e( 'Webhook URL', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"><input type="text"></div> </div> </div> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-row--has-divider easy-wp-smtp-row--inactive easy-wp-smtp-alert-setting-row"> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-row__heading"> <?php esc_html_e( 'SMS via Twilio', 'easy-wp-smtp' ); ?> </div> <div class="easy-wp-smtp-row__desc"> <?php esc_html_e( 'To receive SMS alerts, you’ll need a Twilio account. Read our documentation to learn how to set up Twilio SMS, then enter your connection details below.', 'easy-wp-smtp' ); ?> </div> </div> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label><?php esc_html_e( 'SMS via Twilio Alerts', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"> <label class="easy-wp-smtp-toggle"> <input type="checkbox"/> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'On', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Off', 'easy-wp-smtp' ); ?></span> </label> </div> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-alert-setting-row-options"> <div class="easy-wp-smtp-row easy-wp-smtp-alert-setting-row-connection-options"> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text"> <div class="easy-wp-smtp-setting-row__label"> <label><?php esc_html_e( 'Twilio Account ID', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"><input type="text"></div> </div> </div> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text"> <div class="easy-wp-smtp-setting-row__label"> <label><?php esc_html_e( 'Twilio Auth Token', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"><input type="text"></div> </div> </div> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text"> <div class="easy-wp-smtp-setting-row__label"> <label><?php esc_html_e( 'From Phone Number', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"><input type="text"></div> </div> </div> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text"> <div class="easy-wp-smtp-setting-row__label"> <label><?php esc_html_e( 'To Phone Number', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"><input type="text"></div> </div> </div> </div> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-row--has-divider easy-wp-smtp-row--inactive easy-wp-smtp-alert-setting-row"> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-row__heading"> <?php esc_html_e( 'Webhook', 'easy-wp-smtp' ); ?> </div> <div class="easy-wp-smtp-row__desc"> <?php esc_html_e( 'Paste in the webhook URL you’d like to use to receive alerts when email sending fails. Read our documentation on setting up webhook alerts.', 'easy-wp-smtp' ); ?> </div> </div> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label><?php esc_html_e( 'Webhook Alerts', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"> <label class="easy-wp-smtp-toggle"> <input type="checkbox"/> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'On', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Off', 'easy-wp-smtp' ); ?></span> </label> </div> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-alert-setting-row-options"> <div class="easy-wp-smtp-row easy-wp-smtp-alert-setting-row-connection-options"> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text"> <div class="easy-wp-smtp-setting-row__label"> <label><?php esc_html_e( 'Webhook URL', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"><input type="text"></div> </div> </div> </div> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-row--has-divider easy-wp-smtp-row--inactive easy-wp-smtp-alert-setting-row"> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-row__heading"> <?php esc_html_e( 'Push Notifications', 'easy-wp-smtp' ); ?> </div> <div class="easy-wp-smtp-row__desc"> <?php esc_html_e( 'To receive push notifications on this device, you\'ll need to allow our plugin to send notifications via this browser. Read our documentation on setting up Push Notification alerts.', 'easy-wp-smtp' ); ?> </div> </div> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label><?php esc_html_e( 'Push Notification Alerts', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"> <label class="easy-wp-smtp-toggle"> <input type="checkbox"/> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'On', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Off', 'easy-wp-smtp' ); ?></span> </label> </div> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-alert-setting-row-options"> <div class="easy-wp-smtp-row easy-wp-smtp-alert-setting-row-connection-options"> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text"> <div class="easy-wp-smtp-setting-row__label"> <label><?php esc_html_e( 'Connection Name', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"><input type="text"></div> </div> </div> </div> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-row--has-divider easy-wp-smtp-row--inactive easy-wp-smtp-alert-setting-row"> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-row__heading"> <?php esc_html_e( 'WhatsApp', 'easy-wp-smtp' ); ?> </div> <div class="easy-wp-smtp-row__desc"> <?php esc_html_e( 'Enter your WhatsApp Cloud API credentials to receive alerts when email sending fails. You\'ll need to create a Meta developer account and set up WhatsApp Business Platform.', 'easy-wp-smtp' ); ?> </div> </div> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label><?php esc_html_e( 'WhatsApp Alerts', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"> <label class="easy-wp-smtp-toggle"> <input type="checkbox"/> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'On', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Off', 'easy-wp-smtp' ); ?></span> </label> </div> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-alert-setting-row-options"> <div class="easy-wp-smtp-row easy-wp-smtp-alert-setting-row-connection-options"> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text"> <div class="easy-wp-smtp-setting-row__label"> <label><?php esc_html_e( 'Access Token', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"><input type="text"></div> </div> <div class="easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text"> <div class="easy-wp-smtp-setting-row__label"> <label><?php esc_html_e( 'WhatsApp Business Account ID', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"><input type="text"></div> </div> <div class="easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text"> <div class="easy-wp-smtp-setting-row__label"> <label><?php esc_html_e( 'Phone Number ID', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"><input type="text"></div> </div> <div class="easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text"> <div class="easy-wp-smtp-setting-row__label"> <label><?php esc_html_e( 'To Phone Number', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"><input type="text"></div> </div> </div> </div> </div> </div> </div> </div> <a href="<?php echo esc_url( $upgrade_button_url ); ?>" target="_blank" rel="noopener noreferrer" class="easy-wp-smtp-btn easy-wp-smtp-btn--lg easy-wp-smtp-btn--green"> <?php esc_html_e( 'Upgrade to Easy WP SMTP Pro', 'easy-wp-smtp' ); ?> </a> <?php } } Pages/ControlTab.php 0000777 00000033361 15252174534 0010410 0 ustar 00 <?php namespace EasyWPSMTP\Admin\Pages; use EasyWPSMTP\Admin\PageAbstract; use EasyWPSMTP\WP; /** * Class ControlTab is a placeholder for Pro Email Control tab settings. * Displays an upsell. * * @since 2.6.0 */ class ControlTab extends PageAbstract { /** * Slug of a tab. * * @since 2.6.0 * * @var string */ protected $slug = 'control'; /** * Link label of a tab. * * @since 2.6.0 * * @return string */ public function get_label() { return esc_html__( 'Email Controls', 'easy-wp-smtp' ); } /** * Title of a tab. * * @since 2.6.0 * * @return string */ public function get_title() { return $this->get_label(); } /** * Get the list of all available emails that we can manage. * * @see https://github.com/johnbillion/wp_mail Apr 12th 2019. * * @since 2.6.0 * * @return array */ public static function get_controls() { return [ 'comments' => [ 'title' => esc_html__( 'Comments', 'easy-wp-smtp' ), 'emails' => [ 'dis_comments_awaiting_moderation' => [ 'label' => esc_html__( 'Awaiting Moderation', 'easy-wp-smtp' ), 'desc' => esc_html__( 'Comment is awaiting moderation. Sent to the site admin and post author if they can edit comments.', 'easy-wp-smtp' ), ], 'dis_comments_published' => [ 'label' => esc_html__( 'Published', 'easy-wp-smtp' ), 'desc' => esc_html__( 'Comment has been published. Sent to the post author.', 'easy-wp-smtp' ), ], ], ], 'admin_email' => [ 'title' => esc_html__( 'Change of Admin Email', 'easy-wp-smtp' ), 'emails' => [ 'dis_admin_email_attempt' => [ 'label' => esc_html__( 'Site Admin Email Change Attempt', 'easy-wp-smtp' ), 'desc' => esc_html__( 'Change of site admin email address was attempted. Sent to the proposed new email address.', 'easy-wp-smtp' ), ], 'dis_admin_email_changed' => [ 'label' => esc_html__( 'Site Admin Email Changed', 'easy-wp-smtp' ), 'desc' => esc_html__( 'Site admin email address was changed. Sent to the old site admin email address.', 'easy-wp-smtp' ), ], 'dis_admin_email_network_attempt' => [ 'label' => esc_html__( 'Network Admin Email Change Attempt', 'easy-wp-smtp' ), 'desc' => esc_html__( 'Change of network admin email address was attempted. Sent to the proposed new email address.', 'easy-wp-smtp' ), ], 'dis_admin_email_network_changed' => [ 'label' => esc_html__( 'Network Admin Email Changed', 'easy-wp-smtp' ), 'desc' => esc_html__( 'Network admin email address was changed. Sent to the old network admin email address.', 'easy-wp-smtp' ), ], ], ], 'user_details' => [ 'title' => esc_html__( 'Change of User Email or Password', 'easy-wp-smtp' ), 'emails' => [ 'dis_user_details_password_reset_request' => [ 'label' => esc_html__( 'Reset Password Request', 'easy-wp-smtp' ), 'desc' => esc_html__( 'User requested a password reset via "Lost your password?". Sent to the user.', 'easy-wp-smtp' ), ], 'dis_user_details_password_reset' => [ 'label' => esc_html__( 'Password Reset Successfully', 'easy-wp-smtp' ), 'desc' => esc_html__( 'User reset their password from the password reset link. Sent to the site admin.', 'easy-wp-smtp' ), ], 'dis_user_details_password_changed' => [ 'label' => esc_html__( 'Password Changed', 'easy-wp-smtp' ), 'desc' => esc_html__( 'User changed their password. Sent to the user.', 'easy-wp-smtp' ), ], 'dis_user_details_email_change_attempt' => [ 'label' => esc_html__( 'Email Change Attempt', 'easy-wp-smtp' ), 'desc' => esc_html__( 'User attempted to change their email address. Sent to the proposed new email address.', 'easy-wp-smtp' ), ], 'dis_user_details_email_changed' => [ 'label' => esc_html__( 'Email Changed', 'easy-wp-smtp' ), 'desc' => esc_html__( 'User changed their email address. Sent to the user.', 'easy-wp-smtp' ), ], ], ], 'personal_data' => [ 'title' => esc_html__( 'Personal Data Requests', 'easy-wp-smtp' ), 'emails' => [ 'dis_personal_data_user_confirmed' => [ 'label' => esc_html__( 'User Confirmed Export / Erasure Request', 'easy-wp-smtp' ), 'desc' => esc_html__( 'User clicked a confirmation link in personal data export or erasure request email. Sent to the site or network admin.', 'easy-wp-smtp' ), ], 'dis_personal_data_erased_data' => [ 'label' => esc_html__( 'Admin Erased Data', 'easy-wp-smtp' ), 'desc' => esc_html__( 'Site admin clicked "Erase Personal Data" button next to a confirmed data erasure request. Sent to the requester email address.', 'easy-wp-smtp' ), ], 'dis_personal_data_sent_export_link' => [ 'label' => esc_html__( 'Admin Sent Link to Export Data', 'easy-wp-smtp' ), 'desc' => esc_html__( 'Site admin clicked "Email Data" button next to a confirmed data export request. Sent to the requester email address.', 'easy-wp-smtp' ) . '<br>' . '<strong>' . esc_html__( 'Disabling this option will block users from being able to export their personal data, as they will not receive an email with a link.', 'easy-wp-smtp' ) . '</strong>', ], ], ], 'auto_updates' => [ 'title' => esc_html__( 'Automatic Updates', 'easy-wp-smtp' ), 'emails' => [ 'dis_auto_updates_plugin_status' => [ 'label' => esc_html__( 'Plugin Status', 'easy-wp-smtp' ), 'desc' => esc_html__( 'Completion or failure of a background automatic plugin update. Sent to the site or network admin.', 'easy-wp-smtp' ), ], 'dis_auto_updates_theme_status' => [ 'label' => esc_html__( 'Theme Status', 'easy-wp-smtp' ), 'desc' => esc_html__( 'Completion or failure of a background automatic theme update. Sent to the site or network admin.', 'easy-wp-smtp' ), ], 'dis_auto_updates_status' => [ 'label' => esc_html__( 'WP Core Status', 'easy-wp-smtp' ), 'desc' => esc_html__( 'Completion or failure of a background automatic core update. Sent to the site or network admin.', 'easy-wp-smtp' ), ], 'dis_auto_updates_full_log' => [ 'label' => esc_html__( 'Full Log', 'easy-wp-smtp' ), 'desc' => esc_html__( 'Full log of background update results which includes information about WordPress core, plugins, themes, and translations updates. Only sent when you are using a development version of WordPress. Sent to the site or network admin.', 'easy-wp-smtp' ), ], ], ], 'new_user' => [ 'title' => esc_html__( 'New User', 'easy-wp-smtp' ), 'emails' => [ 'dis_new_user_created_to_admin' => [ 'label' => esc_html__( 'Created (Admin)', 'easy-wp-smtp' ), 'desc' => esc_html__( 'A new user was created. Sent to the site admin.', 'easy-wp-smtp' ), ], 'dis_new_user_created_to_user' => [ 'label' => esc_html__( 'Created (User)', 'easy-wp-smtp' ), 'desc' => esc_html__( 'A new user was created. Sent to the new user.', 'easy-wp-smtp' ), ], 'dis_new_user_invited_to_site_network' => [ 'label' => esc_html__( 'Invited To Site', 'easy-wp-smtp' ), 'desc' => esc_html__( 'A new user was invited to a site from Users -> Add New -> Add New User. Sent to the invited user.', 'easy-wp-smtp' ), ], 'dis_new_user_created_network' => [ 'label' => esc_html__( 'Created On Site', 'easy-wp-smtp' ), 'desc' => esc_html__( 'A new user account was created. Sent to Network Admin.', 'easy-wp-smtp' ), ], 'dis_new_user_added_activated_network' => [ 'label' => esc_html__( 'Added / Activated on Site', 'easy-wp-smtp' ), 'desc' => esc_html__( 'A user has been added, or their account activation has been successful. Sent to the user, that has been added/activated.', 'easy-wp-smtp' ), ], ], ], 'network_new_site' => [ 'title' => esc_html__( 'New Site', 'easy-wp-smtp' ), 'emails' => [ 'dis_new_site_user_registered_site_network' => [ 'label' => esc_html__( 'User Created Site', 'easy-wp-smtp' ), 'desc' => esc_html__( 'User registered for a new site. Sent to the site admin.', 'easy-wp-smtp' ), ], 'dis_new_site_user_added_activated_site_in_network_to_admin' => [ 'label' => esc_html__( 'Network Admin: User Activated / Added Site', 'easy-wp-smtp' ), 'desc' => esc_html__( 'User activated their new site, or site was added from Network Admin -> Sites -> Add New. Sent to Network Admin.', 'easy-wp-smtp' ), ], 'dis_new_site_user_added_activated_site_in_network_to_site' => [ 'label' => esc_html__( 'Site Admin: Activated / Added Site', 'easy-wp-smtp' ), 'desc' => esc_html__( 'User activated their new site, or site was added from Network Admin -> Sites -> Add New. Sent to Site Admin.', 'easy-wp-smtp' ), ], ], ], ]; } /** * Output HTML of the email controls settings preview. * * @since 2.6.0 */ public function display() { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh $top_upgrade_button_url = add_query_arg( [ 'discount' => 'LITEUPGRADE' ], easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'Email Controls', 'content' => 'Upgrade to Easy WP SMTP Pro Button Top', ] ) ); $upgrade_link_url = add_query_arg( [ 'discount' => 'LITEUPGRADE' ], easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'Email Controls', 'content' => 'Upgrade to Easy WP SMTP Pro Link', ] ) ); $bottom_upgrade_button_url = add_query_arg( [ 'discount' => 'LITEUPGRADE' ], easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'Email Controls', 'content' => 'Upgrade to Easy WP SMTP Pro Button Bottom', ] ) ); ?> <div class="easy-wp-smtp-email-controls-product-education"> <div class="easy-wp-smtp-meta-box"> <div class="easy-wp-smtp-meta-box__header"> <div class="easy-wp-smtp-meta-box__heading"> <?php esc_html_e( 'Email Controls', 'easy-wp-smtp' ); ?> </div> <a href="<?php echo esc_url( $top_upgrade_button_url ); ?>" target="_blank" rel="noopener noreferrer" class="easy-wp-smtp-btn easy-wp-smtp-btn--sm easy-wp-smtp-btn--green"> <?php esc_html_e( 'Upgrade to Pro', 'easy-wp-smtp' ); ?> </a> </div> <div class="easy-wp-smtp-meta-box__content"> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-row__desc"> <?php echo wp_kses( sprintf( /* translators: %s - EasyWPSMTP.com page URL. */ __( 'With email controls, you can manage the automatic notifications sent by your WordPress site. A simple switch lets you reduce inbox clutter and focus on the alerts that truly matter. Easily turn off emails related to comments, account changes, updates, registrations, and data requests. <a href="%s" target="_blank" rel="noopener noreferrer">Upgrade to Easy WP SMTP Pro</a>.', 'easy-wp-smtp' ), esc_url( $upgrade_link_url ) ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], ] ); ?> </div> </div> </div> </div> <?php foreach ( static::get_controls() as $section_id => $section ) : if ( empty( $section['emails'] ) ) { continue; } if ( $this->is_it_for_multisite( sanitize_key( $section_id ) ) && ! WP::use_global_plugin_settings() ) { continue; } ?> <div class="easy-wp-smtp-meta-box"> <div class="easy-wp-smtp-meta-box__header"> <div class="easy-wp-smtp-meta-box__heading"> <?php echo esc_html( $section['title'] ); ?> </div> </div> <div class="easy-wp-smtp-meta-box__content"> <?php foreach ( $section['emails'] as $email_id => $email ) : $email_id = sanitize_key( $email_id ); if ( empty( $email_id ) || empty( $email['label'] ) ) { continue; } if ( $this->is_it_for_multisite( sanitize_key( $email_id ) ) && ! WP::use_global_plugin_settings() ) { continue; } ?> <div class="easy-wp-smtp-row easy-wp-smtp-setting-row easy-wp-smtp-row--inactive"> <div class="easy-wp-smtp-setting-row__label"> <label> <?php echo esc_html( $email['label'] ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <label class="easy-wp-smtp-toggle"> <input type="checkbox" checked/> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'ON', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'OFF', 'easy-wp-smtp' ); ?></span> </label> <?php if ( ! empty( $email['desc'] ) ) : ?> <p class="desc"> <?php echo $email['desc']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> </p> <?php endif; ?> </div> </div> <?php endforeach; ?> </div> </div> <?php endforeach; ?> <a href="<?php echo esc_url( $bottom_upgrade_button_url ); ?>" target="_blank" rel="noopener noreferrer" class="easy-wp-smtp-btn easy-wp-smtp-btn--lg easy-wp-smtp-btn--green"> <?php esc_html_e( 'Upgrade to Easy WP SMTP Pro', 'easy-wp-smtp' ); ?> </a> </div> <?php } /** * Whether this key dedicated to MultiSite environment. * * @since 2.6.0 * * @param string $key Email unique key. * * @return bool */ protected function is_it_for_multisite( $key ) { return strpos( $key, 'network' ) !== false; } /** * Not used as we display an upsell. * * @since 2.6.0 * * @param array $data Post data specific for the plugin. */ public function process_post( $data ) {} } Pages/EmailReports.php 0000777 00000001312 15252174534 0010736 0 ustar 00 <?php namespace EasyWPSMTP\Admin\Pages; use EasyWPSMTP\Admin\ParentPageAbstract; /** * Class EmailReports. * * @since 2.1.0 */ class EmailReports extends ParentPageAbstract { /** * Page default tab slug. * * @since 2.1.0 * * @var string */ protected $default_tab = 'reports'; /** * Slug of a page. * * @since 2.1.0 * * @var string */ protected $slug = 'reports'; /** * Link label of a page. * * @since 2.1.0 * * @return string */ public function get_label() { return esc_html__( 'Email Reports', 'easy-wp-smtp' ); } /** * Title of a page. * * @since 2.1.0 * * @return string */ public function get_title() { return $this->get_label(); } } Pages/LogsTab.php 0000777 00000014001 15252174534 0007662 0 ustar 00 <?php namespace EasyWPSMTP\Admin\Pages; use EasyWPSMTP\Admin\PageAbstract; use EasyWPSMTP\Admin\ParentPageAbstract; /** * Class LogsTab is a placeholder for Lite users and redirects them to Email Log page. * * @since 2.1.0 */ class LogsTab extends PageAbstract { /** * Part of the slug of a tab. * * @since 2.1.0 * * @var string */ protected $slug = 'logs'; /** * Constructor. * * @since 2.1.0 * * @param ParentPageAbstract $parent_page Tab parent page. */ public function __construct( $parent_page = null ) { parent::__construct( $parent_page ); $current_tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( easy_wp_smtp()->get_admin()->is_admin_page() && $current_tab === 'logs' ) { $this->hooks(); } } /** * Link label of a tab. * * @since 2.1.0 * * @return string */ public function get_label() { return esc_html__( 'Email Log', 'easy-wp-smtp' ); } /** * Title of a tab. * * @since 2.1.0 * * @return string */ public function get_title() { return $this->get_label(); } /** * Register hooks. * * @since 2.1.0 */ public function hooks() { add_action( 'easy_wp_smtp_admin_area_enqueue_assets', [ $this, 'enqueue_assets' ] ); } /** * Enqueue required JS and CSS. * * @since 2.1.0 */ public function enqueue_assets() { wp_enqueue_style( 'easy-wp-smtp-admin-lity', easy_wp_smtp()->assets_url . '/css/vendor/lity.min.css', [], '2.4.1' ); wp_enqueue_script( 'easy-wp-smtp-admin-lity', easy_wp_smtp()->assets_url . '/js/vendor/lity.min.js', [], '2.4.1', false ); } /** * Display the upsell content for the Email Log feature. * * @since 2.1.0 */ public function display() { $button_upgrade_link = add_query_arg( [ 'discount' => 'LITEUPGRADE' ], easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'logs', 'content' => 'Upgrade to Pro Button', ] ) ); $link_upgrade_link = add_query_arg( [ 'discount' => 'LITEUPGRADE' ], easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'logs', 'content' => 'upgrade-to-easy-wp-smtp-pro-text-link', ] ) ); $assets_url = easy_wp_smtp()->assets_url . '/images/education/logs/'; $screenshots = [ [ 'url' => $assets_url . 'screenshot-01.png', 'url_thumbnail' => $assets_url . 'thumbnail-01.png', 'title' => __( 'Email Logs', 'easy-wp-smtp' ), ], [ 'url' => $assets_url . 'screenshot-02.png', 'url_thumbnail' => $assets_url . 'thumbnail-02.png', 'title' => __( 'Detailed Email Log', 'easy-wp-smtp' ), ], ]; ?> <div class="easy-wp-smtp-meta-box"> <div class="easy-wp-smtp-meta-box__header"> <div class="easy-wp-smtp-meta-box__heading"> <?php echo esc_html( $this->get_title() ); ?> </div> <a href="<?php echo esc_url( $button_upgrade_link ); ?>" target="_blank" rel="noopener noreferrer" class="easy-wp-smtp-btn easy-wp-smtp-btn--sm easy-wp-smtp-btn--green"> <?php esc_html_e( 'Upgrade to Pro', 'easy-wp-smtp' ); ?> </a> </div> <div class="easy-wp-smtp-meta-box__content"> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-row__desc"> <?php echo wp_kses( sprintf( /* translators: %s - EasyWPSMTP.com page URL. */ __( 'Email Logging saves information about all the emails sent from your WordPress site. Search and filter the email log to find specific emails and check their delivery statuses. When you enable email logging, you’ll also be able to resend emails, save attachments, and export logs as a CSV, Excel, or EML file. <a href="%s" target="_blank" rel="noopener noreferrer">Upgrade to Easy WP SMTP Pro</a> to start using email logs today.', 'easy-wp-smtp' ), esc_url( $link_upgrade_link ) ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], ] ); ?> </div> </div> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-product-education-screenshots easy-wp-smtp-product-education-screenshots--two"> <?php foreach ( $screenshots as $screenshot ) : ?> <div> <a href="<?php echo esc_url( $screenshot['url'] ); ?>" data-lity data-lity-desc="<?php echo esc_attr( $screenshot['title'] ); ?>"> <img src="<?php echo esc_url( $screenshot['url_thumbnail'] ); ?>" alt="<?php esc_attr( $screenshot['title'] ); ?>"> </a> <span><?php echo esc_html( $screenshot['title'] ); ?></span> </div> <?php endforeach; ?> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-row--has-bg-color easy-wp-smtp-product-education-cta-row"> <div class="easy-wp-smtp-row__heading easy-wp-smtp-settings-heading"> <?php esc_html_e( 'Unlock these awesome logging features:', 'easy-wp-smtp' ); ?> </div> <div class="easy-wp-smtp-product-education-list"> <ul> <li><?php esc_html_e( 'Save detailed email headers', 'easy-wp-smtp' ); ?></li> <li><?php esc_html_e( 'View email delivery status (sent or failed)', 'easy-wp-smtp' ); ?></li> </ul> <ul> <li><?php esc_html_e( 'Resend emails and attachments', 'easy-wp-smtp' ); ?></li> <li><?php esc_html_e( 'Track email opens and clicks', 'easy-wp-smtp' ); ?></li> </ul> <ul> <li><?php esc_html_e( 'Print or save email logs as PDFs', 'easy-wp-smtp' ); ?></li> <li><?php esc_html_e( 'Export logs to CSV, XLSX, or EML', 'easy-wp-smtp' ); ?></li> </ul> </div> <a href="<?php echo esc_url( $button_upgrade_link ); ?>" target="_blank" rel="noopener noreferrer" class="easy-wp-smtp-btn easy-wp-smtp-btn--lg easy-wp-smtp-btn--green"> <?php esc_html_e( 'Upgrade to Easy WP SMTP Pro', 'easy-wp-smtp' ); ?> </a> </div> </div> </div> <?php } /** * Not used as we are simply redirecting users. * * @since 2.1.0 * * @param array $data Post data specific for the plugin. */ public function process_post( $data ) { } } Pages/ActionSchedulerTab.php 0000777 00000007507 15252174534 0012047 0 ustar 00 <?php namespace EasyWPSMTP\Admin\Pages; use EasyWPSMTP\Admin\PageAbstract; /** * Class ActionScheduler. * * @since 2.1.0 */ class ActionSchedulerTab extends PageAbstract { /** * Part of the slug of a tab. * * @since 2.1.0 * * @var string */ protected $slug = 'action-scheduler'; /** * Tab priority. * * @since 2.1.0 * * @var int */ protected $priority = 30; /** * Link label of a tab. * * @since 2.1.0 * * @return string */ public function get_label() { return esc_html__( 'Scheduled Actions', 'easy-wp-smtp' ); } /** * Title of a tab. * * @since 2.1.0 * * @return string */ public function get_title() { return $this->get_label(); } /** * URL to a tab. * * @since 2.1.0 * * @return string */ public function get_link() { return add_query_arg( [ 's' => 'easy_wp_smtp' ], parent::get_link() ); } /** * Register hooks. * * @since 2.1.0 */ public function hooks() { add_action( 'current_screen', [ $this, 'init' ], 20 ); } /** * Init. * * @since 2.1.0 */ public function init() { if ( $this->is_applicable() ) { \ActionScheduler_AdminView::instance()->process_admin_ui(); } } /** * Display scheduled actions table. * * @since 2.1.0 */ public function display() { if ( ! $this->is_applicable() ) { return; } ?> <div class="easy-wp-smtp-meta-box"> <div class="easy-wp-smtp-meta-box__header"> <div class="easy-wp-smtp-meta-box__heading"> <?php esc_html_e( 'Scheduled Actions', 'easy-wp-smtp' ); ?> </div> </div> <div class="easy-wp-smtp-meta-box__content"> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-row__desc"> <p> <?php echo sprintf( wp_kses( /* translators: %s - Action Scheduler website URL. */ __( 'Easy WP SMTP uses the <a href="%s" target="_blank" rel="noopener noreferrer">Action Scheduler</a> library, which lets it queue and process large tasks in the background without slowing down your site for visitors. Here you can see the list of all Easy WP SMTP Action Scheduler tasks and their statuses. This table can help with debugging certain issues.', 'easy-wp-smtp' ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], ] ), 'https://actionscheduler.org/' ); ?> </p> <p> <?php echo esc_html__( 'The Action Scheduler library is also used by other plugins, such as WPForms and WooCommerce. You might see tasks below that are not related to our plugin.', 'easy-wp-smtp' ); ?> </p> </div> </div> </div> </div> <?php if ( isset( $_GET['s'] ) ) : // phpcs:ignore WordPress.Security.NonceVerification.Recommended ?> <div id="easy-wp-smtp-reset-filter"> <?php echo wp_kses( sprintf( /* translators: %s - search term. */ __( 'Search results for <strong>%s</strong>', 'easy-wp-smtp' ), sanitize_text_field( wp_unslash( $_GET['s'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended ), [ 'strong' => [] ] ); ?> <a href="<?php echo esc_url( remove_query_arg( 's' ) ); ?>"> <i class="reset dashicons dashicons-dismiss"></i> </a> </div> <?php endif; ?> <div class="easy-wp-smtp-wp-list-table"> <?php \ActionScheduler_AdminView::instance()->render_admin_ui(); ?> </div> <!-- Remove `.wp-header-end` element from DOM to prevent wrong notices position. --> <script> (function() { const headerEnd = document.querySelector( '.wrap > hr.wp-header-end' ); if ( headerEnd !== null ) headerEnd.remove(); })(); </script> <?php } /** * Check if ActionScheduler_AdminView class exists. * * @since 2.1.0 * * @return bool */ private function is_applicable() { return class_exists( 'ActionScheduler_AdminView' ); } } Pages/SettingsTab.php 0000777 00000037747 15252174534 0010604 0 ustar 00 <?php namespace EasyWPSMTP\Admin\Pages; use EasyWPSMTP\Admin\ConnectionSettings; use EasyWPSMTP\Admin\PageAbstract; use EasyWPSMTP\Admin\SetupWizard; use EasyWPSMTP\Options; use EasyWPSMTP\WP; /** * Class SettingsTab is part of Area, displays general settings of the plugin. * * @since 2.0.0 */ class SettingsTab extends PageAbstract { /** * Settings constructor. * * @since 2.1.0 */ public function __construct() { parent::__construct(); $this->hooks(); } /** * Slug of a tab. * * @since 2.0.0 * * @var string */ protected $slug = 'settings'; /** * Link label of a tab. * * @since 2.0.0 * * @return string */ public function get_label() { return esc_html__( 'Settings', 'easy-wp-smtp' ); } /** * Title of a tab. * * @since 2.0.0 * * @return string */ public function get_title() { return $this->get_label(); } /** * Register hooks. * * @since 2.1.0 */ public function hooks() { add_action( 'easy_wp_smtp_admin_pages_settings_license_key', [ __CLASS__, 'display_license_key_field_content' ] ); add_action( 'easy_wp_smtp_admin_area_enqueue_assets', [ $this, 'enqueue_assets' ] ); } /** * Enqueue required JS and CSS. * * @since 2.1.0 */ public function enqueue_assets() { if ( ! easy_wp_smtp()->get_admin()->is_admin_page( 'general' ) ) { return; } if ( $this->is_display_pro_banner() ) { wp_enqueue_style( 'easy-wp-smtp-admin-lity', easy_wp_smtp()->assets_url . '/css/vendor/lity.min.css', [], '2.4.1' ); wp_enqueue_script( 'easy-wp-smtp-admin-lity', easy_wp_smtp()->assets_url . '/js/vendor/lity.min.js', [], '2.4.1', false ); } } /** * Settings tab content. * * @since 2.0.0 */ public function display() { ?> <form method="POST" action="" autocomplete="off" class="easy-wp-smtp-connection-settings-form"> <?php $this->wp_nonce_field(); ?> <?php ob_start(); ?> <div class="easy-wp-smtp-meta-box"> <div class="easy-wp-smtp-meta-box__header"> <div class="easy-wp-smtp-meta-box__heading"> <?php esc_html_e( 'License', 'easy-wp-smtp' ); ?> </div> </div> <div class="easy-wp-smtp-meta-box__content"> <?php do_action( 'easy_wp_smtp_admin_pages_settings_license_key', Options::init() ); ?> </div> </div> <?php $connection = easy_wp_smtp()->get_connections_manager()->get_primary_connection(); $connection_settings = new ConnectionSettings( $connection ); // Display connection settings. $connection_settings->display(); ?> <?php $settings_content = apply_filters( 'easy_wp_smtp_admin_settings_tab_display', ob_get_clean() ); echo $settings_content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> <?php $this->display_backup_connection_education(); ?> <?php $this->display_save_btn(); ?> </form> <?php if ( $this->is_display_pro_banner() ) { $this->display_pro_banner(); } } /** * License key text for a Lite version of the plugin. * * @since 2.1.0 * * @param Options $options */ public static function display_license_key_field_content( $options ) { ?> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-row__desc"> <?php esc_html_e( 'You\'re using Easy WP SMTP Lite - no license key required. Enjoy!', 'easy-wp-smtp' ); ?> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-row--has-divider"> <div class="easy-wp-smtp-license-upgrade-notice"> <p> <b> <?php printf( wp_kses( /* translators: %s - EasyWPSMTP.com upgrade URL. */ __( 'Unlock more features by <strong><a href="%s" target="_blank" rel="noopener noreferrer">upgrading to PRO</a></strong>.', 'easy-wp-smtp' ), array( 'a' => array( 'href' => array(), 'class' => array(), 'target' => array(), 'rel' => array(), ), 'strong' => array(), ) ), esc_url( easy_wp_smtp()->get_upgrade_link( 'general-license-key' ) ) ); ?> </b> </p> <p> <?php printf( wp_kses( /* Translators: %s - discount value 50% */ __( 'As thanks for being an Easy WP SMTP Lite user, we’re offering you <span>%s off</span>, applied automatically at checkout.', 'easy-wp-smtp' ), array( 'span' => array(), ) ), '50%' ); ?> </p> </div> </div> <!-- License Key --> <div class="easy-wp-smtp-row easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-license_key"> <?php esc_html_e( 'License Key', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <div class="easy-wp-smtp-input-btn-row"> <input type="password" id="easy-wp-smtp-setting-upgrade-license-key" class="easy-wp-smtp-not-form-input" placeholder="<?php esc_attr_e( 'Paste license key here', 'easy-wp-smtp' ); ?>" value="" /> <button type="button" class="easy-wp-smtp-btn easy-wp-smtp-btn--primary" id="easy-wp-smtp-setting-upgrade-license-button"> <?php esc_attr_e( 'Connect', 'easy-wp-smtp' ); ?> </button> </div> <p class="desc"> <?php esc_html_e( 'Already purchased? Simply enter your license key above to connect with Easy WP SMTP Pro!', 'easy-wp-smtp' ); ?> </p> </div> </div> <?php } /** * Whether to display Easy WP SMTP Pro upgrade banner. * * @since 2.1.0 * * @return bool */ private function is_display_pro_banner() { // Display only to site admins. Only site admins can install plugins. if ( ! is_super_admin() ) { return false; } // Do not display if Easy WP SMTP Pro already installed. if ( easy_wp_smtp()->is_pro() ) { return false; } $is_dismissed = get_user_meta( get_current_user_id(), 'easy_wp_smtp_pro_banner_dismissed', true ); // Do not display if user dismissed. if ( (bool) $is_dismissed === true ) { return false; } return true; } /** * Display Easy WP SMTP Pro upgrade banner. * * @since 2.1.0 */ protected function display_pro_banner() { $assets_url = easy_wp_smtp()->assets_url . '/images/education/'; $screenshots = [ [ 'url' => $assets_url . 'logs/screenshot-01.png', 'url_thumbnail' => $assets_url . 'logs/thumbnail-01.png', 'title' => __( 'Email Logs', 'easy-wp-smtp' ), ], [ 'url' => $assets_url . 'reports/screenshot-01.png', 'url_thumbnail' => $assets_url . 'reports/thumbnail-01.png', 'title' => __( 'Email Reports', 'easy-wp-smtp' ), ], [ 'url' => $assets_url . 'reports/screenshot-03.png', 'url_thumbnail' => $assets_url . 'reports/thumbnail-03.png', 'title' => __( 'Weekly Email Report', 'easy-wp-smtp' ), ], ]; $upgrade_link = easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'pro-banner', 'content' => 'upgrade-today-link', ] ); $button_upgrade_link = easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'pro-banner', 'content' => 'upgrade-to-easy-wp-smtp-pro-button-link', ] ); ?> <div class="easy-wp-smtp-meta-box easy-wp-smtp-pro-banner"> <div class="easy-wp-smtp-meta-box__content"> <a href="#" title="<?php esc_attr_e( 'Dismiss', 'easy-wp-smtp' ); ?>" class="easy-wp-smtp-pro-banner__dismiss js-easy-wp-smtp-pro-banner-dismiss"> <svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"> <path d="m8 0.25c-4.2812 0-7.75 3.4688-7.75 7.75 0 4.2812 3.4688 7.75 7.75 7.75 4.2812 0 7.75-3.4688 7.75-7.75 0-4.2812-3.4688-7.75-7.75-7.75zm0 14c-3.4688 0-6.25-2.7812-6.25-6.25 0-3.4375 2.7812-6.25 6.25-6.25 3.4375 0 6.25 2.8125 6.25 6.25 0 3.4688-2.8125 6.25-6.25 6.25zm3.1562-8.1875c0.1563-0.125 0.1563-0.375 0-0.53125l-0.6874-0.6875c-0.1563-0.15625-0.4063-0.15625-0.5313 0l-1.9375 1.9375-1.9688-1.9375c-0.125-0.15625-0.375-0.15625-0.53125 0l-0.6875 0.6875c-0.15625 0.15625-0.15625 0.40625 0 0.53125l1.9375 1.9375-1.9375 1.9688c-0.15625 0.12505-0.15625 0.37505 0 0.53125l0.6875 0.6875c0.15625 0.1563 0.40625 0.1563 0.53125 0l1.9688-1.9375 1.9375 1.9375c0.125 0.1563 0.375 0.1563 0.5313 0l0.6874-0.6875c0.1563-0.1562 0.1563-0.4062 0-0.53125l-1.9374-1.9688 1.9374-1.9375z" fill="currentColor"/> </svg> </a> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-row__heading"> <?php esc_html_e( 'Get Easy WP SMTP Pro and Gain Access to more Powerful Features', 'easy-wp-smtp' ); ?> </div> <div class="easy-wp-smtp-row__desc"> <?php printf( wp_kses( /* translators: %s - sendlayer.com URL. */ __( 'Learn the full potential of Easy WP SMTP with our Pro version. <a href="%s" target="_blank" rel="noopener noreferrer">Upgrade today</a> and start using advanced features to track and monitor email activity.', 'easy-wp-smtp' ), [ 'a' => [ 'href' => [], 'target' => [], 'rel' => [], ], ] ), $upgrade_link ); ?> </div> </div> <div class="easy-wp-smtp-row easy-wp-smtp-row--has-divider"> <div class="easy-wp-smtp-product-education-screenshots easy-wp-smtp-product-education-screenshots--three"> <?php foreach ( $screenshots as $screenshot ) : ?> <div> <a href="<?php echo esc_url( $screenshot['url'] ); ?>" data-lity data-lity-desc="<?php echo esc_attr( $screenshot['title'] ); ?>"> <img src="<?php echo esc_url( $screenshot['url_thumbnail'] ); ?>" alt="<?php esc_attr( $screenshot['title'] ); ?>"> </a> <span><?php echo esc_html( $screenshot['title'] ); ?></span> </div> <?php endforeach; ?> </div> </div> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-row__heading easy-wp-smtp-settings-heading"> <?php esc_html_e( 'Pro Features:', 'easy-wp-smtp' ); ?> </div> <div class="easy-wp-smtp-product-education-list-v2"> <ul> <li> <strong><?php esc_html_e( 'Email Logs', 'easy-wp-smtp' ); ?></strong> <ul> <li><?php esc_html_e( 'Open and click tracking', 'easy-wp-smtp' ); ?></li> <li><?php esc_html_e( 'Status (was the email delivered, sent, pending, or failed)', 'easy-wp-smtp' ); ?></li> <li><?php esc_html_e( 'Email log export (.eml, .csv, .xlsx) and bulk exporter', 'easy-wp-smtp' ); ?></li> <li><?php esc_html_e( 'Source (which plugin/theme sent the email and it\'s path location)', 'easy-wp-smtp' ); ?></li> </ul> </li> </ul> <ul> <li><?php esc_html_e( 'Backup Connection - send emails through a backup if the primary connection fails', 'easy-wp-smtp' ); ?></li> <li><?php esc_html_e( 'Smart Routing - set specific conditions for how your emails are sent', 'easy-wp-smtp' ); ?></li> <li><?php esc_html_e( 'Pro mailers: Amazon SES and Microsoft 365 / Outlook', 'easy-wp-smtp' ); ?></li> <li><?php esc_html_e( 'Advanced Email Reports', 'easy-wp-smtp' ); ?></li> <li><?php esc_html_e( 'Intuitive Dashboard Widget with email stats', 'easy-wp-smtp' ); ?></li> <li><?php esc_html_e( 'Weekly Email Summaries delivered to your inbox', 'easy-wp-smtp' ); ?></li> </ul> </div> </div> <div class="easy-wp-smtp-row"> <a href="<?php echo esc_url( $button_upgrade_link ); ?>" target="_blank" rel="noopener noreferrer" class="easy-wp-smtp-btn easy-wp-smtp-btn--lg easy-wp-smtp-btn--green"> <?php esc_html_e( 'Upgrade to Easy WP SMTP Pro', 'easy-wp-smtp' ); ?> </a> </div> </div> </div> <?php } /** * Display backup connection education section. * * @since 2.6.0 */ private function display_backup_connection_education() { if ( easy_wp_smtp()->is_pro() ) { return; } $upgrade_button_url = add_query_arg( [ 'discount' => 'LITEUPGRADE' ], easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'Backup Connections', 'content' => 'Upgrade to Pro Button', ] ) ); $upgrade_link_url = add_query_arg( [ 'discount' => 'LITEUPGRADE' ], easy_wp_smtp()->get_upgrade_link( [ 'medium' => 'Backup Connections', 'content' => 'Upgrade to Pro Link', ] ) ); ?> <div class="easy-wp-smtp-meta-box"> <div class="easy-wp-smtp-meta-box__header"> <div class="easy-wp-smtp-meta-box__heading"> <?php esc_html_e( 'Backup Connection', 'easy-wp-smtp' ); ?> </div> <a href="<?php echo esc_url( $upgrade_button_url ); ?>" target="_blank" rel="noopener noreferrer" class="easy-wp-smtp-btn easy-wp-smtp-btn--sm easy-wp-smtp-btn--green"> <?php esc_html_e( 'Upgrade to Pro', 'easy-wp-smtp' ); ?> </a> </div> <div class="easy-wp-smtp-meta-box__content"> <!-- Backup Connection Section Title --> <div class="easy-wp-smtp-row easy-wp-smtp-row--has-divider"> <div class="easy-wp-smtp-row__desc"> <p> <?php echo wp_kses( sprintf( /* translators: %s - EasyWPSMTP.com Upgrade page URL. */ __( 'Avoid the risk of losing emails by adding an additional connection and setting it as your Backup Connection. Should the Primary Connection fail to send an email, the Backup Connection will take over. <a href="%s" target="_blank" rel="noopener noreferrer">Upgrade to Easy WP SMTP Pro</a>.', 'easy-wp-smtp' ), esc_url( $upgrade_link_url ) ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], ] ); ?> </p> </div> </div> <!-- Backup Connection Selector --> <div id="easy-wp-smtp-setting-row-backup_connection" class="easy-wp-smtp-row easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label> <?php esc_html_e( 'Backup Connection', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <div class="easy-wp-smtp-radio-group easy-wp-smtp-connection-selector"> <label class="easy-wp-smtp-radio" for="easy-wp-smtp-setting-row-backup_connection_none"> <input type="radio" id="easy-wp-smtp-setting-row-backup_connection_none" checked/> <span class="easy-wp-smtp-radio__checkmark"></span> <span class="easy-wp-smtp-radio__label"><?php esc_html_e( 'None', 'easy-wp-smtp' ); ?></span> </label> </div> <p class="desc"> <?php echo wp_kses( sprintf( /* translators: %s - Additional connections settings page url. */ __( 'Once you add an <a href="%s">additional connection</a>, you can select it here.', 'easy-wp-smtp' ), add_query_arg( [ 'tab' => 'connections', ], easy_wp_smtp()->get_admin()->get_admin_page_url() ) ), [ 'a' => [ 'href' => [], ], ] ); ?> </p> </div> </div> </div> </div> <?php } /** * Process tab form submission ($_POST). * * @since 2.0.0 * * @param array $data Post data specific for the plugin. */ public function process_post( $data ) { $this->check_admin_referer(); $connection = easy_wp_smtp()->get_connections_manager()->get_primary_connection(); $connection_settings = new ConnectionSettings( $connection ); $old_data = $connection->get_options()->get_all(); $data = $connection_settings->process( $data, $old_data ); /** * Filters mail settings before save. * * @since 2.0.0 * * @param array $data Settings data. */ $data = apply_filters( 'easy_wp_smtp_settings_tab_process_post', $data ); // All the sanitization is done in Options class. Options::init()->set( $data, false, false ); $connection_settings->post_process( $data, $old_data ); if ( $connection_settings->get_scroll_to() !== false ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated wp_safe_redirect( sanitize_text_field( wp_unslash( $_POST['_wp_http_referer'] ) ) . $connection_settings->get_scroll_to() ); exit; } WP::add_admin_notice( esc_html__( 'Settings were successfully saved.', 'easy-wp-smtp' ), WP::ADMIN_NOTICE_SUCCESS ); } } Pages/Logs.php 0000777 00000001566 15252174534 0007247 0 ustar 00 <?php namespace EasyWPSMTP\Admin\Pages; use EasyWPSMTP\Admin\Area; use EasyWPSMTP\Admin\PageAbstract; use EasyWPSMTP\WP; /** * Class Logs */ class Logs extends PageAbstract { /** * Slug of a page. * * @since 2.1.0 * * @var string */ protected $slug = 'logs'; /** * Get the page/tab link. * * @since 2.1.0 * * @return string */ public function get_link() { return add_query_arg( 'tab', $this->slug, WP::admin_url( 'admin.php?page=' . Area::SLUG ) ); } /** * Link label of a tab. * * @since 2.1.0 * * @return string */ public function get_label() { return esc_html__( 'Email Log', 'easy-wp-smtp' ); } /** * Title of a tab. * * @since 2.1.0 * * @return string */ public function get_title() { return $this->get_label(); } /** * Tab content. * * @since 2.1.0 */ public function display() {} } PageInterface.php 0000777 00000001047 15252174534 0007773 0 ustar 00 <?php namespace EasyWPSMTP\Admin; /** * Class PageInterface defines what should be in each page class. * * @since 2.0.0 */ interface PageInterface { /** * URL to a tab. * * @since 2.0.0 * * @return string */ public function get_link(); /** * Title of a tab. * * @since 2.0.0 * * @return string */ public function get_title(); /** * Link label of a tab. * * @since 2.0.0 * * @return string */ public function get_label(); /** * Tab content. * * @since 2.0.0 */ public function display(); } UserFeedback.php 0000777 00000015237 15252174534 0007627 0 ustar 00 <?php namespace EasyWPSMTP\Admin; use EasyWPSMTP\Options; /** * Asking users for their experience with this plugin. * * @since 1.5.3 */ class UserFeedback { /** * The wp option for notice dismissal data. * * @since 1.5.3 */ const OPTION_NAME = 'easy_wp_smtp_user_feedback_notice'; /** * How many days after activation it should display the user feedback notice. * * @since 1.5.3 */ const DELAY_NOTICE = 14; /** * Initialize user feedback notice functionality. * * @since 1.5.3 */ public function init() { add_action( 'admin_init', [ $this, 'admin_notices' ] ); add_action( 'wp_ajax_easy_wp_smtp_feedback_notice_dismiss', [ $this, 'feedback_notice_dismiss' ] ); } /** * Display notices only in Network Admin if in Multisite. * Otherwise, display in Admin Dashboard. * * @since 2.2.0 * * @return void */ public function admin_notices() { if ( is_multisite() ) { add_action( 'network_admin_notices', [ $this, 'maybe_display' ] ); } else { add_action( 'admin_notices', [ $this, 'maybe_display' ] ); } } /** * Maybe display the user feedback notice. * * @since 1.5.3 */ public function maybe_display() { // Only admin users should see the feedback notice. if ( ! is_super_admin() ) { return; } $options = get_option( self::OPTION_NAME ); // Set default options. if ( empty( $options ) ) { $options = [ 'time' => time(), 'dismissed' => false, ]; update_option( self::OPTION_NAME, $options ); } // Check if the feedback notice was not dismissed already. if ( isset( $options['dismissed'] ) && ! $options['dismissed'] ) { $this->display(); } } /** * Display the user feedback notice. * * @since 1.5.3 */ private function display() { // Skip if SMTP settings are not configured. if ( ! $this->is_smtp_configured() ) { return; } // Fetch when plugin was initially activated. $activated = get_option( 'easy_wp_smtp_activated_time' ); // Skip if the plugin is active for less than a defined number of days. if ( empty( $activated ) || ( $activated + ( DAY_IN_SECONDS * self::DELAY_NOTICE ) ) > time() ) { return; } ?> <div class="notice notice-info is-dismissible easy-wp-smtp-notice easy-wp-smtp-review-notice"> <div class="easy-wp-smtp-review-step easy-wp-smtp-review-step-1"> <p><?php esc_html_e( 'Are you enjoying Easy WP SMTP?', 'easy-wp-smtp' ); ?></p> <p> <a href="#" class="easy-wp-smtp-review-switch-step" data-step="3"><?php esc_html_e( 'Yes', 'easy-wp-smtp' ); ?></a><br/> <a href="#" class="easy-wp-smtp-review-switch-step" data-step="2"><?php esc_html_e( 'Not Really', 'easy-wp-smtp' ); ?></a> </p> </div> <div class="easy-wp-smtp-review-step easy-wp-smtp-review-step-2" style="display: none"> <p><?php esc_html_e( 'We\'re sorry to hear you aren\'t enjoying Easy WP SMTP. We would love a chance to improve. Could you take a minute and let us know what we can do better?', 'easy-wp-smtp' ); ?></p> <p> <?php printf( '<a href="https://easywpsmtp.com/plugin-feedback/" class="easy-wp-smtp-dismiss-review-notice easy-wp-smtp-review-out" target="_blank" rel="noopener noreferrer">%s</a>', esc_html__( 'Give Feedback', 'easy-wp-smtp' ) ); ?> <br> <a href="#" class="easy-wp-smtp-dismiss-review-notice" target="_blank" rel="noopener noreferrer"> <?php esc_html_e( 'No thanks', 'easy-wp-smtp' ); ?> </a> </p> </div> <div class="easy-wp-smtp-review-step easy-wp-smtp-review-step-3" style="display: none"> <p><?php esc_html_e( 'That’s awesome! Could you please do me a BIG favor and give it a 5-star rating on WordPress to help us spread the word and boost our motivation?', 'easy-wp-smtp' ); ?></p> <p><strong><?php esc_html_e( '~ Easy WP SMTP team', 'easy-wp-smtp' ); ?></strong></p> <p> <a href="https://wordpress.org/support/plugin/easy-wp-smtp/reviews/#new-post" class="easy-wp-smtp-dismiss-review-notice easy-wp-smtp-review-out" target="_blank" rel="noopener noreferrer"> <?php esc_html_e( 'OK, you deserve it', 'easy-wp-smtp' ); ?> </a><br> <a href="#" class="easy-wp-smtp-dismiss-review-notice" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Nope, maybe later', 'easy-wp-smtp' ); ?></a><br> <a href="#" class="easy-wp-smtp-dismiss-review-notice" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'I already did', 'easy-wp-smtp' ); ?></a> </p> </div> </div> <script type="text/javascript"> jQuery(document).ready(function ($) { $(document).on('click', '.easy-wp-smtp-dismiss-review-notice, .easy-wp-smtp-review-notice button', function (e) { if (!$(this).hasClass('easy-wp-smtp-review-out')) { e.preventDefault(); } $.post(ajaxurl, {action: 'easy_wp_smtp_feedback_notice_dismiss'}); $('.easy-wp-smtp-review-notice').remove(); }); $(document).on('click', '.easy-wp-smtp-review-switch-step', function (e) { e.preventDefault(); var target = parseInt($(this).attr('data-step'), 10); if (target) { var $notice = $(this).closest('.easy-wp-smtp-review-notice'); var $review_step = $notice.find('.easy-wp-smtp-review-step-' + target); if ($review_step.length > 0) { $notice.find('.easy-wp-smtp-review-step:visible').fadeOut(function () { $review_step.fadeIn(); }); } } }); }); </script> <?php } /** * Check if the mailer is configured. * * @since 1.5.3 * * @return bool */ public function is_smtp_configured() { // Get the currently selected mailer. $mailer = Options::init()->get( 'mail', 'mailer' ); // Skip if no or the default mailer is selected. if ( empty( $mailer ) || $mailer === 'mail' ) { return false; } $mailer_object = easy_wp_smtp() ->get_providers() ->get_mailer( $mailer, easy_wp_smtp()->get_processor()->get_phpmailer() ); // Check if mailer setup is complete. return ! empty( $mailer_object ) ? $mailer_object->is_mailer_complete() : false; } /** * Dismiss the user feedback admin notice. * * @since 1.5.3 */ public function feedback_notice_dismiss() { $options = get_option( self::OPTION_NAME, [] ); $options['time'] = time(); $options['dismissed'] = true; update_option( self::OPTION_NAME, $options ); if ( is_super_admin() && is_multisite() ) { $site_list = get_sites(); foreach ( (array) $site_list as $site ) { switch_to_blog( $site->blog_id ); update_option( self::OPTION_NAME, $options ); restore_current_blog(); } } wp_send_json_success(); } } SetupWizard.php 0000777 00000156711 15252174534 0007570 0 ustar 00 <?php namespace EasyWPSMTP\Admin; use EasyWPSMTP\Admin\Pages\TestTab; use EasyWPSMTP\Connect; use EasyWPSMTP\Helpers\Helpers; use EasyWPSMTP\Options; use EasyWPSMTP\UsageTracking\UsageTracking; use EasyWPSMTP\WP; use EasyWPSMTP\Reports\Emails\Summary as SummaryReportEmail; use EasyWPSMTP\Tasks\Reports\SummaryEmailTask as SummaryReportEmailTask; use Plugin_Upgrader; /** * Class for the plugin's Setup Wizard. * * @since 2.1.0 */ class SetupWizard { /** * The WP Option key for storing setup wizard stats. * * @since 2.1.0 */ const STATS_OPTION_KEY = 'easy_wp_smtp_setup_wizard_stats'; /** * Run all the hooks needed for the Setup Wizard. * * @since 2.1.0 */ public function hooks() { add_action( 'admin_init', [ $this, 'maybe_load_wizard' ] ); add_action( 'admin_init', [ $this, 'maybe_redirect_after_activation' ], 9999 ); add_action( 'admin_menu', [ $this, 'add_dashboard_page' ], 20 ); add_filter( 'removable_query_args', [ $this, 'maybe_disable_automatic_query_args_removal' ] ); // API AJAX callbacks. add_action( 'wp_ajax_easy_wp_smtp_vue_wizard_steps_started', [ $this, 'wizard_steps_started' ] ); add_action( 'wp_ajax_easy_wp_smtp_vue_get_settings', [ $this, 'get_settings' ] ); add_action( 'wp_ajax_easy_wp_smtp_vue_update_settings', [ $this, 'update_settings' ] ); add_action( 'wp_ajax_easy_wp_smtp_vue_get_oauth_url', [ $this, 'get_oauth_url' ] ); add_action( 'wp_ajax_easy_wp_smtp_vue_remove_oauth_connection', [ $this, 'remove_oauth_connection' ] ); add_action( 'wp_ajax_easy_wp_smtp_vue_install_plugin', [ $this, 'install_plugin' ] ); add_action( 'wp_ajax_easy_wp_smtp_vue_get_partner_plugins_info', [ $this, 'get_partner_plugins_info' ] ); add_action( 'wp_ajax_easy_wp_smtp_vue_subscribe_to_newsletter', [ $this, 'subscribe_to_newsletter' ] ); add_action( 'wp_ajax_easy_wp_smtp_vue_upgrade_plugin', [ $this, 'upgrade_plugin' ] ); add_action( 'wp_ajax_easy_wp_smtp_vue_check_mailer_configuration', [ $this, 'check_mailer_configuration' ] ); add_action( 'wp_ajax_easy_wp_smtp_vue_send_feedback', [ $this, 'send_feedback' ] ); } /** * Get the URL of the Setup Wizard page. * * @since 2.1.0 * * @return string */ public static function get_site_url() { return easy_wp_smtp()->get_admin()->get_admin_page_url() . '-setup-wizard'; } /** * Checks if the Wizard should be loaded in current context. * * @since 2.1.0 */ public function maybe_load_wizard() { // Check for wizard-specific parameter // Allow plugins to disable the setup wizard // Check if current user is allowed to save settings. if ( ! ( isset( $_GET['page'] ) && // phpcs:ignore WordPress.Security.NonceVerification.Recommended Area::SLUG . '-setup-wizard' === $_GET['page'] && // phpcs:ignore WordPress.Security.NonceVerification.Recommended $this->should_setup_wizard_load() && current_user_can( easy_wp_smtp()->get_capability_manage_options() ) ) ) { return; } // Don't load the interface if doing an ajax call. if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) { return; } set_current_screen(); // Remove an action in the Gutenberg plugin ( not core Gutenberg ) which throws an error. remove_action( 'admin_print_styles', 'gutenberg_block_editor_admin_print_styles' ); // Remove hooks for deprecated functions in WordPress 6.4.0. remove_action( 'admin_print_styles', 'print_emoji_styles' ); remove_action( 'admin_head', 'wp_admin_bar_header' ); $this->load_setup_wizard(); } /** * Maybe redirect to the setup wizard after plugin activation on a new install. * * @since 2.1.0 */ public function maybe_redirect_after_activation() { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh if ( wp_doing_ajax() || wp_doing_cron() ) { return; } // Check if we should consider redirection. if ( ! get_transient( 'easy_wp_smtp_activation_redirect' ) ) { return; } delete_transient( 'easy_wp_smtp_activation_redirect' ); // Check option to disable setup wizard redirect. if ( get_option( 'easy_wp_smtp_activation_prevent_redirect' ) ) { return; } // Only do this for single site installs. if ( isset( $_GET['activate-multi'] ) || is_network_admin() || WP::use_global_plugin_settings() ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return; } // Don't redirect if the Setup Wizard is disabled. if ( ! $this->should_setup_wizard_load() ) { return; } // Initial install. if ( get_option( 'easy_wp_smtp_initial_version' ) === EasyWPSMTP_PLUGIN_VERSION ) { update_option( 'easy_wp_smtp_activation_prevent_redirect', true ); wp_safe_redirect( self::get_site_url() ); exit; } } /** * Register page through WordPress's hooks. * * Create a dummy admin page, where the Setup Wizard app can be displayed, * but it's not visible in the admin dashboard menu. * * @since 2.1.0 */ public function add_dashboard_page() { if ( ! $this->should_setup_wizard_load() ) { return; } add_submenu_page( '', '', '', easy_wp_smtp()->get_capability_manage_options(), Area::SLUG . '-setup-wizard', '' ); } /** * Load the Setup Wizard template. * * @since 2.1.0 */ private function load_setup_wizard() { /** * Before setup wizard load. * * @since 2.1.0 * * @param \EasyWPSMTP\Admin\SetupWizard $setup_wizard SetupWizard instance. */ do_action( 'easy_wp_smtp_admin_setup_wizard_load_setup_wizard_before', $this ); $this->enqueue_scripts(); $this->setup_wizard_header(); $this->setup_wizard_content(); $this->setup_wizard_footer(); /** * After setup wizard load. * * @since 2.1.0 * * @param \EasyWPSMTP\Admin\SetupWizard $setup_wizard SetupWizard instance. */ do_action( 'easy_wp_smtp_admin_setup_wizard_load_setup_wizard_after', $this ); exit; } /** * Load the scripts needed for the Setup Wizard. * * @since 2.1.0 */ public function enqueue_scripts() { if ( ! defined( 'EasyWPSMTP_VUE_LOCAL_DEV' ) || ! EasyWPSMTP_VUE_LOCAL_DEV ) { $rtl = is_rtl() ? '.rtl' : ''; wp_enqueue_style( 'easy-wp-smtp-vue-style', easy_wp_smtp()->assets_url . '/vue/css/wizard' . $rtl . '.min.css', [], EasyWPSMTP_PLUGIN_VERSION ); } wp_enqueue_script( 'easy-wp-smtp-vue-vendors', easy_wp_smtp()->assets_url . '/vue/js/chunk-vendors.min.js', [], EasyWPSMTP_PLUGIN_VERSION, true ); wp_enqueue_script( 'easy-wp-smtp-vue-script', easy_wp_smtp()->assets_url . '/vue/js/wizard.min.js', [ 'easy-wp-smtp-vue-vendors' ], EasyWPSMTP_PLUGIN_VERSION, true ); wp_localize_script( 'easy-wp-smtp-vue-script', 'easy_wp_smtp_vue', [ 'ajax_url' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'easywpsmtp-admin-nonce' ), 'is_multisite' => is_multisite(), 'translations' => WP::get_jed_locale_data( 'easy-wp-smtp' ), 'exit_url' => easy_wp_smtp()->get_admin()->get_admin_page_url(), 'email_test_tab_url' => add_query_arg( 'tab', 'test', easy_wp_smtp()->get_admin()->get_admin_page_url( Area::SLUG . '-tools' ) ), 'is_pro' => easy_wp_smtp()->is_pro(), 'is_ssl' => is_ssl(), 'license_exists' => apply_filters( 'easy_wp_smtp_admin_setup_wizard_license_exists', false ), 'plugin_version' => EasyWPSMTP_PLUGIN_VERSION, 'mailer_options' => $this->prepare_mailer_options(), 'defined_constants' => $this->prepare_defined_constants(), 'upgrade_link' => easy_wp_smtp()->get_upgrade_link( 'setup-wizard' ), 'versions' => $this->prepare_versions_data(), 'public_url' => easy_wp_smtp()->assets_url . '/vue/', 'current_user_email' => wp_get_current_user()->user_email, 'completed_time' => self::get_stats()['completed_time'], 'education' => [ 'upgrade_text' => esc_html__( 'Sorry, but the %mailer% mailer isn’t available in the lite version. Please upgrade to PRO to unlock this mailer and much more.', 'easy-wp-smtp' ), 'upgrade_button' => esc_html__( 'Upgrade to PRO', 'easy-wp-smtp' ), 'upgrade_url' => add_query_arg( 'discount', 'SMTPLITEUPGRADE', easy_wp_smtp()->get_upgrade_link( '' ) ), 'upgrade_bonus_short' => sprintf( wp_kses( /* Translators: %s - discount value 50%. */ __( '<b>%s OFF</b> for Easy WP SMTP users, applied at checkout.', 'easy-wp-smtp' ), [ 'b' => [], ] ), '50%' ), 'upgrade_bonus_long' => sprintf( wp_kses( /* Translators: %s - discount value 50%. */ __( 'You can upgrade to the Pro plan and <b>save %s today</b>, automatically applied at checkout.', 'easy-wp-smtp' ), [ 'b' => [], ] ), '50%' ), 'upgrade_doc' => sprintf( '<a href="%1$s" target="_blank" rel="noopener noreferrer" class="already-purchased">%2$s</a>', // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound esc_url( easy_wp_smtp()->get_utm_url( 'https://easywpsmtp.com/docs/how-to-upgrade-easy-wp-smtp-to-pro-version/', [ 'medium' => 'setup-wizard', 'content' => 'Wizard Pro Mailer Popup - Already purchased' ] ) ), esc_html__( 'Already purchased?', 'easy-wp-smtp' ) ), ], ] ); } /** * Outputs the simplified header used for the Setup Wizard. * * @since 2.1.0 */ public function setup_wizard_header() { ?> <!DOCTYPE html> <html <?php language_attributes(); ?>> <head> <meta name="viewport" content="width=device-width"/> <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/> <title><?php esc_html_e( 'Easy WP SMTP › Setup Wizard', 'easy-wp-smtp' ); ?></title> <?php do_action( 'admin_print_styles' ); ?> <?php do_action( 'admin_print_scripts' ); ?> <?php do_action( 'admin_head' ); ?> </head> <body class="easy-wp-smtp-setup-wizard"> <?php } /** * Outputs the content of the current step. * * @since 2.1.0 */ public function setup_wizard_content() { $admin_url = is_network_admin() ? network_admin_url() : admin_url(); $this->settings_error_page( 'easy-wp-smtp-vue-setup-wizard', '<a href="' . $admin_url . '">' . esc_html__( 'Go back to the Dashboard', 'easy-wp-smtp' ) . '</a>' ); $this->settings_inline_js(); } /** * Outputs the simplified footer used for the Setup Wizard. * * @since 2.1.0 */ public function setup_wizard_footer() { ?> <?php wp_print_scripts( 'easy-wp-smtp-vue-script' ); ?> </body> </html> <?php } /** * Error page HTML * * @since 2.1.0 * * @param string $id The HTML ID attribute of the main container div. * @param string $footer The centered footer content. */ private function settings_error_page( $id = 'easy-wp-smtp-vue-site-settings', $footer = '' ) { $inline_logo_image = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzAwIiBoZWlnaHQ9IjU0IiB2aWV3Qm94PSIwIDAgMzAwIDU0IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8ZyBjbGlwLXBhdGg9InVybCgjY2xpcDBfNjgwXzc5MDkpIj4KPHBhdGggZD0iTTI0LjI5NjQgNDYuNjEwMkMyNC44MzE4IDQ2LjMyNTMgMjUuNDMwNiA0Ni4xNzYyIDI2LjAzODggNDYuMTc2Mkg1Ni4xNTE4QzU2Ljc2IDQ2LjE3NjIgNTcuMzU4OCA0Ni4zMjUzIDU3Ljg5NDIgNDYuNjEwMkM2MS4yNTI4IDQ4LjM5NyA1OS45NjcgNTMuNDI4NiA1Ni4xNTE4IDUzLjQyODZIMjYuMDM4OEMyMi4yMjM2IDUzLjQyODYgMjAuOTM3OSA0OC4zOTcgMjQuMjk2NCA0Ni42MTAyWiIgZmlsbD0iIzIxMUY5QSIvPgo8cGF0aCBkPSJNMTMuMDgzNSAzMy45MTg0QzEzLjYxMDEgMzMuNjMzNiAxNC4xOTkgMzMuNDg0NCAxNC43OTcyIDMzLjQ4NDRINjcuMzkzMkM2Ny45OTE0IDMzLjQ4NDQgNjguNTgwMSAzMy42MzM2IDY5LjEwNjYgMzMuOTE4NEM3Mi40MTA0IDM1LjcwNTMgNzEuMTQ1OCA0MC43MzY4IDY3LjM5MzIgNDAuNzM2OEgxNC43OTcyQzExLjA0NDggNDAuNzM2OCA5Ljc4MDEzIDM1LjcwNTMgMTMuMDgzNSAzMy45MTg0WiIgZmlsbD0iIzIxMUY5QSIvPgo8cGF0aCBkPSJNOS41MzM2IDIuNTg0NTlDOC45NTQ2MyAzLjEyMzQgOC40NTUzIDMuNzU2NzkgOC4wNjA5MSA0LjQ2OTg0TDEuMjYwNTMgMTYuNzY1NkMtMS41NDE1MSAyMS44MzIgMi4xMjQyNiAyOC4wNDUxIDcuOTE1NTUgMjguMDQ1MUg3NC4yNzUxQzgwLjA2NjEgMjguMDQ1MSA4My43MzIgMjEuODMyIDgwLjkyOTcgMTYuNzY1Nkw3NC4xMjk1IDQuNDY5ODRDNzMuNjk5NiAzLjY5MjgzIDczLjE0NTQgMy4wMTA0IDcyLjQ5OSAyLjQ0MTg5QzY1Ljk2MzYgNi41Mzg0MSA0OS42MjcyIDE2LjE5ODIgNDAuOTAyNCAxNi4xOTgyQzMyLjI3NzQgMTYuMTk4MiAxNi4yMTM5IDYuNzU4MDcgOS41MzM2IDIuNTg0NTlaIiBmaWxsPSIjMjExRjlBIi8+CjxwYXRoIGQ9Ik0xMi4zNzgxIDAuOTE0MjE1QzE5LjU3ODYgNS4yODQ4MSAzMy4zNDEzIDEyLjk4NzYgNDAuOTAyMyAxMi45ODc2QzQ4LjUwODUgMTIuOTg3NiA2Mi4zOTEgNS4xOTIzNSA2OS41NTUzIDAuODM2MTIxQzY4Ljg4NzYgMC42NDYyMyA2OC4xODg0IDAuNTQ2MjY5IDY3LjQ3NDggMC41NDYyNjlMMTQuNzE1OSAwLjU0NjI2NUMxMy45MDk5IDAuNTQ2MjY1IDEzLjEyMjUgMC42NzM3MjYgMTIuMzc4MSAwLjkxNDIxNVoiIGZpbGw9IiMyMTFGOUEiLz4KPHBhdGggZD0iTTk5LjQ2MzEgMjYuMDk5MUgxMTMuMzYyVjIyLjYwMDlIMTAzLjUyOVYxNi41ODE1SDExMS4wNjFWMTMuMDgzMkgxMDMuNTI5VjcuMjIxMzRIMTEyLjg1OFYzLjcyMzE0SDk5LjQ2MzFWMjYuMDk5MVoiIGZpbGw9IiMwOTA5MkMiLz4KPHBhdGggZD0iTTExNC42NiAyMS40NjYzQzExNC42NiAyNC42ODA5IDExNy4yNDQgMjYuNDc3MyAxMjAuMTEyIDI2LjQ3NzNDMTIzLjc2OCAyNi40NzczIDEyNS4wMjggMjMuNjQwOSAxMjQuOTk3IDIzLjY0MDlIMTI1LjA2QzEyNS4wNiAyMy42NDA5IDEyNC45OTcgMjQuMTQ1MiAxMjQuOTk3IDI0Ljc3NTVWMjYuMDk5MUgxMjguNjg0VjE2LjA0NTdDMTI4LjY4NCAxMS45ODAyIDEyNi4yMjYgOS42NDgwNyAxMjIuMDAzIDkuNjQ4MDdDMTE4LjE5IDkuNjQ4MDcgMTE1LjcgMTEuNjMzNSAxMTUuNyAxMS42MzM1TDExNy4yMTMgMTQuNTAxNEMxMTcuMjEzIDE0LjUwMTQgMTE5LjMyNCAxMi45NTcyIDEyMS42MjUgMTIuOTU3MkMxMjMuMzkgMTIuOTU3MiAxMjQuNzEzIDEzLjY4MiAxMjQuNzEzIDE1Ljc5MzZWMTYuMDE0MkgxMjQuMTc3QzEyMS41NjIgMTYuMDE0MiAxMTQuNjYgMTYuMzYwOSAxMTQuNjYgMjEuNDY2M1pNMTE4LjY5NCAyMS4yNzcyQzExOC42OTQgMTkuMDM5NiAxMjIuMDAzIDE4Ljc1NiAxMjQuMDUyIDE4Ljc1NkgxMjQuNzQ1VjE5LjEzNDJDMTI0Ljc0NSAyMS4wODgyIDEyMy4yMzIgMjMuMzU3MyAxMjEuMTIxIDIzLjM1NzNDMTE5LjQ4MSAyMy4zNTczIDExOC42OTQgMjIuMzQ4NyAxMTguNjk0IDIxLjI3NzJaIiBmaWxsPSIjMDkwOTJDIi8+CjxwYXRoIGQ9Ik0xMzAuNTUgMjQuMTEzN0MxMzAuNTUgMjQuMTEzNyAxMzIuNzg3IDI2LjQ3NzMgMTM2Ljc4OSAyNi40NzczQzE0MC42MDMgMjYuNDc3MyAxNDIuOTM1IDI0LjMzNDMgMTQyLjkzNSAyMS42NTU0QzE0Mi45MzUgMTYuNDg2OSAxMzUuMTE5IDE2Ljc3MDYgMTM1LjExOSAxNC41MDE0QzEzNS4xMTkgMTMuNDkyOSAxMzYuMTI4IDEzLjA1MTcgMTM3LjE2OCAxMy4wNTE3QzEzOS42MjYgMTMuMDUxNyAxNDEuMTA3IDE0LjQzODQgMTQxLjEwNyAxNC40Mzg0TDE0Mi41ODggMTEuNDc1OUMxNDIuNTg4IDExLjQ3NTkgMTQwLjgyNCA5LjY0ODA3IDEzNy4xOTkgOS42NDgwN0MxMzMuNzMzIDkuNjQ4MDcgMTMxLjA1NCAxMS4zODE0IDEzMS4wNTQgMTQuMzc1M0MxMzEuMDU0IDE5LjU0MzkgMTM4Ljg2OSAxOS4yMjg3IDEzOC44NjkgMjEuNjIzOUMxMzguODY5IDIyLjU2OTMgMTM3Ljg5MyAyMy4wNzM2IDEzNi43NTggMjMuMDczNkMxMzQuMTQyIDIzLjA3MzYgMTMyLjM3NyAyMS4zMDg4IDEzMi4zNzcgMjEuMzA4OEwxMzAuNTUgMjQuMTEzN1oiIGZpbGw9IiMwOTA5MkMiLz4KPHBhdGggZD0iTTE0My4wMzQgMzEuNjc3M0MxNDMuMDM0IDMxLjY3NzMgMTQ0LjQ1MiAzMi43MTczIDE0Ni4zNzUgMzIuNzE3M0MxNDguODAyIDMyLjcxNzMgMTUxLjAzOSAzMS40NTY3IDE1Mi4xNzMgMjguNTI1OEwxNTkuMzU5IDEwLjAyNjJIMTU0Ljk3OUwxNTEuODI3IDE5LjM1NDdDMTUxLjU0MyAyMC4yMDU3IDE1MS4yOTEgMjEuNDY2MyAxNTEuMjkxIDIxLjQ2NjNIMTUxLjIyOEMxNTEuMjI4IDIxLjQ2NjMgMTUwLjk0NSAyMC4xNDI2IDE1MC42MjkgMTkuMjkxN0wxNDcuMjU3IDEwLjAyNjJIMTQyLjc1TDE0OS41MjYgMjUuODQ3TDE0OC45MjcgMjcuMjY1MUMxNDguMzI5IDI4LjY4MzQgMTQ3LjI1NyAyOS4zNDUyIDE0Ni4xNTUgMjkuMzQ1MkMxNDUuMjQgMjkuMzQ1MiAxNDQuMzU4IDI4LjY4MzQgMTQ0LjM1OCAyOC42ODM0TDE0My4wMzQgMzEuNjc3M1oiIGZpbGw9IiMwOTA5MkMiLz4KPHBhdGggZD0iTTE4Mi4xNjUgMy43MjMxNEgxNzguNjM2TDE3NC42MDIgMTkuMTk3MkMxNzQuMjU1IDIwLjQ4OTQgMTc0LjIyMyAyMS41NjA5IDE3NC4xOTIgMjEuNTYwOUgxNzQuMTI5QzE3NC4xMjkgMjEuNTYwOSAxNzQuMDM0IDIwLjQ1NzkgMTczLjc1MSAxOS4xOTcyTDE3MC4yMjEgMy43MjMxNEgxNjYuMDI5TDE3MS42MzkgMjYuMDk5MUgxNzYuMzM1TDE3OS43NyAxMi44NjI2QzE4MC4xNDggMTEuNDEyOSAxODAuMzM4IDkuOTMxNjkgMTgwLjMzOCA5LjkzMTY5SDE4MC40QzE4MC40IDkuOTMxNjkgMTgwLjU4OSAxMS40MTI5IDE4MC45NjggMTIuODYyNkwxODQuNDAzIDI2LjA5OTFIMTg5LjA5OEwxOTQuODY2IDMuNzIzMTRIMTkwLjY3NEwxODYuOTg3IDE5LjE5NzJDMTg2LjcwNCAyMC40NTc5IDE4Ni42MDkgMjEuNTYwOSAxODYuNjA5IDIxLjU2MDlIMTg2LjU0NkMxODYuNTE1IDIxLjU2MDkgMTg2LjQ4MyAyMC40ODk0IDE4Ni4xMzYgMTkuMTk3MkwxODIuMTY1IDMuNzIzMTRaIiBmaWxsPSIjMDkwOTJDIi8+CjxwYXRoIGQ9Ik0xOTcuNTM5IDI2LjA5OTFIMjAxLjYwNVYxOC4zNzc4SDIwNi4xNzVDMjEwLjM2NiAxOC4zNzc4IDIxMy4yOTcgMTUuMzUyMyAyMTMuMjk3IDExLjAwMzJDMjEzLjI5NyA2LjY1NDEgMjEwLjM2NiAzLjcyMzE0IDIwNi4xNzUgMy43MjMxNEgxOTcuNTM5VjI2LjA5OTFaTTIwMS42MDUgMTQuODQ4MVY3LjIyMTM0SDIwNS40ODFDMjA3Ljc4MiA3LjIyMTM0IDIwOS4xNjggOC43MDI1NyAyMDkuMTY4IDExLjAwMzJDMjA5LjE2OCAxMy4zMzU0IDIwNy43ODIgMTQuODQ4MSAyMDUuNDE4IDE0Ljg0ODFIMjAxLjYwNVoiIGZpbGw9IiMwOTA5MkMiLz4KPHBhdGggZD0iTTIyMC45NjggMjMuNDIwM0MyMjAuOTY4IDIzLjQyMDMgMjIzLjcxIDI2LjQ3NzMgMjI4LjY5IDI2LjQ3NzNDMjMzLjM1NCAyNi40NzczIDIzNi4wNjQgMjMuNDgzNCAyMzYuMDY0IDE5LjkyMjFDMjM2LjA2NCAxMi43NjgxIDIyNS41MzggMTMuNzQ1MSAyMjUuNTM4IDkuNzc0MTFDMjI1LjUzOCA4LjE5ODM2IDIyNy4wMiA3LjA5NTMyIDIyOC45MTEgNy4wOTUzMkMyMzEuNzE1IDcuMDk1MzIgMjMzLjg1OCA5LjA0OTI4IDIzMy44NTggOS4wNDkyOEwyMzUuNjIzIDUuNzQwMTRDMjM1LjYyMyA1Ljc0MDE0IDIzMy4zNTQgMy4zNDQ5NyAyMjguOTQyIDMuMzQ0OTdDMjI0LjY1NiAzLjM0NDk3IDIyMS40NDEgNi4xMTgzMyAyMjEuNDQxIDkuODM3MTJDMjIxLjQ0MSAxNi43MDc1IDIzMS45OTkgMTYuMDE0MiAyMzEuOTk5IDIwLjAxNjdDMjMxLjk5OSAyMS44NDQ1IDIzMC40NTQgMjIuNzI3IDIyOC43NTIgMjIuNzI3QzIyNS42MDEgMjIuNzI3IDIyMy4xNzQgMjAuMzYzMyAyMjMuMTc0IDIwLjM2MzNMMjIwLjk2OCAyMy40MjAzWiIgZmlsbD0iIzA5MDkyQyIvPgo8cGF0aCBkPSJNMjM4LjQ4MiAyNi4wOTkxSDI0Mi41NDdMMjQzLjQ5MyAxMy41ODc1QzI0My41ODcgMTIuMTA2MyAyNDMuNTI1IDEwLjA4OTMgMjQzLjUyNSAxMC4wODkzSDI0My41ODdDMjQzLjU4NyAxMC4wODkzIDI0NC4yODEgMTIuMjk1NCAyNDQuODE2IDEzLjU4NzVMMjQ4LjQwOSAyMi4yNTQySDI1MS45N0wyNTUuNTk1IDEzLjU4NzVDMjU2LjEzIDEyLjI5NTQgMjU2Ljc5MiAxMC4xMjA4IDI1Ni43OTIgMTAuMTIwOEgyNTYuODU1QzI1Ni44NTUgMTAuMTIwOCAyNTYuNzkyIDEyLjEwNjMgMjU2Ljg4NyAxMy41ODc1TDI1Ny44MzIgMjYuMDk5MUgyNjEuODY2TDI2MC4wNyAzLjcyMzE0SDI1NS43MjFMMjUxLjM0IDE0Ljc4NUMyNTAuODM2IDE2LjEwODcgMjUwLjIzNyAxOC4wNjI3IDI1MC4yMzcgMTguMDYyN0gyNTAuMTc0QzI1MC4xNzQgMTguMDYyNyAyNDkuNTQ0IDE2LjEwODcgMjQ5LjAzOSAxNC43ODVMMjQ0LjY1OSAzLjcyMzE0SDI0MC4zMUwyMzguNDgyIDI2LjA5OTFaIiBmaWxsPSIjMDkwOTJDIi8+CjxwYXRoIGQ9Ik0yNzAuNTMxIDI2LjA5OTFIMjc0LjU5N1Y3LjIyMTM0SDI4MS45NFYzLjcyMzE0SDI2My4xODhWNy4yMjEzNEgyNzAuNTMxVjI2LjA5OTFaIiBmaWxsPSIjMDkwOTJDIi8+CjxwYXRoIGQ9Ik0yODMuOTQyIDI2LjA5OTFIMjg4LjAwOFYxOC4zNzc4SDI5Mi41NzdDMjk2Ljc2OSAxOC4zNzc4IDI5OS43IDE1LjM1MjMgMjk5LjcgMTEuMDAzMkMyOTkuNyA2LjY1NDEgMjk2Ljc2OSAzLjcyMzE0IDI5Mi41NzcgMy43MjMxNEgyODMuOTQyVjI2LjA5OTFaTTI4OC4wMDggMTQuODQ4MVY3LjIyMTM0SDI5MS44ODRDMjk0LjE4NSA3LjIyMTM0IDI5NS41NzEgOC43MDI1NyAyOTUuNTcxIDExLjAwMzJDMjk1LjU3MSAxMy4zMzU0IDI5NC4xODUgMTQuODQ4MSAyOTEuODIxIDE0Ljg0ODFIMjg4LjAwOFoiIGZpbGw9IiMwOTA5MkMiLz4KPHBhdGggZD0iTTk5LjQ2MzEgNDcuNDUyMUM5OS40NjMxIDQ5LjE1MjUgMTAwLjg5NCA1MC4wNDQ3IDEwMi4zNTkgNTAuMDQ0N0MxMDQuMzk2IDUwLjA0NDcgMTA1LjA4NyA0OC4zNjEyIDEwNS4wODcgNDguMzYxMkgxMDUuMTJDMTA1LjEyIDQ4LjM2MTIgMTA1LjA4NyA0OC42NDc0IDEwNS4wODcgNDkuMDM0N1Y0OS44NDI3SDEwNi42MDJWNDQuNDU1NUMxMDYuNjAyIDQyLjMwMDYgMTA1LjM4OSA0MS4xMjIxIDEwMy4yMzQgNDEuMTIyMUMxMDEuMjgxIDQxLjEyMjEgMTAwLjA1MiA0Mi4xMzIyIDEwMC4wNTIgNDIuMTMyMkwxMDAuNzI2IDQzLjMyNzVDMTAwLjcyNiA0My4zMjc1IDEwMS43ODcgNDIuNTAyNiAxMDMuMSA0Mi41MDI2QzEwNC4xNzcgNDIuNTAyNiAxMDQuOTY5IDQyLjk3NCAxMDQuOTY5IDQ0LjM3MTNWNDQuNTIyOEgxMDQuNTk4QzEwMy4xNjcgNDQuNTIyOCA5OS40NjMxIDQ0LjY0MDcgOTkuNDYzMSA0Ny40NTIxWk0xMDEuMTEzIDQ3LjM2OEMxMDEuMTEzIDQ1LjgzNiAxMDMuMzM2IDQ1Ljc1MTggMTA0LjU2NCA0NS43NTE4SDEwNC45ODZWNDYuMDIxMkMxMDQuOTg2IDQ3LjI4MzggMTA0LjA5MyA0OC43MzE2IDEwMi43MTIgNDguNzMxNkMxMDEuNjUyIDQ4LjczMTYgMTAxLjExMyA0OC4wNTgyIDEwMS4xMTMgNDcuMzY4WiIgZmlsbD0iIzVGNUY3NiIvPgo8cGF0aCBkPSJNMTEyLjA5MyA0OC40NjIyQzExMi4wOTMgNDguNDYyMiAxMTMuNTU4IDUwLjA5NTIgMTE2LjIxOCA1MC4wOTUyQzExOC43MSA1MC4wOTUyIDEyMC4xNTcgNDguNDk1OSAxMjAuMTU3IDQ2LjU5MzVDMTIwLjE1NyA0Mi43NzE5IDExNC41MzQgNDMuMjkzOCAxMTQuNTM0IDQxLjE3MjZDMTE0LjUzNCA0MC4zMzA4IDExNS4zMjUgMzkuNzQxNiAxMTYuMzM1IDM5Ljc0MTZDMTE3LjgzNCAzOS43NDE2IDExOC45NzkgNDAuNzg1NCAxMTguOTc5IDQwLjc4NTRMMTE5LjkyMSAzOS4wMTc3QzExOS45MjEgMzkuMDE3NyAxMTguNzEgMzcuNzM4MiAxMTYuMzUyIDM3LjczODJDMTE0LjA2MyAzNy43MzgyIDExMi4zNDYgMzkuMjE5NyAxMTIuMzQ2IDQxLjIwNjJDMTEyLjM0NiA0NC44NzYzIDExNy45ODUgNDQuNTA1OSAxMTcuOTg1IDQ2LjY0NEMxMTcuOTg1IDQ3LjYyMDUgMTE3LjE2MSA0OC4wOTE5IDExNi4yNTEgNDguMDkxOUMxMTQuNTY4IDQ4LjA5MTkgMTEzLjI3MiA0Ni44MjkyIDExMy4yNzIgNDYuODI5MkwxMTIuMDkzIDQ4LjQ2MjJaIiBmaWxsPSIjMjExRjlBIi8+CjxwYXRoIGQ9Ik0xMjAuODc2IDQ1LjYwMDNDMTIwLjg3NiA0OC4wNDE0IDEyMi42NDQgNTAuMDk1MiAxMjUuNDg5IDUwLjA5NTJDMTI3LjYyNyA1MC4wOTUyIDEyOC45NCA0OC44NjYzIDEyOC45NCA0OC44NjYzTDEyOC4xMTYgNDcuMzM0M0MxMjguMTE2IDQ3LjMzNDMgMTI3LjAyMSA0OC4yNzcgMTI1LjY0IDQ4LjI3N0MxMjQuMzYxIDQ4LjI3NyAxMjMuMTk5IDQ3LjUwMjYgMTIzLjA2NSA0Ni4wMDQzSDEyOC45OUMxMjguOTkgNDYuMDA0MyAxMjkuMDQxIDQ1LjQzMTkgMTI5LjA0MSA0NS4xNzk0QzEyOS4wNDEgNDIuOTA2NyAxMjcuNzExIDQxLjEwNTMgMTI1LjIzNyA0MS4xMDUzQzEyMi42NzcgNDEuMTA1MyAxMjAuODc2IDQyLjk1NzEgMTIwLjg3NiA0NS42MDAzWk0xMjMuMTMyIDQ0LjUzOTdDMTIzLjMzNCA0My40Mjg1IDEyNC4wOTIgNDIuNzU1MSAxMjUuMTg2IDQyLjc1NTFDMTI2LjEyOSA0Mi43NTUxIDEyNi44NTMgNDMuMzc4IDEyNi44ODYgNDQuNTM5N0gxMjMuMTMyWiIgZmlsbD0iIzIxMUY5QSIvPgo8cGF0aCBkPSJNMTMwLjI5OSA0OS44OTMySDEzMi40MzdWNDUuOTUzOEMxMzIuNDM3IDQ1LjU0OTcgMTMyLjQ3MSA0NS4xNjI2IDEzMi41ODkgNDQuODA5QzEzMi45MDkgNDMuNzgyMSAxMzMuNzUgNDMuMDU4MiAxMzQuODk1IDQzLjA1ODJDMTM1Ljk4OSA0My4wNTgyIDEzNi4yNTkgNDMuNzY1MiAxMzYuMjU5IDQ0LjgwOVY0OS44OTMySDEzOC4zOFY0NC4zNzEzQzEzOC4zOCA0Mi4wOTg1IDEzNy4zMDMgNDEuMTA1MyAxMzUuNCA0MS4xMDUzQzEzMy42NjYgNDEuMTA1MyAxMzIuNzI0IDQyLjE2NTkgMTMyLjM1MyA0Mi44ODk4SDEzMi4zMTlDMTMyLjMxOSA0Mi44ODk4IDEzMi4zNTMgNDIuNjIwNCAxMzIuMzUzIDQyLjMwMDZWNDEuMzA3M0gxMzAuMjk5VjQ5Ljg5MzJaIiBmaWxsPSIjMjExRjlBIi8+CjxwYXRoIGQ9Ik0xMzkuNTI5IDQ1LjYwMDJDMTM5LjUyOSA0OC4yNjAyIDE0MS4wMTEgNTAuMDk1MiAxNDMuMzM0IDUwLjA5NTJDMTQ1LjMwNCA1MC4wOTUyIDE0Ni4wMjggNDguNjMwNSAxNDYuMDI4IDQ4LjYzMDVIMTQ2LjA2MkMxNDYuMDYyIDQ4LjYzMDUgMTQ2LjAyOCA0OC44NjYzIDE0Ni4wMjggNDkuMTg2MVY0OS44OTMySDE0OC4wNDhWMzcuOTQwMkgxNDUuOTFWNDEuNzExM0MxNDUuOTEgNDEuOTgwNiAxNDUuOTI3IDQyLjE5OTUgMTQ1LjkyNyA0Mi4xOTk1SDE0NS44OTNDMTQ1Ljg5MyA0Mi4xOTk1IDE0NS4zMDQgNDEuMTA1MiAxNDMuNDE4IDQxLjEwNTJDMTQxLjE0NSA0MS4xMDUyIDEzOS41MjkgNDIuODcyOSAxMzkuNTI5IDQ1LjYwMDJaTTE0MS42ODUgNDUuNjAwMkMxNDEuNjg1IDQzLjg5OTkgMTQyLjY2MSA0Mi45NDAzIDE0My44MzkgNDIuOTQwM0MxNDUuMjcgNDIuOTQwMyAxNDUuOTc3IDQ0LjI1MzQgMTQ1Ljk3NyA0NS41ODM0QzE0NS45NzcgNDcuNDg1OCAxNDQuOTM0IDQ4LjI5MzggMTQzLjgyMiA0OC4yOTM4QzE0Mi41NTkgNDguMjkzOCAxNDEuNjg1IDQ3LjIzMzIgMTQxLjY4NSA0NS42MDAyWiIgZmlsbD0iIzIxMUY5QSIvPgo8cGF0aCBkPSJNMTUwLjI0MiA0OS44OTMySDE1Ny41ODJWNDguMDI0NUgxNTIuNDE0VjM3Ljk0MDJIMTUwLjI0MlY0OS44OTMyWiIgZmlsbD0iIzIxMUY5QSIvPgo8cGF0aCBkPSJNMTU3Ljc1MiA0Ny40MTg1QzE1Ny43NTIgNDkuMTM1NiAxNTkuMTMyIDUwLjA5NTIgMTYwLjY2NCA1MC4wOTUyQzE2Mi42MTcgNTAuMDk1MiAxNjMuMjkgNDguNTgwMSAxNjMuMjczIDQ4LjU4MDFIMTYzLjMwN0MxNjMuMzA3IDQ4LjU4MDEgMTYzLjI3MyA0OC44NDk0IDE2My4yNzMgNDkuMTg2MVY0OS44OTMySDE2NS4yNDNWNDQuNTIyOEMxNjUuMjQzIDQyLjM1MTEgMTYzLjkzMSA0MS4xMDUzIDE2MS42NzQgNDEuMTA1M0MxNTkuNjM3IDQxLjEwNTMgMTU4LjMwNyA0Mi4xNjU5IDE1OC4zMDcgNDIuMTY1OUwxNTkuMTE1IDQzLjY5NzlDMTU5LjExNSA0My42OTc5IDE2MC4yNDMgNDIuODczIDE2MS40NzIgNDIuODczQzE2Mi40MTUgNDIuODczIDE2My4xMjIgNDMuMjYwMiAxNjMuMTIyIDQ0LjM4ODJWNDQuNTA2SDE2Mi44MzZDMTYxLjQzOSA0NC41MDYgMTU3Ljc1MiA0NC42OTEyIDE1Ny43NTIgNDcuNDE4NVpNMTU5LjkwNyA0Ny4zMTc1QzE1OS45MDcgNDYuMTIyMSAxNjEuNjc0IDQ1Ljk3MDYgMTYyLjc2OSA0NS45NzA2SDE2My4xMzlWNDYuMTcyNkMxNjMuMTM5IDQ3LjIxNjQgMTYyLjMzMSA0OC40Mjg2IDE2MS4yMDMgNDguNDI4NkMxNjAuMzI3IDQ4LjQyODYgMTU5LjkwNyA0Ny44ODk5IDE1OS45MDcgNDcuMzE3NVoiIGZpbGw9IiMyMTFGOUEiLz4KPHBhdGggZD0iTTE2NS44MDIgNTIuODczMUMxNjUuODAyIDUyLjg3MzEgMTY2LjU2IDUzLjQyODYgMTY3LjU4NiA1My40Mjg2QzE2OC44ODMgNTMuNDI4NiAxNzAuMDc4IDUyLjc1NTIgMTcwLjY4NCA1MS4xODk1TDE3NC41MjMgNDEuMzA3M0gxNzIuMTgyTDE3MC40OTkgNDYuMjkwNUMxNzAuMzQ3IDQ2Ljc0NSAxNzAuMjEzIDQ3LjQxODUgMTcwLjIxMyA0Ny40MTg1SDE3MC4xNzlDMTcwLjE3OSA0Ny40MTg1IDE3MC4wMjggNDYuNzExNCAxNjkuODU5IDQ2LjI1NjhMMTY4LjA1OCA0MS4zMDczSDE2NS42NUwxNjkuMjcgNDkuNzU4NUwxNjguOTUgNTAuNTE2MUMxNjguNjMgNTEuMjczNyAxNjguMDU4IDUxLjYyNzIgMTY3LjQ2OCA1MS42MjcyQzE2Ni45ODEgNTEuNjI3MiAxNjYuNTA5IDUxLjI3MzcgMTY2LjUwOSA1MS4yNzM3TDE2NS44MDIgNTIuODczMVoiIGZpbGw9IiMyMTFGOUEiLz4KPHBhdGggZD0iTTE3NC42MzIgNDUuNjAwM0MxNzQuNjMyIDQ4LjA0MTQgMTc2LjM5OSA1MC4wOTUyIDE3OS4yNDQgNTAuMDk1MkMxODEuMzgzIDUwLjA5NTIgMTgyLjY5NiA0OC44NjYzIDE4Mi42OTYgNDguODY2M0wxODEuODcxIDQ3LjMzNDNDMTgxLjg3MSA0Ny4zMzQzIDE4MC43NzYgNDguMjc3IDE3OS4zOTYgNDguMjc3QzE3OC4xMTcgNDguMjc3IDE3Ni45NTUgNDcuNTAyNiAxNzYuODIxIDQ2LjAwNDNIMTgyLjc0NkMxODIuNzQ2IDQ2LjAwNDMgMTgyLjc5NyA0NS40MzE5IDE4Mi43OTcgNDUuMTc5NEMxODIuNzk3IDQyLjkwNjcgMTgxLjQ2NyA0MS4xMDUzIDE3OC45OTIgNDEuMTA1M0MxNzYuNDMzIDQxLjEwNTMgMTc0LjYzMiA0Mi45NTcxIDE3NC42MzIgNDUuNjAwM1pNMTc2Ljg4OCA0NC41Mzk3QzE3Ny4wOSA0My40Mjg1IDE3Ny44NDcgNDIuNzU1MSAxNzguOTQyIDQyLjc1NTFDMTc5Ljg4NSA0Mi43NTUxIDE4MC42MDggNDMuMzc4IDE4MC42NDIgNDQuNTM5N0gxNzYuODg4WiIgZmlsbD0iIzIxMUY5QSIvPgo8cGF0aCBkPSJNMTg0LjA1NCA0OS44OTMySDE4Ni4xOTNWNDYuNDkyNUMxODYuMTkzIDQ1Ljk4NzUgMTg2LjI0MyA0NS41MTYxIDE4Ni4zNzggNDUuMDk1MkMxODYuNzgyIDQzLjgxNTggMTg3LjgwOSA0My4yOTM4IDE4OC43MTggNDMuMjkzOEMxODkuMDA0IDQzLjI5MzggMTg5LjIyMyA0My4zMjc1IDE4OS4yMjMgNDMuMzI3NVY0MS4yMjMyQzE4OS4yMjMgNDEuMjIzMiAxODkuMDM4IDQxLjE4OTUgMTg4LjgzNSA0MS4xODk1QzE4Ny41MjMgNDEuMTg5NSAxODYuNDk2IDQyLjE2NTkgMTg2LjEwOCA0My4zOTQ5SDE4Ni4wNzVDMTg2LjA3NSA0My4zOTQ5IDE4Ni4xMDggNDMuMTA4NiAxODYuMTA4IDQyLjc4ODhWNDEuMzA3M0gxODQuMDU0VjQ5Ljg5MzJaIiBmaWxsPSIjMjExRjlBIi8+CjxwYXRoIGQ9Ik0xOTQuNzE0IDUzLjIwOTdIMTk2LjM0OFY0OS4zODgxQzE5Ni4zNDggNDguOTUwNCAxOTYuMzE0IDQ4LjY0NzQgMTk2LjMxNCA0OC42NDc0SDE5Ni4zNDhDMTk2LjM0OCA0OC42NDc0IDE5Ny4wODggNTAuMDQ0NyAxOTguOTU3IDUwLjA0NDdDMjAxLjE4IDUwLjA0NDcgMjAyLjgxMyA0OC4yOTM4IDIwMi44MTMgNDUuNTgzM0MyMDIuODEzIDQyLjk0MDMgMjAxLjM2NSA0MS4xMjIxIDE5OS4wNzUgNDEuMTIyMUMxOTYuOTM3IDQxLjEyMjEgMTk2LjIxMyA0Mi42NzA5IDE5Ni4yMTMgNDIuNjcwOUgxOTYuMTc5QzE5Ni4xNzkgNDIuNjcwOSAxOTYuMjEzIDQyLjM4NDcgMTk2LjIxMyA0Mi4wNDhWNDEuMzI0SDE5NC43MTRWNTMuMjA5N1pNMTk2LjI5NyA0NS42MTdDMTk2LjI5NyA0My40NDUzIDE5Ny40NzUgNDIuNTUzIDE5OC43NTUgNDIuNTUzQzIwMC4xNjkgNDIuNTUzIDIwMS4xNjMgNDMuNzQ4MyAyMDEuMTYzIDQ1LjYwMDJDMjAxLjE2MyA0Ny41MzYyIDIwMC4wNTIgNDguNjQ3NCAxOTguNzA1IDQ4LjY0NzRDMTk3LjEzOSA0OC42NDc0IDE5Ni4yOTcgNDcuMTMyMiAxOTYuMjk3IDQ1LjYxN1oiIGZpbGw9IiM1RjVGNzYiLz4KPHBhdGggZD0iTTIwNC4zMDQgNDkuODQyN0gyMDUuOTM2VjQ2LjM1NzhDMjA1LjkzNiA0NS44MzU5IDIwNS45ODcgNDUuMzE0IDIwNi4xMzggNDQuODI1OEMyMDYuNTI2IDQzLjU2MzEgMjA3LjQ4NSA0Mi44MjI0IDIwOC41MjkgNDIuODIyNEMyMDguNzgyIDQyLjgyMjQgMjA5IDQyLjg3MyAyMDkgNDIuODczVjQxLjI1NjhDMjA5IDQxLjI1NjggMjA4Ljc5OSA0MS4yMjMxIDIwOC41OCA0MS4yMjMxQzIwNy4yNjcgNDEuMjIzMSAyMDYuMjczIDQyLjE5OTUgMjA1Ljg4NiA0My40NDUzSDIwNS44NTJDMjA1Ljg1MiA0My40NDUzIDIwNS44ODYgNDMuMTU5MiAyMDUuODg2IDQyLjgwNTZWNDEuMzI0MUgyMDQuMzA0VjQ5Ljg0MjdaIiBmaWxsPSIjNUY1Rjc2Ii8+CjxwYXRoIGQ9Ik0yMDkuNDQgNDUuNTY2NUMyMDkuNDQgNDguMTU5MSAyMTEuNDk0IDUwLjA0NDcgMjE0LjAzNiA1MC4wNDQ3QzIxNi41NzggNTAuMDQ0NyAyMTguNjMyIDQ4LjE1OTEgMjE4LjYzMiA0NS41NjY1QzIxOC42MzIgNDIuOTkwNyAyMTYuNTc4IDQxLjEyMjEgMjE0LjAzNiA0MS4xMjIxQzIxMS40OTQgNDEuMTIyMSAyMDkuNDQgNDIuOTkwNyAyMDkuNDQgNDUuNTY2NVpNMjExLjEwNiA0NS41NjY1QzIxMS4xMDYgNDMuNzk4OCAyMTIuNDM2IDQyLjUzNjIgMjE0LjAzNiA0Mi41MzYyQzIxNS42NTIgNDIuNTM2MiAyMTYuOTY1IDQzLjc5ODggMjE2Ljk2NSA0NS41NjY1QzIxNi45NjUgNDcuMzUxMSAyMTUuNjUyIDQ4LjYzMDUgMjE0LjAzNiA0OC42MzA1QzIxMi40MzYgNDguNjMwNSAyMTEuMTA2IDQ3LjM1MTEgMjExLjEwNiA0NS41NjY1WiIgZmlsbD0iIzVGNUY3NiIvPgo8cGF0aCBkPSJNMjE5LjUwOCA0NS41ODM0QzIxOS41MDggNDguMjI2NSAyMjAuOTU2IDUwLjA0NDcgMjIzLjI2MiA1MC4wNDQ3QzIyNS4zNjYgNTAuMDQ0NyAyMjYuMDU3IDQ4LjQ2MjIgMjI2LjA1NyA0OC40NjIySDIyNi4wOUMyMjYuMDkgNDguNDYyMiAyMjYuMDc0IDQ4LjY5NzkgMjI2LjA3NCA0OS4wMzQ2VjQ5Ljg0MjdIMjI3LjYyMlYzNy45NTdIMjI1Ljk4OVY0MS44OTY1QzIyNS45ODkgNDIuMjE2MyAyMjYuMDIzIDQyLjQ2ODkgMjI2LjAyMyA0Mi40Njg5SDIyNS45ODlDMjI1Ljk4OSA0Mi40Njg5IDIyNS4zMzMgNDEuMTIyMSAyMjMuMzYzIDQxLjEyMjFDMjIxLjEwNyA0MS4xMjIxIDIxOS41MDggNDIuODczIDIxOS41MDggNDUuNTgzNFpNMjIxLjE3NSA0NS41ODM0QzIyMS4xNzUgNDMuNjQ3NCAyMjIuMjg2IDQyLjUzNjIgMjIzLjYzMiA0Mi41MzYyQzIyNS4yNDggNDIuNTM2MiAyMjYuMDQgNDQuMDUxNCAyMjYuMDQgNDUuNTY2NUMyMjYuMDQgNDcuNzM4MyAyMjQuODQ0IDQ4LjYzMDYgMjIzLjU4MiA0OC42MzA2QzIyMi4xNjggNDguNjMwNiAyMjEuMTc1IDQ3LjQzNTMgMjIxLjE3NSA0NS41ODM0WiIgZmlsbD0iIzVGNUY3NiIvPgo8cGF0aCBkPSJNMjI5LjU1MSA0Ni43Nzg2QzIyOS41NTEgNDkuMDM0NiAyMzAuNTQ1IDUwLjA0NDcgMjMyLjQ2NCA1MC4wNDQ3QzIzNC4xMyA1MC4wNDQ3IDIzNS4yNDIgNDguOTMzNiAyMzUuNTk1IDQ4LjA5MThIMjM1LjYyOUMyMzUuNjI5IDQ4LjA5MTggMjM1LjU5NSA0OC4zNjEyIDIzNS41OTUgNDguNzE0N1Y0OS44NDI3SDIzNy4xNzhWNDEuMzI0SDIzNS41NDRWNDUuMzE0QzIzNS41NDQgNDYuOTk3NSAyMzQuNTE4IDQ4LjUyOTUgMjMyLjc4NCA0OC41Mjk1QzIzMS40MiA0OC41Mjk1IDIzMS4xODQgNDcuNTg2OCAyMzEuMTg0IDQ2LjQwODNWNDEuMzI0SDIyOS41NTFWNDYuNzc4NloiIGZpbGw9IiM1RjVGNzYiLz4KPHBhdGggZD0iTTIzOC42MiA0NS41ODMzQzIzOC42MiA0OC4xNDIzIDI0MC41MDUgNTAuMDQ0NyAyNDMuMTgyIDUwLjA0NDdDMjQ1LjQwNCA1MC4wNDQ3IDI0Ni41ODMgNDguNjgxMSAyNDYuNTgzIDQ4LjY4MTFMMjQ1LjkyNiA0Ny40ODU3QzI0NS45MjYgNDcuNDg1NyAyNDQuODgyIDQ4LjYzMDUgMjQzLjMgNDguNjMwNUMyNDEuNTMyIDQ4LjYzMDUgMjQwLjI4NiA0Ny4zMDA2IDI0MC4yODYgNDUuNTY2NUMyNDAuMjg2IDQzLjgxNTcgMjQxLjUzMiA0Mi41MzYyIDI0My4yNSA0Mi41MzYyQzI0NC42OTcgNDIuNTM2MiAyNDUuNjA2IDQzLjUxMjcgMjQ1LjYwNiA0My41MTI3TDI0Ni4zODEgNDIuMzY3OEMyNDYuMzgxIDQyLjM2NzggMjQ1LjMyIDQxLjEyMjEgMjQzLjE4MiA0MS4xMjIxQzI0MC41MDUgNDEuMTIyMSAyMzguNjIgNDMuMDU4MSAyMzguNjIgNDUuNTgzM1oiIGZpbGw9IiM1RjVGNzYiLz4KPHBhdGggZD0iTTI0OC4yODEgNDYuNzI4MkMyNDguMjgxIDQ5LjU3MzMgMjUwLjQ1MyA0OS45MSAyNTEuNTQ3IDQ5LjkxQzI1MS44ODQgNDkuOTEgMjUyLjExOSA0OS44NzY0IDI1Mi4xMTkgNDkuODc2NFY0OC40Mjg2QzI1Mi4xMTkgNDguNDI4NiAyNTEuOTY4IDQ4LjQ2MjIgMjUxLjczMiA0OC40NjIyQzI1MS4xMDkgNDguNDYyMiAyNDkuOTE0IDQ4LjI0MzMgMjQ5LjkxNCA0Ni41NDNWNDIuNzU1MUgyNTEuOTY4VjQxLjQ0MTlIMjQ5LjkxNFYzOC45ODRIMjQ4LjMzMlY0MS40NDE5SDI0Ny4xN1Y0Mi43NTUxSDI0OC4yODFWNDYuNzI4MloiIGZpbGw9IiM1RjVGNzYiLz4KPC9nPgo8ZGVmcz4KPGNsaXBQYXRoIGlkPSJjbGlwMF82ODBfNzkwOSI+CjxyZWN0IHdpZHRoPSIzMDAiIGhlaWdodD0iNTMuOTc0OSIgZmlsbD0id2hpdGUiLz4KPC9jbGlwUGF0aD4KPC9kZWZzPgo8L3N2Zz4K'; if ( ! easy_wp_smtp()->is_pro() ) { $contact_url = 'https://wordpress.org/support/plugin/easy-wp-smtp/'; } else { // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound $contact_url = esc_url( easy_wp_smtp()->get_utm_url( 'https://easywpsmtp.com/contact/', [ 'medium' => 'setup-wizard', 'content' => 'Contact Us' ] ) ); } ?> <style type="text/css"> #easy-wp-smtp-settings-area { visibility: hidden; animation: loadEasyWPSMTPSettingsNoJSView 0s 2s forwards; } @keyframes loadEasyWPSMTPSettingsNoJSView{ to { visibility: visible; } } body { background: #F2F2F4; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; margin: 0; } #easy-wp-smtp-settings-area .easy-wp-smtp-setup-wizard-header { text-align: center; } #easy-wp-smtp-settings-area .easy-wp-smtp-setup-wizard-header h1 { margin: 0; } #easy-wp-smtp-settings-area .easy-wp-smtp-logo { display: inline-block; width: 300px; margin-top: 10px; padding: 0 10px; } #easy-wp-smtp-settings-area .easy-wp-smtp-logo img { width: 100%; height: 100%; } #easy-wp-smtp-settings-error-loading-area { box-sizing: border-box; max-width: 90%; width: auto; margin: 0 auto; background: #fff; border: 1px solid #DADADF; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); padding: 20px 30px; } #easy-wp-smtp-settings-area .easy-wp-smtp-error-footer { text-align: center; margin-top: 20px; margin-bottom: 20px; font-size: 14px; } #easy-wp-smtp-settings-area .easy-wp-smtp-error-footer a { color: #6F6F84; transition: 0.1s; } #easy-wp-smtp-settings-area .easy-wp-smtp-error-footer a:hover { color: #3A3A56; } #easy-wp-smtp-error-js h3 { font-weight: 500; font-size: 24px; line-height: 22px; margin: 0 0 15px; color: #09092C; } #easy-wp-smtp-error-js p.info, #easy-wp-smtp-error-js ul.info { color: #3A3A56; font-size: 16px; line-height: 24px; margin: 0 0 10px; } #easy-wp-smtp-error-js ul.info { margin: -10px 0 20px; } #easy-wp-smtp-error-js a.button { display: inline-block; background-color: #211FA6; color: #ffffff; padding: 12px 20px; font-size: 16px; line-height: 18px; border-radius: 4px; border: none; cursor: pointer; text-decoration: none; margin-top: 7px; } #easy-wp-smtp-error-js a.button:hover, #easy-wp-smtp-error-js a.button:active, #easy-wp-smtp-error-js a.button:focus { background-color: #15137A; } #easy-wp-smtp-error-js a.button:focus { box-shadow: 0 0 0 1px #ffffff, 0 0 0 3px #15137A; } #easy-wp-smtp-error-js .medium-bold { font-weight: 500; } #easy-wp-smtp-nojs-error-message > div { background: rgba(223, 42, 74, 0.05); border-left: 3px solid #DF2A4A; color: #42000C; font-weight: 400; font-size: 14px; line-height: 21px; padding: 15px; text-align: left; } @media (min-width: 782px) { #easy-wp-smtp-settings-area .easy-wp-smtp-logo { margin-top: 60px; padding: 0; } #easy-wp-smtp-settings-error-loading-area { width: 650px; margin-top: 50px; padding: 60px; } #easy-wp-smtp-settings-area .easy-wp-smtp-error-footer { margin-top: 50px; margin-bottom: 50px; } #easy-wp-smtp-error-js p.info { margin: 0 0 20px; } } </style> <!--[if IE]> <style> #easy-wp-smtp-settings-area{ visibility: visible !important; } </style> <![endif]--> <div id="<?php echo esc_attr( $id ); ?>"> <div id="easy-wp-smtp-settings-area" class="easy-wp-smtp-settings-area easywpsmtp-container"> <header class="easy-wp-smtp-setup-wizard-header"> <div class="easy-wp-smtp-logo"> <img src="<?php echo esc_attr( $inline_logo_image ); ?>" alt="<?php esc_attr_e( 'Easy WP SMTP logo', 'easy-wp-smtp' ); ?>" class="easy-wp-smtp-logo-img"> </div> </header> <div id="easy-wp-smtp-settings-error-loading-area-container"> <div id="easy-wp-smtp-settings-error-loading-area"> <div> <div id="easy-wp-smtp-error-js"> <h3><?php esc_html_e( 'Whoops, something\'s not working.', 'easy-wp-smtp' ); ?></h3> <p class="info"><?php esc_html_e( 'It looks like something is preventing JavaScript from loading on your website. Easy WP SMTP requires JavaScript in order to give you the best possible experience.', 'easy-wp-smtp' ); ?></p> <p class="info"> <?php esc_html_e( 'In order to fix this issue, please check each of the items below:', 'easy-wp-smtp' ); ?> </p> <ul class="info"> <li><?php esc_html_e( 'If you are using an ad blocker, please disable it or whitelist the current page.', 'easy-wp-smtp' ); ?></li> <li><?php esc_html_e( 'If you aren\'t already using Chrome, Firefox, Safari, or Edge, then please try switching to one of these popular browsers.', 'easy-wp-smtp' ); ?></li> <li><?php esc_html_e( 'Confirm that your browser is updated to the latest version.', 'easy-wp-smtp' ); ?></li> </ul> <p class="info"> <?php esc_html_e( 'If you\'ve checked each of these details and are still running into issues, then please get in touch with our support team. We’d be happy to help!', 'easy-wp-smtp' ); ?> </p> <div style="display: none;" id="easy-wp-smtp-nojs-error-message"> <div> <strong style="font-weight: 500;" id="easy-wp-smtp-alert-message"></strong> </div> <p style="font-size: 14px;color: #6f6f84;padding-bottom: 15px;"><?php esc_html_e( 'Copy the error message above and paste it in a message to the Easy WP SMTP support team.', 'easy-wp-smtp' ); ?></p> </div> <a href="<?php echo esc_url( $contact_url ); ?>" target="_blank" class="button" rel="noopener noreferrer"> <?php esc_html_e( 'Contact Us', 'easy-wp-smtp' ); ?> </a> </div> </div> </div> <div class="easy-wp-smtp-error-footer"> <?php echo wp_kses_post( $footer ); ?> </div> </div> </div> </div> <?php } /** * Attempt to catch the js error preventing the Vue app from loading and displaying that message for better support. * * @since 2.1.0 */ private function settings_inline_js() { ?> <script type="text/javascript"> window.onerror = function myErrorHandler( errorMsg, url, lineNumber ) { /* Don't try to put error in container that no longer exists post-vue loading */ var message_container = document.getElementById( 'easy-wp-smtp-nojs-error-message' ); if ( ! message_container ) { return false; } var message = document.getElementById( 'easy-wp-smtp-alert-message' ); message.innerHTML = errorMsg; message_container.style.display = 'block'; return false; } </script> <?php } /** * Ajax handler for retrieving the plugin settings. * * @since 2.1.0 */ public function get_settings() { check_ajax_referer( 'easywpsmtp-admin-nonce', 'nonce' ); if ( ! current_user_can( easy_wp_smtp()->get_capability_manage_options() ) ) { wp_send_json_error( esc_html__( 'You don\'t have permission to change options for this WP site!', 'easy-wp-smtp' ) ); } $options = Options::init(); wp_send_json_success( $options->get_all() ); } /** * Ajax handler for starting the Setup Wizard steps. * * @since 2.1.0 */ public function wizard_steps_started() { check_ajax_referer( 'easywpsmtp-admin-nonce', 'nonce' ); if ( ! current_user_can( easy_wp_smtp()->get_capability_manage_options() ) ) { wp_send_json_error( esc_html__( 'You don\'t have permission to change options for this WP site!', 'easy-wp-smtp' ) ); } self::update_stats( [ 'launched_time' => time(), ] ); wp_send_json_success(); } /** * Ajax handler for updating the settings. * * @since 2.1.0 */ public function update_settings() { check_ajax_referer( 'easywpsmtp-admin-nonce', 'nonce' ); if ( ! current_user_can( easy_wp_smtp()->get_capability_manage_options() ) ) { wp_send_json_error(); } $options = Options::init(); $overwrite = ! empty( $_POST['overwrite'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $value = isset( $_POST['value'] ) ? wp_slash( json_decode( wp_unslash( $_POST['value'] ), true ) ) : []; // Cancel summary report email task if summary report email was disabled. if ( ! SummaryReportEmail::is_disabled() && isset( $value['general'][ SummaryReportEmail::SETTINGS_SLUG ] ) && $value['general'][ SummaryReportEmail::SETTINGS_SLUG ] === true ) { ( new SummaryReportEmailTask() )->cancel(); } /** * Before updating settings in Setup Wizard. * * @since 2.1.0 * * @param array $post POST data. */ do_action( 'easy_wp_smtp_admin_setup_wizard_update_settings', $value ); $options->set( $value, false, $overwrite ); wp_send_json_success(); } /** * Prepare mailer options for all mailers. * * @since 2.1.0 * * @return array */ private function prepare_mailer_options() { $data = []; foreach ( easy_wp_smtp()->get_providers()->get_options_all() as $provider ) { $data[ $provider->get_slug() ] = [ 'slug' => $provider->get_slug(), 'title' => $provider->get_title(), 'description' => $provider->get_description(), 'edu_notice' => $provider->get_notice( 'educational' ), 'min_php' => $provider->get_php_version(), 'disabled' => $provider->is_disabled(), 'recommended' => $provider->is_recommended(), ]; } return apply_filters( 'easy_wp_smtp_admin_setup_wizard_prepare_mailer_options', $data ); } /** * AJAX callback for getting the oAuth authorization URL. * * @since 2.1.0 */ public function get_oauth_url() { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh check_ajax_referer( 'easywpsmtp-admin-nonce', 'nonce' ); if ( ! current_user_can( easy_wp_smtp()->get_capability_manage_options() ) ) { wp_send_json_error(); } $data = []; $mailer = ! empty( $_POST['mailer'] ) ? sanitize_text_field( wp_unslash( $_POST['mailer'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $settings = isset( $_POST['settings'] ) ? wp_slash( json_decode( wp_unslash( $_POST['settings'] ), true ) ) : []; if ( empty( $mailer ) ) { wp_send_json_error(); } $settings = array_merge( $settings, [ 'is_setup_wizard_auth' => true ] ); $options = Options::init(); $options->set( [ $mailer => $settings ], false, false ); $data = apply_filters( 'easy_wp_smtp_admin_setup_wizard_get_oauth_url', $data, $mailer ); wp_send_json_success( array_merge( [ 'mailer' => $mailer ], $data ) ); } /** * AJAX callback for removing the oAuth authorization connection. * * @since 2.1.0 */ public function remove_oauth_connection() { check_ajax_referer( 'easywpsmtp-admin-nonce', 'nonce' ); if ( ! current_user_can( easy_wp_smtp()->get_capability_manage_options() ) ) { wp_send_json_error(); } $mailer = ! empty( $_POST['mailer'] ) ? sanitize_text_field( wp_unslash( $_POST['mailer'] ) ) : ''; if ( empty( $mailer ) ) { wp_send_json_error(); } $options = Options::init(); $old_opt = $options->get_all_raw(); foreach ( $old_opt[ $mailer ] as $key => $value ) { // Unset everything except Client ID, Client Secret and Domain. if ( ! in_array( $key, array( 'domain', 'client_id', 'client_secret' ), true ) ) { unset( $old_opt[ $mailer ][ $key ] ); } } $options->set( $old_opt ); wp_send_json_success(); } /** * AJAX callback for installing a plugin. * Has to contain the `slug` POST parameter. * * @since 2.1.0 */ public function install_plugin() { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded check_ajax_referer( 'easywpsmtp-admin-nonce', 'nonce' ); // Check for permissions. if ( ! current_user_can( 'install_plugins' ) ) { wp_send_json_error( esc_html__( 'Could not install the plugin. You don\'t have permission to install plugins.', 'easy-wp-smtp' ) ); } if ( ! current_user_can( 'activate_plugins' ) ) { wp_send_json_error( esc_html__( 'Could not install the plugin. You don\'t have permission to activate plugins.', 'easy-wp-smtp' ) ); } $slug = ! empty( $_POST['slug'] ) ? sanitize_text_field( wp_unslash( $_POST['slug'] ) ) : ''; if ( empty( $slug ) ) { wp_send_json_error( esc_html__( 'Could not install the plugin. Plugin slug is missing.', 'easy-wp-smtp' ) ); } if ( ! in_array( $slug, [ 'wpforms-lite', 'all-in-one-seo-pack' ], true ) ) { wp_send_json_error( esc_html__( 'Could not install the plugin. Plugin is not whitelisted.', 'easy-wp-smtp' ) ); } $url = esc_url_raw( WP::admin_url( 'admin.php?page=' . Area::SLUG . '-setup-wizard' ) ); /* * The `request_filesystem_credentials` function will output a credentials form in case of failure. * We don't want that, since it will break AJAX response. So just hide output with a buffer. */ ob_start(); // phpcs:ignore WPForms.Formatting.EmptyLineAfterAssigmentVariables.AddEmptyLine $creds = request_filesystem_credentials( $url, '', false, false, null ); ob_end_clean(); // Check for file system permissions. if ( false === $creds ) { wp_send_json_error( esc_html__( 'Could not install the plugin. Don\'t have file permission.', 'easy-wp-smtp' ) ); } if ( ! WP_Filesystem( $creds ) ) { wp_send_json_error( esc_html__( 'Could not install the plugin. Don\'t have file permission.', 'easy-wp-smtp' ) ); } // Do not allow WordPress to search/download translations, as this will break JS output. remove_action( 'upgrader_process_complete', [ 'Language_Pack_Upgrader', 'async_upgrade' ], 20 ); // Import the plugin upgrader. Helpers::include_plugin_upgrader(); // Create the plugin upgrader with our custom skin. $installer = new Plugin_Upgrader( new PluginsInstallSkin() ); // Error check. if ( ! method_exists( $installer, 'install' ) || empty( $slug ) ) { wp_send_json_error( esc_html__( 'Could not install the plugin. WP Plugin installer initialization failed.', 'easy-wp-smtp' ) ); } include_once ABSPATH . 'wp-admin/includes/plugin-install.php'; $api = plugins_api( 'plugin_information', [ 'slug' => $slug, 'fields' => [ 'short_description' => false, 'sections' => false, 'requires' => false, 'rating' => false, 'ratings' => false, 'downloaded' => false, 'last_updated' => false, 'added' => false, 'tags' => false, 'compatibility' => false, 'homepage' => false, 'donate_link' => false, ], ] ); if ( is_wp_error( $api ) ) { wp_send_json_error( $api->get_error_message() ); } $installer->install( $api->download_link ); // Flush the cache and return the newly installed plugin basename. wp_cache_flush(); if ( $installer->plugin_info() ) { $plugin_basename = $installer->plugin_info(); // Disable the WPForms redirect after plugin activation. if ( $slug === 'wpforms-lite' ) { update_option( 'wpforms_activation_redirect', true ); } // Disable the AIOSEO redirect after plugin activation. if ( $slug === 'all-in-one-seo-pack' ) { update_option( 'aioseo_activation_redirect', true ); } // Activate the plugin silently. $activated = activate_plugin( $plugin_basename ); if ( ! is_wp_error( $activated ) ) { wp_send_json_success( [ 'slug' => $slug, 'is_installed' => true, 'is_activated' => true, ] ); } else { wp_send_json_success( [ 'slug' => $slug, 'is_installed' => true, 'is_activated' => false, ] ); } } wp_send_json_error( esc_html__( 'Could not install the plugin. WP Plugin installer could not retrieve plugin information.', 'easy-wp-smtp' ) ); } /** * AJAX callback for getting all partner's plugin information. * * @since 2.1.0 */ public function get_partner_plugins_info() { check_ajax_referer( 'easywpsmtp-admin-nonce', 'nonce' ); $contact_form_plugin_already_installed = false; $contact_form_basenames = [ 'wpforms-lite/wpforms.php', 'wpforms/wpforms.php', 'formidable/formidable.php', 'formidable/formidable-pro.php', 'gravityforms/gravityforms.php', 'ninja-forms/ninja-forms.php', ]; $installed_plugins = get_plugins(); foreach ( $installed_plugins as $basename => $plugin_info ) { if ( in_array( $basename, $contact_form_basenames, true ) ) { $contact_form_plugin_already_installed = true; break; } } // Final check if maybe WPForms is already install and active as a MU plugin. if ( class_exists( '\WPForms\WPForms' ) ) { $contact_form_plugin_already_installed = true; } $data = [ 'plugins' => [], 'contact_form_plugin_already_installed' => $contact_form_plugin_already_installed, ]; wp_send_json_success( $data ); } /** * AJAX callback for subscribing an email address to the Easy WP SMTP Drip newsletter. * * @since 2.1.0 */ public function subscribe_to_newsletter() { check_ajax_referer( 'easywpsmtp-admin-nonce', 'nonce' ); $email = ! empty( $_POST['email'] ) ? filter_var( wp_unslash( $_POST['email'] ), FILTER_VALIDATE_EMAIL ) : ''; if ( empty( $email ) ) { wp_send_json_error(); } $body = [ 'email' => base64_encode( $email ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode ]; $wpforms_version_type = $this->get_wpforms_version_type(); if ( ! empty( $wpforms_version_type ) ) { $body['wpforms_version_type'] = $wpforms_version_type; } wp_remote_post( 'https://connect.easywpsmtp.com/subscribe/drip/', [ 'user-agent' => Helpers::get_default_user_agent(), 'body' => $body, ] ); wp_send_json_success(); } /** * Get the WPForms version type if it's installed. * * @since 2.2.0 * * @return false|string Return `false` if WPForms is not installed, otherwise return either `lite` or `pro`. */ private function get_wpforms_version_type() { if ( ! function_exists( 'wpforms' ) ) { return false; } if ( method_exists( wpforms(), 'is_pro' ) ) { $is_wpforms_pro = wpforms()->is_pro(); } else { $is_wpforms_pro = wpforms()->pro; } return $is_wpforms_pro ? 'pro' : 'lite'; } /** * AJAX callback for plugin upgrade, from lite to pro. * * @since 2.1.0 */ public function upgrade_plugin() { check_ajax_referer( 'easywpsmtp-admin-nonce', 'nonce' ); if ( easy_wp_smtp()->is_pro() ) { wp_send_json_success( esc_html__( 'You are already using the Easy WP SMTP PRO version. Please refresh this page and verify your license key.', 'easy-wp-smtp' ) ); } if ( ! current_user_can( 'install_plugins' ) ) { wp_send_json_error( esc_html__( 'You don\'t have the permission to perform this action.', 'easy-wp-smtp' ) ); } $license_key = ! empty( $_POST['license_key'] ) ? sanitize_key( $_POST['license_key'] ) : ''; if ( empty( $license_key ) ) { wp_send_json_error( esc_html__( 'Please enter a valid license key!', 'easy-wp-smtp' ) ); } $url = Connect::generate_url( $license_key, '', add_query_arg( 'upgrade-redirect', '1', self::get_site_url() ) . '#/step/license' ); if ( empty( $url ) ) { wp_send_json_error( esc_html__( 'Upgrade functionality not available!', 'easy-wp-smtp' ) ); } wp_send_json_success( [ 'redirect_url' => $url ] ); } /** * AJAX callback for checking the mailer configuration. * - Send a test email * - Check the domain setup with the Domain Checker API. * * @since 2.1.0 */ public function check_mailer_configuration() { check_ajax_referer( 'easywpsmtp-admin-nonce', 'nonce' ); $options = Options::init(); $mailer = $options->get( 'mail', 'mailer' ); $email = $options->get( 'mail', 'from_email' ); $domain = ''; // Send the test mail. $result = wp_mail( $email, 'Easy WP SMTP Automatic Email Test', TestTab::get_email_message_text(), array( 'X-Mailer-Type:EasyWPSMTP/Admin/SetupWizard/Test', ) ); if ( ! $result ) { $this->update_completed_stat( false ); ( new UsageTracking() )->send_failed_setup_wizard_usage_tracking_data(); wp_send_json_error(); } // Add the optional sending domain parameter. if ( in_array( $mailer, [ 'mailgun', 'sendinblue', 'sendgrid' ], true ) ) { $domain = $options->get( $mailer, 'domain' ); } // Perform the domain checker API test. $domain_checker = new DomainChecker( $mailer, $email, $domain ); if ( $domain_checker->has_errors() ) { $this->update_completed_stat( false ); ( new UsageTracking() )->send_failed_setup_wizard_usage_tracking_data( $domain_checker ); wp_send_json_error(); } $this->update_completed_stat( true ); wp_send_json_success(); } /** * AJAX callback for sending feedback. * * @since 2.1.0 */ public function send_feedback() { check_ajax_referer( 'easywpsmtp-admin-nonce', 'nonce' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $data = ! empty( $_POST['data'] ) ? json_decode( wp_unslash( $_POST['data'] ), true ) : []; $feedback = ! empty( $data['feedback'] ) ? sanitize_textarea_field( $data['feedback'] ) : ''; $permission = ! empty( $data['permission'] ); wp_remote_post( 'https://easywpsmtp.com/wizard-feedback/', [ 'user-agent' => Helpers::get_default_user_agent(), 'body' => [ 'wpforms' => [ 'id' => 2271, 'fields' => [ '1' => $feedback, '2' => $permission ? wp_get_current_user()->user_email : '', '3' => easy_wp_smtp()->get_license_type(), '4' => EasyWPSMTP_PLUGIN_VERSION, ], ], ], ] ); wp_send_json_success(); } /** * Data used for the Vue scripts to display old PHP and WP versions warnings. * * @since 2.1.0 */ private function prepare_versions_data() { global $wp_version; return [ 'php_version' => phpversion(), 'wp_version' => $wp_version, 'wp_version_below_52' => version_compare( $wp_version, '5.2', '<' ), ]; } /** * Remove 'error' from the automatic clearing list of query arguments after page loads. * This will fix the issue with missing oAuth 'error' argument for the Setup Wizard. * * @since 2.1.0 * * @param array $defaults Array of query arguments to be cleared after page load. * * @return array */ public function maybe_disable_automatic_query_args_removal( $defaults ) { if ( ( isset( $_GET['page'] ) && $_GET['page'] === 'easy-wp-smtp-setup-wizard' ) && // phpcs:ignore WordPress.Security.NonceVerification.Recommended ( ! empty( $_GET['error'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended ) { $defaults = array_values( array_diff( $defaults, [ 'error' ] ) ); } return $defaults; } /** * Check if the Setup Wizard should load. * * @since 2.1.0 * * @return bool */ public function should_setup_wizard_load() { return (bool) apply_filters( 'easy_wp_smtp_admin_setup_wizard_load_wizard', true ); } /** * Get the Setup Wizard stats. * - launched_time -> when the Setup Wizard was last launched. * - completed_time -> when the Setup Wizard was last completed. * - was_successful -> if the Setup Wizard was completed successfully. * * @since 2.1.0 * * @return array */ public static function get_stats() { $defaults = [ 'launched_time' => 0, 'completed_time' => 0, 'was_successful' => false, ]; return get_option( self::STATS_OPTION_KEY, $defaults ); } /** * Update the Setup Wizard stats. * * @since 2.1.0 * * @param array $options Take a look at SetupWizard::get_stats method for the possible array keys. */ public static function update_stats( $options ) { update_option( self::STATS_OPTION_KEY, array_merge( self::get_stats(), $options ) , false ); } /** * Update the completed Setup Wizard stats. * * @since 2.1.0 * * @param bool $was_successful If the Setup Wizard was completed successfully. */ private function update_completed_stat( $was_successful ) { self::update_stats( [ 'completed_time' => time(), 'was_successful' => $was_successful, ] ); } /** * Prepare an array of Easy WP SMTP PHP constants in use. * Those that are used in the setup wizard. * * @since 2.1.0 * * @return array */ private function prepare_defined_constants() { $options = Options::init(); if ( ! $options->is_const_enabled() ) { return []; } $constants = [ 'EASY_WP_SMTP_MAIL_FROM' => [ 'mail', 'from_email' ], 'EASY_WP_SMTP_MAIL_FROM_FORCE' => [ 'mail', 'from_email_force' ], 'EASY_WP_SMTP_MAIL_FROM_NAME' => [ 'mail', 'from_name' ], 'EASY_WP_SMTP_MAIL_FROM_NAME_FORCE' => [ 'mail', 'from_name_force' ], 'EASY_WP_SMTP_MAILER' => [ 'mail', 'mailer' ], 'EASY_WP_SMTP_SMTPCOM_API_KEY' => [ 'smtpcom', 'api_key' ], 'EASY_WP_SMTP_SMTPCOM_CHANNEL' => [ 'smtpcom', 'channel' ], 'EASY_WP_SMTP_SENDINBLUE_API_KEY' => [ 'sendinblue', 'api_key' ], 'EASY_WP_SMTP_SENDINBLUE_DOMAIN' => [ 'sendinblue', 'domain' ], 'EASY_WP_SMTP_AMAZONSES_CLIENT_ID' => [ 'amazonses', 'client_id' ], 'EASY_WP_SMTP_AMAZONSES_CLIENT_SECRET' => [ 'amazonses', 'client_secret' ], 'EASY_WP_SMTP_AMAZONSES_REGION' => [ 'amazonses', 'region' ], 'EASY_WP_SMTP_MAILGUN_API_KEY' => [ 'mailgun', 'api_key' ], 'EASY_WP_SMTP_MAILGUN_DOMAIN' => [ 'mailgun', 'domain' ], 'EASY_WP_SMTP_MAILGUN_REGION' => [ 'mailgun', 'region' ], 'EASY_WP_SMTP_OUTLOOK_CLIENT_ID' => [ 'outlook', 'client_id' ], 'EASY_WP_SMTP_OUTLOOK_CLIENT_SECRET' => [ 'outlook', 'client_secret' ], 'EASY_WP_SMTP_SENDGRID_API_KEY' => [ 'sendgrid', 'api_key' ], 'EASY_WP_SMTP_SENDGRID_DOMAIN' => [ 'sendgrid', 'domain' ], 'EASY_WP_SMTP_POSTMARK_SERVER_API_TOKEN' => [ 'postmark', 'server_api_token' ], 'EASY_WP_SMTP_POSTMARK_MESSAGE_STREAM' => [ 'postmark', 'message_stream' ], 'EASY_WP_SMTP_SPARKPOST_API_KEY' => [ 'sparkpost', 'api_key' ], 'EASY_WP_SMTP_SPARKPOST_REGION' => [ 'sparkpost', 'region' ], 'EASY_WP_SMTP_ZOHO_DOMAIN' => [ 'zoho', 'domain' ], 'EASY_WP_SMTP_ZOHO_CLIENT_ID' => [ 'zoho', 'client_id' ], 'EASY_WP_SMTP_ZOHO_CLIENT_SECRET' => [ 'zoho', 'client_secret' ], 'EASY_WP_SMTP_RESEND_API_KEY' => [ 'resend', 'api_key' ], 'EASY_WP_SMTP_SMTP_HOST' => [ 'smtp', 'host' ], 'EASY_WP_SMTP_SMTP_PORT' => [ 'smtp', 'port' ], 'EASY_WP_SMTP_SSL' => [ 'smtp', 'encryption' ], 'EASY_WP_SMTP_SMTP_AUTH' => [ 'smtp', 'auth' ], 'EASY_WP_SMTP_SMTP_AUTOTLS' => [ 'smtp', 'autotls' ], 'EASY_WP_SMTP_SMTP_USER' => [ 'smtp', 'user' ], 'EASY_WP_SMTP_SMTP_PASS' => [ 'smtp', 'pass' ], 'EASY_WP_SMTP_LOGS_ENABLED' => [ 'logs', 'enabled' ], 'EASY_WP_SMTP_SUMMARY_REPORT_EMAIL_DISABLED' => [ 'general', SummaryReportEmail::SETTINGS_SLUG ], ]; $defined = []; foreach ( $constants as $constant => $group_and_key ) { if ( $options->is_const_defined( $group_and_key[0], $group_and_key[1] ) ) { $defined[] = $constant; } } return $defined; } } ConnectionSettings.php 0000777 00000063220 15252174534 0011117 0 ustar 00 <?php namespace EasyWPSMTP\Admin; use EasyWPSMTP\ConnectionInterface; use EasyWPSMTP\Debug; use EasyWPSMTP\Helpers\UI; use EasyWPSMTP\Options; /** * Class ConnectionSettings. * * @since 2.0.0 */ class ConnectionSettings { /** * The Connection object. * * @since 2.0.0 * * @var ConnectionInterface */ private $connection; /** * After process scroll to anchor. * * @since 2.0.0 * * @var false|string */ private $scroll_to = false; /** * Constructor. * * @since 2.0.0 * * @param ConnectionInterface $connection The Connection object. */ public function __construct( $connection ) { $this->connection = $connection; } /** * Display connection settings. * * @since 2.0.0 */ public function display() { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded, Generic.Metrics.NestingLevel.MaxExceeded $mailer = $this->connection->get_mailer_slug(); $connection_options = $this->connection->get_options(); $disabled_email = in_array( $mailer, [ 'zoho' ], true ) ? 'disabled' : ''; $disabled_name = in_array( $mailer, [ 'outlook' ], true ) ? 'disabled' : ''; $disabled_reply_to = in_array( $mailer, [ 'zoho' ], true ) ? 'disabled' : ''; if ( empty( $mailer ) || ! in_array( $mailer, Options::$mailers, true ) ) { $mailer = 'mail'; } $mailer_supported_settings = easy_wp_smtp()->get_providers()->get_options( $mailer )->get_supports(); ?> <div class="easy-wp-smtp-meta-box"> <div class="easy-wp-smtp-meta-box__header"> <div class="easy-wp-smtp-meta-box__heading"> <?php esc_html_e( 'Mailer Settings', 'easy-wp-smtp' ); ?> </div> </div> <div class="easy-wp-smtp-meta-box__content"> <!-- Mailer --> <div id="easy-wp-smtp-setting-row-mailer" class="easy-wp-smtp-row"> <div class="easy-wp-smtp-row__desc"> <p> <?php esc_html_e( 'Choose a mailer or use an SMTP server.', 'easy-wp-smtp' ); ?> <?php if ( ! is_network_admin() && $this->connection->is_primary() ) : ?> <?php printf( wp_kses( /* translators: %s - URL to Setup Wizard. */ __( 'If you’d like a guided setup, run through our <a href="%s">Setup Wizard</a>.', 'easy-wp-smtp' ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], ] ), esc_url( SetupWizard::get_site_url() ) ); ?> <?php endif; ?> </p> <p> <?php printf( wp_kses( /* translators: %s - URL to suggest a mailer form. */ __( 'Don\'t see what you\'re looking for? <a href="%s" target="_blank" rel="noopener noreferrer">Suggest a mailer</a>.', 'easy-wp-smtp' ), [ 'a' => [ 'href' => [], 'rel' => [], 'target' => [], ], ] ), esc_url( easy_wp_smtp()->get_utm_url( 'https://easywpsmtp.com/suggest-a-mailer/', 'Suggest a Mailer' ) ) ); ?> </p> </div> <div class="easy-wp-smtp-mailers-picker"> <?php foreach ( easy_wp_smtp()->get_providers()->get_options_all( $this->connection ) as $provider ) : ?> <div class="easy-wp-smtp-mailers-picker__item"> <?php if ( $provider->is_disabled() ) : ?> <input type="radio" name="easy-wp-smtp[mail][mailer]" disabled class="easy-wp-smtp-mailers-picker__input easy-wp-smtp-educate" id="easy-wp-smtp-setting-mailer-<?php echo esc_attr( $provider->get_slug() ); ?>" value="<?php echo esc_attr( $provider->get_slug() ); ?>" data-title="<?php echo esc_attr( $provider->get_title() ); ?>" /> <?php else : ?> <input id="easy-wp-smtp-setting-mailer-<?php echo esc_attr( $provider->get_slug() ); ?>" type="radio" name="easy-wp-smtp[mail][mailer]" value="<?php echo esc_attr( $provider->get_slug() ); ?>" class="easy-wp-smtp-mailers-picker__input" <?php checked( $provider->get_slug(), $mailer ); ?> <?php disabled( $connection_options->is_const_defined( 'mail', 'mailer' ) ); ?> /> <?php endif; ?> <label for="easy-wp-smtp-setting-mailer-<?php echo esc_attr( $provider->get_slug() ); ?>" class="easy-wp-smtp-mailers-picker__mailer <?php echo 'easy-wp-smtp-mailers-picker__mailer--' . esc_attr( $provider->get_slug() ); ?><?php echo $provider->is_recommended() ? ' easy-wp-smtp-mailers-picker__mailer--recommended' : ''; ?><?php echo $provider->is_disabled() ? ' easy-wp-smtp-mailers-picker__mailer--disabled' : ''; ?>"<?php echo $provider->is_recommended() ? ' data-recommended-text="' . esc_html__( 'Recommended', 'easy-wp-smtp' ) . '"' : ''; ?><?php echo $provider->is_disabled() ? ' data-disabled-text="' . esc_html__( 'Pro', 'easy-wp-smtp' ) . '"' : ''; ?>> <span class="easy-wp-smtp-mailers-picker__image"> <img src="<?php echo esc_url( $provider->get_logo_url() ); ?>" alt="<?php echo esc_attr( $provider->get_title() ); ?>"> </span> <?php if ( in_array( $provider->get_slug(), [ 'mail', 'smtp' ], true ) ) : ?> <span class="easy-wp-smtp-mailers-picker__title"> <?php echo esc_html( $provider->get_title() ); ?> </span> <?php endif; ?> </label> </div> <?php endforeach; ?> </div> </div> <!-- Mailer Options --> <?php foreach ( easy_wp_smtp()->get_providers()->get_options_all( $this->connection ) as $provider ) : ?> <?php $provider_desc = $provider->get_description(); ?> <div class="easy-wp-smtp-mailer-options easy-wp-smtp-mailer-options--<?php echo $mailer === $provider->get_slug() ? 'active' : 'hidden'; ?>" data-mailer="<?php echo esc_attr( $provider->get_slug() ); ?>"> <?php if ( ! $provider->is_disabled() ) : ?> <!-- Mailer Title/Notice/Description --> <div class="easy-wp-smtp-row"> <div class="easy-wp-smtp-row__heading"> <?php echo esc_html( $provider->get_title() ); ?> </div> <?php $provider_edu_notice = $provider->get_notice( 'educational' ); $is_dismissed = (bool) get_user_meta( get_current_user_id(), "easy_wp_smtp_notice_educational_for_{$provider->get_slug()}_dismissed", true ); if ( ! empty( $provider_edu_notice ) && ! $is_dismissed ) : ?> <div class="easy-wp-smtp-notice easy-wp-smtp-notice--info easy-wp-smtp-notice--dismissible" data-notice="educational" data-mailer="<?php echo esc_attr( $provider->get_slug() ); ?>" style="margin: 25px 0 25px 0;"> <a href="#" title="<?php esc_attr_e( 'Dismiss this notice', 'easy-wp-smtp' ); ?>" class="easy-wp-smtp-notice__dismiss js-easy-wp-smtp-mailer-notice-dismiss"> <svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"> <path d="m8 0.25c-4.2812 0-7.75 3.4688-7.75 7.75 0 4.2812 3.4688 7.75 7.75 7.75 4.2812 0 7.75-3.4688 7.75-7.75 0-4.2812-3.4688-7.75-7.75-7.75zm0 14c-3.4688 0-6.25-2.7812-6.25-6.25 0-3.4375 2.7812-6.25 6.25-6.25 3.4375 0 6.25 2.8125 6.25 6.25 0 3.4688-2.8125 6.25-6.25 6.25zm3.1562-8.1875c0.1563-0.125 0.1563-0.375 0-0.53125l-0.6874-0.6875c-0.1563-0.15625-0.4063-0.15625-0.5313 0l-1.9375 1.9375-1.9688-1.9375c-0.125-0.15625-0.375-0.15625-0.53125 0l-0.6875 0.6875c-0.15625 0.15625-0.15625 0.40625 0 0.53125l1.9375 1.9375-1.9375 1.9688c-0.15625 0.12505-0.15625 0.37505 0 0.53125l0.6875 0.6875c0.15625 0.1563 0.40625 0.1563 0.53125 0l1.9688-1.9375 1.9375 1.9375c0.125 0.1563 0.375 0.1563 0.5313 0l0.6874-0.6875c0.1563-0.1562 0.1563-0.4062 0-0.53125l-1.9374-1.9688 1.9374-1.9375z" fill="currentColor"/> </svg> </a> <?php echo wp_kses_post( $provider_edu_notice ); ?> </div> <?php endif; ?> <?php if ( ! empty( $provider_desc ) ) : ?> <div class="easy-wp-smtp-row__desc"> <?php echo wp_kses_post( $provider_desc ); ?> </div> <?php endif; ?> </div> <?php endif; ?> <?php $provider->display_options(); ?> </div> <?php endforeach; ?> </div> </div> <div class="easy-wp-smtp-meta-box"> <div class="easy-wp-smtp-meta-box__header"> <div class="easy-wp-smtp-meta-box__heading"> <?php esc_html_e( 'General Settings', 'easy-wp-smtp' ); ?> </div> </div> <div class="easy-wp-smtp-meta-box__content"> <!-- From Email --> <div id="easy-wp-smtp-setting-row-from_email" class="easy-wp-smtp-row easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-from_email"><?php esc_html_e( 'From Email Address', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"> <div class="easy-wp-smtp-setting-row__sub-row js-easy-wp-smtp-setting-from_email" style="display: <?php echo empty( $mailer_supported_settings['from_email'] ) ? 'none' : 'block'; ?>;"> <input name="easy-wp-smtp[mail][from_email]" type="email" value="<?php echo esc_attr( $connection_options->get( 'mail', 'from_email' ) ); ?>" id="easy-wp-smtp-setting-from_email" spellcheck="false" placeholder="<?php echo esc_attr( easy_wp_smtp()->get_processor()->get_default_email() ); ?>" <?php disabled( $connection_options->is_const_defined( 'mail', 'from_email' ) || ! empty( $disabled_email ) ); ?> /> <p class="desc"> <?php esc_html_e( 'The email address that emails are sent from.', 'easy-wp-smtp' ); ?> </p> <?php if ( ! $disabled_email ) : ?> <p class="desc"> <?php esc_html_e( 'Please note that other plugins can change this. Enable the Force From Email setting below to prevent them from doing so.', 'easy-wp-smtp' ); ?> </p> <?php endif; ?> </div> <div class="easy-wp-smtp-setting-row__sub-row js-easy-wp-smtp-setting-from_email_force" style="display: <?php echo empty( $mailer_supported_settings['from_email_force'] ) ? 'none' : 'block'; ?>;"> <label for="easy-wp-smtp-setting-from_email_force" class="easy-wp-smtp-toggle"> <input name="easy-wp-smtp[mail][from_email_force]" type="checkbox" value="true" id="easy-wp-smtp-setting-from_email_force" <?php checked( true, (bool) $connection_options->get( 'mail', 'from_email_force' ) || ! empty( $disabled_email ) ); ?> <?php disabled( $connection_options->is_const_defined( 'mail', 'from_email_force' ) || ! empty( $disabled_email ) ); ?> /> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--static"> <?php esc_html_e( 'Force From Email', 'easy-wp-smtp' ); ?> </span> </label> <?php if ( ! $disabled_email ) : ?> <p class="desc"> <?php esc_html_e( 'If enabled, your specified From Email Address will be used for all outgoing emails, regardless of values set by other plugins.', 'easy-wp-smtp' ); ?> </p> <?php else : ?> <p class="desc"> <?php esc_html_e( 'Current provider will automatically force From Email to be the email address that you use to set up the OAuth connection above.', 'easy-wp-smtp' ); ?> </p> <?php endif; ?> </div> </div> </div> <!-- From Name --> <div id="easy-wp-smtp-setting-row-from_name" class="easy-wp-smtp-row easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-from_name"><?php esc_html_e( 'From Name', 'easy-wp-smtp' ); ?></label> </div> <div class="easy-wp-smtp-setting-row__field"> <div class="easy-wp-smtp-setting-row__sub-row js-easy-wp-smtp-setting-from_name" style="display: <?php echo empty( $mailer_supported_settings['from_name'] ) ? 'none' : 'block'; ?>;"> <input name="easy-wp-smtp[mail][from_name]" type="text" value="<?php echo esc_attr( $connection_options->get( 'mail', 'from_name' ) ); ?>" id="easy-wp-smtp-setting-from_name" spellcheck="false" placeholder="<?php echo esc_attr( easy_wp_smtp()->get_processor()->get_default_name() ); ?>" <?php disabled( $connection_options->is_const_defined( 'mail', 'from_name' ) || ! empty( $disabled_name ) ); ?> /> <?php if ( empty( $disabled_name ) ) : ?> <p class="desc"> <?php esc_html_e( 'The name that emails are sent from.', 'easy-wp-smtp' ); ?> </p> <?php endif; ?> </div> <div class="easy-wp-smtp-setting-row__sub-row js-easy-wp-smtp-setting-from_name_force" style="display: <?php echo empty( $mailer_supported_settings['from_name_force'] ) ? 'none' : 'block'; ?>;"> <label for="easy-wp-smtp-setting-from_name_force" class="easy-wp-smtp-toggle"> <input name="easy-wp-smtp[mail][from_name_force]" type="checkbox" value="true" id="easy-wp-smtp-setting-from_name_force" <?php checked( true, (bool) $connection_options->get( 'mail', 'from_name_force' ) ); ?> <?php disabled( $connection_options->is_const_defined( 'mail', 'from_name_force' ) || ! empty( $disabled_name ) ); ?> /> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--static"> <?php esc_html_e( 'Force From Name Replacement', 'easy-wp-smtp' ); ?> </span> </label> <?php if ( ! empty( $disabled_name ) ) : ?> <p class="desc"> <?php esc_html_e( 'Current provider doesn\'t support setting and forcing From Name. Emails will be sent on behalf of the account name used to setup the OAuth connection below.', 'easy-wp-smtp' ); ?> </p> <?php else : ?> <p class="desc"> <?php esc_html_e( 'If enabled, your specified From Name will be used for all outgoing emails, regardless of values set by other plugins.', 'easy-wp-smtp' ); ?> </p> <?php endif; ?> </div> </div> </div> <?php if ( $this->connection->is_primary() ) : ?> <!-- Advanced options --> <div id="easy-wp-smtp-setting-row-advanced" class="easy-wp-smtp-row easy-wp-smtp-setting-row"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-advanced"> <?php esc_html_e( 'Advanced Settings', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <label class="easy-wp-smtp-toggle" for="easy-wp-smtp-setting-advanced"> <input name="easy-wp-smtp[mail][advanced]" type="checkbox" value="true" <?php checked( true, $connection_options->get( 'mail', 'advanced' ) ); ?> id="easy-wp-smtp-setting-advanced" /> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--checked"><?php esc_html_e( 'Show', 'easy-wp-smtp' ); ?></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--unchecked"><?php esc_html_e( 'Hide', 'easy-wp-smtp' ); ?></span> </label> </div> </div> <!-- Reply-To Email Address --> <div class="easy-wp-smtp-row easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text<?php echo ! $connection_options->get( 'mail', 'advanced' ) ? ' easy-wp-smtp-hidden' : ''; ?>"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-reply_to_email"> <?php esc_html_e( 'Reply-To Email Address', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <div class="easy-wp-smtp-setting-row__sub-row"> <input name="easy-wp-smtp[mail][reply_to_email]" type="text" value="<?php echo esc_attr( $connection_options->get( 'mail', 'reply_to_email' ) ); ?>" <?php echo $connection_options->is_const_defined( 'mail', 'reply_to_email' ) || ! empty( $disabled_reply_to ) ? 'disabled' : ''; ?> id="easy-wp-smtp-setting-reply_to_email" spellcheck="false" /> <p class="desc"> <?php esc_html_e( '(Optional) This email address will be used in the Reply-To field of emails sent from your site. Leave it blank to use the From Email Address as the reply-to value.', 'easy-wp-smtp' ); ?> </p> </div> <div class="easy-wp-smtp-setting-row__sub-row"> <label class="easy-wp-smtp-toggle" for="easy-wp-smtp-setting-reply_to_replace_from"> <input name="easy-wp-smtp[mail][reply_to_replace_from]" type="checkbox" value="true" id="easy-wp-smtp-setting-reply_to_replace_from" <?php echo $connection_options->is_const_defined( 'mail', 'reply_to_replace_from' ) || ! empty( $disabled_reply_to ) ? 'disabled' : ''; ?> <?php checked( true, $connection_options->get( 'mail', 'reply_to_replace_from' ) ); ?> /> <span class="easy-wp-smtp-toggle__switch"></span> <span class="easy-wp-smtp-toggle__label easy-wp-smtp-toggle__label--static"><?php esc_html_e( 'Substitute Mode', 'easy-wp-smtp' ); ?></span> </label> <p class="desc"> <?php esc_html_e( 'When enabled, this setting will replace the From Email Address with the Reply-To Email Address if the From Email Address is found in the reply-to header. This can prevent conflicts with other plugins that specify their own reply-to email addresses.', 'easy-wp-smtp' ); ?> </p> <p class="desc"> <?php esc_html_e( 'If no Reply-To Email Address has been set or if the reply-to header of an email is empty, this setting has no effect.', 'easy-wp-smtp' ); ?> </p> </div> </div> </div> <!-- BCC Email Address --> <div class="easy-wp-smtp-row easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text<?php echo ! $connection_options->get( 'mail', 'advanced' ) ? ' easy-wp-smtp-hidden' : ''; ?>"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-bcc_emails"> <?php esc_html_e( 'BCC Email Address', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <input name="easy-wp-smtp[mail][bcc_emails]" type="text" value="<?php echo esc_attr( $connection_options->get( 'mail', 'bcc_emails' ) ); ?>" <?php echo $connection_options->is_const_defined( 'mail', 'bcc_emails' ) ? 'disabled' : ''; ?> id="easy-wp-smtp-setting-bcc_emails" spellcheck="false" /> <p class="desc"> <?php esc_html_e( '(Optional) This email address will be used in the BCC field of all outgoing emails. You can enter multiple email addresses separated by commas. Please use this setting carefully, as the email address(es) entered above will be included on every email your site sends.', 'easy-wp-smtp' ); ?> </p> </div> </div> <!-- Don't Replace "From" Field --> <div class="easy-wp-smtp-row easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text<?php echo ! $connection_options->get( 'mail', 'advanced' ) ? ' easy-wp-smtp-hidden' : ''; ?>"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-from_email_force_exclude_emails"> <?php esc_html_e( 'Don\'t Replace in From Field', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <input name="easy-wp-smtp[mail][from_email_force_exclude_emails]" type="text" value="<?php echo esc_attr( $connection_options->get( 'mail', 'from_email_force_exclude_emails' ) ); ?>" <?php echo $connection_options->is_const_defined( 'mail', 'from_email_force_exclude_emails' ) ? 'disabled' : ''; ?> id="easy-wp-smtp-setting-from_email_force_exclude_emails" spellcheck="false" /> <p class="desc"> <?php esc_html_e( 'Comma separated emails list. (Example value: email1@domain.com, email2@domain.com)', 'easy-wp-smtp' ); ?> </p> <p class="desc"> <?php esc_html_e( '(Optional) This option is useful when you are using several email aliases on your SMTP server. If you don\'t want your aliases to be replaced by the address specified in From Email Address setting, enter them in this field.', 'easy-wp-smtp' ); ?> </p> </div> </div> <!-- Return Path --> <div class="easy-wp-smtp-row easy-wp-smtp-setting-row easy-wp-smtp-setting-row--text<?php echo ! $connection_options->get( 'mail', 'advanced' ) ? ' easy-wp-smtp-hidden' : ''; ?> js-easy-wp-smtp-setting-return-path" style="display: <?php echo empty( $mailer_supported_settings['return_path'] ) ? 'none' : 'flex'; ?>;"> <div class="easy-wp-smtp-setting-row__label"> <label for="easy-wp-smtp-setting-return_path"> <?php esc_html_e( 'Return Path', 'easy-wp-smtp' ); ?> </label> </div> <div class="easy-wp-smtp-setting-row__field"> <?php UI::toggle( [ 'name' => 'easy-wp-smtp[mail][return_path]', 'id' => 'easy-wp-smtp-setting-return_path', 'value' => 'true', 'checked' => (bool) $connection_options->get( 'mail', 'return_path' ), 'disabled' => $connection_options->is_const_defined( 'mail', 'return_path' ), ] ); ?> <p class="desc"> <?php esc_html_e( 'Return Path specifies the address that should receive non-delivery notices (bounce messages).', 'easy-wp-smtp' ); ?><br/> <?php esc_html_e( 'If this option is disabled, bounce messages may not reach you.', 'easy-wp-smtp' ); ?> </p> </div> </div> <?php endif; ?> </div> </div> <?php } /** * Process connection settings. Should be called before options save. * * @since 2.0.0 * * @param array $data Connection data. * @param array $old_data Old connection data. */ public function process( $data, $old_data ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded, Generic.Metrics.CyclomaticComplexity.TooHigh // When checkbox is unchecked - it's not submitted at all, so we need to define its default false value. if ( ! isset( $data['mail']['from_email_force'] ) ) { $data['mail']['from_email_force'] = false; } if ( ! isset( $data['mail']['from_name_force'] ) ) { $data['mail']['from_name_force'] = false; } if ( ! isset( $data['mail']['return_path'] ) ) { $data['mail']['return_path'] = false; } if ( ! isset( $data['mail']['advanced'] ) ) { $data['mail']['advanced'] = false; } if ( ! isset( $data['mail']['reply_to_replace_from'] ) ) { $data['mail']['reply_to_replace_from'] = false; } if ( ! isset( $data['smtp']['autotls'] ) ) { $data['smtp']['autotls'] = false; } if ( ! isset( $data['smtp']['auth'] ) ) { $data['smtp']['auth'] = false; } if ( ! isset( $data['mailersend']['has_pro_plan'] ) ) { $data['mailersend']['has_pro_plan'] = false; } // When switching mailers. if ( ! empty( $old_data['mail']['mailer'] ) && ! empty( $data['mail']['mailer'] ) && $old_data['mail']['mailer'] !== $data['mail']['mailer'] ) { // Remove all debug messages when switching mailers. Debug::clear(); // Save correct from email address if Zoho mailer is already configured. if ( in_array( $data['mail']['mailer'], [ 'zoho' ], true ) && ! empty( $old_data[ $data['mail']['mailer'] ]['user_details']['email'] ) ) { $data['mail']['from_email'] = $old_data[ $data['mail']['mailer'] ]['user_details']['email']; } } // Prevent redirect to setup wizard from settings page after successful auth. if ( ! empty( $data['mail']['mailer'] ) && in_array( $data['mail']['mailer'], [ 'gmail', 'outlook', 'zoho' ], true ) ) { $data[ $data['mail']['mailer'] ]['is_setup_wizard_auth'] = false; } /** * Filters connection data. * * @since 2.9.0 * * @param array $data Connection data. * @param array $old_data Old connection data. */ return apply_filters( 'easy_wp_smtp_admin_connection_settings_process_data', $data, $old_data ); return $data; } /** * Post process connection settings. Should be called after options save. * * @since 2.0.0 * * @param array $data Connection data. * @param array $old_data Old connection data. */ public function post_process( $data, $old_data ) { // When switching mailers. if ( ! empty( $old_data['mail']['mailer'] ) && ! empty( $data['mail']['mailer'] ) && $old_data['mail']['mailer'] !== $data['mail']['mailer'] ) { // Save correct from email address if Outlook mailer is already configured. if ( $data['mail']['mailer'] === 'outlook' ) { $auth = easy_wp_smtp()->get_providers()->get_auth( $data['mail']['mailer'], $this->connection ); $user_info = ! $auth->is_auth_required() ? $auth->get_user_info() : false; if ( ! empty( $user_info['email'] ) && is_email( $user_info['email'] ) !== false && ( empty( $data['mail']['from_email'] ) || $data['mail']['from_email'] !== $user_info['email'] ) ) { $data['mail']['from_email'] = $user_info['email']; $this->connection->get_options()->set( $data, false, false ); } } } } /** * Get connection settings admin page URL. * * @since 2.0.0 * * @return string */ public function get_admin_page_url() { /** * Filters connection settings admin page URL. * * @since 2.0.0 * * @param string $admin_page_url Connection settings admin page URL. * @param ConnectionInterface $connection The Connection object. */ return apply_filters( 'easy_wp_smtp_admin_connection_settings_get_admin_page_url', easy_wp_smtp()->get_admin()->get_admin_page_url(), $this->connection ); } /** * Get after process scroll to anchor. Returns `false` if scroll is not needed. * * @since 2.0.0 */ public function get_scroll_to() { return $this->scroll_to; } } Notifications.php 0000777 00000032740 15252174534 0010113 0 ustar 00 <?php namespace EasyWPSMTP\Admin; use EasyWPSMTP\Helpers\Helpers; use EasyWPSMTP\Options; use EasyWPSMTP\WP; /** * Notifications. * * @since 2.0.0 */ class Notifications { /** * Source of notifications content. * * @since 2.0.0 * * @var string */ const SOURCE_URL = 'https://easywpsmtpapi.com/feeds/v1/notifications'; /** * The WP option key for storing the notification options. * * @since 2.0.0 * * @var string */ const OPTION_KEY = 'easy_wp_smtp_notifications'; /** * Option value. * * @since 2.0.0 * * @var bool|array */ public $option = false; /** * Initialize class. * * @since 2.0.0 */ public function init() { $this->hooks(); } /** * Register hooks. * * @since 2.0.0 */ public function hooks() { add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] ); add_action( 'easy_wp_smtp_admin_pages_before_content', [ $this, 'output' ] ); add_action( 'wp_ajax_easy_wp_smtp_notification_dismiss', [ $this, 'dismiss' ] ); } /** * Check if notifications are enabled. * * @since 2.8.0 * * @return bool */ public function is_enabled() { return ! Options::init()->get( 'general', 'am_notifications_hidden' ); } /** * Check if user has access and is enabled. * * @since 2.0.0 * * @return bool */ public function has_access() { $access = false; if ( current_user_can( easy_wp_smtp()->get_capability_manage_options() ) && $this->is_enabled() ) { $access = true; } return apply_filters( 'easy_wp_smtp_admin_notifications_has_access', $access ); } /** * Get option value. * * @since 2.0.0 * * @param bool $cache Reference property cache if available. * * @return array */ public function get_option( $cache = true ) { if ( $this->option && $cache ) { return $this->option; } $option = get_option( self::OPTION_KEY, [] ); $this->option = [ 'update' => ! empty( $option['update'] ) ? $option['update'] : 0, 'events' => ! empty( $option['events'] ) ? $option['events'] : [], 'feed' => ! empty( $option['feed'] ) ? $option['feed'] : [], 'dismissed' => ! empty( $option['dismissed'] ) ? $option['dismissed'] : [], ]; return $this->option; } /** * Fetch notifications from feed. * * @since 2.0.0 * * @return array */ protected function fetch_feed() { $feed_url = self::SOURCE_URL . '/' . easy_wp_smtp()->get_license_type(); $response = wp_remote_get( $feed_url, [ 'user-agent' => Helpers::get_default_user_agent(), ] ); if ( is_wp_error( $response ) ) { return []; } $body = wp_remote_retrieve_body( $response ); if ( empty( $body ) ) { return []; } return $this->verify( json_decode( $body, true ) ); } /** * Verify notification data before it is saved. * * @since 2.0.0 * * @param array $notifications Array of notification items to verify. * * @return array */ protected function verify( $notifications ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh $data = []; if ( ! is_array( $notifications ) || empty( $notifications ) ) { return $data; } $option = $this->get_option(); foreach ( $notifications as $notification ) { // The message should never be empty, if they are, ignore. if ( empty( $notification['content'] ) ) { continue; } // Ignore if license type does not match. if ( ! in_array( easy_wp_smtp()->get_license_type(), $notification['type'], true ) ) { continue; } // Ignore if expired. if ( ! empty( $notification['end'] ) && time() > strtotime( $notification['end'] ) ) { continue; } // Ignore if notification has already been dismissed. if ( ! empty( $option['dismissed'] ) && in_array( $notification['id'], $option['dismissed'] ) ) { // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict continue; } // Ignore if notification existed before installing WPForms. // Prevents bombarding the user with notifications after activation. $activated = get_option( 'easy_wp_smtp_activated_time' ); if ( ! empty( $activated ) && ! empty( $notification['start'] ) && $activated > strtotime( $notification['start'] ) ) { continue; } $data[] = $notification; } return $data; } /** * Verify saved notification data for active notifications. * * @since 2.0.0 * * @param array $notifications Array of notification items to verify. * * @return array */ protected function verify_active( $notifications ) { if ( ! is_array( $notifications ) || empty( $notifications ) ) { return []; } // Remove notifications that are not active. foreach ( $notifications as $key => $notification ) { if ( ( ! empty( $notification['start'] ) && time() < strtotime( $notification['start'] ) ) || ( ! empty( $notification['end'] ) && time() > strtotime( $notification['end'] ) ) ) { unset( $notifications[ $key ] ); } } return $notifications; } /** * Get notification data. * * @since 2.0.0 * @since 2.2.0 Make the AS a recurring task. * * @return array */ public function get() { if ( ! $this->has_access() ) { return []; } $option = $this->get_option(); $events = ! empty( $option['events'] ) ? $this->verify_active( $option['events'] ) : []; $feed = ! empty( $option['feed'] ) ? $this->verify_active( $option['feed'] ) : []; return array_merge( $events, $feed ); } /** * Get the update notifications interval. * * @since 2.2.0 * * @return int */ public function get_notification_update_task_interval() { /** * Filters the interval for the notifications update task. * * @since 2.2.0 * * @param int $interval The interval in seconds. Default to a day (in seconds). */ return (int) apply_filters( 'easy_wp_smtp_admin_notifications_get_notification_update_task_interval', DAY_IN_SECONDS ); } /** * Get notification count. * * @since 2.0.0 * * @return int */ public function get_count() { return count( $this->get() ); } /** * Add a manual notification event. * * @since 2.0.0 * * @param array $notification Notification data. */ public function add( $notification ) { if ( empty( $notification['id'] ) ) { return; } $option = $this->get_option(); if ( in_array( $notification['id'], $option['dismissed'] ) ) { // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict return; } foreach ( $option['events'] as $item ) { if ( $item['id'] === $notification['id'] ) { return; } } $notification = $this->verify( [ $notification ] ); update_option( self::OPTION_KEY, [ 'update' => $option['update'], 'feed' => $option['feed'], 'events' => array_merge( $notification, $option['events'] ), 'dismissed' => $option['dismissed'], ] ); } /** * Update notification data from feed. * * @since 2.0.0 */ public function update() { $option = $this->get_option(); // Bail if feed was updated less than an interval ago. if ( time() - (int) $option['update'] < $this->get_notification_update_task_interval() ) { return; } $feed = $this->fetch_feed(); update_option( self::OPTION_KEY, [ 'update' => time(), 'feed' => $feed, 'events' => $option['events'], 'dismissed' => $option['dismissed'], ] ); } /** * Admin area assets. * * @since 2.0.0 * * @param string $hook Hook suffix for the current admin page. */ public function enqueue_assets( $hook ) { if ( strpos( $hook, Area::SLUG ) === false ) { return; } if ( ! $this->has_access() ) { return; } $notifications = $this->get(); if ( empty( $notifications ) ) { return; } wp_enqueue_style( 'easy-wp-smtp-admin-notifications', easy_wp_smtp()->assets_url . '/css/admin-notifications.min.css', [], EasyWPSMTP_PLUGIN_VERSION ); wp_enqueue_script( 'easy-wp-smtp-admin-notifications', easy_wp_smtp()->assets_url . '/js/smtp-notifications' . WP::asset_min() . '.js', [ 'jquery' ], EasyWPSMTP_PLUGIN_VERSION, true ); } /** * Output notifications. * * @since 2.0.0 */ public function output() { // phpcs:ignore Generic.Metrics.NestingLevel.MaxExceeded $notifications = $this->get(); if ( empty( $notifications ) ) { return; } $notifications_html = ''; $current_class = ' current'; $content_allowed_tags = [ 'em' => [], 'i' => [], 'strong' => [], 'span' => [ 'style' => [], ], 'a' => [ 'href' => [], 'target' => [], 'rel' => [], ], 'br' => [], 'p' => [ 'id' => [], 'class' => [], ], ]; foreach ( $notifications as $notification ) { // Buttons HTML. $buttons_html = ''; if ( ! empty( $notification['btns'] ) && is_array( $notification['btns'] ) ) { foreach ( $notification['btns'] as $btn_type => $btn ) { if ( empty( $btn['text'] ) ) { continue; } $buttons_html .= sprintf( '<a href="%1$s" class="easy-wp-smtp-btn easy-wp-smtp-btn--sm easy-wp-smtp-btn--%2$s"%3$s>%4$s</a>', ! empty( $btn['url'] ) ? esc_url( $btn['url'] ) : '', $btn_type === 'main' ? 'primary' : 'secondary', ! empty( $btn['target'] ) && $btn['target'] === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '', sanitize_text_field( $btn['text'] ) ); } $buttons_html = ! empty( $buttons_html ) ? '<div class="easy-wp-smtp-notifications-buttons">' . $buttons_html . '</div>' : ''; } // Notification HTML. $notifications_html .= sprintf( '<div class="easy-wp-smtp-notifications-message%5$s" data-message-id="%4$s"> <h3 class="easy-wp-smtp-notifications-title">%1$s</h3> <div class="easy-wp-smtp-notifications-content">%2$s</div> %3$s </div>', ! empty( $notification['title'] ) ? sanitize_text_field( $notification['title'] ) : '', ! empty( $notification['content'] ) ? wp_kses( wpautop( $notification['content'] ), $content_allowed_tags ) : '', $buttons_html, ! empty( $notification['id'] ) ? esc_attr( sanitize_text_field( $notification['id'] ) ) : 0, $current_class ); // Only first notification is current. $current_class = ''; } ?> <div id="easy-wp-smtp-notifications"> <div class="easy-wp-smtp-notifications-header"> <div class="easy-wp-smtp-notifications-bell"> <svg width="17" height="19" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M16.434 13.012c-.668-.739-1.97-1.828-1.97-5.45 0-2.707-1.898-4.886-4.5-5.449v-.738C9.965.777 9.474.25 8.876.25 8.242.25 7.75.777 7.75 1.375v.738c-2.602.563-4.5 2.742-4.5 5.45 0 3.62-1.3 4.71-1.969 5.449-.21.21-.316.492-.281.738 0 .598.422 1.125 1.125 1.125H15.59c.703 0 1.125-.527 1.16-1.125 0-.246-.105-.527-.316-.738zm-13.079.175c.739-.949 1.547-2.601 1.583-5.59v-.035c0-2.144 1.757-3.937 3.937-3.937 2.145 0 3.938 1.793 3.938 3.938 0 .035-.036.035-.036.035.036 2.988.844 4.64 1.582 5.59H3.355zm5.52 5.063c1.23 0 2.215-.984 2.215-2.25H6.625c0 1.266.984 2.25 2.25 2.25z" fill="#DF2A4A"/> </svg> </div> <div class="easy-wp-smtp-notifications-title"><?php esc_html_e( 'Notifications', 'easy-wp-smtp' ); ?></div> </div> <div class="easy-wp-smtp-notifications-body"> <a class="dismiss" title="<?php echo esc_attr__( 'Dismiss this message', 'easy-wp-smtp' ); ?>"> <svg width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M8 .25A7.749 7.749 0 0 0 .25 8 7.749 7.749 0 0 0 8 15.75 7.749 7.749 0 0 0 15.75 8 7.749 7.749 0 0 0 8 .25zm0 14A6.228 6.228 0 0 1 1.75 8 6.248 6.248 0 0 1 8 1.75c3.438 0 6.25 2.813 6.25 6.25A6.248 6.248 0 0 1 8 14.25zm3.156-8.188c.156-.125.156-.375 0-.53l-.687-.688c-.156-.157-.406-.157-.531 0L8 6.78 6.031 4.844c-.125-.157-.375-.157-.531 0l-.688.687c-.156.157-.156.407 0 .532L6.75 8 4.812 9.969c-.156.125-.156.375 0 .531l.688.688c.156.156.406.156.531 0L8 9.25l1.938 1.938c.124.156.374.156.53 0l.688-.688c.156-.156.156-.406 0-.531L9.22 8l1.937-1.938z" fill="currentColor"/> </svg> </a> <?php if ( count( $notifications ) > 1 ) : ?> <div class="navigation"> <a class="prev"> <span class="screen-reader-text"><?php esc_attr_e( 'Previous message', 'easy-wp-smtp' ); ?></span> <span aria-hidden="true">‹</span> </a> <a class="next"> <span class="screen-reader-text"><?php esc_attr_e( 'Next message', 'easy-wp-smtp' ); ?>"></span> <span aria-hidden="true">›</span> </a> </div> <?php endif; ?> <div class="easy-wp-smtp-notifications-messages"> <?php echo $notifications_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> </div> </div> </div> <?php } /** * Dismiss notification via AJAX. * * @since 2.0.0 */ public function dismiss() { // Run a security check. check_ajax_referer( 'easy-wp-smtp-admin', 'nonce' ); // Check for access and required param. if ( ! current_user_can( easy_wp_smtp()->get_capability_manage_options() ) || empty( $_POST['id'] ) ) { wp_send_json_error(); } $id = sanitize_text_field( wp_unslash( $_POST['id'] ) ); $option = $this->get_option(); $type = is_numeric( $id ) ? 'feed' : 'events'; $option['dismissed'][] = $id; $option['dismissed'] = array_unique( $option['dismissed'] ); // Remove notification. if ( is_array( $option[ $type ] ) && ! empty( $option[ $type ] ) ) { foreach ( $option[ $type ] as $key => $notification ) { if ( $notification['id'] == $id ) { // phpcs:ignore WordPress.PHP.StrictComparisons unset( $option[ $type ][ $key ] ); break; } } } update_option( self::OPTION_KEY, $option ); wp_send_json_success(); } } ProductForm/ComponentTrait.php 0000777 00000001315 15252227404 0012501 0 ustar 00 <?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; } } ProductForm/FormFactory.php 0000777 00000016476 15252227404 0012004 0 ustar 00 <?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() ); } } } ProductForm/Tab.php 0000777 00000002322 15252227404 0010240 0 ustar 00 <?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 ) ) ); } } } ProductForm/Component.php 0000777 00000005602 15252227404 0011500 0 ustar 00 <?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 ); } ) ); } } ProductForm/Section.php 0000777 00000002234 15252227404 0011140 0 ustar 00 <?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 ) ) ); } } } ProductForm/Subsection.php 0000777 00000000304 15252227404 0011646 0 ustar 00 <?php /** * Handles product form SubSection related methods. */ namespace Automattic\WooCommerce\Internal\Admin\ProductForm; /** * SubSection class. */ class Subsection extends Component {} ProductForm/Field.php 0000777 00000002422 15252227404 0010556 0 ustar 00 <?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 ) ) ); } } } WCAdminAssets.php 0000777 00000043120 15252227404 0007734 0 ustar 00 <?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' ); } } Agentic/AgenticController.php 0000777 00000003616 15252227404 0012265 0 ustar 00 <?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; } } Agentic/AgenticCommerceIntegration.php 0000777 00000002561 15252227404 0014076 0 ustar 00 <?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(); } } Agentic/AgenticWebhookPayloadBuilder.php 0000777 00000013244 15252227404 0014357 0 ustar 00 <?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 ); } } Agentic/AgenticSettingsPage.php 0000777 00000022570 15252227404 0012537 0 ustar 00 <?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 > 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 > Settings > Advanced > 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 ); } } Agentic/AgenticWebhookManager.php 0000777 00000020356 15252227404 0013033 0 ustar 00 <?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(); } } } ShippingLabelBannerDisplayRules.php 0000777 00000007206 15252227404 0013504 0 ustar 00 <?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; } } Emails/EmailListingRestController.php 0000777 00000013276 15252227404 0013775 0 ustar 00 <?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 ) ); } } Homescreen.php 0000777 00000021234 15252227404 0007361 0 ustar 00 <?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; } } Onboarding/OnboardingProfile.php 0000777 00000003773 15252227404 0012766 0 ustar 00 <?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; } } Onboarding/OnboardingSync.php 0000777 00000010013 15252227404 0012263 0 ustar 00 <?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() ); } } Onboarding/OnboardingProducts.php 0000777 00000012534 15252227404 0013164 0 ustar 00 <?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, ); } } Onboarding/OnboardingSetupWizard.php 0000777 00000025235 15252227404 0013644 0 ustar 00 <?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(); } } Onboarding/OnboardingIndustries.php 0000777 00000005622 15252227404 0013512 0 ustar 00 <?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; } } Onboarding/OnboardingHelper.php 0000777 00000011564 15252227404 0012602 0 ustar 00 <?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; } } Onboarding/OnboardingMailchimp.php 0000777 00000002301 15252227404 0013253 0 ustar 00 <?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(); } } } Onboarding/Onboarding.php 0000777 00000001142 15252227404 0011431 0 ustar 00 <?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(); } } Onboarding/OnboardingJetpack.php 0000777 00000003464 15252227404 0012744 0 ustar 00 <?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; } } BlockTemplates/BlockTemplate.php 0000777 00000001400 15252227404 0012721 0 ustar 00 <?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 ); } } BlockTemplates/Block.php 0000777 00000001356 15252227404 0011237 0 ustar 00 <?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 ); } } BlockTemplates/AbstractBlockTemplate.php 0000777 00000006740 15252227404 0014421 0 ustar 00 <?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(), ); } } BlockTemplates/BlockTemplateLogger.php 0000777 00000034344 15252227404 0014076 0 ustar 00 <?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()})"; } } BlockTemplates/BlockFormattedTemplateTrait.php 0000777 00000003335 15252227404 0015604 0 ustar 00 <?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; } } BlockTemplates/BlockContainerTrait.php 0000777 00000024122 15252227404 0014102 0 ustar 00 <?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, ); } } BlockTemplates/AbstractBlock.php 0000777 00000021724 15252227404 0012724 0 ustar 00 <?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; } } WCPayPromotion/Init.php 0000777 00000014012 15252227404 0011062 0 ustar 00 <?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 ); } } WCPayPromotion/DefaultPromotions.php 0000777 00000005134 15252227404 0013642 0 ustar 00 <?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 ) ); } } WCPayPromotion/WCPaymentGatewayPreInstallWCPayPromotion.php 0000777 00000005711 15252227404 0020167 0 ustar 00 <?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']; } } WCPayPromotion/WCPayPromotionDataSourcePoller.php 0000777 00000002567 15252227404 0016216 0 ustar 00 <?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; } } CouponsMovedTrait.php 0000777 00000004646 15252227404 0010726 0 ustar 00 <?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 ); } } SiteHealth.php 0000777 00000004502 15252227404 0007322 0 ustar 00 <?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; } } Analytics.php 0000777 00000027443 15252227404 0007230 0 ustar 00 <?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' ); } } Marketing/MarketingSpecs.php 0000777 00000005037 15252227404 0012134 0 ustar 00 <?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; } } Coupons.php 0000777 00000005560 15252227404 0006723 0 ustar 00 <?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 ); } } EmailImprovements/EmailImprovements.php 0000777 00000017216 15252227404 0014376 0 ustar 00 <?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 ); } } Settings.php 0000777 00000035030 15252227404 0007070 0 ustar 00 <?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; } } SystemStatusReport.php 0000777 00000013546 15252227404 0011164 0 ustar 00 <?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 } } WCAdminUser.php 0000777 00000012416 15252227404 0007414 0 ustar 00 <?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; } } WcPayWelcomePage.php 0000777 00000014521 15252227404 0010426 0 ustar 00 <?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(); } } WCAdminSharedSettings.php 0000777 00000004120 15252227404 0011416 0 ustar 00 <?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 ); } ); } } } Loader.php 0000777 00000005276 15252227404 0006507 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin; use Automattic\WooCommerce\Admin\DeprecatedClassFacade; use Automattic\WooCommerce\Admin\Features\Features; use Automattic\WooCommerce\Internal\Admin\WCAdminAssets; /** * Loader Class. * * @deprecated since 6.3.0, use WooCommerce\Internal\Admin\Loader. */ class Loader extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Loader'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '6.3.0'; /** * Returns if a specific wc-admin feature is enabled. * * @param string $feature Feature slug. * @return bool Returns true if the feature is enabled. * * @deprecated since 5.0.0, use Features::is_enabled( $feature ) */ public static function is_feature_enabled( $feature ) { wc_deprecated_function( 'is_feature_enabled', '5.0', '\Automattic\WooCommerce\Internal\Features\Features::is_enabled()' ); return Features::is_enabled( $feature ); } /** * Returns true if we are on a JS powered admin page or * a "classic" (non JS app) powered admin page (an embedded page). * * @deprecated 6.3.0 */ public static function is_admin_or_embed_page() { wc_deprecated_function( 'is_admin_or_embed_page', '6.3', '\Automattic\WooCommerce\Admin\PageController::is_admin_or_embed_page()' ); return PageController::is_admin_or_embed_page(); } /** * Returns true if we are on a JS powered admin page. * * @deprecated 6.3.0 */ public static function is_admin_page() { wc_deprecated_function( 'is_admin_page', '6.3', '\Automattic\WooCommerce\Admin\PageController::is_admin_page()' ); return PageController::is_admin_page(); } /** * Returns true if we are on a "classic" (non JS app) powered admin page. * * @deprecated 6.3.0 */ public static function is_embed_page() { wc_deprecated_function( 'is_embed_page', '6.3', '\Automattic\WooCommerce\Admin\PageController::is_embed_page()' ); return PageController::is_embed_page(); } /** * 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. * * @deprecated since 6.3.0, use WCAdminAssets::should_use_minified_js_file( $script_debug ) */ public static function should_use_minified_js_file( $script_debug ) { // Bail if WC isn't initialized (This can be called from WCAdmin's entrypoint). if ( ! defined( 'WC_ABSPATH' ) ) { return; } return WCAdminAssets::should_use_minified_js_file( $script_debug ); } } Schedulers/ImportInterface.php 0000777 00000001306 15252227404 0012463 0 ustar 00 <?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(); } Schedulers/CustomersScheduler.php 0000777 00000007055 15252227404 0013222 0 ustar 00 <?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 ); } } } Schedulers/MailchimpScheduler.php 0000777 00000010166 15252227404 0013136 0 ustar 00 <?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 ); } } Schedulers/ImportScheduler.php 0000777 00000011456 15252227404 0012510 0 ustar 00 <?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 ) ); } } Schedulers/OrdersScheduler.php 0000777 00000056727 15252227404 0012506 0 ustar 00 <?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 ); } } Logging/LogHandlerFileV2.php 0000777 00000017150 15252227404 0011710 0 ustar 00 <?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; } } Logging/FileV2/SearchListTable.php 0000777 00000013173 15252227404 0012762 0 ustar 00 <?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']; } } Logging/FileV2/FileListTable.php 0000777 00000017357 15252227404 0012444 0 ustar 00 <?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( ' – <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']; } } Logging/FileV2/FileController.php 0000777 00000045453 15252227404 0012702 0 ustar 00 <?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 ); } } Logging/FileV2/File.php 0000777 00000032670 15252227404 0010633 0 ustar 00 <?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; } } Logging/FileV2/FileExporter.php 0000777 00000007471 15252227404 0012365 0 ustar 00 <?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 ); } } Logging/PageController.php 0000777 00000054326 15252227404 0011607 0 ustar 00 <?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 = ' '; } $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 } } } Logging/Settings.php 0000777 00000040664 15252227404 0010467 0 ustar 00 <?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; } } CategoryLookup.php 0000777 00000017764 15252227404 0010255 0 ustar 00 <?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; } } } Translations.php 0000777 00000027246 15252227404 0007763 0 ustar 00 <?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 ); } } } EmailPreview/EmailPreviewRestController.php 0000777 00000022754 15252227404 0015165 0 ustar 00 <?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 ), ); } } EmailPreview/EmailPreview.php 0000777 00000061314 15252227404 0012256 0 ustar 00 <?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; } } } Orders/PostsRedirectionController.php 0000777 00000014706 15252227404 0014101 0 ustar 00 <?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}" ] ] ); } } } Orders/PageController.php 0000777 00000040533 15252227404 0011452 0 ustar 00 <?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 ‹ %2$s — 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 ‹ %3$s — 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 ‹ %2$s — 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; } } Orders/ListTable.php 0000777 00000163437 15252227404 0010426 0 ustar 00 <?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 – %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 '–'; 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 '–'; } } /** * 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 '–'; } } /** * 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 } } Orders/MetaBoxes/CustomerHistory.php 0000777 00000003142 15252227404 0013577 0 ustar 00 <?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; } } Orders/MetaBoxes/OrderAttribution.php 0000777 00000003715 15252227404 0013722 0 ustar 00 <?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 ); } } Orders/MetaBoxes/CustomMetaBox.php 0000777 00000041101 15252227404 0013143 0 ustar 00 <?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(); } } } Orders/MetaBoxes/TaxonomiesMetaBox.php 0000777 00000010430 15252227404 0014020 0 ustar 00 <?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 ); } } Orders/COTRedirectionController.php 0000777 00000005374 15252227404 0013417 0 ustar 00 <?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; } } } Orders/Edit.php 0000777 00000037475 15252227404 0007432 0 ustar 00 <?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 } } Orders/EditLock.php 0000777 00000017132 15252227404 0010227 0 ustar 00 <?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 } } Settings/PaymentsProviders/NexiCheckout.php 0000777 00000010001 15252227404 0015146 0 ustar 00 <?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; } } Settings/PaymentsProviders/Mollie.php 0000777 00000015175 15252227404 0014017 0 ustar 00 <?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; } } Settings/PaymentsProviders/Affirm.php 0000777 00000002353 15252227404 0013774 0 ustar 00 <?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 ); } } Settings/PaymentsProviders/PayPal.php 0000777 00000015032 15252227404 0013754 0 ustar 00 <?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; } } Settings/PaymentsProviders/Razorpay.php 0000777 00000004655 15252227404 0014406 0 ustar 00 <?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 ); } } Settings/PaymentsProviders/Antom.php 0000777 00000011037 15252227404 0013645 0 ustar 00 <?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; } } Settings/PaymentsProviders/KlarnaCheckout.php 0000777 00000005353 15252227404 0015471 0 ustar 00 <?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 ); } } Settings/PaymentsProviders/Vivacom.php 0000777 00000005200 15252227404 0014166 0 ustar 00 <?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 ); } } Settings/PaymentsProviders/Payoneer.php 0000777 00000006255 15252227404 0014357 0 ustar 00 <?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 ); } } Settings/PaymentsProviders/Stripe.php 0000777 00000020477 15252227404 0014045 0 ustar 00 <?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(); } } Settings/PaymentsProviders/Tilopay.php 0000777 00000003064 15252227404 0014211 0 ustar 00 <?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 ); } } Settings/PaymentsProviders/Visa.php 0000777 00000011352 15252227404 0013471 0 ustar 00 <?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; } } Settings/PaymentsProviders/Klarna.php 0000777 00000002451 15252227404 0013777 0 ustar 00 <?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 ); } } Settings/PaymentsProviders/AfterpayClearpay.php 0000777 00000010623 15252227404 0016023 0 ustar 00 <?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; } } Settings/PaymentsProviders/PayUIndia.php 0000777 00000002633 15252227404 0014414 0 ustar 00 <?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 ); } } Settings/PaymentsProviders/PaymentGateway.php 0000777 00000134022 15252227404 0015526 0 ustar 00 <?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 ); } } Settings/PaymentsProviders/Paytrail.php 0000777 00000006256 15252227404 0014363 0 ustar 00 <?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 ); } } Settings/PaymentsProviders/AmazonPay.php 0000777 00000013254 15252227404 0014471 0 ustar 00 <?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; } } Settings/PaymentsProviders/PseudoWCPaymentGateway.php 0000777 00000011541 15252227404 0017140 0 ustar 00 <?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§ion=' . 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; } } Settings/PaymentsProviders/WCCore.php 0000777 00000010073 15252227404 0013710 0 ustar 00 <?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; } } Settings/PaymentsProviders/Monei.php 0000777 00000004216 15252227404 0013637 0 ustar 00 <?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 ); } } Settings/PaymentsProviders/Paystack.php 0000777 00000006067 15252227404 0014355 0 ustar 00 <?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 ); } } Settings/PaymentsProviders/Airwallex.php 0000777 00000007650 15252227404 0014525 0 ustar 00 <?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; } } Settings/PaymentsProviders/Payfast.php 0000777 00000004316 15252227404 0014200 0 ustar 00 <?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 ); } } Settings/PaymentsProviders/WooPayments/WooPaymentsRestController.php 0000777 00000137770 15252227404 0022260 0 ustar 00 <?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, ), ); } } Settings/PaymentsProviders/WooPayments/WooPaymentsService.php 0000777 00000314317 15252227404 0020671 0 ustar 00 <?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; } } Settings/PaymentsProviders/WooPayments/WooPaymentsController.php 0000777 00000006030 15252227404 0021402 0 ustar 00 <?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 ); } } } } Settings/PaymentsProviders/WooPayments.php 0000777 00000067544 15252227404 0015072 0 ustar 00 <?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(), ); } } Settings/PaymentsProviders/Eway.php 0000777 00000007612 15252227404 0013500 0 ustar 00 <?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; } } Settings/PaymentsProviders/HelioPay.php 0000777 00000006727 15252227404 0014313 0 ustar 00 <?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 ); } } Settings/PaymentsProviders/GoCardless.php 0000777 00000003773 15252227404 0014625 0 ustar 00 <?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 ); } } Settings/PaymentsProviders/Paymob.php 0000777 00000007673 15252227404 0014031 0 ustar 00 <?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; } } Settings/PaymentsProviders/MercadoPago.php 0000777 00000016145 15252227404 0014755 0 ustar 00 <?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; } } Settings/Exceptions/ApiException.php 0000777 00000003041 15252227404 0013576 0 ustar 00 <?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; } } Settings/Exceptions/ApiArgumentException.php 0000777 00000000302 15252227404 0015276 0 ustar 00 <?php declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\Admin\Settings\Exceptions; /** * ApiArgumentException class. */ class ApiArgumentException extends ApiException {} Settings/PaymentsRestController.php 0000777 00000144232 15252227404 0013577 0 ustar 00 <?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, ), ), ), ), ), ), ); } } Settings/Payments.php 0000777 00000066746 15252227404 0010712 0 ustar 00 <?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'], ) ); } } } } Settings/Utils.php 0000777 00000035654 15252227404 0010204 0 ustar 00 <?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; } } Settings/PaymentsController.php 0000777 00000030630 15252227404 0012735 0 ustar 00 <?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; } } Settings/PaymentsProviders.php 0000777 00000175362 15252227404 0012603 0 ustar 00 <?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; } } ImportExport/CSVUploadHelper.php 0000777 00000017110 15252227404 0012703 0 ustar 00 <?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; } } RemoteFreeExtensions/Init.php 0000777 00000005163 15252227404 0012314 0 ustar 00 <?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; } } RemoteFreeExtensions/DefaultFreeExtensions.php 0000777 00000055512 15252227404 0015662 0 ustar 00 <?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', ), ); } } RemoteFreeExtensions/ProcessCoreProfilerPluginInstallOptions.php 0000777 00000012025 15252227404 0021420 0 ustar 00 <?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; } } RemoteFreeExtensions/EvaluateExtension.php 0000777 00000005642 15252227404 0015056 0 ustar 00 <?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, ); } } RemoteFreeExtensions/RemoteFreeExtensionsDataSourcePoller.php 0000777 00000002104 15252227404 0020647 0 ustar 00 <?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', ); } } CustomerEffortScoreTracks.php 0000777 00000043234 15252227404 0012410 0 ustar 00 <?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' ); } } Events.php 0000777 00000021250 15252227404 0006533 0 ustar 00 <?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(); } } } RemoteInboxNotifications.php 0000777 00000001644 15252227404 0012261 0 ustar 00 <?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(); } } } Marketplace.php 0000777 00000006751 15252227404 0007530 0 ustar 00 <?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; } } ProductReviews/ReviewsListTable.php 0000777 00000133771 15252227404 0013520 0 ustar 00 <?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&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&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, '…' ); } 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 ? '☆ ' . __( '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( '★', $rating ); $stars .= str_repeat( '☆', 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' => '★', '2' => '★★', '3' => '★★★', '4' => '★★★★', '5' => '★★★★★', ]; ?> <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…', '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">—</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' ) ); } } } ProductReviews/ReviewsCommentsOverrides.php 0000777 00000011623 15252227404 0015274 0 ustar 00 <?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; } } ProductReviews/Reviews.php 0000777 00000051541 15252227404 0011706 0 ustar 00 <?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 } } ProductReviews/ReviewsUtil.php 0000777 00000006264 15252227404 0012546 0 ustar 00 <?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; } } ActivityPanels.php 0000777 00000003120 15252227404 0010222 0 ustar 00 <?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; } } Survey.php 0000777 00000001400 15252227404 0006557 0 ustar 00 <?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; } } FeaturePlugin.php 0000777 00000001673 15252227404 0010050 0 ustar 00 <?php /** * WooCommerce Admin: Feature plugin main class. */ namespace Automattic\WooCommerce\Admin; defined( 'ABSPATH' ) || exit; /** * Feature plugin main class. * * @deprecated since 6.4.0 */ class FeaturePlugin extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\FeaturePlugin'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '6.4.0'; /** * Constructor * * @return void */ protected function __construct() {} /** * Get class instance. * * @return object Instance. */ final public static function instance() { return new static(); } /** * Init the feature plugin, only if we can detect both Gutenberg and WooCommerce. * * @deprecated 6.4.0 */ public function init() {} } ShippingLabelBanner.php 0000777 00000011253 15252227404 0011140 0 ustar 00 <?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 } } Suggestions/PaymentsExtensionSuggestions.php 0000777 00000367542 15252227404 0015552 0 ustar 00 <?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; } } Suggestions/Incentives/WooPayments.php 0000777 00000030342 15252227404 0014177 0 ustar 00 <?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, ) ) ); } } Suggestions/Incentives/Incentive.php 0000777 00000025247 15252227404 0013646 0 ustar 00 <?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; } Suggestions/PaymentsExtensionSuggestionIncentives.php 0000777 00000016126 15252227404 0017404 0 ustar 00 <?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 ); } } Notes/OnboardingPayments.php 0000777 00000003364 15252227404 0012170 0 ustar 00 <?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; } } Notes/ScheduledUpdatesPromotion.php 0000777 00000005374 15252227404 0013525 0 ustar 00 <?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' ); } } Notes/MarketingJetpack.php 0000777 00000007265 15252227404 0011614 0 ustar 00 <?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 ); } } Notes/ManageOrdersOnTheGo.php 0000777 00000003051 15252227404 0012151 0 ustar 00 <?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; } } Notes/GivingFeedbackNotes.php 0000777 00000003002 15252227404 0012213 0 ustar 00 <?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; } } Notes/WooSubscriptionsNotes.php 0000777 00000032374 15252227404 0012735 0 ustar 00 <?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 ); } } } Notes/EditProductsOnTheMove.php 0000777 00000003264 15252227404 0012562 0 ustar 00 <?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; } } Notes/TrackingOptIn.php 0000777 00000006263 15252227404 0011102 0 ustar 00 <?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§ion=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 ) ); } } } Notes/WooCommerceSubscriptions.php 0000777 00000003602 15252227404 0013367 0 ustar 00 <?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; } } Notes/EmailImprovements.php 0000777 00000005503 15252227404 0012022 0 ustar 00 <?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; } } Notes/MigrateFromShopify.php 0000777 00000004330 15252227404 0012135 0 ustar 00 <?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; } } Notes/MobileApp.php 0000777 00000002615 15252227404 0010233 0 ustar 00 <?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; } } Notes/WooCommercePayments.php 0000777 00000014400 15252227404 0012316 0 ustar 00 <?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; } } Notes/SellingOnlineCourses.php 0000777 00000004600 15252227404 0012465 0 ustar 00 <?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; } } Notes/PaymentsRemindMeLater.php 0000777 00000004075 15252227404 0012576 0 ustar 00 <?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; } } Notes/OnlineClothingStore.php 0000777 00000005271 15252227404 0012315 0 ustar 00 <?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; } } Notes/CustomizingProductCatalog.php 0000777 00000004312 15252227404 0013526 0 ustar 00 <?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; } } Notes/UnsecuredReportFiles.php 0000777 00000004176 15252227404 0012503 0 ustar 00 <?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 ); } } Notes/OrderMilestones.php 0000777 00000022226 15252227404 0011501 0 ustar 00 <?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(); } } Notes/PaymentsMoreInfoNeeded.php 0000777 00000004215 15252227404 0012725 0 ustar 00 <?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; } } Notes/LaunchChecklist.php 0000777 00000003270 15252227404 0011425 0 ustar 00 <?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; } } Notes/CustomizeStoreWithBlocks.php 0000777 00000004543 15252227404 0013356 0 ustar 00 <?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; } } Notes/RealTimeOrderAlerts.php 0000777 00000003012 15252227404 0012224 0 ustar 00 <?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; } } Notes/FirstProduct.php 0000777 00000004272 15252227404 0011014 0 ustar 00 <?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; } } Notes/InstallJPAndWCSPlugins.php 0000777 00000011122 15252227404 0012556 0 ustar 00 <?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(); } } Notes/EUVATNumber.php 0000777 00000003212 15252227404 0010412 0 ustar 00 <?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; } } Notes/MagentoMigration.php 0000777 00000004714 15252227404 0011631 0 ustar 00 <?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; } } Notes/PerformanceOnMobile.php 0000777 00000003215 15252227404 0012246 0 ustar 00 <?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; } } Notes/NewSalesRecord.php 0000777 00000012404 15252227404 0011240 0 ustar 00 <?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 ); } } Notes/PersonalizeStore.php 0000777 00000003646 15252227404 0011700 0 ustar 00 <?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; } } Marketing.php 0000777 00000014450 15252227404 0007214 0 ustar 00 <?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; } } MobileAppBanner.php 0000777 00000001674 15252227404 0010275 0 ustar 00 <?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', ) ); } } Marketing/MarketingChannels.php 0000777 00000003130 15252240713 0012600 0 ustar 00 <?php /** * Handles the registration of marketing channels and acts as their repository. */ namespace Automattic\WooCommerce\Admin\Marketing; use Exception; /** * MarketingChannels repository class * * @since x.x.x */ class MarketingChannels { /** * The registered marketing channels. * * @var MarketingChannelInterface[] */ private $registered_channels = []; /** * Registers a marketing channel. * * @param MarketingChannelInterface $channel The marketing channel to register. * * @return void * * @throws Exception If the given marketing channel is already registered. */ public function register( MarketingChannelInterface $channel ): void { if ( isset( $this->registered_channels[ $channel->get_slug() ] ) ) { throw new Exception( __( 'Marketing channel cannot be registered because there is already a channel registered with the same slug!', 'woocommerce' ) ); } $this->registered_channels[ $channel->get_slug() ] = $channel; } /** * Unregisters all marketing channels. * * @return void */ public function unregister_all(): void { unset( $this->registered_channels ); } /** * Returns an array of all registered marketing channels. * * @return MarketingChannelInterface[] */ public function get_registered_channels(): array { /** * Filter the list of registered marketing channels. * * @param MarketingChannelInterface[] $channels Array of registered marketing channels. * * @since x.x.x */ $channels = apply_filters( 'woocommerce_marketing_channels', $this->registered_channels ); return array_values( $channels ); } } Marketing/InstalledExtensions.php 0000777 00000042437 15252240713 0013217 0 ustar 00 <?php /** * InstalledExtensions class file. */ namespace Automattic\WooCommerce\Admin\Marketing; use Automattic\WooCommerce\Admin\PluginsHelper; /** * Installed Marketing Extensions class. */ class InstalledExtensions { /** * Gets an array of plugin data for the "Installed marketing extensions" card. * * Valid extensions statuses are: installed, activated, configured */ public static function get_data() { $data = []; $automatewoo = self::get_automatewoo_extension_data(); $aw_referral = self::get_aw_referral_extension_data(); $aw_birthdays = self::get_aw_birthdays_extension_data(); $mailchimp = self::get_mailchimp_extension_data(); $facebook = self::get_facebook_extension_data(); $pinterest = self::get_pinterest_extension_data(); $google = self::get_google_extension_data(); $amazon_ebay = self::get_amazon_ebay_extension_data(); $mailpoet = self::get_mailpoet_extension_data(); $klaviyo = self::get_klaviyo_extension_data(); $creative_mail = self::get_creative_mail_extension_data(); $tiktok = self::get_tiktok_extension_data(); $jetpack_crm = self::get_jetpack_crm_extension_data(); $zapier = self::get_zapier_extension_data(); $salesforce = self::get_salesforce_extension_data(); $vimeo = self::get_vimeo_extension_data(); $trustpilot = self::get_trustpilot_extension_data(); if ( $automatewoo ) { $data[] = $automatewoo; } if ( $aw_referral ) { $data[] = $aw_referral; } if ( $aw_birthdays ) { $data[] = $aw_birthdays; } if ( $mailchimp ) { $data[] = $mailchimp; } if ( $facebook ) { $data[] = $facebook; } if ( $pinterest ) { $data[] = $pinterest; } if ( $google ) { $data[] = $google; } if ( $amazon_ebay ) { $data[] = $amazon_ebay; } if ( $mailpoet ) { $data[] = $mailpoet; } if ( $klaviyo ) { $data[] = $klaviyo; } if ( $creative_mail ) { $data[] = $creative_mail; } if ( $tiktok ) { $data[] = $tiktok; } if ( $jetpack_crm ) { $data[] = $jetpack_crm; } if ( $zapier ) { $data[] = $zapier; } if ( $salesforce ) { $data[] = $salesforce; } if ( $vimeo ) { $data[] = $vimeo; } if ( $trustpilot ) { $data[] = $trustpilot; } return $data; } /** * Get allowed plugins. * * @return array */ public static function get_allowed_plugins() { return [ 'automatewoo', 'mailchimp-for-woocommerce', 'creative-mail-by-constant-contact', 'facebook-for-woocommerce', 'pinterest-for-woocommerce', 'google-listings-and-ads', 'hubspot-for-woocommerce', 'woocommerce-amazon-ebay-integration', 'mailpoet', ]; } /** * Get AutomateWoo extension data. * * @return array|bool */ protected static function get_automatewoo_extension_data() { $slug = 'automatewoo'; if ( ! PluginsHelper::is_plugin_installed( $slug ) ) { return false; } $data = self::get_extension_base_data( $slug ); $data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/automatewoo.svg'; if ( 'activated' === $data['status'] && function_exists( 'AW' ) ) { $data['settingsUrl'] = admin_url( 'admin.php?page=automatewoo-settings' ); $data['docsUrl'] = 'https://automatewoo.com/docs/'; $data['status'] = 'configured'; // Currently no configuration step. } return $data; } /** * Get AutomateWoo Refer a Friend extension data. * * @return array|bool */ protected static function get_aw_referral_extension_data() { $slug = 'automatewoo-referrals'; if ( ! PluginsHelper::is_plugin_installed( $slug ) ) { return false; } $data = self::get_extension_base_data( $slug ); $data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/automatewoo.svg'; if ( 'activated' === $data['status'] ) { $data['docsUrl'] = 'https://automatewoo.com/docs/refer-a-friend/'; $data['status'] = 'configured'; if ( function_exists( 'AW_Referrals' ) ) { $data['settingsUrl'] = admin_url( 'admin.php?page=automatewoo-settings&tab=referrals' ); } } return $data; } /** * Get AutomateWoo Birthdays extension data. * * @return array|bool */ protected static function get_aw_birthdays_extension_data() { $slug = 'automatewoo-birthdays'; if ( ! PluginsHelper::is_plugin_installed( $slug ) ) { return false; } $data = self::get_extension_base_data( $slug ); $data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/automatewoo.svg'; if ( 'activated' === $data['status'] ) { $data['docsUrl'] = 'https://automatewoo.com/docs/getting-started-with-birthdays/'; $data['status'] = 'configured'; if ( function_exists( 'AW_Birthdays' ) ) { $data['settingsUrl'] = admin_url( 'admin.php?page=automatewoo-settings&tab=birthdays' ); } } return $data; } /** * Get MailChimp extension data. * * @return array|bool */ protected static function get_mailchimp_extension_data() { $slug = 'mailchimp-for-woocommerce'; if ( ! PluginsHelper::is_plugin_installed( $slug ) ) { return false; } $data = self::get_extension_base_data( $slug ); $data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/mailchimp.svg'; if ( 'activated' === $data['status'] && function_exists( 'mailchimp_is_configured' ) ) { $data['docsUrl'] = 'https://mailchimp.com/help/connect-or-disconnect-mailchimp-for-woocommerce/'; $data['settingsUrl'] = admin_url( 'admin.php?page=mailchimp-woocommerce' ); if ( mailchimp_is_configured() ) { $data['status'] = 'configured'; } } return $data; } /** * Get Facebook extension data. * * @return array|bool */ protected static function get_facebook_extension_data() { $slug = 'facebook-for-woocommerce'; if ( ! PluginsHelper::is_plugin_installed( $slug ) ) { return false; } $data = self::get_extension_base_data( $slug ); $data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/facebook-icon.svg'; if ( 'activated' === $data['status'] && function_exists( 'facebook_for_woocommerce' ) ) { $integration = facebook_for_woocommerce()->get_integration(); if ( $integration->is_configured() ) { $data['status'] = 'configured'; } $data['settingsUrl'] = facebook_for_woocommerce()->get_settings_url(); $data['docsUrl'] = facebook_for_woocommerce()->get_documentation_url(); } return $data; } /** * Get Pinterest extension data. * * @return array|bool */ protected static function get_pinterest_extension_data() { $slug = 'pinterest-for-woocommerce'; if ( ! PluginsHelper::is_plugin_installed( $slug ) ) { return false; } $data = self::get_extension_base_data( $slug ); $data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/pinterest.svg'; $data['docsUrl'] = 'https://woocommerce.com/document/pinterest-for-woocommerce/?utm_medium=product'; if ( 'activated' === $data['status'] && class_exists( 'Pinterest_For_Woocommerce' ) ) { $pinterest_onboarding_completed = Pinterest_For_Woocommerce()::is_setup_complete(); if ( $pinterest_onboarding_completed ) { $data['status'] = 'configured'; $data['settingsUrl'] = admin_url( 'admin.php?page=wc-admin&path=/pinterest/settings' ); } else { $data['settingsUrl'] = admin_url( 'admin.php?page=wc-admin&path=/pinterest/landing' ); } } return $data; } /** * Get Google extension data. * * @return array|bool */ protected static function get_google_extension_data() { $slug = 'google-listings-and-ads'; if ( ! PluginsHelper::is_plugin_installed( $slug ) ) { return false; } $data = self::get_extension_base_data( $slug ); $data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/google.svg'; if ( 'activated' === $data['status'] && function_exists( 'woogle_get_container' ) && class_exists( '\Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService' ) ) { $merchant_center = woogle_get_container()->get( \Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService::class ); if ( $merchant_center->is_setup_complete() ) { $data['status'] = 'configured'; $data['settingsUrl'] = admin_url( 'admin.php?page=wc-admin&path=/google/settings' ); } else { $data['settingsUrl'] = admin_url( 'admin.php?page=wc-admin&path=/google/start' ); } $data['docsUrl'] = 'https://woocommerce.com/document/google-listings-and-ads/?utm_medium=product'; } return $data; } /** * Get Amazon / Ebay extension data. * * @return array|bool */ protected static function get_amazon_ebay_extension_data() { $slug = 'woocommerce-amazon-ebay-integration'; if ( ! PluginsHelper::is_plugin_installed( $slug ) ) { return false; } $data = self::get_extension_base_data( $slug ); $data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/amazon-ebay.svg'; if ( 'activated' === $data['status'] && class_exists( '\CodistoConnect' ) ) { $codisto_merchantid = get_option( 'codisto_merchantid' ); // Use same check as codisto admin tabs. if ( is_numeric( $codisto_merchantid ) ) { $data['status'] = 'configured'; } $data['settingsUrl'] = admin_url( 'admin.php?page=codisto-settings' ); $data['docsUrl'] = 'https://woocommerce.com/document/multichannel-for-woocommerce-google-amazon-ebay-walmart-integration/?utm_medium=product'; } return $data; } /** * Get MailPoet extension data. * * @return array|bool */ protected static function get_mailpoet_extension_data() { $slug = 'mailpoet'; if ( ! PluginsHelper::is_plugin_installed( $slug ) ) { return false; } $data = self::get_extension_base_data( $slug ); $data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/mailpoet.svg'; if ( 'activated' === $data['status'] && class_exists( '\MailPoet\API\API' ) ) { $mailpoet_api = \MailPoet\API\API::MP( 'v1' ); if ( ! method_exists( $mailpoet_api, 'isSetupComplete' ) || $mailpoet_api->isSetupComplete() ) { $data['status'] = 'configured'; $data['settingsUrl'] = admin_url( 'admin.php?page=mailpoet-settings' ); } else { $data['settingsUrl'] = admin_url( 'admin.php?page=mailpoet-newsletters' ); } $data['docsUrl'] = 'https://kb.mailpoet.com/'; $data['supportUrl'] = 'https://www.mailpoet.com/support/'; } return $data; } /** * Get Klaviyo extension data. * * @return array|bool */ protected static function get_klaviyo_extension_data() { $slug = 'klaviyo'; if ( ! PluginsHelper::is_plugin_installed( $slug ) ) { return false; } $data = self::get_extension_base_data( $slug ); $data['icon'] = plugins_url( 'assets/images/marketing/klaviyo.png', WC_PLUGIN_FILE ); if ( 'activated' === $data['status'] ) { $klaviyo_options = get_option( 'klaviyo_settings' ); if ( isset( $klaviyo_options['klaviyo_public_api_key'] ) ) { $data['status'] = 'configured'; } $data['settingsUrl'] = admin_url( 'admin.php?page=klaviyo_settings' ); } return $data; } /** * Get Creative Mail for WooCommerce extension data. * * @return array|bool */ protected static function get_creative_mail_extension_data() { $slug = 'creative-mail-by-constant-contact'; if ( ! PluginsHelper::is_plugin_installed( $slug ) ) { return false; } $data = self::get_extension_base_data( $slug ); $data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/creative-mail-by-constant-contact.png'; if ( 'activated' === $data['status'] && class_exists( '\CreativeMail\Helpers\OptionsHelper' ) ) { if ( ! method_exists( '\CreativeMail\Helpers\OptionsHelper', 'get_instance_id' ) || \CreativeMail\Helpers\OptionsHelper::get_instance_id() !== null ) { $data['status'] = 'configured'; $data['settingsUrl'] = admin_url( 'admin.php?page=creativemail_settings' ); } else { $data['settingsUrl'] = admin_url( 'admin.php?page=creativemail' ); } $data['docsUrl'] = 'https://app.creativemail.com/kb/help/WooCommerce'; $data['supportUrl'] = 'https://app.creativemail.com/kb/help/'; } return $data; } /** * Get TikTok for WooCommerce extension data. * * @return array|bool */ protected static function get_tiktok_extension_data() { $slug = 'tiktok-for-business'; if ( ! PluginsHelper::is_plugin_installed( $slug ) ) { return false; } $data = self::get_extension_base_data( $slug ); $data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/tiktok.jpg'; if ( 'activated' === $data['status'] ) { if ( false !== get_option( 'tt4b_access_token' ) ) { $data['status'] = 'configured'; } $data['settingsUrl'] = admin_url( 'admin.php?page=tiktok' ); $data['docsUrl'] = 'https://woocommerce.com/document/tiktok-for-woocommerce/'; $data['supportUrl'] = 'https://ads.tiktok.com/athena/user-feedback/?identify_key=6a1e079024806640c5e1e695d13db80949525168a052299b4970f9c99cb5ac78'; } return $data; } /** * Get Jetpack CRM for WooCommerce extension data. * * @return array|bool */ protected static function get_jetpack_crm_extension_data() { $slug = 'zero-bs-crm'; if ( ! PluginsHelper::is_plugin_installed( $slug ) ) { return false; } $data = self::get_extension_base_data( $slug ); $data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/jetpack-crm.png'; if ( 'activated' === $data['status'] ) { $data['status'] = 'configured'; $data['settingsUrl'] = admin_url( 'admin.php?page=zerobscrm-plugin-settings' ); $data['docsUrl'] = 'https://kb.jetpackcrm.com/'; $data['supportUrl'] = 'https://kb.jetpackcrm.com/crm-support/'; } return $data; } /** * Get WooCommerce Zapier extension data. * * @return array|bool */ protected static function get_zapier_extension_data() { $slug = 'woocommerce-zapier'; if ( ! PluginsHelper::is_plugin_installed( $slug ) ) { return false; } $data = self::get_extension_base_data( $slug ); $data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/zapier.png'; if ( 'activated' === $data['status'] ) { $data['status'] = 'configured'; $data['settingsUrl'] = admin_url( 'admin.php?page=wc-settings&tab=wc_zapier' ); $data['docsUrl'] = 'https://docs.om4.io/woocommerce-zapier/'; } return $data; } /** * Get Salesforce extension data. * * @return array|bool */ protected static function get_salesforce_extension_data() { $slug = 'integration-with-salesforce'; if ( ! PluginsHelper::is_plugin_installed( $slug ) ) { return false; } $data = self::get_extension_base_data( $slug ); $data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/salesforce.jpg'; if ( 'activated' === $data['status'] && class_exists( '\Integration_With_Salesforce_Admin' ) ) { if ( ! method_exists( '\Integration_With_Salesforce_Admin', 'get_connection_status' ) || \Integration_With_Salesforce_Admin::get_connection_status() ) { $data['status'] = 'configured'; } $data['settingsUrl'] = admin_url( 'admin.php?page=integration-with-salesforce' ); $data['docsUrl'] = 'https://woocommerce.com/document/salesforce-integration/'; $data['supportUrl'] = 'https://wpswings.com/submit-query/'; } return $data; } /** * Get Vimeo extension data. * * @return array|bool */ protected static function get_vimeo_extension_data() { $slug = 'vimeo'; if ( ! PluginsHelper::is_plugin_installed( $slug ) ) { return false; } $data = self::get_extension_base_data( $slug ); $data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/vimeo.png'; if ( 'activated' === $data['status'] && class_exists( '\Tribe\Vimeo_WP\Vimeo\Vimeo_Auth' ) ) { if ( method_exists( '\Tribe\Vimeo_WP\Vimeo\Vimeo_Auth', 'has_access_token' ) ) { $vimeo_auth = new \Tribe\Vimeo_WP\Vimeo\Vimeo_Auth(); if ( $vimeo_auth->has_access_token() ) { $data['status'] = 'configured'; } } else { $data['status'] = 'configured'; } $data['settingsUrl'] = admin_url( 'options-general.php?page=vimeo_settings' ); $data['docsUrl'] = 'https://woocommerce.com/document/vimeo/'; $data['supportUrl'] = 'https://vimeo.com/help/contact'; } return $data; } /** * Get Trustpilot extension data. * * @return array|bool */ protected static function get_trustpilot_extension_data() { $slug = 'trustpilot-reviews'; if ( ! PluginsHelper::is_plugin_installed( $slug ) ) { return false; } $data = self::get_extension_base_data( $slug ); $data['icon'] = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing/trustpilot.png'; if ( 'activated' === $data['status'] ) { $data['status'] = 'configured'; $data['settingsUrl'] = admin_url( 'admin.php?page=woocommerce-trustpilot-settings-page' ); $data['docsUrl'] = 'https://woocommerce.com/document/trustpilot-reviews/'; $data['supportUrl'] = 'https://support.trustpilot.com/hc/en-us/requests/new'; } return $data; } /** * Get an array of basic data for a given extension. * * @param string $slug Plugin slug. * * @return array|false */ protected static function get_extension_base_data( $slug ) { $status = PluginsHelper::is_plugin_active( $slug ) ? 'activated' : 'installed'; $plugin_data = PluginsHelper::get_plugin_data( $slug ); if ( ! $plugin_data ) { return false; } return [ 'slug' => $slug, 'status' => $status, 'name' => $plugin_data['Name'], 'description' => html_entity_decode( wp_trim_words( $plugin_data['Description'], 20 ) ), 'supportUrl' => 'https://woocommerce.com/my-account/create-a-ticket/?utm_medium=product', ]; } } Marketing/MarketingCampaignType.php 0000777 00000005577 15252240713 0013447 0 ustar 00 <?php /** * Represents a marketing campaign type supported by a marketing channel. * * Marketing channels (implementing MarketingChannelInterface) can use this class to define what kind of campaigns they support. */ namespace Automattic\WooCommerce\Admin\Marketing; /** * MarketingCampaignType class * * @since x.x.x */ class MarketingCampaignType { /** * The unique identifier. * * @var string */ protected $id; /** * The marketing channel that this campaign type belongs to. * * @var MarketingChannelInterface */ protected $channel; /** * Name of the marketing campaign type. * * @var string */ protected $name; /** * Description of the marketing campaign type. * * @var string */ protected $description; /** * The URL to the create campaign page. * * @var string */ protected $create_url; /** * The URL to an image/icon for the campaign type. * * @var string */ protected $icon_url; /** * MarketingCampaignType constructor. * * @param string $id A unique identifier for the campaign type. * @param MarketingChannelInterface $channel The marketing channel that this campaign type belongs to. * @param string $name Name of the marketing campaign type. * @param string $description Description of the marketing campaign type. * @param string $create_url The URL to the create campaign page. * @param string $icon_url The URL to an image/icon for the campaign type. */ public function __construct( string $id, MarketingChannelInterface $channel, string $name, string $description, string $create_url, string $icon_url ) { $this->id = $id; $this->channel = $channel; $this->name = $name; $this->description = $description; $this->create_url = $create_url; $this->icon_url = $icon_url; } /** * Returns the marketing campaign's unique identifier. * * @return string */ public function get_id(): string { return $this->id; } /** * Returns the marketing channel that this campaign type belongs to. * * @return MarketingChannelInterface */ public function get_channel(): MarketingChannelInterface { return $this->channel; } /** * Returns the name of the marketing campaign type. * * @return string */ public function get_name(): string { return $this->name; } /** * Returns the description of the marketing campaign type. * * @return string */ public function get_description(): string { return $this->description; } /** * Returns the URL to the create campaign page. * * @return string */ public function get_create_url(): string { return $this->create_url; } /** * Returns the URL to an image/icon for the campaign type. * * @return string */ public function get_icon_url(): string { return $this->icon_url; } } Marketing/Price.php 0000777 00000001523 15252240713 0010251 0 ustar 00 <?php /** * Represents a price with a currency. */ namespace Automattic\WooCommerce\Admin\Marketing; /** * Price class * * @since x.x.x */ class Price { /** * The price. * * @var string */ protected $value; /** * The currency of the price. * * @var string */ protected $currency; /** * Price constructor. * * @param string $value The value of the price. * @param string $currency The currency of the price. */ public function __construct( string $value, string $currency ) { $this->value = $value; $this->currency = $currency; } /** * Get value of the price. * * @return string */ public function get_value(): string { return $this->value; } /** * Get the currency of the price. * * @return string */ public function get_currency(): string { return $this->currency; } } Marketing/MarketingCampaign.php 0000777 00000005416 15252240713 0012575 0 ustar 00 <?php /** * Represents a marketing/ads campaign for marketing channels. * * Marketing channels (implementing MarketingChannelInterface) can use this class to map their campaign data and present it to WooCommerce core. */ namespace Automattic\WooCommerce\Admin\Marketing; /** * MarketingCampaign class * * @since x.x.x */ class MarketingCampaign { /** * The unique identifier. * * @var string */ protected $id; /** * The marketing campaign type. * * @var MarketingCampaignType */ protected $type; /** * Title of the marketing campaign. * * @var string */ protected $title; /** * The URL to the channel's campaign management page. * * @var string */ protected $manage_url; /** * The cost of the marketing campaign with the currency. * * @var Price */ protected $cost; /** * The sales of the marketing campaign with the currency. * * @var Price */ protected $sales; /** * MarketingCampaign constructor. * * @param string $id The marketing campaign's unique identifier. * @param MarketingCampaignType $type The marketing campaign type. * @param string $title The title of the marketing campaign. * @param string $manage_url The URL to the channel's campaign management page. * @param Price|null $cost The cost of the marketing campaign with the currency. * @param Price|null $sales The sales of the marketing campaign with the currency. */ public function __construct( string $id, MarketingCampaignType $type, string $title, string $manage_url, ?Price $cost = null, ?Price $sales = null ) { $this->id = $id; $this->type = $type; $this->title = $title; $this->manage_url = $manage_url; $this->cost = $cost; $this->sales = $sales; } /** * Returns the marketing campaign's unique identifier. * * @return string */ public function get_id(): string { return $this->id; } /** * Returns the marketing campaign type. * * @return MarketingCampaignType */ public function get_type(): MarketingCampaignType { return $this->type; } /** * Returns the title of the marketing campaign. * * @return string */ public function get_title(): string { return $this->title; } /** * Returns the URL to manage the marketing campaign. * * @return string */ public function get_manage_url(): string { return $this->manage_url; } /** * Returns the cost of the marketing campaign with the currency. * * @return Price|null */ public function get_cost(): ?Price { return $this->cost; } /** * Returns the sales of the marketing campaign with the currency. * * @return Price|null */ public function get_sales(): ?Price { return $this->sales; } } Marketing/MarketingChannelInterface.php 0000777 00000004377 15252240713 0014254 0 ustar 00 <?php /** * Represents a marketing channel for the multichannel-marketing feature. * * This interface will be implemented by third-party extensions to register themselves as marketing channels. */ namespace Automattic\WooCommerce\Admin\Marketing; /** * MarketingChannelInterface interface * * @since x.x.x */ interface MarketingChannelInterface { public const PRODUCT_LISTINGS_NOT_APPLICABLE = 'not-applicable'; public const PRODUCT_LISTINGS_SYNC_IN_PROGRESS = 'sync-in-progress'; public const PRODUCT_LISTINGS_SYNC_FAILED = 'sync-failed'; public const PRODUCT_LISTINGS_SYNCED = 'synced'; /** * Returns the unique identifier string for the marketing channel extension, also known as the plugin slug. * * @return string */ public function get_slug(): string; /** * Returns the name of the marketing channel. * * @return string */ public function get_name(): string; /** * Returns the description of the marketing channel. * * @return string */ public function get_description(): string; /** * Returns the path to the channel icon. * * @return string */ public function get_icon_url(): string; /** * Returns the setup status of the marketing channel. * * @return bool */ public function is_setup_completed(): bool; /** * Returns the URL to the settings page, or the link to complete the setup/onboarding if the channel has not been set up yet. * * @return string */ public function get_setup_url(): string; /** * Returns the status of the marketing channel's product listings. * * @return string */ public function get_product_listings_status(): string; /** * Returns the number of channel issues/errors (e.g. account-related errors, product synchronization issues, etc.). * * @return int The number of issues to resolve, or 0 if there are no issues with the channel. */ public function get_errors_count(): int; /** * Returns an array of marketing campaign types that the channel supports. * * @return MarketingCampaignType[] Array of marketing campaign type objects. */ public function get_supported_campaign_types(): array; /** * Returns an array of the channel's marketing campaigns. * * @return MarketingCampaign[] */ public function get_campaigns(): array; } DataSourcePoller.php 0000777 00000004247 15252240713 0010504 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin; use Automattic\WooCommerce\Admin\RemoteSpecs\DataSourcePoller as RemoteSpecsDataSourcePoller; /** * Specs data source poller class. * This handles polling specs from JSON endpoints, and * stores the specs in to the database as an option. * * @deprecated since 8.8.0 */ abstract class DataSourcePoller extends RemoteSpecsDataSourcePoller { /** * Log a deprecation to the error log. */ private static function log_deprecation() { /** * Note: Deprecation messages have been temporarily disabled due to upgrade issues. * For more details, see the discussion in the WooCommerce GitHub repository: * https://github.com/woocommerce/woocommerce/pull/45892. */ } /** * Constructor. * * @param string $id id of DataSourcePoller. * @param array $data_sources urls for data sources. * @param array $args Options for DataSourcePoller. */ public function __construct( $id, $data_sources = array(), $args = array() ) { self::log_deprecation(); parent::__construct( $id, $data_sources, $args ); } /** * Reads the data sources for specs and persists those specs. * * @deprecated 8.8.0 * @return array list of specs. */ public function get_specs_from_data_sources() { self::log_deprecation(); return parent::get_specs_from_data_sources(); } /** * Reads the data sources for specs and persists those specs. * * @deprecated 8.8.0 * @return bool Whether any specs were read. */ public function read_specs_from_data_sources() { self::log_deprecation(); return parent::read_specs_from_data_sources(); } /** * Delete the specs transient. * * @deprecated 8.8.0 * @return bool success of failure of transient deletion. */ public function delete_specs_transient() { self::log_deprecation(); return parent::delete_specs_transient(); } /** * Set the specs transient. * * @param array $specs The specs to set in the transient. * @param int $expiration The expiration time for the transient. * * @deprecated 8.8.0 */ public function set_specs_transient( $specs, $expiration = 0 ) { self::log_deprecation(); return parent::set_specs_transient( $specs, $expiration ); } } Features/Features.php 0000777 00000030630 15252240713 0010623 0 ustar 00 <?php /** * Features loader for features developed in WooCommerce Admin. */ namespace Automattic\WooCommerce\Admin\Features; use Automattic\WooCommerce\Admin\PageController; use Automattic\WooCommerce\Internal\Admin\Loader; use Automattic\WooCommerce\Internal\Admin\WCAdminAssets; use Automattic\WooCommerce\Utilities\FeaturesUtil; /** * Features Class. */ class Features { /** * Class instance. * * @var Loader instance */ protected static $instance = null; /** * Optional features * * @var array */ protected static $optional_features = array( 'analytics' => array( 'default' => 'yes' ), 'remote-inbox-notifications' => array( 'default' => 'yes' ), ); /** * Beta features * * @var array */ protected static $beta_features = array( 'settings', ); /** * Get class instance. */ public static function get_instance() { if ( ! self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Constructor. */ public function __construct() { $this->register_internal_class_aliases(); if ( ! self::should_load_features() ) { return; } // Load feature before WooCommerce update hooks. add_action( 'init', array( __CLASS__, 'load_features' ), 4 ); add_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_load_beta_features_modal' ) ); add_action( 'admin_enqueue_scripts', array( __CLASS__, 'load_scripts' ), 15 ); add_filter( 'admin_body_class', array( __CLASS__, 'add_admin_body_classes' ) ); add_filter( 'update_option_woocommerce_allow_tracking', array( __CLASS__, 'maybe_disable_features' ), 10, 2 ); } /** * Gets a build configured array of enabled WooCommerce Admin features/sections, but does not respect optionally disabled features. * * @return array Enabled Woocommerce Admin features/sections. */ public static function get_features() { return apply_filters( 'woocommerce_admin_features', array() ); } /** * Gets the optional feature options as an associative array that can be toggled on or off. * * @return array */ public static function get_optional_feature_options() { $features = array(); foreach ( array_keys( self::$optional_features ) as $optional_feature_key ) { $feature_class = self::get_feature_class( $optional_feature_key ); if ( $feature_class ) { $features[ $optional_feature_key ] = $feature_class::TOGGLE_OPTION_NAME; } } return $features; } /** * Returns if a specific wc-admin feature exists in the current environment. * * @param string $feature Feature slug. * @return bool Returns true if the feature exists. */ public static function exists( $feature ) { $features = self::get_features(); return in_array( $feature, $features, true ); } /** * Get the feature class as a string. * * @param string $feature Feature name. * @return string|null */ public static function get_feature_class( $feature ) { $feature = str_replace( '-', '', ucwords( strtolower( $feature ), '-' ) ); $feature_class = 'Automattic\\WooCommerce\\Admin\\Features\\' . $feature; $should_autoload_class = self::should_load_features(); if ( class_exists( $feature_class, $should_autoload_class ) ) { return $feature_class; } // Handle features contained in subdirectory. if ( class_exists( $feature_class . '\\Init', $should_autoload_class ) ) { return $feature_class . '\\Init'; } return null; } /** * Class loader for enabled WooCommerce Admin features/sections. */ public static function load_features() { if ( ! self::should_load_features() ) { return; } $features = self::get_features(); foreach ( $features as $feature ) { $feature_class = self::get_feature_class( $feature ); if ( $feature_class ) { new $feature_class(); } } if ( FeaturesUtil::feature_is_enabled( 'blueprint' ) ) { new \Automattic\WooCommerce\Admin\Features\Blueprint\Init(); } } /** * Gets a build configured array of enabled WooCommerce Admin respecting optionally disabled features. * * @return array Enabled Woocommerce Admin features/sections. */ public static function get_available_features() { $features = self::get_features(); $optional_feature_keys = array_keys( self::$optional_features ); $optional_features_unavailable = array(); /** * Filter allowing WooCommerce Admin optional features to be disabled. * * @param bool $disabled False. */ if ( apply_filters( 'woocommerce_admin_disabled', false ) ) { return array_values( array_diff( $features, $optional_feature_keys ) ); } foreach ( $optional_feature_keys as $optional_feature_key ) { $feature_class = self::get_feature_class( $optional_feature_key ); if ( $feature_class ) { $default = isset( self::$optional_features[ $optional_feature_key ]['default'] ) ? self::$optional_features[ $optional_feature_key ]['default'] : 'no'; // Check if the feature is currently being enabled, if it is continue. /* phpcs:disable WordPress.Security.NonceVerification */ $feature_option = $feature_class::TOGGLE_OPTION_NAME; if ( isset( $_POST[ $feature_option ] ) && '1' === $_POST[ $feature_option ] ) { continue; } if ( 'yes' !== get_option( $feature_class::TOGGLE_OPTION_NAME, $default ) ) { $optional_features_unavailable[] = $optional_feature_key; } } } return array_values( array_diff( $features, $optional_features_unavailable ) ); } /** * Check if a feature is enabled. * * @param string $feature Feature slug. * @return bool */ public static function is_enabled( $feature ) { $available_features = self::get_available_features(); return in_array( $feature, $available_features, true ); } /** * Enable a toggleable optional feature. * * @param string $feature Feature name. * @return bool */ public static function enable( $feature ) { $features = self::get_optional_feature_options(); if ( isset( $features[ $feature ] ) ) { update_option( $features[ $feature ], 'yes' ); return true; } return false; } /** * Disable a toggleable optional feature. * * @param string $feature Feature name. * @return bool */ public static function disable( $feature ) { $features = self::get_optional_feature_options(); if ( isset( $features[ $feature ] ) ) { update_option( $features[ $feature ], 'no' ); return true; } return false; } /** * Disable features when opting out of tracking. * * @param string $old_value Old value. * @param string $value New value. */ public static function maybe_disable_features( $old_value, $value ) { if ( 'yes' === $value ) { return; } foreach ( self::$beta_features as $feature ) { self::disable( $feature ); } } /** * Adds the Features section to the advanced tab of WooCommerce Settings * * @deprecated 7.0 The WooCommerce Admin features are now handled by the WooCommerce features engine (see the FeaturesController class). * * @param array $sections Sections. * @return array */ public static function add_features_section( $sections ) { return $sections; } /** * Adds the Features settings. * * @deprecated 7.0 The WooCommerce Admin features are now handled by the WooCommerce features engine (see the FeaturesController class). * * @param array $settings Settings. * @param string $current_section Current section slug. * @return array */ public static function add_features_settings( $settings, $current_section ) { return $settings; } /** * Conditionally loads the beta features tracking modal. * * @param string $hook Page hook. */ public static function maybe_load_beta_features_modal( $hook ) { if ( 'woocommerce_page_wc-settings' !== $hook || ! isset( $_GET['tab'] ) || 'advanced' !== $_GET['tab'] || // phpcs:ignore CSRF ok. ! isset( $_GET['section'] ) || 'features' !== $_GET['section'] // phpcs:ignore CSRF ok. ) { return; } $tracking_enabled = get_option( 'woocommerce_allow_tracking', 'no' ); if ( empty( self::$beta_features ) ) { return; } if ( 'yes' === $tracking_enabled ) { return; } WCAdminAssets::register_style( 'beta-features-tracking-modal', 'style', array( 'wp-components' ) ); WCAdminAssets::register_script( 'wp-admin-scripts', 'beta-features-tracking-modal', array( 'wp-i18n', 'wp-element', WC_ADMIN_APP ) ); } /** * Loads the required scripts on the correct pages. */ public static function load_scripts() { if ( ! PageController::is_admin_or_embed_page() ) { return; } $features = self::get_features(); $enabled_features = array(); foreach ( $features as $key ) { $enabled_features[ $key ] = self::is_enabled( $key ); } wp_add_inline_script( WC_ADMIN_APP, 'window.wcAdminFeatures = ' . wp_json_encode( $enabled_features, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), 'before' ); } /** * 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() ) { return $admin_body_class; } $classes = explode( ' ', trim( $admin_body_class ) ); $features = self::get_features(); foreach ( $features as $feature_key ) { $classes[] = sanitize_html_class( 'woocommerce-feature-enabled-' . $feature_key ); } $admin_body_class = implode( ' ', array_unique( $classes ) ); return " $admin_body_class "; } /** * Alias internal features classes to make them backward compatible. * We've moved our feature classes to src-internal as part of merging this * repository with WooCommerce Core to form a monorepo. * See https://wp.me/p90Yrv-2HY for details. */ private function register_internal_class_aliases() { $aliases = array( // new class => original class (this will be aliased). 'Automattic\WooCommerce\Internal\Admin\WCPayPromotion\Init' => 'Automattic\WooCommerce\Admin\Features\WcPayPromotion\Init', 'Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions\Init' => 'Automattic\WooCommerce\Admin\Features\RemoteFreeExtensions\Init', 'Automattic\WooCommerce\Internal\Admin\ActivityPanels' => 'Automattic\WooCommerce\Admin\Features\ActivityPanels', 'Automattic\WooCommerce\Internal\Admin\Analytics' => 'Automattic\WooCommerce\Admin\Features\Analytics', 'Automattic\WooCommerce\Internal\Admin\Coupons' => 'Automattic\WooCommerce\Admin\Features\Coupons', 'Automattic\WooCommerce\Internal\Admin\CouponsMovedTrait' => 'Automattic\WooCommerce\Admin\Features\CouponsMovedTrait', 'Automattic\WooCommerce\Internal\Admin\CustomerEffortScoreTracks' => 'Automattic\WooCommerce\Admin\Features\CustomerEffortScoreTracks', 'Automattic\WooCommerce\Internal\Admin\Homescreen' => 'Automattic\WooCommerce\Admin\Features\Homescreen', 'Automattic\WooCommerce\Internal\Admin\Marketing' => 'Automattic\WooCommerce\Admin\Features\Marketing', 'Automattic\WooCommerce\Internal\Admin\MobileAppBanner' => 'Automattic\WooCommerce\Admin\Features\MobileAppBanner', 'Automattic\WooCommerce\Internal\Admin\RemoteInboxNotifications' => 'Automattic\WooCommerce\Admin\Features\RemoteInboxNotifications', 'Automattic\WooCommerce\Internal\Admin\ShippingLabelBanner' => 'Automattic\WooCommerce\Admin\Features\ShippingLabelBanner', 'Automattic\WooCommerce\Internal\Admin\ShippingLabelBannerDisplayRules' => 'Automattic\WooCommerce\Admin\Features\ShippingLabelBannerDisplayRules', 'Automattic\WooCommerce\Internal\Admin\WcPayWelcomePage' => 'Automattic\WooCommerce\Admin\Features\WcPayWelcomePage', ); foreach ( $aliases as $new_class => $orig_class ) { class_alias( $new_class, $orig_class ); } } /** * Check if we're in an admin context where features should be loaded. * * @return boolean */ private static function should_load_features() { $should_load = ( is_admin() || wp_doing_ajax() || wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ) || ( WC()->is_rest_api_request() && ! WC()->is_store_api_request() ) || // Allow features to be loaded in frontend for admin users. This is needed for the use case such as the coming soon footer banner. current_user_can( 'manage_woocommerce' ) ); /** * Filter to determine if admin features should be loaded. * * @since 9.6.0 * @param boolean $should_load Whether admin features should be loaded. It defaults to true when the current request is in an admin context. */ return apply_filters( 'woocommerce_admin_should_load_features', $should_load ); } } Features/MarketingRecommendations/MarketingRecommendationsDataSourcePoller.php 0000777 00000002177 15252240713 0024165 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\MarketingRecommendations; use Automattic\WooCommerce\Admin\RemoteSpecs\DataSourcePoller; use WC_Helper; /** * Specs data source poller class for marketing recommendations. */ class MarketingRecommendationsDataSourcePoller extends DataSourcePoller { /** * Data Source Poller ID. */ const ID = 'marketing_recommendations'; /** * Default data sources array. * * @deprecated since 9.5.0. Use get_data_sources() instead. */ const DATA_SOURCES = array(); /** * Class instance. * * @var MarketingRecommendationsDataSourcePoller 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' => 'product', ) ); } 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/marketing-tab/1.3/recommendations.json', ); } } Features/MarketingRecommendations/MiscRecommendationsDataSourcePoller.php 0000777 00000002514 15252240713 0023132 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\Admin\Features\MarketingRecommendations; use Automattic\WooCommerce\Admin\RemoteSpecs\DataSourcePoller; use WC_Helper; /** * Specs data source poller class for misc recommendations. * * The misc recommendations are fetched from the WooCommerce.com API, the data structure looks like this: * * [ * { * "id": "woocommerce-analytics", * "order_attribution_promotion_percentage": [ * [ "9.7", 100 ], * [ "9.6", 60 ], * [ "9.5", 10 ] * ] * } * ] * * @since 9.5.0 */ class MiscRecommendationsDataSourcePoller extends DataSourcePoller { /** * Data Source Poller ID. */ const ID = 'misc_recommendations'; /** * Class instance. * * @var MiscRecommendationsDataSourcePoller 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( 'transient_expiry' => DAY_IN_SECONDS, ) ); } 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/marketing-tab/misc/recommendations.json', ); } } Features/MarketingRecommendations/Init.php 0000777 00000015604 15252240713 0014745 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\MarketingRecommendations; use Automattic\WooCommerce\Admin\RemoteSpecs\RemoteSpecsEngine; defined( 'ABSPATH' ) || exit; /** * Marketing Recommendations engine. * This goes through the specs and gets marketing recommendations. */ class Init extends RemoteSpecsEngine { /** * Slug of the category specifying marketing extensions on the WooCommerce.com store. * * @var string */ const MARKETING_EXTENSION_CATEGORY_SLUG = 'marketing'; /** * Slug of the subcategory specifying marketing channels on the WooCommerce.com store. * * @var string */ const MARKETING_CHANNEL_SUBCATEGORY_SLUG = 'sales-channels'; /** * Constructor. */ public function __construct() { add_action( 'woocommerce_updated', array( __CLASS__, 'delete_specs_transient' ) ); } /** * Delete the specs transient. */ public static function delete_specs_transient() { MarketingRecommendationsDataSourcePoller::get_instance()->delete_specs_transient(); MiscRecommendationsDataSourcePoller::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 DefaultMarketingRecommendations::get_all(); } $specs = MarketingRecommendationsDataSourcePoller::get_instance()->get_specs_from_data_sources(); // Fetch specs if they don't yet exist. if ( ! is_array( $specs ) || 0 === count( $specs ) ) { return DefaultMarketingRecommendations::get_all(); } return $specs; } /** * Get misc recommendations specs or fetch remotely if they don't exist. * * @since 9.5.0 */ public static function get_misc_recommendations_specs() { if ( 'no' === get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) ) { return array(); } $specs = MiscRecommendationsDataSourcePoller::get_instance()->get_specs_from_data_sources(); // Return empty specs if they don't yet exist. if ( ! is_array( $specs ) ) { return array(); } return $specs; } /** * Process specs. * * @param array|null $specs Marketing recommendations spec array. * @return array */ protected static function evaluate_specs( ?array $specs = null ) { $suggestions = array(); $errors = array(); foreach ( $specs as $spec ) { try { $suggestions[] = self::object_to_array( $spec ); } catch ( \Throwable $e ) { $errors[] = $e; } } return array( 'suggestions' => $suggestions, 'errors' => $errors, ); } /** * Load recommended plugins from WooCommerce.com * * @return array */ public static function get_recommended_plugins(): array { $specs = self::get_specs(); $results = self::evaluate_specs( $specs ); $specs_to_return = $results['suggestions']; $specs_to_save = null; if ( empty( $specs_to_return ) ) { // When suggestions is empty, replace it with defaults and save for 3 hours. $specs_to_save = DefaultMarketingRecommendations::get_all(); $specs_to_return = self::evaluate_specs( $specs_to_save )['suggestions']; } elseif ( count( $results['errors'] ) > 0 ) { // When suggestions is not empty but has errors, save it for 3 hours. $specs_to_save = $specs; } if ( $specs_to_save ) { MarketingRecommendationsDataSourcePoller::get_instance()->set_specs_transient( $specs_to_save, 3 * HOUR_IN_SECONDS ); } $errors = $results['errors']; if ( ! empty( $errors ) ) { self::log_errors( $errors ); } return $specs_to_return; } /** * Return only the recommended marketing channels from WooCommerce.com. * * @return array */ public static function get_recommended_marketing_channels(): array { return array_filter( self::get_recommended_plugins(), function ( array $plugin_data ) { return self::is_marketing_channel_plugin( $plugin_data ); } ); } /** * Return all recommended marketing extensions EXCEPT the marketing channels from WooCommerce.com. * * @return array */ public static function get_recommended_marketing_extensions_excluding_channels(): array { return array_filter( self::get_recommended_plugins(), function ( array $plugin_data ) { return self::is_marketing_plugin( $plugin_data ) && ! self::is_marketing_channel_plugin( $plugin_data ); } ); } /** * Load misc recommendations from WooCommerce.com * * @since 9.5.0 * @return array */ public static function get_misc_recommendations(): array { $specs = self::get_misc_recommendations_specs(); $results = self::evaluate_specs( $specs ); $specs_to_return = $results['suggestions']; $specs_to_save = null; if ( empty( $specs_to_return ) ) { // When misc_recommendations is empty, replace it with defaults and save for 3 hours. $specs_to_save = array(); } elseif ( count( $results['errors'] ) > 0 ) { // When misc_recommendations is not empty but has errors, save it for 3 hours. $specs_to_save = $specs; } if ( $specs_to_save ) { MiscRecommendationsDataSourcePoller::get_instance()->set_specs_transient( $specs_to_save, 3 * HOUR_IN_SECONDS ); } $errors = $results['errors']; if ( ! empty( $errors ) ) { self::log_errors( $errors ); } return $specs_to_return; } /** * Returns whether a plugin is a marketing extension. * * @param array $plugin_data The plugin properties returned by the API. * * @return bool */ protected static function is_marketing_plugin( array $plugin_data ): bool { $categories = $plugin_data['categories'] ?? array(); return in_array( self::MARKETING_EXTENSION_CATEGORY_SLUG, $categories, true ); } /** * Returns whether a plugin is a marketing channel. * * @param array $plugin_data The plugin properties returned by the API. * * @return bool */ protected static function is_marketing_channel_plugin( array $plugin_data ): bool { if ( ! self::is_marketing_plugin( $plugin_data ) ) { return false; } $subcategories = $plugin_data['subcategories'] ?? array(); foreach ( $subcategories as $subcategory ) { if ( isset( $subcategory['slug'] ) && self::MARKETING_CHANNEL_SUBCATEGORY_SLUG === $subcategory['slug'] ) { return true; } } return false; } /** * Convert an object to an array. * This is used to convert the specs to an array so that they can be returned by the API. * * @param mixed $obj Object to convert. * @param array &$visited Reference to an array keeping track of all seen objects to detect circular references. * @return array */ public static function object_to_array( $obj, &$visited = array() ) { if ( is_object( $obj ) ) { if ( in_array( $obj, $visited, true ) ) { // Circular reference detected. return null; } $visited[] = $obj; $obj = (array) $obj; } if ( is_array( $obj ) ) { $new = array(); foreach ( $obj as $key => $val ) { $new[ $key ] = self::object_to_array( $val, $visited ); } } else { $new = $obj; } return $new; } } Features/MarketingRecommendations/DefaultMarketingRecommendations.php 0000777 00000040623 15252240713 0022337 0 ustar 00 <?php /** * Gets a list of fallback methods if remote fetching is disabled. */ namespace Automattic\WooCommerce\Admin\Features\MarketingRecommendations; defined( 'ABSPATH' ) || exit; /** * Default Marketing Recommendations */ class DefaultMarketingRecommendations { /** * Get default specs. * * @return array Default specs. */ public static function get_all() { // Icon directory URL. $icon_dir_url = WC_ADMIN_IMAGES_FOLDER_URL . '/marketing'; $utm_string = '?utm_source=marketingtab&utm_medium=product&utm_campaign=wcaddons'; // Categories. Note that these are keys used in code, not texts to be displayed in the UI. $marketing = 'marketing'; $coupons = 'coupons'; // Subcategories. $sales_channels = array( 'slug' => 'sales-channels', 'name' => __( 'Sales channels', 'woocommerce' ), ); $email = array( 'slug' => 'email', 'name' => __( 'Email', 'woocommerce' ), ); $automations = array( 'slug' => 'automations', 'name' => __( 'Automations', 'woocommerce' ), ); $conversion = array( 'slug' => 'conversion', 'name' => __( 'Conversion', 'woocommerce' ), ); $crm = array( 'slug' => 'crm', 'name' => __( 'CRM', 'woocommerce' ), ); // Tags. $built_by_woocommerce = array( 'slug' => 'built-by-woocommerce', 'name' => __( 'Built by WooCommerce', 'woocommerce' ), ); return array( array( 'title' => 'Google for WooCommerce', 'description' => __( 'Get in front of shoppers and drive traffic so you can grow your business with Smart Shopping Campaigns and free listings.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/google-listings-and-ads/{$utm_string}", 'direct_install' => true, 'icon' => "{$icon_dir_url}/google.svg", 'product' => 'google-listings-and-ads', 'plugin' => 'google-listings-and-ads/google-listings-and-ads.php', 'categories' => array( $marketing, ), 'subcategories' => array( $sales_channels, ), 'tags' => array( $built_by_woocommerce, ), ), array( 'title' => 'Pinterest for WooCommerce', 'description' => __( 'Grow your business on Pinterest! Use this official plugin to allow shoppers to Pin products while browsing your store, track conversions, and advertise on Pinterest.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/pinterest-for-woocommerce/{$utm_string}", 'direct_install' => true, 'icon' => "{$icon_dir_url}/pinterest.svg", 'product' => 'pinterest-for-woocommerce', 'plugin' => 'pinterest-for-woocommerce/pinterest-for-woocommerce.php', 'categories' => array( $marketing, ), 'subcategories' => array( $sales_channels, ), 'tags' => array( $built_by_woocommerce, ), ), array( 'title' => 'TikTok for WooCommerce', 'description' => __( 'Create advertising campaigns and reach one billion global users with TikTok for WooCommerce.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/tiktok-for-woocommerce/{$utm_string}", 'direct_install' => true, 'icon' => "{$icon_dir_url}/tiktok.jpg", 'product' => 'tiktok-for-business', 'plugin' => 'tiktok-for-business/tiktok-for-woocommerce.php', 'categories' => array( $marketing, ), 'subcategories' => array( $sales_channels, ), 'tags' => array(), ), array( 'title' => 'Blaze Ads', 'description' => __( 'The quickest way to grow your business by advertising to over 100 million users across Tumblr and WordPress, starting at just \$5/day.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/blaze-ads/{$utm_string}", 'direct_install' => true, 'icon' => "{$icon_dir_url}/blaze.svg", 'product' => 'blaze-ads', 'plugin' => 'blaze-ads/blaze-ads.php', 'categories' => array( $marketing, ), 'subcategories' => array( $sales_channels, ), 'tags' => array( $built_by_woocommerce, ), ), array( 'title' => 'Facebook for WooCommerce', 'description' => __( 'List products and create ads on Facebook and Instagram.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/facebook/{$utm_string}", 'direct_install' => true, 'icon' => "{$icon_dir_url}/facebook.svg", 'product' => 'facebook-for-woocommerce', 'plugin' => 'facebook-for-woocommerce/facebook-for-woocommerce.php', 'categories' => array( $marketing, ), 'subcategories' => array( $sales_channels, ), 'tags' => array(), ), array( 'title' => 'Meta Ads and Pixel by Kliken', 'description' => __( 'Automate Facebook & Instagram marketing with Kliken. Launch ads and schedule a month of posts in 5 minutes—first 5 free! Plans start at just $20/mo.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/meta-ads-and-pixel/{$utm_string}", 'direct_install' => true, 'icon' => "{$icon_dir_url}/kliken.svg", 'product' => 'kliken-ads-pixel-for-meta', 'plugin' => 'kliken-ads-pixel-for-meta/kliken-ads-pixel-for-meta.php', 'categories' => array( $marketing, ), 'subcategories' => array( $sales_channels, ), 'tags' => array(), ), array( 'title' => 'MailPoet', 'description' => __( 'Create and send purchase follow-up emails, newsletters, and promotional campaigns straight from your dashboard.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/mailpoet/{$utm_string}", 'direct_install' => true, 'icon' => "{$icon_dir_url}/mailpoet.svg", 'product' => 'mailpoet', 'plugin' => 'mailpoet/mailpoet.php', 'categories' => array( $marketing, ), 'subcategories' => array( $email, ), 'tags' => array( $built_by_woocommerce, ), ), array( 'title' => 'Mailchimp for WooCommerce', 'description' => __( 'Send targeted campaigns, recover abandoned carts and more with Mailchimp.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/mailchimp-for-woocommerce/{$utm_string}", 'direct_install' => true, 'icon' => "{$icon_dir_url}/mailchimp.svg", 'product' => 'mailchimp-for-woocommerce', 'plugin' => 'mailchimp-for-woocommerce/mailchimp-woocommerce.php', 'categories' => array( $marketing, ), 'subcategories' => array( $email, ), 'tags' => array(), ), array( 'title' => 'Klaviyo for WooCommerce', 'description' => __( 'Grow and retain customers with intelligent, impactful email and SMS marketing automation and a consolidated view of customer interactions.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/klaviyo-for-woocommerce/{$utm_string}", 'direct_install' => true, 'icon' => "{$icon_dir_url}/klaviyo.png", 'product' => 'klaviyo', 'plugin' => 'klaviyo/klaviyo.php', 'categories' => array( $marketing, ), 'subcategories' => array( $email, ), 'tags' => array(), ), array( 'title' => 'AutomateWoo', 'description' => __( 'Convert and retain customers with automated marketing that does the hard work for you.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/automatewoo/{$utm_string}", 'direct_install' => false, 'icon' => "{$icon_dir_url}/automatewoo.svg", 'product' => 'automatewoo', 'plugin' => 'automatewoo/automatewoo.php', 'categories' => array( $marketing, ), 'subcategories' => array( $automations, ), 'tags' => array( $built_by_woocommerce, ), ), array( 'title' => 'AutomateWoo Refer a Friend', 'description' => __( 'Boost your organic sales by adding a customer referral program to your WooCommerce store.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/automatewoo-refer-a-friend/{$utm_string}", 'direct_install' => false, 'icon' => "{$icon_dir_url}/automatewoo.svg", 'product' => 'automatewoo-referrals', 'plugin' => 'automatewoo-referrals/automatewoo-referrals.php', 'categories' => array( $marketing, ), 'subcategories' => array( $automations, ), 'tags' => array( $built_by_woocommerce, ), ), array( 'title' => 'AutomateWoo Birthdays', 'description' => __( 'Delight customers and boost organic sales with a special WooCommerce birthday email (and coupon!) on their special day.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/automatewoo-birthdays/{$utm_string}", 'direct_install' => false, 'icon' => "{$icon_dir_url}/automatewoo.svg", 'product' => 'automatewoo-birthdays', 'plugin' => 'automatewoo-birthdays/automatewoo-birthdays.php', 'categories' => array( $marketing, ), 'subcategories' => array( $automations, ), 'tags' => array( $built_by_woocommerce, ), ), array( 'title' => 'Trustpilot Reviews', 'description' => __( 'Collect and showcase verified reviews that consumers trust.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/trustpilot-reviews/{$utm_string}", 'direct_install' => true, 'icon' => "{$icon_dir_url}/trustpilot.png", 'product' => 'trustpilot-reviews', 'plugin' => 'trustpilot-reviews/wc_trustpilot.php', 'categories' => array( $marketing, ), 'subcategories' => array( $conversion, ), 'tags' => array(), ), array( 'title' => 'Vimeo for WooCommerce', 'description' => __( 'Turn your product images into stunning videos that engage and convert audiences - no video experience required.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/vimeo/{$utm_string}", 'direct_install' => true, 'icon' => "{$icon_dir_url}/vimeo.png", 'product' => 'vimeo', 'plugin' => 'vimeo/Core.php', 'categories' => array( $marketing, ), 'subcategories' => array( $conversion, ), 'tags' => array(), ), array( 'title' => 'Jetpack CRM for WooCommerce', 'description' => __( 'Harness data from WooCommerce to grow your business. Manage leads, customers, and segments, through automation, quotes, invoicing, billing, and email marketing. Power up your store with CRM.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/jetpack-crm/{$utm_string}", 'direct_install' => true, 'icon' => "{$icon_dir_url}/jetpack-crm.svg", 'product' => 'zero-bs-crm', 'plugin' => 'zero-bs-crm/ZeroBSCRM.php', 'categories' => array( $marketing, ), 'subcategories' => array( $crm, ), 'tags' => array(), ), array( 'title' => 'WooCommerce Zapier', 'description' => __( 'Integrate your WooCommerce store with 5000+ cloud apps and services today. Trusted by 11,000+ users.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/woocommerce-zapier/{$utm_string}", 'direct_install' => false, 'icon' => "{$icon_dir_url}/zapier.png", 'product' => 'woocommerce-zapier', 'plugin' => 'woocommerce-zapier/woocommerce-zapier.php', 'categories' => array( $marketing, ), 'subcategories' => array( $crm, ), 'tags' => array(), ), array( 'title' => 'Salesforce', 'description' => __( 'Sync your website\'s data like contacts, products, and orders over Salesforce CRM with Salesforce Integration for WooCommerce.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/integration-with-salesforce-for-woocommerce/{$utm_string}", 'direct_install' => false, 'icon' => "{$icon_dir_url}/salesforce.jpg", 'product' => 'integration-with-salesforce', 'plugin' => 'integration-with-salesforce/integration-with-salesforce.php', 'categories' => array( $marketing, ), 'subcategories' => array( $crm, ), 'tags' => array(), ), array( 'title' => 'Personalized Coupons', 'description' => __( 'Generate dynamic personalized coupons for your customers that increase purchase rates.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/automatewoo/{$utm_string}", 'direct_install' => false, 'icon' => "{$icon_dir_url}/automatewoo-personalized-coupons.svg", 'product' => 'automatewoo', 'plugin' => 'automatewoo/automatewoo.php', 'categories' => array( $coupons, ), 'subcategories' => array(), 'tags' => array(), ), array( 'title' => 'Smart Coupons', 'description' => __( 'Powerful, "all in one" solution for gift certificates, store credits, discount coupons and vouchers.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/smart-coupons/{$utm_string}", 'direct_install' => false, 'icon' => "{$icon_dir_url}/woocommerce-smart-coupons.svg", 'product' => 'woocommerce-smart-coupons', 'plugin' => 'woocommerce-smart-coupons/woocommerce-smart-coupons.php', 'categories' => array( $coupons, ), 'subcategories' => array(), 'tags' => array(), ), array( 'title' => 'URL Coupons', 'description' => __( 'Create a unique URL that applies a discount and optionally adds one or more products to the customer\'s cart.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/url-coupons/{$utm_string}", 'direct_install' => false, 'icon' => "{$icon_dir_url}/woocommerce-url-coupons.svg", 'product' => 'woocommerce-url-coupons', 'plugin' => 'woocommerce-url-coupons/woocommerce-url-coupons.php', 'categories' => array( $coupons, ), 'subcategories' => array(), 'tags' => array(), ), array( 'title' => 'WooCommerce Store Credit', 'description' => __( 'Create "store credit" coupons for customers which are redeemable at checkout.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/store-credit/{$utm_string}", 'direct_install' => false, 'icon' => "{$icon_dir_url}/woocommerce-store-credit.svg", 'product' => 'woocommerce-store-credit', 'plugin' => 'woocommerce-store-credit/woocommerce-store-credit.php', 'categories' => array( $coupons, ), 'subcategories' => array(), 'tags' => array(), ), array( 'title' => 'Free Gift Coupons', 'description' => __( 'Give away a free item to any customer with the coupon code.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/free-gift-coupons/{$utm_string}", 'direct_install' => false, 'icon' => "{$icon_dir_url}/woocommerce-free-gift-coupons.svg", 'product' => 'woocommerce-free-gift-coupons', 'plugin' => 'woocommerce-free-gift-coupons/woocommerce-free-gift-coupons.php', 'categories' => array( $coupons, ), 'subcategories' => array(), 'tags' => array(), ), array( 'title' => 'Group Coupons', 'description' => __( 'Coupons for groups. Provides the option to have coupons that are restricted to group members or roles. Works with the free Groups plugin.', 'woocommerce' ), 'url' => "https://woocommerce.com/products/group-coupons/{$utm_string}", 'direct_install' => false, 'icon' => "{$icon_dir_url}/woocommerce-group-coupons.svg", 'product' => 'woocommerce-group-coupons', 'plugin' => 'woocommerce-group-coupons/woocommerce-group-coupons.php', 'categories' => array( $coupons, ), 'subcategories' => array(), 'tags' => array(), ), ); } } Features/Settings/Transformer.php 0000777 00000023363 15252240713 0013154 0 ustar 00 <?php /** * WooCommerce Settings Data Transformer. */ declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\Features\Settings; /** * Transforms WooCommerce settings data into a structured format with logical groupings. */ class Transformer { /** * Current group being processed. * * @var array|null */ private ?array $current_group = null; /** * Current checkbox group being processed. * * @var array|null */ private ?array $current_checkbox_group = null; /** * Transform settings data. * * @param array $raw_settings Raw settings data. * * @return array Transformed settings data. */ public function transform( array $raw_settings ): array { $transformed = array(); foreach ( $raw_settings as $tab_id => $tab ) { // If the tab doesn't have sections, or the sections aren't an array, skip it. if ( ! isset( $tab['sections'] ) || ! is_array( $tab['sections'] ) ) { $transformed[ $tab_id ] = $tab; continue; } $transformed[ $tab_id ] = $tab; $transformed[ $tab_id ]['sections'] = $this->transform_sections( $tab['sections'] ); } return $transformed; } /** * Transform sections within a tab. * * @param array $sections Sections to transform. * * @return array Transformed sections. */ private function transform_sections( array $sections ): array { $transformed_sections = array(); foreach ( $sections as $section_id => $section ) { // If the section doesn't have settings, or the settings aren't an array, skip it. if ( ! isset( $section['settings'] ) || ! is_array( $section['settings'] ) ) { $transformed_sections[ $section_id ] = $section; continue; } $transformed_sections[ $section_id ] = $section; $transformed_sections[ $section_id ]['settings'] = $this->transform_section_settings( $section['settings'] ); } return $transformed_sections; } /** * Transform settings within a section. * * @param array $settings Settings to transform. * * @return array Transformed settings. */ private function transform_section_settings( array $settings ): array { $this->reset_state(); $transformed_settings = array(); foreach ( $settings as $setting ) { $this->process_setting( $setting, $transformed_settings ); } $this->finalize_transformation( $transformed_settings ); return $transformed_settings; } /** * Process individual setting. * * @param array $setting Setting to process. * @param array $transformed_settings Transformed settings array. */ private function process_setting( ?array $setting, array &$transformed_settings ): void { if ( ! isset( $setting ) ) { return; } $type = $setting['type'] ?? ''; if ( $this->current_checkbox_group && 'checkbox' !== $type ) { // It's expected that a checkbox group will always be closed before a non-checkbox setting. // If not, it's likely a checkbox group was not closed properly so we flush the current checkbox group and add the setting as-is. $this->flush_current_checkbox_group(); } switch ( $type ) { case 'title': $this->handle_group_start( $setting, $transformed_settings ); break; case 'sectionend': $this->handle_group_end( $setting, $transformed_settings ); break; case 'checkbox': $this->handle_checkbox_setting( $setting, $transformed_settings ); break; case 'info': if ( ! empty( $setting['text'] ) ) { $setting['text'] = wp_kses_post( wpautop( wptexturize( $setting['text'] ) ) ); } if ( ! empty( $setting['row_class'] ) && substr( $setting['row_class'], 0, 16 ) !== 'wc-settings-row-' ) { $setting['row_class'] = 'wc-settings-row-' . $setting['row_class']; } $this->add_setting( $setting, $transformed_settings ); break; default: $this->add_setting( $setting, $transformed_settings ); break; } } /** * Handle the start of a new group. * * @param array $setting Setting to add. * @param array $transformed_settings Transformed settings array. */ private function handle_group_start( array $setting, array &$transformed_settings ): void { // If we already have a group, flush it to settings before starting a new one. if ( $this->current_group ) { $this->flush_current_group( $transformed_settings ); } $this->current_group = array( $setting ); } /** * Handle the end of a group. * * @param array $setting Setting to add. * @param array $transformed_settings Transformed settings array. */ private function handle_group_end( array $setting, array &$transformed_settings ): void { $ids_match = $this->current_group && isset( $this->current_group[0]['id'] ) && isset( $setting['id'] ) && $this->current_group[0]['id'] === $setting['id']; $ids_match_undefined = $this->current_group && ! isset( $this->current_group[0]['id'] ) && ! isset( $setting['id'] ); // If IDs match, add the group and close it. if ( $ids_match || $ids_match_undefined ) { // Compose the group setting. $title_setting = array_shift( $this->current_group ); $title_setting['id'] = $title_setting['id'] ?? wp_unique_prefixed_id( 'setting_group' ); $transformed_settings[] = array_merge( $title_setting, array( 'type' => 'group', 'settings' => $this->current_group, ) ); $this->current_group = null; return; } // If IDs don't match, we don't need to transform anything so flush the current group. $this->flush_current_group( $transformed_settings ); $this->add_setting( $setting, $transformed_settings ); } /** * Flush current group to transformed settings. * * @param array $transformed_settings Transformed settings array. */ private function flush_current_group( array &$transformed_settings ): void { if ( is_array( $this->current_group ) && ! empty( $this->current_group ) ) { $this->current_group[0]['id'] = $this->current_group[0]['id'] ?? wp_unique_prefixed_id( 'setting_title' ); $transformed_settings = array_merge( $transformed_settings, $this->current_group ); } $this->current_group = null; } /** * Handle checkbox setting and grouping. * * @param array $setting Setting to add. * @param array $transformed_settings Transformed settings array. */ private function handle_checkbox_setting( array $setting, array &$transformed_settings ): void { $checkboxgroup = $setting['checkboxgroup'] ?? ''; switch ( $checkboxgroup ) { case 'start': $this->start_checkbox_group( $setting ); break; case 'end': $this->end_checkbox_group( $setting, $transformed_settings ); break; default: $this->handle_checkbox_group_item( $setting, $transformed_settings ); break; } } /** * Start a new checkbox group. * * @param array $setting Setting to add. */ private function start_checkbox_group( array $setting ): void { // If we already have an open checkbox group, flush it to settings before starting a new one. if ( is_array( $this->current_checkbox_group ) ) { $this->flush_current_checkbox_group(); } $this->current_checkbox_group = array( $setting ); } /** * End current checkbox group. * * @param array $setting Setting to add. * @param array $transformed_settings Transformed settings array. */ private function end_checkbox_group( array $setting, array &$transformed_settings ): void { if ( empty( $this->current_checkbox_group ) ) { // If we don't have an open checkbox group, add the setting as-is. $this->add_setting( $setting, $transformed_settings ); return; } $this->current_checkbox_group[] = $setting; $first_setting = $this->current_checkbox_group[0]; $checkbox_group_setting = array( 'id' => wp_unique_prefixed_id( 'setting_checkboxgroup' ), 'type' => 'checkboxgroup', 'title' => $first_setting['title'] ?? '', 'settings' => $this->current_checkbox_group, ); $this->add_setting( $checkbox_group_setting, $transformed_settings ); $this->current_checkbox_group = null; } /** * Handle checkbox within a group. * * @param array $setting Setting to add. * @param array $transformed_settings Transformed settings array. */ private function handle_checkbox_group_item( array $setting, array &$transformed_settings ): void { if ( is_array( $this->current_checkbox_group ) ) { $this->current_checkbox_group[] = $setting; return; } // If we don't have an open checkbox group, add the setting as-is. $this->add_setting( $setting, $transformed_settings ); } /** * Flush current checkbox group to transformed settings. */ private function flush_current_checkbox_group(): void { if ( is_array( $this->current_checkbox_group ) ) { if ( is_array( $this->current_group ) ) { $this->current_group = array_merge( $this->current_group, $this->current_checkbox_group ); } else { $this->current_group = $this->current_checkbox_group; } $this->current_checkbox_group = null; } } /** * Add setting to current context (group or root). * * @param array $setting Setting to add. * @param array $transformed_settings Transformed settings array. */ private function add_setting( array $setting, array &$transformed_settings ): void { $setting['id'] = $setting['id'] ?? wp_unique_prefixed_id( 'setting_field' ); if ( is_array( $this->current_group ) ) { $this->current_group[] = $setting; return; } $transformed_settings[] = $setting; } /** * Finalize the transformation process. * * @param array &$transformed_settings Transformed settings array. */ private function finalize_transformation( array &$transformed_settings ): void { $this->flush_current_checkbox_group(); $this->flush_current_group( $transformed_settings ); } /** * Reset the state to its initial values. */ public function reset_state(): void { $this->current_group = null; $this->current_checkbox_group = null; } } Features/Settings/Init.php 0000777 00000016065 15252240713 0011556 0 ustar 00 <?php /** * WooCommerce Settings. */ declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\Features\Settings; use Automattic\WooCommerce\Internal\Admin\WCAdminAssets; /** * Contains backend logic for the Settings feature. */ class Init { /** * Class instance. * * @var Init 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_filter( 'woocommerce_admin_shared_settings', array( __CLASS__, 'add_component_settings' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_settings_editor_scripts' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_settings_editor_styles' ) ); } /** * Check if the current screen is the WooCommerce settings page. * * @return bool */ public function is_settings_page() { $screen = get_current_screen(); return $screen && 'woocommerce_page_wc-settings' === $screen->id; } /** * Enqueue styles for the settings editor. */ public function enqueue_settings_editor_styles() { if ( ! self::get_instance()->is_settings_page() ) { return; } $style_name = 'wc-admin-edit-settings'; $style_path_name = 'settings'; $style_assets_filename = WCAdminAssets::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; // Settings Editor styles. wp_register_style( $style_name, WCAdminAssets::get_url( $style_path_name . '/style', 'css' ), // Manually set dependencies for now, because the asset file is not being generated correctly. // See plugins/woocommerce/assets/client/admin/settings-editor/style.asset.php. Should be: `isset( $style_assets['dependencies'] ) ? $style_assets['dependencies'] : array(),`. array( 'wp-components', 'wc-components' ), WCAdminAssets::get_file_version( 'css', $style_assets['version'] ), ); wp_enqueue_style( $style_name ); // Global presets styles. wp_register_style( 'wc-global-presets', false ); // phpcs:ignore wp_add_inline_style( 'wc-global-presets', wp_get_global_stylesheet( array( 'presets' ) ) ); wp_enqueue_style( 'wc-global-presets' ); } /** * Enqueue scripts for the settings editor. */ public function enqueue_settings_editor_scripts() { if ( ! self::get_instance()->is_settings_page() ) { return; } // Make sure the Settings Editor package is loaded. wp_enqueue_script( 'wc-settings-editor' ); wp_enqueue_style( 'wc-settings-editor' ); $script_name = 'wc-admin-edit-settings'; $script_path_name = 'settings'; $script_assets_filename = WCAdminAssets::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; wp_enqueue_script( $script_name, WCAdminAssets::get_url( $script_path_name . '/index', 'js' ), $script_assets['dependencies'], WCAdminAssets::get_file_version( 'js', $script_assets['version'] ), true ); wp_set_script_translations( 'wc-admin-' . $script_name, 'woocommerce' ); } /** * Add the necessary data to initially load the WooCommerce Settings pages. * * @param array $settings Array of component settings. * @return array Array of component settings. */ public static function add_component_settings( $settings ) { if ( ! self::get_instance()->is_settings_page() ) { return $settings; } global $wp_scripts; // Set the scripts that all settings pages should have. $ignored_settings_scripts = array( 'wc-admin-app', 'woocommerce_admin', 'wc-settings-editor', 'wc-admin-edit-settings', 'woo-tracks', 'woocommerce-admin-test-helper', 'woocommerce-beta-tester-live-branches', 'WCPAY_DASH_APP', ); $default_scripts_handles = array_diff( $wp_scripts->queue, $ignored_settings_scripts, ); $settings['settingsScripts']['_default'] = self::get_script_urls( $default_scripts_handles ); // Add the settings data to the settings array. $setting_pages = \WC_Admin_Settings::get_settings_pages(); $settings = self::get_page_data( $settings, $setting_pages ); return $settings; } /** * Get the page data for the settings editor. * * @param array $settings The settings array. * @param array $setting_pages The setting pages. * @return array The settings array. */ public static function get_page_data( $settings, $setting_pages ) { global $wp_scripts; /** * Filters the settings tabs array. * * @since 2.5.0 * * @param array $available_pages The available pages. */ $available_pages = apply_filters( 'woocommerce_settings_tabs_array', array() ); $pages = array(); foreach ( $setting_pages as $setting_page ) { // If any page has removed itself from the tabs array, avoid adding this page to the settings editor. if ( ! in_array( $setting_page->get_id(), array_keys( $available_pages ), true ) ) { continue; } $scripts_before_adding_settings = $wp_scripts->queue; $pages = $setting_page->add_settings_page_data( $pages ); $settings_scripts_handles = array_diff( $wp_scripts->queue, $scripts_before_adding_settings ); $settings['settingsScripts'][ $setting_page->get_id() ] = self::get_script_urls( $settings_scripts_handles ); } $transformer = new Transformer(); $settings['settingsData']['pages'] = $transformer->transform( $pages ); $settings['settingsData']['start'] = $setting_pages[0]->get_custom_view( 'woocommerce_settings_start' ); $settings['settingsData']['_wpnonce'] = wp_create_nonce( 'wp_rest' ); return $settings; } /** * Retrieve the script URLs from the provided script handles. * This will also filter out scripts from WordPress core since they only need to be loaded once. * * @param array $script_handles Array of script handles. * @return array Array of script URLs. */ private static function get_script_urls( $script_handles ) { global $wp_scripts; $script_urls = array(); foreach ( $script_handles as $script ) { $registered_script = $wp_scripts->registered[ $script ]; if ( ! isset( $registered_script->src ) ) { continue; } // Skip scripts from WordPress core since they only need to be loaded once. if ( strpos( $registered_script->src, '/' . WPINC . '/js' ) === 0 || strpos( $registered_script->src, '/wp-admin/js' ) === 0 ) { continue; } $src = $registered_script->src; $ver = $registered_script->ver ? $registered_script->ver : false; // Add version query parameter. if ( $ver ) { $src = add_query_arg( 'ver', $ver, $src ); } // Add home URL if the src is a relative path. if ( strpos( $src, '/' ) === 0 ) { $script_urls[] = home_url( $src ); } else { $script_urls[] = $src; } } return $script_urls; } } Features/Onboarding.php 0000777 00000005176 15252240713 0011136 0 ustar 00 <?php /** * WooCommerce Onboarding */ namespace Automattic\WooCommerce\Admin\Features; use Automattic\WooCommerce\Admin\DeprecatedClassFacade; /** * Contains backend logic for the onboarding profile and checklist feature. * * @deprecated since 6.3.0, use WooCommerce\Internal\Admin\Onboarding. */ class Onboarding extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Admin\Features\Onboarding'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '6.3.0'; /** * Hook into WooCommerce. */ public function __construct() { } /** * Get a list of allowed industries for the onboarding wizard. * * @deprecated 6.3.0 * @return array */ public static function get_allowed_industries() { wc_deprecated_function( 'get_allowed_industries', '6.3', '\Automattic\WooCommerce\Internal\Admin\OnboardingIndustries::get_allowed_industries()' ); return \Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingIndustries::get_allowed_industries(); } /** * Get a list of allowed product types for the onboarding wizard. * * @deprecated 6.3.0 * @return array */ public static function get_allowed_product_types() { wc_deprecated_function( 'get_allowed_product_types', '6.3', '\Automattic\WooCommerce\Internal\Admin\OnboardingProducts::get_allowed_product_types()' ); return \Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProducts::get_allowed_product_types(); } /** * Get a list of themes for the onboarding wizard. * * @deprecated 6.3.0 * @return array */ public static function get_themes() { wc_deprecated_function( 'get_themes', '6.3' ); return array(); } /** * Get theme data used in onboarding theme browser. * * @deprecated 6.3.0 * @param WP_Theme $theme Theme to gather data from. * @return array */ public static function get_theme_data( $theme ) { wc_deprecated_function( 'get_theme_data', '6.3' ); return array(); } /** * Gets an array of themes that can be installed & activated via the onboarding wizard. * * @deprecated 6.3.0 * @return array */ public static function get_allowed_themes() { wc_deprecated_function( 'get_allowed_themes', '6.3' ); return array(); } /** * Get dynamic product data from API. * * @deprecated 6.3.0 * @param array $product_types Array of product types. * @return array */ public static function get_product_data( $product_types ) { wc_deprecated_function( 'get_product_data', '6.3' ); return array(); } } Features/ShippingPartnerSuggestions/ShippingPartnerSuggestionsDataSourcePoller.php 0000777 00000002133 15252240713 0025113 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\ShippingPartnerSuggestions; use Automattic\WooCommerce\Admin\RemoteSpecs\DataSourcePoller; use WC_Helper; /** * Specs data source poller class for shipping partner suggestions. */ class ShippingPartnerSuggestionsDataSourcePoller extends DataSourcePoller { /** * Data Source Poller ID. */ const ID = 'shipping_partner_suggestions'; /** * Default data sources array. * * @deprecated since 9.5.0. Use get_data_sources() instead. */ const DATA_SOURCES = array(); /** * Class instance. * * @var ShippingPartnerSuggestionsDataSourcePoller 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() { return array( WC_Helper::get_woocommerce_com_base_url() . 'wp-json/wccom/shipping-partner-suggestions/2.0/suggestions.json', ); } } Features/ShippingPartnerSuggestions/ShippingPartnerSuggestions.php 0000777 00000005035 15252240713 0021766 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\ShippingPartnerSuggestions; use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\EvaluateSuggestion; use Automattic\WooCommerce\Admin\RemoteSpecs\RemoteSpecsEngine; /** * Class ShippingPartnerSuggestions */ class ShippingPartnerSuggestions extends RemoteSpecsEngine { /** * Go through the specs and run them. * * @param array|null $specs shipping partner suggestion spec array. * @return array */ public static function get_suggestions( ?array $specs = null ) { $locale = get_user_locale(); $specs = is_array( $specs ) ? $specs : self::get_specs(); $results = EvaluateSuggestion::evaluate_specs( $specs, array( 'source' => 'wc-shipping-partner-suggestions' ) ); $specs_to_return = $results['suggestions']; $specs_to_save = null; if ( empty( $specs_to_return ) ) { // When suggestions is empty, replace it with defaults and save for 3 hours. $specs_to_save = DefaultShippingPartners::get_all(); $specs_to_return = EvaluateSuggestion::evaluate_specs( $specs_to_save, array( 'source' => 'wc-shipping-partner-suggestions' ) )['suggestions']; } elseif ( count( $results['errors'] ) > 0 ) { // When suggestions is not empty but has errors, save it for 3 hours. $specs_to_save = $specs; } if ( $specs_to_save ) { ShippingPartnerSuggestionsDataSourcePoller::get_instance()->set_specs_transient( array( $locale => $specs_to_save ), 3 * HOUR_IN_SECONDS ); } return $specs_to_return; } /** * Get specs or fetch remotely if they don't exist. */ public static function get_specs() { if ( 'no' === get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) ) { /** * It can be used to modify shipping partner suggestions spec. * * @since 7.4.1 */ return apply_filters( 'woocommerce_admin_shipping_partner_suggestions_specs', DefaultShippingPartners::get_all() ); } $specs = ShippingPartnerSuggestionsDataSourcePoller::get_instance()->get_specs_from_data_sources(); // Fetch specs if they don't yet exist. if ( false === $specs || ! is_array( $specs ) || 0 === count( $specs ) ) { /** * It can be used to modify shipping partner suggestions spec. * * @since 7.4.1 */ return apply_filters( 'woocommerce_admin_shipping_partner_suggestions_specs', DefaultShippingPartners::get_all() ); } /** * It can be used to modify shipping partner suggestions spec. * * @since 7.4.1 */ return apply_filters( 'woocommerce_admin_shipping_partner_suggestions_specs', $specs ); } } Features/ShippingPartnerSuggestions/DefaultShippingPartners.php 0000777 00000021570 15252240713 0021225 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\ShippingPartnerSuggestions; /** * Default Shipping Partners */ class DefaultShippingPartners { /** * Get default specs. * * @return array Default specs. */ public static function get_all() { $asset_base_url = WC()->plugin_url() . '/assets/images/shipping_partners/'; $column_layout_features = array( array( 'icon' => $asset_base_url . 'timer.svg', 'title' => __( 'Save time', 'woocommerce' ), 'description' => __( 'Automatically import order information to quickly print your labels.', 'woocommerce' ), ), array( 'icon' => $asset_base_url . 'discount.svg', 'title' => __( 'Save money', 'woocommerce' ), 'description' => __( 'Shop for the best shipping rates, and access pre-negotiated discounted rates.', 'woocommerce' ), ), array( 'icon' => $asset_base_url . 'star.svg', 'title' => __( 'Wow your shoppers', 'woocommerce' ), 'description' => __( 'Keep your customers informed with tracking notifications.', 'woocommerce' ), ), ); $check_icon = $asset_base_url . 'check.svg'; return array( array( 'id' => 'woocommerce-shipstation-integration', 'name' => 'ShipStation', 'slug' => 'woocommerce-shipstation-integration', 'description' => __( 'Powerful yet easy-to-use solution:', 'woocommerce' ), 'layout_column' => array( 'image' => $asset_base_url . 'shipstation-column.svg', 'features' => $column_layout_features, ), 'layout_row' => array( 'image' => $asset_base_url . 'shipstation-row.svg', 'features' => array( array( 'icon' => $check_icon, 'description' => __( 'Discounted labels from top global carriers', 'woocommerce' ), ), array( 'icon' => $check_icon, 'description' => __( 'Sync all your selling channels in one place', 'woocommerce' ), ), array( 'icon' => $check_icon, 'description' => __( 'Advanced automated workflows and customs', 'woocommerce' ), ), array( 'icon' => $check_icon, 'description' => __( 'Instantly send tracking to your customers', 'woocommerce' ), ), array( 'icon' => $check_icon, 'description' => __( '30-day free trial', 'woocommerce' ), ), ), ), 'learn_more_link' => 'https://wordpress.org/plugins/woocommerce-shipstation-integration/', 'is_visible' => array( self::get_rules_for_countries( array( 'AU', 'CA', 'GB' ) ), ), 'available_layouts' => array( 'row', 'column' ), ), array( 'id' => 'skydropx-cotizador-y-envios', 'name' => 'Skydropx', 'slug' => 'skydropx-cotizador-y-envios', 'layout_column' => array( 'image' => $asset_base_url . 'skydropx-column.svg', 'features' => $column_layout_features, ), 'description' => '', 'learn_more_link' => 'https://wordpress.org/plugins/skydropx-cotizador-y-envios/', 'is_visible' => array( self::get_rules_for_countries( array() ), // No countries eligible for SkydropX promotion at this time. ), 'available_layouts' => array( 'column' ), ), array( 'id' => 'envia', 'name' => 'Envia', 'slug' => '', 'description' => '', 'layout_column' => array( 'image' => $asset_base_url . 'envia-column.svg', 'features' => $column_layout_features, ), 'learn_more_link' => 'https://woocommerce.com/products/envia-shipping-and-fulfillment/', 'is_visible' => array( self::get_rules_for_countries( array( 'CL', 'AR', 'PE', 'BR', 'UY', 'GT' ) ), ), 'available_layouts' => array( 'column' ), ), array( 'id' => 'easyship-woocommerce-shipping-rates', 'name' => 'Easyship', 'slug' => 'easyship-woocommerce-shipping-rates', 'description' => __( 'Simplified shipping with: ', 'woocommerce' ), 'layout_column' => array( 'image' => $asset_base_url . 'easyship-column.svg', 'features' => $column_layout_features, ), 'layout_row' => array( 'image' => $asset_base_url . 'easyship-row.svg', 'features' => array( array( 'icon' => $check_icon, 'description' => __( 'Highly discounted shipping rates', 'woocommerce' ), ), array( 'icon' => $check_icon, 'description' => __( 'Seamless order sync and label printing', 'woocommerce' ), ), array( 'icon' => $check_icon, 'description' => __( 'Branded tracking experience', 'woocommerce' ), ), array( 'icon' => $check_icon, 'description' => __( 'Built-in Tax & Duties paperwork', 'woocommerce' ), ), array( 'icon' => $check_icon, 'description' => __( 'Free Plan Available', 'woocommerce' ), ), ), ), 'learn_more_link' => 'https://woocommerce.com/products/easyship-shipping-rates/', 'is_visible' => array( self::get_rules_for_countries( array( 'SG', 'HK', 'AU', 'NZ' ) ), ), 'available_layouts' => array( 'row', 'column' ), ), array( 'id' => 'packlink-pro-shipping', 'name' => 'Packlink', 'slug' => 'packlink-pro-shipping', 'description' => __( 'Optimize your full shipping process:', 'woocommerce' ), 'layout_column' => array( 'image' => $asset_base_url . 'packlink-column.svg', 'features' => $column_layout_features, ), 'layout_row' => array( 'image' => $asset_base_url . 'packlink-row.svg', 'features' => array( array( 'icon' => $check_icon, 'description' => __( 'Automated, real-time order import', 'woocommerce' ), ), array( 'icon' => $check_icon, 'description' => __( 'Direct access to leading carriers', 'woocommerce' ), ), array( 'icon' => $check_icon, 'description' => __( 'Access competitive shipping prices', 'woocommerce' ), ), array( 'icon' => $check_icon, 'description' => __( 'Quickly bulk print labels', 'woocommerce' ), ), array( 'icon' => $check_icon, 'description' => __( 'Free shipping platform', 'woocommerce' ), ), ), ), 'learn_more_link' => 'https://wordpress.org/plugins/packlink-pro-shipping/', 'is_visible' => array( self::get_rules_for_countries( array( 'FR', 'DE', 'ES', 'IT' ) ), ), 'available_layouts' => array( 'row', 'column' ), ), array( 'id' => 'woocommerce-shipping', 'name' => 'WooCommerce Shipping', 'slug' => 'woocommerce-shipping', 'description' => __( 'Save time and money by printing your shipping labels right from your computer with WooCommerce Shipping. Try WooCommerce Shipping for free.', 'woocommerce' ), 'layout_column' => array( 'image' => $asset_base_url . 'wcs-column.svg', 'features' => array( array( 'icon' => $asset_base_url . 'printer.svg', 'title' => __( 'Buy postage when you need it', 'woocommerce' ), 'description' => __( 'No need to wonder where that stampbook went.', 'woocommerce' ), ), array( 'icon' => $asset_base_url . 'paper.svg', 'title' => __( 'Print at home', 'woocommerce' ), 'description' => __( 'Pick up an order, then just pay, print, package and post.', 'woocommerce' ), ), array( 'icon' => $asset_base_url . 'discount.svg', 'title' => __( 'Discounted rates', 'woocommerce' ), 'description' => __( 'Access discounted shipping rates with USPS, UPS, and DHL.', 'woocommerce' ), ), ), ), 'learn_more_link' => 'https://woocommerce.com/products/shipping/', 'is_visible' => array( self::get_rules_for_countries( array( 'US' ) ), (object) array( 'type' => 'not', 'operand' => array( (object) array( 'type' => 'plugins_activated', 'plugins' => array( 'woocommerce-shipping' ), ), ), ), ), 'available_layouts' => array( 'column' ), ), ); } /** * Get rules that match the store base location to one of the provided countries. * * @param array $countries Array of countries to match. * @return object Rules to match. */ public static function get_rules_for_countries( $countries ) { return (object) array( 'type' => 'base_location_country', 'operation' => 'in', 'value' => $countries, ); } } Features/Blueprint/Exporters/ExportWCSettingsShipping.php 0000777 00000012621 15252240713 0017742 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\Admin\Features\Blueprint\Exporters; use Automattic\WooCommerce\Blueprint\Steps\RunSql; use Automattic\WooCommerce\Blueprint\Steps\SetSiteOptions; use Automattic\WooCommerce\Blueprint\Util; /** * Class ExportWCSettingsShipping * * Exports WooCommerce settings on the Shipping page. * * @package Automattic\WooCommerce\Admin\Features\Blueprint\Exporters */ class ExportWCSettingsShipping extends ExportWCSettings { /** * Export WooCommerce shipping settings. * * @return array Array of RunSql|SetSiteOptions instances. */ public function export(): array { $shipping_settings = parent::export(); $steps = array_merge( array( $shipping_settings ), $this->get_steps_for_classes_and_terms(), $this->get_steps_for_zones(), $this->get_steps_for_locations(), $this->get_steps_for_methods_and_options() ); $steps[] = $this->get_step_for_local_pickup(); return $steps; } /** * Retrieve term data based on provided classes. * * @param array $classes List of classes with term IDs. * @return array Retrieved term data. */ protected function get_terms( array $classes ): array { global $wpdb; $term_ids = array_map( fn( $term ) => (int) $term['term_id'], $classes ); $term_ids = implode( ', ', $term_ids ); return ! empty( $term_ids ) ? $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}terms WHERE term_id IN (%s)", $term_ids ), ARRAY_A ) : array(); } /** * Retrieve shipping classes and related terms. * * @return array Steps for shipping classes and terms. */ protected function get_steps_for_classes_and_terms(): array { global $wpdb; $classes = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}term_taxonomy WHERE taxonomy = 'product_shipping_class'", ARRAY_A ); $classes_steps = array_map( fn( $class_row ) => new RunSql( Util::array_to_insert_sql( $class_row, $wpdb->prefix . 'term_taxonomy', 'replace into' ) ), $classes ); $terms = array_map( fn( $term ) => new RunSql( Util::array_to_insert_sql( $term, $wpdb->prefix . 'terms', 'replace into' ) ), $this->get_terms( $classes ) ); return array_merge( $classes_steps, $terms ); } /** * Get the name of the step. * * @return string */ public function get_step_name(): string { return RunSql::get_step_name(); } /** * Return label used in the frontend. * * @return string */ public function get_label(): string { return __( 'Shipping', 'woocommerce' ); } /** * Return description used in the frontend. * * @return string */ public function get_description(): string { return __( 'Includes all settings in WooCommerce | Settings | Shipping.', 'woocommerce' ); } /** * Get the alias. * * @return string */ public function get_alias(): string { return 'setWCShipping'; } /** * Retrieve shipping zones from the database. * * @return array Steps for shipping zones. */ private function get_steps_for_zones(): array { global $wpdb; return array_map( fn( $zone ) => new RunSql( Util::array_to_insert_sql( $zone, $wpdb->prefix . 'woocommerce_shipping_zones', 'replace into' ) ), $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}woocommerce_shipping_zones", ARRAY_A ) ); } /** * Retrieve shipping zone locations. * * @return array Steps for shipping zone locations. */ private function get_steps_for_locations(): array { global $wpdb; return array_map( fn( $location ) => new RunSql( Util::array_to_insert_sql( $location, $wpdb->prefix . 'woocommerce_shipping_zone_locations', 'replace into' ) ), $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}woocommerce_shipping_zone_locations", ARRAY_A ) ); } /** * Retrieve shipping methods and options. * * @return array Steps for shipping methods and options. */ private function get_steps_for_methods_and_options(): array { global $wpdb; $methods = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}woocommerce_shipping_zone_methods", ARRAY_A ); $method_options = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}options WHERE option_name LIKE 'woocommerce_flat_rate_%_settings' OR option_name LIKE 'woocommerce_free_shipping_%_settings'", ARRAY_A ); return array_merge( array_map( fn( $method ) => new RunSql( Util::array_to_insert_sql( $method, $wpdb->prefix . 'woocommerce_shipping_zone_methods', 'replace into' ) ), $methods ), array_map( fn( $option ) => new RunSql( Util::array_to_insert_sql( $option, $wpdb->prefix . 'options', 'replace into' ) ), $method_options ) ); } /** * Retrieve local pickup settings. * * @return SetSiteOptions Local pickup settings step. */ private function get_step_for_local_pickup(): SetSiteOptions { return new SetSiteOptions( array( 'woocommerce_pickup_location_settings' => get_option( 'woocommerce_pickup_location_settings', array() ), 'pickup_location_pickup_locations' => get_option( 'pickup_location_pickup_locations', array() ), ) ); } /** * Check if the current user has the required capabilities for this step. * * @return bool True if the user has the required capabilities. False otherwise. */ public function check_step_capabilities(): bool { return current_user_can( 'manage_woocommerce' ); } /** * Get the page ID for the settings page. * * @return string */ public function get_page_id(): string { return 'shipping'; } } Features/Blueprint/Exporters/ExportWCSettingsSiteVisibility.php 0000777 00000003544 15252240713 0021141 0 ustar 00 <?php declare( strict_types = 1); namespace Automattic\WooCommerce\Admin\Features\Blueprint\Exporters; use Automattic\WooCommerce\Blueprint\Exporters\HasAlias; use Automattic\WooCommerce\Blueprint\Exporters\StepExporter; use Automattic\WooCommerce\Blueprint\Steps\SetSiteOptions; use Automattic\WooCommerce\Blueprint\UseWPFunctions; /** * Class ExportWCSettingsSiteVisibility * * This class exports WooCommerce settings on the Site Visibility page. * * @package Automattic\WooCommerce\Admin\Features\Blueprint\Exporters */ class ExportWCSettingsSiteVisibility implements StepExporter, HasAlias { use UseWPFunctions; /** * Export Site Visibility settings. * * @return SetSiteOptions */ public function export() { return new SetSiteOptions( array( 'woocommerce_coming_soon' => $this->wp_get_option( 'woocommerce_coming_soon' ), 'woocommerce_store_pages_only' => $this->wp_get_option( 'woocommerce_store_pages_only' ), ) ); } /** * Get the alias for this exporter. * * @return string */ public function get_alias() { return 'setWCSettingsSiteVisibility'; } /** * Return label used in the frontend. * * @return string */ public function get_label() { return __( 'Site Visibility', 'woocommerce' ); } /** * Return description used in the frontend. * * @return string */ public function get_description() { return __( 'Includes all settings in WooCommerce | Settings | Visibility.', 'woocommerce' ); } /** * Get the name of the step. * * @return string */ public function get_step_name() { return 'setSiteOptions'; } /** * Check if the current user has the required capabilities for this step. * * @return bool True if the user has the required capabilities. False otherwise. */ public function check_step_capabilities(): bool { return current_user_can( 'manage_woocommerce' ); } } Features/Blueprint/Exporters/ExportWCSettingsAdvanced.php 0000777 00000002055 15252240713 0017666 0 ustar 00 <?php declare( strict_types = 1); namespace Automattic\WooCommerce\Admin\Features\Blueprint\Exporters; use Automattic\WooCommerce\Blueprint\UseWPFunctions; /** * Class ExportWCSettingsAdvanced * * This class exports WooCommerce settings on the Advanced page. * * @package Automattic\WooCommerce\Admin\Features\Blueprint\Exporters */ class ExportWCSettingsAdvanced extends ExportWCSettings { use UseWPFunctions; /** * Get the alias for this exporter. * * @return string */ public function get_alias() { return 'setWCSettingsAdvanced'; } /** * Return label used in the frontend. * * @return string */ public function get_label() { return __( 'Advanced', 'woocommerce' ); } /** * Return description used in the frontend. * * @return string */ public function get_description() { return __( 'Includes all settings in WooCommerce | Settings | Advanced.', 'woocommerce' ); } /** * Get the page ID for the settings page. * * @return string */ protected function get_page_id(): string { return 'advanced'; } } Features/Blueprint/Exporters/ExportWCSettings.php 0000777 00000004376 15252240713 0016250 0 ustar 00 <?php declare( strict_types = 1); namespace Automattic\WooCommerce\Admin\Features\Blueprint\Exporters; use Automattic\WooCommerce\Admin\Features\Blueprint\SettingOptions; use Automattic\WooCommerce\Blueprint\Exporters\HasAlias; use Automattic\WooCommerce\Blueprint\Exporters\StepExporter; use Automattic\WooCommerce\Blueprint\Steps\SetSiteOptions; use Automattic\WooCommerce\Blueprint\UseWPFunctions; /** * Class ExportWCSettings * * This abstract class provides the functionality for exporting WooCommerce settings on a specific page. * * @package Automattic\WooCommerce\Admin\Features\Blueprint\Exporters */ abstract class ExportWCSettings implements StepExporter, HasAlias { use UseWPFunctions; /** * The setting options class. * * @var SettingOptions */ protected $setting_options; /** * Constructor. * * @param SettingOptions|null $setting_options The setting options class. */ public function __construct( ?SettingOptions $setting_options = null ) { $this->setting_options = $setting_options ?? new SettingOptions(); } /** * Return a page I.D to export. * * @return string The page ID. */ abstract protected function get_page_id(): string; /** * Export WooCommerce settings. * * @return SetSiteOptions */ public function export() { return new SetSiteOptions( $this->setting_options->get_page_options( $this->get_page_id() ) ); } /** * Get the name of the step. * * @return string */ public function get_step_name() { return 'setSiteOptions'; } /** * Get the alias for this exporter. * * @return string */ public function get_alias() { return 'setWCSettingsGeneral'; } /** * Return label used in the frontend. * * @return string */ public function get_label() { return __( 'General', 'woocommerce' ); } /** * Return description used in the frontend. * * @return string */ public function get_description() { return __( 'Includes all settings in WooCommerce | Settings | General.', 'woocommerce' ); } /** * Check if the current user has the required capabilities for this step. * * @return bool True if the user has the required capabilities. False otherwise. */ public function check_step_capabilities(): bool { return current_user_can( 'manage_woocommerce' ); } } Features/Blueprint/Exporters/ExportWCSettingsTax.php 0000777 00000004463 15252240713 0016722 0 ustar 00 <?php declare( strict_types = 1); namespace Automattic\WooCommerce\Admin\Features\Blueprint\Exporters; use Automattic\WooCommerce\Blueprint\UseWPFunctions; use Automattic\WooCommerce\Blueprint\Steps\RunSql; use Automattic\WooCommerce\Blueprint\Util; use Automattic\WooCommerce\Admin\Features\Blueprint\SettingOptions; /** * Class ExportWCSettingsTax * * This class exports WooCommerce settings on the Tax page. * * @package Automattic\WooCommerce\Admin\Features\Blueprint\Exporters */ class ExportWCSettingsTax extends ExportWCSettings { use UseWPFunctions; /** * Constructor. * * @param SettingOptions|null $setting_options The setting options class. */ public function __construct( ?SettingOptions $setting_options = null ) { // phpcs:ignore Generic.CodeAnalysis.UselessOverridingMethod.Found parent::__construct( $setting_options ); } /** * Get the alias for this exporter. * * @return string */ public function get_alias() { return 'setWCSettingsTax'; } /** * Export WooCommerce tax rates. * * @return array array of steps */ public function export(): array { $basic_tax_settings = parent::export(); return array( $basic_tax_settings, ...$this->generateTaxRateSteps( 'wc_tax_rate_classes' ), ...$this->generateTaxRateSteps( 'woocommerce_tax_rates' ), ...$this->generateTaxRateSteps( 'woocommerce_tax_rate_locations' ), ); } /** * Return label used in the frontend. * * @return string */ public function get_label() { return __( 'Tax', 'woocommerce' ); } /** * Return description used in the frontend. * * @return string */ public function get_description() { return __( 'Includes all settings in WooCommerce | Settings | Tax.', 'woocommerce' ); } /** * Get the page ID for the settings page. * * @return string */ protected function get_page_id(): string { return 'tax'; } /** * Generate SQL steps for exporting data. * * @param string $table Table identifier. * @return array Array of RunSql steps. */ private function generateTaxRateSteps( string $table ): array { global $wpdb; $table = $wpdb->prefix . $table; return array_map( fn( $record ) => new RunSql( Util::array_to_insert_sql( $record, $table, 'replace into' ) ), $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i', $table ), ARRAY_A ), ); } } Features/Blueprint/Exporters/ExportWCPaymentGateways.php 0000777 00000004226 15252240713 0017564 0 ustar 00 <?php declare( strict_types = 1); namespace Automattic\WooCommerce\Admin\Features\Blueprint\Exporters; use Automattic\WooCommerce\Blueprint\Exporters\StepExporter; use Automattic\WooCommerce\Blueprint\Steps\SetSiteOptions; use Automattic\WooCommerce\Blueprint\Steps\Step; /** * ExportWCPaymentGateways class */ class ExportWCPaymentGateways implements StepExporter { /** * Payment gateway IDs to exclude from export * * @var array|string[] Payment gateway IDs to exclude from export */ protected array $exclude_ids = array( 'pre_install_woocommerce_payments_promotion' ); /** * Export the step * * @return Step */ public function export(): Step { $options = array(); $this->maybe_hide_wcpay_gateways(); foreach ( $this->get_wc_payment_gateways() as $id => $payment_gateway ) { if ( in_array( $id, $this->exclude_ids, true ) ) { continue; } $options[ 'woocommerce_' . $id . '_settings' ] = $payment_gateway->settings; } return new SetSiteOptions( $options ); } /** * Return the payment gateways resgietered in WooCommerce * * @return string */ public function get_wc_payment_gateways() { return WC()->payment_gateways->payment_gateways(); } /** * Get the step name * * @return string */ public function get_step_name() { return 'wcPaymentGateways'; } /** * Maybe hide WooCommerce Payments gateways * * @return void */ protected function maybe_hide_wcpay_gateways() { if ( class_exists( 'WC_Payments' ) ) { \WC_Payments::hide_gateways_on_settings_page(); } } /** * Return label used in the frontend. * * @return string */ public function get_label() { return __( 'Payments', 'woocommerce' ); } /** * Return description used in the frontend. * * @return string */ public function get_description() { return __( 'Includes all settings in WooCommerce | Settings | Payments.', 'woocommerce' ); } /** * Check if the current user has the required capabilities for this step. * * @return bool True if the user has the required capabilities. False otherwise. */ public function check_step_capabilities(): bool { return current_user_can( 'manage_woocommerce' ); } } Features/Blueprint/Exporters/ExportWCTaskOptions.php 0000777 00000003567 15252240713 0016727 0 ustar 00 <?php declare( strict_types = 1); namespace Automattic\WooCommerce\Admin\Features\Blueprint\Exporters; use Automattic\WooCommerce\Blueprint\Exporters\HasAlias; use Automattic\WooCommerce\Blueprint\Exporters\StepExporter; use Automattic\WooCommerce\Blueprint\Steps\SetSiteOptions; use Automattic\WooCommerce\Blueprint\UseWPFunctions; /** * Class ExportWCTaskOptions * * This class exports WooCommerce task options. * * @package Automattic\WooCommerce\Admin\Features\Blueprint\Exporters */ class ExportWCTaskOptions implements StepExporter, HasAlias { use UseWPFunctions; /** * Export WooCommerce task options. * * @return SetSiteOptions */ public function export() { return new SetSiteOptions( array( 'woocommerce_admin_customize_store_completed' => $this->wp_get_option( 'woocommerce_admin_customize_store_completed', 'no' ), 'woocommerce_task_list_tracked_completed_actions' => $this->wp_get_option( 'woocommerce_task_list_tracked_completed_actions', array() ), ) ); } /** * Get the name of the step. * * @return string */ public function get_step_name() { return 'setOptions'; } /** * Get the alias for this exporter. * * @return string */ public function get_alias() { return 'setWCTaskOptions'; } /** * Return label used in the frontend. * * @return string */ public function get_label() { return __( 'Task Configurations', 'woocommerce' ); } /** * Return description used in the frontend. * * @return string */ public function get_description() { return __( 'Includes the task configurations for WooCommerce.', 'woocommerce' ); } /** * Check if the current user has the required capabilities for this step. * * @return bool True if the user has the required capabilities. False otherwise. */ public function check_step_capabilities(): bool { return current_user_can( 'manage_woocommerce' ); } } Features/Blueprint/Exporters/ExportWCSettingsProducts.php 0000777 00000002055 15252240713 0017764 0 ustar 00 <?php declare( strict_types = 1); namespace Automattic\WooCommerce\Admin\Features\Blueprint\Exporters; use Automattic\WooCommerce\Blueprint\UseWPFunctions; /** * Class ExportWCSettingsProducts * * This class exports WooCommerce settings on the Products page. * * @package Automattic\WooCommerce\Admin\Features\Blueprint\Exporters */ class ExportWCSettingsProducts extends ExportWCSettings { use UseWPFunctions; /** * Get the alias for this exporter. * * @return string */ public function get_alias() { return 'setWCSettingsProducts'; } /** * Return label used in the frontend. * * @return string */ public function get_label() { return __( 'Products', 'woocommerce' ); } /** * Return description used in the frontend. * * @return string */ public function get_description() { return __( 'Includes all settings in WooCommerce | Settings | Products.', 'woocommerce' ); } /** * Get the page ID for the settings page. * * @return string */ protected function get_page_id(): string { return 'products'; } } Features/Blueprint/Exporters/ExportWCSettingsAccount.php 0000777 00000002112 15252240713 0017547 0 ustar 00 <?php declare( strict_types = 1); namespace Automattic\WooCommerce\Admin\Features\Blueprint\Exporters; use Automattic\WooCommerce\Blueprint\UseWPFunctions; /** * Class ExportWCSettingsAccount * * This class exports WooCommerce settings on the Account and Privacy page. * * @package Automattic\WooCommerce\Admin\Features\Blueprint\Exporters */ class ExportWCSettingsAccount extends ExportWCSettings { use UseWPFunctions; /** * Get the alias for this exporter. * * @return string */ public function get_alias() { return 'setWCSettingsAccount'; } /** * Return label used in the frontend. * * @return string */ public function get_label() { return __( 'Account and Privacy', 'woocommerce' ); } /** * Return description used in the frontend. * * @return string */ public function get_description() { return __( 'Includes all settings in WooCommerce | Settings | Account and Privacy.', 'woocommerce' ); } /** * Get the page ID for the settings page. * * @return string */ protected function get_page_id(): string { return 'account'; } } Features/Blueprint/Exporters/ExportWCCoreProfilerOptions.php 0000777 00000003515 15252240713 0020411 0 ustar 00 <?php declare( strict_types = 1); namespace Automattic\WooCommerce\Admin\Features\Blueprint\Exporters; use Automattic\WooCommerce\Blueprint\Exporters\StepExporter; use Automattic\WooCommerce\Blueprint\Exporters\HasAlias; use Automattic\WooCommerce\Blueprint\Steps\SetSiteOptions; use Automattic\WooCommerce\Blueprint\UseWPFunctions; /** * ExportWCCoreProfilerOptions class */ class ExportWCCoreProfilerOptions implements StepExporter, HasAlias { use UseWPFunctions; /** * Export the step * * @return SetSiteOptions */ public function export() { return new SetSiteOptions( array( 'blogname' => $this->wp_get_option( 'blogname' ), 'woocommerce_allow_tracking' => $this->wp_get_option( 'woocommerce_allow_tracking' ), 'woocommerce_onboarding_profile' => $this->wp_get_option( 'woocommerce_onboarding_profile', array() ), 'woocommerce_default_country' => $this->wp_get_option( 'woocommerce_default_country' ), ) ); } /** * Get the step name * * @return string */ public function get_step_name() { return 'setSiteOptions'; } /** * Get the alias * * @return string */ public function get_alias() { return 'setWCCoreProfilerOptions'; } /** * Return label used in the frontend. * * @return string */ public function get_label() { return __( 'Onboarding Configuration', 'woocommerce' ); } /** * Return description used in the frontend. * * @return string */ public function get_description() { return __( 'Includes onboarding configuration options', 'woocommerce' ); } /** * Check if the current user has the required capabilities for this step. * * @return bool True if the user has the required capabilities. False otherwise. */ public function check_step_capabilities(): bool { return current_user_can( 'manage_woocommerce' ); } } Features/Blueprint/Exporters/ExportWCSettingsGeneral.php 0000777 00000002046 15252240713 0017536 0 ustar 00 <?php declare( strict_types = 1); namespace Automattic\WooCommerce\Admin\Features\Blueprint\Exporters; use Automattic\WooCommerce\Blueprint\UseWPFunctions; /** * Class ExportWCSettingsGeneral * * This class exports WooCommerce settings on the General page. * * @package Automattic\WooCommerce\Admin\Features\Blueprint\Exporters */ class ExportWCSettingsGeneral extends ExportWCSettings { use UseWPFunctions; /** * Get the alias for this exporter. * * @return string */ public function get_alias() { return 'setWCSettingsGeneral'; } /** * Return label used in the frontend. * * @return string */ public function get_label() { return __( 'General', 'woocommerce' ); } /** * Return description used in the frontend. * * @return string */ public function get_description() { return __( 'Includes all settings in WooCommerce | Settings | General.', 'woocommerce' ); } /** * Get the page ID for the settings page. * * @return string */ protected function get_page_id(): string { return 'general'; } } Features/Blueprint/Exporters/ExportWCSettingsEmails.php 0000777 00000003271 15252240713 0017374 0 ustar 00 <?php declare( strict_types = 1); namespace Automattic\WooCommerce\Admin\Features\Blueprint\Exporters; use Automattic\WooCommerce\Admin\Features\Blueprint\SettingOptions; use Automattic\WooCommerce\Blueprint\UseWPFunctions; use Automattic\WooCommerce\Blueprint\Steps\SetSiteOptions; /** * Class ExportWCSettingsEmails * * This class exports WooCommerce settings on the Emails page. * * @package Automattic\WooCommerce\Admin\Features\Blueprint\Exporters */ class ExportWCSettingsEmails extends ExportWCSettings { use UseWPFunctions; /** * Get the alias for this exporter. * * @return string */ public function get_alias() { return 'setWCSettingsEmails'; } /** * Export WooCommerce settings. * * @return SetSiteOptions */ public function export() { $emails = \WC_Emails::instance(); $setting_options = new SettingOptions(); $email_settings = $setting_options->get_page_options( $this->get_page_id() ); // Get sub-settings for each email. foreach ( $emails->get_emails() as $email ) { $email_settings = array_merge( $email_settings, $setting_options->get_page_options( 'email_' . $email->id ) ); } return new SetSiteOptions( $email_settings ); } /** * Return label used in the frontend. * * @return string */ public function get_label() { return __( 'Emails', 'woocommerce' ); } /** * Return description used in the frontend. * * @return string */ public function get_description() { return __( 'Includes all settings in WooCommerce | Settings | Emails.', 'woocommerce' ); } /** * Get the page ID for the settings page. * * @return string */ protected function get_page_id(): string { return 'email'; } } Features/Blueprint/Exporters/ExportWCSettingsIntegrations.php 0000777 00000003161 15252240713 0020626 0 ustar 00 <?php declare( strict_types = 1); namespace Automattic\WooCommerce\Admin\Features\Blueprint\Exporters; use Automattic\WooCommerce\Blueprint\Steps\SetSiteOptions; use Automattic\WooCommerce\Blueprint\UseWPFunctions; /** * Class ExportWCSettingsIntegrations * * This class exports WooCommerce settings on the Integrations page. * * @package Automattic\WooCommerce\Admin\Features\Blueprint\Exporters */ class ExportWCSettingsIntegrations extends ExportWCSettings { use UseWPFunctions; /** * Get the alias for this exporter. * * @return string */ public function get_alias() { return 'setWCSettingsIntegrations'; } /** * Return label used in the frontend. * * @return string */ public function get_label() { return __( 'Integrations', 'woocommerce' ); } /** * Export WooCommerce settings. * * @return SetSiteOptions */ public function export() { if ( ! isset( WC()->integrations ) ) { return new SetSiteOptions( array() ); } $integrations = WC()->integrations->get_integrations(); $settings = array(); foreach ( $integrations as $integration ) { $option_key = $integration->get_option_key(); $settings[ $option_key ] = get_option( $option_key, null ); } return new SetSiteOptions( $settings ); } /** * Return description used in the frontend. * * @return string */ public function get_description() { return __( 'Includes all settings in WooCommerce | Settings | Integrations.', 'woocommerce' ); } /** * Get the page ID for the settings page. * * @return string */ protected function get_page_id(): string { return 'integration'; } } Features/Blueprint/SettingOptions.php 0000777 00000003224 15252240713 0014001 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\Features\Blueprint; /** * Handles getting options from WooCommerce settings pages. * * Class SettingOptions */ class SettingOptions { /** * Setting option controller. * * @var \WC_REST_Setting_Options_Controller */ private $setting_option_controller; /** * Ignore setting types. * * @var array */ private $ignore_setting_types = array( 'title', 'sectionend', 'slotfill_placeholder', 'hidden' ); /** * Constructor. */ public function __construct() { $this->setting_option_controller = new \WC_REST_Setting_Options_Controller(); } /** * Get options for a specific settings page. * * @param string $page_id The page ID. * @return array * * @throws \Exception If the settings page is not found. */ public function get_page_options( $page_id ) { $settings = $this->setting_option_controller->get_group_settings( $page_id ); if ( is_wp_error( $settings ) ) { throw new \Exception( esc_html( $settings->get_error_message() ) ); } $page_options = array(); foreach ( $settings as $setting ) { // Skip if the setting type is not valid. if ( in_array( $setting['type'], $this->ignore_setting_types, true ) || ! isset( $setting['id'] ) ) { continue; } $key = is_array( $setting['option_key'] ) ? $setting['option_key'][0] : $setting['option_key']; // Skip if the option key is already in the page options. if ( in_array( $key, $page_options, true ) ) { continue; } $default_value = $setting['default'] ?? null; $page_options[ $key ] = get_option( $key, $default_value ); } return $page_options; } } Features/Blueprint/RestApi.php 0000777 00000025567 15252240713 0012375 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\Features\Blueprint; use Automattic\WooCommerce\Blueprint\Exporters\ExportInstallPluginSteps; use Automattic\WooCommerce\Blueprint\Exporters\ExportInstallThemeSteps; use Automattic\WooCommerce\Blueprint\ExportSchema; use Automattic\WooCommerce\Blueprint\ImportStep; use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonHelper; use WP_Error; /** * Class RestApi * * This class handles the REST API endpoints for importing and exporting WooCommerce Blueprints. * * @package Automattic\WooCommerce\Admin\Features\Blueprint */ class RestApi { /** * Maximum allowed file size in bytes (50MB) */ const MAX_FILE_SIZE = 52428800; // 50 * 1024 * 1024 /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * ComingSoonHelper instance. * * @var ComingSoonHelper */ protected $coming_soon_helper; /** * Constructor. */ public function __construct() { $this->coming_soon_helper = new ComingSoonHelper(); } /** * Get maximum allowed file size for blueprint uploads. * * @return int Maximum file size in bytes */ protected function get_max_file_size() { /** * Filters the maximum allowed file size for blueprint uploads. * * @since 9.3.0 * @param int $max_size Maximum file size in bytes. */ return apply_filters( 'woocommerce_blueprint_upload_max_file_size', self::MAX_FILE_SIZE ); } /** * Register routes. * * @since 9.3.0 */ public function register_routes() { register_rest_route( $this->namespace, '/blueprint/export', array( array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'export' ), 'permission_callback' => array( $this, 'check_export_permission' ), 'args' => array( 'steps' => array( 'description' => __( 'A list of plugins to install', 'woocommerce' ), 'type' => 'object', 'properties' => array( 'settings' => array( 'type' => 'array', 'items' => array( 'type' => 'string', ), ), 'plugins' => array( 'type' => 'array', 'items' => array( 'type' => 'string', ), ), 'themes' => array( 'type' => 'array', 'items' => array( 'type' => 'string', ), ), ), 'default' => array(), 'required' => true, ), ), ), ) ); register_rest_route( $this->namespace, '/blueprint/import-step', array( array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'import_step' ), 'permission_callback' => array( $this, 'check_import_permission' ), 'args' => array( 'step_definition' => array( 'description' => __( 'The step definition to import', 'woocommerce' ), 'type' => 'object', 'required' => true, ), ), ), 'schema' => array( $this, 'get_import_step_response_schema' ), ) ); register_rest_route( $this->namespace, '/blueprint/import-allowed', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_import_allowed' ), 'permission_callback' => function () { return current_user_can( 'manage_woocommerce' ); }, ), 'schema' => array( $this, 'get_import_allowed_schema' ), ) ); } /** * General permission check for export requests. * * @return bool|\WP_Error */ public function check_export_permission() { if ( ! current_user_can( 'manage_woocommerce' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot export WooCommerce Blueprints.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * General permission check for import requests. * * @return bool|\WP_Error */ public function check_import_permission() { if ( ! current_user_can( 'manage_woocommerce' ) || ! current_user_can( 'manage_options' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot import WooCommerce Blueprints.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Handle the export request. * * @param \WP_REST_Request $request The request object. * @return \WP_HTTP_Response The response object. */ public function export( $request ) { $payload = $request->get_param( 'steps' ); $steps = $this->steps_payload_to_blueprint_steps( $payload ); $exporter = new ExportSchema(); if ( isset( $payload['plugins'] ) ) { $exporter->on_before_export( 'installPlugin', function ( ExportInstallPluginSteps $exporter ) use ( $payload ) { $exporter->filter( function ( array $plugins ) use ( $payload ) { return array_intersect_key( $plugins, array_flip( $payload['plugins'] ) ); } ); } ); } if ( isset( $payload['themes'] ) ) { $exporter->on_before_export( 'installTheme', function ( ExportInstallThemeSteps $exporter ) use ( $payload ) { $exporter->filter( function ( array $plugins ) use ( $payload ) { return array_intersect_key( $plugins, array_flip( $payload['themes'] ) ); } ); } ); } $data = $exporter->export( $steps ); if ( is_wp_error( $data ) ) { return new \WP_REST_Response( $data, 400 ); } return new \WP_HTTP_Response( array( 'data' => $data, 'type' => 'json', ) ); } /** * Convert step list from the frontend to the backend format. * * From: * { * "settings": ["setWCSettings", "setWCShippingZones", "setWCShippingMethods", "setWCShippingRates"], * "plugins": ["akismet/akismet.php], * "themes": ["approach], * } * * To: * * ["setWCSettings", "setWCShippingZones", "setWCShippingMethods", "setWCShippingRates", "installPlugin", "installTheme"] * * @param array $steps steps payload from the frontend. * * @return array */ private function steps_payload_to_blueprint_steps( $steps ) { $blueprint_steps = array(); if ( isset( $steps['settings'] ) && count( $steps['settings'] ) > 0 ) { $blueprint_steps = array_merge( $blueprint_steps, $steps['settings'] ); } if ( isset( $steps['plugins'] ) && count( $steps['plugins'] ) > 0 ) { $blueprint_steps[] = 'installPlugin'; } if ( isset( $steps['themes'] ) && count( $steps['themes'] ) > 0 ) { $blueprint_steps[] = 'installTheme'; } return $blueprint_steps; } /** * Import a single step. * * @param \WP_REST_Request $request The request object. * * @return \WP_REST_Response|array */ public function import_step( \WP_REST_Request $request ) { $session_token = $request->get_header( 'X-Blueprint-Import-Session' ); // If no session token, this is the first step: generate and store a new token. if ( ! $session_token ) { $session_token = function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'bp_', true ); } if ( ! $this->can_import_blueprint( $session_token ) ) { return array( 'success' => false, 'messages' => array( array( 'message' => __( 'Blueprint imports are disabled', 'woocommerce' ), 'type' => 'error', ), ), ); } if ( false === get_transient( 'blueprint_import_session_' . $session_token ) ) { set_transient( 'blueprint_import_session_' . $session_token, true, 10 * MINUTE_IN_SECONDS ); } // Get the raw body size. $body_size = strlen( $request->get_body() ); if ( $body_size > $this->get_max_file_size() ) { return array( 'success' => false, 'messages' => array( array( 'message' => sprintf( // Translators: %s is the maximum file size in megabytes. __( 'Blueprint step definition size exceeds maximum limit of %s MB', 'woocommerce' ), ( $this->get_max_file_size() / ( 1024 * 1024 ) ) ), 'type' => 'error', ), ), ); } // Make sure we're dealing with object. $step_definition = json_decode( wp_json_encode( $request->get_param( 'step_definition' ) ) ); $step_importer = new ImportStep( $step_definition ); $result = $step_importer->import(); $response = new \WP_REST_Response( array( 'success' => $result->is_success(), 'messages' => $result->get_messages(), ) ); $response->header( 'X-Blueprint-Import-Session', $session_token ); return $response; } /** * Check if blueprint imports are allowed based on site status, configuration, and session token. * * @param string|null $session_token Optional session token for import session. * @return bool Returns true if imports are allowed, false otherwise. */ private function can_import_blueprint( $session_token = null ) { // Allow import if a valid session token is present so when a site is turned into live during the import process, the import can continue. if ( $session_token && get_transient( 'blueprint_import_session_' . $session_token ) ) { return true; } // Check if override constant is defined and true. if ( defined( 'ALLOW_BLUEPRINT_IMPORT_IN_LIVE_MODE' ) && ALLOW_BLUEPRINT_IMPORT_IN_LIVE_MODE ) { return true; } // Only allow imports in coming soon mode. if ( $this->coming_soon_helper->is_site_live() ) { return false; } return true; } /** * Get whether blueprint imports are allowed. * * @return \WP_REST_Response */ public function get_import_allowed() { $can_import = $this->can_import_blueprint(); return rest_ensure_response( array( 'import_allowed' => $can_import, ) ); } /** * Get the schema for the import-allowed endpoint. * * @return array */ public function get_import_allowed_schema() { return array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'blueprint-import-allowed', 'type' => 'object', 'properties' => array( 'import_allowed' => array( 'description' => __( 'Whether blueprint imports are currently allowed', 'woocommerce' ), 'type' => 'boolean', 'context' => array( 'view' ), 'readonly' => true, ), ), ); } /** * Get the schema for the import-step endpoint. * * @return array */ public function get_import_step_response_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'import-step', 'type' => 'object', 'properties' => array( 'success' => array( 'type' => 'boolean', ), 'messages' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'message' => array( 'type' => 'string', ), 'type' => array( 'type' => 'string', ), ), 'required' => array( 'message', 'type' ), ), ), ), 'required' => array( 'success', 'messages' ), ); return $schema; } } Features/Blueprint/Init.php 0000777 00000026224 15252240713 0011720 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\Features\Blueprint; use Automattic\WooCommerce\Admin\Features\Blueprint\Exporters\ExportWCPaymentGateways; use Automattic\WooCommerce\Admin\Features\Blueprint\Exporters\ExportWCSettingsAccount; use Automattic\WooCommerce\Admin\Features\Blueprint\Exporters\ExportWCSettingsAdvanced; use Automattic\WooCommerce\Admin\Features\Blueprint\Exporters\ExportWCSettingsEmails; use Automattic\WooCommerce\Admin\Features\Blueprint\Exporters\ExportWCSettingsGeneral; use Automattic\WooCommerce\Admin\Features\Blueprint\Exporters\ExportWCSettingsTax; use Automattic\WooCommerce\Admin\Features\Blueprint\Exporters\ExportWCSettingsIntegrations; use Automattic\WooCommerce\Admin\Features\Blueprint\Exporters\ExportWCSettingsProducts; use Automattic\WooCommerce\Admin\Features\Blueprint\Exporters\ExportWCSettingsSiteVisibility; use Automattic\WooCommerce\Admin\Features\Blueprint\Exporters\ExportWCSettingsShipping; use Automattic\WooCommerce\Admin\PageController; use Automattic\WooCommerce\Blueprint\Exporters\HasAlias; use Automattic\WooCommerce\Blueprint\Exporters\StepExporter; use Automattic\WooCommerce\Blueprint\UseWPFunctions; /** * Class Init * * This class initializes the Blueprint feature for WooCommerce. */ class Init { use UseWPFunctions; const INSTALLED_WP_ORG_PLUGINS_TRANSIENT = 'woocommerce_blueprint_installed_wp_org_plugins'; const INSTALLED_WP_ORG_THEMES_TRANSIENT = 'woocommerce_blueprint_installed_wp_org_themes'; /** * Array of initialized exporters. * * @var StepExporter[] */ private array $initialized_exporters = array(); /** * Init constructor. */ public function __construct() { add_action( 'rest_api_init', array( $this, 'init_rest_api' ) ); add_filter( 'woocommerce_admin_shared_settings', array( $this, 'add_js_vars' ) ); add_filter( 'wooblueprint_export_landingpage', function () { return '/wp-admin/admin.php?page=wc-admin'; } ); add_filter( 'wooblueprint_exporters', array( $this, 'add_woo_exporters' ) ); add_action( 'upgrader_process_complete', array( $this, 'clear_installed_wp_org_plugins_transient' ), 10, 2 ); add_action( 'deleted_plugin', array( $this, 'clear_installed_wp_org_plugins_transient' ), 10, 2 ); add_action( 'upgrader_process_complete', array( $this, 'clear_installed_wp_org_themes_transient' ), 10, 2 ); add_action( 'switch_theme', array( $this, 'clear_installed_wp_org_themes_transient' ) ); add_action( 'deleted_theme', array( $this, 'clear_installed_wp_org_themes_transient' ) ); } /** * Register REST API routes. * * @return void */ public function init_rest_api() { ( new RestApi() )->register_routes(); } /** * Return Woo Exporter classnames. * * @return StepExporter[] */ public function get_woo_exporters() { $classnames = array( ExportWCSettingsGeneral::class, ExportWCSettingsProducts::class, ExportWCSettingsTax::class, ExportWCSettingsShipping::class, ExportWCPaymentGateways::class, ExportWCSettingsAccount::class, ExportWCSettingsEmails::class, ExportWCSettingsIntegrations::class, ExportWCSettingsSiteVisibility::class, ExportWCSettingsAdvanced::class, ); $exporters = array(); foreach ( $classnames as $classname ) { $exporters[ $classname ] = $this->initialized_exporters[ $classname ] ?? new $classname(); $this->initialized_exporters[ $classname ] = $exporters[ $classname ]; } return array_values( $exporters ); } /** * Add Woo Specific Exporters. * * @param StepExporter[] $exporters Array of step exporters. * * @return StepExporter[] */ public function add_woo_exporters( array $exporters ) { return array_merge( $exporters, $this->get_woo_exporters() ); } /** * Get plugins for export group. * * @return array|array[] $plugins */ public function get_plugins_for_export_group() { $plugins = $this->get_installed_wp_org_plugins(); // Get active plugins from WordPress options and transform plugins array into export format. $active_plugins = $this->wp_get_option( 'active_plugins', array() ); $plugins = array_map( function ( $key, $plugin ) use ( $active_plugins ) { return array( 'id' => $key, 'label' => $plugin['Name'], 'checked' => in_array( $key, $active_plugins, true ), ); }, array_keys( $plugins ), $plugins ); usort( $plugins, function ( $a, $b ) { return $b['checked'] <=> $a['checked']; } ); return $plugins; } /** * Clear the installed WordPress.org plugins transient. */ public function clear_installed_wp_org_plugins_transient() { delete_transient( self::INSTALLED_WP_ORG_PLUGINS_TRANSIENT ); } /** * Clear the installed WordPress.org themes transient. */ public function clear_installed_wp_org_themes_transient() { delete_transient( self::INSTALLED_WP_ORG_THEMES_TRANSIENT ); } /** * Get themes for export group. * * @return array $themes */ public function get_themes_for_export_group() { $themes = $this->get_installed_wp_org_themes(); $active_theme = $this->wp_get_theme(); $themes = array_map( function ( $theme ) use ( $active_theme ) { return array( 'id' => $theme->get_stylesheet(), 'label' => $theme->get( 'Name' ), 'checked' => $theme->get_stylesheet() === $active_theme->get_stylesheet(), ); }, $themes ); usort( $themes, function ( $a, $b ) { return $b['checked'] <=> $a['checked']; } ); return array_values( $themes ); } /** * Return step groups for JS. * * This is used to populate exportable items on the blueprint settings page. * * @return array */ public function get_step_groups_for_js() { return array( array( 'id' => 'settings', 'description' => __( 'Includes all the items featured in WooCommerce | Settings.', 'woocommerce' ), 'label' => __( 'WooCommerce Settings', 'woocommerce' ), 'icon' => 'settings', 'items' => array_map( function ( $exporter ) { return array( 'id' => $exporter instanceof HasAlias ? $exporter->get_alias() : $exporter->get_step_name(), 'label' => $exporter->get_label(), 'description' => $exporter->get_description(), 'checked' => true, ); }, $this->get_woo_exporters() ), ), array( 'id' => 'plugins', 'description' => __( 'Includes all the installed plugins.', 'woocommerce' ), 'label' => __( 'Plugins', 'woocommerce' ), 'icon' => 'plugins', 'items' => $this->get_plugins_for_export_group(), ), array( 'id' => 'themes', 'description' => __( 'Includes all the installed themes.', 'woocommerce' ), 'label' => __( 'Themes', 'woocommerce' ), 'icon' => 'layout', 'items' => $this->get_themes_for_export_group(), ), ); } /** * Add shared JS vars. * * @param array $settings shared settings. * * @return mixed */ public function add_js_vars( $settings ) { if ( ! is_admin() ) { return $settings; } if ( 'woocommerce_page_wc-settings-advanced-blueprint' === PageController::get_instance()->get_current_screen_id() ) { // Used on the settings page. // wcSettings.admin.blueprint_step_groups. $settings['blueprint_step_groups'] = $this->get_step_groups_for_js(); $settings['blueprint_max_step_size_bytes'] = RestApi::MAX_FILE_SIZE; } return $settings; } /** * Get all installed WordPress.org plugins. * * @return array */ private function get_installed_wp_org_plugins() { // Try to get cached plugin list. $wp_org_plugins = get_transient( self::INSTALLED_WP_ORG_PLUGINS_TRANSIENT ); if ( is_array( $wp_org_plugins ) ) { return $wp_org_plugins; } // Get all installed plugins. $all_plugins = $this->wp_get_plugins(); $plugin_slugs = array(); // Build a map of plugin file => slug. foreach ( $all_plugins as $key => $plugin ) { $slug = dirname( $key ); /** * Apply the WP Core "wp_plugin_dependencies_slug" filter to get the correct plugin slug. */ $slug = apply_filters( 'wp_plugin_dependencies_slug', $slug ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment $plugin_slugs[] = $slug; $all_plugins[ $key ]['slug'] = $slug; } $api_response = $this->wp_plugins_api( 'plugin_information', array( 'fields' => array( 'short_description' => false, 'sections' => false, 'description' => false, 'tested' => false, 'requires' => false, 'rating' => false, 'ratings' => false, 'downloaded' => false, 'downloadlink' => false, 'last_updated' => false, 'added' => false, 'tags' => false, 'compatibility' => false, 'homepage' => false, 'versions' => false, 'donate_link' => false, 'reviews' => false, 'banners' => false, 'icons' => false, 'active_installs' => false, ), 'slugs' => $plugin_slugs, ) ); // If API fails, return all plugins. if ( is_wp_error( $api_response ) ) { return $all_plugins; } // Filter plugins: only keep those with a valid API response (no 'error' for their slug). $wp_org_plugins = array_filter( $all_plugins, function ( $plugin ) use ( $api_response ) { $slug = $plugin['slug']; return isset( $api_response->{$slug} ) && ! isset( $api_response->{$slug}['error'] ); } ); set_transient( self::INSTALLED_WP_ORG_PLUGINS_TRANSIENT, $wp_org_plugins ); return $wp_org_plugins; } /** * Get all installed WordPress.org themes. * * @return array */ private function get_installed_wp_org_themes() { // Try to get cached theme list. $wp_org_themes = get_transient( self::INSTALLED_WP_ORG_THEMES_TRANSIENT ); if ( is_array( $wp_org_themes ) ) { return $wp_org_themes; } // Get all installed themes. $all_themes = $this->wp_get_themes(); $theme_slugs = array(); // Build an array of installed theme slugs. foreach ( $all_themes as $key => $theme ) { if ( is_string( $key ) ) { $theme_slugs[] = strtolower( $key ); } } $api_response = $this->wp_themes_api( 'theme_information', array( 'fields' => array( 'downloadlink' => true, 'sections' => false, 'description' => false, 'rating' => false, 'ratings' => false, 'downloaded' => false, 'last_updated' => false, 'tags' => false, 'homepage' => false, 'screenshots' => false, 'screenshot_url' => false, 'parent' => false, 'versions' => false, 'extended_author' => false, ), 'slugs' => $theme_slugs, ) ); // If the API fails, return all installed themes. if ( is_wp_error( $api_response ) ) { return $all_themes; } $wp_org_themes = array_filter( $all_themes, function ( $theme ) use ( $api_response ) { $slug = $theme->get_stylesheet(); return isset( $api_response->{$slug}['download_link'] ); } ); set_transient( self::INSTALLED_WP_ORG_THEMES_TRANSIENT, $wp_org_themes ); return $wp_org_themes; } } Features/Navigation/RemovedDeprecated.php 0000777 00000002446 15252240713 0014532 0 ustar 00 <?php declare(strict_types=1); namespace Automattic\WooCommerce\Admin\Features\Navigation; use WC_Tracks; /** * Handle calls to deprecated methods. */ class RemovedDeprecated { /** * Handle deprecated method calls. * * @param string $name The name of the deprecated method. */ private static function handle_deprecated_method_call( $name ) { $logger = wc_get_logger(); if ( $logger ) { $logger->warning( "The WooCommerce Admin Navigation feature and its classes (Screen, Menu, CoreMenu) are deprecated since 9.3 with no alternative. Please remove the call to $name." ); } if ( class_exists( 'WC_Tracks' ) ) { WC_Tracks::record_event( 'deprecated_navigation_method_called' ); } } /** * Handle calls to deprecated methods. * * @param string $name The name of the deprecated method. * @param array $arguments The arguments passed to the deprecated method. */ public function __call( $name, $arguments ) { self::handle_deprecated_method_call( $name ); } /** * Handle static calls to deprecated methods. * * @param string $name The name of the deprecated method. * @param array $arguments The arguments passed to the deprecated method. */ public static function __callStatic( $name, $arguments ) { self::handle_deprecated_method_call( $name ); } } Features/AsyncProductEditorCategoryField/Init.php 0000777 00000004641 15252240713 0016202 0 ustar 00 <?php /** * WooCommerce Async Product Editor Category Field. */ namespace Automattic\WooCommerce\Admin\Features\AsyncProductEditorCategoryField; use Automattic\Jetpack\Constants; use Automattic\WooCommerce\Admin\Features\Features; use Automattic\WooCommerce\Internal\Admin\WCAdminAssets; use Automattic\WooCommerce\Admin\PageController; /** * Loads assets related to the async category field for the product editor. */ class Init { const FEATURE_ID = 'async-product-editor-category-field'; /** * Constructor */ public function __construct() { if ( Features::is_enabled( self::FEATURE_ID ) ) { add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_styles' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) ); add_filter( 'woocommerce_taxonomy_args_product_cat', array( $this, 'add_metabox_args' ) ); } } /** * Adds meta_box_cb callback arguments for custom metabox. * * @param array $args Category taxonomy args. * @return array $args category taxonomy args. */ public function add_metabox_args( $args ) { if ( ! isset( $args['meta_box_cb'] ) ) { $args['meta_box_cb'] = 'WC_Meta_Box_Product_Categories::output'; $args['meta_box_sanitize_cb'] = 'taxonomy_meta_box_sanitize_cb_checkboxes'; } return $args; } /** * Enqueue scripts needed for the product form block editor. */ public function enqueue_scripts() { if ( ! PageController::is_embed_page() ) { return; } WCAdminAssets::register_script( 'wp-admin-scripts', 'product-category-metabox', true ); wp_localize_script( 'wc-admin-product-category-metabox', 'wc_product_category_metabox_params', array( 'search_categories_nonce' => wp_create_nonce( 'search-categories' ), 'search_taxonomy_terms_nonce' => wp_create_nonce( 'search-taxonomy-terms' ), ) ); wp_enqueue_script( 'product-category-metabox' ); } /** * Enqueue styles needed for the rich text editor. */ public function enqueue_styles() { if ( ! PageController::is_embed_page() ) { return; } $version = Constants::get_constant( 'WC_VERSION' ); wp_register_style( 'woocommerce_admin_product_category_metabox_styles', WCAdminAssets::get_url( 'product-category-metabox/style', 'css' ), array(), $version ); wp_style_add_data( 'woocommerce_admin_product_category_metabox_styles', 'rtl', 'replace' ); wp_enqueue_style( 'woocommerce_admin_product_category_metabox_styles' ); } } Features/ProductDataViews/Init.php 0000777 00000006766 15252240713 0013215 0 ustar 00 <?php /** * WooCommerce Product Data Views */ declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\Features\ProductDataViews; use Automattic\Jetpack\Constants; use Automattic\WooCommerce\Blocks\Utils\Utils; use Automattic\WooCommerce\Internal\Admin\WCAdminAssets; /** * Loads assets related to the product block editor. */ class Init { /** * Constructor */ public function __construct() { add_action( 'admin_menu', array( $this, 'woocommerce_add_new_products_dashboard' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_styles' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) ); if ( $this->is_product_data_view_page() ) { add_filter( 'admin_body_class', static function ( $classes ) { return "$classes"; } ); } } /** * Returns true if we are on a JS powered admin page. */ public static function is_product_data_view_page() { // phpcs:disable WordPress.Security.NonceVerification return isset( $_GET['page'] ) && 'woocommerce-products-dashboard' === $_GET['page']; // phpcs:enable WordPress.Security.NonceVerification } /** * Enqueue styles needed for the rich text editor. */ public function enqueue_styles() { if ( ! $this->is_product_data_view_page() ) { return; } wp_enqueue_style( 'wc-product-editor' ); } /** * Enqueue scripts needed for the product form block editor. */ public function enqueue_scripts() { if ( ! $this->is_product_data_view_page() ) { return; } $script_handle = 'wc-admin-edit-product'; wp_register_script( $script_handle, '', array( 'wp-blocks' ), '0.1.0', true ); wp_enqueue_script( $script_handle ); wp_enqueue_media(); wp_register_style( 'wc-global-presets', false ); // phpcs:ignore wp_add_inline_style( 'wc-global-presets', wp_get_global_stylesheet( array( 'presets' ) ) ); wp_enqueue_style( 'wc-global-presets' ); } /** * Replaces the default posts menu item with the new posts dashboard. */ public function woocommerce_add_new_products_dashboard() { $gutenberg_experiments = get_option( 'gutenberg-experiments' ); if ( ! $gutenberg_experiments ) { return; } $ptype_obj = get_post_type_object( 'product' ); add_submenu_page( 'edit.php?post_type=product', $ptype_obj->labels->name, esc_html__( 'All Products ( new )', 'woocommerce' ), 'manage_woocommerce', 'woocommerce-products-dashboard', array( $this, 'woocommerce_products_dashboard' ), 1 ); } /** * Renders the new posts dashboard page. */ public function woocommerce_products_dashboard() { $suffix = Constants::is_true( 'SCRIPT_DEBUG' ) ? '' : '.min'; $version = Constants::get_constant( 'WC_VERSION' ); if ( function_exists( 'gutenberg_url' ) ) { // phpcs:disable WordPress.WP.EnqueuedResourceParameters.MissingVersion wp_register_style( 'wp-gutenberg-posts-dashboard', gutenberg_url( 'build/edit-site/posts.css', __FILE__ ), array( 'wp-components' ), ); // phpcs:enable WordPress.WP.EnqueuedResourceParameters.MissingVersion wp_enqueue_style( 'wp-gutenberg-posts-dashboard' ); } WCAdminAssets::get_instance(); wp_enqueue_script( 'wc-admin-product-editor', WC()->plugin_url() . '/assets/js/admin/product-editor' . $suffix . '.js', array( 'wc-product-editor' ), $version, false ); wp_add_inline_script( 'wp-edit-site', 'window.wc.productEditor.initializeProductsDashboard( "woocommerce-products-dashboard" );', 'after' ); wp_enqueue_script( 'wp-edit-site' ); echo '<div id="woocommerce-products-dashboard"></div>'; } } Features/OnboardingTasks/TaskLists.php 0000777 00000025540 15252240713 0014062 0 ustar 00 <?php /** * Handles storage and retrieval of task lists */ namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks; use Automattic\WooCommerce\Admin\Features\Features; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\ReviewShippingOptions; use Automattic\WooCommerce\Utilities\FeaturesUtil; /** * Task Lists class. */ class TaskLists { /** * Class instance. * * @var TaskLists instance */ protected static $instance = null; /** * An array of all registered lists. * * @var array */ protected static $lists = array(); /** * Boolean value to indicate if default tasks have been added. * * @var boolean */ protected static $default_tasks_loaded = false; /** * The contents of this array is used in init_tasks() to run their init() methods. * If the classes do not have an init() method then nothing is executed. * Beyond that, adding tasks to this list has no effect, see init_default_lists() for the list of tasks. * that are added for each task list. * * @var array */ const DEFAULT_TASKS = array( 'StoreDetails', 'Products', 'WooCommercePayments', 'Payments', 'Tax', 'Shipping', 'Marketing', 'AdditionalPayments', 'ReviewShippingOptions', 'GetMobileApp', ); /** * Get class instance. */ final public static function instance() { if ( ! static::$instance ) { static::$instance = new static(); } return static::$instance; } /** * Initialize the task lists. */ public static function init() { self::init_default_lists(); add_action( 'admin_init', array( __CLASS__, 'set_active_task' ), 5 ); add_action( 'init', array( __CLASS__, 'init_tasks' ) ); add_action( 'admin_menu', array( __CLASS__, 'menu_task_count' ) ); add_filter( 'woocommerce_admin_shared_settings', array( __CLASS__, 'task_list_preloaded_settings' ), 20 ); } /** * Check if an experiment is the treatment or control. * * @param string $name Name prefix of experiment. * @return bool */ public static function is_experiment_treatment( $name ) { $anon_id = isset( $_COOKIE['tk_ai'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['tk_ai'] ) ) : ''; $allow_tracking = 'yes' === get_option( 'woocommerce_allow_tracking' ); $abtest = new \WooCommerce\Admin\Experimental_Abtest( $anon_id, 'woocommerce', $allow_tracking ); $date = new \DateTime(); $date->setTimeZone( new \DateTimeZone( 'UTC' ) ); $experiment_name = sprintf( '%s_%s_%s', $name, $date->format( 'Y' ), $date->format( 'm' ) ); return $abtest->get_variation( $experiment_name ) === 'treatment'; } /** * Initialize default lists. */ public static function init_default_lists() { $tasks = array( 'StoreDetails', 'Products', 'Payments', 'CustomizeStore', 'Tax', 'Shipping', 'LaunchYourStore', ); if ( Features::is_enabled( 'core-profiler' ) ) { $key = array_search( 'StoreDetails', $tasks, true ); if ( false !== $key ) { unset( $tasks[ $key ] ); } } self::add_list( array( 'id' => 'setup', 'title' => __( 'Get ready to start selling', 'woocommerce' ), 'tasks' => $tasks, 'display_progress_header' => true, 'event_prefix' => 'tasklist_', 'options' => array( 'use_completed_title' => true, ), 'visible' => true, ) ); self::add_list( array( 'id' => 'extended', 'title' => __( 'Things to do next', 'woocommerce' ), 'sort_by' => array( array( 'key' => 'is_complete', 'order' => 'asc', ), array( 'key' => 'level', 'order' => 'asc', ), ), 'tasks' => array( 'Marketing', 'ExtendStore', 'AdditionalPayments', 'GetMobileApp', ), ) ); if ( Features::is_enabled( 'shipping-smart-defaults' ) ) { self::add_task( 'extended', new ReviewShippingOptions( self::get_list( 'extended' ) ) ); // Tasklist that will never be shown in homescreen, // used for having tasks that are accessed by other means. self::add_list( array( 'id' => 'secret_tasklist', 'hidden_id' => 'setup', 'tasks' => array( 'ExperimentalShippingRecommendation', ), 'event_prefix' => 'secret_tasklist_', 'visible' => false, ) ); } if ( has_filter( 'woocommerce_admin_experimental_onboarding_tasklists' ) ) { /** * Filter to override default task lists. * * @since 7.4 * @param array $lists Array of tasklists. */ self::$lists = apply_filters( 'woocommerce_admin_experimental_onboarding_tasklists', self::$lists ); } } /** * Initialize tasks. */ public static function init_tasks() { foreach ( self::DEFAULT_TASKS as $task ) { $class = 'Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\\' . $task; if ( ! method_exists( $class, 'init' ) ) { continue; } $class::init(); } } /** * Temporarily store the active task to persist across page loads when necessary. * Most tasks do not need this. */ public static function set_active_task() { if ( ! isset( $_GET[ Task::ACTIVE_TASK_TRANSIENT ] ) || ! current_user_can( 'manage_woocommerce' ) ) { // phpcs:ignore csrf ok. return; } $referer = wp_get_referer(); if ( ! $referer || 0 !== strpos( $referer, wc_admin_url() ) ) { return; } $task_id = sanitize_title_with_dashes( wp_unslash( $_GET[ Task::ACTIVE_TASK_TRANSIENT ] ) ); // phpcs:ignore csrf ok. $task = self::get_task( $task_id ); if ( ! $task ) { return; } $task->set_active(); } /** * Add a task list. * * @param array $args Task list properties. * @return \WP_Error|TaskList */ public static function add_list( $args ) { if ( isset( self::$lists[ $args['id'] ] ) ) { return new \WP_Error( 'woocommerce_task_list_exists', __( 'Task list ID already exists', 'woocommerce' ) ); } self::$lists[ $args['id'] ] = new TaskList( $args ); return self::$lists[ $args['id'] ]; } /** * Add task to a given task list. * * @param string $list_id List ID to add the task to. * @param Task $task Task object. * * @return \WP_Error|Task */ public static function add_task( $list_id, $task ) { if ( ! isset( self::$lists[ $list_id ] ) ) { return new \WP_Error( 'woocommerce_task_list_invalid_list', __( 'Task list ID does not exist', 'woocommerce' ) ); } self::$lists[ $list_id ]->add_task( $task ); } /** * Add default extended task lists. * * @param array $extended_tasks list of extended tasks. */ public static function maybe_add_extended_tasks( $extended_tasks ) { $tasks = $extended_tasks ?? array(); foreach ( self::$lists as $task_list ) { if ( 'extended' !== substr( $task_list->id, 0, 8 ) ) { continue; } foreach ( $tasks as $args ) { $task = new DeprecatedExtendedTask( $task_list, $args ); $task_list->add_task( $task ); } } } /** * Get all task lists. * * @return array */ public static function get_lists() { return self::$lists; } /** * Get all task lists. * * @param array $ids list of task list ids. * @return array */ public static function get_lists_by_ids( $ids ) { return array_filter( self::$lists, function ( $task_list ) use ( $ids ) { return in_array( $task_list->get_list_id(), $ids, true ); } ); } /** * Get all task list ids. * * @return array */ public static function get_list_ids() { return array_keys( self::$lists ); } /** * Clear all task lists. */ public static function clear_lists() { self::$lists = array(); return self::$lists; } /** * Get visible task lists. */ public static function get_visible() { return array_filter( self::get_lists(), function ( $task_list ) { return $task_list->is_visible(); } ); } /** * Retrieve a task list by ID. * * @param String $id Task list ID. * * @return TaskList|null */ public static function get_list( $id ) { if ( isset( self::$lists[ $id ] ) ) { return self::$lists[ $id ]; } return null; } /** * Retrieve single task. * * @param String $id Task ID. * @param String $task_list_id Task list ID. * * @return Object */ public static function get_task( $id, $task_list_id = null ) { $task_list = $task_list_id ? self::get_list( $task_list_id ) : null; if ( $task_list_id && ! $task_list ) { return null; } $tasks_to_search = $task_list ? $task_list->tasks : array_reduce( self::get_lists(), function ( $all, $curr ) { return array_merge( $all, $curr->tasks ); }, array() ); foreach ( $tasks_to_search as $task ) { if ( $id === $task->get_id() ) { return $task; } } return null; } /** * Return number of setup tasks remaining * * This is not updated immediately when a task is completed, but rather when task is marked as complete in the database to reduce performance impact. * * @return int|null */ public static function setup_tasks_remaining() { $setup_list = self::get_list( 'setup' ); if ( ! $setup_list || $setup_list->is_hidden() || $setup_list->has_previously_completed() ) { return; } $viewable_tasks = $setup_list->get_viewable_tasks(); $completed_tasks = get_option( Task::COMPLETED_OPTION, array() ); if ( ! is_array( $completed_tasks ) ) { $completed_tasks = array(); } return count( array_filter( $viewable_tasks, function ( $task ) use ( $completed_tasks ) { return ! in_array( $task->get_id(), $completed_tasks, true ); } ) ); } /** * Add badge to homescreen menu item for remaining tasks */ public static function menu_task_count() { global $submenu; $tasks_count = self::setup_tasks_remaining(); if ( ! $tasks_count || ! isset( $submenu['woocommerce'] ) ) { return; } foreach ( $submenu['woocommerce'] as $key => $menu_item ) { if ( 0 === strpos( $menu_item[0], _x( 'Home', 'Admin menu name', 'woocommerce' ) ) ) { $submenu['woocommerce'][ $key ][0] .= ' <span class="awaiting-mod update-plugins remaining-tasks-badge woocommerce-task-list-remaining-tasks-badge"><span class="count-' . esc_attr( $tasks_count ) . '">' . absint( $tasks_count ) . '</span></span>'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited break; } } } /** * Add visible list ids to component settings. * * @param array $settings Component settings. * * @return array */ public static function task_list_preloaded_settings( $settings ) { $settings['visibleTaskListIds'] = self::all_hidden() ? array() : array_keys( self::get_visible() ); $settings['completedTaskListIds'] = get_option( TaskList::COMPLETED_OPTION, array() ); return $settings; } /** * Check if all task lists are hidden. * * @return bool */ public static function all_hidden() { $hidden_lists = get_option( TaskList::HIDDEN_OPTION, array() ); return count( $hidden_lists ) === count( self::get_lists() ); } } Features/OnboardingTasks/DeprecatedExtendedTask.php 0000777 00000006322 15252240713 0016502 0 ustar 00 <?php /** * A temporary class for creating tasks on the fly from deprecated tasks. */ namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks; /** * DeprecatedExtendedTask class. */ class DeprecatedExtendedTask extends Task { /** * ID. * * @var string */ public $id = ''; /** * Additional info. * * @var string|null */ public $additional_info = ''; /** * Content. * * @var string */ public $content = ''; /** * Whether the task is complete or not. * * @var boolean */ public $is_complete = false; /** * Snoozeable. * * @var boolean */ public $is_snoozeable = false; /** * Dismissable. * * @var boolean */ public $is_dismissable = false; /** * Whether the store is capable of viewing the task. * * @var bool */ public $can_view = true; /** * Level. * * @var int */ public $level = 3; /** * Time. * * @var string|null */ public $time; /** * Title. * * @var string */ public $title = ''; /** * Constructor. * * @param TaskList $task_list Parent task list. * @param array $args Array of task args. */ public function __construct( $task_list, $args ) { parent::__construct( $task_list ); $task_args = wp_parse_args( $args, array( 'id' => null, 'is_dismissable' => false, 'is_snoozeable' => false, 'can_view' => true, 'level' => 3, 'additional_info' => null, 'content' => '', 'title' => '', 'is_complete' => false, 'time' => null, ) ); $this->id = $task_args['id']; $this->additional_info = $task_args['additional_info']; $this->content = $task_args['content']; $this->is_complete = $task_args['is_complete']; $this->is_dismissable = $task_args['is_dismissable']; $this->is_snoozeable = $task_args['is_snoozeable']; $this->can_view = $task_args['can_view']; $this->level = $task_args['level']; $this->time = $task_args['time']; $this->title = $task_args['title']; } /** * ID. * * @return string */ public function get_id() { return $this->id; } /** * Additional info. * * @return string */ public function get_additional_info() { return $this->additional_info; } /** * Content. * * @return string */ public function get_content() { return $this->content; } /** * Level. * * @return int */ public function get_level() { return $this->level; } /** * Title * * @return string */ public function get_title() { return $this->title; } /** * Time * * @return string|null */ public function get_time() { return $this->time; } /** * Check if a task is snoozeable. * * @return bool */ public function is_snoozeable() { return $this->is_snoozeable; } /** * Check if a task is dismissable. * * @return bool */ public function is_dismissable() { return $this->is_dismissable; } /** * Check if a task is dismissable. * * @return bool */ public function is_complete() { return $this->is_complete; } /** * Check if a task is dismissable. * * @return bool */ public function can_view() { return $this->can_view; } } Features/OnboardingTasks/TaskTraits.php 0000777 00000001713 15252240713 0014226 0 ustar 00 <?php /** * Task and TaskList Traits */ namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks; defined( 'ABSPATH' ) || exit; /** * TaskTraits class. */ trait TaskTraits { /** * Record a tracks event with the prefixed event name. * * @param string $event_name Event name. * @param array $args Array of tracks arguments. * @return string Prefixed event name. */ public function record_tracks_event( $event_name, $args = array() ) { if ( ! $this->get_list_id() ) { return; } $prefixed_event_name = $this->prefix_event( $event_name ); wc_admin_record_tracks_event( $prefixed_event_name, $args ); return $prefixed_event_name; } /** * Get the task list ID. * * @return string */ public function get_list_id() { $namespaced_class = get_class( $this ); return is_subclass_of( $namespaced_class, 'Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task' ) ? $this->get_parent_id() : $this->id; } } Features/OnboardingTasks/Task.php 0000777 00000031414 15252240713 0013040 0 ustar 00 <?php /** * Handles task related methods. */ namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks; use Automattic\WooCommerce\Internal\Admin\WCAdminUser; /** * Task class. */ abstract class Task { /** * Task traits. */ use TaskTraits; /** * Name of the dismiss option. * * @var string */ const DISMISSED_OPTION = 'woocommerce_task_list_dismissed_tasks'; /** * Name of the snooze option. * * @var string * * @deprecated 7.2.0 */ const SNOOZED_OPTION = 'woocommerce_task_list_remind_me_later_tasks'; /** * Name of the actioned option. * * @var string */ const ACTIONED_OPTION = 'woocommerce_task_list_tracked_completed_actions'; /** * Option name of completed tasks. * * @var string */ const COMPLETED_OPTION = 'woocommerce_task_list_tracked_completed_tasks'; /** * Name of the active task transient. * * @var string */ const ACTIVE_TASK_TRANSIENT = 'wc_onboarding_active_task'; /** * Parent task list. * * @var TaskList */ protected $task_list; /** * Duration to millisecond mapping. * * @var string */ protected $duration_to_ms = array( 'day' => DAY_IN_SECONDS * 1000, 'hour' => HOUR_IN_SECONDS * 1000, 'week' => WEEK_IN_SECONDS * 1000, ); /** * Constructor * * @param TaskList|null $task_list Parent task list. */ public function __construct( $task_list = null ) { $this->task_list = $task_list; } /** * ID. * * @return string */ abstract public function get_id(); /** * Title. * * @return string */ abstract public function get_title(); /** * Content. * * @return string */ abstract public function get_content(); /** * Time. * * @return string */ abstract public function get_time(); /** * Parent ID. * * @return string */ public function get_parent_id() { if ( ! $this->task_list ) { return ''; } return $this->task_list->get_list_id(); } /** * Get task list options. * * @return array */ public function get_parent_options() { if ( ! $this->task_list ) { return array(); } return $this->task_list->options; } /** * Get custom option. * * @param string $option_name name of custom option. * @return mixed|null */ public function get_parent_option( $option_name ) { if ( $this->task_list && isset( $this->task_list->options[ $option_name ] ) ) { return $this->task_list->options[ $option_name ]; } return null; } /** * Prefix event for track event naming. * * @param string $event_name Event name. * @return string */ public function prefix_event( $event_name ) { if ( ! $this->task_list ) { return ''; } return $this->task_list->prefix_event( $event_name ); } /** * Additional info. * * @return string */ public function get_additional_info() { return ''; } /** * Additional data. * * @return mixed */ public function get_additional_data() { return null; } /** * Badge. * * @return string */ public function get_badge() { return ''; } /** * Level. * * @deprecated 7.2.0 * * @return string */ public function get_level() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.2.0' ); return 3; } /** * Action label. * * @return string */ public function get_action_label() { return __( "Let's go", 'woocommerce' ); } /** * Action URL. * * @return string */ public function get_action_url() { return null; } /** * Check if a task is dismissable. * * @return bool */ public function is_dismissable() { return false; } /** * Bool for task dismissal. * * @return bool */ public function is_dismissed() { if ( ! $this->is_dismissable() ) { return false; } $dismissed = get_option( self::DISMISSED_OPTION, array() ); return in_array( $this->get_id(), $dismissed, true ); } /** * Dismiss the task. * * @return bool */ public function dismiss() { if ( ! $this->is_dismissable() ) { return false; } $dismissed = get_option( self::DISMISSED_OPTION, array() ); $dismissed[] = $this->get_id(); $update = update_option( self::DISMISSED_OPTION, array_unique( $dismissed ) ); if ( $update ) { $this->record_tracks_event( 'dismiss_task', array( 'task_name' => $this->get_id() ) ); } return $update; } /** * Undo task dismissal. * * @return bool */ public function undo_dismiss() { $dismissed = get_option( self::DISMISSED_OPTION, array() ); $dismissed = array_diff( $dismissed, array( $this->get_id() ) ); $update = update_option( self::DISMISSED_OPTION, $dismissed ); if ( $update ) { $this->record_tracks_event( 'undo_dismiss_task', array( 'task_name' => $this->get_id() ) ); } return $update; } /** * Check if a task is snoozeable. * * @deprecated 7.2.0 * * @return bool */ public function is_snoozeable() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.2.0' ); return false; } /** * Get the snoozed until datetime. * * @deprecated 7.2.0 * * @return string */ public function get_snoozed_until() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.2.0' ); $snoozed_tasks = get_option( self::SNOOZED_OPTION, array() ); if ( isset( $snoozed_tasks[ $this->get_id() ] ) ) { return $snoozed_tasks[ $this->get_id() ]; } return null; } /** * Bool for task snoozed. * * @deprecated 7.2.0 * * @return bool */ public function is_snoozed() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.2.0' ); if ( ! $this->is_snoozeable() ) { return false; } $snoozed = get_option( self::SNOOZED_OPTION, array() ); return isset( $snoozed[ $this->get_id() ] ) && $snoozed[ $this->get_id() ] > ( time() * 1000 ); } /** * Snooze the task. * * @param string $duration Duration to snooze. day|hour|week. * * @deprecated 7.2.0 * * @return bool */ public function snooze( $duration = 'day' ) { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.2.0' ); if ( ! $this->is_snoozeable() ) { return false; } $snoozed = get_option( self::SNOOZED_OPTION, array() ); $snoozed_until = $this->duration_to_ms[ $duration ] + ( time() * 1000 ); $snoozed[ $this->get_id() ] = $snoozed_until; $update = update_option( self::SNOOZED_OPTION, $snoozed ); if ( $update ) { if ( $update ) { $this->record_tracks_event( 'remindmelater_task', array( 'task_name' => $this->get_id() ) ); } } return $update; } /** * Undo task snooze. * * @deprecated 7.2.0 * * @return bool */ public function undo_snooze() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.2.0' ); $snoozed = get_option( self::SNOOZED_OPTION, array() ); unset( $snoozed[ $this->get_id() ] ); $update = update_option( self::SNOOZED_OPTION, $snoozed ); if ( $update ) { $this->record_tracks_event( 'undo_remindmelater_task', array( 'task_name' => $this->get_id() ) ); } return $update; } /** * Check if a task list has previously been marked as complete. * * @return bool */ public function has_previously_completed() { $complete = get_option( self::COMPLETED_OPTION, array() ); return in_array( $this->get_id(), $complete, true ); } /** * Track task completion if task is viewable and is complete. * * @return void */ public function possibly_track_completion() { if ( $this->has_previously_completed() ) { return; } // Expensive check. if ( ! $this->is_complete() ) { return; } $completed_tasks = get_option( self::COMPLETED_OPTION, array() ); $completed_tasks[] = $this->get_id(); update_option( self::COMPLETED_OPTION, $completed_tasks ); $this->record_tracks_event( 'task_completed', array( 'task_name' => $this->get_id() ) ); } /** * Set this as the active task across page loads. */ public function set_active() { if ( $this->is_complete() ) { return; } set_transient( self::ACTIVE_TASK_TRANSIENT, $this->get_id(), DAY_IN_SECONDS ); } /** * Check if this is the active task. */ public function is_active() { return get_transient( self::ACTIVE_TASK_TRANSIENT ) === $this->get_id(); } /** * Check if the store is capable of viewing the task. * * @return bool */ public function can_view() { return true; } /** * Check if task is disabled. * * @deprecated 7.2.0 * * @return bool */ public function is_disabled() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.2.0' ); return false; } /** * Check if the task is complete. * * @return bool */ public function is_complete() { return self::is_actioned(); } /** * Check if the task is in progress. * * @return bool */ public function is_in_progress() { return false; } /** * The task in progress label. * * @return string */ public function in_progress_label() { return esc_html__( 'In progress', 'woocommerce' ); } /** * If a task is always accessible, relevant for when a task list is hidden but a task can still be viewed. * * @return bool */ public function is_always_accessible() { return false; } /** * Check if the task has been visited. * * @return bool */ public function is_visited() { $user_id = get_current_user_id(); $response = WCAdminUser::get_user_data_field( $user_id, 'task_list_tracked_started_tasks' ); $tracked_tasks = $response ? json_decode( $response, true ) : array(); return isset( $tracked_tasks[ $this->get_id() ] ) && $tracked_tasks[ $this->get_id() ] > 0; } /** * Check if should record event when task is viewed * * @return bool */ public function get_record_view_event(): bool { return false; } /** * Get the task as JSON. * * @return array */ public function get_json() { $is_complete = $this->is_complete(); if ( $is_complete ) { $this->possibly_track_completion(); } return array( 'id' => $this->get_id(), 'parentId' => $this->get_parent_id(), 'title' => $this->get_title(), 'badge' => $this->get_badge(), 'canView' => $this->can_view(), 'content' => $this->get_content(), 'additionalInfo' => $this->get_additional_info(), 'actionLabel' => $this->get_action_label(), 'actionUrl' => $this->get_action_url(), 'isComplete' => $is_complete, 'isInProgress' => $this->is_in_progress(), 'inProgressLabel' => $this->in_progress_label(), 'time' => $this->get_time(), 'level' => 3, 'isActioned' => $this->is_actioned(), 'isDismissed' => $this->is_dismissed(), 'isDismissable' => $this->is_dismissable(), 'isSnoozed' => false, 'isSnoozeable' => false, 'isVisited' => $this->is_visited(), 'isDisabled' => false, 'snoozedUntil' => null, 'additionalData' => self::convert_object_to_camelcase( $this->get_additional_data() ), 'eventPrefix' => $this->prefix_event( '' ), 'recordViewEvent' => $this->get_record_view_event(), ); } /** * Convert object keys to camelcase. * * @param array $data Data to convert. * @return object */ public static function convert_object_to_camelcase( $data ) { if ( ! is_array( $data ) ) { return $data; } $new_object = (object) array(); foreach ( $data as $key => $value ) { $new_key = lcfirst( implode( '', array_map( 'ucfirst', explode( '_', $key ) ) ) ); $new_object->$new_key = $value; } return $new_object; } /** * Mark a task as actioned. Used to verify an action has taken place in some tasks. * * @return bool */ public function mark_actioned() { $actioned = get_option( self::ACTIONED_OPTION, array() ); $actioned[] = $this->get_id(); $update = update_option( self::ACTIONED_OPTION, array_unique( $actioned ) ); if ( $update ) { $this->record_tracks_event( 'actioned_task', array( 'task_name' => $this->get_id() ) ); } return $update; } /** * Check if a task has been actioned. * * @return bool */ public function is_actioned() { return self::is_task_actioned( $this->get_id() ); } /** * Check if a provided task ID has been actioned. * * @param string $id Task ID. * @return bool */ public static function is_task_actioned( $id ) { $actioned = get_option( self::ACTIONED_OPTION, array() ); return in_array( $id, $actioned, true ); } /** * Sorting function for tasks. * * @param Task $a Task a. * @param Task $b Task b. * @param array $sort_by list of columns with sort order. * @return int */ public static function sort( $a, $b, $sort_by = array() ) { $result = 0; foreach ( $sort_by as $data ) { $key = $data['key']; $a_val = $a->$key ?? false; $b_val = $b->$key ?? false; if ( 'asc' === $data['order'] ) { $result = $a_val <=> $b_val; } else { $result = $b_val <=> $a_val; } if ( 0 !== $result ) { break; } } return $result; } } Features/OnboardingTasks/Tasks/ExtendStore.php 0000777 00000002156 15252240713 0015470 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task; /** * ExtendStore Task */ class ExtendStore extends Task { /** * ID. * * @return string */ public function get_id() { return 'extend-store'; } /** * Title. * * @return string */ public function get_title() { return __( 'Enhance your store with extensions', 'woocommerce' ); } /** * Content. * * @return string */ public function get_content() { return ''; } /** * Additional info. * * @return string */ public function get_additional_info() { return ''; } /** * Time. * * @return string */ public function get_time() { return ''; } /** * Task completion. * * @return bool */ public function is_complete() { return $this->is_visited(); } /** * Always dismissable. * * @return bool */ public function is_dismissable() { return false; } /** * Action URL. * * @return string */ public function get_action_url() { return admin_url( 'admin.php?page=wc-admin&path=/extensions' ); } } Features/OnboardingTasks/Tasks/StoreCreation.php 0000777 00000002044 15252240713 0016001 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks; use Automattic\WooCommerce\Admin\Features\Onboarding; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task; /** * Store Details Task */ class StoreCreation extends Task { /** * ID. * * @return string */ public function get_id() { return 'store_creation'; } /** * Title. * * @return string */ public function get_title() { /* translators: Store name */ return sprintf( __( 'You created %s', 'woocommerce' ), get_bloginfo( 'name' ) ); } /** * Content. * * @return string */ public function get_content() { return ''; } /** * Time. * * @return string */ public function get_time() { return ''; } /** * Time. * * @return string */ public function get_action_url() { return ''; } /** * Task completion. * * @return bool */ public function is_complete() { return true; } /** * Check if task is disabled. * * @return bool */ public function is_disabled() { return true; } } Features/OnboardingTasks/Tasks/Appearance.php 0000777 00000002513 15252240713 0015260 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks; use Automattic\WooCommerce\Admin\PageController; use Automattic\WooCommerce\Internal\Admin\Loader; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\Products; use Automattic\WooCommerce\Internal\Admin\WCAdminAssets; /** * Appearance Task */ class Appearance extends Task { /** * Constructor. */ public function __construct() { if ( ! $this->is_complete() ) { add_action( 'load-theme-install.php', array( $this, 'mark_actioned' ) ); } } /** * ID. * * @return string */ public function get_id() { return 'appearance'; } /** * Title. * * @return string */ public function get_title() { return __( 'Choose your theme', 'woocommerce' ); } /** * Content. * * @return string */ public function get_content() { return __( "Choose a theme that best fits your brand's look and feel, then make it your own. Change the colors, add your logo, and create pages.", 'woocommerce' ); } /** * Time. * * @return string */ public function get_time() { return __( '2 minutes', 'woocommerce' ); } /** * Action label. * * @return string */ public function get_action_label() { return __( 'Choose theme', 'woocommerce' ); } } Features/OnboardingTasks/Tasks/TourInAppMarketplace.php 0000777 00000002277 15252240713 0017262 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task; /** * Tour In-App Marketplace task */ class TourInAppMarketplace extends Task { /** * ID. * * @return string */ public function get_id() { return 'tour-in-app-marketplace'; } /** * Title. * * @return string */ public function get_title() { return __( 'Discover ways of extending your store with a tour of the Woo Marketplace', 'woocommerce' ); } /** * Content. * * @return string */ public function get_content() { return ''; } /** * Time. * * @return string */ public function get_time() { return ''; } /** * Task completion. * * @return bool */ public function is_complete() { return get_option( 'woocommerce_admin_dismissed_in_app_marketplace_tour' ) === 'yes'; } /** * Action URL. * * @return string */ public function get_action_url() { return admin_url( 'admin.php?page=wc-admin&path=%2Fextensions&tutorial=true' ); } /** * Check if should record event when task is viewed * * @return bool */ public function get_record_view_event(): bool { return true; } } Features/OnboardingTasks/Tasks/Payments.php 0000777 00000032172 15252240713 0015025 0 ustar 00 <?php declare( strict_types=1 ); namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task; use Automattic\WooCommerce\Internal\Admin\Settings\Payments as SettingsPaymentsService; use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\DefaultPaymentGateways; use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders; use Automattic\WooCommerce\Internal\Admin\Suggestions\PaymentsExtensionSuggestions; use WC_Gateway_BACS; use WC_Gateway_Cheque; use WC_Gateway_COD; /** * Payments Task */ class Payments extends Task { /** * Used to cache is_complete() method result. * * @var null */ private $is_complete_result = null; /** * ID. * * @return string */ public function get_id() { return 'payments'; } /** * Title. * * @return string */ public function get_title() { return __( 'Set up payments', 'woocommerce' ); } /** * Content. * * @return string */ public function get_content() { return __( 'Choose payment providers and enable payment methods at checkout.', 'woocommerce' ); } /** * Time. * * @return string */ public function get_time() { return __( '5 minutes', 'woocommerce' ); } /** * Task completion. * * @return bool */ public function is_complete() { if ( null === $this->is_complete_result ) { if ( $this->is_woopayments_active() ) { // If WooPayments is active, check if it is fully onboarded with a live account. $this->is_complete_result = $this->is_woopayments_onboarded() && ! $this->has_woopayments_test_account(); } else { // If WooPayments is not active, check if there are any enabled gateways. $this->is_complete_result = self::has_gateways(); } } return $this->is_complete_result; } /** * Task visibility. * * @return bool */ public function can_view() { // The task is always visible. return true; } /** * Check if the store has any enabled gateways. * * @return bool */ public static function has_gateways() { $gateways = WC()->payment_gateways()->payment_gateways; $enabled_gateways = array_filter( $gateways, function ( $gateway ) { return 'yes' === $gateway->enabled; } ); return ! empty( $enabled_gateways ); } /** * Check if the task is in progress. * * @return bool */ public function is_in_progress() { // If the task is already complete, it's not in progress. if ( $this->is_complete() ) { return false; } return ( $this->has_woopayments_live_account_in_progress() || $this->has_woopayments_test_account() ); } /** * The task in progress label. * * @return string */ public function in_progress_label() { // If WooPayments live account onboarding is in progress, show "Action needed" label. if ( $this->has_woopayments_live_account_in_progress() ) { return esc_html__( 'Action needed', 'woocommerce' ); } return esc_html__( 'Test account', 'woocommerce' ); } /** * The task action URL. * * Empty string means the JS logic will handle the task linking. * * @return string */ public function get_action_url() { // Link to the Payments settings page. return admin_url( 'admin.php?page=wc-settings&tab=checkout&from=' . SettingsPaymentsService::FROM_PAYMENTS_TASK ); } /** * Additional data to be passed to the front-end JS logic. * * Primarily used to inform the behavior of the Payments task in the LYS context. * * @return array */ public function get_additional_data() { return array( 'wooPaymentsIsActive' => $this->is_woopayments_active(), 'wooPaymentsIsInstalled' => $this->is_woopayments_installed(), 'wooPaymentsSettingsCountryIsSupported' => $this->is_woopayments_supported_country( $this->get_payments_settings_country() ), 'wooPaymentsIsOnboarded' => $this->is_woopayments_onboarded(), 'wooPaymentsHasTestAccount' => $this->has_woopayments_test_account(), 'wooPaymentsHasOtherProvidersEnabled' => $this->has_providers_enabled_other_than_woopayments(), 'wooPaymentsHasOtherProvidersNeedSetup' => $this->has_providers_needing_setup_other_than_woopayments(), 'wooPaymentsHasOnlineGatewaysEnabled' => $this->has_online_gateways(), ); } /** * Check if the WooPayments plugin is active. * * @return bool */ private function is_woopayments_active(): bool { return class_exists( '\WC_Payments' ); } /** * Check if the WooPayments plugin is installed. * * @return bool */ private function is_woopayments_installed(): bool { if ( $this->is_woopayments_active() ) { // If it is active, it is also installed. return true; } $woopayments_suggestion = $this->get_woopayments_suggestion(); // We should have the WooPayments suggestion, but if not, return false. if ( ! $woopayments_suggestion ) { return false; } // Check if the suggestion has its plugin installed. if ( ! empty( $woopayments_suggestion['plugin']['status'] ) && PaymentsProviders::EXTENSION_INSTALLED === $woopayments_suggestion['plugin']['status'] ) { return true; } return false; } /** * Check if WooPayments is completely onboarded. * * @return bool */ private function is_woopayments_onboarded(): bool { if ( ! $this->is_woopayments_active() ) { return false; } $woopayments_provider = $this->get_woopayments_provider(); // We should have the WooPayments provider, but if not, return false. if ( ! $woopayments_provider ) { return false; } // Check the provider's state to determine if it is onboarded. if ( ! empty( $woopayments_provider['onboarding']['state']['completed'] ) ) { return true; } return false; } /** * Check if WooPayments has a live account onboarding in progress. * * @return bool */ private function has_woopayments_live_account_in_progress() { if ( $this->is_woopayments_onboarded() ) { return false; } $woopayments_provider = $this->get_woopayments_provider(); // We should have the WooPayments provider, but if not, return false. if ( ! $woopayments_provider ) { return false; } // If we have a test account, we are not in live account onboarding. if ( $this->has_woopayments_test_account() ) { return false; } // Check the provider's state to determine if a live account onboarding is started. if ( ! empty( $woopayments_provider['onboarding']['state']['started'] ) ) { return true; } return false; } /** * Check if WooPayments is onboarded and has a test [drive] account. * * @return bool */ private function has_woopayments_test_account(): bool { if ( ! $this->is_woopayments_onboarded() ) { return false; } $woopayments_provider = $this->get_woopayments_provider(); // We should have the WooPayments provider, but if not, return false. if ( ! $woopayments_provider ) { return false; } // Check the provider's state to determine if a test [drive] account is in use. if ( ! empty( $woopayments_provider['onboarding']['state']['test_drive_account'] ) ) { return true; } return false; } /** * Check if the store is in a WooPayments-supported geography. * * @param string $country_code Country code to check. If not provided, uses store base country. * * @return bool Whether the country is supported by WooPayments. */ private function is_woopayments_supported_country( string $country_code ): bool { if ( class_exists( '\WC_Payments_Utils' ) && is_callable( array( '\WC_Payments_Utils', 'supported_countries' ) ) ) { $supported_countries = array_keys( \WC_Payments_Utils::supported_countries() ); return in_array( $country_code, $supported_countries, true ); } // WooPayments is not installed and active, use core's list of supported countries. $supported_countries = DefaultPaymentGateways::get_wcpay_countries(); return in_array( $country_code, $supported_countries, true ); } /** * Check if the store has any enabled providers other than WooPayments. * * @return bool */ public function has_providers_enabled_other_than_woopayments(): bool { $providers = $this->get_payments_providers(); foreach ( $providers as $provider ) { // Check if the provider is enabled and is not WooPayments. if ( ! empty( $provider['state']['enabled'] ) && ! empty( $provider['id'] ) && 'woocommerce_payments' !== $provider['id'] ) { return true; } } return false; } /** * Check if any non-WooPayments providers need setup. * * @return bool */ private function has_providers_needing_setup_other_than_woopayments(): bool { $providers = $this->get_payments_providers(); foreach ( $providers as $provider ) { // Check if the provider needs setup and is not WooPayments. if ( ! empty( $provider['state']['needs_setup'] ) && ! empty( $provider['id'] ) && 'woocommerce_payments' !== $provider['id'] ) { return true; } } return false; } /** * Check if the store has any enabled online gateways. * * @return bool */ private function has_online_gateways(): bool { $providers = $this->get_payments_providers(); foreach ( $providers as $provider ) { // Check if the provider is enabled and is not an offline payment method. if ( ! empty( $provider['state']['enabled'] ) && ! empty( $provider['id'] ) && ! in_array( $provider['id'], array( WC_Gateway_BACS::ID, WC_Gateway_Cheque::ID, WC_Gateway_COD::ID ), true ) ) { return true; } } return false; } /** * Get the store's business registration country/location as it is used on the Payments Settings page. * * @return string The business registration country/location code. */ private function get_payments_settings_country(): string { try { /** * The Payments Settings [page] service. * * @var SettingsPaymentsService $settings_payments_service */ $settings_payments_service = wc_get_container()->get( SettingsPaymentsService::class ); return $settings_payments_service->get_country(); } catch ( \Throwable $e ) { // In case of any error, return the WooCommerce base country. return WC()->countries->get_base_country(); } } /** * Get the list of payments providers as it is used on the Payments Settings page. * * The list can include payments extension suggestions, the same as on the Payments Settings page. * * @return array The list of payments providers. */ private function get_payments_providers(): array { try { /** * The Payments Settings [page] service. * * @var SettingsPaymentsService $settings_payments_service */ $settings_payments_service = wc_get_container()->get( SettingsPaymentsService::class ); // Get the raw list of payment providers, including suggestions, but remove shells. // This way we prevent shell gateways that are (wrongly) reported as enabled from affecting the task completion. return $settings_payments_service->get_payment_providers( $settings_payments_service->get_country(), false, true ); } catch ( \Throwable $e ) { // In case of any error, return an empty array. return array(); } } /** * Get the list of payments extension suggestions as it is used on the Payments Settings page. * * @return array The list of payments extension suggestions. */ private function get_payments_extension_suggestions(): array { try { /** * The Payments Settings [page] service. * * @var SettingsPaymentsService $settings_payments_service */ $settings_payments_service = wc_get_container()->get( SettingsPaymentsService::class ); return $settings_payments_service->get_payment_extension_suggestions( $settings_payments_service->get_country() ); } catch ( \Throwable $e ) { // In case of any error, return an empty array. return array(); } } /** * Get the WooPayments provider details from the list used on the Payments Settings page. * * @return array|null The WooPayments provider details or null if not found. */ private function get_woopayments_provider(): ?array { $providers = $this->get_payments_providers(); foreach ( $providers as $provider ) { if ( ! empty( $provider['id'] ) && PaymentsProviders\WooPayments\WooPaymentsService::GATEWAY_ID === $provider['id'] ) { return $provider; } } return null; } /** * Get the WooPayments payments extension suggestion details from the lists used on the Payments Settings page. * * @return array|null The WooPayments suggestion details or null if not found. */ private function get_woopayments_suggestion(): ?array { // First, check the payments providers list. $providers = $this->get_payments_providers(); foreach ( $providers as $provider ) { if ( ! empty( $provider['_type'] ) && PaymentsProviders::TYPE_SUGGESTION === $provider['_type'] && ! empty( $provider['_suggestion_id'] ) && PaymentsExtensionSuggestions::WOOPAYMENTS === $provider['_suggestion_id'] ) { return $provider; } } // If not found in the main list, check the payments extension suggestions list. $suggestions = $this->get_payments_extension_suggestions(); foreach ( $suggestions as $suggestion ) { if ( ! empty( $suggestion['id'] ) && PaymentsExtensionSuggestions::WOOPAYMENTS === $suggestion['id'] ) { return $suggestion; } } return null; } } Features/OnboardingTasks/Tasks/WooCommercePayments.php 0000777 00000020264 15252240713 0017164 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks; use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task; use Automattic\WooCommerce\Admin\PluginsHelper; use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\Init as Suggestions; use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\DefaultPaymentGateways; use Automattic\WooCommerce\Internal\Admin\WcPayWelcomePage; use WC_Gateway_BACS; use WC_Gateway_Cheque; use WC_Gateway_COD; /** * WooCommercePayments Task. * * @deprecated 9.9.0 The WooPayments onboarding task is deprecated and will be removed in a future version of WooCommerce. */ class WooCommercePayments extends Task { /** * Used to cache is_complete() method result. * * @var null */ private $is_complete_result = null; /** * ID. * * @return string */ public function get_id() { return 'woocommerce-payments'; } /** * Title. * * @return string */ public function get_title() { /* translators: %s: Payment provider name. */ return sprintf( __( 'Get paid with %s', 'woocommerce' ), 'WooPayments' ); } /** * Badge. * * @return string */ public function get_badge() { /** * Filter WooPayments onboarding task badge. * * @param string $badge Badge content. * @since 8.2.0 */ return apply_filters( 'woocommerce_admin_woopayments_onboarding_task_badge', '' ); } /** * Content. * * @return string */ public function get_content() { return __( "You're only one step away from getting paid. Verify your business details to start managing transactions with WooPayments.", 'woocommerce' ); } /** * Additional data. * * @return mixed */ public function get_additional_data() { /** * Filter WooPayments onboarding task additional data. * * @since 9.4.0 * * @param ?array $additional_data The task additional data. */ return apply_filters( 'woocommerce_admin_woopayments_onboarding_task_additional_data', null ); } /** * Time. * * @return string */ public function get_time() { return __( '2 minutes', 'woocommerce' ); } /** * Action label. * * @return string */ public function get_action_label() { return __( 'Finish setup', 'woocommerce' ); } /** * Task completion. * * @return bool */ public function is_complete() { if ( null === $this->is_complete_result ) { // This task is complete if there are other ecommerce gateways enabled (offline payment methods are excluded), // or if WooPayments is active and has a connected, fully onboarded account. $this->is_complete_result = self::has_other_ecommerce_gateways() || ( self::is_connected() && ! self::is_account_partially_onboarded() ); } return $this->is_complete_result; } /** * Task visibility. * * @return bool */ public function can_view() { return self::is_supported(); } /** * Check if the WooPayments plugin was requested during onboarding. * * @return bool */ public static function is_requested() { $profiler_data = get_option( OnboardingProfile::DATA_OPTION, array() ); $product_types = isset( $profiler_data['product_types'] ) ? $profiler_data['product_types'] : array(); $business_extensions = isset( $profiler_data['business_extensions'] ) ? $profiler_data['business_extensions'] : array(); $subscriptions_and_us = in_array( 'subscriptions', $product_types, true ) && 'US' === WC()->countries->get_base_country(); return in_array( 'woocommerce-payments', $business_extensions, true ) || $subscriptions_and_us; } /** * Check if the WooPayments plugin is installed. * * @return bool */ public static function is_installed() { $installed_plugins = PluginsHelper::get_installed_plugin_slugs(); return in_array( 'woocommerce-payments', $installed_plugins, true ); } /** * Check if the WooPayments plugin is active. * * @return bool */ public static function is_wcpay_active() { return class_exists( '\WC_Payments' ); } /** * Check if WooPayments is connected. * * @return bool */ public static function is_connected() { if ( ! self::is_wcpay_active() ) { return false; } $wc_payments_gateway = self::get_gateway(); if ( $wc_payments_gateway && method_exists( $wc_payments_gateway, 'is_connected' ) ) { return $wc_payments_gateway->is_connected(); } return false; } /** * Check if WooPayments needs setup. * Errored data or payments not enabled. * * @return bool */ public static function is_account_partially_onboarded() { if ( ! self::is_wcpay_active() ) { return false; } $wc_payments_gateway = self::get_gateway(); if ( $wc_payments_gateway && method_exists( $wc_payments_gateway, 'is_account_partially_onboarded' ) ) { return $wc_payments_gateway->is_account_partially_onboarded(); } return false; } /** * Get the WooPayments payment gateway suggestion. * * @return object|null The WooPayments suggestion, or null if none found. */ public static function get_suggestion() { $suggestions = Suggestions::get_suggestions( DefaultPaymentGateways::get_all() ); $wcpay_suggestions = array_filter( $suggestions, function ( $suggestion ) { if ( empty( $suggestion->plugins ) || ! is_array( $suggestion->plugins ) ) { return false; } return in_array( 'woocommerce-payments', $suggestion->plugins, true ); } ); if ( empty( $wcpay_suggestions ) ) { return null; } return reset( $wcpay_suggestions ); } /** * Check if the store location is in a WooPayments supported country. * * We infer this from the availability of a WooPayments payment gateways suggestion. * * @return bool True if the store location is in a WooPayments supported country, false otherwise. */ public static function is_supported() { return ! empty( self::get_suggestion() ); } /** * Get the WooPayments gateway. * * @return \WC_Payments|null */ private static function get_gateway() { $payment_gateways = WC()->payment_gateways()->payment_gateways(); if ( isset( $payment_gateways['woocommerce_payments'] ) ) { return $payment_gateways['woocommerce_payments']; } return null; } /** * Check if the store has any enabled ecommerce gateways, other than WooPayments. * * We exclude offline payment methods from this check. * * @return bool */ public static function has_other_ecommerce_gateways(): bool { $gateways = WC()->payment_gateways()->payment_gateways; $enabled_gateways = array_filter( $gateways, function ( $gateway ) { // Filter out any WooPayments-related or offline gateways. return 'yes' === $gateway->enabled && 0 !== strpos( $gateway->id, 'woocommerce_payments' ) && ! in_array( $gateway->id, array( WC_Gateway_BACS::ID, WC_Gateway_Cheque::ID, WC_Gateway_COD::ID ), true ); } ); return ! empty( $enabled_gateways ); } /** * The task action URL. * * @return string */ public function get_action_url() { if ( self::is_supported() ) { // If WooPayments is active, point to the WooPayments client surfaces/flows. if ( self::is_wcpay_active() ) { // Point to a WooPayments connect link to let the WooPayments client figure out the proper // place to redirect the user to. return add_query_arg( array( 'wcpay-connect' => '1', 'from' => 'WCADMIN_PAYMENT_TASK', '_wpnonce' => wp_create_nonce( 'wcpay-connect' ), ), admin_url( 'admin.php' ) ); } // Check if there is an active WooPayments incentive via the welcome page. if ( WcPayWelcomePage::instance()->has_incentive() ) { // Point to the WooPayments welcome page. return add_query_arg( 'from', 'WCADMIN_PAYMENT_TASK', admin_url( 'admin.php?page=wc-admin&path=/wc-pay-welcome-page' ) ); } // WooPayments is not active. // Trigger the WooPayments plugin installation and/or activation by pointing to the task suggestion URL. return add_query_arg( array( 'task' => $this->get_id(), 'id' => self::get_suggestion()->id, ), admin_url( 'admin.php?page=wc-admin' ) ); } // Fall back to the WooPayments task page URL. return add_query_arg( 'task', $this->get_id(), admin_url( 'admin.php?page=wc-admin' ) ); } } Features/OnboardingTasks/Tasks/GetMobileApp.php 0000777 00000005014 15252240713 0015530 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task; use Automattic\Jetpack\Connection\Manager; // https://github.com/Automattic/jetpack/blob/trunk/projects/packages/connection/src/class-manager.php . /** * Get Mobile App Task */ class GetMobileApp extends Task { /** * ID. * * @return string */ public function get_id() { return 'get-mobile-app'; } /** * Title. * * @return string */ public function get_title() { return __( 'Get the free WooCommerce mobile app', 'woocommerce' ); } /** * Content. * * @return string */ public function get_content() { return ''; } /** * Time. * * @return string */ public function get_time() { return ''; } /** * Task completion. * * @return bool */ public function is_complete() { return get_option( 'woocommerce_admin_dismissed_mobile_app_modal' ) === 'yes'; } /** * Task visibility. * Can view under these conditions: * - Jetpack is installed and connected && current site user has a wordpress.com account connected to jetpack * - Jetpack is not connected && current user is capable of installing plugins * * @return bool */ public function can_view() { $jetpack_can_be_installed = current_user_can( 'manage_woocommerce' ) && current_user_can( 'install_plugins' ) && ! self::is_jetpack_connected(); $jetpack_is_installed_and_current_user_connected = self::is_current_user_connected(); return $jetpack_can_be_installed || $jetpack_is_installed_and_current_user_connected; } /** * Determines if site has any users connected to WordPress.com via JetPack * * @return bool */ private static function is_jetpack_connected() { if ( class_exists( '\Automattic\Jetpack\Connection\Manager' ) && method_exists( '\Automattic\Jetpack\Connection\Manager', 'is_active' ) ) { $connection = new Manager(); return $connection->is_active(); } return false; } /** * Determines if the current user is connected to Jetpack. * * @return bool */ private static function is_current_user_connected() { if ( class_exists( '\Automattic\Jetpack\Connection\Manager' ) && method_exists( '\Automattic\Jetpack\Connection\Manager', 'is_user_connected' ) ) { $connection = new Manager(); return $connection->is_connection_owner(); } return false; } /** * Action URL. * * @return string */ public function get_action_url() { return admin_url( 'admin.php?page=wc-admin&mobileAppModal=true' ); } } Features/OnboardingTasks/Tasks/Products.php 0000777 00000016137 15252240713 0015033 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task; use Automattic\WooCommerce\Enums\ProductStatus; use Automattic\WooCommerce\Internal\Admin\WCAdminAssets; use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile; /** * Products Task */ class Products extends Task { const HAS_PRODUCT_TRANSIENT = 'woocommerce_product_task_has_product_transient'; /** * Constructor * * @param TaskList $task_list Parent task list. */ public function __construct( $task_list ) { parent::__construct( $task_list ); add_action( 'admin_enqueue_scripts', array( $this, 'possibly_add_import_return_notice_script' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'possibly_add_load_sample_return_notice_script' ) ); add_action( 'woocommerce_update_product', array( $this, 'maybe_set_has_product_transient' ), 10, 2 ); add_action( 'woocommerce_new_product', array( $this, 'maybe_set_has_product_transient' ), 10, 2 ); add_action( 'untrashed_post', array( $this, 'maybe_set_has_product_transient_on_untrashed_post' ) ); add_action( 'current_screen', array( $this, 'maybe_redirect_to_add_product_tasklist' ), 30, 0 ); } /** * ID. * * @return string */ public function get_id() { return 'products'; } /** * Title. * * @return string */ public function get_title() { $onboarding_profile = get_option( OnboardingProfile::DATA_OPTION, array() ); if ( isset( $onboarding_profile['business_choice'] ) && 'im_already_selling' === $onboarding_profile['business_choice'] ) { return __( 'Import your products', 'woocommerce' ); } return __( 'Add your products', 'woocommerce' ); } /** * Content. * * @return string */ public function get_content() { return __( 'Start by adding the first product to your store. You can add your products manually, via CSV, or import them from another service.', 'woocommerce' ); } /** * Time. * * @return string */ public function get_time() { return __( '1 minute per product', 'woocommerce' ); } /** * Task completion. * * @return bool */ public function is_complete() { if ( $this->has_previously_completed() ) { return true; } return self::has_products(); } /** * Additional data. * * @return array */ public function get_additional_data() { return array( 'has_products' => self::has_products(), ); } /** * If a task is always accessible, relevant for when a task list is hidden but a task can still be viewed. * * @return bool */ public function is_always_accessible() { return true; } /** * Adds a return to task list notice when completing the import product task. * * @param string $hook Page hook. */ public function possibly_add_import_return_notice_script( $hook ) { $step = isset( $_GET['step'] ) ? $_GET['step'] : ''; // phpcs:ignore csrf ok, sanitization ok. if ( $hook !== 'product_page_product_importer' || $step !== 'done' ) { return; } if ( ! $this->is_active() || $this->is_complete() ) { return; } WCAdminAssets::register_script( 'wp-admin-scripts', 'onboarding-product-import-notice', true ); } /** * Adds a return to task list notice when completing the loading sample products action. * * @param string $hook Page hook. */ public function possibly_add_load_sample_return_notice_script( $hook ) { if ( $hook !== 'edit.php' || get_query_var( 'post_type' ) !== 'product' ) { return; } $referer = wp_get_referer(); if ( ! $referer || strpos( $referer, wc_admin_url() ) !== 0 ) { return; } if ( ! isset( $_GET[ Task::ACTIVE_TASK_TRANSIENT ] ) ) { return; } $task_id = sanitize_title_with_dashes( wp_unslash( $_GET[ Task::ACTIVE_TASK_TRANSIENT ] ) ); if ( $task_id !== $this->get_id() || ! $this->is_complete() ) { return; } WCAdminAssets::register_script( 'wp-admin-scripts', 'onboarding-load-sample-products-notice', true ); } /** * Set the has products transient if the post qualifies as a user created product. * * @param int $post_id Post ID. */ public function maybe_set_has_product_transient_on_untrashed_post( $post_id ) { if ( get_post_type( $post_id ) !== 'product' ) { return; } $this->maybe_set_has_product_transient( $post_id, wc_get_product( $post_id ) ); } /** * Set the has products transient if the product qualifies as a user created product. * * @param int $product_id Product ID. * @param WC_Product $product Product object. */ public function maybe_set_has_product_transient( $product_id, $product ) { if ( ! $this->has_previously_completed() && $this->is_valid_product( $product ) ) { set_transient( self::HAS_PRODUCT_TRANSIENT, 'yes' ); $this->possibly_track_completion(); } } /** * Check if the product qualifies as a user created product. * * @param WC_Product $product Product object. * @return bool */ private function is_valid_product( $product ) { return ProductStatus::PUBLISH === $product->get_status() && ( ! $product->get_meta( '_headstart_post' ) || get_post_meta( $product->get_id(), '_edit_last', true ) ); } /** * Check if the store has any user created published products. * * @return bool */ public static function has_products() { $product_exists = get_transient( self::HAS_PRODUCT_TRANSIENT ); if ( $product_exists ) { return 'yes' === $product_exists; } global $wpdb; /* * Check if any valid products exist and return 'yes' or 'no' * A valid product must: * 1. Be a published product post type * 2. Meet one of these conditions: * - Have been edited by a user (_edit_last meta exists), OR * - Not have _headstart_post meta, OR * - Have _headstart_post meta but it's NULL */ $value = $wpdb->get_var( $wpdb->prepare( "SELECT IF( EXISTS ( SELECT 1 FROM {$wpdb->posts} p WHERE p.post_type = %s AND p.post_status = %s AND ( EXISTS ( SELECT 1 FROM {$wpdb->postmeta} pm WHERE pm.post_id = p.ID AND pm.meta_key = %s ) OR NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} pm WHERE pm.post_id = p.ID AND pm.meta_key = %s ) OR EXISTS ( SELECT 1 FROM {$wpdb->postmeta} pm WHERE pm.post_id = p.ID AND pm.meta_key = %s AND pm.meta_value = '' ) ) LIMIT 1 ), 'yes', 'no' )", 'product', ProductStatus::PUBLISH, '_edit_last', '_headstart_post', '_headstart_post' ) ); set_transient( self::HAS_PRODUCT_TRANSIENT, $value ); return 'yes' === $value; } /** * Redirect to the add product tasklist if there are no products. * * @return void */ public function maybe_redirect_to_add_product_tasklist() { $screen = get_current_screen(); if ( 'edit' === $screen->base && 'product' === $screen->post_type ) { // wp_count_posts is cached. $counts = (array) wp_count_posts( $screen->post_type ); unset( $counts['auto-draft'] ); $count = array_sum( $counts ); if ( $count > 0 ) { return; } wp_safe_redirect( admin_url( 'admin.php?page=wc-admin&task=products' ) ); exit; } } } Features/OnboardingTasks/Tasks/ExperimentalShippingRecommendation.php 0000777 00000003350 15252240713 0022245 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks; use Automattic\Jetpack\Connection\Manager; use Automattic\WooCommerce\Admin\Features\Features; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task; use Automattic\WooCommerce\Admin\PluginsHelper; use Automattic\WooCommerce\Internal\Jetpack\JetpackConnection; /** * Shipping Task */ class ExperimentalShippingRecommendation extends Task { /** * ID. * * @return string */ public function get_id() { return 'shipping-recommendation'; } /** * Title. * * @return string */ public function get_title() { return __( 'Get your products shipped', 'woocommerce' ); } /** * Content. * * @return string */ public function get_content() { return ''; } /** * Time. * * @return string */ public function get_time() { return ''; } /** * Task completion. * * @return bool */ public function is_complete() { return self::has_plugins_active() && self::has_jetpack_connected(); } /** * Task visibility. * * @return bool */ public function can_view() { return Features::is_enabled( 'shipping-smart-defaults' ); } /** * Action URL. * * @return string */ public function get_action_url() { return ''; } /** * Check if the store has any shipping zones. * * @return bool */ public static function has_plugins_active() { return PluginsHelper::is_plugin_active( 'woocommerce-shipping' ); } /** * Check if the Jetpack is connected. * * @return bool */ public static function has_jetpack_connected() { $jetpack_connection_manager = JetpackConnection::get_manager(); return $jetpack_connection_manager->is_connected() && $jetpack_connection_manager->has_connected_owner(); } } Features/OnboardingTasks/Tasks/LaunchYourStore.php 0000777 00000004650 15252240713 0016333 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks; use Automattic\WooCommerce\Admin\Features\Features; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task; /** * Launch Your Store Task */ class LaunchYourStore extends Task { /** * Constructor * * @param TaskList $task_list Parent task list. */ public function __construct( $task_list ) { parent::__construct( $task_list ); add_action( 'show_admin_bar', array( $this, 'possibly_hide_wp_admin_bar' ) ); } /** * ID. * * @return string */ public function get_id() { return 'launch-your-store'; } /** * Title. * * @return string */ public function get_title() { return __( 'Launch your store', 'woocommerce' ); } /** * Content. * * @return string */ public function get_content() { return __( "It's time to celebrate – you're ready to launch your store! Woo! Hit the button to preview your store and make it public.", 'woocommerce' ); } /** * Time. * * @return string */ public function get_time() { return ''; } /** * Action URL. * * @return string */ public function get_action_url() { return admin_url( 'admin.php?page=wc-admin&path=%2Flaunch-your-store' ); } /** * Task completion. * * @return bool */ public function is_complete() { return 'yes' !== get_option( 'woocommerce_coming_soon' ); } /** * Task visibility. * * @return bool */ public function can_view() { return Features::is_enabled( 'launch-your-store' ); } /** * Hide the WP admin bar when the user is previewing the site. * * @param bool $show Whether to show the admin bar. */ public function possibly_hide_wp_admin_bar( $show ) { if ( isset( $_GET['site-preview'] ) ) { // @phpcs:ignore return false; } global $wp; $http_referer = wp_get_referer() ?? ''; $parsed_url = wp_parse_url( $http_referer, PHP_URL_QUERY ); $query_string = is_string( $parsed_url ) ? $parsed_url : ''; // Check if the user is coming from the site preview link. if ( strpos( $query_string, 'site-preview' ) !== false ) { if ( ! isset( $_SERVER['REQUEST_URI'] ) ) { return $show; } // Redirect to the current URL with the site-preview query string. $current_url = add_query_arg( array( 'site-preview' => 1, ), esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) ); wp_safe_redirect( $current_url ); exit; } return $show; } } Features/OnboardingTasks/Tasks/CustomizeStore.php 0000777 00000006645 15252240713 0016232 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task; use WP_Post; /** * Customize Your Store Task * * @internal */ class CustomizeStore extends Task { /** * Constructor * * @param TaskList $task_list Parent task list. */ public function __construct( $task_list ) { parent::__construct( $task_list ); add_action( 'save_post_wp_global_styles', array( $this, 'mark_task_as_complete_block_theme' ), 10, 3 ); add_action( 'save_post_wp_template', array( $this, 'mark_task_as_complete_block_theme' ), 10, 3 ); add_action( 'save_post_wp_template_part', array( $this, 'mark_task_as_complete_block_theme' ), 10, 3 ); add_action( 'customize_save_after', array( $this, 'mark_task_as_complete_classic_theme' ) ); } /** * Mark the CYS task as complete whenever the user updates their global styles. * * @param int $post_id Post ID. * @param WP_Post $post Post object. * @param bool $update Whether this is an existing post being updated. * * @return void */ public function mark_task_as_complete_block_theme( $post_id, $post, $update ) { if ( $post instanceof WP_Post ) { $is_cys_complete = $this->has_custom_global_styles( $post ) || $this->has_custom_template( $post ); if ( $is_cys_complete ) { update_option( 'woocommerce_admin_customize_store_completed', 'yes' ); } } } /** * Mark the CYS task as complete whenever the user saves the customizer changes. * * @return void */ public function mark_task_as_complete_classic_theme() { update_option( 'woocommerce_admin_customize_store_completed', 'yes' ); } /** * ID. * * @return string */ public function get_id() { return 'customize-store'; } /** * Title. * * @return string */ public function get_title() { return __( 'Customize your store ', 'woocommerce' ); } /** * Content. * * @return string */ public function get_content() { return ''; } /** * Time. * * @return string */ public function get_time() { return ''; } /** * Task completion. * * @return bool */ public function is_complete() { return get_option( 'woocommerce_admin_customize_store_completed' ) === 'yes'; } /** * Task visibility. * * @return bool */ public function can_view() { return true; } /** * Action URL. * * @return string */ public function get_action_url() { return admin_url( 'admin.php?page=wc-admin&path=%2Fcustomize-store' ); } /** * Checks if the post has custom global styles stored (if it is different from the default global styles). * * @param WP_Post $post The post object. * @return bool */ private function has_custom_global_styles( WP_Post $post ) { $required_keys = array( 'version', 'isGlobalStylesUserThemeJSON' ); $json_post_content = json_decode( $post->post_content, true ); if ( is_null( $json_post_content ) ) { return false; } $post_content_keys = array_keys( $json_post_content ); return ! empty( array_diff( $post_content_keys, $required_keys ) ) || ! empty( array_diff( $required_keys, $post_content_keys ) ); } /** * Checks if the post is a template or a template part. * * @param WP_Post $post The post object. * @return bool Whether the post is a template or a template part. */ private function has_custom_template( WP_Post $post ) { return in_array( $post->post_type, array( 'wp_template', 'wp_template_part' ), true ); } } Features/OnboardingTasks/Tasks/StoreDetails.php 0000777 00000004207 15252240713 0015625 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks; use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task; /** * Store Details Task */ class StoreDetails extends Task { /** * ID. * * @return string */ public function get_id() { return 'store_details'; } /** * Title. * * @return string */ public function get_title() { if ( true === $this->get_parent_option( 'use_completed_title' ) ) { if ( $this->is_complete() ) { return __( 'You added store details', 'woocommerce' ); } return __( 'Add store details', 'woocommerce' ); } return __( 'Store details', 'woocommerce' ); } /** * Content. * * @return string */ public function get_content() { return __( 'Your store address is required to set the origin country for shipping, currencies, and payment options.', 'woocommerce' ); } /** * Time. * * @return string */ public function get_time() { return __( '4 minutes', 'woocommerce' ); } /** * Time. * * @return string */ public function get_action_url() { return ! $this->is_complete() ? admin_url( 'admin.php?page=wc-settings&tab=general&tutorial=true' ) : admin_url( 'admin.php?page=wc-settings&tab=general' ); } /** * Task completion. * * @return bool */ public function is_complete() { $country = WC()->countries->get_base_country(); $country_locale = WC()->countries->get_country_locale(); $locale = $country_locale[ $country ] ?? array(); $hide_postcode = $locale['postcode']['hidden'] ?? false; // If postcode is hidden, just check that the store address and city are set. if ( $hide_postcode ) { return get_option( 'woocommerce_store_address', '' ) !== '' && get_option( 'woocommerce_store_city', '' ) !== ''; } // Mark as completed if the store address, city and postcode are set. We don't need to check the country because it's set by default. return get_option( 'woocommerce_store_address', '' ) !== '' && get_option( 'woocommerce_store_city', '' ) !== '' && get_option( 'woocommerce_store_postcode', '' ) !== ''; } } Features/OnboardingTasks/Tasks/ReviewShippingOptions.php 0000777 00000002177 15252240713 0017546 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task; /** * Review Shipping Options Task */ class ReviewShippingOptions extends Task { /** * ID. * * @return string */ public function get_id() { return 'review-shipping'; } /** * Title. * * @return string */ public function get_title() { return __( 'Review shipping options', 'woocommerce' ); } /** * Content. * * @return string */ public function get_content() { return ''; } /** * Time. * * @return string */ public function get_time() { return ''; } /** * Task completion. * * @return bool */ public function is_complete() { return get_option( 'woocommerce_admin_reviewed_default_shipping_zones' ) === 'yes'; } /** * Task visibility. * * @return bool */ public function can_view() { return get_option( 'woocommerce_admin_created_default_shipping_zones' ) === 'yes'; } /** * Action URL. * * @return string */ public function get_action_url() { return admin_url( 'admin.php?page=wc-settings&tab=shipping' ); } } Features/OnboardingTasks/Tasks/AdditionalPayments.php 0000777 00000006737 15252240713 0017026 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks; use Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders; use Automattic\WooCommerce\Internal\Admin\Settings\Payments as SettingsPaymentsService; /** * Payments Task */ class AdditionalPayments extends Payments { /** * Used to cache is_complete() method result. * * @var null */ private $is_complete_result = null; /** * Used to cache can_view() method result. * * @var null */ private $can_view_result = null; /** * ID. * * @return string */ public function get_id() { return 'payments'; } /** * Title. * * @return string */ public function get_title() { return __( 'Set up additional payment options', 'woocommerce' ); } /** * Content. * * @return string */ public function get_content() { return __( 'Choose payment providers and enable payment methods at checkout.', 'woocommerce' ); } /** * Time. * * @return string */ public function get_time() { return __( '2 minutes', 'woocommerce' ); } /** * Task completion. * * @return bool */ public function is_complete() { if ( null === $this->is_complete_result ) { $this->is_complete_result = $this->has_enabled_non_psp_payment_suggestion(); } return $this->is_complete_result; } /** * Task visibility. * * @return bool */ public function can_view() { if ( null !== $this->can_view_result ) { return $this->can_view_result; } // Always show task if there are any gateways enabled (i.e. the Payments task is complete). if ( self::has_gateways() ) { $this->can_view_result = true; } else { $this->can_view_result = false; } return $this->can_view_result; } /** * Action URL. * * @return string */ public function get_action_url(): string { // We auto-expand the "Other" section to show the additional payment methods. return admin_url( 'admin.php?page=wc-settings&tab=checkout&other_pes_section=expanded&from=' . SettingsPaymentsService::FROM_ADDITIONAL_PAYMENTS_TASK ); } /** * Check if there are any enabled non-PSP payment suggestions. * * @return bool True if there are enabled non-PSP payment suggestions, false otherwise. */ private function has_enabled_non_psp_payment_suggestion(): bool { $providers = $this->get_payment_providers(); foreach ( $providers as $provider ) { // Check if the provider is enabled and has a suggestion category ID that matches the ones we are interested in. if ( ! empty( $provider['state']['enabled'] ) && ! empty( $provider['_suggestion_category_id'] ) && in_array( $provider['_suggestion_category_id'], array( PaymentsProviders::CATEGORY_BNPL, PaymentsProviders::CATEGORY_EXPRESS_CHECKOUT, PaymentsProviders::CATEGORY_CRYPTO ), true ) ) { return true; } } return false; } /** * Get the list of payments providers as it is used on the Payments Settings page. * * @return array The list of payment providers. */ private function get_payment_providers(): array { try { /** * The Payments Settings [page] service. * * @var SettingsPaymentsService $settings_payments_service */ $settings_payments_service = wc_get_container()->get( SettingsPaymentsService::class ); $providers = $settings_payments_service->get_payment_providers( $settings_payments_service->get_country(), false ); } catch ( \Throwable $e ) { // In case of any error, return an empty array. $providers = array(); } return $providers; } } Features/OnboardingTasks/Tasks/Shipping.php 0000777 00000011726 15252240713 0015010 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks; use Automattic\WooCommerce\Admin\Features\Features; use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task; use WC_Data_Store; /** * Shipping Task */ class Shipping extends Task { const ZONE_COUNT_TRANSIENT_NAME = 'woocommerce_shipping_task_zone_count_transient'; /** * Constructor * * @param TaskList $task_list Parent task list. */ public function __construct( $task_list = null ) { parent::__construct( $task_list ); // wp_ajax_woocommerce_shipping_zone_methods_save_changes // and wp_ajax_woocommerce_shipping_zones_save_changes get fired // when a new zone is added or an existing one has been changed. add_action( 'wp_ajax_woocommerce_shipping_zones_save_changes', array( __CLASS__, 'delete_zone_count_transient' ), 9 ); add_action( 'wp_ajax_woocommerce_shipping_zone_methods_save_changes', array( __CLASS__, 'delete_zone_count_transient' ), 9 ); add_action( 'woocommerce_shipping_zone_method_added', array( __CLASS__, 'delete_zone_count_transient' ), 9 ); add_action( 'woocommerce_after_shipping_zone_object_save', array( __CLASS__, 'delete_zone_count_transient' ), 9 ); } /** * ID. * * @return string */ public function get_id() { return 'shipping'; } /** * Title. * * @return string */ public function get_title() { return __( 'Select your shipping options', 'woocommerce' ); } /** * Content. * * @return string */ public function get_content() { return __( "Set your store location and where you'll ship to.", 'woocommerce' ); } /** * Time. * * @return string */ public function get_time() { return __( '1 minute', 'woocommerce' ); } /** * Task completion. * * @return bool */ public function is_complete() { return self::has_shipping_zones(); } /** * Task visibility. * * @return bool */ public function can_view() { if ( Features::is_enabled( 'shipping-smart-defaults' ) ) { if ( 'yes' === get_option( 'woocommerce_admin_created_default_shipping_zones' ) ) { // If the user has already created a default shipping zone, we don't need to show the task. return false; } /** * Do not display the task when: * - The store sells digital products only * Display the task when: * - We don't know where the store's located * - The store is located in the UK, Australia or Canada */ if ( self::is_selling_digital_type_only() ) { return false; } $default_store_country = wc_format_country_state_string( get_option( 'woocommerce_default_country', '' ) )['country']; // Check if a store address is set so that we don't default to WooCommerce's default country US. // Similar logic: https://github.com/woocommerce/woocommerce/blob/059d542394b48468587f252dcb6941c6425cd8d3/plugins/woocommerce-admin/client/profile-wizard/steps/store-details/index.js#L511-L516. $store_country = ''; if ( ! empty( get_option( 'woocommerce_store_address', '' ) ) || 'US' !== $default_store_country ) { $store_country = $default_store_country; } // Unknown country. if ( empty( $store_country ) ) { return true; } return in_array( $store_country, array( 'CA', 'AU', 'NZ', 'SG', 'HK', 'GB', 'ES', 'IT', 'DE', 'FR', 'CL', 'AR', 'PE', 'BR', 'UY', 'GT', 'NL', 'AT', 'BE' ), true ); } return self::has_physical_products(); } /** * Action URL. * * @return string */ public function get_action_url() { return self::has_shipping_zones() ? admin_url( 'admin.php?page=wc-settings&tab=shipping' ) : null; } /** * Check if the store has any shipping zones. * * @return bool */ public static function has_shipping_zones() { $zone_count = get_transient( self::ZONE_COUNT_TRANSIENT_NAME ); if ( false !== $zone_count ) { return (int) $zone_count > 0; } $zone_count = count( WC_Data_Store::load( 'shipping-zone' )->get_zones() ); set_transient( self::ZONE_COUNT_TRANSIENT_NAME, $zone_count ); return $zone_count > 0; } /** * Check if the store has physical products. * * @return bool */ public static function has_physical_products() { $profiler_data = get_option( OnboardingProfile::DATA_OPTION, array() ); $product_types = isset( $profiler_data['product_types'] ) ? $profiler_data['product_types'] : array(); return in_array( 'physical', $product_types, true ); } /** * Delete the zone count transient used in has_shipping_zones() method * to refresh the cache. */ public static function delete_zone_count_transient() { delete_transient( self::ZONE_COUNT_TRANSIENT_NAME ); } /** * Check if the store sells digital products only. * * @return bool */ private static function is_selling_digital_type_only() { $profiler_data = get_option( OnboardingProfile::DATA_OPTION, array() ); $product_types = isset( $profiler_data['product_types'] ) ? $profiler_data['product_types'] : array(); return array( 'downloads' ) === $product_types; } } Features/OnboardingTasks/Tasks/Marketing.php 0000777 00000004506 15252240713 0015146 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks; use Automattic\WooCommerce\Admin\Features\Features; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task; /** * Marketing Task */ class Marketing extends Task { /** * Constructor * * @param TaskList $task_list Parent task list. */ public function __construct( $task_list ) { parent::__construct( $task_list ); add_action( 'activated_plugin', array( $this, 'on_activated_plugin' ), 10, 1 ); } /** * Mark the task as complete when related plugins are activated. */ public function on_activated_plugin( $plugin ) { $plugin_basename = basename( plugin_basename( $plugin ), '.php' ); // Example: How to mark the marketing task as complete when a specific plugin is activated. /** * Example: * if ( * $plugin_basename === 'multichannel-by-cedcommerce' && * $this->task_list->visible && * ! $this->task_list->is_hidden() && * ! $this->is_complete() * ) { * $this->mark_actioned(); * } */ } /** * Used to cache is_complete() method result. * * @var null */ private $is_complete_result = null; /** * ID. * * @return string */ public function get_id() { return 'marketing'; } /** * Title. * * @return string */ public function get_title() { return __( 'Grow your business', 'woocommerce' ); } /** * Content. * * @return string */ public function get_content() { return __( 'Add recommended marketing tools to reach new customers and grow your business', 'woocommerce' ); } /** * Time. * * @return string */ public function get_time() { return __( '2 minutes', 'woocommerce' ); } /** * Task visibility. * * @return bool */ public function can_view() { return Features::is_enabled( 'remote-free-extensions' ); } /** * Get the marketing plugins. * * @deprecated 9.3.0 Removed to improve performance. * @return array */ public static function get_plugins() { wc_deprecated_function( __METHOD__, '9.3.0' ); return array(); } /** * Check if the store has installed marketing extensions. * * @deprecated 9.3.0 Removed to improve performance. * @return bool */ public static function has_installed_extensions() { wc_deprecated_function( __METHOD__, '9.3.0' ); return false; } } Features/OnboardingTasks/Tasks/Tax.php 0000777 00000015562 15252240713 0013765 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks; use Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\DataStore as TaxDataStore; use Automattic\WooCommerce\Admin\Features\Features; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task; use Automattic\WooCommerce\Admin\PluginsHelper; use Automattic\WooCommerce\Internal\Admin\WCAdminAssets; /** * Tax Task */ class Tax extends Task { private const TAX_RATE_EXISTS_CACHE_KEY = 'woocommerce_onboarding_task_tax_rates_exist'; /** * Used to cache is_complete() method result. * * @var null */ private $is_complete_result = null; /** * Constructor * * @param TaskList $task_list Parent task list. */ public function __construct( $task_list ) { parent::__construct( $task_list ); add_action( 'admin_enqueue_scripts', array( $this, 'possibly_add_return_notice_script' ) ); add_action( 'woocommerce_tax_rate_added', array( $this, 'on_tax_rate_added' ) ); add_action( 'woocommerce_tax_rate_deleted', array( $this, 'on_tax_rate_deleted' ) ); } /** * Adds a return to task list notice when completing the task. */ public function possibly_add_return_notice_script() { $page = isset( $_GET['page'] ) ? $_GET['page'] : ''; // phpcs:ignore csrf ok, sanitization ok. $tab = isset( $_GET['tab'] ) ? $_GET['tab'] : ''; // phpcs:ignore csrf ok, sanitization ok. if ( $page !== 'wc-settings' || $tab !== 'tax' ) { return; } if ( ! $this->is_active() || $this->is_complete() ) { return; } WCAdminAssets::register_script( 'wp-admin-scripts', 'onboarding-tax-notice', true ); } /** * ID. * * @return string */ public function get_id() { return 'tax'; } /** * Title. * * @return string */ public function get_title() { return __( 'Collect sales tax', 'woocommerce' ); } /** * Content. * * @return string */ public function get_content() { return self::can_use_automated_taxes() ? __( 'Good news! WooCommerce Tax can automate your sales tax calculations for you.', 'woocommerce' ) : __( 'Set your store location and configure tax rate settings.', 'woocommerce' ); } /** * Time. * * @return string */ public function get_time() { return __( '1 minute', 'woocommerce' ); } /** * Action label. * * @return string */ public function get_action_label() { return self::can_use_automated_taxes() ? __( 'Yes please', 'woocommerce' ) : __( "Let's go", 'woocommerce' ); } /** * Task completion. * * @return bool */ public function is_complete() { if ( $this->is_complete_result === null ) { $wc_connect_taxes_enabled = get_option( 'wc_connect_taxes_enabled' ); $is_wc_connect_taxes_enabled = ( $wc_connect_taxes_enabled === 'yes' ) || ( $wc_connect_taxes_enabled === true ); // seems that in some places boolean is used, and other places 'yes' | 'no' is used // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- We will replace this with a formal system by WC 9.6 so lets not advertise it yet. $third_party_complete = apply_filters( 'woocommerce_admin_third_party_tax_setup_complete', false ); /** * Ideally we would check against `wc_tax_enabled()` instead of `false !== get_option( 'woocommerce_no_sales_tax' )`, * however, tax is disabled by default making this task complete by default if we use it. If we change taxes * to be enabled by default in the future, this can be updated to check against `wc_tax_enabled()` which is * more accurate for this evaluation. */ $this->is_complete_result = $is_wc_connect_taxes_enabled || $third_party_complete || false !== get_option( 'woocommerce_no_sales_tax' ) || $this->has_existing_tax_rates(); } return $this->is_complete_result; } /** * Determines if a tax rate exists in the database. Result is indefinitely cached. * * @return bool */ private function has_existing_tax_rates() { global $wpdb; $has_existing_tax_rates = wp_cache_get( self::TAX_RATE_EXISTS_CACHE_KEY ); if ( false === $has_existing_tax_rates ) { $rate_exists = (bool) $wpdb->get_var( "SELECT 1 FROM {$wpdb->prefix}woocommerce_tax_rates limit 1" ); $has_existing_tax_rates = $rate_exists ? 'yes' : 'no'; wp_cache_set( self::TAX_RATE_EXISTS_CACHE_KEY, $has_existing_tax_rates ); } return 'yes' === $has_existing_tax_rates; } /** * Marks the task as actioned any time a tax rate has been added. Called from the `woocommerce_tax_rate_added` hook. * * @return void */ public function on_tax_rate_added() { $this->mark_actioned(); wp_cache_set( self::TAX_RATE_EXISTS_CACHE_KEY, 'yes' ); } /** * Clears the tax rate exists cache when a tax rate is deleted. Called from the `woocommerce_tax_rate_added` hook. * * @return void */ public function on_tax_rate_deleted() { wp_cache_delete( self::TAX_RATE_EXISTS_CACHE_KEY ); } /** * Additional data. * * @return array */ public function get_additional_data() { return array( 'avalara_activated' => PluginsHelper::is_plugin_active( 'woocommerce-avatax' ), 'tax_jar_activated' => class_exists( 'WC_Taxjar' ), 'stripe_tax_activated' => PluginsHelper::is_plugin_active( 'stripe-tax-for-woocommerce' ), 'woocommerce_tax_activated' => PluginsHelper::is_plugin_active( 'woocommerce-tax' ), 'woocommerce_shipping_activated' => PluginsHelper::is_plugin_active( 'woocommerce-shipping' ), 'woocommerce_tax_countries' => self::get_automated_support_countries(), 'stripe_tax_countries' => self::get_stripe_tax_support_countries(), ); } /** * Check if the store has any enabled gateways. * * @return bool */ public static function can_use_automated_taxes() { if ( ! class_exists( 'WC_Taxjar' ) ) { return false; } return in_array( WC()->countries->get_base_country(), self::get_automated_support_countries(), true ); } /** * Get an array of countries that support automated tax. * * @return array */ public static function get_automated_support_countries() { // https://developers.taxjar.com/api/reference/#countries . $tax_supported_countries = array_merge( array( 'US', 'CA', 'AU', 'GB' ), WC()->countries->get_european_union_countries() ); return $tax_supported_countries; } /** * Get an array of countries that support Stripe tax. * * @return array */ private static function get_stripe_tax_support_countries() { // https://docs.stripe.com/tax/supported-countries#supported-countries accurate as of 2024-08-26. // countries with remote sales not included. return array( 'AU', 'AT', 'BE', 'BG', 'CA', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HK', 'HU', 'IE', 'IT', 'JP', 'LV', 'LT', 'LU', 'MT', 'NL', 'NZ', 'NO', 'PL', 'PT', 'RO', 'SG', 'SK', 'SI', 'ES', 'SE', 'CH', 'AE', 'GB', 'US', ); } } Features/OnboardingTasks/TaskListSection.php 0000777 00000004466 15252240713 0015230 0 ustar 00 <?php /** * Handles storage and retrieval of a task list section */ namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks; /** * Task List section class. * * @deprecated 7.2.0 */ class TaskListSection { /** * Title. * * @var string */ public $id = ''; /** * Title. * * @var string */ public $title = ''; /** * Description. * * @var string */ public $description = ''; /** * Image. * * @var string */ public $image = ''; /** * Tasks. * * @var array */ public $task_names = array(); /** * Parent task list. * * @var TaskList */ protected $task_list; /** * Constructor * * @param array $data Task list data. * @param TaskList|null $task_list Parent task list. */ public function __construct( $data = array(), $task_list = null ) { $defaults = array( 'id' => '', 'title' => '', 'description' => '', 'image' => '', 'tasks' => array(), ); $data = wp_parse_args( $data, $defaults ); $this->task_list = $task_list; $this->id = $data['id']; $this->title = $data['title']; $this->description = $data['description']; $this->image = $data['image']; $this->task_names = $data['task_names']; } /** * Returns if section is complete. * * @return boolean; */ private function is_complete() { $complete = true; foreach ( $this->task_names as $task_name ) { if ( null !== $this->task_list && isset( $this->task_list->task_class_id_map[ $task_name ] ) ) { $task = $this->task_list->get_task( $this->task_list->task_class_id_map[ $task_name ] ); if ( $task->can_view() && ! $task->is_complete() ) { $complete = false; break; } } } return $complete; } /** * Get the list for use in JSON. * * @return array */ public function get_json() { return array( 'id' => $this->id, 'title' => $this->title, 'description' => $this->description, 'image' => $this->image, 'tasks' => array_map( function( $task_name ) { if ( null !== $this->task_list && isset( $this->task_list->task_class_id_map[ $task_name ] ) ) { return $this->task_list->task_class_id_map[ $task_name ]; } return ''; }, $this->task_names ), 'isComplete' => $this->is_complete(), ); } } Features/OnboardingTasks/Init.php 0000777 00000002116 15252240713 0013036 0 ustar 00 <?php /** * WooCommerce Onboarding Tasks */ namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\DeprecatedOptions; /** * Contains the logic for completing onboarding tasks. */ class Init { /** * Class instance. * * @var OnboardingTasks instance */ protected static $instance = null; /** * Get class instance. */ public static function get_instance() { if ( ! self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Constructor */ public function __construct() { DeprecatedOptions::init(); TaskLists::init(); } /** * Get task item data for settings filter. * * @return array */ public static function get_settings() { $settings = array(); $wc_pay_is_connected = false; if ( class_exists( '\WC_Payments' ) ) { $wc_payments_gateway = \WC_Payments::get_gateway(); $wc_pay_is_connected = method_exists( $wc_payments_gateway, 'is_connected' ) ? $wc_payments_gateway->is_connected() : false; } return $settings; } } Features/OnboardingTasks/DeprecatedOptions.php 0000777 00000005000 15252240713 0015542 0 ustar 00 <?php /** * Filters for maintaining backwards compatibility with deprecated options. */ namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\TaskList; use WC_Install; /** * DeprecatedOptions class. */ class DeprecatedOptions { /** * Initialize. */ public static function init() { add_filter( 'pre_option_woocommerce_task_list_hidden', array( __CLASS__, 'get_deprecated_options' ), 10, 2 ); add_filter( 'pre_option_woocommerce_extended_task_list_hidden', array( __CLASS__, 'get_deprecated_options' ), 10, 2 ); add_action( 'pre_update_option_woocommerce_task_list_hidden', array( __CLASS__, 'update_deprecated_options' ), 10, 3 ); add_action( 'pre_update_option_woocommerce_extended_task_list_hidden', array( __CLASS__, 'update_deprecated_options' ), 10, 3 ); } /** * Get the values from the correct source when attempting to retrieve deprecated options. * * @param string $pre_option Pre option value. * @param string $option Option name. * @return string */ public static function get_deprecated_options( $pre_option, $option ) { if ( defined( 'WC_INSTALLING' ) && WC_INSTALLING === true ) { return $pre_option; } $hidden = get_option( 'woocommerce_task_list_hidden_lists', array() ); switch ( $option ) { case 'woocommerce_task_list_hidden': return in_array( 'setup', $hidden, true ) ? 'yes' : 'no'; case 'woocommerce_extended_task_list_hidden': return in_array( 'extended', $hidden, true ) ? 'yes' : 'no'; } } /** * Updates the new option names when deprecated options are updated. * This is a temporary fallback until we can fully remove the old task list components. * * @param string $value New value. * @param string $old_value Old value. * @param string $option Option name. * @return string */ public static function update_deprecated_options( $value, $old_value, $option ) { switch ( $option ) { case 'woocommerce_task_list_hidden': $task_list = TaskLists::get_list( 'setup' ); if ( ! $task_list ) { return; } $update = 'yes' === $value ? $task_list->hide() : $task_list->unhide(); delete_option( 'woocommerce_task_list_hidden' ); return false; case 'woocommerce_extended_task_list_hidden': $task_list = TaskLists::get_list( 'extended' ); if ( ! $task_list ) { return; } $update = 'yes' === $value ? $task_list->hide() : $task_list->unhide(); delete_option( 'woocommerce_extended_task_list_hidden' ); return false; } } } Features/OnboardingTasks/TaskList.php 0000777 00000024005 15252240713 0013672 0 ustar 00 <?php /** * Handles storage and retrieval of a task list */ namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task; use Automattic\WooCommerce\Admin\WCAdminHelper; /** * Task List class. */ class TaskList { /** * Task traits. */ use TaskTraits; /** * Option name hidden task lists. */ const HIDDEN_OPTION = 'woocommerce_task_list_hidden_lists'; /** * Option name of completed task lists. */ const COMPLETED_OPTION = 'woocommerce_task_list_completed_lists'; /** * Option name of hidden reminder bar. */ const REMINDER_BAR_HIDDEN_OPTION = 'woocommerce_task_list_reminder_bar_hidden'; /** * ID. * * @var string */ public $id = ''; /** * ID. * * @var string */ public $hidden_id = ''; /** * ID. * * @var boolean */ public $display_progress_header = false; /** * Title. * * @var string */ public $title = ''; /** * Tasks. * * @var array */ public $tasks = array(); /** * Sort keys. * * @var array */ public $sort_by = array(); /** * Event prefix. * * @var string|null */ public $event_prefix = null; /** * Task list visibility. * * @var boolean */ public $visible = true; /** * Array of custom options. * * @var array */ public $options = array(); /** * Array of TaskListSection. * * @deprecated 7.2.0 * * @var array */ private $sections = array(); /** * Key value map of task class and id used for sections. * * @deprecated 7.2.0 * * @var array */ public $task_class_id_map = array(); /** * Constructor * * @param array $data Task list data. */ public function __construct( $data = array() ) { $defaults = array( 'id' => null, 'hidden_id' => null, 'title' => '', 'tasks' => array(), 'sort_by' => array(), 'event_prefix' => null, 'options' => array(), 'visible' => true, 'display_progress_header' => false, ); $data = wp_parse_args( $data, $defaults ); $this->id = $data['id']; $this->hidden_id = $data['hidden_id']; $this->title = $data['title']; $this->sort_by = $data['sort_by']; $this->event_prefix = $data['event_prefix']; $this->options = $data['options']; $this->visible = $data['visible']; $this->display_progress_header = $data['display_progress_header']; foreach ( $data['tasks'] as $task_name ) { $class = 'Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\\' . $task_name; $task = new $class( $this ); $this->add_task( $task ); } $this->possibly_remove_reminder_bar(); } /** * Check if the task list is hidden. * * @return bool */ public function is_hidden() { $hidden = get_option( self::HIDDEN_OPTION, array() ); return in_array( $this->hidden_id ? $this->hidden_id : $this->id, $hidden, true ); } /** * Check if the task list is visible. * * @return bool */ public function is_visible() { // If the task list is explicitly set to not be visible, return false. if ( ! $this->visible ) { return false; } // If the task list is hidden, return false. if ( $this->is_hidden() ) { return false; } // If the task list has no viewable tasks, return false. $no_viewable_tasks = count( $this->get_viewable_tasks() ) === 0; if ( $no_viewable_tasks ) { return false; } return true; } /** * Hide the task list. * * @return bool */ public function hide() { if ( $this->is_hidden() ) { return; } $viewable_tasks = $this->get_viewable_tasks(); $completed_count = array_reduce( $viewable_tasks, function ( $total, $task ) { return $task->is_complete() ? $total + 1 : $total; }, 0 ); $this->record_tracks_event( 'completed', array( 'action' => 'remove_card', 'completed_task_count' => $completed_count, 'incomplete_task_count' => count( $viewable_tasks ) - $completed_count, 'tasklist_id' => $this->id, ) ); $hidden = get_option( self::HIDDEN_OPTION, array() ); $hidden[] = $this->hidden_id ? $this->hidden_id : $this->id; $this->maybe_set_default_layout( $hidden ); return update_option( self::HIDDEN_OPTION, array_unique( $hidden ) ); } /** * Sets the default homepage layout to two_columns if "setup" tasklist is completed or hidden. * * @param array $completed_or_hidden_tasklist_ids Array of tasklist ids. */ public function maybe_set_default_layout( $completed_or_hidden_tasklist_ids ) { if ( in_array( 'setup', $completed_or_hidden_tasklist_ids, true ) ) { update_option( 'woocommerce_default_homepage_layout', 'two_columns' ); } } /** * Undo hiding of the task list. * * @return bool */ public function unhide() { $hidden = get_option( self::HIDDEN_OPTION, array() ); $hidden = array_diff( $hidden, array( $this->hidden_id ? $this->hidden_id : $this->id ) ); return update_option( self::HIDDEN_OPTION, $hidden ); } /** * Check if all viewable tasks are complete. * * @return bool */ public function is_complete() { foreach ( $this->get_viewable_tasks() as $viewable_task ) { if ( $viewable_task->is_complete() === false ) { return false; } } return true; } /** * Check if a task list has previously been marked as complete. * * @return bool */ public function has_previously_completed() { $complete = get_option( self::COMPLETED_OPTION, array() ); return in_array( $this->get_list_id(), $complete, true ); } /** * Add task to the task list. * * @param Task $task Task class. */ public function add_task( $task ) { if ( ! is_subclass_of( $task, 'Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task' ) ) { return new \WP_Error( 'woocommerce_task_list_invalid_task', __( 'Task is not a subclass of `Task`', 'woocommerce' ) ); } if ( array_search( $task, $this->tasks, true ) ) { return; } $this->tasks[] = $task; } /** * Get only visible tasks in list. * * @param string $task_id id of task. * @return Task */ public function get_task( $task_id ) { return current( array_filter( $this->tasks, function ( $task ) use ( $task_id ) { return $task->get_id() === $task_id; } ) ); } /** * Get only visible tasks in list. * * @return array */ public function get_viewable_tasks() { return array_values( array_filter( $this->tasks, function ( $task ) { return $task->can_view(); } ) ); } /** * Get task list sections. * * @deprecated 7.2.0 * * @return array */ public function get_sections() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.2.0' ); return $this->sections; } /** * Track list completion of viewable tasks. */ public function possibly_track_completion() { if ( $this->has_previously_completed() ) { return; } // If it's hidden, completion is tracked via hide method. if ( $this->is_hidden() ) { return; } // Expensive check, do it last. if ( ! $this->is_complete() ) { return; } $completed_lists = get_option( self::COMPLETED_OPTION, array() ); $completed_lists[] = $this->get_list_id(); update_option( self::COMPLETED_OPTION, $completed_lists, true ); $this->maybe_set_default_layout( $completed_lists ); $this->record_tracks_event( 'tasks_completed', array( 'tasklist_id' => $this->id, ) ); } /** * Sorts the attached tasks array. * * @param array $sort_by list of columns with sort order. * @return TaskList returns $this, for chaining. */ public function sort_tasks( $sort_by = array() ) { $sort_by = count( $sort_by ) > 0 ? $sort_by : $this->sort_by; if ( 0 !== count( $sort_by ) ) { usort( $this->tasks, function ( $a, $b ) use ( $sort_by ) { return Task::sort( $a, $b, $sort_by ); } ); } return $this; } /** * Prefix event for track event naming. * * @param string $event_name Event name. * @return string */ public function prefix_event( $event_name ) { if ( null !== $this->event_prefix ) { return $this->event_prefix . $event_name; } return $this->get_list_id() . '_tasklist_' . $event_name; } /** * Returns option to keep completed task list. * * @return string */ public function get_keep_completed_task_list() { return get_option( 'woocommerce_task_list_keep_completed', 'no' ); } /** * Remove reminder bar four weeks after store creation. */ public static function possibly_remove_reminder_bar() { $bar_hidden = get_option( self::REMINDER_BAR_HIDDEN_OPTION, 'no' ); $active_for_four_weeks = WCAdminHelper::is_wc_admin_active_for( WEEK_IN_SECONDS * 4 ); if ( 'yes' === $bar_hidden || ! $active_for_four_weeks ) { return; } update_option( self::REMINDER_BAR_HIDDEN_OPTION, 'yes' ); } /** * Get the list for use in JSON. * * @return array */ public function get_json() { $this->possibly_track_completion(); $tasks_json = array(); foreach ( $this->tasks as $task ) { // We have no use for hidden lists, it's expensive to compute individual tasks completion. // Exception: Secret tasklist is always hidden, or a task is always accessible. $list_is_visible = $this->is_visible() || 'secret_tasklist' === $this->id; if ( $list_is_visible || ( method_exists( $task, 'is_always_accessible' ) && $task->is_always_accessible() ) ) { $json = $task->get_json(); if ( $json['canView'] ) { $tasks_json[] = $json; } } } return array( 'id' => $this->get_list_id(), 'title' => $this->title, 'isHidden' => $this->is_hidden(), 'isVisible' => $this->is_visible(), 'isComplete' => $this->is_complete(), 'tasks' => $tasks_json, 'eventPrefix' => $this->prefix_event( '' ), 'displayProgressHeader' => $this->display_progress_header, 'keepCompletedTaskList' => $this->get_keep_completed_task_list(), ); } } Features/LaunchYourStore.php 0000777 00000026720 15252240713 0012160 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\Features; use Automattic\WooCommerce\Admin\PluginsHelper; use Automattic\WooCommerce\Admin\WCAdminHelper; use Automattic\WooCommerce\Internal\Admin\WCAdminUser; /** * Takes care of Launch Your Store related actions. */ class LaunchYourStore { const BANNER_DISMISS_USER_META_KEY = 'coming_soon_banner_dismissed'; /** * Constructor. */ public function __construct() { add_action( 'woocommerce_update_options_site-visibility', array( $this, 'save_site_visibility_options' ) ); add_filter( 'woocommerce_admin_shared_settings', array( $this, 'preload_settings' ) ); add_action( 'wp_footer', array( $this, 'maybe_add_coming_soon_banner_on_frontend' ) ); add_action( 'init', array( $this, 'register_launch_your_store_user_meta_fields' ) ); add_filter( 'woocommerce_tracks_event_properties', array( $this, 'append_coming_soon_global_tracks' ), 10, 2 ); add_action( 'wp_login', array( $this, 'reset_woocommerce_coming_soon_banner_dismissed' ), 10, 2 ); add_filter( 'woocommerce_admin_get_user_data_fields', array( $this, 'add_user_data_fields' ) ); if ( Features::is_enabled( 'coming-soon-newsletter-template' ) ) { add_action( 'admin_enqueue_scripts', array( $this, 'load_newsletter_scripts' ) ); add_action( 'save_post_wp_template', array( $this, 'maybe_track_template_change' ), 10, 3 ); } } /** * Save values submitted from WooCommerce -> Settings -> General. * * @return void */ public function save_site_visibility_options() { $nonce = isset( $_REQUEST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) ) : ''; // New Settings API uses wp_rest nonce. $nonce_string = Features::is_enabled( 'settings' ) ? 'wp_rest' : 'woocommerce-settings'; if ( empty( $nonce ) || ! wp_verify_nonce( $nonce, $nonce_string ) ) { return; } // options to allowed update and their allowed values. $options = array( 'woocommerce_coming_soon' => array( 'yes', 'no' ), 'woocommerce_store_pages_only' => array( 'yes', 'no' ), 'woocommerce_private_link' => array( 'yes', 'no' ), ); $event_data = array(); foreach ( $options as $name => $allowed_values ) { $current_value = get_option( $name, 'not set' ); $new_value = $current_value; if ( isset( $_POST[ $name ] ) ) { $input_value = sanitize_text_field( wp_unslash( $_POST[ $name ] ) ); // no-op if input value is invalid. if ( in_array( $input_value, $allowed_values, true ) ) { update_option( $name, $input_value ); $new_value = $input_value; // log the transition if there is one. if ( $current_value !== $new_value ) { $enabled_or_disabled = 'yes' === $new_value ? 'enabled' : 'disabled'; $event_data[ $name . '_toggled' ] = $enabled_or_disabled; } } } $event_data[ $name ] = $new_value; } wc_admin_record_tracks_event( 'site_visibility_saved', $event_data ); } /** * Append coming soon prop tracks globally. * * @param array $event_properties Event properties array. * * @return array */ public function append_coming_soon_global_tracks( $event_properties ) { if ( is_array( $event_properties ) ) { $coming_soon = 'no'; if ( 'yes' === get_option( 'woocommerce_coming_soon', 'no' ) ) { if ( 'yes' === get_option( 'woocommerce_store_pages_only', 'no' ) ) { $coming_soon = 'store'; } else { $coming_soon = 'site'; } } $event_properties['coming_soon'] = $coming_soon; } return $event_properties; } /** * Preload settings for Site Visibility. * * @param array $settings settings array. * * @return mixed */ public function preload_settings( $settings ) { if ( ! is_admin() ) { return $settings; } $current_screen = get_current_screen(); $is_setting_page = $current_screen && 'woocommerce_page_wc-settings' === $current_screen->id; // phpcs:disable WordPress.Security.NonceVerification.Recommended $is_woopayments_connect = isset( $_GET['path'] ) && isset( $_GET['page'] ) && ( '/payments/connect' === sanitize_text_field( wp_unslash( $_GET['path'] ) ) || '/payments/onboarding' === sanitize_text_field( wp_unslash( $_GET['path'] ) ) ) && 'wc-admin' === $_GET['page']; // phpcs:enable if ( $is_setting_page || $is_woopayments_connect ) { // Regnerate the share key if it's not set. add_option( 'woocommerce_share_key', wp_generate_password( 32, false ) ); $settings['siteVisibilitySettings'] = array( 'shop_permalink' => get_permalink( wc_get_page_id( 'shop' ) ), 'woocommerce_coming_soon' => get_option( 'woocommerce_coming_soon' ), 'woocommerce_store_pages_only' => get_option( 'woocommerce_store_pages_only' ), 'woocommerce_private_link' => get_option( 'woocommerce_private_link' ), 'woocommerce_share_key' => get_option( 'woocommerce_share_key' ), ); } return $settings; } /** * User must be an admin or editor. * * @return bool */ private function is_manager_or_admin() { // phpcs:ignore if ( ! current_user_can( 'shop_manager' ) && ! current_user_can( 'administrator' ) ) { return false; } return true; } /** * Add 'coming soon' banner on the frontend when the following conditions met. * * - User must be either an admin or store editor (must be logged in). * - 'woocommerce_coming_soon' option value must be 'yes' * - The page must not be the Coming soon page itself. */ public function maybe_add_coming_soon_banner_on_frontend() { // Do not show the banner if the site is being previewed. if ( isset( $_GET['site-preview'] ) ) { // @phpcs:ignore return false; } $current_user_id = get_current_user_id(); if ( ! $current_user_id ) { return false; } $has_dismissed_banner = WCAdminUser::get_user_data_field( $current_user_id, self::BANNER_DISMISS_USER_META_KEY ) // Remove this check in WC 9.4. || get_user_meta( $current_user_id, 'woocommerce_' . self::BANNER_DISMISS_USER_META_KEY, true ) === 'yes'; if ( $has_dismissed_banner ) { return false; } if ( ! $this->is_manager_or_admin() ) { return false; } // 'woocommerce_coming_soon' must be 'yes' if ( get_option( 'woocommerce_coming_soon', 'no' ) !== 'yes' ) { return false; } $store_pages_only = get_option( 'woocommerce_store_pages_only' ) === 'yes'; if ( $store_pages_only && ! WCAdminHelper::is_current_page_store_page() ) { return false; } $link = admin_url( 'admin.php?page=wc-settings&tab=site-visibility' ); $rest_url = rest_url( 'wp/v2/users/' . $current_user_id ); $rest_nonce = wp_create_nonce( 'wp_rest' ); $text = sprintf( // translators: no need to translate it. It's a link. __( " This page is in \"Coming soon\" mode and is only visible to you and those who have permission. To make it public to everyone, <a href='%s'>change visibility settings</a> ", 'woocommerce' ), $link ); // phpcs:ignore echo "<div id='coming-soon-footer-banner'><div class='coming-soon-footer-banner__content'>$text</div><a class='coming-soon-footer-banner-dismiss' data-rest-url='$rest_url' data-rest-nonce='$rest_nonce'></a></div>"; } /** * Register user meta fields for Launch Your Store. * * This should be removed in WC 9.4. */ public function register_launch_your_store_user_meta_fields() { if ( ! $this->is_manager_or_admin() ) { return; } register_meta( 'user', 'woocommerce_launch_your_store_tour_hidden', array( 'type' => 'string', 'description' => 'Indicate whether the user has dismissed the site visibility tour on the home screen.', 'single' => true, 'show_in_rest' => true, ) ); register_meta( 'user', 'woocommerce_coming_soon_banner_dismissed', array( 'type' => 'string', 'description' => 'Indicate whether the user has dismissed the coming soon notice or not.', 'single' => true, 'show_in_rest' => true, ) ); } /** * Register user meta fields for Launch Your Store. * * @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( 'launch_your_store_tour_hidden', self::BANNER_DISMISS_USER_META_KEY, ) ); } /** * Reset 'woocommerce_coming_soon_banner_dismissed' user meta to 'no'. * * Runs when a user logs-in successfully. * * @param string $user_login user login. * @param object $user user object. */ public function reset_woocommerce_coming_soon_banner_dismissed( $user_login, $user ) { $existing_meta = WCAdminUser::get_user_data_field( $user->ID, self::BANNER_DISMISS_USER_META_KEY ); if ( 'yes' === $existing_meta ) { WCAdminUser::update_user_data_field( $user->ID, self::BANNER_DISMISS_USER_META_KEY, 'no' ); } } /** * Check if the Mailpoet is connected. * * @return bool true if Mailpoet is fully connected, meaning the API key is valid and approved. */ private function is_mailpoet_connected() { if ( ! class_exists( '\MailPoet\DI\ContainerWrapper' ) || ! class_exists( '\MailPoet\Settings\SettingsController' ) ) { return false; } $container = \MailPoet\DI\ContainerWrapper::getInstance( WP_DEBUG ); // SettingController retrieves data from wp_mailpoet_settings table. $settings = $container->get( \MailPoet\Settings\SettingsController::class ); if ( false === $settings instanceof \MailPoet\Settings\SettingsController ) { return false; } $mta = $settings->get( 'mta' ); $api_state = $mta['mailpoet_api_key_state'] ?? null; if ( ! $api_state || ! isset( $api_state['state'], $api_state['code'] ) ) { return false; } return 'valid' === $api_state['state'] && 200 === $api_state['code']; } /** * Track when coming soon template is changed. * * @param int $post_id The post ID. * @param WP_Post $post The post object. * @param bool $update Whether the post is being updated. */ public function maybe_track_template_change( $post_id, $post, $update ) { if ( ! $post instanceof \WP_Post || ! isset( $post->post_name, $post->post_title ) ) { return; } // Check multiple fields to avoid false matches with non-WooCommerce templates. if ( 'coming-soon' === $post->post_name && 'Page: Coming soon' === $post->post_title ) { $matches = array(); $content = $post->post_content; preg_match( '/"comingSoonPatternId":"([^"]+)"/', $content, $matches ); if ( isset( $matches[1] ) ) { wc_admin_record_tracks_event( 'coming_soon_template_saved', array( 'pattern_id' => $matches[1], 'is_update' => $update, ) ); } } } /** * Load slotfill script and JS variables for the newsletter. * The comingSoonNewsletter is used in client/wp-admin-scripts/coming-soon-newsletter-panel * * @return void */ public function load_newsletter_scripts() { $screen = get_current_screen(); if ( ! $screen instanceof \WP_Screen ) { return; } if ( 'site-editor' !== $screen->id ) { return; } $mailpoet = array( 'mailpoet_installed' => PluginsHelper::is_plugin_installed( 'mailpoet' ), 'mailpoet_connected' => $this->is_mailpoet_connected(), ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion, WordPress.WP.EnqueuedResourceParameters.NotInFooter wp_register_script( 'coming-soon-newsletter-mailpoet', '' ); wp_enqueue_script( 'coming-soon-newsletter-mailpoet' ); wp_add_inline_script( 'coming-soon-newsletter-mailpoet', 'var comingSoonNewsletter = ' . wp_json_encode( $mailpoet, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) . ';' ); } } Features/ProductBlockEditor/Init.php 0000777 00000036101 15252240713 0013511 0 ustar 00 <?php /** * WooCommerce Product Block Editor */ declare(strict_types = 1); namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor; use Automattic\WooCommerce\Admin\Features\Features; use Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplate; use Automattic\WooCommerce\Admin\PageController; use Automattic\WooCommerce\Enums\ProductType; use Automattic\WooCommerce\LayoutTemplates\LayoutTemplateRegistry; use Automattic\WooCommerce\Internal\Features\ProductBlockEditor\ProductTemplates\SimpleProductTemplate; use Automattic\WooCommerce\Internal\Features\ProductBlockEditor\ProductTemplates\ProductVariationTemplate; use WC_Meta_Data; use WP_Block_Editor_Context; /** * Loads assets related to the product block editor. */ class Init { /** * The context name used to identify the editor. */ const EDITOR_CONTEXT_NAME = 'woocommerce/edit-product'; /** * Supported product types. * * @var array */ private $supported_product_types = array( ProductType::SIMPLE ); /** * Registered product templates. * * @var array */ private $product_templates = array(); /** * Redirection controller. * * @var RedirectionController */ private $redirection_controller; /** * Constructor */ public function __construct() { if ( ! is_admin() && ! WC()->is_rest_api_request() ) { return; } array_push( $this->supported_product_types, ProductType::VARIABLE ); array_push( $this->supported_product_types, ProductType::EXTERNAL ); array_push( $this->supported_product_types, ProductType::GROUPED ); $this->redirection_controller = new RedirectionController(); if ( \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'product_block_editor' ) ) { add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_styles' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'dequeue_conflicting_styles' ), 100 ); add_action( 'get_edit_post_link', array( $this, 'update_edit_product_link' ), 10, 2 ); add_filter( 'woocommerce_admin_get_user_data_fields', array( $this, 'add_user_data_fields' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) ); add_filter( 'woocommerce_register_post_type_product_variation', array( $this, 'enable_rest_api_for_product_variation' ) ); add_action( 'current_screen', array( $this, 'set_current_screen_to_block_editor_if_wc_admin' ) ); add_action( 'rest_api_init', array( $this, 'register_layout_templates' ) ); add_action( 'rest_api_init', array( $this, 'register_user_metas' ) ); add_filter( 'register_block_type_args', array( $this, 'register_metadata_attribute' ) ); add_filter( 'woocommerce_get_block_types', array( $this, 'get_block_types' ), 999, 1 ); add_filter( 'woocommerce_rest_prepare_product_object', array( $this, 'possibly_add_template_id' ), 10, 2 ); add_filter( 'woocommerce_rest_prepare_product_variation_object', array( $this, 'possibly_add_template_id' ), 10, 2 ); // Make sure the block registry is initialized so that core blocks are registered. BlockRegistry::get_instance(); $tracks = new Tracks(); $tracks->init(); $this->register_product_templates(); } } /** * Adds the product template ID to the product if it doesn't exist. * * @param WP_REST_Response $response The response object. * @param WC_Product $product The product. */ public function possibly_add_template_id( $response, $product ) { if ( ! $product ) { return $response; } if ( ! $product->meta_exists( '_product_template_id' ) ) { /** * Experimental: Allows to determine a product template id based on the product data. * * @ignore * @since 9.1.0 */ $product_template_id = apply_filters( 'experimental_woocommerce_product_editor_product_template_id_for_product', '', $product ); if ( $product_template_id ) { $response->data['meta_data'][] = new WC_Meta_Data( array( 'key' => '_product_template_id', 'value' => $product_template_id, ) ); } } return $response; } /** * Enqueue scripts needed for the product form block editor. */ public function enqueue_scripts() { if ( ! PageController::is_admin_or_embed_page() ) { return; } $editor_settings = $this->get_product_editor_settings(); $script_handle = 'wc-admin-edit-product'; wp_register_script( $script_handle, '', array( 'wp-blocks' ), '0.1.0', true ); wp_enqueue_script( $script_handle ); wp_add_inline_script( $script_handle, 'var productBlockEditorSettings = productBlockEditorSettings || ' . wp_json_encode( $editor_settings, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) . ';', 'before' ); wp_add_inline_script( $script_handle, sprintf( 'wp.blocks.setCategories( %s );', wp_json_encode( $editor_settings['blockCategories'], JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) ), 'before' ); wp_tinymce_inline_scripts(); wp_enqueue_media(); wp_register_style( 'wc-global-presets', false ); // phpcs:ignore wp_add_inline_style( 'wc-global-presets', wp_get_global_stylesheet( array( 'presets' ) ) ); wp_enqueue_style( 'wc-global-presets' ); } /** * Enqueue styles needed for the rich text editor. */ public function enqueue_styles() { if ( ! PageController::is_admin_page() ) { return; } wp_enqueue_style( 'wc-product-editor' ); wp_enqueue_style( 'wp-editor' ); wp_enqueue_style( 'wp-format-library' ); wp_enqueue_editor(); /** * Enqueue any block editor related assets. * * @since 7.1.0 */ do_action( 'enqueue_block_editor_assets' ); } /** * Dequeue conflicting styles. */ public function dequeue_conflicting_styles() { if ( ! PageController::is_admin_page() ) { return; } // Dequeuing this to avoid conflicts, until we remove the 'woocommerce-page' class. wp_dequeue_style( 'woocommerce-blocktheme' ); } /** * Update the edit product links when the new experience is enabled. * * @param string $link The edit link. * @param int $post_id Post ID. * @return string */ public function update_edit_product_link( $link, $post_id ) { $product = wc_get_product( $post_id ); if ( ! $product ) { return $link; } if ( $product->get_type() === ProductType::SIMPLE ) { return admin_url( 'admin.php?page=wc-admin&path=/product/' . $product->get_id() ); } return $link; } /** * Enables variation post type in REST API. * * @param array $args Array of post type arguments. * @return array Array of post type arguments. */ public function enable_rest_api_for_product_variation( $args ) { $args['show_in_rest'] = true; return $args; } /** * Adds fields so that we can store user preferences for the variations block. * * @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( 'variable_product_block_tour_shown', 'local_attributes_notice_dismissed_ids', 'variable_items_without_price_notice_dismissed', 'product_advice_card_dismissed', ) ); } /** * Sets the current screen to the block editor if a wc-admin page. */ public function set_current_screen_to_block_editor_if_wc_admin() { $screen = get_current_screen(); // phpcs:ignore Squiz.PHP.CommentedOutCode.Found // (no idea why I need that phpcs:ignore above, but I'm tired trying to re-write this comment to get it to pass) // we can't check the 'path' query param because client-side routing is used within wc-admin, // so this action handler is only called on the initial page load from the server, which might // not be the product edit page (it mostly likely isn't). if ( PageController::is_admin_page() ) { $screen->is_block_editor( true ); wp_add_inline_script( 'wp-blocks', 'wp.blocks && wp.blocks.unstable__bootstrapServerSideBlockDefinitions && wp.blocks.unstable__bootstrapServerSideBlockDefinitions(' . wp_json_encode( get_block_editor_server_block_settings(), JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) . ');' ); } } /** * Get the product editor settings. */ private function get_product_editor_settings() { $editor_settings['productTemplates'] = array_map( function ( $product_template ) { return $product_template->to_json(); }, $this->product_templates ); $block_editor_context = new WP_Block_Editor_Context( array( 'name' => self::EDITOR_CONTEXT_NAME ) ); return get_block_editor_settings( $editor_settings, $block_editor_context ); } /** * Get default product templates. * * @return array The default templates. */ private function get_default_product_templates() { $templates = array(); $templates[] = new ProductTemplate( array( 'id' => 'standard-product-template', 'title' => __( 'Standard product', 'woocommerce' ), 'description' => __( 'A single physical or virtual product, e.g. a t-shirt or an eBook.', 'woocommerce' ), 'order' => 10, 'icon' => 'shipping', 'layout_template_id' => 'simple-product', 'product_data' => array( 'type' => ProductType::SIMPLE, ), ) ); $templates[] = new ProductTemplate( array( 'id' => 'grouped-product-template', 'title' => __( 'Grouped product', 'woocommerce' ), 'description' => __( 'A set of products that go well together, e.g. camera kit.', 'woocommerce' ), 'order' => 20, 'icon' => 'group', 'layout_template_id' => 'simple-product', 'product_data' => array( 'type' => ProductType::GROUPED, ), ) ); $templates[] = new ProductTemplate( array( 'id' => 'affiliate-product-template', 'title' => __( 'Affiliate product', 'woocommerce' ), 'description' => __( 'A link to a product sold on a different website, e.g. brand collab.', 'woocommerce' ), 'order' => 30, 'icon' => 'link', 'layout_template_id' => 'simple-product', 'product_data' => array( 'type' => ProductType::EXTERNAL, ), ) ); return $templates; } /** * Create default product template by custom product type if it does not have a * template associated yet. * * @param array $templates The registered product templates. * @return array The new templates. */ private function create_default_product_template_by_custom_product_type( array $templates ) { // Getting the product types registered via the classic editor. $registered_product_types = wc_get_product_types(); $custom_product_types = array_filter( $registered_product_types, function ( $product_type ) { return ! in_array( $product_type, $this->supported_product_types, true ); }, ARRAY_FILTER_USE_KEY ); $templates_with_product_type = array_filter( $templates, function ( $template ) { $product_data = $template->get_product_data(); return ! is_null( $product_data ) && array_key_exists( 'type', $product_data ); } ); $custom_product_types_on_templates = array_map( function ( $template ) { $product_data = $template->get_product_data(); return $product_data['type']; }, $templates_with_product_type ); foreach ( $custom_product_types as $product_type => $title ) { if ( in_array( $product_type, $custom_product_types_on_templates, true ) ) { continue; } $templates[] = new ProductTemplate( array( 'id' => $product_type . '-product-template', 'title' => $title, 'product_data' => array( 'type' => $product_type, ), ) ); } return $templates; } /** * Register layout templates. */ public function register_layout_templates() { $layout_template_registry = wc_get_container()->get( LayoutTemplateRegistry::class ); if ( ! $layout_template_registry->is_registered( 'simple-product' ) ) { $layout_template_registry->register( 'simple-product', 'product-form', SimpleProductTemplate::class ); } if ( ! $layout_template_registry->is_registered( 'product-variation' ) ) { $layout_template_registry->register( 'product-variation', 'product-form', ProductVariationTemplate::class ); } } /** * Register product templates. */ public function register_product_templates() { /** * Allows for new product template registration. * * @since 8.5.0 */ $this->product_templates = apply_filters( 'woocommerce_product_editor_product_templates', $this->get_default_product_templates() ); $this->product_templates = $this->create_default_product_template_by_custom_product_type( $this->product_templates ); usort( $this->product_templates, function ( $a, $b ) { return $a->get_order() - $b->get_order(); } ); $this->redirection_controller->set_product_templates( $this->product_templates ); // PFT: Initialize the product form controller. if ( Features::is_enabled( 'product-editor-template-system' ) ) { $product_form_controller = new ProductFormsController(); $product_form_controller->init(); } } /** * Register user metas. */ public function register_user_metas() { register_rest_field( 'user', 'metaboxhidden_product', array( 'get_callback' => function ( $object, $attr ) { $hidden = get_user_meta( $object['id'], $attr, true ); if ( is_array( $hidden ) ) { // Ensures to always return a string array. return array_values( $hidden ); } return array( 'postcustom' ); }, 'update_callback' => function ( $value, $object, $attr ) { // Update the field/meta value. update_user_meta( $object->ID, $attr, $value ); }, 'schema' => array( 'type' => 'array', 'description' => __( 'The metaboxhidden_product meta from the user metas.', 'woocommerce' ), 'items' => array( 'type' => 'string', ), 'arg_options' => array( 'sanitize_callback' => 'wp_parse_list', 'validate_callback' => 'rest_validate_request_arg', ), ), ) ); } /** * Registers the metadata block attribute for all block types. * This is a fallback/temporary solution until * the Gutenberg core version registers the metadata attribute. * * @see https://github.com/WordPress/gutenberg/blob/6aaa3686ae67adc1a6a6b08096d3312859733e1b/lib/compat/wordpress-6.5/blocks.php#L27-L47 * To do: Remove this method once the Gutenberg core version registers the metadata attribute. * * @param array $args Array of arguments for registering a block type. * @return array $args */ public function register_metadata_attribute( $args ) { // Setup attributes if needed. if ( ! isset( $args['attributes'] ) || ! is_array( $args['attributes'] ) ) { $args['attributes'] = array(); } // Add metadata attribute if it doesn't exist. if ( ! array_key_exists( 'metadata', $args['attributes'] ) ) { $args['attributes']['metadata'] = array( 'type' => 'object', ); } return $args; } /** * Filters woocommerce block types. * * @param string[] $block_types Array of woocommerce block types. * @return array */ public function get_block_types( $block_types ) { if ( PageController::is_admin_page() ) { // Ignore all woocommerce blocks. return array(); } return $block_types; } } Features/ProductBlockEditor/BlockTemplateUtils.php 0000777 00000004751 15252240713 0016363 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor; use Automattic\WooCommerce\LayoutTemplates\LayoutTemplateRegistry; /** * Utils for block templates. */ class BlockTemplateUtils { /** * Directory which contains all templates * * @var string */ const TEMPLATES_ROOT_DIR = 'templates'; /** * Directory names. * * @var array */ const DIRECTORY_NAMES = array( 'TEMPLATES' => 'product-form', 'TEMPLATE_PARTS' => 'product-form/parts', ); /** * Gets the directory where templates of a specific template type can be found. * * @param string $template_type wp_template or wp_template_part. * @return string */ private static function get_templates_directory( $template_type = 'wp_template' ) { $root_path = dirname( __DIR__, 4 ) . '/' . self::TEMPLATES_ROOT_DIR . DIRECTORY_SEPARATOR; $templates_directory = $root_path . self::DIRECTORY_NAMES['TEMPLATES']; $template_parts_directory = $root_path . self::DIRECTORY_NAMES['TEMPLATE_PARTS']; if ( 'wp_template_part' === $template_type ) { return $template_parts_directory; } return $templates_directory; } /** * Return the path to a block template file. * Otherwise, False. * * @param string $slug - Template slug. * @return string|bool Path to the template file or false. */ public static function get_block_template_path( $slug ) { $directory = self::get_templates_directory(); $path = trailingslashit( $directory ) . $slug . '.php'; if ( ! file_exists( $path ) ) { return false; } return $path; } /** * Get the template data from the headers. * * @param string $file_path - File path. * @return array Template data. */ public static function get_template_file_data( $file_path ) { if ( ! file_exists( $file_path ) ) { return array(); } $file_data = get_file_data( $file_path, array( 'title' => 'Title', 'slug' => 'Slug', 'description' => 'Description', 'product_types' => 'Product Types', ), ); $file_data['product_types'] = explode( ',', trim( $file_data['product_types'] ) ); return $file_data; } /** * Get the template content from the file. * * @param string $file_path - File path. * @return string Content. */ public static function get_template_content( $file_path ) { if ( ! file_exists( $file_path ) ) { return ''; } ob_start(); include $file_path; $content = ob_get_contents(); ob_end_clean(); return $content; } } Features/ProductBlockEditor/ProductFormsController.php 0000777 00000007120 15252240713 0017300 0 ustar 00 <?php /** * WooCommerce Product Forms Controller */ namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor; /** * Handle retrieval of product forms. */ class ProductFormsController { /** * Product form templates. * * @var array */ private $product_form_templates = array( 'simple', ); /** * Set up the product forms controller. */ public function init() { // phpcs:ignore WooCommerce.Functions.InternalInjectionMethod.MissingFinal, WooCommerce.Functions.InternalInjectionMethod.MissingInternalTag -- Not an injection. add_action( 'upgrader_process_complete', array( $this, 'migrate_templates_when_plugin_updated' ), 10, 2 ); } /** * Migrate form templates after WooCommerce plugin update. * * @param \WP_Upgrader $upgrader The WP_Upgrader instance. * @param array $hook_extra Extra arguments passed to hooked filters. * @return void */ public function migrate_templates_when_plugin_updated( \WP_Upgrader $upgrader, array $hook_extra ) { // If it is not a plugin hook type, bail early. $type = isset( $hook_extra['type'] ) ? $hook_extra['type'] : ''; if ( 'plugin' !== $type ) { return; } // If it is not the WooCommerce plugin, bail early. $plugins = isset( $hook_extra['plugins'] ) ? $hook_extra['plugins'] : array(); if ( ! in_array( 'woocommerce/woocommerce.php', $plugins, true ) ) { return; } // If the action is not install or update, bail early. $action = isset( $hook_extra['action'] ) ? $hook_extra['action'] : ''; if ( 'install' !== $action && 'update' !== $action ) { return; } // Trigger the migration process. $this->migrate_product_form_posts( $action ); } /** * Create or update a product_form post for each product form template. * If the post already exists, it will be updated. * If the post does not exist, it will be created even if the action is `update`. * * @param string $action - The action to perform. `insert` | `update`. * @return void */ public function migrate_product_form_posts( $action ) { /** * Allow extend the list of templates that should be auto-generated. * * @since 9.1.0 * @param array $templates List of templates to auto-generate. */ $templates = apply_filters( 'woocommerce_product_form_templates', $this->product_form_templates ); foreach ( $templates as $slug ) { $file_path = BlockTemplateUtils::get_block_template_path( $slug ); if ( ! $file_path ) { continue; } $file_data = BlockTemplateUtils::get_template_file_data( $file_path ); $posts = get_posts( array( 'name' => $slug, 'post_type' => 'product_form', 'post_status' => 'any', 'posts_per_page' => 1, ) ); /* * Update the the CPT post if it already exists, * and the action is `update`. */ if ( 'update' === $action ) { $post = $posts[0] ?? null; if ( ! empty( $post ) ) { wp_update_post( array( 'ID' => $post->ID, 'post_title' => $file_data['title'], 'post_content' => BlockTemplateUtils::get_template_content( $file_path ), 'post_excerpt' => $file_data['description'], ) ); } } /* * Skip the post creation if the post already exists. */ if ( ! empty( $posts ) ) { continue; } $post = wp_insert_post( array( 'post_title' => $file_data['title'], 'post_name' => $slug, 'post_status' => 'publish', 'post_type' => 'product_form', 'post_content' => BlockTemplateUtils::get_template_content( $file_path ), 'post_excerpt' => $file_data['description'], ) ); } } } Features/ProductBlockEditor/BlockRegistry.php 0000777 00000022075 15252240713 0015376 0 ustar 00 <?php /** * WooCommerce Product Editor Block Registration */ namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor; use Automattic\WooCommerce\Internal\Admin\WCAdminAssets; use Automattic\WooCommerce\Blocks\Utils\Utils; /** * Product block registration and style registration functionality. */ class BlockRegistry { /** * Generic blocks directory. */ const GENERIC_BLOCKS_DIR = 'product-editor/blocks/generic'; /** * Product fields blocks directory. */ const PRODUCT_FIELDS_BLOCKS_DIR = 'product-editor/blocks/product-fields'; /** * Array of all available generic blocks. */ const GENERIC_BLOCKS = array( 'woocommerce/conditional', 'woocommerce/product-checkbox-field', 'woocommerce/product-collapsible', 'woocommerce/product-radio-field', 'woocommerce/product-pricing-field', 'woocommerce/product-section', 'woocommerce/product-section-description', 'woocommerce/product-subsection', 'woocommerce/product-subsection-description', 'woocommerce/product-details-section-description', 'woocommerce/product-tab', 'woocommerce/product-toggle-field', 'woocommerce/product-taxonomy-field', 'woocommerce/product-text-field', 'woocommerce/product-text-area-field', 'woocommerce/product-number-field', 'woocommerce/product-linked-list-field', 'woocommerce/product-select-field', 'woocommerce/product-notice-field', ); /** * Array of all available product fields blocks. */ const PRODUCT_FIELDS_BLOCKS = array( 'woocommerce/product-catalog-visibility-field', 'woocommerce/product-custom-fields', 'woocommerce/product-custom-fields-toggle-field', 'woocommerce/product-description-field', 'woocommerce/product-downloads-field', 'woocommerce/product-images-field', 'woocommerce/product-inventory-email-field', 'woocommerce/product-sku-field', 'woocommerce/product-name-field', 'woocommerce/product-regular-price-field', 'woocommerce/product-sale-price-field', 'woocommerce/product-schedule-sale-fields', 'woocommerce/product-shipping-class-field', 'woocommerce/product-shipping-dimensions-fields', 'woocommerce/product-summary-field', 'woocommerce/product-tag-field', 'woocommerce/product-inventory-quantity-field', 'woocommerce/product-variation-items-field', 'woocommerce/product-password-field', 'woocommerce/product-list-field', 'woocommerce/product-has-variations-notice', 'woocommerce/product-single-variation-notice', ); /** * Singleton instance. * * @var BlockRegistry */ private static $instance = null; /** * Get the singleton instance. */ public static function get_instance(): BlockRegistry { if ( ! self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Constructor */ protected function __construct() { add_filter( 'block_categories_all', array( $this, 'register_categories' ), 10, 2 ); $this->register_product_blocks(); } /** * Get a file path for a given block file. * * @param string $path File path. * @param string $dir File directory. */ private function get_file_path( $path, $dir ) { return WC_ABSPATH . WCAdminAssets::get_path( 'js' ) . trailingslashit( $dir ) . $path; } /** * Register all the product blocks. */ private function register_product_blocks() { foreach ( self::PRODUCT_FIELDS_BLOCKS as $block_name ) { $this->register_block( $block_name, self::PRODUCT_FIELDS_BLOCKS_DIR ); } foreach ( self::GENERIC_BLOCKS as $block_name ) { $this->register_block( $block_name, self::GENERIC_BLOCKS_DIR ); } } /** * Register product related block categories. * * @param array[] $block_categories Array of categories for block types. * @param WP_Block_Editor_Context $editor_context The current block editor context. */ public function register_categories( $block_categories, $editor_context ) { if ( INIT::EDITOR_CONTEXT_NAME === $editor_context->name ) { $block_categories[] = array( 'slug' => 'woocommerce', 'title' => __( 'WooCommerce', 'woocommerce' ), 'icon' => null, ); } return $block_categories; } /** * Get the block name without the "woocommerce/" prefix. * * @param string $block_name Block name. * * @return string */ private function remove_block_prefix( $block_name ) { if ( 0 === strpos( $block_name, 'woocommerce/' ) ) { return substr_replace( $block_name, '', 0, strlen( 'woocommerce/' ) ); } return $block_name; } /** * Augment the attributes of a block by adding attributes that are used by the product editor. * * @param array $attributes Block attributes. */ private function augment_attributes( $attributes ) { global $wp_version; // Note: If you modify this function, also update the client-side // registerWooBlockType function in @woocommerce/block-templates. $augmented_attributes = array_merge( $attributes, array( '_templateBlockId' => array( 'type' => 'string', 'role' => 'content', ), '_templateBlockOrder' => array( 'type' => 'integer', 'role' => 'content', ), '_templateBlockHideConditions' => array( 'type' => 'array', 'role' => 'content', ), '_templateBlockDisableConditions' => array( 'type' => 'array', 'role' => 'content', ), 'disabled' => isset( $attributes['disabled'] ) ? $attributes['disabled'] : array( 'type' => 'boolean', 'role' => 'content', ), ) ); if ( ! $this->has_role_support() ) { foreach ( $augmented_attributes as $key => $attribute ) { if ( isset( $attribute['role'] ) ) { $augmented_attributes[ $key ]['__experimentalRole'] = $attribute['role']; } } } return $augmented_attributes; } /** * Checks for block attribute role support. */ private function has_role_support() { if ( Utils::wp_version_compare( '6.7', '>=' ) ) { return true; } if ( is_plugin_active( 'gutenberg/gutenberg.php' ) ) { $gutenberg_version = ''; if ( defined( 'GUTENBERG_VERSION' ) ) { $gutenberg_version = GUTENBERG_VERSION; } if ( ! $gutenberg_version ) { $gutenberg_data = get_file_data( WP_PLUGIN_DIR . '/gutenberg/gutenberg.php', array( 'Version' => 'Version' ) ); $gutenberg_version = $gutenberg_data['Version']; } return version_compare( $gutenberg_version, '19.4', '>=' ); } return false; } /** * Augment the uses_context of a block by adding attributes that are used by the product editor. * * @param array $uses_context Block uses_context. */ private function augment_uses_context( $uses_context ) { // Note: If you modify this function, also update the client-side // registerProductEditorBlockType function in @woocommerce/product-editor. return array_merge( isset( $uses_context ) ? $uses_context : array(), array( 'postType', ) ); } /** * Register a single block. * * @param string $block_name Block name. * @param string $block_dir Block directory. * * @return WP_Block_Type|false The registered block type on success, or false on failure. */ private function register_block( $block_name, $block_dir ) { $block_name = $this->remove_block_prefix( $block_name ); $block_json_file = $this->get_file_path( $block_name . '/block.json', $block_dir ); return $this->register_block_type_from_metadata( $block_json_file ); } /** * Check if a block is registered. * * @param string $block_name Block name. */ public function is_registered( $block_name ): bool { $registry = \WP_Block_Type_Registry::get_instance(); return $registry->is_registered( $block_name ); } /** * Unregister a block. * * @param string $block_name Block name. */ public function unregister( $block_name ) { $registry = \WP_Block_Type_Registry::get_instance(); if ( $registry->is_registered( $block_name ) ) { $registry->unregister( $block_name ); } } /** * Register a block type from metadata stored in the block.json file. * * @param string $file_or_folder Path to the JSON file with metadata definition for the block or * path to the folder where the `block.json` file is located. * * @return \WP_Block_Type|false The registered block type on success, or false on failure. */ public function register_block_type_from_metadata( $file_or_folder ) { $metadata_file = ( ! str_ends_with( $file_or_folder, 'block.json' ) ) ? trailingslashit( $file_or_folder ) . 'block.json' : $file_or_folder; if ( ! file_exists( $metadata_file ) ) { return false; } // We are dealing with a local file, so we can use file_get_contents. // phpcs:disable WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $metadata = json_decode( file_get_contents( $metadata_file ), true ); if ( ! is_array( $metadata ) || ! $metadata['name'] ) { return false; } $this->unregister( $metadata['name'] ); return register_block_type_from_metadata( $metadata_file, array( 'attributes' => $this->augment_attributes( isset( $metadata['attributes'] ) ? $metadata['attributes'] : array() ), 'uses_context' => $this->augment_uses_context( isset( $metadata['usesContext'] ) ? $metadata['usesContext'] : array() ), ) ); } } Features/ProductBlockEditor/Tracks.php 0000777 00000002263 15252240713 0014037 0 ustar 00 <?php /** * WooCommerce Product Block Editor */ namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor; /** * Add tracks for the product block editor. */ class Tracks { /** * Initialize the tracks. */ public function init() { add_filter( 'woocommerce_product_source', array( $this, 'add_product_source' ) ); } /** * Check if a URL is a product editor page. * * @param string $url Url to check. * @return boolean */ protected function is_product_editor_page( $url ) { $query_string = wp_parse_url( wp_get_referer(), PHP_URL_QUERY ); parse_str( $query_string, $query ); if ( ! isset( $query['page'] ) || 'wc-admin' !== $query['page'] || ! isset( $query['path'] ) ) { return false; } $path_pieces = explode( '/', $query['path'] ); $route = $path_pieces[1]; return 'add-product' === $route || 'product' === $route; } /** * Update the product source if we're on the product editor page. * * @param string $source Source of product. * @return string */ public function add_product_source( $source ) { if ( $this->is_product_editor_page( wp_get_referer() ) ) { return 'product-block-editor-v1'; } return $source; } } Features/ProductBlockEditor/RedirectionController.php 0000777 00000012200 15252240713 0017113 0 ustar 00 <?php /** * WooCommerce Product Editor Redirection Controller */ namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor; use Automattic\WooCommerce\Admin\Features\Features; use Automattic\WooCommerce\Enums\ProductType; use Automattic\WooCommerce\Internal\Admin\WCAdminAssets; /** * Handle redirecting to the old or new editor based on features and support. */ class RedirectionController { /** * Registered product templates. * * @var array */ private $product_templates = array(); /** * Set up the hooks used for redirection. */ public function __construct() { if ( \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'product_block_editor' ) ) { add_action( 'current_screen', array( $this, 'maybe_redirect_to_new_editor' ), 30, 0 ); add_action( 'current_screen', array( $this, 'redirect_non_supported_product_types' ), 30, 0 ); } else { add_action( 'current_screen', array( $this, 'maybe_redirect_to_old_editor' ), 30, 0 ); } } /** * Check if the current screen is the legacy add product screen. */ protected function is_legacy_add_new_screen(): bool { $screen = get_current_screen(); return 'post' === $screen->base && 'product' === $screen->post_type && 'add' === $screen->action; } /** * Check if the current screen is the legacy edit product screen. */ protected function is_legacy_edit_screen(): bool { $screen = get_current_screen(); return 'post' === $screen->base && 'product' === $screen->post_type && isset( $_GET['post'] ) && isset( $_GET['action'] ) && 'edit' === $_GET['action']; } /** * Check if a product is supported by the new experience. * * @param integer $product_id Product ID. */ protected function is_product_supported( $product_id ): bool { $product = $product_id ? wc_get_product( $product_id ) : null; if ( is_null( $product ) ) { return false; } $digital_product = $product->is_downloadable() || $product->is_virtual(); $product_template_id = $product->get_meta( '_product_template_id' ); foreach ( $this->product_templates as $product_template ) { if ( is_null( $product_template->get_layout_template_id() ) ) { continue; } $product_data = $product_template->get_product_data(); $product_data_type = $product_data['type']; // Treat a variable product as a simple product since there is not a product template // for variable products. $product_type = $product->get_type() === ProductType::VARIABLE ? ProductType::SIMPLE : $product->get_type(); if ( isset( $product_data_type ) && $product_data_type !== $product_type ) { continue; } if ( isset( $product_template_id ) && $product_template_id === $product_template->get_id() ) { return true; } if ( isset( $product_data_type ) ) { return true; } } return false; } /** * Check if a product is supported by the new experience. * * @param array $product_templates The registered product templates. */ public function set_product_templates( array $product_templates ): void { $this->product_templates = $product_templates; } /** * Redirects from old product form to the new product form if the * feature `product_block_editor` is enabled. */ public function maybe_redirect_to_new_editor(): void { if ( $this->is_legacy_add_new_screen() ) { wp_safe_redirect( admin_url( 'admin.php?page=wc-admin&path=/add-product' ) ); exit(); } if ( $this->is_legacy_edit_screen() ) { $product_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : null; if ( ! $this->is_product_supported( $product_id ) ) { return; } wp_safe_redirect( admin_url( 'admin.php?page=wc-admin&path=/product/' . $product_id ) ); exit(); } } /** * Redirects from new product form to the old product form if the * feature `product_block_editor` is enabled. */ public function maybe_redirect_to_old_editor(): void { $route = $this->get_parsed_route(); if ( 'add-product' === $route['page'] ) { wp_safe_redirect( admin_url( 'post-new.php?post_type=product' ) ); exit(); } if ( 'product' === $route['page'] ) { wp_safe_redirect( admin_url( 'post.php?post=' . $route['product_id'] . '&action=edit' ) ); exit(); } } /** * Get the parsed WooCommerce Admin path. */ protected function get_parsed_route(): array { if ( ! \Automattic\WooCommerce\Admin\PageController::is_admin_page() || ! isset( $_GET['path'] ) ) { return array( 'page' => null, 'product_id' => null, ); } $path = esc_url_raw( wp_unslash( $_GET['path'] ) ); $path_pieces = explode( '/', wp_parse_url( $path, PHP_URL_PATH ) ); return array( 'page' => $path_pieces[1] ?? '', 'product_id' => 'product' === ( $path_pieces[1] ?? '' ) ? absint( $path_pieces[2] ?? 0 ) : null, ); } /** * Redirect non supported product types to legacy editor. */ public function redirect_non_supported_product_types(): void { $route = $this->get_parsed_route(); $product_id = $route['product_id']; if ( 'product' === $route['page'] && ! $this->is_product_supported( $product_id ) ) { wp_safe_redirect( admin_url( 'post.php?post=' . $route['product_id'] . '&action=edit' ) ); exit(); } } } Features/ProductBlockEditor/ProductTemplate.php 0000777 00000010353 15252240713 0015723 0 ustar 00 <?php /** * WooCommerce Product Block Editor */ namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor; /** * The Product Template that represents the relation between the Product and * the LayoutTemplate (ProductFormTemplateInterface) * * @see ProductFormTemplateInterface */ class ProductTemplate { /** * The template id. * * @var string */ private $id; /** * The template title. * * @var string */ private $title; /** * The product data. * * @var array */ private $product_data; /** * The template order. * * @var Integer */ private $order = 999; /** * The layout template id. * * @var string */ private $layout_template_id = null; /** * The template description. * * @var string */ private $description = null; /** * The template icon. * * @var string */ private $icon = null; /** * If the template is directly selectable through the UI. * * @var boolean */ private $is_selectable_by_user = true; /** * ProductTemplate constructor * * @param array $data The data. */ public function __construct( array $data ) { $this->id = $data['id']; $this->title = $data['title']; $this->product_data = $data['product_data']; if ( isset( $data['order'] ) ) { $this->order = $data['order']; } if ( isset( $data['layout_template_id'] ) ) { $this->layout_template_id = $data['layout_template_id']; } if ( isset( $data['description'] ) ) { $this->description = $data['description']; } if ( isset( $data['icon'] ) ) { $this->icon = $data['icon']; } if ( isset( $data['is_selectable_by_user'] ) ) { $this->is_selectable_by_user = $data['is_selectable_by_user']; } } /** * Get the template ID. * * @return string The ID. */ public function get_id() { return $this->id; } /** * Get the template title. * * @return string The title. */ public function get_title() { return $this->title; } /** * Get the layout template ID. * * @return string The layout template ID. */ public function get_layout_template_id() { return $this->layout_template_id; } /** * Set the layout template ID. * * @param string $layout_template_id The layout template ID. */ public function set_layout_template_id( string $layout_template_id ) { $this->layout_template_id = $layout_template_id; } /** * Get the product data. * * @return array The product data. */ public function get_product_data() { return $this->product_data; } /** * Get the template description. * * @return string The description. */ public function get_description() { return $this->description; } /** * Set the template description. * * @param string $description The template description. */ public function set_description( string $description ) { $this->description = $description; } /** * Get the template icon. * * @return string The icon. */ public function get_icon() { return $this->icon; } /** * Set the template icon. * * @see https://github.com/WordPress/gutenberg/tree/trunk/packages/icons. * * @param string $icon The icon name from the @wordpress/components or a url for an external image resource. */ public function set_icon( string $icon ) { $this->icon = $icon; } /** * Get the template order. * * @return int The order. */ public function get_order() { return $this->order; } /** * Get the selectable attribute. * * @return boolean Selectable. */ public function get_is_selectable_by_user() { return $this->is_selectable_by_user; } /** * Set the template order. * * @param int $order The template order. */ public function set_order( int $order ) { $this->order = $order; } /** * Get the product template as JSON like. * * @return array The JSON. */ public function to_json() { return array( 'id' => $this->get_id(), 'title' => $this->get_title(), 'description' => $this->get_description(), 'icon' => $this->get_icon(), 'order' => $this->get_order(), 'layoutTemplateId' => $this->get_layout_template_id(), 'productData' => $this->get_product_data(), 'isSelectableByUser' => $this->get_is_selectable_by_user(), ); } } Features/ProductBlockEditor/ProductTemplates/ProductFormTemplateInterface.php 0000777 00000002455 15252240713 0023673 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates; use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface; use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface; /** * Interface for block containers. */ interface ProductFormTemplateInterface extends BlockTemplateInterface { /** * Adds a new group block. * * @param array $block_config block config. * @return GroupInterface new group block. */ public function add_group( array $block_config ): GroupInterface; /** * Gets Group block by id. * * @param string $group_id group id. * @return GroupInterface|null */ public function get_group_by_id( string $group_id ): ?GroupInterface; /** * Gets Section block by id. * * @param string $section_id section id. * @return SectionInterface|null */ public function get_section_by_id( string $section_id ): ?SectionInterface; /** * Gets subsection block by id. * * @param string $subsection_id subsection id. * @return SubsectionInterface|null */ public function get_subsection_by_id( string $subsection_id ): ?SubsectionInterface; /** * Gets Block by id. * * @param string $block_id block id. * @return BlockInterface|null */ public function get_block_by_id( string $block_id ): ?BlockInterface; } Features/ProductBlockEditor/ProductTemplates/GroupInterface.php 0000777 00000001353 15252240713 0021023 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates; use Automattic\WooCommerce\Admin\BlockTemplates\BlockContainerInterface; use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface; /** * Interface for group containers, which contain sections and blocks. */ interface GroupInterface extends BlockContainerInterface { /** * Adds a new section to the group * * @param array $block_config block config. * @return SectionInterface new block section. */ public function add_section( array $block_config ): SectionInterface; /** * Adds a new block to the group. * * @param array $block_config block config. */ public function add_block( array $block_config ): BlockInterface; } Features/ProductBlockEditor/ProductTemplates/SectionInterface.php 0000777 00000001727 15252240713 0021340 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates; use Automattic\WooCommerce\Admin\BlockTemplates\BlockContainerInterface; use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface; /** * Interface for section containers, which contain sub-sections and blocks. */ interface SectionInterface extends BlockContainerInterface { /** * Adds a new sub-section to the section. * * @param array $block_config block config. * @return SubsectionInterface new block sub-section. */ public function add_subsection( array $block_config ): SubsectionInterface; /** * Adds a new block to the section. * * @param array $block_config block config. */ public function add_block( array $block_config ): BlockInterface; /** * Adds a new sub-section to the section. * * @deprecated 8.6.0 * * @param array $block_config The block data. */ public function add_section( array $block_config ): SubsectionInterface; } Features/ProductBlockEditor/ProductTemplates/SubsectionInterface.php 0000777 00000001047 15252240713 0022045 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates; use Automattic\WooCommerce\Admin\BlockTemplates\BlockContainerInterface; use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface; /** * Interface for subsection containers, which contain sub-sections and blocks. */ interface SubsectionInterface extends BlockContainerInterface { /** * Adds a new block to the sub-section. * * @param array $block_config block config. */ public function add_block( array $block_config ): BlockInterface; } Features/PaymentGatewaySuggestions/DefaultPaymentGateways.php 0000777 00000131230 15252240713 0020664 0 ustar 00 <?php /** * Gets a list of fallback methods if remote fetching is disabled. */ namespace Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions; defined( 'ABSPATH' ) || exit; use WC_Gateway_BACS; use WC_Gateway_COD; /** * Default Payment Gateways */ class DefaultPaymentGateways { /** * This is the default priority for countries that are not in the $recommendation_priority_map. * Priority is used to determine which payment gateway to recommend first. * The lower the number, the higher the priority. * * @var array */ private static $recommendation_priority = array( 'woocommerce_payments' => 1, 'woocommerce_payments:with-in-person-payments' => 1, 'woocommerce_payments:without-in-person-payments' => 1, 'stripe' => 2, 'woo-mercado-pago-custom' => 3, // PayPal Payments. 'ppcp-gateway' => 4, 'mollie_wc_gateway_banktransfer' => 5, 'razorpay' => 5, 'payfast' => 5, 'payubiz' => 6, 'square_credit_card' => 6, 'klarna_payments' => 6, // Klarna Checkout. 'kco' => 6, 'paystack' => 6, 'eway' => 7, 'amazon_payments_advanced' => 7, 'affirm' => 8, 'afterpay' => 9, 'zipmoney' => 10, 'payoneer-checkout' => 11, ); /** * Get default specs. * * @return array Default specs. */ public static function get_all() { $payment_gateways = array( array( 'id' => 'affirm', 'title' => __( 'Affirm', 'woocommerce' ), 'content' => __( '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' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/affirm.png', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/affirm.png', 'plugins' => array(), 'external_link' => 'https://woocommerce.com/products/woocommerce-gateway-affirm', 'is_visible' => array( self::get_rules_for_countries( array( 'US', 'CA', ) ), (object) array( 'type' => 'or', 'operands' => array( self::get_rules_for_wcpay_activated( false ), self::get_rules_for_wcpay_connected( false ), ), ), ), 'category_other' => array(), 'category_additional' => array( 'US', 'CA', ), ), array( 'id' => 'afterpay', 'title' => __( 'Afterpay', 'woocommerce' ), 'content' => __( 'Afterpay allows customers to receive products immediately and pay for purchases over four installments, always interest-free.', 'woocommerce' ), 'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/afterpay.png', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/afterpay.png', 'plugins' => array( 'afterpay-gateway-for-woocommerce' ), 'is_visible' => array( self::get_rules_for_countries( array( 'US', 'CA', 'AU', ) ), (object) array( 'type' => 'or', 'operands' => array( self::get_rules_for_wcpay_activated( false ), self::get_rules_for_wcpay_connected( false ), ), ), ), 'category_other' => array(), 'category_additional' => array( 'US', 'CA', 'AU', ), ), array( 'id' => 'airwallex_main', 'title' => __( 'Airwallex Payments', 'woocommerce' ), 'content' => __( 'Boost international sales and save on FX fees. Accept 60+ local payment methods including Apple Pay and Google Pay.', 'woocommerce' ), 'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/airwallex.png', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/airwallex.png', 'plugins' => array( 'airwallex-online-payments-gateway' ), 'is_visible' => array( self::get_rules_for_countries( array( 'GB', 'AT', 'BE', 'EE', 'FR', 'DE', 'GR', 'IE', 'IT', 'NL', 'PL', 'PT', 'AU', 'NZ', 'HK', 'SG', 'CN' ) ), ), 'category_other' => array( 'GB', 'AT', 'BE', 'EE', 'FR', 'DE', 'GR', 'IE', 'IT', 'NL', 'PL', 'PT', 'AU', 'NZ', 'HK', 'SG', 'CN' ), 'category_additional' => array(), ), array( 'id' => 'amazon_payments_advanced', 'title' => __( 'Amazon Pay', 'woocommerce' ), 'content' => __( 'Enable a familiar, fast checkout for hundreds of millions of active Amazon customers globally.', 'woocommerce' ), 'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/amazonpay.png', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/amazonpay.png', 'plugins' => array( 'woocommerce-gateway-amazon-payments-advanced' ), 'is_visible' => array( self::get_rules_for_countries( array( 'US', 'AT', 'BE', 'CY', 'DK', 'ES', 'FR', 'DE', 'GB', 'HU', 'IE', 'IT', 'LU', 'NL', 'PT', 'SL', 'SE', 'JP', ) ), ), 'category_other' => array(), 'category_additional' => array( 'US', 'AT', 'BE', 'CY', 'DK', 'ES', 'FR', 'DE', 'GB', 'HU', 'IE', 'IT', 'LU', 'NL', 'PT', 'SL', 'SE', 'JP', ), ), array( 'id' => WC_Gateway_BACS::ID, 'title' => __( 'Direct bank transfer', 'woocommerce' ), 'content' => __( 'Take payments via bank transfer.', 'woocommerce' ), 'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/bacs.svg', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/bacs.png', 'is_visible' => array( self::get_rules_for_cbd( false ), ), 'is_offline' => true, ), array( 'id' => WC_Gateway_COD::ID, 'title' => __( 'Cash on delivery', 'woocommerce' ), 'content' => __( 'Take payments in cash upon delivery.', 'woocommerce' ), 'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/cod.svg', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/cod.png', 'is_visible' => array( self::get_rules_for_cbd( false ), ), 'is_offline' => true, ), array( 'id' => 'eway', 'title' => __( 'Eway', 'woocommerce' ), 'content' => __( 'The Eway extension for WooCommerce allows you to take credit card payments directly on your store without redirecting your customers to a third party site to make payment.', 'woocommerce' ), 'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/eway.png', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/eway.png', 'plugins' => array( 'woocommerce-gateway-eway' ), 'is_visible' => false, 'category_other' => array(), 'category_additional' => array(), ), array( 'id' => 'kco', 'title' => __( 'Klarna Checkout', 'woocommerce' ), 'content' => __( 'Choose the payment that you want, pay now, pay later or slice it. No credit card numbers, no passwords, no worries.', 'woocommerce' ), 'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/klarna-black.png', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/klarna.png', 'plugins' => array( 'klarna-checkout-for-woocommerce' ), 'is_visible' => array( self::get_rules_for_countries( array( 'NO', 'SE', 'FI', ) ), self::get_rules_for_cbd( false ), ), 'category_other' => array( 'NO', 'SE', 'FI', ), 'category_additional' => array(), ), array( 'id' => 'klarna_payments', 'title' => __( 'Klarna Payments', 'woocommerce' ), 'content' => __( 'Choose the payment that you want, pay now, pay later or slice it. No credit card numbers, no passwords, no worries.', 'woocommerce' ), 'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/klarna-black.png', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/klarna.png', 'plugins' => array( 'klarna-payments-for-woocommerce' ), 'is_visible' => array( self::get_rules_for_countries( array( 'MX', 'US', 'CA', 'AT', 'BE', 'CH', 'DK', 'ES', 'FI', 'FR', 'DE', 'GB', 'IT', 'NL', 'NO', 'PL', 'SE', 'NZ', 'AU', ) ), self::get_rules_for_cbd( false ), (object) array( 'type' => 'or', 'operands' => array( (object) array( 'type' => 'not', 'operand' => array( self::get_rules_for_countries( self::get_wcpay_countries() ), ), ), self::get_rules_for_wcpay_activated( false ), self::get_rules_for_wcpay_connected( false ), ), ), ), 'category_other' => array(), 'category_additional' => array( 'MX', 'US', 'CA', 'AT', 'BE', 'CH', 'DK', 'ES', 'FI', 'FR', 'DE', 'GB', 'IT', 'NL', 'NO', 'PL', 'SE', 'NZ', 'AU', ), ), array( 'id' => 'mollie_wc_gateway_banktransfer', 'title' => __( 'Mollie', 'woocommerce' ), 'content' => __( 'Effortless payments by Mollie: Offer global and local payment methods, get onboarded in minutes, and supported in your language.', 'woocommerce' ), 'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/mollie.svg', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/mollie.png', 'plugins' => array( 'mollie-payments-for-woocommerce' ), 'is_visible' => array( self::get_rules_for_countries( array( 'AT', 'BE', 'CH', 'ES', 'FI', 'FR', 'DE', 'GB', 'IT', 'NL', 'PL', ) ), ), 'category_other' => array( 'AT', 'BE', 'CH', 'ES', 'FI', 'FR', 'DE', 'GB', 'IT', 'NL', 'PL', ), 'category_additional' => array(), ), array( 'id' => 'payfast', 'title' => __( 'Payfast', 'woocommerce' ), 'content' => __( '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' => WC_ADMIN_IMAGES_FOLDER_URL . '/payfast.png', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/payfast.png', 'plugins' => array( 'woocommerce-payfast-gateway' ), 'is_visible' => array( self::get_rules_for_countries( array( 'ZA' ) ), self::get_rules_for_cbd( false ), ), 'category_other' => array( 'ZA' ), 'category_additional' => array(), ), array( 'id' => 'payoneer-checkout', 'title' => __( 'Payoneer Checkout', 'woocommerce' ), 'content' => __( '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' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/payoneer.png', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/payoneer.png', 'plugins' => array( 'payoneer-checkout' ), 'is_visible' => array( self::get_rules_for_countries( array( 'HK', 'CN', ) ), ), 'category_other' => array(), 'category_additional' => array( 'HK', 'CN', ), ), array( 'id' => 'paystack', 'title' => __( 'Paystack', 'woocommerce' ), 'content' => __( 'Paystack helps African merchants accept one-time and recurring payments online with a modern, safe, and secure payment gateway.', 'woocommerce' ), 'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/paystack.png', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/paystack.png', 'plugins' => array( 'woo-paystack' ), 'is_visible' => array( self::get_rules_for_countries( array( 'ZA', 'GH', 'NG' ) ), self::get_rules_for_cbd( false ), ), 'category_other' => array( 'ZA', 'GH', 'NG' ), 'category_additional' => array(), ), array( 'id' => 'payubiz', 'title' => __( 'PayU for WooCommerce', 'woocommerce' ), 'content' => __( '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' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/payu.svg', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/payu.png', 'plugins' => array( 'payu-india' ), 'is_visible' => array( (object) array( 'type' => 'base_location_country', 'value' => 'IN', 'operation' => '=', ), self::get_rules_for_cbd( false ), ), 'category_other' => array( 'IN' ), 'category_additional' => array(), ), array( 'id' => 'ppcp-gateway', 'title' => __( 'PayPal Payments', 'woocommerce' ), 'content' => __( "Safe and secure payments using credit cards or your customer's PayPal account.", 'woocommerce' ), 'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/paypal.png', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/paypal.png', 'plugins' => array( 'woocommerce-paypal-payments' ), 'is_visible' => array( self::get_rules_for_countries( array( 'US', 'CA', 'MX', 'BR', 'AR', 'CL', 'CO', 'EC', 'PE', 'UY', 'VE', 'AT', 'BE', 'BG', 'HR', 'CH', 'CY', 'CZ', 'DK', 'EE', 'ES', 'FI', 'FR', 'DE', 'GB', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'NO', 'PL', 'PT', 'RO', 'SK', 'SL', 'SE', 'AU', 'NZ', 'HK', 'JP', 'SG', 'CN', 'ID', 'IN', ) ), self::get_rules_for_cbd( false ), ), 'category_other' => array( 'US', 'CA', 'MX', 'BR', 'AR', 'CL', 'CO', 'EC', 'PE', 'UY', 'VE', 'AT', 'BE', 'BG', 'HR', 'CH', 'CY', 'CZ', 'DK', 'EE', 'ES', 'FI', 'FR', 'DE', 'GB', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'NO', 'PL', 'PT', 'RO', 'SK', 'SL', 'SE', 'AU', 'NZ', 'HK', 'JP', 'SG', 'CN', 'ID', ), 'category_additional' => array( 'US', 'CA', 'ZA', 'NG', 'GH', 'EC', 'VE', 'AR', 'CL', 'CO', 'PE', 'UY', 'MX', 'BR', 'AT', 'BE', 'BG', 'HR', 'CH', 'CY', 'CZ', 'DK', 'EE', 'ES', 'FI', 'FR', 'DE', 'GB', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'NO', 'PL', 'PT', 'RO', 'SK', 'SL', 'SE', 'AU', 'NZ', 'HK', 'JP', 'SG', 'CN', 'ID', 'IN', ), ), array( 'id' => 'razorpay', 'title' => __( 'Razorpay', 'woocommerce' ), 'content' => __( 'The official Razorpay extension for WooCommerce allows you to accept credit cards, debit cards, netbanking, wallet, and UPI payments.', 'woocommerce' ), 'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/razorpay.svg', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/razorpay.png', 'plugins' => array( 'woo-razorpay' ), 'is_visible' => array( (object) array( 'type' => 'base_location_country', 'value' => 'IN', 'operation' => '=', ), self::get_rules_for_cbd( false ), ), 'category_other' => array( 'IN' ), 'category_additional' => array(), ), array( 'id' => 'square_credit_card', 'title' => __( 'Square', 'woocommerce' ), 'content' => __( 'Securely accept credit and debit cards with one low rate, no surprise fees (custom rates available). Sell online and in store and track sales and inventory in one place.', 'woocommerce' ), 'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/square-black.png', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/square.png', 'plugins' => array( 'woocommerce-square' ), 'is_visible' => array( (object) array( 'type' => 'or', 'operands' => (object) array( array( self::get_rules_for_countries( array( 'US' ) ), self::get_rules_for_cbd( true ), ), array( self::get_rules_for_countries( array( 'US', 'CA', 'IE', 'ES', 'FR', 'GB', 'AU', 'JP', ) ), (object) array( 'type' => 'or', 'operands' => (object) array( self::get_rules_for_selling_venues( array( 'brick-mortar', 'brick-mortar-other' ) ), self::get_rules_selling_offline(), ), ), ), ), ), ), 'category_other' => array( 'US', 'CA', 'IE', 'ES', 'FR', 'GB', 'AU', 'JP', ), 'category_additional' => array(), ), array( 'id' => 'stripe', 'title' => __( ' Stripe', 'woocommerce' ), 'content' => __( 'Accept debit and credit cards in 135+ currencies, methods such as Alipay, and one-touch checkout with Apple Pay.', 'woocommerce' ), 'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/stripe.png', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/stripe.png', 'plugins' => array( 'woocommerce-gateway-stripe' ), 'is_visible' => array( // https://stripe.com/global. self::get_rules_for_countries( array( 'US', 'CA', 'MX', 'BR', 'AT', 'BE', 'BG', 'CH', 'CY', 'CZ', 'DK', 'EE', 'ES', 'FI', 'FR', 'DE', 'GB', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'NO', 'PL', 'PT', 'RO', 'SK', 'SL', 'SE', 'AU', 'NZ', 'HK', 'JP', 'SG', 'ID', 'IN', ) ), self::get_rules_for_cbd( false ), ), 'category_other' => array( 'US', 'CA', 'MX', 'BR', 'AT', 'BE', 'BG', 'CH', 'CY', 'CZ', 'DK', 'EE', 'ES', 'FI', 'FR', 'DE', 'GB', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'NO', 'PL', 'PT', 'RO', 'SK', 'SL', 'SE', 'AU', 'NZ', 'HK', 'JP', 'SG', 'ID', 'IN', ), 'category_additional' => array(), ), array( 'id' => 'woo-mercado-pago-custom', 'title' => __( 'Mercado Pago', 'woocommerce' ), 'content' => __( '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' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/mercadopago.png', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/mercadopago.png', 'plugins' => array( 'woocommerce-mercadopago' ), 'is_visible' => array( self::get_rules_for_countries( array( 'AR', 'CL', 'CO', 'EC', 'PE', 'UY', 'MX', 'BR', ) ), ), 'is_local_partner' => true, 'category_other' => array( 'AR', 'CL', 'CO', 'EC', 'PE', 'UY', 'MX', 'BR', ), 'category_additional' => array(), ), // This is for backwards compatibility only (WC < 5.10.0-dev or WCA < 2.9.0-dev). array( 'id' => 'woocommerce_payments', 'title' => __( 'WooPayments', 'woocommerce' ), 'content' => __( 'Manage transactions without leaving your WordPress Dashboard. Only with WooPayments.', 'woocommerce' ), 'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/wcpay.svg', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/wcpay.svg', 'plugins' => array( 'woocommerce-payments' ), 'description' => __( 'With WooPayments, you can securely accept major cards, Apple Pay, and payments in over 100 currencies. Track cash flow and manage recurring revenue directly from your store’s dashboard - with no setup costs or monthly fees.', 'woocommerce' ), 'is_visible' => array( self::get_rules_for_cbd( false ), self::get_rules_for_countries( self::get_wcpay_countries() ), (object) array( 'type' => 'plugin_version', 'plugin' => 'woocommerce', 'version' => '5.10.0-dev', 'operator' => '<', ), (object) array( 'type' => 'or', 'operands' => (object) array( (object) array( 'type' => 'not', 'operand' => array( (object) array( 'type' => 'plugins_activated', 'plugins' => array( 'woocommerce-admin' ), ), ), ), (object) array( 'type' => 'plugin_version', 'plugin' => 'woocommerce-admin', 'version' => '2.9.0-dev', 'operator' => '<', ), ), ), ), ), array( 'id' => 'woocommerce_payments:without-in-person-payments', 'title' => __( 'WooPayments', 'woocommerce' ), 'content' => __( 'Manage transactions without leaving your WordPress Dashboard. Only with WooPayments.', 'woocommerce' ), 'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/wcpay.svg', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/wcpay.svg', 'plugins' => array( 'woocommerce-payments' ), 'description' => __( 'With WooPayments, you can securely accept major cards, Apple Pay, and payments in over 100 currencies. Track cash flow and manage recurring revenue directly from your store’s dashboard - with no setup costs or monthly fees.', 'woocommerce' ), 'is_visible' => array( self::get_rules_for_cbd( false ), self::get_rules_for_countries( array_diff( self::get_wcpay_countries(), array( 'US', 'CA' ) ) ), (object) array( 'type' => 'or', // Older versions of WooCommerce Admin require the ID to be `woocommerce-payments` to show the suggestion card. 'operands' => (object) array( (object) array( 'type' => 'plugin_version', 'plugin' => 'woocommerce-admin', 'version' => '2.9.0-dev', 'operator' => '>=', ), (object) array( 'type' => 'plugin_version', 'plugin' => 'woocommerce', 'version' => '5.10.0-dev', 'operator' => '>=', ), ), ), ), ), // This is the same as the above, but with a different description for countries that support in-person payments such as US and CA. array( 'id' => 'woocommerce_payments:with-in-person-payments', 'title' => __( 'WooPayments', 'woocommerce' ), 'content' => __( 'Manage transactions without leaving your WordPress Dashboard. Only with WooPayments.', 'woocommerce' ), 'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/wcpay.svg', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/wcpay.svg', 'plugins' => array( 'woocommerce-payments' ), 'description' => __( 'With WooPayments, you can securely accept major cards, Apple Pay, and payments in over 100 currencies – with no setup costs or monthly fees – and you can now accept in-person payments with the Woo mobile app.', 'woocommerce' ), 'is_visible' => array( self::get_rules_for_cbd( false ), self::get_rules_for_countries( array( 'US', 'CA' ) ), (object) array( 'type' => 'or', // Older versions of WooCommerce Admin require the ID to be `woocommerce-payments` to show the suggestion card. 'operands' => (object) array( (object) array( 'type' => 'plugin_version', 'plugin' => 'woocommerce-admin', 'version' => '2.9.0-dev', 'operator' => '>=', ), (object) array( 'type' => 'plugin_version', 'plugin' => 'woocommerce', 'version' => '5.10.0-dev', 'operator' => '>=', ), ), ), ), ), array( 'id' => 'woocommerce_payments:bnpl', 'title' => __( 'Activate BNPL instantly on WooPayments', 'woocommerce' ), 'content' => __( 'The world’s favorite buy now, pay later options and many more are right at your fingertips with WooPayments — all from one dashboard, without needing multiple extensions and logins.', 'woocommerce' ), 'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/wcpay-bnpl.svg', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/wcpay-bnpl.svg', 'plugins' => array( 'woocommerce-payments' ), 'is_visible' => array( self::get_rules_for_countries( array_intersect( array( 'US', 'CA', 'AU', 'AT', 'BE', 'CH', 'DK', 'ES', 'FI', 'FR', 'DE', 'GB', 'IT', 'NL', 'NO', 'PL', 'SE', 'NZ', ), self::get_wcpay_countries() ), ), self::get_rules_for_cbd( false ), self::get_rules_for_wcpay_activated( true ), self::get_rules_for_wcpay_connected( true ), ), ), array( 'id' => 'zipmoney', 'title' => __( 'Zip Co - Buy Now, Pay Later', 'woocommerce' ), 'content' => __( 'Give your customers the power to pay later, interest free and watch your sales grow.', 'woocommerce' ), 'image' => WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/zipco.png', 'image_72x72' => WC_ADMIN_IMAGES_FOLDER_URL . '/payment_methods/72x72/zipco.png', 'plugins' => array( 'zipmoney-payments-woocommerce' ), 'is_visible' => false, 'category_other' => array(), 'category_additional' => array(), ), ); $base_location = wc_get_base_location(); $country = $base_location['country']; foreach ( $payment_gateways as $index => $payment_gateway ) { $payment_gateways[ $index ]['recommendation_priority'] = self::get_recommendation_priority( $payment_gateway['id'], $country ); } return $payment_gateways; } /** * Get array of countries supported by WCPay depending on feature flag. * * @return array Array of countries. */ public static function get_wcpay_countries() { return array( 'US', 'PR', 'AU', 'CA', 'CY', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR', 'GB', 'GR', 'IE', 'IT', 'LU', 'LT', 'LV', 'NO', 'NZ', 'MT', 'AT', 'BE', 'NL', 'PL', 'PT', 'CH', 'HK', 'SI', 'SK', 'SG', 'BG', 'CZ', 'HR', 'HU', 'RO', 'SE', 'JP', 'AE' ); } /** * Get rules that match the store base location to one of the provided countries. * * @param array $countries Array of countries to match. * @return object Rules to match. */ public static function get_rules_for_countries( $countries ) { $rules = array(); foreach ( $countries as $country ) { $rules[] = (object) array( 'type' => 'base_location_country', 'value' => $country, 'operation' => '=', ); } return (object) array( 'type' => 'or', 'operands' => $rules, ); } /** * Get rules that match the store's selling venues. * * @param array $selling_venues Array of venues to match. * @return object Rules to match. */ public static function get_rules_for_selling_venues( $selling_venues ) { $rules = array(); foreach ( $selling_venues as $venue ) { $rules[] = (object) array( 'type' => 'option', 'transformers' => array( (object) array( 'use' => 'dot_notation', 'arguments' => (object) array( 'path' => 'selling_venues', ), ), ), 'option_name' => 'woocommerce_onboarding_profile', 'operation' => '=', 'value' => $venue, 'default' => array(), ); } return (object) array( 'type' => 'or', 'operands' => $rules, ); } /** * Get rules for when selling offline for core profiler. * * @return object Rules to match. */ public static function get_rules_selling_offline() { return (object) array( 'type' => 'option', 'transformers' => array( (object) array( 'use' => 'dot_notation', 'arguments' => (object) array( 'path' => 'selling_online_answer', ), ), ), 'option_name' => 'woocommerce_onboarding_profile', 'operation' => 'in', 'value' => array( 'no_im_selling_offline', 'im_selling_both_online_and_offline' ), 'default' => '', ); } /** * Get default rules for CBD based on given argument. * * @param bool $should_have Whether or not the store should have CBD as an industry (true) or not (false). * @return object Rules to match. */ public static function get_rules_for_cbd( $should_have ) { return (object) array( 'type' => 'option', 'transformers' => array( (object) array( 'use' => 'dot_notation', 'arguments' => (object) array( 'path' => 'industry', ), ), (object) array( 'use' => 'array_column', 'arguments' => (object) array( 'key' => 'slug', ), ), ), 'option_name' => 'woocommerce_onboarding_profile', 'operation' => $should_have ? 'contains' : '!contains', 'value' => 'cbd-other-hemp-derived-products', 'default' => array(), ); } /** * Get default rules for the WooPayments plugin being installed and activated. * * @param bool $should_be Whether WooPayments should be activated. * * @return object Rules to match. */ public static function get_rules_for_wcpay_activated( $should_be ) { $active_rule = (object) array( 'type' => 'plugins_activated', 'plugins' => array( 'woocommerce-payments' ), ); if ( $should_be ) { return $active_rule; } return (object) array( 'type' => 'not', 'operand' => array( $active_rule ), ); } /** * Get default rules for WooPayments being connected or not. * * This does not include the check for the WooPayments plugin to be active. * * @param bool $should_be Whether WooPayments should be connected. * * @return object Rules to match. */ public static function get_rules_for_wcpay_connected( $should_be ) { return (object) array( 'type' => 'option', 'transformers' => array( // Extract only the 'data' key from the option. (object) array( 'use' => 'dot_notation', 'arguments' => (object) array( 'path' => 'data', ), ), // Extract the keys from the data array. (object) array( 'use' => 'array_keys', ), ), 'option_name' => 'wcpay_account_data', // The rule will be look for the 'account_id' key in the account data array. 'operation' => $should_be ? 'contains' : '!contains', 'value' => 'account_id', 'default' => array(), ); } /** * Get recommendation priority for a given payment gateway by id and country. * If country is not supported, return null. * * @param string $gateway_id Payment gateway id. * @param string $country_code Store country code. * @return int|null Priority. Priority is 0-indexed, so 0 is the highest priority. */ private static function get_recommendation_priority( $gateway_id, $country_code ) { $recommendation_priority_map = array( 'US' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'square_credit_card', 'amazon_payments_advanced', 'affirm', 'afterpay', 'klarna_payments', ), 'CA' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'square_credit_card', 'affirm', 'afterpay', 'klarna_payments', ), 'AT' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'airwallex_main', 'mollie_wc_gateway_banktransfer', 'klarna_payments', 'amazon_payments_advanced', ), 'BE' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'airwallex_main', 'mollie_wc_gateway_banktransfer', 'klarna_payments', 'amazon_payments_advanced', ), 'BG' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', ), 'HR' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'ppcp-gateway', ), 'CH' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'mollie_wc_gateway_banktransfer', 'klarna_payments', ), 'CY' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'amazon_payments_advanced', ), 'CZ' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', ), 'DK' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'klarna_payments', 'amazon_payments_advanced', ), 'EE' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'airwallex_main', ), 'ES' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'mollie_wc_gateway_banktransfer', 'square_credit_card', 'klarna_payments', 'amazon_payments_advanced', ), 'FI' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'mollie_wc_gateway_banktransfer', 'kco', 'klarna_payments', ), 'FR' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'airwallex_main', 'mollie_wc_gateway_banktransfer', 'square_credit_card', 'klarna_payments', 'amazon_payments_advanced', ), 'DE' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'airwallex_main', 'mollie_wc_gateway_banktransfer', 'klarna_payments', 'amazon_payments_advanced', ), 'GB' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'airwallex_main', 'mollie_wc_gateway_banktransfer', 'square_credit_card', 'klarna_payments', 'amazon_payments_advanced', ), 'GR' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'airwallex_main', ), 'HU' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'amazon_payments_advanced', ), 'IE' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'airwallex_main', 'square_credit_card', 'amazon_payments_advanced', ), 'IT' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'airwallex_main', 'mollie_wc_gateway_banktransfer', 'klarna_payments', 'amazon_payments_advanced', ), 'LV' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', ), 'LT' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', ), 'LU' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'amazon_payments_advanced', ), 'MT' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', ), 'NL' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'airwallex_main', 'mollie_wc_gateway_banktransfer', 'klarna_payments', 'amazon_payments_advanced', ), 'NO' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'kco', 'klarna_payments', ), 'PL' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'airwallex_main', 'mollie_wc_gateway_banktransfer', 'klarna_payments', ), 'PT' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'airwallex_main', 'amazon_payments_advanced', ), 'RO' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', ), 'SK' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', ), 'SL' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'amazon_payments_advanced', ), 'SE' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'kco', 'klarna_payments', 'amazon_payments_advanced', ), 'MX' => array( 'stripe', 'woo-mercado-pago-custom', 'ppcp-gateway', 'klarna_payments', ), 'BR' => array( 'stripe', 'woo-mercado-pago-custom', 'ppcp-gateway' ), 'AR' => array( 'woo-mercado-pago-custom', 'ppcp-gateway' ), 'BO' => array(), 'CL' => array( 'woo-mercado-pago-custom', 'ppcp-gateway' ), 'CO' => array( 'woo-mercado-pago-custom', 'ppcp-gateway' ), 'EC' => array( 'woo-mercado-pago-custom', 'ppcp-gateway' ), 'FK' => array(), 'GF' => array(), 'GY' => array(), 'PY' => array(), 'PE' => array( 'woo-mercado-pago-custom', 'ppcp-gateway' ), 'SR' => array(), 'UY' => array( 'woo-mercado-pago-custom', 'ppcp-gateway' ), 'VE' => array( 'ppcp-gateway' ), 'AU' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'airwallex_main', 'ppcp-gateway', 'square_credit_card', 'afterpay', 'klarna_payments', ), 'NZ' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'airwallex_main', 'ppcp-gateway', 'klarna_payments', ), 'HK' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'airwallex_main', 'ppcp-gateway', 'payoneer-checkout', ), 'JP' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'ppcp-gateway', 'square_credit_card', 'amazon_payments_advanced', ), 'SG' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', 'stripe', 'airwallex_main', 'ppcp-gateway', ), 'CN' => array( 'airwallex_main', 'ppcp-gateway', 'payoneer-checkout' ), 'FJ' => array(), 'GU' => array(), 'ID' => array( 'stripe', 'ppcp-gateway' ), 'IN' => array( 'stripe', 'razorpay', 'payubiz', 'ppcp-gateway' ), 'ZA' => array( 'payfast', 'paystack' ), 'NG' => array( 'paystack' ), 'GH' => array( 'paystack' ), 'AE' => array( 'woocommerce_payments:with-in-person-payments', 'woocommerce_payments:without-in-person-payments', 'woocommerce_payments', ), ); // If the country code is not in the list, return default priority. if ( ! isset( $recommendation_priority_map[ $country_code ] ) ) { return self::get_default_recommendation_priority( $gateway_id ); } $index = array_search( $gateway_id, $recommendation_priority_map[ $country_code ], true ); // If the gateway is not in the list, return the last index + 1. if ( false === $index ) { return count( $recommendation_priority_map[ $country_code ] ); } return $index; } /** * Get the default recommendation priority for a payment gateway. * This is used when a country is not in the $recommendation_priority_map array. * * @param string $id Payment gateway id. * @return int Priority. */ private static function get_default_recommendation_priority( $id ) { if ( ! $id || ! array_key_exists( $id, self::$recommendation_priority ) ) { return null; } return self::$recommendation_priority[ $id ]; } } Features/PaymentGatewaySuggestions/PaymentGatewaysController.php 0000777 00000010671 15252240713 0021430 0 ustar 00 <?php /** * Logic for extending WC_REST_Payment_Gateways_Controller. */ namespace Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions; use Automattic\WooCommerce\Admin\Features\TransientNotices; defined( 'ABSPATH' ) || exit; /** * PaymentGateway class */ class PaymentGatewaysController { /** * Initialize payment gateway changes. */ public static function init() { add_filter( 'woocommerce_rest_prepare_payment_gateway', array( __CLASS__, 'extend_response' ), 10, 3 ); add_filter( 'admin_init', array( __CLASS__, 'possibly_do_connection_return_action' ) ); add_action( 'woocommerce_admin_payment_gateway_connection_return', array( __CLASS__, 'handle_successfull_connection' ) ); } /** * Add necessary fields to REST API response. * * @param WP_REST_Response $response Response data. * @param WC_Payment_Gateway $gateway Payment gateway object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public static function extend_response( $response, $gateway, $request ) { $data = $response->get_data(); $data['needs_setup'] = $gateway->needs_setup(); $data['post_install_scripts'] = self::get_post_install_scripts( $gateway ); $data['settings_url'] = method_exists( $gateway, 'get_settings_url' ) ? $gateway->get_settings_url() : admin_url( 'admin.php?page=wc-settings&tab=checkout§ion=' . strtolower( $gateway->id ) ); $return_url = wc_admin_url( '&task=payments&connection-return=' . strtolower( $gateway->id ) . '&_wpnonce=' . wp_create_nonce( 'connection-return' ) ); $data['connection_url'] = method_exists( $gateway, 'get_connection_url' ) ? $gateway->get_connection_url( $return_url ) : null; $data['setup_help_text'] = method_exists( $gateway, 'get_setup_help_text' ) ? $gateway->get_setup_help_text() : null; $data['required_settings_keys'] = method_exists( $gateway, 'get_required_settings_keys' ) ? $gateway->get_required_settings_keys() : array(); $response->set_data( $data ); return $response; } /** * Get payment gateway scripts for post-install. * * @param WC_Payment_Gateway $gateway Payment gateway object. * @return array Install scripts. */ public static function get_post_install_scripts( $gateway ) { $scripts = array(); $wp_scripts = wp_scripts(); $handles = method_exists( $gateway, 'get_post_install_script_handles' ) ? $gateway->get_post_install_script_handles() : array(); foreach ( $handles as $handle ) { if ( isset( $wp_scripts->registered[ $handle ] ) ) { $scripts[] = $wp_scripts->registered[ $handle ]; } } return $scripts; } /** * Call an action after a gating has been successfully returned. */ public static function possibly_do_connection_return_action() { if ( ! isset( $_GET['page'] ) || 'wc-admin' !== $_GET['page'] || ! isset( $_GET['task'] ) || 'payments' !== $_GET['task'] || ! isset( $_GET['connection-return'] ) || ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( wc_clean( wp_unslash( $_GET['_wpnonce'] ) ), 'connection-return' ) ) { return; } $gateway_id = sanitize_text_field( wp_unslash( $_GET['connection-return'] ) ); do_action( 'woocommerce_admin_payment_gateway_connection_return', $gateway_id ); } /** * Handle a successful gateway connection. * * @param string $gateway_id Gateway ID. */ public static function handle_successfull_connection( $gateway_id ) { // phpcs:disable WordPress.Security.NonceVerification if ( ! isset( $_GET['success'] ) || 1 !== intval( $_GET['success'] ) ) { return; } // phpcs:enable WordPress.Security.NonceVerification $payment_gateways = WC()->payment_gateways()->payment_gateways(); $payment_gateway = isset( $payment_gateways[ $gateway_id ] ) ? $payment_gateways[ $gateway_id ] : null; if ( ! $payment_gateway ) { return; } $payment_gateway->update_option( 'enabled', 'yes' ); TransientNotices::add( array( 'user_id' => get_current_user_id(), 'id' => 'payment-gateway-connection-return-' . str_replace( ',', '-', $gateway_id ), 'status' => 'success', 'content' => sprintf( /* translators: the title of the payment gateway */ __( '%s connected successfully', 'woocommerce' ), $payment_gateway->method_title ), ) ); wc_admin_record_tracks_event( 'tasklist_payment_connect_method', array( 'payment_method' => $gateway_id, ) ); wp_safe_redirect( wc_admin_url() ); } } Features/PaymentGatewaySuggestions/Init.php 0000777 00000010177 15252240713 0015146 0 ustar 00 <?php /** * Handles running payment gateway suggestion specs */ namespace Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\RemoteSpecs\RemoteSpecsEngine; /** * Remote Payment Methods engine. * This goes through the specs and gets eligible payment gateways. */ class Init extends RemoteSpecsEngine { /** * Option name for dismissed payment method suggestions. */ const RECOMMENDED_PAYMENT_PLUGINS_DISMISS_OPTION = 'woocommerce_setting_payments_recommendations_hidden'; /** * Constructor. */ public function __construct() { PaymentGatewaysController::init(); add_action( 'update_option_woocommerce_default_country', array( $this, 'delete_specs_transient' ) ); } /** * Go through the specs and run them. * * @param array|null $specs payment suggestion spec array. * @return array */ public static function get_suggestions( ?array $specs = null ) { $locale = get_user_locale(); $specs = is_array( $specs ) ? $specs : self::get_specs(); $results = EvaluateSuggestion::evaluate_specs( $specs ); $specs_to_return = $results['suggestions']; $specs_to_save = null; if ( empty( $specs_to_return ) ) { // When suggestions is empty, replace it with defaults and save for 3 hours. $specs_to_save = DefaultPaymentGateways::get_all(); $specs_to_return = EvaluateSuggestion::evaluate_specs( $specs_to_save )['suggestions']; } elseif ( count( $results['errors'] ) > 0 ) { // When suggestions is not empty but has errors, save it for 3 hours. $specs_to_save = $specs; } if ( count( $results['errors'] ) > 0 ) { self::log_errors( $results['errors'] ); } if ( $specs_to_save ) { PaymentGatewaySuggestionsDataSourcePoller::get_instance()->set_specs_transient( array( $locale => $specs_to_save ), 3 * HOUR_IN_SECONDS ); } return $specs_to_return; } /** * Gets either cached or default suggestions. * * @return array */ public static function get_cached_or_default_suggestions() { $specs = 'no' === get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) ? DefaultPaymentGateways::get_all() : PaymentGatewaySuggestionsDataSourcePoller::get_instance()->get_cached_specs(); if ( ! is_array( $specs ) || 0 === count( $specs ) ) { $specs = DefaultPaymentGateways::get_all(); } /** * Allows filtering of payment gateway suggestion specs * * @since 6.4.0 * * @param array Gateway specs. */ $specs = apply_filters( 'woocommerce_admin_payment_gateway_suggestion_specs', $specs ); $results = EvaluateSuggestion::evaluate_specs( $specs ); return $results['suggestions']; } /** * Delete the specs transient. */ public static function delete_specs_transient() { PaymentGatewaySuggestionsDataSourcePoller::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 apply_filters( 'woocommerce_admin_payment_gateway_suggestion_specs', DefaultPaymentGateways::get_all() ); } $specs = PaymentGatewaySuggestionsDataSourcePoller::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 apply_filters( 'woocommerce_admin_payment_gateway_suggestion_specs', DefaultPaymentGateways::get_all() ); } return apply_filters( 'woocommerce_admin_payment_gateway_suggestion_specs', $specs ); } /** * Check if suggestions should be shown in the settings screen. * * @return bool */ public static function should_display() { if ( 'yes' === get_option( self::RECOMMENDED_PAYMENT_PLUGINS_DISMISS_OPTION, 'no' ) ) { return false; } if ( 'no' === get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) ) { return false; } return apply_filters( 'woocommerce_allow_payment_recommendations', true ); } /** * Dismiss the suggestions. */ public static function dismiss() { return update_option( self::RECOMMENDED_PAYMENT_PLUGINS_DISMISS_OPTION, 'yes' ); } } Features/PaymentGatewaySuggestions/PaymentGatewaySuggestionsDataSourcePoller.php 0000777 00000002716 15252240713 0024566 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions; use Automattic\WooCommerce\Admin\RemoteSpecs\DataSourcePoller; use WC_Helper; /** * Specs data source poller class for payment gateway suggestions. */ class PaymentGatewaySuggestionsDataSourcePoller extends DataSourcePoller { /** * Data Source Poller ID. */ const ID = 'payment_gateway_suggestions'; /** * Default data sources array. * * @deprecated since 9.5.0. Use get_data_sources() instead. */ const DATA_SOURCES = array(); /** * Class instance. * * @var PaymentGatewaySuggestionsDataSourcePoller 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 with dynamic base URL. * * @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/suggestions.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; } } Features/PaymentGatewaySuggestions/EvaluateSuggestion.php 0000777 00000006526 15252240713 0020064 0 ustar 00 <?php /** * Evaluates the spec and returns a status. */ namespace Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\RuleEvaluator; /** * Evaluates the spec and returns the evaluated suggestion. */ class EvaluateSuggestion { /** * Stores memoized results of evaluate_specs. * * @var array */ protected static $memo = array(); /** * Evaluates the spec and returns the suggestion. * * @param object|array $spec The suggestion to evaluate. * @param array $logger_args Optional. Arguments for the rule evaluator logger. * * @return object The evaluated suggestion. */ public static function evaluate( $spec, $logger_args = array() ) { $rule_evaluator = new RuleEvaluator(); $suggestion = is_array( $spec ) ? (object) $spec : clone $spec; if ( isset( $suggestion->is_visible ) ) { // Determine the suggestion's logger slug. $logger_slug = ! empty( $suggestion->id ) ? $suggestion->id : ''; // If the suggestion has no ID, use the title to generate a slug. if ( empty( $logger_slug ) ) { $logger_slug = ! empty( $suggestion->title ) ? sanitize_title_with_dashes( trim( $suggestion->title ) ) : 'anonymous-suggestion'; } // Evaluate the visibility of the suggestion. $is_visible = $rule_evaluator->evaluate( $suggestion->is_visible, null, array( 'slug' => $logger_slug, 'source' => $logger_args['source'] ?? 'wc-payment-gateway-suggestions', ) ); $suggestion->is_visible = $is_visible; } return $suggestion; } /** * Evaluates the specs and returns the visible suggestions. * * @param array $specs payment suggestion spec array. * @param array $logger_args Optional. Arguments for the rule evaluator logger. * * @return array The visible suggestions and errors. */ public static function evaluate_specs( $specs, $logger_args = array() ) { $specs_key = self::get_memo_key( $specs ); if ( isset( self::$memo[ $specs_key ] ) ) { return self::$memo[ $specs_key ]; } $suggestions = array(); $errors = array(); foreach ( $specs as $spec ) { try { $suggestion = self::evaluate( $spec, $logger_args ); if ( ! property_exists( $suggestion, 'is_visible' ) || $suggestion->is_visible ) { $suggestions[] = $suggestion; } } catch ( \Throwable $e ) { $errors[] = $e; } } $result = array( 'suggestions' => $suggestions, 'errors' => $errors, ); // Memoize results, with a fail safe to prevent unbounded memory growth. // This limit is unlikely to be reached under normal circumstances. if ( count( self::$memo ) > 50 ) { self::reset_memo(); } self::$memo[ $specs_key ] = $result; return $result; } /** * Resets the memoized results. Useful for testing. */ public static function reset_memo() { self::$memo = array(); } /** * Returns a memoization key for the given specs. * * @param array $specs The specs to generate a key for. * * @return string The memoization key. */ private static function get_memo_key( $specs ) { $data = wp_json_encode( $specs ); if ( function_exists( 'hash' ) && in_array( 'xxh3', hash_algos(), true ) ) { // Use xxHash (xxh3) if available. return hash( 'xxh3', $data ); } // Fall back to CRC32. return (string) crc32( $data ); } } Features/TransientNotices.php 0000777 00000005450 15252240713 0012343 0 ustar 00 <?php /** * WooCommerce Transient Notices */ namespace Automattic\WooCommerce\Admin\Features; use Automattic\WooCommerce\Internal\Admin\Loader; /** * Shows print shipping label banner on edit order page. */ class TransientNotices { /** * Option name for the queue. */ const QUEUE_OPTION = 'woocommerce_admin_transient_notices_queue'; /** * Constructor */ public function __construct() { add_filter( 'woocommerce_admin_preload_options', array( $this, 'preload_options' ) ); } /** * Get all notices in the queue. * * @return array */ public static function get_queue() { return get_option( self::QUEUE_OPTION, array() ); } /** * Get all notices in the queue by a given user ID. * * @param int $user_id User ID. * @return array */ public static function get_queue_by_user( $user_id ) { $notices = self::get_queue(); return array_filter( $notices, function( $notice ) use ( $user_id ) { return ! isset( $notice['user_id'] ) || null === $notice['user_id'] || $user_id === $notice['user_id']; } ); } /** * Get a notice by ID. * * @param array $notice_id Notice of ID to get. * @return array|null */ public static function get( $notice_id ) { $queue = self::get_queue(); if ( isset( $queue[ $notice_id ] ) ) { return $queue[ $notice_id ]; } return null; } /** * Add a notice to be shown. * * @param array $notice Notice. * $notice = array( * 'id' => (string) Unique ID for the notice. Required. * 'user_id' => (int|null) User ID to show the notice to. * 'status' => (string) info|error|success * 'content' => (string) Content to be shown for the notice. Required. * 'options' => (array) Array of options to be passed to the notice component. * See https://developer.wordpress.org/block-editor/reference-guides/data/data-core-notices/#createNotice for available options. * ). */ public static function add( $notice ) { $queue = self::get_queue(); $defaults = array( 'user_id' => null, 'status' => 'info', 'options' => array(), ); $notice_data = array_merge( $defaults, $notice ); $notice_data['options'] = (object) $notice_data['options']; $queue[ $notice['id'] ] = $notice_data; update_option( self::QUEUE_OPTION, $queue ); } /** * Remove a notice by ID. * * @param array $notice_id Notice of ID to remove. */ public static function remove( $notice_id ) { $queue = self::get_queue(); unset( $queue[ $notice_id ] ); update_option( self::QUEUE_OPTION, $queue ); } /** * Preload options to prime state of the application. * * @param array $options Array of options to preload. * @return array */ public function preload_options( $options ) { $options[] = self::QUEUE_OPTION; return $options; } } WCAdminHelper.php 0000777 00000015350 15252240713 0007713 0 ustar 00 <?php /** * WCAdminHelper * * Helper class for generic WCAdmin functions. */ namespace Automattic\WooCommerce\Admin; defined( 'ABSPATH' ) || exit; /** * Class WCAdminHelper */ class WCAdminHelper { /** * WC Admin timestamp option name. */ const WC_ADMIN_TIMESTAMP_OPTION = 'woocommerce_admin_install_timestamp'; const WC_ADMIN_STORE_AGE_RANGES = array( 'week-1' => array( 'start' => 0, 'end' => WEEK_IN_SECONDS, ), 'week-1-4' => array( 'start' => WEEK_IN_SECONDS, 'end' => WEEK_IN_SECONDS * 4, ), 'month-1-3' => array( 'start' => MONTH_IN_SECONDS, 'end' => MONTH_IN_SECONDS * 3, ), 'month-3-6' => array( 'start' => MONTH_IN_SECONDS * 3, 'end' => MONTH_IN_SECONDS * 6, ), 'month-6+' => array( 'start' => MONTH_IN_SECONDS * 6, ), ); /** * Get the number of seconds that the store has been active. * * @return number Number of seconds. */ public static function get_wcadmin_active_for_in_seconds() { $install_timestamp = get_option( self::WC_ADMIN_TIMESTAMP_OPTION ); if ( ! is_numeric( $install_timestamp ) ) { $install_timestamp = time(); update_option( self::WC_ADMIN_TIMESTAMP_OPTION, $install_timestamp ); } return time() - $install_timestamp; } /** * Test how long WooCommerce Admin has been active. * * @param int $seconds Time in seconds to check. * @return bool Whether or not WooCommerce admin has been active for $seconds. */ public static function is_wc_admin_active_for( $seconds ) { $wc_admin_active_for = self::get_wcadmin_active_for_in_seconds(); return ( $wc_admin_active_for >= $seconds ); } /** * Test if WooCommerce Admin has been active within a pre-defined range. * * @param string $range range available in WC_ADMIN_STORE_AGE_RANGES. * @param int $custom_start custom start in range. * @throws \InvalidArgumentException Throws exception when invalid $range is passed in. * @return bool Whether or not WooCommerce admin has been active within the range. */ public static function is_wc_admin_active_in_date_range( $range, $custom_start = null ) { if ( ! array_key_exists( $range, self::WC_ADMIN_STORE_AGE_RANGES ) ) { throw new \InvalidArgumentException( sprintf( '"%s" range is not supported, use one of: %s', $range, implode( ', ', array_keys( self::WC_ADMIN_STORE_AGE_RANGES ) ) ) ); } $wc_admin_active_for = self::get_wcadmin_active_for_in_seconds(); $range_data = self::WC_ADMIN_STORE_AGE_RANGES[ $range ]; $start = null !== $custom_start ? $custom_start : $range_data['start']; if ( $range_data && $wc_admin_active_for >= $start ) { return isset( $range_data['end'] ) ? $wc_admin_active_for < $range_data['end'] : true; } return false; } /** * Test if the site is fresh. A fresh site must meet the following requirements. * * - The current user was registered less than 1 month ago. * - fresh_site option must be 1 * * @return bool */ public static function is_site_fresh() { $fresh_site = get_option( 'fresh_site' ); if ( '1' !== $fresh_site ) { return false; } $current_userdata = get_userdata( get_current_user_id() ); // Return false if we can't get user meta data for some reason. if ( ! $current_userdata ) { return false; } $date = new \DateTime( $current_userdata->user_registered ); $month_ago = new \DateTime( '-1 month' ); return $date > $month_ago; } /** * Check if the current page is a store page. * * This should only be called when WP has has set up the query, typically during or after the parse_query or template_redirect action hooks. * * @return bool */ public static function is_current_page_store_page() { // WC store pages. $store_pages = array( 'shop' => wc_get_page_id( 'shop' ), 'cart' => wc_get_page_id( 'cart' ), 'checkout' => wc_get_page_id( 'checkout' ), 'terms' => wc_terms_and_conditions_page_id(), 'coming_soon' => wc_get_page_id( 'coming_soon' ), ); /** * Filter the store pages array to check if a URL is a store page. * * @since 8.8.0 * @param array $store_pages The store pages array. The keys are the page slugs and the values are the page IDs. */ $store_pages = apply_filters( 'woocommerce_store_pages', $store_pages ); foreach ( $store_pages as $page_slug => $page_id ) { if ( $page_id > 0 && is_page( $page_id ) ) { return true; } } // Product archive page. if ( is_post_type_archive( 'product' ) ) { return true; } // Product page. if ( is_singular( 'product' ) ) { return true; } // Product taxonomy page (e.g. Product Category, Product Tag, etc.). if ( is_product_taxonomy() ) { return true; } global $wp; $url = self::get_url_from_wp( $wp ); /** * Filter if a URL is a store page. * * @since 9.3.0 * @param bool $is_store_page Whether or not the URL is a store page. * @param string $url URL to check. */ $is_store_page = apply_filters( 'woocommerce_is_extension_store_page', false, $url ); return filter_var( $is_store_page, FILTER_VALIDATE_BOOL ); } /** * Test if a URL is a store page. * * @param string $url URL to check. If not provided, the current URL will be used. * @return bool Whether or not the URL is a store page. * @deprecated 9.8.0 Use is_current_page_store_page instead. */ public static function is_store_page( $url = '' ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found _deprecated_function( __METHOD__, '9.8.0', 'is_current_page_store_page' ); return self::is_current_page_store_page(); } /** * Get normalized URL path. * 1. Only keep the path and query string (if any). * 2. Remove wp home path from the URL path if WP is installed in a subdirectory. * 3. Remove leading and trailing slashes. * * For example: * * - https://example.com/wordpress/shop/uncategorized/test/?add-to-cart=123 => shop/uncategorized/test/?add-to-cart=123 * * @param string $url URL to normalize. */ private static function get_normalized_url_path( $url ) { $query = wp_parse_url( $url, PHP_URL_QUERY ); $path = wp_parse_url( $url, PHP_URL_PATH ) . ( $query ? '?' . $query : '' ); $home_path = wp_parse_url( site_url(), PHP_URL_PATH ) ?? ''; $normalized_path = trim( substr( $path, strlen( $home_path ) ), '/' ); return $normalized_path; } /** * Builds the relative URL from the WP instance. * * @internal * @link https://wordpress.stackexchange.com/a/274572 * @param \WP $wp WordPress environment instance. */ private static function get_url_from_wp( \WP $wp ) { // Initialize query vars if they haven't been set. if ( empty( $wp->query_vars ) || empty( $wp->request ) ) { $wp->parse_request(); } return home_url( add_query_arg( $wp->query_vars, $wp->request ) ); } } PluginsProvider/PluginsProviderInterface.php 0000777 00000001650 15252240713 0015400 0 ustar 00 <?php /** * Interface for a provider for getting access to plugin queries, * designed to be mockable for unit tests. */ namespace Automattic\WooCommerce\Admin\PluginsProvider; defined( 'ABSPATH' ) || exit; /** * Plugins Provider Interface */ interface PluginsProviderInterface { /** * Get an array of active plugin slugs. * * @return array */ public function get_active_plugin_slugs(); /** * Get plugin data. * * @param string $plugin Path to the plugin file relative to the plugins directory or the plugin directory name. * * @return array|false */ public function get_plugin_data( $plugin ); /** * Get the path to the plugin file relative to the plugins directory from the plugin slug. * * E.g. 'woocommerce' returns 'woocommerce/woocommerce.php' * * @param string $slug Plugin slug to get path for. * * @return string|false */ public function get_plugin_path_from_slug( $slug ); } PluginsProvider/PluginsProvider.php 0000777 00000003510 15252240713 0013554 0 ustar 00 <?php /** * A provider for getting access to plugin queries. */ namespace Automattic\WooCommerce\Admin\PluginsProvider; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\PluginsProvider\PluginsProviderInterface; use Automattic\WooCommerce\Admin\PluginsHelper; /** * Plugins Provider. * * Uses the live PluginsHelper. */ class PluginsProvider implements PluginsProviderInterface { /** * The deactivated plugin slug. * * @var string */ private static $deactivated_plugin_slug = ''; /** * Get an array of active plugin slugs. * * @return array */ public function get_active_plugin_slugs() { return array_filter( PluginsHelper::get_active_plugin_slugs(), function( $p ) { return $p !== self::$deactivated_plugin_slug; } ); } /** * Set the deactivated plugin. This is needed because the deactivated_plugin * hook happens before the option is updated which means that getting the * active plugins includes the deactivated plugin. * * @param string $plugin_path The path to the plugin being deactivated. */ public static function set_deactivated_plugin( $plugin_path ) { self::$deactivated_plugin_slug = explode( '/', $plugin_path )[0]; } /** * Get plugin data. * * @param string $plugin Path to the plugin file relative to the plugins directory or the plugin directory name. * * @return array|false */ public function get_plugin_data( $plugin ) { return PluginsHelper::get_plugin_data( $plugin ); } /** * Get the path to the plugin file relative to the plugins directory from the plugin slug. * * E.g. 'woocommerce' returns 'woocommerce/woocommerce.php' * * @param string $slug Plugin slug to get path for. * * @return string|false */ public function get_plugin_path_from_slug( $slug ) { return PluginsHelper::get_plugin_path_from_slug( $slug ); } } PluginsHelper.php 0000777 00000112223 15252240713 0010047 0 ustar 00 <?php /** * PluginsHelper * * Helper class for the site's plugins. */ namespace Automattic\WooCommerce\Admin; use ActionScheduler; use ActionScheduler_DBStore; use ActionScheduler_QueueRunner; use Automatic_Upgrader_Skin; use Automattic\WooCommerce\Admin\PluginsInstallLoggers\AsyncPluginsInstallLogger; use Automattic\WooCommerce\Admin\PluginsInstallLoggers\PluginsInstallLogger; use Automattic\WooCommerce\Internal\Admin\WCAdminAssets; use Automattic\WooCommerce\Utilities\PluginUtil; use Plugin_Upgrader; use WC_Helper; use WC_Helper_Updater; use WP_Error; use WP_Upgrader; defined( 'ABSPATH' ) || exit; if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } /** * Class PluginsHelper */ class PluginsHelper { /** * Subscription notices in Woo screens are shown in clear priority order, first * expired, and if those don't exist, expiring, and finally if none of those exist, * then missing. This keeps track of whether we can show the next set of notices. * * @var bool */ public static $subscription_usage_notices_already_shown = false; /** * The URL for the WooCommerce subscription page. */ const WOO_SUBSCRIPTION_PAGE_URL = 'https://woocommerce.com/my-account/my-subscriptions/'; /** * The URL for the WooCommerce.com cart page. */ const WOO_CART_PAGE_URL = 'https://woocommerce.com/cart/'; /** * The URL for the WooCommerce.com add payment method page. */ const WOO_ADD_PAYMENT_METHOD_URL = 'https://woocommerce.com/my-account/add-payment-method/'; /** * Meta key for dismissing expired subscription notices. */ const DISMISS_EXPIRED_SUBS_NOTICE = 'woo_subscription_expired_notice_dismiss'; /** * Meta key for dismissing expiring subscription notices */ const DISMISS_EXPIRING_SUBS_NOTICE = 'woo_subscription_expiring_notice_dismiss'; /** * Meta key for dismissing missing subscription notices */ const DISMISS_MISSING_SUBS_NOTICE = 'woo_subscription_missing_notice_dismiss'; /** * Meta key for dismissing disconnected notice */ const DISMISS_DISCONNECT_NOTICE = 'woo_disconnect_notice_dismiss'; /** * Meta key for dismissing connected notice */ const DISMISS_CONNECT_NOTICE = 'woo_connect_notice_dismiss'; /** * Initialize hooks. */ public static function init() { add_action( 'woocommerce_plugins_install_callback', array( __CLASS__, 'install_plugins' ), 10, 2 ); add_action( 'woocommerce_plugins_install_and_activate_async_callback', array( __CLASS__, 'install_and_activate_plugins_async_callback' ), 10, 3 ); add_action( 'woocommerce_plugins_activate_callback', array( __CLASS__, 'activate_plugins' ), 10, 2 ); add_action( 'admin_notices', array( __CLASS__, 'maybe_show_connect_notice_in_plugin_list' ) ); add_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_enqueue_scripts_for_connect_notice' ) ); add_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_enqueue_scripts_for_notices_in_plugins' ) ); } /** * Get the path to the plugin file relative to the plugins directory from the plugin slug. * * E.g. 'woocommerce' returns 'woocommerce/woocommerce.php' * * @param string $slug Plugin slug to get path for. * * @return string|false The plugin path or false if the plugin is not installed. */ public static function get_plugin_path_from_slug( $slug ) { $plugins = get_plugins(); if ( strstr( $slug, '/' ) ) { // The slug is already a plugin path. return $slug; } foreach ( $plugins as $plugin_path => $data ) { $path_parts = explode( '/', $plugin_path ); if ( $path_parts[0] === $slug ) { return $plugin_path; } } return false; } /** * Get an array of installed plugin slugs. * * @return array */ public static function get_installed_plugin_slugs() { return array_map( function ( $plugin_path ) { $path_parts = explode( '/', $plugin_path ); return $path_parts[0]; }, array_keys( get_plugins() ) ); } /** * Get an array of installed plugins with their file paths as a key value pair. * * @return array */ public static function get_installed_plugins_paths() { $plugins = get_plugins(); $installed_plugins = array(); foreach ( $plugins as $path => $plugin ) { $path_parts = explode( '/', $path ); $slug = $path_parts[0]; $installed_plugins[ $slug ] = $path; } return $installed_plugins; } /** * Get an array of active plugin slugs. * * The list will include both network active and site active plugins. * * @return array The list of active plugin slugs. */ public static function get_active_plugin_slugs() { return array_unique( array_map( function ( $absolute_path ) { // Make the path relative to the plugins directory. $plugin_path = str_replace( WP_PLUGIN_DIR . '/', '', $absolute_path ); // Split the path to get the plugin slug (aka the directory name). $path_parts = explode( '/', $plugin_path ); return $path_parts[0]; }, // Use this method as it is the most bulletproof way to get the active plugins. wc_get_container()->get( PluginUtil::class )->get_all_active_valid_plugins() ) ); } /** * Checks if a plugin is installed. * * @param string $plugin Path to the plugin file relative to the plugins directory or the plugin directory name. * * @return bool */ public static function is_plugin_installed( $plugin ) { $plugin_path = self::get_plugin_path_from_slug( $plugin ); return $plugin_path ? array_key_exists( $plugin_path, get_plugins() ) : false; } /** * Checks if a plugin is active. * * @param string $plugin Path to the plugin file relative to the plugins directory or the plugin directory name. * * @return bool */ public static function is_plugin_active( $plugin ) { $plugin_path = self::get_plugin_path_from_slug( $plugin ); return $plugin_path && \is_plugin_active( $plugin_path ); } /** * Get plugin data. * * @param string $plugin Path to the plugin file relative to the plugins directory or the plugin directory name. * * @return array|false */ public static function get_plugin_data( $plugin ) { $plugin_path = self::get_plugin_path_from_slug( $plugin ); $plugins = get_plugins(); return isset( $plugins[ $plugin_path ] ) ? $plugins[ $plugin_path ] : false; } /** * Install an array of plugins. * * @param array $plugins Plugins to install. * @param PluginsInstallLogger|null $logger an optional logger. * @param string|null $source place where the request is coming from. * * @return array */ public static function install_plugins( $plugins, ?PluginsInstallLogger $logger = null, ?string $source = null ) { /** * Filter the list of plugins to install. * * @param array $plugins A list of the plugins to install. * * @since 6.4.0 */ $plugins = apply_filters( 'woocommerce_admin_plugins_pre_install', $plugins ); if ( empty( $plugins ) || ! is_array( $plugins ) ) { return new WP_Error( 'woocommerce_plugins_invalid_plugins', __( 'Plugins must be a non-empty array.', 'woocommerce' ) ); } require_once ABSPATH . 'wp-admin/includes/plugin.php'; include_once ABSPATH . '/wp-admin/includes/admin.php'; include_once ABSPATH . '/wp-admin/includes/plugin-install.php'; include_once ABSPATH . '/wp-admin/includes/plugin.php'; include_once ABSPATH . '/wp-admin/includes/class-wp-upgrader.php'; include_once ABSPATH . '/wp-admin/includes/class-plugin-upgrader.php'; $existing_plugins = self::get_installed_plugins_paths(); $installed_plugins = array(); $results = array(); $time = array(); $errors = new WP_Error(); $install_start_time = time(); foreach ( $plugins as $plugin ) { $slug = sanitize_key( $plugin ); $logger && $logger->install_requested( $plugin ); if ( isset( $existing_plugins[ $slug ] ) ) { $installed_plugins[] = $plugin; $logger && $logger->installed( $plugin, 0 ); continue; } $start_time = microtime( true ); $api = plugins_api( 'plugin_information', array( 'slug' => $slug, 'fields' => array( 'sections' => false, ), ) ); if ( is_wp_error( $api ) ) { $properties = array( 'error_message' => sprintf( // translators: %s: plugin slug (example: woocommerce-services). __( 'The requested plugin `%s` could not be installed. Plugin API call failed.', 'woocommerce' ), $slug ), 'api_error_message' => $api->get_error_message(), 'slug' => $slug, ); wc_admin_record_tracks_event( 'install_plugin_error', $properties ); /** * Action triggered when a plugin API call failed. * * @param string $slug The plugin slug. * @param WP_Error $api The API response. * * @since 6.4.0 */ do_action( 'woocommerce_plugins_install_api_error', $slug, $api ); $error_message = sprintf( /* translators: %s: plugin slug (example: woocommerce-services) */ __( 'The requested plugin `%s` could not be installed. Plugin API call failed.', 'woocommerce' ), $slug ); $errors->add( $plugin, $error_message ); $logger && $logger->add_error( $plugin, $error_message ); continue; } /** * Action triggered before a plugin is installed. * * @since 9.8 */ do_action( 'woocommerce_plugins_install_before', $slug, $source ); $upgrader = new Plugin_Upgrader( new Automatic_Upgrader_Skin() ); $result = $upgrader->install( $api->download_link ); // result can be false or WP_Error. $results[ $plugin ] = $result; $time[ $plugin ] = round( ( microtime( true ) - $start_time ) * 1000 ); if ( is_wp_error( $result ) || is_null( $result ) ) { $properties = array( 'error_message' => sprintf( /* translators: %s: plugin slug (example: woocommerce-services) */ __( 'The requested plugin `%s` could not be installed.', 'woocommerce' ), $slug ), 'slug' => $slug, 'api_version' => $api->version, 'api_download_link' => $api->download_link, 'upgrader_skin_message' => implode( ',', $upgrader->skin->get_upgrade_messages() ), 'result' => is_wp_error( $result ) ? $result->get_error_message() : 'null', ); wc_admin_record_tracks_event( 'install_plugin_error', $properties ); /** * Action triggered when a plugin installation fails. * * @param string $slug The plugin slug. * @param object $api The plugin API object. * @param WP_Error|null $result The result of the plugin installation. * @param Plugin_Upgrader $upgrader The plugin upgrader. * * @since 6.4.0 */ do_action( 'woocommerce_plugins_install_error', $slug, $api, $result, $upgrader ); $install_error_message = sprintf( /* translators: %s: plugin slug (example: woocommerce-services) */ __( 'The requested plugin `%s` could not be installed. Upgrader install failed.', 'woocommerce' ), $slug ); $errors->add( $plugin, $install_error_message ); $logger && $logger->add_error( $plugin, $install_error_message ); continue; } $installed_plugins[] = $plugin; $logger && $logger->installed( $plugin, $time[ $plugin ] ); /** * Action triggered after a plugin is installed. * * @since 9.8 */ do_action( 'woocommerce_plugins_install_after', $slug, $source ); } $data = array( 'installed' => $installed_plugins, 'results' => $results, 'errors' => $errors, 'time' => $time, ); $logger && $logger->complete( array_merge( $data, array( 'start_time' => $install_start_time ) ) ); return $data; } /** * Callback registered by OnboardingPlugins::install_and_activate_async. * * It is used to call install_plugins and activate_plugins with a custom logger. * * @param array $plugins A list of plugins to install. * @param string $job_id An unique job I.D. * @param string|null $source The source of the request. * * @return bool */ public static function install_and_activate_plugins_async_callback( array $plugins, string $job_id, ?string $source = null ) { $option_name = 'woocommerce_onboarding_plugins_install_and_activate_async_' . $job_id; $logger = new AsyncPluginsInstallLogger( $option_name ); self::install_plugins( $plugins, $logger, $source ); self::activate_plugins( $plugins, $logger ); return true; } /** * Schedule plugin installation. * * @param array $plugins Plugins to install. * * @return string Job ID. */ public static function schedule_install_plugins( $plugins ) { if ( empty( $plugins ) || ! is_array( $plugins ) ) { return new WP_Error( 'woocommerce_plugins_invalid_plugins', __( 'Plugins must be a non-empty array.', 'woocommerce' ), 404 ); } $job_id = uniqid(); WC()->queue()->schedule_single( time() + 5, 'woocommerce_plugins_install_callback', array( $plugins ) ); return $job_id; } /** * Activate the requested plugins. * * @param array $plugins Plugins. * @param PluginsInstallLogger|null $logger Logger. * * @return WP_Error|array Plugin Status */ public static function activate_plugins( $plugins, ?PluginsInstallLogger $logger = null ) { if ( empty( $plugins ) || ! is_array( $plugins ) ) { return new WP_Error( 'woocommerce_plugins_invalid_plugins', __( 'Plugins must be a non-empty array.', 'woocommerce' ), 404 ); } require_once ABSPATH . 'wp-admin/includes/plugin.php'; // the mollie-payments-for-woocommerce plugin calls `WP_Filesystem()` during it's activation hook, which crashes without this include. require_once ABSPATH . 'wp-admin/includes/file.php'; /** * Filter the list of plugins to activate. * * @param array $plugins A list of the plugins to activate. * * @since 6.4.0 */ $plugins = apply_filters( 'woocommerce_admin_plugins_pre_activate', $plugins ); $plugin_paths = self::get_installed_plugins_paths(); $errors = new WP_Error(); $activated_plugins = array(); foreach ( $plugins as $plugin ) { $slug = $plugin; $path = isset( $plugin_paths[ $slug ] ) ? $plugin_paths[ $slug ] : false; if ( ! $path ) { /* translators: %s: plugin slug (example: woocommerce-services) */ $message = sprintf( __( 'The requested plugin `%s`. is not yet installed.', 'woocommerce' ), $slug ); $errors->add( $plugin, $message ); $logger && $logger->add_error( $plugin, $message ); continue; } $result = activate_plugin( $path ); if ( ! is_plugin_active( $path ) ) { /** * Action triggered when a plugin activation fails. * * @param string $slug The plugin slug. * @param null|WP_Error $result The result of the plugin activation. * * @since 6.4.0 */ do_action( 'woocommerce_plugins_activate_error', $slug, $result ); /* translators: %s: plugin slug (example: woocommerce-services) */ $message = sprintf( __( 'The requested plugin `%s` could not be activated.', 'woocommerce' ), $slug ); $errors->add( $plugin, $message ); $logger && $logger->add_error( $plugin, $message ); continue; } $activated_plugins[] = $plugin; $logger && $logger->activated( $plugin ); } $data = array( 'activated' => $activated_plugins, 'active' => self::get_active_plugin_slugs(), 'errors' => $errors, ); return $data; } /** * Schedule plugin activation. * * @param array $plugins Plugins to activate. * * @return string Job ID. */ public static function schedule_activate_plugins( $plugins ) { if ( empty( $plugins ) || ! is_array( $plugins ) ) { return new WP_Error( 'woocommerce_plugins_invalid_plugins', __( 'Plugins must be a non-empty array.', 'woocommerce' ), 404 ); } $job_id = uniqid(); WC()->queue()->schedule_single( time() + 5, 'woocommerce_plugins_activate_callback', array( $plugins, $job_id ) ); return $job_id; } /** * Installation status. * * @param int $job_id Job ID. * * @return array Job data. */ public static function get_installation_status( $job_id = null ) { $actions = WC()->queue()->search( array( 'hook' => 'woocommerce_plugins_install_callback', 'search' => $job_id, 'orderby' => 'date', 'order' => 'DESC', ) ); return self::get_action_data( $actions ); } /** * Gets the plugin data for the first action. * * @param array $actions Array of AS actions. * * @return array Array of action data. */ public static function get_action_data( $actions ) { $data = array(); foreach ( $actions as $action_id => $action ) { $store = new ActionScheduler_DBStore(); $args = $action->get_args(); $data[] = array( 'job_id' => $args[1], 'plugins' => $args[0], 'status' => $store->get_status( $action_id ), ); } return $data; } /** * Activation status. * * @param int $job_id Job ID. * * @return array Array of action data. */ public static function get_activation_status( $job_id = null ) { $actions = WC()->queue()->search( array( 'hook' => 'woocommerce_plugins_activate_callback', 'search' => $job_id, 'orderby' => 'date', 'order' => 'DESC', ) ); return self::get_action_data( $actions ); } /** * Show notices to connect to woocommerce.com for unconnected store in the plugin list. * * @return void */ public static function maybe_show_connect_notice_in_plugin_list() { if ( 'woocommerce_page_wc-settings' !== get_current_screen()->id ) { return; } $notice_type = WC_Helper_Updater::get_woo_connect_notice_type(); if ( 'none' === $notice_type ) { return; } $notice_string = ''; if ( 'long' === $notice_type ) { $notice_string .= __( 'Your store might be at risk as you are running old versions of WooCommerce plugins.', 'woocommerce' ); $notice_string .= ' '; } $connect_page_url = add_query_arg( array( 'page' => 'wc-admin', 'tab' => 'my-subscriptions', 'path' => rawurlencode( '/extensions' ), 'utm_source' => 'pu', 'utm_campaign' => 'pu_setting_screen_connect', ), admin_url( 'admin.php' ) ); $notice_string .= sprintf( /* translators: %s: Connect page URL */ __( '<a id="woo-connect-notice-url" href="%s">Connect your store</a> to WooCommerce.com to get updates and streamlined support for your subscriptions.', 'woocommerce' ), esc_url( $connect_page_url ) ); echo '<div class="woo-connect-notice notice notice-error is-dismissible"> <p class="widefat">' . wp_kses_post( $notice_string ) . '</p> </div>'; } /** * Enqueue scripts for connect notice in WooCommerce settings page. * * @return void */ public static function maybe_enqueue_scripts_for_connect_notice() { if ( 'woocommerce_page_wc-settings' !== get_current_screen()->id ) { return; } $notice_type = WC_Helper_Updater::get_woo_connect_notice_type(); if ( 'none' === $notice_type ) { return; } WCAdminAssets::register_script( 'wp-admin-scripts', 'woo-connect-notice' ); wp_enqueue_script( 'woo-connect-notice' ); } /** * Enqueue scripts for notices in plugin list page. * * @return void */ public static function maybe_enqueue_scripts_for_notices_in_plugins() { if ( 'plugins' !== get_current_screen()->id ) { return; } WCAdminAssets::register_script( 'wp-admin-scripts', 'woo-plugin-update-connect-notice' ); WCAdminAssets::register_script( 'wp-admin-scripts', 'woo-enable-autorenew' ); WCAdminAssets::register_script( 'wp-admin-scripts', 'woo-renew-subscription' ); wp_enqueue_script( 'woo-plugin-update-connect-notice' ); wp_enqueue_script( 'woo-enable-autorenew' ); wp_enqueue_script( 'woo-renew-subscription' ); wp_enqueue_script( 'woo-purchase-subscription' ); } /** * Show notice about to expired subscription on WC settings page. * * @return void */ public static function maybe_show_expired_subscriptions_notice() { if ( ! WC_Helper::is_site_connected() ) { return; } if ( 'woocommerce_page_wc-settings' !== get_current_screen()->id ) { return; } $notice = self::get_expired_subscription_notice(); if ( isset( $notice['description'] ) ) { echo '<div id="woo-subscription-expired-notice" class="woo-subscription-expired-notice woo-subscription-notices notice notice-error is-dismissible" data-dismissnonce="' . esc_attr( wp_create_nonce( 'dismiss_notice' ) ) . '"> <p class="widefat">' . wp_kses_post( $notice['description'] ) . '</p> </div>'; } } /** * Show notice about to expiring subscription on WC settings page. * * @return void */ public static function maybe_show_expiring_subscriptions_notice() { if ( ! WC_Helper::is_site_connected() ) { return; } if ( 'woocommerce_page_wc-settings' !== get_current_screen()->id ) { return; } $notice = self::get_expiring_subscription_notice(); if ( isset( $notice['description'] ) ) { echo '<div id="woo-subscription-expiring-notice" class="woo-subscription-expiring-notice woo-subscription-notices notice notice-error is-dismissible" data-dismissnonce="' . esc_attr( wp_create_nonce( 'dismiss_notice' ) ) . '"> <p class="widefat">' . wp_kses_post( $notice['description'] ) . '</p> </div>'; } } /** * Enqueue scripts for woo subscription notice. * * @return void */ public static function maybe_enqueue_scripts_for_subscription_notice() { if ( 'woocommerce_page_wc-settings' !== get_current_screen()->id ) { return; } WCAdminAssets::register_script( 'wp-admin-scripts', 'woo-subscriptions-notice' ); wp_enqueue_script( 'woo-subscriptions-notice' ); } /** * Construct the subscription notice data based on user subscriptions data. * * @param array $all_subs all subscription data. * @param array $subs_to_show filtered subscriptions as condition. * @param int $total total subscription count. * @param array $messages message. * @param string $type type of notice, whether it is for expiring or expired subscription. * @return array notice data to return. Contains type, parsed_message and product_id (can be a single value or an array). */ public static function get_subscriptions_notice_data( array $all_subs, array $subs_to_show, int $total, array $messages, string $type ) { $utm_campaign = 'expired' === $type ? 'pu_settings_screen_renew' : ( 'missing' === $type ? 'pu_settings_screen_purchase' : 'pu_settings_screen_enable_autorenew' ); if ( 1 < $total ) { $hyperlink_url = add_query_arg( array( 'utm_source' => 'pu', 'utm_campaign' => $utm_campaign, ), self::WOO_SUBSCRIPTION_PAGE_URL ); $parsed_message = sprintf( $messages['different_subscriptions'], esc_attr( $total ), esc_url( $hyperlink_url ), esc_attr( $total ), ); // All product ids. $product_ids = array_map( function ( $sub ) { return $sub['product_id']; }, $subs_to_show ); return array( 'type' => 'different_subscriptions', 'parsed_message' => $parsed_message, 'product_id' => $product_ids, ); } $subscription = reset( $subs_to_show ); $product_id = $subscription['product_id']; // check if $all_subs has multiple subs for this product. $has_multiple_subs_for_product = 1 < count( array_filter( $all_subs, function ( $sub ) use ( $product_id ) { return $product_id === $sub['product_id']; } ) ); $message_key = $has_multiple_subs_for_product ? 'multiple_manage' : 'single_manage'; $renew_string = __( 'Renew', 'woocommerce' ); $subscribe_string = __( 'Subscribe', 'woocommerce' ); if ( isset( $subscription['product_regular_price'] ) ) { /* translators: 1: Product price */ $renew_string = sprintf( __( 'Renew for %1$s', 'woocommerce' ), $subscription['product_regular_price'] ); } $expiry_date = date_i18n( 'F jS', $subscription['expires'] ); $hyperlink_url = add_query_arg( array( 'product_id' => $product_id, 'type' => $type, 'utm_source' => 'pu', 'utm_campaign' => $utm_campaign, ), self::WOO_SUBSCRIPTION_PAGE_URL ); // Construct message based on template for multiple_manage or single_manage, parameter used: // 1. Product name // 2. Expiry date // 3. URL to My Subscriptions page with extra params // 4. Renew string. if ( isset( $messages[ $message_key ] ) ) { $parsed_message = sprintf( $messages[ $message_key ], esc_attr( $subscription['product_name'] ), esc_attr( $expiry_date ), esc_url( $hyperlink_url ), // Show subscribe for missing subscriptions, renew otherwise. 'missing' === $type ? esc_attr( $subscribe_string ) : esc_attr( $renew_string ), ); return array( 'type' => $message_key, 'parsed_message' => $parsed_message, 'product_id' => $product_id, ); } return array( 'type' => 'invalid', 'parsed_message' => '', 'product_id' => '', ); } /** * Get formatted notice information for expiring subscription. * * @param boolean $allowed_link whether the notice description should include a link. * @return array notice information. */ public static function get_expiring_subscription_notice( $allowed_link = true ) { if ( ! WC_Helper::is_site_connected() ) { return array(); } if ( self::$subscription_usage_notices_already_shown ) { return array(); } if ( ! self::should_show_notice( self::DISMISS_EXPIRING_SUBS_NOTICE ) ) { return array(); } $subscriptions = WC_Helper::get_subscription_list_data(); $expiring_subscriptions = array_filter( $subscriptions, function ( $sub ) { return ( ! empty( $sub['local']['installed'] ) && ! empty( $sub['product_key'] ) ) && ( $sub['active'] || empty( $sub['connections'] ) ) // Active on current site or not connected to any sites. && $sub['expiring'] && ! $sub['autorenew']; }, ); if ( ! $expiring_subscriptions ) { return array(); } $total_expiring_subscriptions = count( $expiring_subscriptions ); // Don't show missing notice if there are expiring subscriptions. self::$subscription_usage_notices_already_shown = true; // When payment method is missing on WooCommerce.com. $helper_notices = WC_Helper::get_notices(); if ( ! empty( $helper_notices['missing_payment_method_notice'] ) ) { return self::get_missing_payment_method_notice( $allowed_link, $total_expiring_subscriptions ); } // Payment method is available but there are expiring subscriptions. $notice_data = self::get_subscriptions_notice_data( $subscriptions, $expiring_subscriptions, $total_expiring_subscriptions, array( /* translators: 1) product name 2) expiry date 3) URL to My Subscriptions page */ 'single_manage' => __( 'Your subscription for <strong>%1$s</strong> expires on %2$s. <a href="%3$s">Enable auto-renewal</a> to continue receiving updates and streamlined support.', 'woocommerce' ), /* translators: 1) product name 2) expiry date 3) URL to My Subscriptions page */ 'multiple_manage' => __( 'One of your subscriptions for <strong>%1$s</strong> expires on %2$s. <a href="%3$s">Enable auto-renewal</a> to continue receiving updates and streamlined support.', 'woocommerce' ), /* translators: 1) total expiring subscriptions 2) URL to My Subscriptions page */ 'different_subscriptions' => __( 'You have <strong>%1$s Woo extension subscriptions</strong> expiring soon. <a href="%2$s">Enable auto-renewal</a> to continue receiving updates and streamlined support.', 'woocommerce' ), ), 'expiring', ); $button_link = add_query_arg( array( 'utm_source' => 'pu', 'utm_campaign' => 'pu_in_apps_screen_enable_autorenew', ), self::WOO_SUBSCRIPTION_PAGE_URL ); if ( in_array( $notice_data['type'], array( 'single_manage', 'multiple_manage' ), true ) ) { $button_link = add_query_arg( array( 'product_id' => $notice_data['product_id'], 'type' => 'expiring', ), $button_link ); } return array( 'description' => $allowed_link ? $notice_data['parsed_message'] : preg_replace( '#<a.*?>(.*?)</a>#i', '\1', $notice_data['parsed_message'] ), 'button_text' => __( 'Enable auto-renewal', 'woocommerce' ), 'button_link' => $button_link, ); } /** * Get formatted notice information for expired subscription. * * @param boolean $allowed_link whether the notice description should include a link. * @return array notice information. */ public static function get_expired_subscription_notice( $allowed_link = true ) { if ( ! WC_Helper::is_site_connected() ) { return array(); } if ( ! self::should_show_notice( self::DISMISS_EXPIRED_SUBS_NOTICE ) ) { return array(); } $subscriptions = WC_Helper::get_subscription_list_data(); $expired_subscriptions = array_filter( $subscriptions, function ( $sub ) { return ( ! empty( $sub['local']['installed'] ) && ! empty( $sub['product_key'] ) ) && ( $sub['active'] || empty( $sub['connections'] ) ) // Active on current site or not connected to any sites. && $sub['expired'] && ! $sub['lifetime']; }, ); if ( ! $expired_subscriptions ) { return array(); } $total_expired_subscriptions = count( $expired_subscriptions ); self::$subscription_usage_notices_already_shown = true; $notice_data = self::get_subscriptions_notice_data( $subscriptions, $expired_subscriptions, $total_expired_subscriptions, array( /* translators: 1) product name 3) URL to My Subscriptions page 4) Renew product price string */ 'single_manage' => __( 'Your subscription for <strong>%1$s</strong> expired. <a href="%3$s">%4$s</a> to continue receiving updates and streamlined support.', 'woocommerce' ), /* translators: 1) product name 3) URL to My Subscriptions page 4) Renew product price string */ 'multiple_manage' => __( 'One of your subscriptions for <strong>%1$s</strong> has expired. <a href="%3$s">%4$s</a> to continue receiving updates and streamlined support.', 'woocommerce' ), /* translators: 1) total expired subscriptions 2) URL to My Subscriptions page */ 'different_subscriptions' => __( 'You have <strong>%1$s Woo extension subscriptions</strong> that expired. <a href="%2$s">Renew</a> to continue receiving updates and streamlined support.', 'woocommerce' ), ), 'expired', ); $button_link = add_query_arg( array( 'add-to-cart' => $notice_data['product_id'], 'utm_source' => 'pu', 'utm_campaign' => $allowed_link ? 'pu_settings_screen_renew' : 'pu_in_apps_screen_renew', ), self::WOO_CART_PAGE_URL ); if ( in_array( $notice_data['type'], array( 'single_manage', 'multiple_manage' ), true ) ) { $button_link = add_query_arg( array( 'add-to-cart' => $notice_data['product_id'], ), $button_link ); } return array( 'description' => $allowed_link ? $notice_data['parsed_message'] : preg_replace( '#<a.*?>(.*?)</a>#i', '\1', $notice_data['parsed_message'] ), 'button_text' => __( 'Renew', 'woocommerce' ), 'button_link' => $button_link, ); } /** * Get formatted notice information for missing subscription. * * @return array notice information. */ public static function get_missing_subscription_notice() { if ( ! WC_Helper::is_site_connected() ) { return array(); } if ( self::$subscription_usage_notices_already_shown ) { return array(); } if ( ! self::should_show_notice( self::DISMISS_MISSING_SUBS_NOTICE ) ) { return array(); } $subscriptions = WC_Helper::get_subscription_list_data(); $missing_subscriptions = array_filter( $subscriptions, function ( $sub ) { return ( ! empty( $sub['local']['installed'] ) && empty( $sub['product_key'] ) ); }, ); // Remove WUM from missing subscriptions list. $missing_subscriptions = array_filter( $missing_subscriptions, function ( $sub ) { return 'woo-update-manager' !== $sub['zip_slug']; } ); if ( ! $missing_subscriptions ) { return array(); } $total_missing_subscriptions = count( $missing_subscriptions ); $notice_data = self::get_subscriptions_notice_data( $subscriptions, $missing_subscriptions, $total_missing_subscriptions, array( /* translators: 1) product name */ 'single_manage' => __( 'You don\'t have a subscription for <strong>%1$s</strong>. Subscribe to receive updates and streamlined support.', 'woocommerce' ), /* translators: 1) total expired subscriptions */ 'different_subscriptions' => __( 'You don\'t have subscriptions for <strong>%1$s Woo extensions</strong>. Subscribe to receive updates and streamlined support.', 'woocommerce' ), ), 'missing', ); $button_link = add_query_arg( array( 'add-to-cart' => $notice_data['product_id'], 'utm_source' => 'pu', 'utm_campaign' => 'pu_in_apps_screen_purchase', ), self::WOO_CART_PAGE_URL ); if ( in_array( $notice_data['type'], array( 'single_manage', 'multiple_manage' ), true ) ) { $button_link = add_query_arg( array( 'add-to-cart' => $notice_data['product_id'], ), $button_link ); } $button_text = __( 'Subscribe', 'woocommerce' ); return array( 'description' => $notice_data['parsed_message'], 'button_text' => $button_text, 'button_link' => $button_link, ); } /** * Get notice information when WCCOM connection is disconnected. * * @return string disconnect notice. */ public static function get_wccom_disconnected_notice() { if ( WC_Helper::is_site_connected() ) { return ''; } if ( ! self::should_show_notice( self::DISMISS_DISCONNECT_NOTICE, false ) ) { return ''; } $user_email = \WC_Helper_Options::get( 'last_disconnected_user_data' )['email'] ?? null; if ( empty( $user_email ) ) { return ''; } return sprintf( /* translators: 1: Disconnected user email */ __( 'Successfully disconnected from <b>%1$s</b>.', 'woocommerce' ), $user_email ); } /** * Get the connected status notice message. * * @param string $user_email the user email. * * @return string the connected notice message. */ public static function get_wccom_connected_notice( $user_email ) { if ( ! WC_Helper::is_site_connected() ) { return ''; } if ( ! self::should_show_notice( self::DISMISS_CONNECT_NOTICE, false ) ) { return ''; } if ( ! $user_email ) { return ''; } return sprintf( /* translators: 1: Disconnected user email */ __( 'Successfully connected to <b>%s</b>.', 'woocommerce' ), $user_email ); } /** * Determine whether a specific notice should be shown to the current user. * * @param string $dismiss_notice_meta User meta that includes the timestamp when a store notice was dismissed. * @param bool $show_after_one_month Show the notices dismissed earlier than one month. * @return bool True if the notice should be shown, false otherwise. */ public static function should_show_notice( $dismiss_notice_meta, $show_after_one_month = true ) { // Get the current user ID. $user_id = get_current_user_id(); // Get the timestamp when the notice was dismissed. $dismissed_timestamp = get_user_meta( $user_id, $dismiss_notice_meta, true ); if ( ! $show_after_one_month ) { return empty( $dismissed_timestamp ); } // If the notice was dismissed within the last month, do not show it. if ( ! empty( $dismissed_timestamp ) && ( time() - $dismissed_timestamp ) < 30 * DAY_IN_SECONDS ) { return false; } // If the notice was dismissed more than a month ago, delete the meta value and show the notice. if ( ! empty( $dismissed_timestamp ) ) { delete_user_meta( $user_id, $dismiss_notice_meta ); } return true; } /** * Get the notice data for missing payment method. * * @param bool $allowed_link whether should show link on the notice or not. * @param int $total_expiring_subscriptions total expiring subscriptions. * * @return array the notices data. */ public static function get_missing_payment_method_notice( $allowed_link = true, $total_expiring_subscriptions = 1 ) { $add_payment_method_link = add_query_arg( array( 'utm_source' => 'pu', 'utm_campaign' => $allowed_link ? 'pu_settings_screen_add_payment_method' : 'pu_in_apps_screen_add_payment_method', ), self::WOO_ADD_PAYMENT_METHOD_URL ); $description = $allowed_link ? sprintf( /* translators: %s: WooCommerce.com URL to add payment method */ _n( 'Your WooCommerce extension subscription is missing a payment method for renewal. <a href="%s">Add a payment method</a> to ensure you continue receiving updates and streamlined support.', 'Your WooCommerce extension subscriptions are missing a payment method for renewal. <a href="%s">Add a payment method</a> to ensure you continue receiving updates and streamlined support.', $total_expiring_subscriptions, 'woocommerce' ), $add_payment_method_link ) : _n( 'Your WooCommerce extension subscription is missing a payment method for renewal. Add a payment method to ensure you continue receiving updates and streamlined support.', 'Your WooCommerce extension subscriptions are missing a payment method for renewal. Add a payment method to ensure you continue receiving updates and streamlined support.', $total_expiring_subscriptions, 'woocommerce' ); return array( 'description' => $description, 'button_text' => __( 'Add payment method', 'woocommerce' ), 'button_link' => $add_payment_method_link, ); } } ReportCSVExporter.php 0000777 00000024204 15252240713 0010647 0 ustar 00 <?php /** * Handles reports CSV export batches. */ namespace Automattic\WooCommerce\Admin; if ( ! defined( 'ABSPATH' ) ) { exit; } use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; /** * Include dependencies. */ if ( ! class_exists( 'WC_CSV_Batch_Exporter', false ) ) { include_once WC_ABSPATH . 'includes/export/abstract-wc-csv-batch-exporter.php'; } /** * ReportCSVExporter Class. */ class ReportCSVExporter extends \WC_CSV_Batch_Exporter { /** * Type of report being exported. * * @var string */ protected $report_type; /** * Parameters for the report query. * * @var array */ protected $report_args; /** * REST controller for the report. * * @var WC_REST_Reports_Controller */ protected $controller; /** * Constructor. * * @param string $type Report type. E.g. 'customers'. * @param array $args Report parameters. */ public function __construct( $type = false, $args = array() ) { parent::__construct(); self::maybe_create_directory(); if ( ! empty( $type ) ) { $this->set_report_type( $type ); $this->set_column_names( $this->get_report_columns() ); } if ( ! empty( $args ) ) { $this->set_report_args( $args ); } } /** * Create the directory for reports if it does not yet exist. */ public static function maybe_create_directory() { $reports_dir = self::get_reports_directory(); $files = array( array( 'base' => $reports_dir, 'file' => '.htaccess', 'content' => 'DirectoryIndex index.php index.html' . PHP_EOL . 'deny from all', ), array( 'base' => $reports_dir, 'file' => 'index.html', 'content' => '', ), ); foreach ( $files as $file ) { if ( ! file_exists( trailingslashit( $file['base'] ) ) ) { wp_mkdir_p( $file['base'] ); } if ( ! file_exists( trailingslashit( $file['base'] ) . $file['file'] ) ) { $file_handle = @fopen( trailingslashit( $file['base'] ) . $file['file'], 'wb' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_read_fopen if ( $file_handle ) { fwrite( $file_handle, $file['content'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fwrite fclose( $file_handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fclose } } } } /** * Get report uploads directory. * * @return string */ public static function get_reports_directory() { $upload_dir = wp_upload_dir(); return trailingslashit( $upload_dir['basedir'] ) . 'woocommerce_uploads/reports/'; } /** * Get file path to export to. * * @return string */ protected function get_file_path() { return self::get_reports_directory() . $this->get_filename(); } /** * Setter for report type. * * @param string $type The report type. E.g. customers. */ public function set_report_type( $type ) { $this->report_type = $type; $this->export_type = "admin_{$type}_report"; $this->filename = "wc-{$type}-report-export"; $this->controller = $this->map_report_controller(); } /** * Setter for report args. * * @param array $args The report args. */ public function set_report_args( $args ) { // Use our own internal limit and include all extended info. $report_args = array_merge( $args, array( 'per_page' => $this->get_limit(), 'extended_info' => true, ) ); // Should this happen externally? if ( isset( $report_args['page'] ) ) { $this->set_page( $report_args['page'] ); } $this->report_args = $report_args; } /** * Get a REST controller instance for the report type. * * @return bool|WC_REST_Reports_Controller Report controller instance or boolean false on error. */ protected function map_report_controller() { /** * Used to add custom report controllers. * * @since x.x.x * * @params array $controller_map A report type to report controller class map. * * @returns array Report type to report controller class map. */ $controller_map = apply_filters( 'woocommerce_export_report_controller_map', array( 'products' => 'Automattic\WooCommerce\Admin\API\Reports\Products\Controller', 'variations' => 'Automattic\WooCommerce\Admin\API\Reports\Variations\Controller', 'orders' => 'Automattic\WooCommerce\Admin\API\Reports\Orders\Controller', 'categories' => 'Automattic\WooCommerce\Admin\API\Reports\Categories\Controller', 'taxes' => 'Automattic\WooCommerce\Admin\API\Reports\Taxes\Controller', 'coupons' => 'Automattic\WooCommerce\Admin\API\Reports\Coupons\Controller', 'stock' => 'Automattic\WooCommerce\Admin\API\Reports\Stock\Controller', 'downloads' => 'Automattic\WooCommerce\Admin\API\Reports\Downloads\Controller', 'customers' => 'Automattic\WooCommerce\Admin\API\Reports\Customers\Controller', 'revenue' => 'Automattic\WooCommerce\Admin\API\Reports\Revenue\Stats\Controller', ) ); if ( isset( $controller_map[ $this->report_type ] ) ) { // Load the controllers if accessing outside the REST API. return new $controller_map[ $this->report_type ](); } // Should this do something else? return false; } /** * Get the report columns from the controller. * * @return array Array of report column names. */ protected function get_report_columns() { // Default to the report's defined export columns. if ( $this->controller instanceof ExportableInterface ) { return $this->controller->get_export_columns(); } // Fallback to generating columns from the report schema. $report_columns = array(); $report_schema = $this->controller->get_item_schema(); if ( isset( $report_schema['properties'] ) ) { foreach ( $report_schema['properties'] as $column_name => $column_info ) { // Expand extended info columns into export. if ( 'extended_info' === $column_name ) { // Remove columns with questionable CSV values, like markup. $extended_info = array_diff( array_keys( $column_info ), array( 'image' ) ); $report_columns = array_merge( $report_columns, $extended_info ); } else { $report_columns[] = $column_name; } } } return $report_columns; } /** * Get total % complete. * * Forces an int from parent::get_percent_complete(), which can return a float. * * @return int Percent complete. */ public function get_percent_complete() { return intval( parent::get_percent_complete() ); } /** * Get total number of rows in export. * * @return int Number of rows to export. */ public function get_total_rows() { return $this->total_rows; } /** * Prepare data for export. */ public function prepare_data_to_export() { /** * Used to add/overwrite report data endpoint. * * @since x.x.x * * @param string $endpoint The report's data endpoint. * @param string $type The report's type. * * @returns string The report's endpoint. */ $report_endpoint = apply_filters( 'woocommerce_export_report_data_endpoint', "/wc-analytics/reports/{$this->report_type}", $this->report_type ); $request = new \WP_REST_Request( 'GET', $report_endpoint ); $params = $this->controller->get_collection_params(); $defaults = array(); foreach ( $params as $arg => $options ) { if ( isset( $options['default'] ) ) { $defaults[ $arg ] = $options['default']; } } $request->set_attributes( array( 'args' => $params ) ); $request->set_default_params( $defaults ); $request->set_query_params( $this->report_args ); $request->sanitize_params(); // Does the controller have an export-specific item retrieval method? // @todo - Potentially revisit. This is only for /revenue/stats/. if ( is_callable( array( $this->controller, 'get_export_items' ) ) ) { $response = $this->controller->get_export_items( $request ); } else { $response = $this->controller->get_items( $request ); } // Use WP_REST_Server::response_to_data() to embed links in data. add_filter( 'woocommerce_rest_check_permissions', '__return_true' ); $rest_server = rest_get_server(); $report_data = $rest_server->response_to_data( $response, true ); remove_filter( 'woocommerce_rest_check_permissions', '__return_true' ); $report_meta = $response->get_headers(); $this->total_rows = $report_meta['X-WP-Total']; $this->row_data = array_map( array( $this, 'generate_row_data' ), $report_data ); } /** * Generate row data from a raw report item. * * @param object $item Report item data. * @return array CSV row data. */ protected function get_raw_row_data( $item ) { $columns = $this->get_column_names(); $row = array(); // Expand extended info. if ( isset( $item['extended_info'] ) ) { // Pull extended info property from report item object. $extended_info = (array) $item['extended_info']; unset( $item['extended_info'] ); // Merge extended info columns into report item object. $item = array_merge( $item, $extended_info ); } foreach ( $columns as $column_id => $column_name ) { $value = isset( $item[ $column_name ] ) ? $item[ $column_name ] : null; if ( has_filter( "woocommerce_export_{$this->export_type}_column_{$column_name}" ) ) { // Filter for 3rd parties. $value = apply_filters( "woocommerce_export_{$this->export_type}_column_{$column_name}", '', $item ); } elseif ( is_callable( array( $this, "get_column_value_{$column_name}" ) ) ) { // Handle special columns which don't map 1:1 to item data. $value = $this->{"get_column_value_{$column_name}"}( $item, $this->export_type ); } elseif ( ! is_scalar( $value ) ) { // Ensure that the value is somewhat readable in CSV. $value = wp_json_encode( $value ); } $row[ $column_id ] = $value; } return $row; } /** * Get the export row for a given report item. * * @param object $item Report item data. * @return array CSV row data. */ protected function generate_row_data( $item ) { // Default to the report's export method. if ( $this->controller instanceof ExportableInterface ) { $row = $this->controller->prepare_item_for_export( $item ); } else { // Fallback to raw report data. $row = $this->get_raw_row_data( $item ); } return apply_filters( "woocommerce_export_{$this->export_type}_row_data", $row, $item ); } } PluginsInstallLoggers/PluginsInstallLogger.php 0000777 00000002377 15252240713 0015701 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\PluginsInstallLoggers; /** * A logger used in PluginsHelper::install_plugins to log the installation progress. */ interface PluginsInstallLogger { /** * Called when a plugin install requested. * * @param string $plugin_name plugin name. * @return mixed */ public function install_requested( string $plugin_name ); /** * Called when a plugin installed successfully. * * @param string $plugin_name plugin name. * @param int $duration # of seconds it took to install $plugin_name. * @return mixed */ public function installed( string $plugin_name, int $duration); /** * Called when a plugin activated successfully. * * @param string $plugin_name plugin name. * @return mixed */ public function activated( string $plugin_name ); /** * Called when an error occurred while installing a plugin. * * @param string $plugin_name plugin name. * @param string|null $error_message error message. * @return mixed */ public function add_error( string $plugin_name, ?string $error_message = null); /** * Called when all plugins are processed. * * @param array $data return data from install_plugins(). * @return mixed */ public function complete( $data = array() ); } PluginsInstallLoggers/AsyncPluginsInstallLogger.php 0000777 00000013160 15252240713 0016667 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\PluginsInstallLoggers; /** * A logger to log plugin installation progress in real time to an option. */ class AsyncPluginsInstallLogger implements PluginsInstallLogger { /** * Variable to store logs. * * @var string $option_name option name to store logs. */ private $option_name; /** * Constructor. * * @param string $option_name option name. */ public function __construct( string $option_name ) { $this->option_name = $option_name; add_option( $this->option_name, array( 'created_time' => time(), 'status' => 'pending', 'plugins' => array(), ), '', 'no' ); // Set status as failed in case we run out of execution time. register_shutdown_function( function () { $error = error_get_last(); if ( isset( $error['type'] ) && E_ERROR === $error['type'] ) { $option = $this->get(); $option['status'] = 'failed'; $this->update( $option ); } } ); } /** * Update the option. * * @param array $data New data. * * @return bool */ private function update( array $data ) { return update_option( $this->option_name, $data ); } /** * Retrieve the option. * * @return false|mixed|void */ private function get() { return get_option( $this->option_name ); } /** * Add requested plugin. * * @param string $plugin_name plugin name. * * @return void */ public function install_requested( string $plugin_name ) { $option = $this->get(); if ( ! isset( $option['plugins'][ $plugin_name ] ) ) { $option['plugins'][ $plugin_name ] = array( 'status' => 'installing', 'errors' => array(), 'install_duration' => 0, ); } $this->update( $option ); } /** * Add installed plugin. * * @param string $plugin_name plugin name. * @param int $duration time took to install plugin. * * @return void */ public function installed( string $plugin_name, int $duration ) { $option = $this->get(); $option['plugins'][ $plugin_name ]['status'] = 'installed'; $option['plugins'][ $plugin_name ]['install_duration'] = $duration; $this->update( $option ); } /** * Change status to activated. * * @param string $plugin_name plugin name. * * @return void */ public function activated( string $plugin_name ) { $option = $this->get(); $option['plugins'][ $plugin_name ]['status'] = 'activated'; $this->update( $option ); } /** * Add an error. * * @param string $plugin_name plugin name. * @param string|null $error_message error message. * * @return void */ public function add_error( string $plugin_name, ?string $error_message = null ) { $option = $this->get(); $option['plugins'][ $plugin_name ]['errors'][] = $error_message; $option['plugins'][ $plugin_name ]['status'] = 'failed'; $option['status'] = 'failed'; wc_admin_record_tracks_event( 'coreprofiler_store_extension_installed_and_activated', array( 'success' => false, 'extension' => $this->get_plugin_track_key( $plugin_name ), 'error_message' => $error_message, ) ); $this->update( $option ); } /** * Record completed_time. * * @param array $data return data from install_plugins(). * @return void */ public function complete( $data = array() ) { $option = $this->get(); $option['complete_time'] = time(); $option['status'] = 'complete'; $this->track( $data ); $this->update( $option ); } private function get_plugin_track_key( $id ) { $slug = explode( ':', $id )[0]; $key = preg_match( '/^woocommerce(-|_)payments$/', $slug ) ? 'wcpay' : explode( ':', str_replace( '-', '_', $slug ) )[0]; return $key; } /** * Returns time frame for a given time in milliseconds. * * @param int $timeInMs - time in milliseconds * * @return string - Time frame. */ function get_timeframe( $timeInMs ) { $time_frames = array( array( 'name' => '0-2s', 'max' => 2, ), array( 'name' => '2-5s', 'max' => 5, ), array( 'name' => '5-10s', 'max' => 10, ), array( 'name' => '10-15s', 'max' => 15, ), array( 'name' => '15-20s', 'max' => 20, ), array( 'name' => '20-30s', 'max' => 30, ), array( 'name' => '30-60s', 'max' => 60, ), array( 'name' => '>60s' ), ); foreach ( $time_frames as $time_frame ) { if ( ! isset( $time_frame['max'] ) ) { return $time_frame['name']; } if ( $timeInMs < $time_frame['max'] * 1000 ) { return $time_frame['name']; } } } private function track( $data ) { $track_data = array( 'success' => true, 'installed_extensions' => array_map( function ( $extension ) { return $this->get_plugin_track_key( $extension ); }, $data['installed'] ), 'total_time' => $this->get_timeframe( ( time() - $data['start_time'] ) * 1000 ), ); foreach ( $data['installed'] as $plugin ) { if ( ! isset( $data['time'][ $plugin ] ) ) { continue; } $plugin_track_key = $this->get_plugin_track_key( $plugin ); $install_time = $this->get_timeframe( $data['time'][ $plugin ] ); $track_data[ 'install_time_' . $plugin_track_key ] = $install_time; wc_admin_record_tracks_event( 'coreprofiler_store_extension_installed_and_activated', array( 'success' => true, 'extension' => $plugin_track_key, 'install_time' => $install_time, ) ); } wc_admin_record_tracks_event( 'coreprofiler_store_extensions_installed_and_activated', $track_data ); } } RemoteInboxNotifications/RuleProcessorInterface.php 0000777 00000001573 15252240713 0016710 0 ustar 00 <?php /** * Interface for a rule processor. * * @deprecated 9.4.0 Use \Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\RuleProcessorInterface instead. */ namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications; defined( 'ABSPATH' ) || exit; /** * Rule processor interface * * @deprecated 9.4.0 Use \Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\RuleProcessorInterface instead. */ interface RuleProcessorInterface { /** * Processes a rule, returning the boolean result of the processing. * * @param object $rule The rule to process. * @param object $stored_state Stored state. * * @return bool The result of the processing. */ public function process( $rule, $stored_state ); /** * Validates the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ); } RemoteInboxNotifications/TransformerInterface.php 0000777 00000002055 15252240713 0016377 0 ustar 00 <?php /** * Interface for a transformer. * * @deprecated 9.4.0 Use \Automattic\WooCommerce\Admin\RemoteSpecs\Transformers\TransformerInterface instead. */ namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications; use stdClass; /** * An interface to define a transformer. * * Interface TransformerInterface * * @package Automattic\WooCommerce\Admin\RemoteInboxNotifications * * @deprecated 9.4.0 Use \Automattic\WooCommerce\Admin\RemoteSpecs\Transformers\TransformerInterface instead. */ interface TransformerInterface { /** * Transform given value to a different value. * * @param mixed $value a value to transform. * @param stdClass|null $arguments arguments. * @param string|null $default_value default value. * * @return mixed|null */ public function transform( $value, ?stdClass $arguments = null, $default_value = null ); /** * Validate Transformer arguments. * * @param stdClass|null $arguments arguments to validate. * * @return mixed */ public function validate( ?stdClass $arguments = null ); } RemoteInboxNotifications/RemoteInboxNotificationsEngine.php 0000777 00000025027 15252240713 0020373 0 ustar 00 <?php /** * Handles running specs */ namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\Features\Features; use Automattic\WooCommerce\Admin\Notes\Notes; use Automattic\WooCommerce\Admin\PluginsProvider\PluginsProvider; use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile; use Automattic\WooCommerce\Admin\Notes\Note; use Automattic\WooCommerce\Admin\RemoteSpecs\RemoteSpecsEngine; use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\StoredStateSetupForProducts; /** * Remote Inbox Notifications engine. * This goes through the specs and runs (creates admin notes) for those * specs that are able to be triggered. */ class RemoteInboxNotificationsEngine extends RemoteSpecsEngine { const STORED_STATE_OPTION_NAME = 'wc_remote_inbox_notifications_stored_state'; const WCA_UPDATED_OPTION_NAME = 'wc_remote_inbox_notifications_wca_updated'; /** * Initialize the engine. * phpcs:disable WooCommerce.Functions.InternalInjectionMethod.MissingFinal * phpcs:disable WooCommerce.Functions.InternalInjectionMethod.MissingInternalTag */ public static function init() { // Init things that need to happen before admin_init. add_action( 'init', array( __CLASS__, 'on_init' ), 0, 0 ); // Continue init via admin_init. add_action( 'admin_init', array( __CLASS__, 'on_admin_init' ) ); // Trigger when the profile data option is updated (during onboarding). add_action( 'update_option_' . OnboardingProfile::DATA_OPTION, array( __CLASS__, 'update_profile_option' ), 10, 2 ); // Hook into WCA updated. This is hooked up here rather than in // on_admin_init because that runs too late to hook into the action. add_action( 'woocommerce_run_on_woocommerce_admin_updated', array( __CLASS__, 'run_on_woocommerce_admin_updated' ) ); add_action( 'woocommerce_updated', function () { $next_hook = WC()->queue()->get_next( 'woocommerce_run_on_woocommerce_admin_updated', array(), 'woocommerce-remote-inbox-engine' ); if ( null === $next_hook ) { WC()->queue()->schedule_single( time(), 'woocommerce_run_on_woocommerce_admin_updated', array(), 'woocommerce-remote-inbox-engine' ); } } ); add_filter( 'woocommerce_get_note_from_db', array( __CLASS__, 'get_note_from_db' ), 10, 1 ); add_filter( 'woocommerce_debug_tools', array( __CLASS__, 'add_debug_tools' ) ); add_action( 'wp_ajax_woocommerce_json_inbox_notifications_search', array( __CLASS__, 'ajax_action_inbox_notification_search' ) ); } /** * This is triggered when the profile option is updated and if the * profiler is being completed, triggers a run of the engine. * * @param mixed $old_value Old value. * @param mixed $new_value New value. */ public static function update_profile_option( $old_value, $new_value ) { // Return early if we're not completing the profiler. if ( ( isset( $old_value['completed'] ) && $old_value['completed'] ) || ! isset( $new_value['completed'] ) || ! $new_value['completed'] ) { return; } self::run(); } /** * Init is continued via admin_init so that WC is loaded when the product * query is used, otherwise the query generates a "0 = 1" in the WHERE * condition and thus doesn't return any results. */ public static function on_admin_init() { add_action( 'activated_plugin', array( __CLASS__, 'run' ) ); add_action( 'deactivated_plugin', array( __CLASS__, 'run_on_deactivated_plugin' ), 10, 1 ); StoredStateSetupForProducts::admin_init(); // Pre-fetch stored state so it has the correct initial values. self::get_stored_state(); } /** * An init hook is used here so that StoredStateSetupForProducts can set * up a hook that gets triggered by action-scheduler - this is needed * because the admin_init hook doesn't get triggered by WP Cron. */ public static function on_init() { StoredStateSetupForProducts::init(); } /** * Go through the specs and run them. */ public static function run() { $specs = RemoteInboxNotificationsDataSourcePoller::get_instance()->get_specs_from_data_sources(); if ( false === $specs || ! is_countable( $specs ) || count( $specs ) === 0 ) { return; } $stored_state = self::get_stored_state(); $errors = array(); foreach ( $specs as $spec ) { $error = SpecRunner::run_spec( $spec, $stored_state ); if ( isset( $error ) ) { $errors[] = $error; } } if ( count( $errors ) > 0 ) { self::log_errors( $errors ); } } /** * Set an option indicating that WooCommerce Admin has just been updated, * run the specs, then clear that option. This lets the * WooCommerceAdminUpdatedRuleProcessor trigger on WCA update. */ public static function run_on_woocommerce_admin_updated() { update_option( self::WCA_UPDATED_OPTION_NAME, true, false ); self::run(); update_option( self::WCA_UPDATED_OPTION_NAME, false, false ); } /** * Gets the stored state option, and does the initial set up if it doesn't * already exist. * * @return object The stored state option. */ public static function get_stored_state() { $stored_state = get_option( self::STORED_STATE_OPTION_NAME ); if ( false === $stored_state || ! is_object( $stored_state ) ) { $stored_state = new \stdClass(); $stored_state = StoredStateSetupForProducts::init_stored_state( $stored_state ); update_option( self::STORED_STATE_OPTION_NAME, $stored_state, false ); } return $stored_state; } /** * The deactivated_plugin hook happens before the option is updated * (https://github.com/WordPress/WordPress/blob/master/wp-admin/includes/plugin.php#L826) * so this captures the deactivated plugin path and pushes it into the * PluginsProvider. * * @param string $plugin Path to the plugin file relative to the plugins directory. */ public static function run_on_deactivated_plugin( $plugin ) { PluginsProvider::set_deactivated_plugin( $plugin ); self::run(); } /** * Update the stored state option. * * @param object $stored_state The stored state. */ public static function update_stored_state( $stored_state ) { update_option( self::STORED_STATE_OPTION_NAME, $stored_state, false ); } /** * Get the note. This is used to display localized note. * * @param Note $note_from_db The note object created from db. * * @return Note The note. */ public static 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; } $specs = RemoteInboxNotificationsDataSourcePoller::get_instance()->get_specs_from_data_sources(); foreach ( $specs as $spec ) { if ( $spec->slug !== $note_from_db->get_name() ) { continue; } $locale = SpecRunner::get_locale( $spec->locales, true ); if ( null === $locale ) { // No locale found, so don't update the note. break; } $localized_actions = SpecRunner::get_actions( $spec ); // Manually copy the action id from the db to the localized action, since they were not being provided. foreach ( $localized_actions as $localized_action ) { $action = $note_from_db->get_action( $localized_action->name ); if ( $action ) { $localized_action->id = $action->id; } } $note_from_db->set_title( $locale->title ); $note_from_db->set_content( $locale->content ); $note_from_db->set_actions( $localized_actions ); } return $note_from_db; } /** * Add the debug tools to the WooCommerce debug tools (WooCommerce > Status > Tools). * * @param array $tools a list of tools. * * @return mixed * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. */ public static function add_debug_tools( $tools ) { // 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; } $tools['refresh_remote_inbox_notifications'] = array( 'name' => __( 'Refresh Remote Inbox Notifications', 'woocommerce' ), 'button' => __( 'Refresh', 'woocommerce' ), 'desc' => __( 'This will refresh the remote inbox notifications', 'woocommerce' ), 'callback' => function () { RemoteInboxNotificationsDataSourcePoller::get_instance()->read_specs_from_data_sources(); RemoteInboxNotificationsEngine::run(); return __( 'Remote inbox notifications have been refreshed', 'woocommerce' ); }, ); $tools['delete_inbox_notification'] = array( 'name' => __( 'Delete an Inbox Notification', 'woocommerce' ), 'button' => __( 'Delete', 'woocommerce' ), 'desc' => __( 'This will delete an inbox notification by slug', 'woocommerce' ), 'selector' => array( 'description' => __( 'Select an inbox notification to delete:', 'woocommerce' ), 'class' => 'wc-product-search', 'search_action' => 'woocommerce_json_inbox_notifications_search', 'name' => 'delete_inbox_notification_note_id', 'placeholder' => esc_attr__( 'Search for an inbox notification…', 'woocommerce' ), ), 'callback' => function () { check_ajax_referer( 'debug_action', '_wpnonce' ); if ( ! isset( $_GET['delete_inbox_notification_note_id'] ) ) { return __( 'No inbox notification selected', 'woocommerce' ); } $note_id = wc_clean( sanitize_text_field( wp_unslash( $_GET['delete_inbox_notification_note_id'] ) ) ); $note = Notes::get_note( $note_id ); if ( ! $note ) { return __( 'Inbox notification not found', 'woocommerce' ); } $note->delete( true ); return __( 'Inbox notification has been deleted', 'woocommerce' ); }, ); return $tools; } /** * Add ajax action for remote inbox notification search. * * @return void * * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. */ public static function ajax_action_inbox_notification_search() { global $wpdb; check_ajax_referer( 'search-products', 'security' ); if ( ! isset( $_GET['term'] ) ) { wp_send_json( array() ); } $search = wc_clean( sanitize_text_field( wp_unslash( $_GET['term'] ) ) ); $results = $wpdb->get_results( $wpdb->prepare( "SELECT note_id, name FROM {$wpdb->prefix}wc_admin_notes WHERE name LIKE %s", '%' . $wpdb->esc_like( $search ) . '%' ) ); $rows = array(); foreach ( $results as $result ) { $rows[ $result->note_id ] = $result->name; } wp_send_json( $rows ); } } RemoteInboxNotifications/RemoteInboxNotificationsDataSourcePoller.php 0000777 00000013174 15252240713 0022376 0 ustar 00 <?php /** * Handles polling and storage of specs */ namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\RemoteSpecs\DataSourcePoller; use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\GetRuleProcessor; use WC_Helper; /** * Specs data source poller class. * This handles polling specs from JSON endpoints, and * stores the specs in to the database as an option. */ class RemoteInboxNotificationsDataSourcePoller extends DataSourcePoller { const ID = 'remote_inbox_notifications'; /** * Default data sources array. * * @deprecated since 9.5.0. Use get_data_sources() instead. */ const DATA_SOURCES = array(); /** * Class instance. * * @var RemoteInboxNotificationsDataSourcePoller 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' => 'slug', ) ); } return self::$instance; } /** * Validate the spec. * * @param object $spec The spec to validate. * @param string $url The url of the feed that provided the spec. * * @return bool The result of the validation. */ protected function validate_spec( $spec, $url ) { $logger = self::get_logger(); $logger_context = array( 'source' => $url ); if ( ! isset( $spec->slug ) ) { $logger->error( 'Spec is invalid because the slug is missing in feed', $logger_context ); // phpcs:ignore $logger->error( print_r( $spec, true ), $logger_context ); return false; } if ( ! isset( $spec->status ) ) { $logger->error( 'Spec is invalid because the status is missing in feed', $logger_context ); // phpcs:ignore $logger->error( print_r( $spec, true ), $logger_context ); return false; } if ( ! isset( $spec->locales ) || ! is_array( $spec->locales ) ) { $logger->error( 'Spec is invalid because the status is missing or empty in feed', $logger_context ); // phpcs:ignore $logger->error( print_r( $spec, true ), $logger_context ); return false; } if ( null === SpecRunner::get_locale( $spec->locales ) ) { $logger->error( 'Spec is invalid because the locale could not be retrieved in feed', $logger_context ); // phpcs:ignore $logger->error( print_r( $spec, true ), $logger_context ); return false; } if ( ! isset( $spec->type ) ) { $logger->error( 'Spec is invalid because the type is missing in feed', $logger_context ); // phpcs:ignore $logger->error( print_r( $spec, true ), $logger_context ); return false; } if ( isset( $spec->actions ) && is_array( $spec->actions ) ) { foreach ( $spec->actions as $action ) { if ( ! $this->validate_action( $action, $url ) ) { $logger->error( 'Spec is invalid because an action is invalid in feed', $logger_context ); // phpcs:ignore $logger->error( print_r( $spec, true ), $logger_context ); return false; } } } if ( isset( $spec->rules ) && is_array( $spec->rules ) ) { foreach ( $spec->rules as $rule ) { if ( ! isset( $rule->type ) ) { $logger->error( 'Spec is invalid because a rule type is empty in feed', $logger_context ); // phpcs:ignore $logger->error( print_r( $rule, true ), $logger_context ); // phpcs:ignore $logger->error( print_r( $spec, true ), $logger_context ); return false; } $processor = GetRuleProcessor::get_processor( $rule->type ); if ( ! $processor->validate( $rule ) ) { $logger->error( 'Spec is invalid because a rule is invalid in feed', $logger_context ); // phpcs:ignore $logger->error( print_r( $rule, true ), $logger_context ); // phpcs:ignore $logger->error( print_r( $spec, true ), $logger_context ); return false; } } } return true; } /** * Validate the action. * * @param object $action The action to validate. * @param string $url The url of the feed containing the action (for error reporting). * * @return bool The result of the validation. */ private function validate_action( $action, $url ) { $logger = self::get_logger(); $logger_context = array( 'source' => $url ); if ( ! isset( $action->locales ) || ! is_array( $action->locales ) ) { $logger->error( 'Action is invalid because it has empty or missing locales in feed', $logger_context ); // phpcs:ignore $logger->error( print_r( $action, true ), $logger_context ); return false; } if ( null === SpecRunner::get_action_locale( $action->locales ) ) { $logger->error( 'Action is invalid because the locale could not be retrieved in feed', $logger_context ); // phpcs:ignore $logger->error( print_r( $action, true ), $logger_context ); return false; } if ( ! isset( $action->name ) ) { $logger->error( 'Action is invalid because the name is missing in feed', $logger_context ); // phpcs:ignore $logger->error( print_r( $action, true ), $logger_context ); return false; } if ( ! isset( $action->status ) ) { $logger->error( 'Action is invalid because the status is missing in feed', $logger_context ); // phpcs:ignore $logger->error( print_r( $action, true ), $logger_context ); return false; } return true; } /** * Get data sources. * * @return array */ public static function get_data_sources() { return array( WC_Helper::get_woocommerce_com_base_url() . 'wp-json/wccom/inbox-notifications/2.0/notifications.json', ); } } RemoteInboxNotifications/SpecRunner.php 0000777 00000011655 15252240713 0014346 0 ustar 00 <?php /** * Runs a single spec. */ namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\Notes\Note; use Automattic\WooCommerce\Admin\Notes\Notes; use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\EvaluateAndGetStatus; use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\RuleEvaluator; /** * Runs a single spec. */ class SpecRunner { /** * Run the spec. * * @param object $spec The spec to run. * @param object $stored_state Stored state. */ public static function run_spec( $spec, $stored_state ) { $data_store = Notes::load_data_store(); // Create or update the note. $existing_note_ids = $data_store->get_notes_with_name( $spec->slug ); if ( ! is_countable( $existing_note_ids ) || count( $existing_note_ids ) === 0 ) { $note = new Note(); $note->set_status( Note::E_WC_ADMIN_NOTE_PENDING ); } else { $note = Notes::get_note( $existing_note_ids[0] ); if ( $note === false ) { return; } } // Evaluate the spec and get the new note status. $previous_status = $note->get_status(); try { $status = EvaluateAndGetStatus::evaluate( $spec, $previous_status, $stored_state, new RuleEvaluator() ); } catch ( \Throwable $e ) { return $e; } // If the status is changing, update the created date to now. if ( $previous_status !== $status ) { $note->set_date_created( time() ); } // Get the matching locale or fall back to en-US. $locale = self::get_locale( $spec->locales ); if ( $locale === null ) { return; } // Set up the note. $note->set_title( $locale->title ); $note->set_content( $locale->content ); $note->set_content_data( isset( $spec->content_data ) ? $spec->content_data : (object) array() ); $note->set_status( $status ); $note->set_type( $spec->type ); $note->set_name( $spec->slug ); if ( isset( $spec->source ) ) { $note->set_source( $spec->source ); } if ( isset( $spec->layout ) ) { $note->set_layout( $spec->layout ); } // Recreate actions. $note->set_actions( self::get_actions( $spec ) ); $note->save(); } /** * Get the URL for an action. * * @param object $action The action. * * @return string The URL for the action. */ private static function get_url( $action ) { if ( ! isset( $action->url ) ) { return ''; } if ( isset( $action->url_is_admin_query ) && $action->url_is_admin_query ) { if ( strpos( $action->url, '&path' ) === 0 ) { return wc_admin_url( $action->url ); } return admin_url( $action->url ); } return $action->url; } /** * Get the locale for the WordPress locale, or fall back to the en_US * locale. * * @param Array $locales The locales to search through. * * @returns object The locale that was found, or null if no matching locale was found. */ public static function get_locale( $locales ) { $wp_locale = get_user_locale(); $matching_wp_locales = array_values( array_filter( $locales, function ( $l ) use ( $wp_locale ) { return $wp_locale === $l->locale; } ) ); if ( count( $matching_wp_locales ) !== 0 ) { return $matching_wp_locales[0]; } // Fall back to en_US locale. $en_us_locales = array_values( array_filter( $locales, function ( $l ) { return $l->locale === 'en_US'; } ) ); if ( count( $en_us_locales ) !== 0 ) { return $en_us_locales[0]; } return null; } /** * Get the action locale that matches the note locale, or fall back to the * en_US locale. * * @param Array $action_locales The locales from the spec's action. * * @return object The matching locale, or the en_US fallback locale, or null if neither was found. */ public static function get_action_locale( $action_locales ) { $wp_locale = get_user_locale(); $matching_wp_locales = array_values( array_filter( $action_locales, function ( $l ) use ( $wp_locale ) { return $wp_locale === $l->locale; } ) ); if ( count( $matching_wp_locales ) !== 0 ) { return $matching_wp_locales[0]; } // Fall back to en_US locale. $en_us_locales = array_values( array_filter( $action_locales, function ( $l ) { return $l->locale === 'en_US'; } ) ); if ( count( $en_us_locales ) !== 0 ) { return $en_us_locales[0]; } return null; } /** * Get the actions for a note. * * @param object $spec The spec. * * @return array The actions. */ public static function get_actions( $spec ) { $note = new Note(); $actions = isset( $spec->actions ) ? $spec->actions : array(); foreach ( $actions as $action ) { $action_locale = self::get_action_locale( $action->locales ); $url = self::get_url( $action ); $note->add_action( $action->name, ( $action_locale === null || ! isset( $action_locale->label ) ) ? '' : $action_locale->label, $url, $action->status ); } return $note->get_actions(); } } DeprecatedClassFacade.php 0000777 00000006307 15252240713 0011405 0 ustar 00 <?php /** * A facade to allow deprecating an entire class. Calling instance or static * functions on the facade triggers a deprecation notice before calling the * underlying function. * * Use it by extending DeprecatedClassFacade in your facade class, setting the * static $facade_over_classname string to the name of the class to build * a facade over, and setting the static $deprecated_in_version to the version * that the class was deprecated in. Eg.: * * class DeprecatedGoose extends DeprecatedClassFacade { * static $facade_over_classname = 'Goose'; * static $deprecated_in_version = '1.7.0'; * } */ namespace Automattic\WooCommerce\Admin; defined( 'ABSPATH' ) || exit; // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped /** * A facade to allow deprecating an entire class. */ class DeprecatedClassFacade { /** * The instance that this facade covers over. * * @var object */ protected $instance; /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = ''; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = ''; /** * Static array of logged messages. * * @var array */ private static $logged_messages = array(); /** * Constructor. */ public function __construct() { if ( '' !== static::$facade_over_classname ) { $this->instance = new static::$facade_over_classname(); } } /** * Log a deprecation to the error log. * * @param string $function The name of the deprecated function being called. */ private static function log_deprecation( $function ) { $message = sprintf( '%1$s is deprecated since version %2$s! Use %3$s instead.', static::class . '::' . $function, static::$deprecated_in_version, static::$facade_over_classname . '::' . $function ); if ( '' !== static::$facade_over_classname ) { $message = $message . sprintf( ' Use %s instead.', static::$facade_over_classname . '::' . $function ); } // Only log when the message has not been logged before. if ( ! in_array( $message, self::$logged_messages, true ) ) { error_log( $message ); // phpcs:ignore self::$logged_messages[] = $message; } } /** * Executes when calling any function on an instance of this class. * * @param string $name The name of the function being called. * @param array $arguments An array of the arguments to the function call. */ public function __call( $name, $arguments ) { self::log_deprecation( $name ); if ( ! isset( $this->instance ) ) { return; } return call_user_func_array( array( $this->instance, $name, ), $arguments ); } /** * Executes when calling any static function on this class. * * @param string $name The name of the function being called. * @param array $arguments An array of the arguments to the function call. */ public static function __callStatic( $name, $arguments ) { self::log_deprecation( $name ); if ( '' === static::$facade_over_classname ) { return; } return call_user_func_array( array( static::$facade_over_classname, $name, ), $arguments ); } } DateTimeProvider/CurrentDateTimeProvider.php 0000777 00000000763 15252240713 0015254 0 ustar 00 <?php /** * A provider for getting the current DateTime. */ namespace Automattic\WooCommerce\Admin\DateTimeProvider; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\DateTimeProvider\DateTimeProviderInterface; /** * Current DateTime Provider. * * Uses the current DateTime. */ class CurrentDateTimeProvider implements DateTimeProviderInterface { /** * Returns the current DateTime. * * @return DateTime */ public function get_now() { return new \DateTime(); } } DateTimeProvider/DateTimeProviderInterface.php 0000777 00000000602 15252240713 0015522 0 ustar 00 <?php /** * Interface for a provider for getting the current DateTime, * designed to be mockable for unit tests. */ namespace Automattic\WooCommerce\Admin\DateTimeProvider; defined( 'ABSPATH' ) || exit; /** * DateTime Provider Interface. */ interface DateTimeProviderInterface { /** * Returns the current DateTime. * * @return DateTime */ public function get_now(); } BlockTemplates/BlockContainerInterface.php 0000777 00000000272 15252240713 0014715 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\BlockTemplates; /** * Interface for block containers. */ interface BlockContainerInterface extends BlockInterface, ContainerInterface {} BlockTemplates/BlockTemplateInterface.php 0000777 00000001450 15252240713 0014545 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\BlockTemplates; /** * Interface for block-based template. */ interface BlockTemplateInterface extends ContainerInterface { /** * Get the template ID. */ public function get_id(): string; /** * Get the template title. */ public function get_title(): string; /** * Get the template description. */ public function get_description(): string; /** * Get the template area. */ public function get_area(): string; /** * 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; /** * Get the template as JSON like array. * * @return array The JSON. */ public function to_json(): array; } BlockTemplates/BlockInterface.php 0000777 00000010123 15252240713 0013046 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\BlockTemplates; /** * Interface for block configuration used to specify blocks in BlockTemplate. */ interface BlockInterface { /** * Key for the block name in the block configuration. */ public const NAME_KEY = 'blockName'; /** * Key for the block ID in the block configuration. */ public const ID_KEY = 'id'; /** * Key for the internal order in the block configuration. */ public const ORDER_KEY = 'order'; /** * Key for the block attributes in the block configuration. */ public const ATTRIBUTES_KEY = 'attributes'; /** * Key for the block hide conditions in the block configuration. */ public const HIDE_CONDITIONS_KEY = 'hideConditions'; /** * Key for the block disable conditions in the block configuration. */ public const DISABLE_CONDITIONS_KEY = 'disableConditions'; /** * Get the block name. */ public function get_name(): string; /** * Get the block ID. */ public function get_id(): string; /** * Get the block order. */ public function get_order(): int; /** * Set the block order. * * @param int $order The block order. */ public function set_order( int $order ); /** * Get the block attributes. */ public function get_attributes(): array; /** * Set the block attributes. * * @param array $attributes The block attributes. */ public function set_attributes( array $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 ); /** * Get the parent container that the block belongs to. */ public function &get_parent(): ContainerInterface; /** * Get the root template that the block belongs to. */ public function &get_root_template(): BlockTemplateInterface; /** * Remove the block from its parent. */ public function remove(); /** * Check if the block is detached from its parent or root template. * * @return bool True if the block is detached from its parent or root template. */ public function is_detached(): bool; /** * 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. * @return string The key of the hide condition, which can be used to remove the hide condition. */ public function add_hide_condition( string $expression ): string; /** * 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 ); /** * Get the hide conditions of the block. */ public function get_hide_conditions(): array; /** * 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 disabled. * 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. * @return string The key of the disable condition, which can be used to remove the disable condition. */ public function add_disable_condition( string $expression ): string; /** * 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 ); /** * Get the disable conditions of the block. */ public function get_disable_conditions(): array; /** * Get the block configuration as a formatted template. * * @return array The block configuration as a formatted template. */ public function get_formatted_template(): array; } BlockTemplates/ContainerInterface.php 0000777 00000001536 15252240713 0013746 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\BlockTemplates; /** * Interface for block containers. */ interface ContainerInterface { /** * Get the root template that the block belongs to. */ public function &get_root_template(): BlockTemplateInterface; /** * Get the block configuration as a formatted template. */ public function get_formatted_template(): array; /** * Get a block by ID. * * @param string $block_id The block ID. */ public function get_block( string $block_id ): ?BlockInterface; /** * Removes a block from the 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 ); /** * Removes all blocks from the container. */ public function remove_blocks(); } Composer/Package.php 0000777 00000004577 15252240713 0010424 0 ustar 00 <?php /** * Returns information about the package and handles init. */ /** * This namespace isn't compatible with the PSR-4 * which ensures that the copy in the standalone plugin will not be autoloaded. */ namespace Automattic\WooCommerce\Admin\Composer; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\Notes\Notes; use Automattic\WooCommerce\Admin\Notes\NotesUnavailableException; use Automattic\WooCommerce\Internal\Admin\FeaturePlugin; /** * Main package class. */ class Package { /** * Version. * * @var string */ const VERSION = '3.3.0'; /** * Package active. * * @var bool */ private static $package_active = false; /** * Active version * * @var bool */ private static $active_version = null; /** * Init the package. * * Only initialize for WP 5.3 or greater. */ public static function init() { // Avoid double initialization when the feature plugin is in use. if (defined( 'WC_ADMIN_VERSION_NUMBER' ) ) { self::$active_version = WC_ADMIN_VERSION_NUMBER; return; } $feature_plugin_instance = FeaturePlugin::instance(); // Indicate to the feature plugin that the core package exists. if ( ! defined( 'WC_ADMIN_PACKAGE_EXISTS' ) ) { define( 'WC_ADMIN_PACKAGE_EXISTS', true ); } self::$package_active = true; self::$active_version = self::VERSION; $feature_plugin_instance->init(); // Unhook the custom Action Scheduler data store class in active older versions of WC Admin. remove_filter( 'action_scheduler_store_class', array( $feature_plugin_instance, 'replace_actionscheduler_store_class' ) ); } /** * Return the version of the package. * * @return string */ public static function get_version() { return self::VERSION; } /** * Return the active version of WC Admin. * * @return string */ public static function get_active_version() { return self::$active_version; } /** * Return whether the package is active. * * @return bool */ public static function is_package_active() { return self::$package_active; } /** * Return the path to the package. * * @return string */ public static function get_path() { return dirname( __DIR__ ); } /** * Checks if notes have been initialized. */ private static function is_notes_initialized() { try { Notes::load_data_store(); } catch ( NotesUnavailableException $e ) { return false; } return true; } } Overrides/ThemeUpgrader.php 0000777 00000004004 15252240713 0011761 0 ustar 00 <?php /** * Theme upgrader used in REST API response. */ namespace Automattic\WooCommerce\Admin\Overrides; defined( 'ABSPATH' ) || exit; /** * Admin\Overrides\ThemeUpgrader Class. */ class ThemeUpgrader extends \Theme_Upgrader { /** * Install a theme package. * * @param string $package The full local path or URI of the package. * @param array $args { * Optional. Other arguments for installing a theme package. Default empty array. * * @type bool $clear_update_cache Whether to clear the updates cache if successful. * Default true. * } * * @return bool|WP_Error True if the installation was successful, false or a WP_Error object otherwise. */ public function install( $package, $args = array() ) { $defaults = array( 'clear_update_cache' => true, ); $parsed_args = wp_parse_args( $args, $defaults ); $this->init(); $this->install_strings(); add_filter( 'upgrader_source_selection', array( $this, 'check_package' ) ); add_filter( 'upgrader_post_install', array( $this, 'check_parent_theme_filter' ), 10, 3 ); if ( $parsed_args['clear_update_cache'] ) { // Clear cache so wp_update_themes() knows about the new theme. add_action( 'upgrader_process_complete', 'wp_clean_themes_cache', 9, 0 ); } $result = $this->run( array( 'package' => $package, 'destination' => get_theme_root(), 'clear_destination' => false, // Do not overwrite files. 'clear_working' => true, 'hook_extra' => array( 'type' => 'theme', 'action' => 'install', ), ) ); remove_action( 'upgrader_process_complete', 'wp_clean_themes_cache', 9 ); remove_filter( 'upgrader_source_selection', array( $this, 'check_package' ) ); remove_filter( 'upgrader_post_install', array( $this, 'check_parent_theme_filter' ) ); if ( $result && ! is_wp_error( $result ) ) { // Refresh the Theme Update information. wp_clean_themes_cache( $parsed_args['clear_update_cache'] ); } return $result; } } Overrides/OrderTraits.php 0000777 00000012606 15252240713 0011476 0 ustar 00 <?php /** * WC Admin Order Trait * * WC Admin Order Trait class that houses shared functionality across order and refund classes. */ namespace Automattic\WooCommerce\Admin\Overrides; defined( 'ABSPATH' ) || exit; /** * OrderTraits class. */ trait OrderTraits { /** * Calculate shipping amount for line item/product as a total shipping amount ratio based on quantity. * * @param WC_Order_Item $item Line item from order. * @param int $order_items_count (optional) The number of order items in an order. This could be the remaining items left to refund. * @param float $shipping_amount (optional) The shipping fee amount in an order. This could be the remaining shipping amount left to refund. * * @return float|int */ public function get_item_shipping_amount( $item, $order_items_count = null, $shipping_amount = null ) { // Shipping amount loosely based on woocommerce code in includes/admin/meta-boxes/views/html-order-item(s).php // distributed simply based on number of line items. $product_qty = $item->get_quantity( 'edit' ); // Use the passed order_items_count if provided, otherwise get the total number of items in the order. // This is useful when calculating refunds for partial items in an order. // For example, if 2 items are refunded from an order with 4 items. The remaining 2 items should have the shipping fee of the refunded items distributed to them. $order_items = null !== $order_items_count ? $order_items_count : $this->get_item_count(); if ( 0 === $order_items ) { return 0; } // Use the passed shipping_amount if provided, otherwise get the total shipping amount in the order. // This is useful when calculating refunds for partial shipping in an order. // For example, if $10 shipping is refunded from an order with $30 shipping, the remaining $20 should be distributed to the remaining items. $total_shipping_amount = null !== $shipping_amount ? $shipping_amount : (float) $this->get_shipping_total(); return $total_shipping_amount / $order_items * $product_qty; } /** * Calculate shipping tax amount for line item/product as a total shipping tax amount ratio based on quantity. * * Loosely based on code in includes/admin/meta-boxes/views/html-order-item(s).php. * * @todo If WC is currently not tax enabled, but it was before (or vice versa), would this work correctly? * * @param WC_Order_Item $item Line item from order. * @param int $order_items_count (optional) The number of order items in an order. This could be the remaining items left to refund. * @param float $shipping_tax_amount (optional) The shipping tax amount in an order. This could be the remaining shipping tax amount left to refund. * * @return float|int */ public function get_item_shipping_tax_amount( $item, $order_items_count = null, $shipping_tax_amount = null ) { // Use the passed order_items_count if provided, otherwise get the total number of items in the order. // This is useful when calculating refunds for partial items in an order. // For example, if 2 items are refunded from an order with 4 items. The remaining 2 items should have the shipping tax of the refunded items distributed to them. $order_items = null !== $order_items_count ? $order_items_count : $this->get_item_count(); if ( 0 === $order_items ) { return 0; } // Use the passed shipping_tax_amount if provided, otherwise initialize it to 0 and calculate the total shipping tax amount in the order. // This is useful when calculating refunds for partial shipping tax in an order. // For example, if $1 shipping tax is refunded from an order with $3 shipping tax, the remaining $2 should be distributed to the remaining items. $total_shipping_tax_amount = $shipping_tax_amount ? $shipping_tax_amount : 0; if ( null === $shipping_tax_amount ) { $order_taxes = $this->get_taxes(); $line_items_shipping = $this->get_items( 'shipping' ); foreach ( $line_items_shipping as $item_id => $shipping_item ) { $tax_data = $shipping_item->get_taxes(); if ( $tax_data ) { foreach ( $order_taxes as $tax_item ) { $tax_item_id = $tax_item->get_rate_id(); $tax_item_total = isset( $tax_data['total'][ $tax_item_id ] ) ? (float) $tax_data['total'][ $tax_item_id ] : 0; $total_shipping_tax_amount += $tax_item_total; } } } } $product_qty = $item->get_quantity( 'edit' ); return $total_shipping_tax_amount / $order_items * $product_qty; } /** * Calculates coupon amount for specified line item/product. * * Coupon calculation based on woocommerce code in includes/admin/meta-boxes/views/html-order-item.php. * * @param WC_Order_Item $item Line item from order. * * @return float */ public function get_item_coupon_amount( $item ) { return floatval( $item->get_subtotal( 'edit' ) - $item->get_total( 'edit' ) ); } /** * Calculate cart tax amount for line item/product. * * @param WC_Order_Item $item Line item from order. * * @return float */ public function get_item_cart_tax_amount( $item ) { $order_taxes = $this->get_taxes(); $tax_data = $item->get_taxes(); $cart_tax_amount = 0.0; foreach ( $order_taxes as $tax_item ) { $tax_item_id = $tax_item->get_rate_id(); $cart_tax_amount += isset( $tax_data['total'][ $tax_item_id ] ) ? (float) $tax_data['total'][ $tax_item_id ] : 0; } return $cart_tax_amount; } } Overrides/Order.php 0000777 00000007123 15252240713 0010305 0 ustar 00 <?php /** * WC Admin Order * * WC Admin Order class that adds some functionality on top of general WooCommerce WC_Order. */ namespace Automattic\WooCommerce\Admin\Overrides; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore; use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrdersStatsDataStore; /** * WC_Order subclass. */ class Order extends \WC_Order { /** * Order traits. */ use OrderTraits; /** * Holds refund amounts and quantities for the order. * * @var void|array */ protected $refunded_line_items; /** * Caches the customer ID. * * @var int */ public $customer_id = null; /** * Get only core class data in array format. * * @return array */ public function get_data_without_line_items() { return array_merge( array( 'id' => $this->get_id(), ), $this->data, array( 'number' => $this->get_order_number(), 'meta_data' => $this->get_meta_data(), ) ); } /** * Get order line item data by type. * * @param string $type Order line item type. * @return array|bool Array of line items on success, boolean false on failure. */ public function get_line_item_data( $type ) { $type_to_items = array( 'line_items' => 'line_item', 'tax_lines' => 'tax', 'shipping_lines' => 'shipping', 'fee_lines' => 'fee', 'coupon_lines' => 'coupon', ); if ( isset( $type_to_items[ $type ] ) ) { return $this->get_items( $type_to_items[ $type ] ); } return false; } /** * Add filter(s) required to hook this class to substitute WC_Order. */ public static function add_filters() { add_filter( 'woocommerce_order_class', array( __CLASS__, 'order_class_name' ), 10, 3 ); } /** * Filter function to swap class WC_Order for this one in cases when it's suitable. * * @param string $classname Name of the class to be created. * @param string $order_type Type of order object to be created. * @param number $order_id Order id to create. * * @return string */ public static function order_class_name( $classname, $order_type, $order_id ) { // @todo - Only substitute class when necessary (during sync). if ( 'WC_Order' === $classname ) { return '\Automattic\WooCommerce\Admin\Overrides\Order'; } else { return $classname; } } /** * Get the customer ID used for reports in the customer lookup table. * * @return int */ public function get_report_customer_id() { if ( is_null( $this->customer_id ) ) { $this->customer_id = CustomersDataStore::get_or_create_customer_from_order( $this ); } return $this->customer_id; } /** * Returns true if the customer has made an earlier order. * * @return bool */ public function is_returning_customer() { return OrdersStatsDataStore::is_returning_customer( $this, $this->get_report_customer_id() ); } /** * Get the customer's first name. */ public function get_customer_first_name() { if ( $this->get_user_id() ) { return get_user_meta( $this->get_user_id(), 'first_name', true ); } if ( '' !== $this->get_billing_first_name( 'edit' ) ) { return $this->get_billing_first_name( 'edit' ); } else { return $this->get_shipping_first_name( 'edit' ); } } /** * Get the customer's last name. */ public function get_customer_last_name() { if ( $this->get_user_id() ) { return get_user_meta( $this->get_user_id(), 'last_name', true ); } if ( '' !== $this->get_billing_last_name( 'edit' ) ) { return $this->get_billing_last_name( 'edit' ); } else { return $this->get_shipping_last_name( 'edit' ); } } } Overrides/OrderRefund.php 0000777 00000004034 15252240713 0011447 0 ustar 00 <?php /** * WC Admin Order Refund * * WC Admin Order Refund class that adds some functionality on top of general WooCommerce WC_Order_Refund. */ namespace Automattic\WooCommerce\Admin\Overrides; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore; /** * WC_Order_Refund subclass. */ class OrderRefund extends \WC_Order_Refund { /** * Order traits. */ use OrderTraits; /** * Caches the customer ID. * * @var int */ public $customer_id = null; /** * Add filter(s) required to hook this class to substitute WC_Order_Refund. */ public static function add_filters() { add_filter( 'woocommerce_order_class', array( __CLASS__, 'order_class_name' ), 10, 3 ); } /** * Filter function to swap class WC_Order_Refund for this one in cases when it's suitable. * * @param string $classname Name of the class to be created. * @param string $order_type Type of order object to be created. * @param number $order_id Order id to create. * * @return string */ public static function order_class_name( $classname, $order_type, $order_id ) { // @todo - Only substitute class when necessary (during sync). if ( 'WC_Order_Refund' === $classname ) { return '\Automattic\WooCommerce\Admin\Overrides\OrderRefund'; } else { return $classname; } } /** * Get the customer ID of the parent order used for reports in the customer lookup table. * * @return int|bool Customer ID of parent order, or false if parent order not found. */ public function get_report_customer_id() { if ( is_null( $this->customer_id ) ) { $parent_order = \wc_get_order( $this->get_parent_id() ); if ( ! $parent_order ) { $this->customer_id = false; } $this->customer_id = CustomersDataStore::get_or_create_customer_from_order( $parent_order ); } return $this->customer_id; } /** * Returns null since refunds should not be counted towards returning customer counts. * * @return null */ public function is_returning_customer() { return null; } } Overrides/ThemeUpgraderSkin.php 0000777 00000001446 15252240713 0012615 0 ustar 00 <?php /** * Theme upgrader skin used in REST API response. */ namespace Automattic\WooCommerce\Admin\Overrides; defined( 'ABSPATH' ) || exit; /** * Admin\Overrides\ThemeUpgraderSkin Class. */ class ThemeUpgraderSkin extends \Theme_Upgrader_Skin { /** * Avoid undefined property error from \Theme_Upgrader::check_parent_theme_filter(). * * @var array */ public $api; /** * Hide the skin header display. */ public function header() {} /** * Hide the skin footer display. */ public function footer() {} /** * Hide the skin feedback display. * * @param string $string String to display. * @param mixed ...$args Optional text replacements. */ public function feedback( $string, ...$args ) {} /** * Hide the skin after display. */ public function after() {} } PluginsInstaller.php 0000777 00000006564 15252240713 0010577 0 ustar 00 <?php /** * PluginsInstaller * * Installer to allow plugin installation via URL query. */ namespace Automattic\WooCommerce\Admin; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Plugins; use Automattic\WooCommerce\Admin\Features\TransientNotices; /** * Class PluginsInstaller */ class PluginsInstaller { /** * Constructor */ public static function init() { add_action( 'admin_init', array( __CLASS__, 'possibly_install_activate_plugins' ) ); } /** * Check if an install or activation is being requested via URL query. */ public static function possibly_install_activate_plugins() { /* phpcs:disable WordPress.Security.NonceVerification.Recommended */ if ( ! isset( $_GET['plugin_action'] ) || ! isset( $_GET['plugins'] ) || ! current_user_can( 'install_plugins' ) || ! isset( $_GET['nonce'] ) ) { return; } $nonce = sanitize_text_field( wp_unslash( $_GET['nonce'] ) ); if ( ! wp_verify_nonce( $nonce, 'install-plugin' ) ) { wp_nonce_ays( 'install-plugin' ); } $plugins = sanitize_text_field( wp_unslash( $_GET['plugins'] ) ); $plugin_action = sanitize_text_field( wp_unslash( $_GET['plugin_action'] ) ); /* phpcs:enable WordPress.Security.NonceVerification.Recommended */ $plugins_api = new Plugins(); $install_result = null; $activate_result = null; switch ( $plugin_action ) { case 'install': $install_result = $plugins_api->install_plugins( array( 'plugins' => $plugins ) ); break; case 'activate': $activate_result = $plugins_api->activate_plugins( array( 'plugins' => $plugins ) ); break; case 'install-activate': $install_result = $plugins_api->install_plugins( array( 'plugins' => $plugins ) ); $activate_result = $plugins_api->activate_plugins( array( 'plugins' => implode( ',', $install_result['data']['installed'] ) ) ); break; } self::cache_results( $plugins, $install_result, $activate_result ); self::redirect_to_referer(); } /** * Display the results of installation and activation on the page. * * @param string $plugins Comma separated list of plugins. * @param array $install_result Result of installation. * @param array $activate_result Result of activation. */ public static function cache_results( $plugins, $install_result, $activate_result ) { if ( ! $install_result && ! $activate_result ) { return; } if ( is_wp_error( $install_result ) || is_wp_error( $activate_result ) ) { $message = $activate_result ? $activate_result->get_error_message() : $install_result->get_error_message(); } else { $message = $activate_result ? $activate_result['message'] : $install_result['message']; } TransientNotices::add( array( 'user_id' => get_current_user_id(), 'id' => 'plugin-installer-' . str_replace( ',', '-', $plugins ), 'status' => 'success', 'content' => $message, ) ); } /** * Redirect back to the referring page if one exists. */ public static function redirect_to_referer() { $referer = wp_get_referer(); if ( $referer && 0 !== strpos( $referer, wp_login_url() ) ) { wp_safe_redirect( $referer ); exit(); } if ( ! isset( $_SERVER['REQUEST_URI'] ) ) { return; } $url = remove_query_arg( 'plugin_action', wp_unslash( $_SERVER['REQUEST_URI'] ) ); // phpcs:ignore sanitization ok. $url = remove_query_arg( 'plugins', $url ); wp_safe_redirect( $url ); exit(); } } Schedulers/SchedulerTraits.php 0000777 00000023133 15252240713 0012475 0 ustar 00 <?php /** * Traits for scheduling actions and dependencies. */ namespace Automattic\WooCommerce\Admin\Schedulers; defined( 'ABSPATH' ) || exit; /** * SchedulerTraits class. */ trait SchedulerTraits { /** * Action scheduler group. * * @var string|null */ public static $group = 'wc-admin-data'; /** * Queue instance. * * @var WC_Queue_Interface */ protected static $queue = null; /** * Add all actions as hooks. */ public static function init() { foreach ( self::get_actions() as $action_name => $action_hook ) { $method = new \ReflectionMethod( static::class, $action_name ); add_action( $action_hook, array( static::class, 'do_action_or_reschedule' ), 10, $method->getNumberOfParameters() ); } } /** * Get queue instance. * * @return WC_Queue_Interface */ public static function queue() { if ( is_null( self::$queue ) ) { self::$queue = WC()->queue(); } return self::$queue; } /** * Set queue instance. * * @param WC_Queue_Interface $queue Queue instance. */ public static function set_queue( $queue ) { self::$queue = $queue; } /** * Gets the default scheduler actions for batching and scheduling actions. */ public static function get_default_scheduler_actions() { return array( 'schedule_action' => 'wc-admin_schedule_action_' . static::$name, 'queue_batches' => 'wc-admin_queue_batches_' . static::$name, ); } /** * Gets the actions for this specific scheduler. * * @return array */ public static function get_scheduler_actions() { return array(); } /** * Get all available scheduling actions. * Used to determine action hook names and clear events. */ public static function get_actions() { return array_merge( static::get_default_scheduler_actions(), static::get_scheduler_actions() ); } /** * Get an action tag name from the action name. * * @param string $action_name The action name. * @return string|null */ public static function get_action( $action_name ) { $actions = static::get_actions(); return isset( $actions[ $action_name ] ) ? $actions[ $action_name ] : null; } /** * Returns an array of actions and dependencies as key => value pairs. * * @return array */ public static function get_dependencies() { return array(); } /** * Get dependencies associated with an action. * * @param string $action_name The action slug. * @return string|null */ public static function get_dependency( $action_name ) { $dependencies = static::get_dependencies(); return isset( $dependencies[ $action_name ] ) ? $dependencies[ $action_name ] : null; } /** * Batch action size. */ public static function get_batch_sizes() { return array( 'queue_batches' => 100, ); } /** * Returns the batch size for an action. * * @param string $action Single batch action name. * @return int Batch size. */ public static function get_batch_size( $action ) { $batch_sizes = static::get_batch_sizes(); $batch_size = isset( $batch_sizes[ $action ] ) ? $batch_sizes[ $action ] : 25; /** * Filter the batch size for regenerating a report table. * * @param int $batch_size Batch size. * @param string $action Batch action name. */ return apply_filters( 'woocommerce_analytics_regenerate_batch_size', $batch_size, static::$name, $action ); } /** * Flatten multidimensional arrays to store for scheduling. * * @param array $args Argument array. * @return string */ public static function flatten_args( $args ) { $flattened = array(); foreach ( $args as $arg ) { if ( is_array( $arg ) ) { $flattened[] = self::flatten_args( $arg ); } else { $flattened[] = $arg; } } $string = '[' . implode( ',', $flattened ) . ']'; return $string; } /** * Check if existing jobs exist for an action and arguments. * * @param string $action_name Action name. * @param array $args Array of arguments to pass to action. * @return bool */ public static function has_existing_jobs( $action_name, $args ) { $existing_jobs = self::queue()->search( array( 'status' => 'pending', 'per_page' => 1, 'claimed' => false, 'hook' => static::get_action( $action_name ), 'search' => self::flatten_args( $args ), 'group' => self::$group, ) ); if ( $existing_jobs ) { $existing_job = current( $existing_jobs ); // Bail out if there's a pending single action, or a pending scheduled actions. if ( ( static::get_action( $action_name ) === $existing_job->get_hook() ) || ( static::get_action( 'schedule_action' ) === $existing_job->get_hook() && in_array( self::get_action( $action_name ), $existing_job->get_args(), true ) ) ) { return true; } } return false; } /** * Get the next blocking job for an action. * * @param string $action_name Action name. * @return false|ActionScheduler_Action */ public static function get_next_blocking_job( $action_name ) { $dependency = self::get_dependency( $action_name ); if ( ! $dependency ) { return false; } $blocking_jobs = self::queue()->search( array( 'status' => 'pending', 'orderby' => 'date', 'order' => 'DESC', 'per_page' => 1, 'search' => $dependency, // search is used instead of hook to find queued batch creation. 'group' => static::$group, ) ); return reset( $blocking_jobs ); } /** * Check for blocking jobs and reschedule if any exist. */ public static function do_action_or_reschedule() { $action_hook = current_action(); $action_name = array_search( $action_hook, static::get_actions(), true ); $args = func_get_args(); // Check if any blocking jobs exist and schedule after they've completed // or schedule to run now if no blocking jobs exist. $blocking_job = static::get_next_blocking_job( $action_name ); if ( $blocking_job ) { $next_action_time = self::get_next_action_time( $blocking_job ); // Some actions, like single actions, don't have a next action time. if ( ! is_a( $next_action_time, 'DateTime' ) ) { $next_action_time = new \DateTime(); } self::queue()->schedule_single( $next_action_time->getTimestamp() + 5, $action_hook, $args, static::$group ); } else { call_user_func_array( array( static::class, $action_name ), $args ); } } /** * Get the DateTime for the next scheduled time an action should run. * This function allows backwards compatibility with Action Scheduler < v3.0. * * @param \ActionScheduler_Action $action Action. * @return DateTime|null */ public static function get_next_action_time( $action ) { if ( method_exists( $action->get_schedule(), 'get_next' ) ) { $after = new \DateTime(); $next_job_schedule = $action->get_schedule()->get_next( $after ); } else { $next_job_schedule = $action->get_schedule()->next(); } return $next_job_schedule; } /** * Schedule an action to run and check for dependencies. * * @param string $action_name Action name. * @param array $args Array of arguments to pass to action. */ public static function schedule_action( $action_name, $args = array() ) { // Check for existing jobs and bail if they already exist. if ( static::has_existing_jobs( $action_name, $args ) ) { return; } $action_hook = static::get_action( $action_name ); if ( ! $action_hook ) { return; } if ( // Skip scheduling if Action Scheduler tables have not been initialized. ! get_option( 'schema-ActionScheduler_StoreSchema' ) || apply_filters( 'woocommerce_analytics_disable_action_scheduling', false ) ) { call_user_func_array( array( static::class, $action_name ), $args ); return; } self::queue()->schedule_single( time() + 5, $action_hook, $args, static::$group ); } /** * Queue a large number of batch jobs, respecting the batch size limit. * Reduces a range of batches down to "single batch" jobs. * * @param int $range_start Starting batch number. * @param int $range_end Ending batch number. * @param string $single_batch_action Action to schedule for a single batch. * @param array $action_args Action arguments. * @return void */ public static function queue_batches( $range_start, $range_end, $single_batch_action, $action_args = array() ) { $batch_size = static::get_batch_size( 'queue_batches' ); $range_size = 1 + ( $range_end - $range_start ); $action_timestamp = time() + 5; if ( $range_size > $batch_size ) { // If the current batch range is larger than a single batch, // split the range into $queue_batch_size chunks. $chunk_size = (int) ceil( $range_size / $batch_size ); for ( $i = 0; $i < $batch_size; $i++ ) { $batch_start = (int) ( $range_start + ( $i * $chunk_size ) ); $batch_end = (int) min( $range_end, $range_start + ( $chunk_size * ( $i + 1 ) ) - 1 ); if ( $batch_start > $range_end ) { return; } self::schedule_action( 'queue_batches', array( $batch_start, $batch_end, $single_batch_action, $action_args ) ); } } else { // Otherwise, queue the single batches. for ( $i = $range_start; $i <= $range_end; $i++ ) { $batch_action_args = array_merge( array( $i ), $action_args ); self::schedule_action( $single_batch_action, $batch_action_args ); } } } /** * Clears all queued actions. */ public static function clear_queued_actions() { if ( version_compare( \ActionScheduler_Versions::instance()->latest_version(), '3.0', '>=' ) ) { \ActionScheduler::store()->cancel_actions_by_group( static::$group ); } else { $actions = static::get_actions(); foreach ( $actions as $action ) { self::queue()->cancel_all( $action, null, static::$group ); } } } } Notes/Note.php 0000777 00000046527 15252240713 0007300 0 ustar 00 <?php /** * WooCommerce Admin (Dashboard) Notes. * * The WooCommerce admin notes class gets admin notes data from storage and checks validity. */ namespace Automattic\WooCommerce\Admin\Notes; defined( 'ABSPATH' ) || exit; /** * Note class. */ class Note extends \WC_Data { // Note types. const E_WC_ADMIN_NOTE_ERROR = 'error'; // used for presenting error conditions. const E_WC_ADMIN_NOTE_WARNING = 'warning'; // used for presenting warning conditions. const E_WC_ADMIN_NOTE_UPDATE = 'update'; // i.e. used when a new version is available. const E_WC_ADMIN_NOTE_INFORMATIONAL = 'info'; // used for presenting informational messages. const E_WC_ADMIN_NOTE_MARKETING = 'marketing'; // used for adding marketing messages. const E_WC_ADMIN_NOTE_SURVEY = 'survey'; // used for adding survey messages. const E_WC_ADMIN_NOTE_EMAIL = 'email'; // used for adding notes that will be sent by email. // Note status codes. const E_WC_ADMIN_NOTE_PENDING = 'pending'; // the note is pending - hidden but not actioned. const E_WC_ADMIN_NOTE_UNACTIONED = 'unactioned'; // the note has not yet been actioned by a user. const E_WC_ADMIN_NOTE_ACTIONED = 'actioned'; // the note has had its action completed by a user. const E_WC_ADMIN_NOTE_SNOOZED = 'snoozed'; // the note has been snoozed by a user. const E_WC_ADMIN_NOTE_SENT = 'sent'; // the note has been sent by email to the user. /** * This is the name of this object type. * * @var string */ protected $object_type = 'admin-note'; /** * Cache group. * * @var string */ protected $cache_group = 'admin-note'; /** * Note constructor. Loads note data. * * @param mixed $data Note data, object, or ID. */ public function __construct( $data = '' ) { // Set default data here to allow `content_data` to be an object. $this->data = array( 'name' => '-', 'type' => self::E_WC_ADMIN_NOTE_INFORMATIONAL, 'locale' => 'en_US', 'title' => '-', 'content' => '-', 'content_data' => new \stdClass(), 'status' => self::E_WC_ADMIN_NOTE_UNACTIONED, 'source' => 'woocommerce', 'date_created' => '0000-00-00 00:00:00', 'date_reminder' => null, 'is_snoozable' => false, 'actions' => array(), 'layout' => 'plain', 'image' => '', 'is_deleted' => false, 'is_read' => false, ); parent::__construct( $data ); if ( $data instanceof Note ) { $this->set_id( absint( $data->get_id() ) ); } elseif ( is_numeric( $data ) ) { $this->set_id( $data ); } elseif ( is_object( $data ) && ! empty( $data->note_id ) ) { $this->set_id( $data->note_id ); unset( $data->icon ); // Icons are deprecated. $this->set_props( (array) $data ); $this->set_object_read( true ); } else { $this->set_object_read( true ); } $this->data_store = Notes::load_data_store(); if ( $this->get_id() > 0 ) { $this->data_store->read( $this ); } } /** * Merge changes with data and clear. * * @since 3.0.0 */ public function apply_changes() { $this->data = array_replace_recursive( $this->data, $this->changes ); // @codingStandardsIgnoreLine // Note actions need to be replaced wholesale. // Merging arrays doesn't allow for deleting note actions. if ( isset( $this->changes['actions'] ) ) { $this->data['actions'] = $this->changes['actions']; } $this->changes = array(); } /* |-------------------------------------------------------------------------- | Helpers |-------------------------------------------------------------------------- | | Methods for getting allowed types, statuses. | */ /** * Get deprecated types. * * @return array */ public static function get_deprecated_types() { return array( self::E_WC_ADMIN_NOTE_EMAIL, ); } /** * Get allowed types. * * @return array */ public static function get_allowed_types() { $allowed_types = array( self::E_WC_ADMIN_NOTE_ERROR, self::E_WC_ADMIN_NOTE_WARNING, self::E_WC_ADMIN_NOTE_UPDATE, self::E_WC_ADMIN_NOTE_INFORMATIONAL, self::E_WC_ADMIN_NOTE_MARKETING, self::E_WC_ADMIN_NOTE_SURVEY, ); return apply_filters( 'woocommerce_note_types', $allowed_types ); } /** * Get allowed statuses. * * @return array */ public static function get_allowed_statuses() { $allowed_statuses = array( self::E_WC_ADMIN_NOTE_PENDING, self::E_WC_ADMIN_NOTE_ACTIONED, self::E_WC_ADMIN_NOTE_UNACTIONED, self::E_WC_ADMIN_NOTE_SNOOZED, self::E_WC_ADMIN_NOTE_SENT, ); return apply_filters( 'woocommerce_note_statuses', $allowed_statuses ); } /* |-------------------------------------------------------------------------- | Getters |-------------------------------------------------------------------------- | | Methods for getting data from the note object. | */ /** * Returns all data for this object. * * Override \WC_Data::get_data() to avoid errantly including meta data * from ID collisions with the posts table. * * @return array */ public function get_data() { return array_merge( array( 'id' => $this->get_id() ), $this->data ); } /** * Get note name. * * @param string $context What the value is for. Valid values are 'view' and 'edit'. * @return string */ public function get_name( $context = 'view' ) { return $this->get_prop( 'name', $context ); } /** * Get note type. * * @param string $context What the value is for. Valid values are 'view' and 'edit'. * @return string */ public function get_type( $context = 'view' ) { return $this->get_prop( 'type', $context ); } /** * Get note locale. * * @param string $context What the value is for. Valid values are 'view' and 'edit'. * @return string */ public function get_locale( $context = 'view' ) { return $this->get_prop( 'locale', $context ); } /** * Get note title. * * @param string $context What the value is for. Valid values are 'view' and 'edit'. * @return string */ public function get_title( $context = 'view' ) { return $this->get_prop( 'title', $context ); } /** * Get note content. * * @param string $context What the value is for. Valid values are 'view' and 'edit'. * @return string */ public function get_content( $context = 'view' ) { return $this->get_prop( 'content', $context ); } /** * Get note content data (i.e. values that would be needed for re-localization) * * @param string $context What the value is for. Valid values are 'view' and 'edit'. * @return object */ public function get_content_data( $context = 'view' ) { return $this->get_prop( 'content_data', $context ); } /** * Get note status. * * @param string $context What the value is for. Valid values are 'view' and 'edit'. * @return string */ public function get_status( $context = 'view' ) { return $this->get_prop( 'status', $context ); } /** * Get note source. * * @param string $context What the value is for. Valid values are 'view' and 'edit'. * @return string */ public function get_source( $context = 'view' ) { return $this->get_prop( 'source', $context ); } /** * Get date note was created. * * @param string $context What the value is for. Valid values are 'view' and 'edit'. * @return WC_DateTime|NULL 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 date on which user should be reminded of the note (if any). * * @param string $context What the value is for. Valid values are 'view' and 'edit'. * @return WC_DateTime|NULL object if the date is set or null if there is no date. */ public function get_date_reminder( $context = 'view' ) { return $this->get_prop( 'date_reminder', $context ); } /** * Get note snoozability. * * @param string $context What the value is for. Valid values are 'view' and 'edit'. * @return bool Whether or not the note can be snoozed. */ public function get_is_snoozable( $context = 'view' ) { return $this->get_prop( 'is_snoozable', $context ); } /** * Get actions on the note (if any). * * @param string $context What the value is for. Valid values are 'view' and 'edit'. * @return array */ public function get_actions( $context = 'view' ) { return $this->get_prop( 'actions', $context ); } /** * Get action by action name on the note. * * @param string $action_name The action name. * @param string $context What the value is for. Valid values are 'view' and 'edit'. * @return object the action. */ public function get_action( $action_name, $context = 'view' ) { $actions = $this->get_prop( 'actions', $context ); $matching_action = null; foreach ( $actions as $i => $action ) { if ( $action->name === $action_name ) { $matching_action =& $actions[ $i ]; break; } } return $matching_action; } /** * Get note layout (the old notes won't have one). * * @param string $context What the value is for. Valid values are 'view' and 'edit'. * @return array */ public function get_layout( $context = 'view' ) { return $this->get_prop( 'layout', $context ); } /** * Get note image (if any). * * @param string $context What the value is for. Valid values are 'view' and 'edit'. * @return array */ public function get_image( $context = 'view' ) { return $this->get_prop( 'image', $context ); } /** * Get deleted status. * * @param string $context What the value is for. Valid values are 'view' and 'edit'. * @return bool */ public function get_is_deleted( $context = 'view' ) { return $this->get_prop( 'is_deleted', $context ); } /** * Get is_read status. * * @param string $context What the value is for. Valid values are 'view' and 'edit'. * @return bool */ public function get_is_read( $context = 'view' ) { return $this->get_prop( 'is_read', $context ); } /* |-------------------------------------------------------------------------- | Setters |-------------------------------------------------------------------------- | | Methods for setting note data. These should not update anything in the | database itself and should only change what is stored in the class | object. | */ /** * Set note name. * * @param string $name Note name. */ public function set_name( $name ) { // Don't allow empty names. if ( empty( $name ) ) { $this->error( 'admin_note_invalid_data', __( 'The admin note name prop cannot be empty.', 'woocommerce' ) ); } $this->set_prop( 'name', $name ); } /** * Set note type. * * @param string $type Note type. */ public function set_type( $type ) { if ( empty( $type ) ) { $this->error( 'admin_note_invalid_data', __( 'The admin note type prop cannot be empty.', 'woocommerce' ) ); } if ( in_array( $type, self::get_deprecated_types(), true ) ) { $this->error( 'admin_note_invalid_data', __( 'The admin note type prop is deprecated.', 'woocommerce' ) ); } if ( ! in_array( $type, self::get_allowed_types(), true ) ) { $this->error( 'admin_note_invalid_data', sprintf( /* translators: %s: admin note type. */ __( 'The admin note type prop (%s) is not one of the supported types.', 'woocommerce' ), $type ) ); } $this->set_prop( 'type', $type ); } /** * Set note locale. * * @param string $locale Note locale. */ public function set_locale( $locale ) { if ( empty( $locale ) ) { $this->error( 'admin_note_invalid_data', __( 'The admin note locale prop cannot be empty.', 'woocommerce' ) ); } $this->set_prop( 'locale', $locale ); } /** * Set note title. * * @param string $title Note title. */ public function set_title( $title ) { if ( empty( $title ) ) { $this->error( 'admin_note_invalid_data', __( 'The admin note title prop cannot be empty.', 'woocommerce' ) ); } $this->set_prop( 'title', $title ); } /** * Set note icon (Deprecated). * * @param string $icon Note icon. */ public function set_icon( $icon ) { wc_deprecated_function( 'set_icon', '4.3' ); } /** * Set note content. * * @param string $content Note content. */ public function set_content( $content ) { $allowed_html = array( 'br' => array(), 'em' => array(), 'strong' => array(), 'a' => array( 'href' => true, 'rel' => true, 'name' => true, 'target' => true, 'download' => array( 'valueless' => 'y', ), ), 'p' => array(), ); $content = wp_kses( $content, $allowed_html ); if ( empty( $content ) ) { $this->error( 'admin_note_invalid_data', __( 'The admin note content prop cannot be empty.', 'woocommerce' ) ); } $this->set_prop( 'content', $content ); } /** * Set note data for potential re-localization. * * @todo Set a default empty array? https://github.com/woocommerce/woocommerce-admin/pull/1763#pullrequestreview-212442921. * @param object $content_data Note data. */ public function set_content_data( $content_data ) { $allowed_type = false; // Make sure $content_data is stdClass Object or an array. if ( ! ( $content_data instanceof \stdClass ) ) { $this->error( 'admin_note_invalid_data', __( 'The admin note content_data prop must be an instance of stdClass.', 'woocommerce' ) ); } $this->set_prop( 'content_data', $content_data ); } /** * Set note status. * * @param string $status Note status. */ public function set_status( $status ) { if ( empty( $status ) ) { $this->error( 'admin_note_invalid_data', __( 'The admin note status prop cannot be empty.', 'woocommerce' ) ); } if ( ! in_array( $status, self::get_allowed_statuses(), true ) ) { $this->error( 'admin_note_invalid_data', sprintf( /* translators: %s: admin note status property. */ __( 'The admin note status prop (%s) is not one of the supported statuses.', 'woocommerce' ), $status ) ); } $this->set_prop( 'status', $status ); } /** * Set note source. * * @param string $source Note source. */ public function set_source( $source ) { if ( empty( $source ) ) { $this->error( 'admin_note_invalid_data', __( 'The admin note source prop cannot be empty.', 'woocommerce' ) ); } $this->set_prop( 'source', $source ); } /** * Set date note was created. NULL is not allowed * * @param string|integer $date UTC timestamp, or ISO 8601 DateTime. If the DateTime string has no timezone or offset, WordPress site timezone will be assumed. */ public function set_date_created( $date ) { if ( empty( $date ) ) { $this->error( 'admin_note_invalid_data', __( 'The admin note date prop cannot be empty.', 'woocommerce' ) ); } if ( is_string( $date ) && ! is_numeric( $date ) ) { $date = wc_string_to_timestamp( $date ); } $this->set_date_prop( 'date_created', $date ); } /** * Set date admin should be reminded of note. NULL IS allowed * * @param string|integer|null $date UTC timestamp, or ISO 8601 DateTime. If the DateTime string has no timezone or offset, WordPress site timezone will be assumed. Null if there is no date. */ public function set_date_reminder( $date ) { if ( is_string( $date ) && ! is_numeric( $date ) ) { $date = wc_string_to_timestamp( $date ); } $this->set_date_prop( 'date_reminder', $date ); } /** * Set note snoozability. * * @param bool $is_snoozable Whether or not the note can be snoozed. */ public function set_is_snoozable( $is_snoozable ) { return $this->set_prop( 'is_snoozable', $is_snoozable ); } /** * Clear actions from a note. */ public function clear_actions() { $this->set_prop( 'actions', array() ); } /** * Set note layout. * * @param string $layout Note layout. */ public function set_layout( $layout ) { // If we don't receive a layout we will set it by default as "plain". if ( empty( $layout ) ) { $layout = 'plain'; } $valid_layouts = array( 'plain', 'thumbnail' ); if ( in_array( $layout, $valid_layouts, true ) ) { $this->set_prop( 'layout', $layout ); } else { $this->error( 'admin_note_invalid_data', __( 'The admin note layout has a wrong prop value.', 'woocommerce' ) ); } } /** * Set note image. * * @param string $image Note image. */ public function set_image( $image ) { $this->set_prop( 'image', $image ); } /** * Set note deleted status. NULL is not allowed * * @param bool $is_deleted Note deleted status. */ public function set_is_deleted( $is_deleted ) { $this->set_prop( 'is_deleted', $is_deleted ); } /** * Set note is_read status. NULL is not allowed * * @param bool $is_read Note is_read status. */ public function set_is_read( $is_read ) { $this->set_prop( 'is_read', $is_read ); } /** * Add an action to the note * * @param string $name Action name (not presented to user). * @param string $label Action label (presented as button label). * @param string $url Action URL, if navigation needed. Optional. * @param string $status Status to transition parent Note to upon click. Defaults to 'actioned'. * @param boolean $primary Deprecated since version 3.4.0. * @param string $actioned_text The label to display after the note has been actioned but before it is dismissed in the UI. */ public function add_action( $name, $label, $url = '', $status = self::E_WC_ADMIN_NOTE_ACTIONED, $primary = false, $actioned_text = '' ) { $name = wc_clean( $name ); $label = wc_clean( $label ); $query = esc_url_raw( $url ); $status = wc_clean( $status ); $actioned_text = wc_clean( $actioned_text ); if ( empty( $name ) ) { $this->error( 'admin_note_invalid_data', __( 'The admin note action name prop cannot be empty.', 'woocommerce' ) ); } if ( empty( $label ) ) { $this->error( 'admin_note_invalid_data', __( 'The admin note action label prop cannot be empty.', 'woocommerce' ) ); } $action = array( 'name' => $name, 'label' => $label, 'query' => $query, 'status' => $status, 'actioned_text' => $actioned_text, 'nonce_name' => null, 'nonce_action' => null, ); $note_actions = $this->get_prop( 'actions', 'edit' ); $note_actions[] = (object) $action; $this->set_prop( 'actions', $note_actions ); } /** * Set actions on a note. * * @param array $actions Note actions. */ public function set_actions( $actions ) { $this->set_prop( 'actions', $actions ); } /** * Add a nonce to an existing note action. * * @link https://codex.wordpress.org/WordPress_Nonces * * @param string $note_action_name Name of action to add a nonce to. * @param string $nonce_action The nonce action. * @param string $nonce_name The nonce Name. This is used as the parameter name in the resulting URL for the action. * @return void * @throws \Exception If note name cannot be found. */ public function add_nonce_to_action( string $note_action_name, string $nonce_action, string $nonce_name ) { $actions = $this->get_prop( 'actions', 'edit' ); $matching_action = null; foreach ( $actions as $i => $action ) { if ( $action->name === $note_action_name ) { $matching_action =& $actions[ $i ]; } } if ( empty( $matching_action ) ) { throw new \Exception( sprintf( 'Could not find action %s in note %s', $note_action_name, $this->get_name() ) ); } $matching_action->nonce_action = $nonce_action; $matching_action->nonce_name = $nonce_name; $this->set_actions( $actions ); } } Notes/DeprecatedNotes.php 0000777 00000033514 15252240713 0011434 0 ustar 00 <?php /** * Define deprecated classes to support changing the naming convention of * admin notes. */ namespace Automattic\WooCommerce\Admin\Notes; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\DeprecatedClassFacade; // phpcs:disable Generic.Files.OneObjectStructurePerFile.MultipleFound /** * WC_Admin_Note. * * @deprecated since 4.8.0, use Note */ class WC_Admin_Note extends DeprecatedClassFacade { // These constants must be redeclared as to not break plugins that use them. const E_WC_ADMIN_NOTE_ERROR = Note::E_WC_ADMIN_NOTE_ERROR; const E_WC_ADMIN_NOTE_WARNING = Note::E_WC_ADMIN_NOTE_WARNING; const E_WC_ADMIN_NOTE_UPDATE = Note::E_WC_ADMIN_NOTE_UPDATE; const E_WC_ADMIN_NOTE_INFORMATIONAL = Note::E_WC_ADMIN_NOTE_INFORMATIONAL; const E_WC_ADMIN_NOTE_MARKETING = Note::E_WC_ADMIN_NOTE_MARKETING; const E_WC_ADMIN_NOTE_SURVEY = Note::E_WC_ADMIN_NOTE_SURVEY; const E_WC_ADMIN_NOTE_PENDING = Note::E_WC_ADMIN_NOTE_PENDING; const E_WC_ADMIN_NOTE_UNACTIONED = Note::E_WC_ADMIN_NOTE_UNACTIONED; const E_WC_ADMIN_NOTE_ACTIONED = Note::E_WC_ADMIN_NOTE_ACTIONED; const E_WC_ADMIN_NOTE_SNOOZED = Note::E_WC_ADMIN_NOTE_SNOOZED; const E_WC_ADMIN_NOTE_EMAIL = Note::E_WC_ADMIN_NOTE_EMAIL; /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Admin\Notes\Note'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; /** * Note constructor. Loads note data. * * @param mixed $data Note data, object, or ID. */ public function __construct( $data = '' ) { $this->instance = new static::$facade_over_classname( $data ); } } /** * WC_Admin_Notes. * * @deprecated since 4.8.0, use Notes */ class WC_Admin_Notes extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Admin\Notes\Notes'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_Customize_Store_With_Blocks. * * @deprecated since 4.8.0, use CustomizeStoreWithBlocks */ class WC_Admin_Notes_Customize_Store_With_Blocks extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\CustomizeStoreWithBlocks'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_Edit_Products_On_The_Move. * * @deprecated since 4.8.0, use EditProductsOnTheMove */ class WC_Admin_Notes_Edit_Products_On_The_Move extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\EditProductsOnTheMove'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_EU_VAT_Number. * * @deprecated since 4.8.0, use EUVATNumber */ class WC_Admin_Notes_EU_VAT_Number extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\EUVATNumber'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_Facebook_Marketing_Expert. * * @deprecated since 4.8.0, use FacebookMarketingExpert */ class WC_Admin_Notes_Facebook_Marketing_Expert extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Admin\Notes\FacebookMarketingExpert'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_First_Product. * * @deprecated since 4.8.0, use FirstProduct */ class WC_Admin_Notes_First_Product extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\FirstProduct'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_Giving_Feedback_Notes. * * @deprecated since 4.8.0, use GivingFeedbackNotes */ class WC_Admin_Notes_Giving_Feedback_Notes extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\GivingFeedbackNotes'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_Install_JP_And_WCS_Plugins. * * @deprecated since 4.8.0, use InstallJPAndWCSPlugins */ class WC_Admin_Notes_Install_JP_And_WCS_Plugins extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\InstallJPAndWCSPlugins'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_Launch_Checklist. * * @deprecated since 4.8.0, use LaunchChecklist */ class WC_Admin_Notes_Launch_Checklist extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\LaunchChecklist'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_Migrate_From_Shopify. * * @deprecated since 4.8.0, use MigrateFromShopify */ class WC_Admin_Notes_Migrate_From_Shopify extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\MigrateFromShopify'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_Mobile_App. * * @deprecated since 4.8.0, use MobileApp */ class WC_Admin_Notes_Mobile_App extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\MobileApp'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_New_Sales_Record. * * @deprecated since 4.8.0, use NewSalesRecord */ class WC_Admin_Notes_New_Sales_Record extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\NewSalesRecord'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_Onboarding_Email_Marketing. * * @deprecated since 4.8.0, use OnboardingEmailMarketing */ class WC_Admin_Notes_Onboarding_Email_Marketing extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Admin\Notes\OnboardingEmailMarketing'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_Onboarding_Payments. * * @deprecated since 4.8.0, use OnboardingPayments */ class WC_Admin_Notes_Onboarding_Payments extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\OnboardingPayments'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_Online_Clothing_Store. * * @deprecated since 4.8.0, use OnlineClothingStore */ class WC_Admin_Notes_Online_Clothing_Store extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\OnlineClothingStore'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_Order_Milestones. * * @deprecated since 4.8.0, use OrderMilestones */ class WC_Admin_Notes_Order_Milestones extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\OrderMilestones'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_Performance_On_Mobile. * * @deprecated since 4.8.0, use PerformanceOnMobile */ class WC_Admin_Notes_Performance_On_Mobile extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\PerformanceOnMobile'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_Personalize_Store. * * @deprecated since 4.8.0, use PersonalizeStore */ class WC_Admin_Notes_Personalize_Store extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\PersonalizeStore'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_Real_Time_Order_Alerts. * * @deprecated since 4.8.0, use RealTimeOrderAlerts */ class WC_Admin_Notes_Real_Time_Order_Alerts extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\RealTimeOrderAlerts'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_Selling_Online_Courses. * * @deprecated since 4.8.0, use SellingOnlineCourses */ class WC_Admin_Notes_Selling_Online_Courses extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\SellingOnlineCourses'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_Tracking_Opt_In. * * @deprecated since 4.8.0, use TrackingOptIn */ class WC_Admin_Notes_Tracking_Opt_In extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\TrackingOptIn'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_Woo_Subscriptions_Notes. * * @deprecated since 4.8.0, use WooSubscriptionsNotes */ class WC_Admin_Notes_Woo_Subscriptions_Notes extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\WooSubscriptionsNotes'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_WooCommerce_Payments. * * @deprecated since 4.8.0, use WooCommercePayments */ class WC_Admin_Notes_WooCommerce_Payments extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\WooCommercePayments'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } /** * WC_Admin_Notes_WooCommerce_Subscriptions. * * @deprecated since 4.8.0, use WooCommerceSubscriptions */ class WC_Admin_Notes_WooCommerce_Subscriptions extends DeprecatedClassFacade { /** * The name of the non-deprecated class that this facade covers. * * @var string */ protected static $facade_over_classname = 'Automattic\WooCommerce\Internal\Admin\Notes\WooCommerceSubscriptions'; /** * The version that this class was deprecated in. * * @var string */ protected static $deprecated_in_version = '4.8.0'; } Notes/Notes.php 0000777 00000033646 15252240713 0007461 0 ustar 00 <?php /** * Handles storage and retrieval of admin notes */ namespace Automattic\WooCommerce\Admin\Notes; use WC_Site_Tracking; if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Admin Notes class. */ class Notes { /** * Hook used for recurring "unsnooze" action. */ const UNSNOOZE_HOOK = 'wc_admin_unsnooze_admin_notes'; /** * Hook appropriate actions. */ public static function init() { add_action( 'admin_init', array( __CLASS__, 'schedule_unsnooze_notes' ) ); add_action( 'admin_init', array( __CLASS__, 'possibly_delete_survey_notes' ) ); add_action( 'update_option_woocommerce_show_marketplace_suggestions', array( __CLASS__, 'possibly_delete_marketing_notes' ), 10, 2 ); add_action( self::UNSNOOZE_HOOK, array( __CLASS__, 'unsnooze_notes' ) ); } /** * Get notes from the database. * * @param string $context Getting notes for what context. Valid values: view, edit. * @param array $args Arguments to pass to the query( e.g. per_page and page). * @return array Array of arrays. */ public static function get_notes( $context = 'edit', $args = array() ) { $data_store = self::load_data_store(); $raw_notes = $data_store->get_notes( $args ); $notes = array(); foreach ( (array) $raw_notes as $raw_note ) { try { $note = new Note( $raw_note ); /** * Filter the note from db. This is used to modify the note before it is returned. * * @since 6.9.0 * @param Note $note The note object from the database. */ $note = apply_filters( 'woocommerce_get_note_from_db', $note ); $note_id = $note->get_id(); $notes[ $note_id ] = $note->get_data(); $notes[ $note_id ]['name'] = $note->get_name( $context ); $notes[ $note_id ]['type'] = $note->get_type( $context ); $notes[ $note_id ]['locale'] = $note->get_locale( $context ); $notes[ $note_id ]['title'] = $note->get_title( $context ); $notes[ $note_id ]['content'] = $note->get_content( $context ); $notes[ $note_id ]['content_data'] = $note->get_content_data( $context ); $notes[ $note_id ]['status'] = $note->get_status( $context ); $notes[ $note_id ]['source'] = $note->get_source( $context ); $notes[ $note_id ]['date_created'] = $note->get_date_created( $context ); $notes[ $note_id ]['date_reminder'] = $note->get_date_reminder( $context ); $notes[ $note_id ]['actions'] = $note->get_actions( $context ); $notes[ $note_id ]['layout'] = $note->get_layout( $context ); $notes[ $note_id ]['image'] = $note->get_image( $context ); $notes[ $note_id ]['is_deleted'] = $note->get_is_deleted( $context ); } catch ( \Exception $e ) { wc_caught_exception( $e, __CLASS__ . '::' . __FUNCTION__, array( $note_id ) ); } } return $notes; } /** * Get admin note using it's ID * * @param int $note_id Note ID. * @return Note|bool */ public static function get_note( $note_id ) { if ( false !== $note_id ) { try { return new Note( $note_id ); } catch ( \Exception $e ) { wc_caught_exception( $e, __CLASS__ . '::' . __FUNCTION__, array( $note_id ) ); return false; } } return false; } /** * Get admin note using its name. * * This is a shortcut for the common pattern of looking up note ids by name and then passing the first id to get_note(). * It will behave unpredictably when more than one note with the given name exists. * * @param string $note_name Note name. * @return Note|bool **/ public static function get_note_by_name( $note_name ) { $data_store = self::load_data_store(); $note_ids = $data_store->get_notes_with_name( $note_name ); if ( empty( $note_ids ) ) { return false; } return self::get_note( $note_ids[0] ); } /** * Get the total number of notes * * @param string $type Comma separated list of note types. * @param string $status Comma separated list of statuses. * @return int */ public static function get_notes_count( $type = array(), $status = array() ) { $data_store = self::load_data_store(); return $data_store->get_notes_count( $type, $status ); } /** * Deletes admin notes with a given name. * * @param string|array $names Name(s) to search for. */ public static function delete_notes_with_name( $names ) { if ( is_string( $names ) ) { $names = array( $names ); } elseif ( ! is_array( $names ) ) { return; } $data_store = self::load_data_store(); foreach ( $names as $name ) { $note_ids = $data_store->get_notes_with_name( $name ); foreach ( (array) $note_ids as $note_id ) { $note = self::get_note( $note_id ); if ( $note ) { $note->delete(); } } } } /** * Update a note. * * @param Note $note The note that will be updated. * @param array $requested_updates a list of requested updates. */ public static function update_note( $note, $requested_updates ) { $note_changed = false; if ( isset( $requested_updates['status'] ) ) { $note->set_status( $requested_updates['status'] ); $note_changed = true; } if ( isset( $requested_updates['date_reminder'] ) ) { $note->set_date_reminder( $requested_updates['date_reminder'] ); $note_changed = true; } if ( isset( $requested_updates['is_deleted'] ) ) { $note->set_is_deleted( $requested_updates['is_deleted'] ); $note_changed = true; } if ( isset( $requested_updates['is_read'] ) ) { $note->set_is_read( $requested_updates['is_read'] ); $note_changed = true; } if ( $note_changed ) { $note->save(); } } /** * Soft delete of a note. * * @param Note $note The note that will be deleted. */ public static function delete_note( $note ) { $note->set_is_deleted( 1 ); $note->save(); } /** * Soft delete of all the admin notes. Returns the deleted items. * * @param array $args Arguments to pass to the query (ex: status). * @return array Array of notes. */ public static function delete_all_notes( $args = array() ) { $data_store = self::load_data_store(); $defaults = array( 'order' => 'desc', 'orderby' => 'date_created', 'per_page' => 25, 'page' => 1, 'type' => array( Note::E_WC_ADMIN_NOTE_INFORMATIONAL, Note::E_WC_ADMIN_NOTE_MARKETING, Note::E_WC_ADMIN_NOTE_WARNING, Note::E_WC_ADMIN_NOTE_SURVEY, ), 'is_deleted' => 0, ); $args = wp_parse_args( $args, $defaults ); // Here we filter for the same params we are using to show the note list in client side. $raw_notes = $data_store->get_notes( $args ); $notes = array(); foreach ( (array) $raw_notes as $raw_note ) { $note = self::get_note( $raw_note->note_id ); if ( $note ) { self::delete_note( $note ); array_push( $notes, $note ); } } return $notes; } /** * Clear note snooze status if the reminder date has been reached. */ public static function unsnooze_notes() { $data_store = self::load_data_store(); $raw_notes = $data_store->get_notes( array( 'status' => array( Note::E_WC_ADMIN_NOTE_SNOOZED ), ) ); $now = new \DateTime(); foreach ( $raw_notes as $raw_note ) { $note = self::get_note( $raw_note->note_id ); if ( false === $note ) { continue; } $date_reminder = $note->get_date_reminder( 'edit' ); if ( $date_reminder < $now ) { $note->set_status( Note::E_WC_ADMIN_NOTE_UNACTIONED ); $note->set_date_reminder( null ); $note->save(); } } } /** * Schedule unsnooze notes event. */ public static function schedule_unsnooze_notes() { if ( ! wp_next_scheduled( self::UNSNOOZE_HOOK ) ) { wp_schedule_event( time() + 5, 'hourly', self::UNSNOOZE_HOOK ); } } /** * Unschedule unsnooze notes event. */ public static function clear_queued_actions() { wp_clear_scheduled_hook( self::UNSNOOZE_HOOK ); } /** * Delete marketing notes if marketing has been opted out. * * @param string $old_value Old value. * @param string $value New value. */ public static function possibly_delete_marketing_notes( $old_value, $value ) { if ( 'no' !== $value ) { return; } $data_store = self::load_data_store(); $note_ids = $data_store->get_note_ids_by_type( Note::E_WC_ADMIN_NOTE_MARKETING ); foreach ( $note_ids as $note_id ) { $note = self::get_note( $note_id ); if ( $note ) { $note->delete(); } } } /** * Delete actioned survey notes. */ public static function possibly_delete_survey_notes() { $data_store = self::load_data_store(); $note_ids = $data_store->get_note_ids_by_type( Note::E_WC_ADMIN_NOTE_SURVEY ); foreach ( $note_ids as $note_id ) { $note = self::get_note( $note_id ); if ( $note && ( $note->get_status() === Note::E_WC_ADMIN_NOTE_ACTIONED ) ) { $note->set_is_deleted( 1 ); $note->save(); } } } /** * Get the status of a given note by name. * * @param string $note_name Name of the note. * @return string|bool The note status. */ public static function get_note_status( $note_name ) { $note = self::get_note_by_name( $note_name ); if ( ! $note ) { return false; } return $note->get_status(); } /** * Get action by id. * * @param Note $note The note that has of the action. * @param int $action_id Action ID. * @return object|bool The found action. */ public static function get_action_by_id( $note, $action_id ) { $actions = $note->get_actions( 'edit' ); $found_action = false; foreach ( $actions as $action ) { if ( $action->id === $action_id ) { $found_action = $action; } } return $found_action; } /** * Trigger note action. * * @param Note $note The note that has the triggered action. * @param object $triggered_action The triggered action. * @return Note|bool */ public static function trigger_note_action( $note, $triggered_action ) { /** * Fires when an admin note action is taken. * * @param string $name The triggered action name. * @param Note $note The corresponding Note. */ do_action( 'woocommerce_note_action', $triggered_action->name, $note ); /** * Fires when an admin note action is taken. * For more specific targeting of note actions. * * @param Note $note The corresponding Note. */ do_action( 'woocommerce_note_action_' . $triggered_action->name, $note ); // Update the note with the status for this action. if ( ! empty( $triggered_action->status ) ) { $note->set_status( $triggered_action->status ); } $note->save(); $event_params = array( 'note_name' => $note->get_name(), 'note_type' => $note->get_type(), 'note_title' => $note->get_title(), 'note_content' => $note->get_content(), 'action_name' => $triggered_action->name, 'action_label' => $triggered_action->label, 'screen' => self::get_screen_name(), ); if ( in_array( $note->get_type(), array( 'error', 'update' ), true ) ) { wc_admin_record_tracks_event( 'store_alert_action', $event_params ); } else { self::record_tracks_event_without_cookies( 'inbox_action_click', $event_params ); } return $note; } /** * Record tracks event for a specific user. * * @param int $user_id The user id we want to record for the event. * @param string $event_name Name of the event to record. * @param array $params The params to send to the event recording. */ public static function record_tracks_event_with_user( $user_id, $event_name, $params ) { // We save the current user id to set it back after the event recording. $current_user_id = get_current_user_id(); wp_set_current_user( $user_id ); self::record_tracks_event_without_cookies( $event_name, $params ); wp_set_current_user( $current_user_id ); } /** * Record tracks event without using cookies. * * @param string $event_name Name of the event to record. * @param array $params The params to send to the event recording. */ private static function record_tracks_event_without_cookies( $event_name, $params ) { // We save the cookie to set it back after the event recording. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $anon_id = isset( $_COOKIE['tk_ai'] ) ? $_COOKIE['tk_ai'] : null; unset( $_COOKIE['tk_ai'] ); wc_admin_record_tracks_event( $event_name, $params ); if ( isset( $anon_id ) ) { WC_Site_Tracking::set_tracking_cookie( 'tk_ai', $anon_id ); } } /** * Get screen name. * * @return string The screen name. */ public static function get_screen_name() { $screen_name = ''; if ( isset( $_SERVER['HTTP_REFERER'] ) ) { parse_str( wp_parse_url( $_SERVER['HTTP_REFERER'], PHP_URL_QUERY ), $queries ); // phpcs:ignore sanitization ok. } if ( isset( $queries ) ) { $page = isset( $queries['page'] ) ? $queries['page'] : null; $path = isset( $queries['path'] ) ? $queries['path'] : null; $post_type = isset( $queries['post_type'] ) ? $queries['post_type'] : null; $post = isset( $queries['post'] ) ? get_post_type( $queries['post'] ) : null; } if ( isset( $page ) ) { $current_page = 'wc-admin' === $page ? 'home_screen' : $page; $screen_name = isset( $path ) ? substr( str_replace( '/', '_', $path ), 1 ) : $current_page; } elseif ( isset( $post_type ) ) { $screen_name = $post_type; } elseif ( isset( $post ) ) { $screen_name = $post; } return $screen_name; } /** * Loads the data store. * * If the "admin-note" data store is unavailable, attempts to load it * will result in an exception. * This method catches that exception and throws a custom one instead. * * @return \WC_Data_Store The "admin-note" data store. * @throws NotesUnavailableException Throws exception if data store loading fails. */ public static function load_data_store() { try { return \WC_Data_Store::load( 'admin-note' ); } catch ( \Exception $e ) { throw new NotesUnavailableException( 'woocommerce_admin_notes_unavailable', __( 'Notes are unavailable because the "admin-note" data store cannot be loaded.', 'woocommerce' ) ); } } } Notes/NoteTraits.php 0000777 00000021436 15252240713 0010457 0 ustar 00 <?php /** * WC Admin Note Traits * * WC Admin Note Traits class that houses shared functionality across notes. */ namespace Automattic\WooCommerce\Admin\Notes; use Automattic\WooCommerce\Admin\WCAdminHelper; defined( 'ABSPATH' ) || exit; /** * NoteTraits class. */ trait NoteTraits { /** * Test how long WooCommerce Admin has been active. * * @param int $seconds Time in seconds to check. * @return bool Whether or not WooCommerce admin has been active for $seconds. */ private static function wc_admin_active_for( $seconds ) { return WCAdminHelper::is_wc_admin_active_for( $seconds ); } /** * Test if WooCommerce Admin has been active within a pre-defined range. * * @param string $range range available in WC_ADMIN_STORE_AGE_RANGES. * @param int $custom_start custom start in range. * @return bool Whether or not WooCommerce admin has been active within the range. */ private static function is_wc_admin_active_in_date_range( $range, $custom_start = null ) { return WCAdminHelper::is_wc_admin_active_in_date_range( $range, $custom_start ); } /** * Check if the note has been previously added. * * @return bool * @throws NotesUnavailableException Throws exception when notes are unavailable. */ public static function note_exists(): bool { /** * Data store instance. * * @var DataStore $data_store */ $data_store = Notes::load_data_store(); $note_ids = $data_store->get_notes_with_name( self::NOTE_NAME ); return ! empty( $note_ids ); } /** * Checks if a note can and should be added. * * @return bool * @throws NotesUnavailableException Throws exception when notes are unavailable. */ public static function can_be_added(): bool { $note = self::get_note(); if ( ! $note instanceof Note && ! $note instanceof WC_Admin_Note ) { return false; } if ( self::note_exists() ) { return false; } if ( 'no' === get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) && Note::E_WC_ADMIN_NOTE_MARKETING === $note->get_type() ) { return false; } return true; } /** * Add the note if it passes predefined conditions. * * @return void * @throws NotesUnavailableException Throws exception when notes are unavailable. */ public static function possibly_add_note(): void { $note = self::get_note(); if ( ! self::can_be_added() ) { return; } if ( $note instanceof Note || $note instanceof WC_Admin_Note ) { $note->save(); } } /** * Alias this method for backwards compatibility. * * @return void * @throws NotesUnavailableException Throws exception when notes are unavailable. */ public static function add_note(): void { self::possibly_add_note(); } /** * Should this note exist? (Default implementation is generous. Override as needed.) */ public static function is_applicable() { return true; } /** * Delete this note if it is not applicable, unless has been soft-deleted or actioned already. * * @return void */ public static function delete_if_not_applicable(): void { if ( ! self::is_applicable() ) { /** * Data store instance. * * @var DataStore $data_store */ $data_store = Notes::load_data_store(); $note_ids = $data_store->get_notes_with_name( self::NOTE_NAME ); if ( ! empty( $note_ids ) ) { $note = Notes::get_note( $note_ids[0] ); if ( $note instanceof Note && ! $note->get_is_deleted() && ( Note::E_WC_ADMIN_NOTE_ACTIONED !== $note->get_status() ) ) { self::possibly_delete_note(); } } } } /** * Possibly delete the note, if it exists in the database. Note that this * is a hard delete, for where it doesn't make sense to soft delete or * action the note. * * @return void * @throws NotesUnavailableException Throws exception when notes are unavailable. */ public static function possibly_delete_note(): void { /** * Data store instance. * * @var DataStore $data_store */ $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 instanceof Note ) { $data_store->delete( $note ); } } } /** * Update the note if it passes predefined conditions. * * @return void * @throws NotesUnavailableException Throws exception when notes are unavailable. */ public static function possibly_update_note(): void { $note_in_db = Notes::get_note_by_name( self::NOTE_NAME ); if ( ! $note_in_db instanceof Note ) { return; } // Backwards compatibility for checking if the note class has a get_note method. /** * Backwards compatibility check. * * @phpstan-ignore-next-line */ if ( ! method_exists( self::class, 'get_note' ) ) { return; } $note = self::get_note(); if ( ! $note instanceof Note && ! $note instanceof WC_Admin_Note ) { return; } $need_save = in_array( true, array( self::update_note_field_if_changed( $note_in_db, $note, 'title' ), self::update_note_field_if_changed( $note_in_db, $note, 'content' ), self::update_note_field_if_changed( $note_in_db, $note, 'content_data' ), self::update_note_field_if_changed( $note_in_db, $note, 'type' ), self::update_note_field_if_changed( $note_in_db, $note, 'locale' ), self::update_note_field_if_changed( $note_in_db, $note, 'source' ), self::update_note_field_if_changed( $note_in_db, $note, 'actions' ), ), true ); if ( $need_save ) { $note_in_db->save(); } } /** * Get if the note has been actioned. * * @return bool * @throws NotesUnavailableException Throws exception when notes are unavailable. */ public static function has_note_been_actioned(): bool { /** * Data store instance. * * @var DataStore $data_store */ $data_store = Notes::load_data_store(); $note_ids = $data_store->get_notes_with_name( self::NOTE_NAME ); if ( ! empty( $note_ids ) ) { $note = Notes::get_note( $note_ids[0] ); if ( $note instanceof Note && Note::E_WC_ADMIN_NOTE_ACTIONED === $note->get_status() ) { return true; } } return false; } /** * Update a note field of note1 if it's different from note2 with getter and setter. * * @param Note|WC_Admin_Note $note1 Note to update. * @param Note|WC_Admin_Note $note2 Note to compare against. * @param string $field_name Field to update. * @return bool True if the field was updated. */ private static function update_note_field_if_changed( $note1, $note2, string $field_name ): bool { // We need to serialize the stdObject to compare it. /** * Getter method for note1. * * @var callable $getter1 */ $getter1 = array( $note1, 'get_' . $field_name ); /** * Getter method for note2. * * @var callable $getter2 */ $getter2 = array( $note2, 'get_' . $field_name ); $note1_field_value = self::possibly_convert_object_to_array( call_user_func( $getter1 ) ); $note2_field_value = self::possibly_convert_object_to_array( call_user_func( $getter2 ) ); if ( 'actions' === $field_name ) { // We need to individually compare the action fields because action object from db is different from action object of note. // For example, action object from db has "id". $diff = array_udiff( (array) $note1_field_value, (array) $note2_field_value, function ( $action1, $action2 ): int { /** * First action object. * * @var object{name?: string, label?: string, query?: string} $action1 */ /** * Second action object. * * @var object{name?: string, label?: string, query?: string} $action2 */ if ( isset( $action1->name, $action2->name, $action1->label, $action2->label, $action1->query, $action2->query ) && $action1->name === $action2->name && $action1->label === $action2->label && $action1->query === $action2->query ) { return 0; } return -1; } ); $need_update = count( $diff ) > 0; } else { $need_update = $note1_field_value !== $note2_field_value; } if ( $need_update ) { /** * Getter method for note2 field. * * @var callable $getter2_again */ $getter2_again = array( $note2, 'get_' . $field_name ); /** * Setter method for note1 field. * * @var callable $setter1 */ $setter1 = array( $note1, 'set_' . $field_name ); // Get note2 field again because it may have been changed during the comparison. call_user_func( $setter1, call_user_func( $getter2_again ) ); return true; } return false; } /** * Convert a value to array if it's a stdClass. * * @param mixed $obj variable to convert. * @return mixed */ private static function possibly_convert_object_to_array( $obj ) { if ( $obj instanceof \stdClass ) { return (array) $obj; } return $obj; } } Notes/DataStore.php 0000777 00000043071 15252240713 0010250 0 ustar 00 <?php /** * WC Admin Note Data_Store class file. */ namespace Automattic\WooCommerce\Admin\Notes; defined( 'ABSPATH' ) || exit; /** * WC Admin Note Data Store (Custom Tables) */ class DataStore extends \WC_Data_Store_WP implements \WC_Object_Data_Store_Interface { // Extensions should define their own contexts and use them to avoid applying woocommerce_note_where_clauses when not needed. const WC_ADMIN_NOTE_OPER_GLOBAL = 'global'; /** * Method to create a new note in the database. * * @param Note $note Admin note. */ public function create( &$note ) { $date_created = time(); $note->set_date_created( $date_created ); global $wpdb; $note_to_be_inserted = array( 'name' => $note->get_name(), 'type' => $note->get_type(), 'locale' => $note->get_locale(), 'title' => $note->get_title(), 'content' => $note->get_content(), 'status' => $note->get_status(), 'source' => $note->get_source(), 'is_snoozable' => (int) $note->get_is_snoozable(), 'layout' => $note->get_layout(), 'image' => $note->get_image(), 'is_deleted' => (int) $note->get_is_deleted(), 'is_read' => (int) $note->get_is_read(), ); $note_to_be_inserted['content_data'] = wp_json_encode( $note->get_content_data() ); $note_to_be_inserted['date_created'] = gmdate( 'Y-m-d H:i:s', $date_created ); $note_to_be_inserted['date_reminder'] = null; $wpdb->insert( $wpdb->prefix . 'wc_admin_notes', $note_to_be_inserted ); $note_id = $wpdb->insert_id; $note->set_id( $note_id ); $this->save_actions( $note ); $note->apply_changes(); /** * Fires when an admin note is created. * * @param int $note_id Note ID. */ do_action( 'woocommerce_note_created', $note_id ); } /** * Method to read a note. * * @param Note $note Admin note. * @throws \Exception Throws exception when invalid data is found. */ public function read( &$note ) { global $wpdb; $note->set_defaults(); $note_row = false; $note_id = $note->get_id(); if ( 0 !== $note_id || '0' !== $note_id ) { $note_row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}wc_admin_notes WHERE note_id = %d LIMIT 1", $note->get_id() ) ); } if ( 0 === $note->get_id() || '0' === $note->get_id() ) { $this->read_actions( $note ); $note->set_object_read( true ); /** * Fires when an admin note is loaded. * * @param int $note_id Note ID. */ do_action( 'woocommerce_note_loaded', $note ); } elseif ( $note_row ) { $note->set_name( $note_row->name ); $note->set_type( $note_row->type ); $note->set_locale( $note_row->locale ); $note->set_title( $note_row->title ); $note->set_content( $note_row->content ); // The default for 'content_value' used to be an array, so there might be rows with invalid data! $content_data = json_decode( $note_row->content_data ); if ( ! $content_data ) { $content_data = new \stdClass(); } elseif ( is_array( $content_data ) ) { $content_data = (object) $content_data; } $note->set_content_data( $content_data ); $note->set_status( $note_row->status ); $note->set_source( $note_row->source ); $note->set_date_created( $note_row->date_created ); $note->set_date_reminder( $note_row->date_reminder ); $note->set_is_snoozable( (bool) $note_row->is_snoozable ); $note->set_is_deleted( (bool) $note_row->is_deleted ); isset( $note_row->is_read ) && $note->set_is_read( (bool) $note_row->is_read ); $note->set_layout( $note_row->layout ); $note->set_image( $note_row->image ); $this->read_actions( $note ); $note->set_object_read( true ); /** * Fires when an admin note is loaded. * * @param int $note_id Note ID. */ do_action( 'woocommerce_note_loaded', $note ); } else { throw new \Exception( __( 'Invalid admin note', 'woocommerce' ) ); } } /** * Updates a note in the database. * * @param Note $note Admin note. */ public function update( &$note ) { global $wpdb; if ( $note->get_id() ) { $date_created = $note->get_date_created(); $date_created_timestamp = $date_created->getTimestamp(); $date_created_to_db = gmdate( 'Y-m-d H:i:s', $date_created_timestamp ); $date_reminder = $note->get_date_reminder(); if ( is_null( $date_reminder ) ) { $date_reminder_to_db = null; } else { $date_reminder_timestamp = $date_reminder->getTimestamp(); $date_reminder_to_db = gmdate( 'Y-m-d H:i:s', $date_reminder_timestamp ); } $wpdb->update( $wpdb->prefix . 'wc_admin_notes', array( 'name' => $note->get_name(), 'type' => $note->get_type(), 'locale' => $note->get_locale(), 'title' => $note->get_title(), 'content' => $note->get_content(), 'content_data' => wp_json_encode( $note->get_content_data() ), 'status' => $note->get_status(), 'source' => $note->get_source(), 'date_created' => $date_created_to_db, 'date_reminder' => $date_reminder_to_db, 'is_snoozable' => (int) $note->get_is_snoozable(), 'layout' => $note->get_layout(), 'image' => $note->get_image(), 'is_deleted' => (int) $note->get_is_deleted(), 'is_read' => (int) $note->get_is_read(), ), array( 'note_id' => $note->get_id() ) ); } $this->save_actions( $note ); $note->apply_changes(); /** * Fires when an admin note is updated. * * @param int $note_id Note ID. */ do_action( 'woocommerce_note_updated', $note->get_id() ); } /** * Deletes a note from the database. * * @param Note $note Admin note. * @param array $args Array of args to pass to the delete method (not used). */ public function delete( &$note, $args = array() ) { $note_id = $note->get_id(); if ( $note_id ) { global $wpdb; $wpdb->delete( $wpdb->prefix . 'wc_admin_notes', array( 'note_id' => $note_id ) ); $wpdb->delete( $wpdb->prefix . 'wc_admin_note_actions', array( 'note_id' => $note_id ) ); $note->set_id( null ); } /** * Fires when an admin note is deleted. * * @param int $note_id Note ID. */ do_action( 'woocommerce_note_deleted', $note_id ); } /** * Read actions from the database. * * @param Note $note Admin note. */ private function read_actions( &$note ) { global $wpdb; $db_actions = $wpdb->get_results( $wpdb->prepare( "SELECT action_id, name, label, query, status, actioned_text, nonce_action, nonce_name FROM {$wpdb->prefix}wc_admin_note_actions WHERE note_id = %d", $note->get_id() ) ); $note_actions = array(); if ( $db_actions ) { foreach ( $db_actions as $action ) { $note_actions[] = (object) array( 'id' => (int) $action->action_id, 'name' => $action->name, 'label' => $action->label, 'query' => $action->query, 'status' => $action->status, 'actioned_text' => $action->actioned_text, 'nonce_action' => $action->nonce_action, 'nonce_name' => $action->nonce_name, ); } } $note->set_actions( $note_actions ); } /** * Save actions to the database. * This function clears old actions, then re-inserts new if any changes are found. * * @param Note $note Note object. * * @return bool|void */ private function save_actions( &$note ) { global $wpdb; $changed_props = array_keys( $note->get_changes() ); if ( ! in_array( 'actions', $changed_props, true ) ) { return false; } // Process action removal. Actions are removed from // the note if they aren't part of the changeset. // See Note::add_action(). $changed_actions = $note->get_actions( 'edit' ); $actions_to_keep = array(); foreach ( $changed_actions as $action ) { if ( ! empty( $action->id ) ) { $actions_to_keep[] = (int) $action->id; } } $clear_actions_query = $wpdb->prepare( "DELETE FROM {$wpdb->prefix}wc_admin_note_actions WHERE note_id = %d", $note->get_id() ); if ( $actions_to_keep ) { $clear_actions_query .= sprintf( ' AND action_id NOT IN (%s)', implode( ',', $actions_to_keep ) ); } $wpdb->query( $clear_actions_query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared // Update/insert the actions in this changeset. foreach ( $changed_actions as $action ) { $action_data = array( 'note_id' => $note->get_id(), 'name' => $action->name, 'label' => $action->label, 'query' => $action->query, 'status' => $action->status, 'actioned_text' => $action->actioned_text, 'nonce_action' => $action->nonce_action, 'nonce_name' => $action->nonce_name, ); $data_format = array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', ); if ( ! empty( $action->id ) ) { $action_data['action_id'] = $action->id; $data_format[] = '%d'; } $wpdb->replace( $wpdb->prefix . 'wc_admin_note_actions', $action_data, $data_format ); } // Update actions from DB (to grab new IDs). $this->read_actions( $note ); } /** * Return an ordered list of notes. * * @param array $args Query arguments. * @param string $context Optional argument that the woocommerce_note_where_clauses filter can use to determine whether to apply extra conditions. Extensions should define their own contexts and use them to avoid adding to notes where clauses when not needed. * @return array An array of objects containing a note id. */ public function get_notes( $args = array(), $context = self::WC_ADMIN_NOTE_OPER_GLOBAL ) { global $wpdb; $defaults = array( 'per_page' => get_option( 'posts_per_page' ), 'page' => 1, 'order' => 'DESC', 'orderby' => 'date_created', ); $args = wp_parse_args( $args, $defaults ); $offset = $args['per_page'] * ( $args['page'] - 1 ); $where_clauses = $this->get_notes_where_clauses( $args, $context ); // sanitize order and orderby. $order_by = '`' . str_replace( '`', '', $args['orderby'] ) . '`'; $order_dir = 'asc' === strtolower( $args['order'] ) ? 'ASC' : 'DESC'; $query = $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT * FROM {$wpdb->prefix}wc_admin_notes WHERE 1=1{$where_clauses} ORDER BY {$order_by} {$order_dir} LIMIT %d, %d", $offset, $args['per_page'] ); return $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared } /** * Return an ordered list of notes, without paging or applying the 'woocommerce_note_where_clauses' filter. * INTERNAL: This method is not intended to be used by external code, and may change without notice. * * @param array $args Query arguments. * @return array An array of database records. */ public function lookup_notes( $args = array() ) { global $wpdb; $defaults = array( 'order' => 'DESC', 'orderby' => 'date_created', ); $args = wp_parse_args( $args, $defaults ); $where_clauses = $this->args_to_where_clauses( $args ); // sanitize order and orderby. $order_by = '`' . str_replace( '`', '', $args['orderby'] ) . '`'; $order_dir = 'asc' === strtolower( $args['order'] ) ? 'ASC' : 'DESC'; $query = "SELECT * FROM {$wpdb->prefix}wc_admin_notes WHERE 1=1{$where_clauses} ORDER BY {$order_by} {$order_dir}"; return $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared } /** * Return a count of notes. * * @param string $type Comma separated list of note types. * @param string $status Comma separated list of statuses. * @param string $context Optional argument that the woocommerce_note_where_clauses filter can use to determine whether to apply extra conditions. Extensions should define their own contexts and use them to avoid adding to notes where clauses when not needed. * @return string Count of objects with given type, status and context. */ public function get_notes_count( $type = array(), $status = array(), $context = self::WC_ADMIN_NOTE_OPER_GLOBAL ) { global $wpdb; $where_clauses = $this->get_notes_where_clauses( array( 'type' => $type, 'status' => $status, ), $context ); if ( ! empty( $where_clauses ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared return $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_admin_notes WHERE 1=1{$where_clauses}" ); } return $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_admin_notes" ); } /** * Parses the query arguments passed in as arrays and escapes the values. * * @param array $args the query arguments. * @param string $key the key of the specific argument. * @param array|null $allowed_types optional allowed_types if only a specific set is allowed. * @return array the escaped array of argument values. */ private function get_escaped_arguments_array_by_key( $args = array(), $key = '', $allowed_types = null ) { $arg_array = array(); if ( isset( $args[ $key ] ) ) { foreach ( $args[ $key ] as $args_type ) { $args_type = trim( $args_type ); $allowed = is_null( $allowed_types ) || in_array( $args_type, $allowed_types, true ); if ( $allowed ) { $arg_array[] = sprintf( "'%s'", esc_sql( $args_type ) ); } } } return $arg_array; } /** * Return where clauses for getting notes by status and type. For use in both the count and listing queries. * Applies woocommerce_note_where_clauses filter. * * @uses args_to_where_clauses * @param array $args Array of args to pass. * @param string $context Optional argument that the woocommerce_note_where_clauses filter can use to determine whether to apply extra conditions. Extensions should define their own contexts and use them to avoid adding to notes where clauses when not needed. * @return string Where clauses for the query. */ public function get_notes_where_clauses( $args = array(), $context = self::WC_ADMIN_NOTE_OPER_GLOBAL ) { $where_clauses = $this->args_to_where_clauses( $args ); /** * Filter the notes WHERE clause before retrieving the data. * * Allows modification of the notes select criterial. * * @param string $where_clauses The generated WHERE clause. * @param array $args The original arguments for the request. * @param string $context Optional argument that the woocommerce_note_where_clauses filter can use to determine whether to apply extra conditions. Extensions should define their own contexts and use them to avoid adding to notes where clauses when not needed. */ return apply_filters( 'woocommerce_note_where_clauses', $where_clauses, $args, $context ); } /** * Return where clauses for notes queries without applying woocommerce_note_where_clauses filter. * INTERNAL: This method is not intended to be used by external code, and may change without notice. * * @param array $args Array of arguments for query conditionals. * @return string Where clauses. */ protected function args_to_where_clauses( $args = array() ) { $allowed_types = Note::get_allowed_types(); $where_type_array = $this->get_escaped_arguments_array_by_key( $args, 'type', $allowed_types ); $allowed_statuses = Note::get_allowed_statuses(); $where_status_array = $this->get_escaped_arguments_array_by_key( $args, 'status', $allowed_statuses ); $escaped_is_deleted = ''; if ( isset( $args['is_deleted'] ) ) { $escaped_is_deleted = esc_sql( $args['is_deleted'] ); } $where_name_array = $this->get_escaped_arguments_array_by_key( $args, 'name' ); $where_excluded_name_array = $this->get_escaped_arguments_array_by_key( $args, 'excluded_name' ); $where_source_array = $this->get_escaped_arguments_array_by_key( $args, 'source' ); $escaped_where_types = implode( ',', $where_type_array ); $escaped_where_status = implode( ',', $where_status_array ); $escaped_where_names = implode( ',', $where_name_array ); $escaped_where_excluded_names = implode( ',', $where_excluded_name_array ); $escaped_where_source = implode( ',', $where_source_array ); $where_clauses = ''; if ( ! empty( $escaped_where_types ) ) { $where_clauses .= " AND type IN ($escaped_where_types)"; } if ( ! empty( $escaped_where_status ) ) { $where_clauses .= " AND status IN ($escaped_where_status)"; } if ( ! empty( $escaped_where_names ) ) { $where_clauses .= " AND name IN ($escaped_where_names)"; } if ( ! empty( $escaped_where_excluded_names ) ) { $where_clauses .= " AND name NOT IN ($escaped_where_excluded_names)"; } if ( ! empty( $escaped_where_source ) ) { $where_clauses .= " AND source IN ($escaped_where_source)"; } if ( isset( $args['is_read'] ) ) { $where_clauses .= $args['is_read'] ? ' AND is_read = 1' : ' AND is_read = 0'; } $where_clauses .= $escaped_is_deleted ? ' AND is_deleted = 1' : ' AND is_deleted = 0'; return $where_clauses; } /** * Find all the notes with a given name. * * @param string $name Name to search for. * @return array An array of matching note ids. */ public function get_notes_with_name( $name ) { global $wpdb; return $wpdb->get_col( $wpdb->prepare( "SELECT note_id FROM {$wpdb->prefix}wc_admin_notes WHERE name = %s ORDER BY note_id ASC", $name ) ); } /** * Find the ids of all notes with a given type. * * @param string $note_type Type to search for. * @return array An array of matching note ids. */ public function get_note_ids_by_type( $note_type ) { global $wpdb; return $wpdb->get_col( $wpdb->prepare( "SELECT note_id FROM {$wpdb->prefix}wc_admin_notes WHERE type = %s ORDER BY note_id ASC", $note_type ) ); } } Notes/NotesUnavailableException.php 0000777 00000000541 15252240713 0013470 0 ustar 00 <?php /** * WooCommerce Admin Notes Unavailable Exception Class * * Exception class thrown when an attempt to use notes is made but notes are unavailable. */ namespace Automattic\WooCommerce\Admin\Notes; defined( 'ABSPATH' ) || exit; /** * Notes\NotesUnavailableException class. */ class NotesUnavailableException extends \WC_Data_Exception {} PageController.php 0000777 00000051457 15252240713 0010221 0 ustar 00 <?php /** * PageController */ namespace Automattic\WooCommerce\Admin; use Automattic\WooCommerce\Internal\Admin\Loader; use Automattic\WooCommerce\Admin\Features\Features; use WC_Gateway_BACS; use WC_Gateway_Cheque; use WC_Gateway_COD; use WC_Gateway_Paypal; defined( 'ABSPATH' ) || exit; /** * PageController */ class PageController { /** * App entry point. */ const APP_ENTRY_POINT = 'wc-admin'; // JS-powered page root. const PAGE_ROOT = 'wc-admin'; /** * Singleton instance of self. * * @var PageController */ private static $instance = false; /** * Current page ID (or false if not registered with this controller). * * @var string */ private $current_page = null; /** * Registered pages * Contains information (breadcrumbs, menu info) about JS powered pages and classic WooCommerce pages. * * @var array */ private $pages = array(); /** * We want a single instance of this class so we can accurately track registered menus and pages. */ 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_menu', array( $this, 'register_page_handler' ) ); add_action( 'admin_menu', array( $this, 'register_store_details_page' ) ); // 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, 'remove_app_entry_page_menu_item' ), 20 ); // Using low priority to run before other hooks. add_action( 'admin_init', array( $this, 'maybe_redirect_payment_tasks_to_settings' ), 1 ); } /** * Connect an existing page to wc-admin. * * @param array $options { * Array describing the page. * * @type string id Id to reference the page. * @type string|array title Page title. Used in menus and breadcrumbs. * @type string|null parent Parent ID. Null for new top level page. * @type string path Path for this page. E.g. admin.php?page=wc-settings&tab=checkout * @type string capability Capability needed to access the page. * @type string icon Icon. Dashicons helper class, base64-encoded SVG, or 'none'. * @type int position Menu item position. * @type boolean js_page If this is a JS-powered page. * } */ public function connect_page( $options ) { if ( ! is_array( $options['title'] ) ) { $options['title'] = array( $options['title'] ); } /** * Filter the options when connecting or registering a page. * * Use the `js_page` option to determine if registering. * * @param array $options { * Array describing the page. * * @type string id Id to reference the page. * @type string|array title Page title. Used in menus and breadcrumbs. * @type string|null parent Parent ID. Null for new top level page. * @type string screen_id The screen ID that represents the connected page. (Not required for registering). * @type string path Path for this page. E.g. admin.php?page=wc-settings&tab=checkout * @type string capability Capability needed to access the page. * @type string icon Icon. Dashicons helper class, base64-encoded SVG, or 'none'. * @type int position Menu item position. * @type boolean js_page If this is a JS-powered page. * } */ $options = apply_filters( 'woocommerce_navigation_connect_page_options', $options ); // @todo check for null ID, or collision. $this->pages[ $options['id'] ] = $options; } /** * Determine the current page ID, if it was registered with this controller. */ public function determine_current_page() { $current_url = ''; $current_screen_id = $this->get_current_screen_id(); if ( isset( $_SERVER['REQUEST_URI'] ) ) { $current_url = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ); } $current_query = wp_parse_url( $current_url, PHP_URL_QUERY ); parse_str( (string) $current_query, $current_pieces ); $current_path = empty( $current_pieces['page'] ) ? '' : $current_pieces['page']; $current_path .= empty( $current_pieces['path'] ) ? '' : '&path=' . $current_pieces['path']; foreach ( $this->pages as $page ) { if ( isset( $page['js_page'] ) && $page['js_page'] ) { // Check registered admin pages. if ( $page['path'] === $current_path ) { $this->current_page = $page; return; } } else { // Check connected admin pages. if ( isset( $page['screen_id'] ) && $page['screen_id'] === $current_screen_id ) { $this->current_page = $page; return; } } } $this->current_page = false; } /** * Get breadcrumbs for WooCommerce Admin Page navigation. * * @return array Navigation pieces (breadcrumbs). */ public function get_breadcrumbs() { $current_page = $this->get_current_page(); // Bail if this isn't a page registered with this controller. if ( false === $current_page ) { // Filter documentation below. return apply_filters( 'woocommerce_navigation_get_breadcrumbs', array( '' ), $current_page ); } $page_title = ! empty( $current_page['page_title'] ) ? $current_page['page_title'] : $current_page['title']; $page_title = (array) $page_title; if ( 1 === count( $page_title ) ) { $breadcrumbs = $page_title; } else { // If this page has multiple title pieces, only link the first one. $breadcrumbs = array_merge( array( array( $current_page['path'], reset( $page_title ) ), ), array_slice( $page_title, 1 ) ); } if ( isset( $current_page['parent'] ) ) { $parent_id = $current_page['parent']; while ( $parent_id ) { if ( isset( $this->pages[ $parent_id ] ) ) { $parent = $this->pages[ $parent_id ]; if ( 0 === strpos( $parent['path'], self::PAGE_ROOT ) ) { $parent['path'] = 'admin.php?page=' . $parent['path']; } array_unshift( $breadcrumbs, array( $parent['path'], reset( $parent['title'] ) ) ); $parent_id = isset( $parent['parent'] ) ? $parent['parent'] : false; } else { $parent_id = false; } } } $woocommerce_breadcrumb = array( 'admin.php?page=' . self::PAGE_ROOT, __( 'WooCommerce', 'woocommerce' ) ); array_unshift( $breadcrumbs, $woocommerce_breadcrumb ); /** * The navigation breadcrumbs for the current page. * * @param array $breadcrumbs Navigation pieces (breadcrumbs). * @param array|boolean $current_page The connected page data or false if not identified. */ return apply_filters( 'woocommerce_navigation_get_breadcrumbs', $breadcrumbs, $current_page ); } /** * Get the current page. * * @return array|boolean Current page or false if not registered with this controller. */ public function get_current_page() { // If 'current_screen' hasn't fired yet, the current page calculation // will fail which causes `false` to be returned for all subsequent calls. if ( ! did_action( 'current_screen' ) ) { _doing_it_wrong( __FUNCTION__, esc_html__( 'Current page retrieval should be called on or after the `current_screen` hook.', 'woocommerce' ), '0.16.0' ); } if ( is_null( $this->current_page ) ) { $this->determine_current_page(); } return $this->current_page; } /** * Returns the current screen ID. * * This is slightly different from WP's get_current_screen, in that it attaches an action, * so certain pages like 'add new' pages can have different breadcrumbs or handling. * It also catches some more unique dynamic pages like taxonomy/attribute management. * * Format: * - {$current_screen->action}-{$current_screen->action}-tab-section * - {$current_screen->action}-{$current_screen->action}-tab * - {$current_screen->action}-{$current_screen->action} if no tab is present * - {$current_screen->action} if no action or tab is present * * @return string Current screen ID. */ public function get_current_screen_id() { // Return early if this is a REST API request. if ( wp_is_serving_rest_request() ) { /** * Filter the current screen ID for REST API requests. * * @since 3.9.0 * * @param string|boolean $screen_id The screen id or false if not identified. * @param WP_Screen $current_screen The current WP_Screen. */ return apply_filters( 'woocommerce_navigation_current_screen_id', false, null ); } $current_screen = get_current_screen(); if ( ! $current_screen ) { // Filter documentation below. return apply_filters( 'woocommerce_navigation_current_screen_id', false, $current_screen ); } $screen_pieces = array( $current_screen->id ); if ( $current_screen->action ) { $screen_pieces[] = $current_screen->action; } if ( ! empty( $current_screen->taxonomy ) && isset( $current_screen->post_type ) && 'product' === $current_screen->post_type ) { // Editing a product attribute. if ( 0 === strpos( $current_screen->taxonomy, 'pa_' ) ) { $screen_pieces = array( 'product_page_product_attribute-edit' ); } // Editing a product taxonomy term. if ( ! empty( $_GET['tag_ID'] ) ) { $screen_pieces = array( $current_screen->taxonomy ); } } // Pages with default tab values. $pages_with_tabs = apply_filters( 'woocommerce_navigation_pages_with_tabs', array( 'wc-reports' => 'orders', 'wc-settings' => 'general', 'wc-status' => 'status', 'wc-addons' => 'browse-extensions', ) ); // Tabs that have sections as well. $wc_emails = \WC_Emails::instance(); $wc_email_ids = array_map( 'sanitize_title', array_keys( $wc_emails->get_emails() ) ); $tabs_with_sections = apply_filters( 'woocommerce_navigation_page_tab_sections', array( 'products' => array( '', 'inventory', 'downloadable', 'download_urls', 'advanced' ), 'shipping' => array( '', 'options', 'classes', 'pickup_location' ), 'checkout' => array( WC_Gateway_BACS::ID, WC_Gateway_Cheque::ID, WC_Gateway_COD::ID, WC_Gateway_Paypal::ID ), 'email' => $wc_email_ids, 'advanced' => array( '', 'keys', 'webhooks', 'legacy_api', 'woocommerce_com', 'features', 'blueprint', ), 'browse-extensions' => array( 'helper' ), ) ); if ( ! empty( $_GET['page'] ) ) { $page = wc_clean( wp_unslash( $_GET['page'] ) ); if ( in_array( $page, array_keys( $pages_with_tabs ) ) ) { if ( ! empty( $_GET['tab'] ) ) { $tab = wc_clean( wp_unslash( $_GET['tab'] ) ); } else { $tab = $pages_with_tabs[ $page ]; } $screen_pieces[] = $tab; if ( ! empty( $_GET['section'] ) ) { $section = wc_clean( wp_unslash( $_GET['section'] ) ); if ( isset( $tabs_with_sections[ $tab ] ) && in_array( $section, array_values( $tabs_with_sections[ $tab ] ), true ) ) { $screen_pieces[] = $section; } } // Editing a shipping zone. if ( ( 'shipping' === $tab ) && isset( $_GET['zone_id'] ) ) { $screen_pieces[] = 'edit_zone'; } } } /** * The current screen id. * * Used for identifying pages to render the WooCommerce Admin header. * * @param string|boolean $screen_id The screen id or false if not identified. * @param WP_Screen $current_screen The current WP_Screen. */ return apply_filters( 'woocommerce_navigation_current_screen_id', implode( '-', $screen_pieces ), $current_screen ); } /** * Returns the path from an ID. * * @param string $id ID to get path for. * @return string Path for the given ID, or the ID on lookup miss. */ public function get_path_from_id( $id ) { if ( isset( $this->pages[ $id ] ) && isset( $this->pages[ $id ]['path'] ) ) { return $this->pages[ $id ]['path']; } return $id; } /** * Returns true if we are on a page connected to this controller. * * @return boolean */ public function is_connected_page() { $current_page = $this->get_current_page(); if ( false === $current_page ) { $is_connected_page = false; } else { $is_connected_page = isset( $current_page['js_page'] ) ? ! $current_page['js_page'] : true; } // Disable embed on the block editor. $current_screen = did_action( 'current_screen' ) ? get_current_screen() : false; if ( ! empty( $current_screen ) && method_exists( $current_screen, 'is_block_editor' ) && $current_screen->is_block_editor() ) { $is_connected_page = false; } /** * Whether or not the current page is an existing page connected to this controller. * * Used to determine if the WooCommerce Admin header should be rendered. * * @param boolean $is_connected_page True if the current page is connected. * @param array|boolean $current_page The connected page data or false if not identified. */ return apply_filters( 'woocommerce_navigation_is_connected_page', $is_connected_page, $current_page ); } /** * Returns true if we are on a page registered with this controller. * * @return boolean */ public function is_registered_page() { $current_page = $this->get_current_page(); if ( false === $current_page ) { $is_registered_page = false; } else { $is_registered_page = isset( $current_page['js_page'] ) && $current_page['js_page']; } /** * Whether or not the current page was registered with this controller. * * Used to determine if this is a JS-powered WooCommerce Admin page. * * @param boolean $is_registered_page True if the current page was registered with this controller. * @param array|boolean $current_page The registered page data or false if not identified. */ return apply_filters( 'woocommerce_navigation_is_registered_page', $is_registered_page, $current_page ); } /** * Adds a JS powered page to wc-admin. * * @param array $options { * Array describing the page. * * @type string id Id to reference the page. * @type string title Page title. Used in menus and breadcrumbs. * @type string|null parent Parent ID. Null for new top level page. * @type string path Path for this page, full path in app context; ex /analytics/report * @type string capability Capability needed to access the page. * @type string icon Icon. Dashicons helper class, base64-encoded SVG, or 'none'. * @type int position Menu item position. * @type int order Navigation item order. * } */ public function register_page( $options ) { $defaults = array( 'id' => null, 'parent' => null, 'title' => '', 'page_title' => '', 'capability' => 'view_woocommerce_reports', 'path' => '', 'icon' => '', 'position' => null, 'js_page' => true, ); $options = wp_parse_args( $options, $defaults ); if ( 0 !== strpos( $options['path'], self::PAGE_ROOT ) ) { $options['path'] = self::PAGE_ROOT . '&path=' . $options['path']; } if ( null !== $options['position'] ) { $options['position'] = intval( round( $options['position'] ) ); } if ( empty( $options['page_title'] ) ) { $options['page_title'] = $options['title']; } if ( is_null( $options['parent'] ) ) { add_menu_page( $options['page_title'], $options['title'], $options['capability'], $options['path'], array( __CLASS__, 'page_wrapper' ), $options['icon'], $options['position'] ); } else { $parent_path = $this->get_path_from_id( $options['parent'] ); // @todo check for null path. add_submenu_page( $parent_path, $options['page_title'], $options['title'], $options['capability'], $options['path'], array( __CLASS__, 'page_wrapper' ) ); } $this->connect_page( $options ); } /** * Get registered pages. * * @return array */ public function get_pages() { return $this->pages; } /** * Set up a div for the app to render into. */ public static function page_wrapper() { Loader::page_wrapper(); } /** * Connects existing WooCommerce pages. * * @todo The entry point for the embed needs moved to this class as well. */ public function register_page_handler() { require_once WC_ADMIN_ABSPATH . 'includes/react-admin/connect-existing-pages.php'; } /** * Registers the store details (profiler) page. */ public function register_store_details_page() { wc_admin_register_page( array( 'id' => 'setup-wizard', 'title' => __( 'Setup Wizard', 'woocommerce' ), 'parent' => '', 'path' => '/setup-wizard', ) ); } /** * Remove the menu item for the app entry point page. */ public function remove_app_entry_page_menu_item() { 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 ) { // Our app entry page menu item has no title. if ( is_null( $submenu_item[0] ) && self::APP_ENTRY_POINT === $submenu_item[2] ) { $wc_admin_key = $submenu_key; break; } } if ( ! $wc_admin_key ) { return; } unset( $submenu['woocommerce'][ $wc_admin_key ] ); } /** * Returns true if we are on a JS powered admin page or * a "classic" (non JS app) powered admin page (an embedded page). */ public static function is_admin_or_embed_page() { return self::is_admin_page() || self::is_embed_page(); } /** * Returns true if we are on a JS powered admin page. */ public static function is_admin_page() { // phpcs:disable WordPress.Security.NonceVerification return isset( $_GET['page'] ) && 'wc-admin' === $_GET['page']; // phpcs:enable WordPress.Security.NonceVerification } /** * Returns true if we are on a settings page. */ public static function is_settings_page() { // phpcs:disable WordPress.Security.NonceVerification return isset( $_GET['page'] ) && 'wc-settings' === $_GET['page']; // phpcs:enable WordPress.Security.NonceVerification } /** * Returns true if we are on a "classic" (non JS app) powered admin page. * * TODO: See usage in `admin.php`. This needs refactored and implemented properly in core. */ public static function is_embed_page() { return wc_admin_is_connected_page(); } /** * Returns true if we are on a modern settings page. */ public static function is_modern_settings_page() { return self::is_settings_page() && Features::is_enabled( 'settings' ); } /** * Redirect payment tasks to the settings page. * * Redirects both 'payments' and 'woocommerce-payments' tasks to the Payments settings page, * when it is safe to do so in terms of backwards compatibility. */ public function maybe_redirect_payment_tasks_to_settings() { // Bail if we are not in the WP admin or not on a WC admin page. if ( ! is_admin() || ! self::is_admin_page() ) { return; } // Bail if we are not requesting a page for a WooCommerce task. // phpcs:ignore WordPress.Security.NonceVerification if ( empty( $_GET['task'] ) ) { return; } // Only sufficiently capable users should be redirected. if ( ! current_user_can( 'manage_woocommerce' ) ) { return; } // Get the current task ID. // phpcs:ignore WordPress.Security.NonceVerification $task_id = wc_clean( wp_unslash( $_GET['task'] ) ); // Bail if the task is not a payments task. if ( ! in_array( $task_id, array( 'payments', 'woocommerce-payments' ), true ) ) { return; } $redirect_url = admin_url( 'admin.php?page=wc-settings&tab=checkout&from=WCADMIN_PAYMENT_TASK' ); // The WooPayments task is always redirected to the settings page. if ( 'woocommerce-payments' === $task_id ) { wp_safe_redirect( $redirect_url ); exit; } // The generic payments task is only redirected if the request is a regular user request, // not part of an onboarding flow or other special case. $special_request_params = array( // This is used by the legacy, Payments task-based suggestions onboarding flow. // Nobody should be using this anymore, but just in case. 'connection-return', // This is used by the legacy, Payments task-based suggestions onboarding flow. // Nobody should be using this anymore, but just in case. 'id', // Some params for gateway IDs, just in case. 'gateway_id', 'gateway-id', // Sometimes the gateway is referred to as 'method'. Stay clear of it. 'method', // If there is a success or error param, better not redirect. 'success', 'error', // If the URL is nonced, better not redirect. '_wpnonce', ); foreach ( $special_request_params as $param ) { // phpcs:ignore WordPress.Security.NonceVerification if ( isset( $_GET[ $param ] ) ) { return; } } // If we reach this point, we can safely redirect to the settings page. wp_safe_redirect( $redirect_url ); exit; } } ReportsSync.php 0000777 00000013704 15252240713 0007565 0 ustar 00 <?php /** * Report table sync related functions and actions. */ namespace Automattic\WooCommerce\Admin; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Internal\Admin\Schedulers\CustomersScheduler; use Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler; use Automattic\WooCommerce\Internal\Admin\Schedulers\ImportScheduler; /** * ReportsSync Class. */ class ReportsSync { /** * Hook in sync methods. */ public static function init() { // Initialize scheduler hooks. foreach ( self::get_schedulers() as $scheduler ) { $scheduler::init(); } add_action( 'woocommerce_update_product', array( __CLASS__, 'clear_stock_count_cache' ) ); add_action( 'woocommerce_new_product', array( __CLASS__, 'clear_stock_count_cache' ) ); add_action( 'update_option_woocommerce_notify_low_stock_amount', array( __CLASS__, 'clear_stock_count_cache' ) ); add_action( 'update_option_woocommerce_notify_no_stock_amount', array( __CLASS__, 'clear_stock_count_cache' ) ); } /** * Get classes for syncing data. * * @return array * @throws \Exception Throws exception when invalid data is found. */ public static function get_schedulers() { $schedulers = apply_filters( 'woocommerce_analytics_report_schedulers', array( new CustomersScheduler(), new OrdersScheduler(), ) ); foreach ( $schedulers as $scheduler ) { if ( ! is_subclass_of( $scheduler, 'Automattic\WooCommerce\Internal\Admin\Schedulers\ImportScheduler' ) ) { throw new \Exception( __( 'Report sync schedulers should be derived from the Automattic\WooCommerce\Internal\Admin\Schedulers\ImportScheduler class.', 'woocommerce' ) ); } } return $schedulers; } /** * Returns true if an import is in progress. * * @return bool */ public static function is_importing() { foreach ( self::get_schedulers() as $scheduler ) { if ( $scheduler::is_importing() ) { return true; } } return false; } /** * Regenerate data for reports. * * @param int|bool $days Number of days to import. * @param bool $skip_existing Skip existing records. * @return string */ public static function regenerate_report_data( $days, $skip_existing ) { if ( self::is_importing() ) { return new \WP_Error( 'wc_admin_import_in_progress', __( 'An import is already in progress. Please allow the previous import to complete before beginning a new one.', 'woocommerce' ) ); } self::reset_import_stats( $days, $skip_existing ); foreach ( self::get_schedulers() as $scheduler ) { $scheduler::schedule_action( 'import_batch_init', array( $days, $skip_existing ) ); } /** * Fires when report data regeneration begins. * * @param int|bool $days Number of days to import. * @param bool $skip_existing Skip existing records. */ do_action( 'woocommerce_analytics_regenerate_init', $days, $skip_existing ); return __( 'Report table data is being rebuilt. Please allow some time for data to fully populate.', 'woocommerce' ); } /** * Update the import stat totals and counts. * * @param int|bool $days Number of days to import. * @param bool $skip_existing Skip existing records. */ public static function reset_import_stats( $days, $skip_existing ) { $import_stats = get_option( ImportScheduler::IMPORT_STATS_OPTION, array() ); $totals = self::get_import_totals( $days, $skip_existing ); foreach ( self::get_schedulers() as $scheduler ) { $import_stats[ $scheduler::$name ]['imported'] = 0; $import_stats[ $scheduler::$name ]['total'] = $totals[ $scheduler::$name ]; } // Update imported from date if older than previous. $previous_import_date = isset( $import_stats['imported_from'] ) ? $import_stats['imported_from'] : null; $current_import_date = $days ? gmdate( 'Y-m-d 00:00:00', time() - ( DAY_IN_SECONDS * $days ) ) : -1; if ( ! $previous_import_date || -1 === $current_import_date || new \DateTime( $previous_import_date ) > new \DateTime( $current_import_date ) ) { $import_stats['imported_from'] = $current_import_date; } update_option( ImportScheduler::IMPORT_STATS_OPTION, $import_stats ); } /** * Get stats for current import. * * @return array */ public static function get_import_stats() { $import_stats = get_option( ImportScheduler::IMPORT_STATS_OPTION, array() ); $import_stats['is_importing'] = self::is_importing(); return $import_stats; } /** * Get the import totals for all syncs. * * @param int|bool $days Number of days to import. * @param bool $skip_existing Skip existing records. * @return array */ public static function get_import_totals( $days, $skip_existing ) { $totals = array(); foreach ( self::get_schedulers() as $scheduler ) { $items = $scheduler::get_items( 1, 1, $days, $skip_existing ); $totals[ $scheduler::$name ] = $items->total; } return $totals; } /** * Clears all queued actions. */ public static function clear_queued_actions() { foreach ( self::get_schedulers() as $scheduler ) { $scheduler::clear_queued_actions(); } } /** * Delete all data for reports. * * @return string */ public static function delete_report_data() { // Cancel all pending import jobs. self::clear_queued_actions(); foreach ( self::get_schedulers() as $scheduler ) { $scheduler::schedule_action( 'delete_batch_init', array() ); } // Delete import options. delete_option( ImportScheduler::IMPORT_STATS_OPTION ); return __( 'Report table data is being deleted.', 'woocommerce' ); } /** * Clear the count cache when products are added or updated, or when * the no/low stock options are changed. * * @param int $id Post/product ID. */ public static function clear_stock_count_cache( $id ) { delete_transient( 'wc_admin_stock_count_lowstock' ); delete_transient( 'wc_admin_product_count' ); $status_options = wc_get_product_stock_status_options(); foreach ( $status_options as $status => $label ) { delete_transient( 'wc_admin_stock_count_' . $status ); } } } ReportExporter.php 0000777 00000014437 15252240713 0010302 0 ustar 00 <?php /** * Handles reports CSV export. */ namespace Automattic\WooCommerce\Admin; if ( ! defined( 'ABSPATH' ) ) { exit; } use Automattic\WooCommerce\Admin\Schedulers\SchedulerTraits; /** * ReportExporter Class. */ class ReportExporter { /** * Slug to identify the scheduler. * * @var string */ public static $name = 'report_exporter'; /** * Scheduler traits. */ use SchedulerTraits { init as scheduler_init; } /** * Export status option name. */ const EXPORT_STATUS_OPTION = 'woocommerce_admin_report_export_status'; /** * Export file download action. */ const DOWNLOAD_EXPORT_ACTION = 'woocommerce_admin_download_report_csv'; /** * Get all available scheduling actions. * Used to determine action hook names and clear events. * * @return array */ public static function get_scheduler_actions() { return array( 'export_report' => 'woocommerce_admin_report_export', 'email_report_download_link' => 'woocommerce_admin_email_report_download_link', ); } /** * Add action dependencies. * * @return array */ public static function get_dependencies() { return array( 'email_report_download_link' => self::get_action( 'export_report' ), ); } /** * Hook in action methods. */ public static function init() { // Initialize scheduled action handlers. self::scheduler_init(); // Report download handler. add_action( 'admin_init', array( __CLASS__, 'download_export_file' ) ); } /** * Queue up actions for a full report export. * * @param string $export_id Unique ID for report (timestamp expected). * @param string $report_type Report type. E.g. 'customers'. * @param array $report_args Report parameters, passed to data query. * @param bool $send_email Optional. Send an email when the export is complete. * @return int Number of items to export. */ public static function queue_report_export( $export_id, $report_type, $report_args = array(), $send_email = false ) { $exporter = new ReportCSVExporter( $report_type, $report_args ); $exporter->prepare_data_to_export(); $total_rows = $exporter->get_total_rows(); $batch_size = $exporter->get_limit(); $num_batches = (int) ceil( $total_rows / $batch_size ); // Create batches, like initial import. $report_batch_args = array( $export_id, $report_type, $report_args ); if ( 0 < $num_batches ) { self::queue_batches( 1, $num_batches, 'export_report', $report_batch_args ); if ( $send_email ) { $email_action_args = array( get_current_user_id(), $export_id, $report_type ); self::schedule_action( 'email_report_download_link', $email_action_args ); } } return $total_rows; } /** * Process a report export action. * * @param int $page_number Page number for this action. * @param string $export_id Unique ID for report (timestamp expected). * @param string $report_type Report type. E.g. 'customers'. * @param array $report_args Report parameters, passed to data query. * @return void */ public static function export_report( $page_number, $export_id, $report_type, $report_args ) { $report_args['page'] = $page_number; $exporter = new ReportCSVExporter( $report_type, $report_args ); $exporter->set_filename( "wc-{$report_type}-report-export-{$export_id}" ); $exporter->generate_file(); self::update_export_percentage_complete( $report_type, $export_id, $exporter->get_percent_complete() ); } /** * Generate a key to reference an export status. * * @param string $report_type Report type. E.g. 'customers'. * @param string $export_id Unique ID for report (timestamp expected). * @return string Status key. */ protected static function get_status_key( $report_type, $export_id ) { return $report_type . ':' . $export_id; } /** * Update the completion percentage of a report export. * * @param string $report_type Report type. E.g. 'customers'. * @param string $export_id Unique ID for report (timestamp expected). * @param int $percentage Completion percentage. * @return void */ public static function update_export_percentage_complete( $report_type, $export_id, $percentage ) { $exports_status = get_option( self::EXPORT_STATUS_OPTION, array() ); $status_key = self::get_status_key( $report_type, $export_id ); $exports_status[ $status_key ] = $percentage; update_option( self::EXPORT_STATUS_OPTION, $exports_status ); } /** * Get the completion percentage of a report export. * * @param string $report_type Report type. E.g. 'customers'. * @param string $export_id Unique ID for report (timestamp expected). * @return bool|int Completion percentage, or false if export not found. */ public static function get_export_percentage_complete( $report_type, $export_id ) { $exports_status = get_option( self::EXPORT_STATUS_OPTION, array() ); $status_key = self::get_status_key( $report_type, $export_id ); if ( isset( $exports_status[ $status_key ] ) ) { return $exports_status[ $status_key ]; } return false; } /** * Serve the export file. */ public static function download_export_file() { // @todo - add nonce? (nonces are good for 24 hours) if ( isset( $_GET['action'] ) && ! empty( $_GET['filename'] ) && self::DOWNLOAD_EXPORT_ACTION === wp_unslash( $_GET['action'] ) && // WPCS: input var ok, sanitization ok. current_user_can( 'view_woocommerce_reports' ) ) { $exporter = new ReportCSVExporter(); $exporter->set_filename( wp_unslash( $_GET['filename'] ) ); // WPCS: input var ok, sanitization ok. $exporter->export(); } } /** * Process a report export email action. * * @param int $user_id User ID that requested the email. * @param string $export_id Unique ID for report (timestamp expected). * @param string $report_type Report type. E.g. 'customers'. * @return void */ public static function email_report_download_link( $user_id, $export_id, $report_type ) { $percent_complete = self::get_export_percentage_complete( $report_type, $export_id ); if ( 100 === $percent_complete ) { $query_args = array( 'action' => self::DOWNLOAD_EXPORT_ACTION, 'filename' => "wc-{$report_type}-report-export-{$export_id}", ); $download_url = add_query_arg( $query_args, admin_url() ); \WC_Emails::instance(); $email = new ReportCSVEmail(); $email->trigger( $user_id, $report_type, $download_url ); } } } RemoteSpecs/DataSourcePoller.php 0000777 00000016431 15252240713 0012733 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\RemoteSpecs; /** * Specs data source poller class. * This handles polling specs from JSON endpoints, and * stores the specs in to the database as an option. */ abstract class DataSourcePoller { /** * Get class instance. */ abstract public static function get_instance(); /** * Name of data sources filter. */ const FILTER_NAME = 'data_source_poller_data_sources'; /** * Name of data source specs filter. */ const FILTER_NAME_SPECS = 'data_source_poller_specs'; /** * Id of DataSourcePoller. * * @var string */ protected $id = array(); /** * Default data sources array. * * @var array */ protected $data_sources = array(); /** * Default args. * * @var array */ protected $args = array(); /** * The logger instance. * * @var WC_Logger|null */ protected static $logger = null; /** * Constructor. * * @param string $id id of DataSourcePoller. * @param array $data_sources urls for data sources. * @param array $args Options for DataSourcePoller. */ public function __construct( $id, $data_sources = array(), $args = array() ) { $this->data_sources = $data_sources; $this->id = $id; $arg_defaults = array( 'spec_key' => 'id', 'transient_name' => 'woocommerce_admin_' . $id . '_specs', 'transient_expiry' => 7 * DAY_IN_SECONDS, ); $this->args = wp_parse_args( $args, $arg_defaults ); } /** * Get the logger instance. * * @return WC_Logger */ protected static function get_logger() { if ( is_null( self::$logger ) ) { self::$logger = wc_get_logger(); } return self::$logger; } /** * Returns the key identifier of spec, this can easily be overwritten. Defaults to id. * * @param mixed $spec a JSON parsed spec coming from the JSON feed. * @return string|boolean */ protected function get_spec_key( $spec ) { $key = $this->args['spec_key']; if ( isset( $spec->$key ) ) { return $spec->$key; } return false; } /** * Reads the data sources for specs and persists those specs. * * @return array list of specs. */ public function get_specs_from_data_sources() { $locale = get_user_locale(); $specs_group = get_transient( $this->args['transient_name'] ) ?? array(); $specs = isset( $specs_group[ $locale ] ) ? $specs_group[ $locale ] : null; if ( ! is_array( $specs ) ) { $this->read_specs_from_data_sources(); $specs_group = get_transient( $this->args['transient_name'] ); $specs = isset( $specs_group[ $locale ] ) ? $specs_group[ $locale ] : array(); } /** * Filter specs. * * @param array $specs List of specs. * @param string $this->id Spec identifier. * * @since 8.8.0 */ $specs = apply_filters( self::FILTER_NAME_SPECS, $specs, $this->id ); return false !== $specs ? $specs : array(); } /** * Gets specs from cache if it exists. * * @return array list of specs. */ public function get_cached_specs() { $locale = get_user_locale(); $specs_group = get_transient( $this->args['transient_name'] ) ?? array(); $specs = isset( $specs_group[ $locale ] ) ? $specs_group[ $locale ] : null; /** * Filter specs. * * @param array $specs List of specs. * @param string $this->id Spec identifier. * * @since 8.8.0 */ $specs = apply_filters( self::FILTER_NAME_SPECS, $specs, $this->id ); return false !== $specs ? $specs : array(); } /** * Reads the data sources for specs and persists those specs. * * @return bool Whether any specs were read. */ public function read_specs_from_data_sources() { $specs = array(); /** * Filter data sources. * * @param array $this->data_sources List of data sources. * @param string $this->id Spec identifier. * * @since 8.8.0 */ $data_sources = apply_filters( self::FILTER_NAME, $this->data_sources, $this->id ); // Note that this merges the specs from the data sources based on the // id - last one wins. foreach ( $data_sources as $url ) { $specs_from_data_source = self::read_data_source( $url ); $this->merge_specs( $specs_from_data_source, $specs, $url ); } $specs_group = get_transient( $this->args['transient_name'] ); $specs_group = is_array( $specs_group ) ? $specs_group : array(); $locale = get_user_locale(); $specs_group[ $locale ] = $specs; // Persist the specs as a transient. $this->set_specs_transient( $specs_group, $this->args['transient_expiry'] ); return count( $specs ) !== 0; } /** * Delete the specs transient. * * @return bool success of failure of transient deletion. */ public function delete_specs_transient() { return delete_transient( $this->args['transient_name'] ); } /** * Set the specs transient. * * @param array $specs The specs to set in the transient. * @param int $expiration The expiration time for the transient. */ public function set_specs_transient( $specs, $expiration = 0 ) { set_transient( $this->args['transient_name'], $specs, $expiration, ); } /** * Read a single data source and return the read specs * * @param string $url The URL to read the specs from. * * @return array The specs that have been read from the data source. */ protected static function read_data_source( $url ) { $logger_context = array( 'source' => $url ); $logger = self::get_logger(); $response = wp_remote_get( add_query_arg( 'locale', get_user_locale(), $url ), array( 'user-agent' => 'WooCommerce/' . WC_VERSION . '; ' . home_url( '/' ), ) ); if ( is_wp_error( $response ) || ! isset( $response['body'] ) ) { $logger->error( 'Error getting data feed', $logger_context ); // phpcs:ignore $logger->error( print_r( $response, true ), $logger_context ); return array(); } $body = $response['body']; $specs = json_decode( $body ); if ( null === $specs ) { $logger->error( 'Empty response in data feed', $logger_context ); return array(); } if ( ! is_array( $specs ) ) { $logger->error( 'Data feed is not an array', $logger_context ); return array(); } return $specs; } /** * Merge the specs. * * @param Array $specs_to_merge_in The specs to merge in to $specs. * @param Array $specs The list of specs being merged into. * @param string $url The url of the feed being merged in (for error reporting). */ protected function merge_specs( $specs_to_merge_in, &$specs, $url ) { foreach ( $specs_to_merge_in as $spec ) { if ( ! $this->validate_spec( $spec, $url ) ) { continue; } $id = $this->get_spec_key( $spec ); $specs[ $id ] = $spec; } } /** * Validate the spec. * * @param object $spec The spec to validate. * @param string $url The url of the feed that provided the spec. * * @return bool The result of the validation. */ protected function validate_spec( $spec, $url ) { $logger = self::get_logger(); $logger_context = array( 'source' => $url ); if ( ! $this->get_spec_key( $spec ) ) { $logger->error( 'Spec is invalid because the id is missing in feed', $logger_context ); // phpcs:ignore $logger->error( print_r( $spec, true ), $logger_context ); return false; } return true; } } RemoteSpecs/RemoteSpecsEngine.php 0000777 00000001540 15252240713 0013075 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\RemoteSpecs; /** * RemoteSpecsEngine class. */ abstract class RemoteSpecsEngine { /** * Log errors. * * @param array $errors Array of errors from \Throwable interface. */ public static function log_errors( $errors = array() ) { if ( true !== defined( 'WP_ENVIRONMENT_TYPE' ) || ! in_array( constant( 'WP_ENVIRONMENT_TYPE' ), array( 'development', 'local' ), true ) ) { return; } $logger = wc_get_logger(); $error_messages = array(); foreach ( $errors as $error ) { if ( isset( $error ) && method_exists( $error, 'getMessage' ) ) { $error_messages[] = $error->getMessage(); } } $logger->error( 'Error while evaluating specs', array( 'source' => 'remotespecsengine-errors', 'class' => static::class, 'errors' => $error_messages, ), ); } } RemoteSpecs/RuleProcessors/BaseLocationStateRuleProcessor.php 0000777 00000002355 15252240713 0020611 0 ustar 00 <?php /** * Rule processor that performs a comparison operation against the base * location - state. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; /** * Rule processor that performs a comparison operation against the base * location - state. */ class BaseLocationStateRuleProcessor implements RuleProcessorInterface { /** * Performs a comparison operation against the base location - state. * * @param object $rule The specific rule being processed by this rule processor. * @param object $stored_state Stored state. * * @return bool The result of the operation. */ public function process( $rule, $stored_state ) { $base_location = wc_get_base_location(); if ( ! is_array( $base_location ) || ! array_key_exists( 'state', $base_location ) ) { return false; } return ComparisonOperation::compare( $base_location['state'], $rule->value, $rule->operation ); } /** * Validates the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ) { if ( ! isset( $rule->value ) ) { return false; } if ( ! isset( $rule->operation ) ) { return false; } return true; } } RemoteSpecs/RuleProcessors/PublishBeforeTimeRuleProcessor.php 0000777 00000002744 15252240713 0020617 0 ustar 00 <?php /** * Rule processor for sending before a specified date/time. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\DateTimeProvider\CurrentDateTimeProvider; /** * Rule processor for sending before a specified date/time. */ class PublishBeforeTimeRuleProcessor implements RuleProcessorInterface { /** * The DateTime provider. * * @var DateTimeProviderInterface */ protected $date_time_provider; /** * Constructor. * * @param DateTimeProviderInterface $date_time_provider The DateTime provider. */ public function __construct( $date_time_provider = null ) { $this->date_time_provider = null === $date_time_provider ? new CurrentDateTimeProvider() : $date_time_provider; } /** * Process the rule. * * @param object $rule The specific rule being processed by this rule processor. * @param object $stored_state Stored state. * * @return bool Whether the rule passes or not. */ public function process( $rule, $stored_state ) { return $this->date_time_provider->get_now() <= new \DateTime( $rule->publish_before ); } /** * Validates the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ) { if ( ! isset( $rule->publish_before ) ) { return false; } try { new \DateTime( $rule->publish_before ); } catch ( \Throwable $e ) { return false; } return true; } } RemoteSpecs/RuleProcessors/WCAdminActiveForProvider.php 0000777 00000000771 15252240713 0017315 0 ustar 00 <?php /** * WCAdmin active for provider. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; use Automattic\WooCommerce\Admin\WCAdminHelper; defined( 'ABSPATH' ) || exit; /** * WCAdminActiveForProvider class */ class WCAdminActiveForProvider { /** * Get the number of seconds that the store has been active. * * @return number Number of seconds. */ public function get_wcadmin_active_for_in_seconds() { return WCAdminHelper::get_wcadmin_active_for_in_seconds(); } } RemoteSpecs/RuleProcessors/TotalPaymentsVolumeProcessor.php 0000777 00000004750 15252240713 0020412 0 ustar 00 <?php /** * Rule processor that passes when a store's payments volume exceeds a provided amount. */ declare(strict_types=1); namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Revenue\Query as RevenueQuery; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; /** * Rule processor that passes when a store's payments volume exceeds a provided amount. */ class TotalPaymentsVolumeProcessor implements RuleProcessorInterface { /** * Compare against the store's total payments volume. * * @param object $rule The rule being processed by this rule processor. * @param object $stored_state Stored state. * * @return bool The result of the operation. */ public function process( $rule, $stored_state ) { $dates = TimeInterval::get_timeframe_dates( $rule->timeframe ); $reports_revenue = $this->get_reports_query( array( 'before' => $dates['end'], 'after' => $dates['start'], 'interval' => 'year', 'fields' => array( 'total_sales' ), ) ); $report_data = $reports_revenue->get_data(); if ( ! $report_data || ! isset( $report_data->totals->total_sales ) ) { return false; } $value = $report_data->totals->total_sales; return ComparisonOperation::compare( $value, $rule->value, $rule->operation ); } /** * Validates the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ) { $allowed_timeframes = array( 'last_week', 'last_month', 'last_quarter', 'last_6_months', 'last_year', ); if ( ! isset( $rule->timeframe ) || ! in_array( $rule->timeframe, $allowed_timeframes, true ) ) { return false; } if ( ! isset( $rule->value ) ) { return false; } if ( ! isset( $rule->operation ) ) { return false; } // If the operation is range, the value must be an array of two numbers. if ( 'range' === $rule->operation ) { if ( ! is_array( $rule->value ) || count( $rule->value ) !== 2 ) { return false; } if ( ! is_numeric( $rule->value[0] ) || ! is_numeric( $rule->value[1] ) ) { return false; } } elseif ( ! is_numeric( $rule->value ) ) { return false; } return true; } /** * Get the report query. * * @param array $args The query args. * * @return RevenueQuery The report query. */ protected function get_reports_query( $args ) { return new RevenueQuery( $args ); } } RemoteSpecs/RuleProcessors/OrderCountRuleProcessor.php 0000777 00000002476 15252240713 0017335 0 ustar 00 <?php /** * Rule processor for publishing based on the number of orders. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; /** * Rule processor for publishing based on the number of orders. */ class OrderCountRuleProcessor implements RuleProcessorInterface { /** * The orders provider. * * @var OrdersProvider */ protected $orders_provider; /** * Constructor. * * @param object $orders_provider The orders provider. */ public function __construct( $orders_provider = null ) { $this->orders_provider = null === $orders_provider ? new OrdersProvider() : $orders_provider; } /** * Process the rule. * * @param object $rule The rule to process. * @param object $stored_state Stored state. * * @return bool Whether the rule passes or not. */ public function process( $rule, $stored_state ) { $count = $this->orders_provider->get_order_count(); return ComparisonOperation::compare( $count, $rule->value, $rule->operation ); } /** * Validates the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ) { if ( ! isset( $rule->value ) ) { return false; } if ( ! isset( $rule->operation ) ) { return false; } return true; } } RemoteSpecs/RuleProcessors/GetRuleProcessor.php 0000777 00000004055 15252240713 0015763 0 ustar 00 <?php /** * Gets the processor for the specified rule type. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; /** * Class encapsulating getting the processor for a given rule type. */ class GetRuleProcessor { /** * Get the processor for the specified rule type. * * @param string $rule_type The rule type. * * @return RuleProcessorInterface The matching processor for the specified rule type, or a FailRuleProcessor if no matching processor is found. */ public static function get_processor( $rule_type ) { switch ( $rule_type ) { case 'plugins_activated': return new PluginsActivatedRuleProcessor(); case 'publish_after_time': return new PublishAfterTimeRuleProcessor(); case 'publish_before_time': return new PublishBeforeTimeRuleProcessor(); case 'not': return new NotRuleProcessor(); case 'or': return new OrRuleProcessor(); case 'fail': return new FailRuleProcessor(); case 'pass': return new PassRuleProcessor(); case 'plugin_version': return new PluginVersionRuleProcessor(); case 'stored_state': return new StoredStateRuleProcessor(); case 'order_count': return new OrderCountRuleProcessor(); case 'wcadmin_active_for': return new WCAdminActiveForRuleProcessor(); case 'product_count': return new ProductCountRuleProcessor(); case 'onboarding_profile': return new OnboardingProfileRuleProcessor(); case 'is_ecommerce': return new IsEcommerceRuleProcessor(); case 'is_woo_express': return new IsWooExpressRuleProcessor(); case 'base_location_country': return new BaseLocationCountryRuleProcessor(); case 'base_location_state': return new BaseLocationStateRuleProcessor(); case 'note_status': return new NoteStatusRuleProcessor(); case 'option': return new OptionRuleProcessor(); case 'wca_updated': return new WooCommerceAdminUpdatedRuleProcessor(); case 'total_payments_value': return new TotalPaymentsVolumeProcessor(); } return new FailRuleProcessor(); } } RemoteSpecs/RuleProcessors/WCAdminActiveForRuleProcessor.php 0000777 00000003752 15252240713 0020334 0 ustar 00 <?php /** * Rule processor for publishing if wc-admin has been active for at least the * given number of seconds. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; /** * Rule processor for publishing if wc-admin has been active for at least the * given number of seconds. */ class WCAdminActiveForRuleProcessor implements RuleProcessorInterface { /** * Provides the amount of time wcadmin has been active for. * * @var WCAdminActiveForProvider */ protected $wcadmin_active_for_provider; /** * Constructor * * @param object $wcadmin_active_for_provider Provides the amount of time wcadmin has been active for. */ public function __construct( $wcadmin_active_for_provider = null ) { $this->wcadmin_active_for_provider = null === $wcadmin_active_for_provider ? new WCAdminActiveForProvider() : $wcadmin_active_for_provider; } /** * Performs a comparison operation against the amount of time wc-admin has * been active for in days. * * @param object $rule The rule being processed. * @param object $stored_state Stored state. * * @return bool The result of the operation. */ public function process( $rule, $stored_state ) { $active_for_seconds = $this->wcadmin_active_for_provider->get_wcadmin_active_for_in_seconds(); if ( ! $active_for_seconds || ! is_numeric( $active_for_seconds ) || $active_for_seconds < 0 ) { return false; } $rule_seconds = $rule->days * DAY_IN_SECONDS; return ComparisonOperation::compare( $active_for_seconds, $rule_seconds, $rule->operation ); } /** * Validates the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ) { // Ensure that 'days' property is set and is a valid numeric value. if ( ! isset( $rule->days ) || ! is_numeric( $rule->days ) || $rule->days < 0 ) { return false; } if ( ! isset( $rule->operation ) ) { return false; } return true; } } RemoteSpecs/RuleProcessors/PublishAfterTimeRuleProcessor.php 0000777 00000002736 15252240713 0020457 0 ustar 00 <?php /** * Rule processor for sending after a specified date/time. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\DateTimeProvider\CurrentDateTimeProvider; /** * Rule processor for sending after a specified date/time. */ class PublishAfterTimeRuleProcessor implements RuleProcessorInterface { /** * The DateTime provider. * * @var DateTimeProviderInterface */ protected $date_time_provider; /** * Constructor. * * @param DateTimeProviderInterface $date_time_provider The DateTime provider. */ public function __construct( $date_time_provider = null ) { $this->date_time_provider = null === $date_time_provider ? new CurrentDateTimeProvider() : $date_time_provider; } /** * Process the rule. * * @param object $rule The specific rule being processed by this rule processor. * @param object $stored_state Stored state. * * @return bool Whether the rule passes or not. */ public function process( $rule, $stored_state ) { return $this->date_time_provider->get_now() >= new \DateTime( $rule->publish_after ); } /** * Validates the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ) { if ( ! isset( $rule->publish_after ) ) { return false; } try { new \DateTime( $rule->publish_after ); } catch ( \Throwable $e ) { return false; } return true; } } RemoteSpecs/RuleProcessors/OrdersProvider.php 0000777 00000001301 15252240713 0015454 0 ustar 00 <?php /** * Provider for order-related queries and operations. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; /** * Provider for order-related queries and operations. */ class OrdersProvider { /** * Allowed order statuses for calculating milestones. * * @var array */ protected $allowed_statuses = array( 'pending', 'processing', 'completed', ); /** * Returns the number of orders. * * @return integer The number of orders. */ public function get_order_count() { $status_counts = array_map( 'wc_orders_count', $this->allowed_statuses ); $orders_count = array_sum( $status_counts ); return $orders_count; } } RemoteSpecs/RuleProcessors/StoredStateRuleProcessor.php 0000777 00000002350 15252240713 0017501 0 ustar 00 <?php /** * Rule processor that performs a comparison operation against a value in the * stored state object. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; /** * Rule processor that performs a comparison operation against a value in the * stored state object. */ class StoredStateRuleProcessor implements RuleProcessorInterface { /** * Performs a comparison operation against a value in the stored state object. * * @param object $rule The rule being processed by this rule processor. * @param object $stored_state Stored state. * * @return bool The result of the operation. */ public function process( $rule, $stored_state ) { if ( ! isset( $stored_state->{$rule->index} ) ) { return false; } return ComparisonOperation::compare( $stored_state->{$rule->index}, $rule->value, $rule->operation ); } /** * Validates the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ) { if ( ! isset( $rule->index ) ) { return false; } if ( ! isset( $rule->value ) ) { return false; } if ( ! isset( $rule->operation ) ) { return false; } return true; } } RemoteSpecs/RuleProcessors/NotRuleProcessor.php 0000777 00000002500 15252240713 0015775 0 ustar 00 <?php /** * Rule processor that negates the rules in the rule's operand. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; /** * Rule processor that negates the rules in the rule's operand. */ class NotRuleProcessor implements RuleProcessorInterface { /** * The rule evaluator to use. * * @var RuleEvaluator */ protected $rule_evaluator; /** * Constructor. * * @param RuleEvaluator $rule_evaluator The rule evaluator to use. */ public function __construct( $rule_evaluator = null ) { $this->rule_evaluator = null === $rule_evaluator ? new RuleEvaluator() : $rule_evaluator; } /** * Evaluates the rules in the operand and negates the result. * * @param object $rule The specific rule being processed by this rule processor. * @param object $stored_state Stored state. * * @return bool The result of the operation. */ public function process( $rule, $stored_state ) { $evaluated_operand = $this->rule_evaluator->evaluate( $rule->operand, $stored_state ); return ! $evaluated_operand; } /** * Validates the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ) { if ( ! isset( $rule->operand ) ) { return false; } return true; } } RemoteSpecs/RuleProcessors/IsEcommerceRuleProcessor.php 0000777 00000002163 15252240713 0017435 0 ustar 00 <?php /** * Rule processor that passes (or fails) when the site is on the eCommerce * plan. * * @package WooCommerce\Admin\Classes */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; /** * Rule processor that passes (or fails) when the site is on the eCommerce * plan. */ class IsEcommerceRuleProcessor implements RuleProcessorInterface { /** * Passes (or fails) based on whether the site is on the eCommerce plan or * not. * * @param object $rule The rule being processed by this rule processor. * @param object $stored_state Stored state. * * @return bool The result of the operation. */ public function process( $rule, $stored_state ) { if ( ! function_exists( 'wc_calypso_bridge_is_ecommerce_plan' ) ) { return false === $rule->value; } return (bool) wc_calypso_bridge_is_ecommerce_plan() === $rule->value; } /** * Validate the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ) { if ( ! isset( $rule->value ) ) { return false; } return true; } } RemoteSpecs/RuleProcessors/PluginVersionRuleProcessor.php 0000777 00000004263 15252240713 0020051 0 ustar 00 <?php /** * Rule processor for sending when the provided plugin is activated and * matches the specified version. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\PluginsProvider\PluginsProvider; /** * Rule processor for sending when the provided plugin is activated and * matches the specified version. */ class PluginVersionRuleProcessor implements RuleProcessorInterface { /** * Plugins provider instance. * * @var PluginsProviderInterface */ private $plugins_provider; /** * Constructor. * * @param PluginsProviderInterface $plugins_provider The plugins provider. */ public function __construct( $plugins_provider = null ) { $this->plugins_provider = null === $plugins_provider ? new PluginsProvider() : $plugins_provider; } /** * Process the rule. * * @param object $rule The specific rule being processed by this rule processor. * @param object $stored_state Stored state. * * @return bool Whether the rule passes or not. */ public function process( $rule, $stored_state ) { $active_plugin_slugs = $this->plugins_provider->get_active_plugin_slugs(); /** * Filters a plugin dependency’s slug before matching to the WordPress.org slug format. * * @since 9.0.0 * * @param string $plugin_name requested plugin name */ $plugin_name = apply_filters( 'wp_plugin_dependencies_slug', $rule->plugin ); if ( ! in_array( $plugin_name, $active_plugin_slugs, true ) ) { return false; } $plugin_data = $this->plugins_provider->get_plugin_data( $plugin_name ); if ( ! is_array( $plugin_data ) || ! array_key_exists( 'Version', $plugin_data ) ) { return false; } $plugin_version = $plugin_data['Version']; return version_compare( $plugin_version, $rule->version, $rule->operator ); } /** * Validates the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ) { if ( ! isset( $rule->plugin ) ) { return false; } if ( ! isset( $rule->version ) ) { return false; } if ( ! isset( $rule->operator ) ) { return false; } return true; } } RemoteSpecs/RuleProcessors/NoteStatusRuleProcessor.php 0000777 00000002454 15252240713 0017356 0 ustar 00 <?php /** * Rule processor that compares against the status of another note. For * example, this could be used to conditionally create a note only if another * note has not been actioned. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\Notes\Notes; /** * Rule processor that compares against the status of another note. */ class NoteStatusRuleProcessor implements RuleProcessorInterface { /** * Compare against the status of another note. * * @param object $rule The rule being processed by this rule processor. * @param object $stored_state Stored state. * * @return bool The result of the operation. */ public function process( $rule, $stored_state ) { $status = Notes::get_note_status( $rule->note_name ); if ( ! $status ) { return false; } return ComparisonOperation::compare( $status, $rule->status, $rule->operation ); } /** * Validates the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ) { if ( ! isset( $rule->note_name ) ) { return false; } if ( ! isset( $rule->status ) ) { return false; } if ( ! isset( $rule->operation ) ) { return false; } return true; } } RemoteSpecs/RuleProcessors/IsWooExpressRuleProcessor.php 0000777 00000003600 15252240713 0017651 0 ustar 00 <?php /** * Rule processor that passes (or fails) when the site is on a Woo Express plan. * * @package WooCommerce\Admin\Classes */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; /** * Rule processor that passes (or fails) when the site is on a Woo Express plan. * You may optionally pass a plan name to target a specific Woo Express plan. */ class IsWooExpressRuleProcessor implements RuleProcessorInterface { /** * Passes (or fails) based on whether the site is a Woo Express plan. * * @param object $rule The rule being processed by this rule processor. * @param object $stored_state Stored state. * * @return bool The result of the operation. */ public function process( $rule, $stored_state ) { if ( ! function_exists( 'wc_calypso_bridge_is_woo_express_plan' ) ) { return false === $rule->value; } // If the plan is undefined, only check if it's a Woo Express plan. if ( ! isset( $rule->plan ) ) { return wc_calypso_bridge_is_woo_express_plan() === $rule->value; } // If a plan name is defined, only evaluate the plan if we're on the Woo Express plan. if ( wc_calypso_bridge_is_woo_express_plan() ) { $fn = 'wc_calypso_bridge_is_woo_express_' . (string) $rule->plan . '_plan'; if ( function_exists( $fn ) ) { return $fn() === $rule->value; } // If an invalid plan name is given, only evaluate the rule if we're targeting all plans other than the specified (invalid) one. return false === $rule->value; } return false; } /** * Validate the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ) { if ( ! isset( $rule->value ) ) { return false; } if ( isset( $rule->plan ) ) { if ( ! function_exists( 'wc_calypso_bridge_is_woo_express_plan' ) ) { return false; } } return true; } } RemoteSpecs/RuleProcessors/ContextPluginsRuleProcessor.php 0000777 00000003516 15252240713 0020233 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; /** * Rule processor for context_plugins rules. * * Processes the following rule: * { * "type": "context_plugins", * "name": "name of a property in the plugin object", * "value": "value to match", * "operation": "operation" * } */ class ContextPluginsRuleProcessor implements RuleProcessorInterface { /** * The list of plugin objects. * * Plugin object is unmodified object from https://woocommerce.com/wp-json/wccom/obw-free-extensions/4.0/extensions.json * * Example: * { * "id": "WooCommerce Shipping", * "description": "description", * "is_visible": true, * "is_built_by_wc": true, * "key": "woocommerce-shipping", * } * * @var array a list of plugins. */ private array $plugins; /** * Constructor. * * @param array $plugins a list of plugins. */ public function __construct( array $plugins ) { $this->plugins = $plugins; } /** * Performs a comparison operation against the option value. * * @param object $rule The specific rule being processed by this rule processor. * @param object $stored_state Stored state. * * @return bool The result of the operation. */ public function process( $rule, $stored_state ) { foreach ( $this->plugins as $plugin ) { if ( ! isset( $plugin->{$rule->name} ) ) { continue; } if ( ComparisonOperation::compare( $plugin->{$rule->name}, $rule->value, $rule->operation ) ) { return true; } } return false; } /** * Validates the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ) { if ( ! isset( $rule->name ) || ! isset( $rule->value ) || ! isset( $rule->operation ) ) { return false; } return true; } } RemoteSpecs/RuleProcessors/OrRuleProcessor.php 0000777 00000002752 15252240713 0015626 0 ustar 00 <?php /** * Rule processor that performs an OR operation on the rule's left and right * operands. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; /** * Rule processor that performs an OR operation on the rule's left and right * operands. */ class OrRuleProcessor implements RuleProcessorInterface { /** * Rule evaluator to use. * * @var RuleEvaluator */ private $rule_evaluator; /** * Constructor. * * @param RuleEvaluator $rule_evaluator The rule evaluator to use. */ public function __construct( $rule_evaluator = null ) { $this->rule_evaluator = null === $rule_evaluator ? new RuleEvaluator() : $rule_evaluator; } /** * Performs an OR operation on the rule's left and right operands. * * @param object $rule The specific rule being processed by this rule processor. * @param object $stored_state Stored state. * * @return bool The result of the operation. */ public function process( $rule, $stored_state ) { foreach ( $rule->operands as $operand ) { $evaluated_operand = $this->rule_evaluator->evaluate( $operand, $stored_state ); if ( $evaluated_operand ) { return true; } } return false; } /** * Validates the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ) { if ( ! isset( $rule->operands ) || ! is_array( $rule->operands ) ) { return false; } return true; } } RemoteSpecs/RuleProcessors/EvaluationLogger.php 0000777 00000004064 15252240713 0015763 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; /** * Class EvaluationLogger * * @package Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors */ class EvaluationLogger { /** * Slug of the spec. * * @var string */ private $slug; /** * Results of rules in the given spec. * * @var array */ private $results = array(); /** * Logger class to use. * * @var \WC_Logger_Interface|null */ private $logger; /** * Logger source. * * @var string Logger source. */ private $source = ''; /** * EvaluationLogger constructor. * * @param string $slug Slug/ID of a spec that is being evaluated. * @param string|null $source Logger source. * @param \WC_Logger_Interface|null $logger Logger class to use. Default to using the WC logger. */ public function __construct( $slug, $source = null, ?\WC_Logger_Interface $logger = null ) { $this->slug = $slug; if ( null === $logger ) { $logger = wc_get_logger(); } if ( $source ) { $this->source = $source; } $this->logger = $logger; } /** * Add evaluation result of a rule. * * @param string $rule_type Name of the rule being tested. * @param boolean $result Result of a given rule. */ public function add_result( $rule_type, $result ) { $this->results[] = array( 'rule' => $rule_type, 'result' => $result ? 'passed' : 'failed', ); } /** * Log the results. */ public function log() { $should_log = defined( 'WC_ADMIN_DEBUG_RULE_EVALUATOR' ) && true === constant( 'WC_ADMIN_DEBUG_RULE_EVALUATOR' ); /** * Filter to determine if the rule evaluator should log the results. * * @since 9.2.0 * * @param bool $should_log Whether the rule evaluator should log the results. */ if ( ! apply_filters( 'woocommerce_admin_remote_specs_evaluator_should_log', $should_log ) ) { return; } foreach ( $this->results as $result ) { $this->logger->debug( "[{$this->slug}] {$result['rule']}: {$result['result']}", array( 'source' => $this->source ) ); } } } RemoteSpecs/RuleProcessors/ComparisonOperation.php 0000777 00000004634 15252240713 0016512 0 ustar 00 <?php /** * Compare two operands using the specified operation. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; /** * Compare two operands using the specified operation. */ class ComparisonOperation { /** * Compare two operands using the specified operation. * * @param object $left_operand The left hand operand. * @param object $right_operand The right hand operand -- 'value' from the rule definition. * @param string $operation The operation used to compare the operands. */ public static function compare( $left_operand, $right_operand, $operation ) { switch ( $operation ) { case '=': return $left_operand === $right_operand; case '<': return $left_operand < $right_operand; case '<=': return $left_operand <= $right_operand; case '>': return $left_operand > $right_operand; case '>=': return $left_operand >= $right_operand; case '!=': return $left_operand !== $right_operand; case 'contains': if ( is_array( $left_operand ) && is_string( $right_operand ) ) { return in_array( $right_operand, $left_operand, true ); } if ( is_string( $right_operand ) && is_string( $left_operand ) ) { return strpos( $right_operand, $left_operand ) !== false; } break; case '!contains': if ( is_array( $left_operand ) && is_string( $right_operand ) ) { return ! in_array( $right_operand, $left_operand, true ); } if ( is_string( $right_operand ) && is_string( $left_operand ) ) { return strpos( $right_operand, $left_operand ) === false; } break; case 'in': if ( is_array( $right_operand ) && is_string( $left_operand ) ) { return in_array( $left_operand, $right_operand, true ); } if ( is_string( $left_operand ) && is_string( $right_operand ) ) { return strpos( $left_operand, $right_operand ) !== false; } break; case '!in': if ( is_array( $right_operand ) && is_string( $left_operand ) ) { return ! in_array( $left_operand, $right_operand, true ); } if ( is_string( $left_operand ) && is_string( $right_operand ) ) { return strpos( $left_operand, $right_operand ) === false; } break; case 'range': if ( ! is_array( $right_operand ) || count( $right_operand ) !== 2 ) { return false; } return $left_operand >= $right_operand[0] && $left_operand <= $right_operand[1]; } return false; } } RemoteSpecs/RuleProcessors/RuleProcessorInterface.php 0000777 00000001223 15252240713 0017136 0 ustar 00 <?php /** * Interface for a rule processor. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; /** * Rule processor interface */ interface RuleProcessorInterface { /** * Processes a rule, returning the boolean result of the processing. * * @param object $rule The rule to process. * @param object $stored_state Stored state. * * @return bool The result of the processing. */ public function process( $rule, $stored_state ); /** * Validates the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ); } RemoteSpecs/RuleProcessors/ProductCountRuleProcessor.php 0000777 00000003204 15252240713 0017670 0 ustar 00 <?php /** * Rule processor that performs a comparison operation against the number of * products. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; use Automattic\WooCommerce\Enums\ProductStatus; defined( 'ABSPATH' ) || exit; /** * Rule processor that performs a comparison operation against the number of * products. */ class ProductCountRuleProcessor implements RuleProcessorInterface { /** * The product query. * * @var WC_Product_Query */ protected $product_query; /** * Constructor. * * @param object $product_query The product query. */ public function __construct( $product_query = null ) { $this->product_query = null === $product_query ? new \WC_Product_Query( array( 'limit' => 1, 'paginate' => true, 'return' => 'ids', 'status' => array( ProductStatus::PUBLISH ), ) ) : $product_query; } /** * Performs a comparison operation against the number of products. * * @param object $rule The specific rule being processed by this rule processor. * @param object $stored_state Stored state. * * @return bool The result of the operation. */ public function process( $rule, $stored_state ) { $products = $this->product_query->get_products(); return ComparisonOperation::compare( $products->total, $rule->value, $rule->operation ); } /** * Validates the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ) { if ( ! isset( $rule->value ) ) { return false; } if ( ! isset( $rule->operation ) ) { return false; } return true; } } RemoteSpecs/RuleProcessors/PluginsActivatedRuleProcessor.php 0000777 00000003523 15252240713 0020511 0 ustar 00 <?php /** * Rule processor for sending when the provided plugins are activated. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\PluginsProvider\PluginsProvider; /** * Rule processor for sending when the provided plugins are activated. */ class PluginsActivatedRuleProcessor implements RuleProcessorInterface { /** * The plugins provider. * * @var PluginsProviderInterface */ protected $plugins_provider; /** * Constructor. * * @param PluginsProviderInterface $plugins_provider The plugins provider. */ public function __construct( $plugins_provider = null ) { $this->plugins_provider = null === $plugins_provider ? new PluginsProvider() : $plugins_provider; } /** * Process the rule. * * @param object $rule The specific rule being processed by this rule processor. * @param object $stored_state Stored state. * * @return bool Whether the rule passes or not. */ public function process( $rule, $stored_state ) { if ( ! is_countable( $rule->plugins ) || 0 === count( $rule->plugins ) ) { return false; } $active_plugin_slugs = $this->plugins_provider->get_active_plugin_slugs(); foreach ( $rule->plugins as $plugin_slug ) { if ( ! is_string( $plugin_slug ) ) { $logger = wc_get_logger(); $logger->warning( __( 'Invalid plugin slug provided in the plugins activated rule.', 'woocommerce' ) ); return false; } if ( ! in_array( $plugin_slug, $active_plugin_slugs, true ) ) { return false; } } return true; } /** * Validates the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ) { if ( ! isset( $rule->plugins ) || ! is_array( $rule->plugins ) ) { return false; } return true; } } RemoteSpecs/RuleProcessors/StoredStateSetupForProducts.php 0000777 00000007154 15252240713 0020174 0 ustar 00 <?php /** * Handles stored state setup for products. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\RemoteInboxNotifications\RemoteInboxNotificationsEngine; /** * Handles stored state setup for products. */ class StoredStateSetupForProducts { const ASYNC_RUN_REMOTE_NOTIFICATIONS_ACTION_NAME = 'woocommerce_admin/stored_state_setup_for_products/async/run_remote_notifications'; /** * Initialize the class via the admin_init hook. */ public static function admin_init() { add_action( 'product_page_product_importer', array( __CLASS__, 'run_on_product_importer' ) ); add_action( 'transition_post_status', array( __CLASS__, 'run_on_transition_post_status' ), 10, 3 ); } /** * Initialize the class via the init hook. * * @internal */ final public static function init() { add_action( self::ASYNC_RUN_REMOTE_NOTIFICATIONS_ACTION_NAME, array( __CLASS__, 'run_remote_notifications' ) ); } /** * Run the remote notifications engine. This is triggered by * action-scheduler after a product is added. It also cleans up from * setting the product count increment. */ public static function run_remote_notifications() { RemoteInboxNotificationsEngine::run(); } /** * Set initial stored state values. * * @param object $stored_state The stored state. * * @return object The stored state. */ public static function init_stored_state( $stored_state ) { $stored_state->there_were_no_products = ! self::are_there_products(); $stored_state->there_are_now_products = ! $stored_state->there_were_no_products; return $stored_state; } /** * Are there products query. * * @return bool */ private static function are_there_products() { $query = new \WC_Product_Query( array( 'limit' => 1, 'paginate' => true, 'return' => 'ids', 'status' => array( 'publish' ), ) ); $products = $query->get_products(); $count = $products->total; return $count > 0; } /** * Runs on product importer steps. */ public static function run_on_product_importer() { // We're only interested in when the importer completes. // phpcs:disable WordPress.Security.NonceVerification.Recommended if ( ! isset( $_REQUEST['step'] ) ) { return; } if ( 'done' !== $_REQUEST['step'] ) { return; } // phpcs:enable self::update_stored_state_and_possibly_run_remote_notifications(); } /** * Runs when a post status transitions, but we're only interested if it is * a product being published. * * @param string $new_status The new status. * @param string $old_status The old status. * @param Post $post The post. */ public static function run_on_transition_post_status( $new_status, $old_status, $post ) { if ( 'product' !== $post->post_type || 'publish' !== $new_status ) { return; } self::update_stored_state_and_possibly_run_remote_notifications(); } /** * Enqueues an async action (using action-scheduler) to run remote * notifications. */ private static function update_stored_state_and_possibly_run_remote_notifications() { $stored_state = RemoteInboxNotificationsEngine::get_stored_state(); // If the stored_state is the same, we don't need to run remote notifications to avoid unnecessary action scheduling. if ( true === $stored_state->there_are_now_products ) { return; } $stored_state->there_are_now_products = true; RemoteInboxNotificationsEngine::update_stored_state( $stored_state ); // Run self::run_remote_notifications asynchronously. as_enqueue_async_action( self::ASYNC_RUN_REMOTE_NOTIFICATIONS_ACTION_NAME ); } } RemoteSpecs/RuleProcessors/OptionRuleProcessor.php 0000777 00000006176 15252240713 0016522 0 ustar 00 <?php /** * Rule processor that performs a comparison operation against an option value. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers\TransformerService; defined( 'ABSPATH' ) || exit; /** * Rule processor that performs a comparison operation against an option value. */ class OptionRuleProcessor implements RuleProcessorInterface { /** * Performs a comparison operation against the option value. * * @param object $rule The specific rule being processed by this rule processor. * @param object $stored_state Stored state. * * @return bool The result of the operation. */ public function process( $rule, $stored_state ) { $is_contains = $rule->operation && strpos( $rule->operation, 'contains' ) !== false; $value_when_default_not_provided = $is_contains ? array() : false; $is_default_set = property_exists( $rule, 'default' ); $default_value = $is_default_set ? $rule->default : $value_when_default_not_provided; $option_value = $this->get_option_value( $rule, $default_value, $is_contains ); if ( isset( $rule->transformers ) && is_array( $rule->transformers ) ) { $option_value = TransformerService::apply( $option_value, $rule->transformers, $is_default_set, $default_value ); } return ComparisonOperation::compare( $option_value, $rule->value, $rule->operation ); } /** * Retrieves the option value and handles logging if necessary. * * @param object $rule The specific rule being processed. * @param mixed $default_value The default value. * @param bool $is_contains Indicates whether the operation is "contains". * * @return mixed The option value. */ private function get_option_value( $rule, $default_value, $is_contains ) { $option_value = get_option( $rule->option_name, $default_value ); $is_contains_valid = $is_contains && ( is_array( $option_value ) || ( is_string( $option_value ) && is_string( $rule->value ) ) ); if ( $is_contains && ! $is_contains_valid ) { $logger = wc_get_logger(); $logger->warning( sprintf( 'ComparisonOperation "%s" option value "%s" is not an array, defaulting to empty array.', $rule->operation, $rule->option_name ), array( 'option_value' => $option_value, 'rule' => $rule, ) ); $option_value = array(); } return $option_value; } /** * Validates the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ) { if ( ! isset( $rule->option_name ) ) { return false; } if ( ! isset( $rule->value ) ) { return false; } if ( ! isset( $rule->operation ) ) { return false; } if ( isset( $rule->transformers ) && is_array( $rule->transformers ) ) { foreach ( $rule->transformers as $transform_args ) { $transformer = TransformerService::create_transformer( $transform_args->use ); if ( ! $transformer->validate( $transform_args->arguments ) ) { return false; } } } return true; } } RemoteSpecs/RuleProcessors/RuleEvaluator.php 0000777 00000004711 15252240713 0015305 0 ustar 00 <?php /** * Evaluate the given rules as an AND operation - return false early if a * rule evaluates to false. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; /** * Evaluate the given rules as an AND operation - return false early if a * rule evaluates to false. */ class RuleEvaluator { /** * GetRuleProcessor to use. * * @var GetRuleProcessor */ private $get_rule_processor; /** * Constructor. * * @param GetRuleProcessor $get_rule_processor The GetRuleProcessor to use. */ public function __construct( $get_rule_processor = null ) { $this->get_rule_processor = null === $get_rule_processor ? new GetRuleProcessor() : $get_rule_processor; } /** * Evaluate the given rules as an AND operation - return false early if a * rule evaluates to false. * * @param array|object $rules The rule or rules being processed. * @param object|null $stored_state Stored state. * @param array $logger_args Arguments for the rule evaluator logger. `slug` is required. * * @throws \InvalidArgumentException Thrown when $logger_args is missing slug. * * @return bool The result of the operation. */ public function evaluate( $rules, $stored_state = null, $logger_args = array() ) { if ( is_bool( $rules ) ) { return $rules; } if ( ! is_array( $rules ) ) { $rules = array( $rules ); } if ( 0 === count( $rules ) ) { return false; } $evaluation_logger = null; if ( count( $logger_args ) ) { if ( ! array_key_exists( 'slug', $logger_args ) ) { throw new \InvalidArgumentException( 'Missing required field: slug in $logger_args.' ); } $source = isset( $logger_args['source'] ) ? $logger_args['source'] : null; $evaluation_logger = new EvaluationLogger( $logger_args['slug'], $source ); } foreach ( $rules as $rule ) { if ( ! is_object( $rule ) ) { $evaluation_logger && $evaluation_logger->add_result( 'rule not an object', false ); $evaluation_logger && $evaluation_logger->log(); return false; } $processor = $this->get_rule_processor->get_processor( $rule->type ); $processor_result = $processor->process( $rule, $stored_state ); $evaluation_logger && $evaluation_logger->add_result( $rule->type, $processor_result ); if ( ! $processor_result ) { $evaluation_logger && $evaluation_logger->log(); return false; } } $evaluation_logger && $evaluation_logger->log(); return true; } } RemoteSpecs/RuleProcessors/BaseLocationCountryRuleProcessor.php 0000777 00000003755 15252240713 0021201 0 ustar 00 <?php /** * Rule processor that performs a comparison operation against the base * location - country. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile; defined( 'ABSPATH' ) || exit; /** * Rule processor that performs a comparison operation against the base * location - country. */ class BaseLocationCountryRuleProcessor implements RuleProcessorInterface { /** * Performs a comparison operation against the base location - country. * * @param object $rule The specific rule being processed by this rule processor. * @param object $stored_state Stored state. * * @return bool The result of the operation. */ public function process( $rule, $stored_state ) { $base_location = wc_get_base_location(); if ( ! is_array( $base_location ) || ! array_key_exists( 'country', $base_location ) || ! array_key_exists( 'state', $base_location ) ) { return false; } $onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() ); $is_address_default = 'US' === $base_location['country'] && 'CA' === $base_location['state'] && empty( get_option( 'woocommerce_store_address', '' ) ); $is_store_country_set = isset( $onboarding_profile['is_store_country_set'] ) && $onboarding_profile['is_store_country_set']; // Return false if the location is the default country and if onboarding hasn't been finished or the store address not been updated. if ( $is_address_default && OnboardingProfile::needs_completion() && ! $is_store_country_set ) { return false; } return ComparisonOperation::compare( $base_location['country'], $rule->value, $rule->operation ); } /** * Validates the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ) { if ( ! isset( $rule->value ) ) { return false; } if ( ! isset( $rule->operation ) ) { return false; } return true; } } RemoteSpecs/RuleProcessors/OnboardingProfileRuleProcessor.php 0000777 00000002647 15252240713 0020654 0 ustar 00 <?php /** * Rule processor that performs a comparison operation against a value in the * onboarding profile. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; /** * Rule processor that performs a comparison operation against a value in the * onboarding profile. */ class OnboardingProfileRuleProcessor implements RuleProcessorInterface { /** * Performs a comparison operation against a value in the onboarding * profile. * * @param object $rule The rule being processed by this rule processor. * @param object $stored_state Stored state. * * @return bool The result of the operation. */ public function process( $rule, $stored_state ) { $onboarding_profile = get_option( 'woocommerce_onboarding_profile' ); if ( empty( $onboarding_profile ) || ! is_array( $onboarding_profile ) ) { return false; } if ( ! isset( $onboarding_profile[ $rule->index ] ) ) { return false; } return ComparisonOperation::compare( $onboarding_profile[ $rule->index ], $rule->value, $rule->operation ); } /** * Validates the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ) { if ( ! isset( $rule->index ) ) { return false; } if ( ! isset( $rule->value ) ) { return false; } if ( ! isset( $rule->operation ) ) { return false; } return true; } } RemoteSpecs/RuleProcessors/PassRuleProcessor.php 0000777 00000001411 15252240713 0016143 0 ustar 00 <?php /** * Rule processor that passes. This is required because an empty set of rules * (or predicate) evaluates to false. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; /** * Rule processor that passes. */ class PassRuleProcessor implements RuleProcessorInterface { /** * Passes the rule. * * @param object $rule The specific rule being processed by this rule processor. * @param object $stored_state Stored state. * * @return bool Always true. */ public function process( $rule, $stored_state ) { return true; } /** * Validates the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ) { return true; } } RemoteSpecs/RuleProcessors/WooCommerceAdminUpdatedRuleProcessor.php 0000777 00000001716 15252240713 0021744 0 ustar 00 <?php /** * Rule processor for sending when WooCommerce Admin has been updated. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; use Automattic\WooCommerce\Admin\RemoteInboxNotifications\RemoteInboxNotificationsEngine; defined( 'ABSPATH' ) || exit; /** * Rule processor for sending when WooCommerce Admin has been updated. */ class WooCommerceAdminUpdatedRuleProcessor implements RuleProcessorInterface { /** * Process the rule. * * @param object $rule The specific rule being processed by this rule processor. * @param object $stored_state Stored state. * * @return bool Whether the rule passes or not. */ public function process( $rule, $stored_state ) { return get_option( RemoteInboxNotificationsEngine::WCA_UPDATED_OPTION_NAME, false ); } /** * Validates the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ) { return true; } } RemoteSpecs/RuleProcessors/FailRuleProcessor.php 0000777 00000001263 15252240713 0016115 0 ustar 00 <?php /** * Rule processor that fails. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; /** * Rule processor that fails. */ class FailRuleProcessor implements RuleProcessorInterface { /** * Fails the rule. * * @param object $rule The specific rule being processed by this rule processor. * @param object $stored_state Stored state. * * @return bool Always false. */ public function process( $rule, $stored_state ) { return false; } /** * Validates the rule. * * @param object $rule The rule to validate. * * @return bool Pass/fail. */ public function validate( $rule ) { return true; } } RemoteSpecs/RuleProcessors/Transformers/ArrayKeys.php 0000777 00000001771 15252240713 0017115 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers; use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers\TransformerInterface; use stdClass; /** * Search array value by one of its key. * * @package Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers */ class ArrayKeys implements TransformerInterface { /** * Search array value by one of its key. * * @param mixed $value a value to transform. * @param stdClass|null $arguments arguments. * @param string|null $default_value default value. * * @return mixed */ public function transform( $value, ?stdClass $arguments = null, $default_value = array() ) { if ( ! is_array( $value ) ) { return $default_value; } return array_keys( $value ); } /** * Validate Transformer arguments. * * @param stdClass|null $arguments arguments to validate. * * @return mixed */ public function validate( ?stdClass $arguments = null ) { return true; } } RemoteSpecs/RuleProcessors/Transformers/ArrayColumn.php 0000777 00000002532 15252240713 0017433 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers; use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers\TransformerInterface; use InvalidArgumentException; use stdClass; /** * Search array value by one of its key. * * @package Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers */ class ArrayColumn implements TransformerInterface { /** * Search array value by one of its key. * * @param mixed $value a value to transform. * @param stdClass|null $arguments required arguments 'key'. * @param string|null $default_value default value. * * @throws InvalidArgumentException Throws when the required argument 'key' is missing. * * @return mixed */ public function transform( $value, ?stdClass $arguments = null, $default_value = array() ) { if ( ! is_array( $value ) ) { return $default_value; } return array_column( $value, $arguments->key ); } /** * Validate Transformer arguments. * * @param stdClass|null $arguments arguments to validate. * * @return mixed */ public function validate( ?stdClass $arguments = null ) { if ( ! isset( $arguments->key ) ) { return false; } if ( null !== $arguments->key && ! is_string( $arguments->key ) && ! is_int( $arguments->key ) ) { return false; } return true; } } RemoteSpecs/RuleProcessors/Transformers/ArrayValues.php 0000777 00000001775 15252240713 0017445 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers; use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers\TransformerInterface; use stdClass; /** * Search array value by one of its key. * * @package Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers */ class ArrayValues implements TransformerInterface { /** * Search array value by one of its key. * * @param mixed $value a value to transform. * @param stdClass|null $arguments arguments. * @param string|null $default_value default value. * * @return mixed */ public function transform( $value, ?stdClass $arguments = null, $default_value = array() ) { if ( ! is_array( $value ) ) { return $default_value; } return array_values( $value ); } /** * Validate Transformer arguments. * * @param stdClass|null $arguments arguments to validate. * * @return mixed */ public function validate( ?stdClass $arguments = null ) { return true; } } RemoteSpecs/RuleProcessors/Transformers/ArrayFlatten.php 0000777 00000002144 15252240713 0017572 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers; use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers\TransformerInterface; use stdClass; /** * Flatten nested array. * * @package Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers */ class ArrayFlatten implements TransformerInterface { /** * Search a given value in the array. * * @param mixed $value a value to transform. * @param stdClass|null $arguments arguments. * @param string|null $default_value default value. * * @return mixed|null */ public function transform( $value, ?stdClass $arguments = null, $default_value = array() ) { if ( ! is_array( $value ) ) { return $default_value; } $return = array(); array_walk_recursive( $value, function ( $item ) use ( &$return ) { $return[] = $item; } ); return $return; } /** * Validate Transformer arguments. * * @param stdClass|null $arguments arguments to validate. * * @return mixed */ public function validate( ?stdClass $arguments = null ) { return true; } } RemoteSpecs/RuleProcessors/Transformers/TransformerService.php 0000777 00000004726 15252240713 0021031 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers; use InvalidArgumentException; use stdClass; /** * A simple service class for the Transformer classes. * * Class TransformerService * * @package Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers */ class TransformerService { /** * Create a transformer object by name. * * @param string $name name of the transformer. * * @return TransformerInterface|null */ public static function create_transformer( $name ) { $camel_cased = str_replace( ' ', '', ucwords( str_replace( '_', ' ', $name ) ) ); $classname = __NAMESPACE__ . '\\' . $camel_cased; if ( ! class_exists( $classname ) ) { return null; } return new $classname(); } /** * Apply transformers to the given value. * * @param mixed $target_value a value to transform. * @param array $transformer_configs transform configuration. * @param bool $is_default_set flag on is default value set. * @param string $default_value default value. * * @throws InvalidArgumentException Throws when one of the required arguments is missing. * @return mixed|null */ public static function apply( $target_value, array $transformer_configs, $is_default_set, $default_value ) { foreach ( $transformer_configs as $transformer_config ) { if ( ! isset( $transformer_config->use ) ) { throw new InvalidArgumentException( 'Missing required config value: use' ); } if ( ! isset( $transformer_config->arguments ) ) { $transformer_config->arguments = null; } $transformer = self::create_transformer( $transformer_config->use ); if ( null === $transformer ) { throw new InvalidArgumentException( "Unable to find a transformer by name: {$transformer_config->use}" ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } $target_value = $transformer->transform( $target_value, $transformer_config->arguments, $is_default_set ? $default_value : null ); // Break early when there's no more value to traverse. if ( null === $target_value ) { break; } } if ( $is_default_set ) { // Nulls always return the default value. if ( null === $target_value ) { return $default_value; } // When type of the default value is different from the target value, return the default value // to ensure type safety. if ( gettype( $default_value ) !== gettype( $target_value ) ) { return $default_value; } } return $target_value; } } RemoteSpecs/RuleProcessors/Transformers/PrepareUrl.php 0000777 00000002447 15252240713 0017265 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers; use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers\TransformerInterface; use stdClass; /** * Prepare site URL for comparison. * * @package Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers */ class PrepareUrl implements TransformerInterface { /** * Prepares the site URL by removing the protocol and trailing slash. * * @param string $value a value to transform. * @param stdClass|null $arguments arguments. * @param string|null $default_value default value. * * @return mixed|null */ public function transform( $value, ?stdClass $arguments = null, $default_value = null ) { if ( ! is_string( $value ) ) { return $default_value; } $url_parts = wp_parse_url( rtrim( $value, '/' ) ); if ( ! $url_parts ) { return $default_value; } if ( ! isset( $url_parts['host'] ) ) { return $default_value; } if ( isset( $url_parts['path'] ) ) { return $url_parts['host'] . $url_parts['path']; } return $url_parts['host']; } /** * Validate Transformer arguments. * * @param stdClass|null $arguments arguments to validate. * * @return mixed */ public function validate( ?stdClass $arguments = null ) { return true; } } RemoteSpecs/RuleProcessors/Transformers/TransformerInterface.php 0000777 00000001501 15252240713 0021315 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers; use stdClass; /** * An interface to define a transformer. * * Interface TransformerInterface * * @package Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers */ interface TransformerInterface { /** * Transform given value to a different value. * * @param mixed $value a value to transform. * @param stdClass|null $arguments arguments. * @param string|null $default_value default value. * * @return mixed|null */ public function transform( $value, ?stdClass $arguments = null, $default_value = null ); /** * Validate Transformer arguments. * * @param stdClass|null $arguments arguments to validate. * * @return mixed */ public function validate( ?stdClass $arguments = null ); } RemoteSpecs/RuleProcessors/Transformers/Count.php 0000777 00000002042 15252240713 0016263 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers; use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers\TransformerInterface; use stdClass; /** * Count elements in Array or Countable object. * * @package Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers */ class Count implements TransformerInterface { /** * Count elements in Array or Countable object. * * @param array|Countable $value an array to count. * @param stdClass|null $arguments arguments. * @param string|null $default_value default value. * * @return number */ public function transform( $value, ?stdClass $arguments = null, $default_value = null ) { if ( ! is_array( $value ) && ! $value instanceof \Countable ) { return $default_value; } return count( $value ); } /** * Validate Transformer arguments. * * @param stdClass|null $arguments arguments to validate. * * @return mixed */ public function validate( ?stdClass $arguments = null ) { return true; } } RemoteSpecs/RuleProcessors/Transformers/DotNotation.php 0000777 00000004113 15252240713 0017436 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers; use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers\TransformerInterface; use InvalidArgumentException; use stdClass; /** * Find an array value by dot notation. * * @package Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers */ class DotNotation implements TransformerInterface { /** * Find given path from the given value. * * @param mixed $value a value to transform. * @param stdClass|null $arguments required argument 'path'. * @param string|null $default_value default value. * * @throws InvalidArgumentException Throws when the required 'path' is missing. * * @return mixed */ public function transform( $value, ?stdclass $arguments = null, $default_value = null ) { if ( is_object( $value ) ) { // if the value is an object, convert it to an array. $value = json_decode( wp_json_encode( $value ), true ); } return $this->get( $value, $arguments->path, $default_value ); } /** * Find the given $path in $array_to_search by dot notation. * * @param array $array_to_search an array to search in. * @param string $path a path in the given array. * @param null $default_value default value to return if $path was not found. * * @return mixed|null */ public function get( $array_to_search, $path, $default_value = null ) { if ( ! is_array( $array_to_search ) ) { return $default_value; } if ( isset( $array_to_search[ $path ] ) ) { return $array_to_search[ $path ]; } foreach ( explode( '.', $path ) as $segment ) { if ( ! is_array( $array_to_search ) || ! array_key_exists( $segment, $array_to_search ) ) { return $default_value; } $array_to_search = $array_to_search[ $segment ]; } return $array_to_search; } /** * Validate Transformer arguments. * * @param stdClass|null $arguments arguments to validate. * * @return mixed */ public function validate( ?stdClass $arguments = null ) { if ( ! isset( $arguments->path ) ) { return false; } return true; } } RemoteSpecs/RuleProcessors/Transformers/ArraySearch.php 0000777 00000002446 15252240713 0017407 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers; use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers\TransformerInterface; use InvalidArgumentException; use stdClass; /** * Searches a given a given value in the array. * * @package Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\Transformers */ class ArraySearch implements TransformerInterface { /** * Search a given value in the array. * * @param mixed $value a value to transform. * @param stdClass|null $arguments required argument 'value'. * @param string|null $default_value default value. * * @throws InvalidArgumentException Throws when the required 'value' is missing. * * @return mixed|null */ public function transform( $value, ?stdClass $arguments = null, $default_value = null ) { if ( ! is_array( $value ) ) { return $default_value; } $key = array_search( $arguments->value, $value, true ); if ( false !== $key ) { return $value[ $key ]; } return null; } /** * Validate Transformer arguments. * * @param stdClass|null $arguments arguments to validate. * * @return mixed */ public function validate( ?stdClass $arguments = null ) { if ( ! isset( $arguments->value ) ) { return false; } return true; } } RemoteSpecs/RuleProcessors/EvaluateOverrides.php 0000777 00000004565 15252240713 0016153 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; /** * Evaluates `overrides` property in the spec and returns the evaluated spec. */ class EvaluateOverrides { /** * Evaluates the spec and returns a status. * * @param array $spec The spec to evaluate. * @param array $context The context variables. * * @return array The evaluated spec. */ public function evaluate( array $spec, array $context = array() ) { $rule_evaluator = new RuleEvaluator( new GetRuleProcessorForContext( $context ) ); foreach ( $spec as $spec_item ) { if ( isset( $spec_item->overrides ) && is_array( $spec_item->overrides ) ) { foreach ( $spec_item->overrides as $override ) { if ( ! isset( $override->rules ) || ! is_array( $override->rules ) || ! isset( $override->field ) || ! isset( $override->value ) ) { continue; } if ( $rule_evaluator->evaluate( $override->rules ) ) { // If value exisit and can be accessed directly, update it. if ( isset( $spec_item->{$override->field} ) ) { $spec_item->{$override->field} = $override->value; } else { // Otherwise, try updating it using dot notation. $this->set_value_with_dot_notation( $spec_item, $override->field, $override->value ); } } } } } return $spec; } /** * Set a new value to $data with dot notation. * * This is a slightly modified version of the simple dot notation to support objects. * * @param mixed $data The data to update. * @param string $path The path to the value to update. * @param mixed $new_value The new value. * * @return mixed|\stdClass */ public function set_value_with_dot_notation( &$data, $path, $new_value ) { $keys = explode( '.', $path ); $last_key = array_pop( $keys ); foreach ( $keys as $key ) { if ( is_numeric( $key ) ) { $key = (int) $key; if ( ! isset( $data[ $key ] ) || ! is_object( $data[ $key ] ) ) { $data[ $key ] = new \stdClass(); } $data = &$data[ $key ]; } else { if ( ! isset( $data->$key ) || ( ! is_array( $data->$key ) && ! is_object( $data->$key ) ) ) { $data->$key = new \stdClass(); } $data = &$data->$key; } } // Assign the new value. if ( is_numeric( $last_key ) ) { $data[ (int) $last_key ] = $new_value; } else { $data->$last_key = $new_value; } return $data; } } RemoteSpecs/RuleProcessors/EvaluateAndGetStatus.php 0000777 00000003544 15252240713 0016553 0 ustar 00 <?php /** * Evaluates the spec and returns a status. */ namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\Notes\Note; /** * Evaluates the spec and returns a status. */ class EvaluateAndGetStatus { /** * Evaluates the spec and returns a status. * * @param array $spec The spec to evaluate. * @param string $current_status The note's current status. * @param object $stored_state Stored state. * @param object $rule_evaluator Evaluates rules into true/false. * * @return string The evaluated status. */ public static function evaluate( $spec, $current_status, $stored_state, $rule_evaluator ) { // No rules should leave the note alone. if ( ! isset( $spec->rules ) ) { return $current_status; } $evaluated_result = $rule_evaluator->evaluate( $spec->rules, $stored_state, array( 'slug' => $spec->slug, 'source' => 'remote-inbox-notifications', ) ); // Pending notes should be the spec status if the spec passes, // left alone otherwise. if ( Note::E_WC_ADMIN_NOTE_PENDING === $current_status ) { return $evaluated_result ? $spec->status : Note::E_WC_ADMIN_NOTE_PENDING; } // If the spec is an alert type and the note is unactioned, set to pending if the spec no longer applies. if ( isset( $spec->type ) && in_array( $spec->type, array( 'error', 'update' ), true ) && Note::E_WC_ADMIN_NOTE_UNACTIONED === $current_status && ! $evaluated_result ) { return Note::E_WC_ADMIN_NOTE_PENDING; } // When allow_redisplay isn't set, just leave the note alone. if ( ! isset( $spec->allow_redisplay ) || ! $spec->allow_redisplay ) { return $current_status; } // allow_redisplay is set, unaction the note if eval to true. return $evaluated_result ? Note::E_WC_ADMIN_NOTE_UNACTIONED : $current_status; } } RemoteSpecs/RuleProcessors/GetRuleProcessorForContext.php 0000777 00000002061 15252240713 0017772 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors; /** * A custom GetRuleProcessor class to support context_vars and context_plugins rule types. * * GetRuleProcessor class. */ class GetRuleProcessorForContext { /** * Contains the context variables. * * @var array $context The context variables. */ protected array $context; /** * Constructor. * * @param array $context The context variables. */ public function __construct( array $context = array() ) { $this->context = $context; } /** * Get the processor for the specified rule type. * * @param string $rule_type The rule type. * * @return RuleProcessorInterface The matching processor for the specified rule type, or a FailRuleProcessor if no matching processor is found. */ public function get_processor( $rule_type ) { switch ( $rule_type ) { case 'context_plugins': return new ContextPluginsRuleProcessor( $this->context['plugins'] ?? array() ); } return GetRuleProcessor::get_processor( $rule_type ); } } ReportCSVEmail.php 0000777 00000010102 15252240713 0010056 0 ustar 00 <?php /** * Handles emailing users CSV Export download links. */ namespace Automattic\WooCommerce\Admin; if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Include dependencies. */ if ( ! class_exists( 'WC_Email', false ) ) { include_once WC_ABSPATH . 'includes/emails/class-wc-email.php'; } /** * ReportCSVEmail Class. */ class ReportCSVEmail extends \WC_Email { /** * Report labels. * * @var array */ protected $report_labels; /** * Report type (e.g. 'customers'). * * @var string */ protected $report_type; /** * Download URL. * * @var string */ protected $download_url; /** * Constructor. */ public function __construct() { $this->id = 'admin_report_export_download'; $this->template_base = WC()->plugin_path() . '/includes/react-admin/emails/'; $this->template_html = 'html-admin-report-export-download.php'; $this->template_plain = 'plain-admin-report-export-download.php'; /** * Used to customise report email labels. * * @since 9.9.0 * * @param string[] $labels An array of labels. * * @return string[] An Array of labels. */ $this->report_labels = apply_filters( 'woocommerce_report_export_email_labels', array( 'categories' => __( 'Categories', 'woocommerce' ), 'coupons' => __( 'Coupons', 'woocommerce' ), 'customers' => __( 'Customers', 'woocommerce' ), 'downloads' => __( 'Downloads', 'woocommerce' ), 'orders' => __( 'Orders', 'woocommerce' ), 'products' => __( 'Products', 'woocommerce' ), 'revenue' => __( 'Revenue', 'woocommerce' ), 'stock' => __( 'Stock', 'woocommerce' ), 'taxes' => __( 'Taxes', 'woocommerce' ), 'variations' => __( 'Variations', 'woocommerce' ), ) ); // Call parent constructor. parent::__construct(); } /** * This email has no user-facing settings. */ public function init_form_fields() {} /** * This email has no user-facing settings. */ public function init_settings() {} /** * Return email type. * * @return string */ public function get_email_type() { return class_exists( 'DOMDocument' ) ? 'html' : 'plain'; } /** * Get email heading. * * @return string */ public function get_default_heading() { return __( 'Your Report Download', 'woocommerce' ); } /** * Get email subject. * * @return string */ public function get_default_subject() { return __( '[{site_title}]: Your {report_name} Report download is ready', 'woocommerce' ); } /** * Get content html. * * @return string */ public function get_content_html() { return wc_get_template_html( $this->template_html, array( 'report_name' => $this->report_type, 'download_url' => $this->download_url, 'email_heading' => $this->get_heading(), 'sent_to_admin' => true, 'plain_text' => false, 'email' => $this, ), '', $this->template_base ); } /** * Get content plain. * * @return string */ public function get_content_plain() { return wc_get_template_html( $this->template_plain, array( 'report_name' => $this->report_type, 'download_url' => $this->download_url, 'email_heading' => $this->get_heading(), 'sent_to_admin' => true, 'plain_text' => true, 'email' => $this, ), '', $this->template_base ); } /** * Trigger the sending of this email. * * @param int $user_id User ID to email. * @param string $report_type The type of report export being emailed. * @param string $download_url The URL for downloading the report. */ public function trigger( $user_id, $report_type, $download_url ) { $user = new \WP_User( $user_id ); $this->recipient = $user->user_email; $this->download_url = $download_url; if ( isset( $this->report_labels[ $report_type ] ) ) { $this->report_type = $this->report_labels[ $report_type ]; $this->placeholders['{report_name}'] = $this->report_type; } $this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() ); } } API/ProductCategories.php 0000777 00000000712 15252240713 0011324 0 ustar 00 <?php /** * REST API Product Categories Controller * * Handles requests to /products/categories. */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Product categories controller. * * @internal * @extends WC_REST_Product_Categories_Controller */ class ProductCategories extends \WC_REST_Product_Categories_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; } API/ProductVariations.php 0000777 00000014042 15252240713 0011357 0 ustar 00 <?php /** * REST API Product Variations Controller * * Handles requests to /products/variations. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Enums\ProductType; defined( 'ABSPATH' ) || exit; /** * Product variations controller. * * @internal * @extends WC_REST_Product_Variations_Controller */ class ProductVariations extends \WC_REST_Product_Variations_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Register the routes for products. */ public function register_routes() { parent::register_routes(); // Add a route for listing variations without specifying the parent product ID. register_rest_route( $this->namespace, '/variations', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['search'] = array( 'description' => __( 'Search by similar product name, sku, or attribute value.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Add in conditional search filters for variations. * * @internal * @param string $where Where clause used to search posts. * @param object $wp_query WP_Query object. * @return string */ public static function add_wp_query_filter( $where, $wp_query ) { global $wpdb; $search = $wp_query->get( 'search' ); if ( $search ) { $like = '%' . $wpdb->esc_like( $search ) . '%'; $conditions = array( $wpdb->prepare( "{$wpdb->posts}.post_title LIKE %s", $like ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->prepare( 'attr_search_meta.meta_value LIKE %s', $like ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared ); if ( wc_product_sku_enabled() ) { $conditions[] = $wpdb->prepare( 'wc_product_meta_lookup.sku LIKE %s', $like ); } $where .= ' AND (' . implode( ' OR ', $conditions ) . ')'; } return $where; } /** * Join posts meta tables when variation search query is present. * * @internal * @param string $join Join clause used to search posts. * @param object $wp_query WP_Query object. * @return string */ public static function add_wp_query_join( $join, $wp_query ) { global $wpdb; $search = $wp_query->get( 'search' ); if ( $search ) { $join .= " LEFT JOIN {$wpdb->postmeta} AS attr_search_meta ON {$wpdb->posts}.ID = attr_search_meta.post_id AND attr_search_meta.meta_key LIKE 'attribute_%' "; } if ( wc_product_sku_enabled() && ! strstr( $join, 'wc_product_meta_lookup' ) ) { $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 product name and sku filtering to the WC API. * * @param WP_REST_Request $request Request data. * @return array */ protected function prepare_objects_query( $request ) { $args = parent::prepare_objects_query( $request ); if ( ! empty( $request['search'] ) ) { $args['search'] = $request['search']; unset( $args['s'] ); } // Retrieve variations without specifying a parent product. if ( "/{$this->namespace}/variations" === $request->get_route() ) { unset( $args['post_parent'] ); } return $args; } /** * Get a collection of posts and add the post title filter option to WP_Query. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { add_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10, 2 ); add_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10, 2 ); add_filter( 'posts_groupby', array( 'Automattic\WooCommerce\Admin\API\Products', 'add_wp_query_group_by' ), 10, 2 ); $response = parent::get_items( $request ); remove_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10 ); remove_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10 ); remove_filter( 'posts_groupby', array( 'Automattic\WooCommerce\Admin\API\Products', 'add_wp_query_group_by' ), 10 ); return $response; } /** * Get the Product's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = parent::get_item_schema(); $schema['properties']['name'] = array( 'description' => __( 'Product parent name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), ); $schema['properties']['type'] = array( 'description' => __( 'Product type.', 'woocommerce' ), 'type' => 'string', 'default' => ProductType::VARIATION, 'enum' => array( ProductType::VARIATION ), 'context' => array( 'view', 'edit' ), ); $schema['properties']['parent_id'] = array( 'description' => __( 'Product parent ID.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), ); return $schema; } /** * Prepare a single variation output for response. * * @param WC_Data $object Object data. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_object_for_response( $object, $request ) { $context = empty( $request['context'] ) ? 'view' : $request['context']; $response = parent::prepare_object_for_response( $object, $request ); $data = $response->get_data(); $data['name'] = $object->get_name( $context ); $data['type'] = $object->get_type(); $data['parent_id'] = $object->get_parent_id( $context ); $response->set_data( $data ); return $response; } } API/MarketingCampaigns.php 0000777 00000023214 15252240713 0011444 0 ustar 00 <?php /** * REST API MarketingCampaigns Controller * * Handles requests to /marketing/campaigns. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Admin\Marketing\MarketingCampaign; use Automattic\WooCommerce\Admin\Marketing\MarketingChannels as MarketingChannelsService; use Automattic\WooCommerce\Admin\Marketing\Price; use WC_REST_Controller; use WP_Error; use WP_REST_Request; use WP_REST_Response; defined( 'ABSPATH' ) || exit; /** * MarketingCampaigns Controller. * * @internal * @extends WC_REST_Controller * @since x.x.x */ class MarketingCampaigns extends WC_REST_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'marketing/campaigns'; /** * 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_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Check whether a given request has permission to view marketing campaigns. * * @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( 'settings', 'read' ) ) { return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Returns an aggregated array of marketing campaigns for all active marketing channels. * * @param WP_REST_Request $request Request data. * * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { /** * MarketingChannels class. * * @var MarketingChannelsService $marketing_channels_service */ $marketing_channels_service = wc_get_container()->get( MarketingChannelsService::class ); // Aggregate the campaigns from all registered marketing channels. $responses = array(); foreach ( $marketing_channels_service->get_registered_channels() as $channel ) { foreach ( $channel->get_campaigns() as $campaign ) { $response = $this->prepare_item_for_response( $campaign, $request ); $responses[] = $this->prepare_response_for_collection( $response ); } } // Pagination. $page = $request['page']; $items_per_page = $request['per_page']; $offset = ( $page - 1 ) * $items_per_page; $paginated_results = array_slice( $responses, $offset, $items_per_page ); $response = rest_ensure_response( $paginated_results ); $total_campaigns = count( $responses ); $max_pages = ceil( $total_campaigns / $items_per_page ); $response->header( 'X-WP-Total', $total_campaigns ); $response->header( 'X-WP-TotalPages', (int) $max_pages ); // Add previous and next page links to response header. $request_params = $request->get_query_params(); $base = add_query_arg( urlencode_deep( $request_params ), rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) ); if ( $page > 1 ) { $prev_page = $page - 1; if ( $prev_page > $max_pages ) { $prev_page = $max_pages; } $prev_link = add_query_arg( 'page', $prev_page, $base ); $response->link_header( 'prev', $prev_link ); } if ( $max_pages > $page ) { $next_page = $page + 1; $next_link = add_query_arg( 'page', $next_page, $base ); $response->link_header( 'next', $next_link ); } return $response; } /** * Get formatted price based on Price type. * * This uses plugins/woocommerce/i18n/currency-info.php and plugins/woocommerce/i18n/locale-info.php to get option object based on $price->currency. * * Example: * * - When $price->currency is 'USD' and $price->value is '1000', it should return '$1000.00'. * - When $price->currency is 'JPY' and $price->value is '1000', it should return '¥1,000'. * - When $price->currency is 'AED' and $price->value is '1000', it should return '5.000,00 د.إ'. * * @param Price $price Price object. * @return String formatted price. */ private function get_formatted_price( $price ) { // Get $num_decimals to be passed to wc_price. $locale_info_all = include WC()->plugin_path() . '/i18n/locale-info.php'; $locale_index = array_search( $price->get_currency(), array_column( $locale_info_all, 'currency_code' ), true ); $locale = array_values( $locale_info_all )[ $locale_index ]; $num_decimals = $locale['num_decimals']; // Get $currency_info based on user locale or default locale. $currency_locales = $locale['locales']; $user_locale = get_user_locale(); $currency_info = $currency_locales[ $user_locale ] ?? $currency_locales['default']; // Get $price_format to be passed to wc_price. $currency_pos = $currency_info['currency_pos']; $currency_formats = array( 'left' => '%1$s%2$s', 'right' => '%2$s%1$s', 'left_space' => '%1$s %2$s', 'right_space' => '%2$s %1$s', ); $price_format = $currency_formats[ $currency_pos ] ?? $currency_formats['left']; $price_value = wc_format_decimal( $price->get_value() ); $price_formatted = wc_price( $price_value, array( 'currency' => $price->get_currency(), 'decimal_separator' => $currency_info['decimal_sep'], 'thousand_separator' => $currency_info['thousand_sep'], 'decimals' => $num_decimals, 'price_format' => $price_format, ) ); return html_entity_decode( wp_strip_all_tags( $price_formatted ) ); } /** * Prepares the item for the REST response. * * @param MarketingCampaign $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. */ public function prepare_item_for_response( $item, $request ) { $data = array( 'id' => $item->get_id(), 'channel' => $item->get_type()->get_channel()->get_slug(), 'title' => $item->get_title(), 'manage_url' => $item->get_manage_url(), ); if ( $item->get_cost() instanceof Price ) { $data['cost'] = array( 'value' => wc_format_decimal( $item->get_cost()->get_value() ), 'currency' => $item->get_cost()->get_currency(), 'formatted' => $this->get_formatted_price( $item->get_cost() ), ); } if ( $item->get_sales() instanceof Price ) { $data['sales'] = array( 'value' => wc_format_decimal( $item->get_sales()->get_value() ), 'currency' => $item->get_sales()->get_currency(), 'formatted' => $this->get_formatted_price( $item->get_sales() ), ); } $context = $request['context'] ?? 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); return rest_ensure_response( $data ); } /** * Retrieves the item's schema, conforming to JSON Schema. * * @return array Item schema data. */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'marketing_campaign', 'type' => 'object', 'properties' => array( 'id' => array( 'description' => __( 'The unique identifier for the marketing campaign.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), 'channel' => array( 'description' => __( 'The unique identifier for the marketing channel that this campaign belongs to.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), 'title' => array( 'description' => __( 'Title of the marketing campaign.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), 'manage_url' => array( 'description' => __( 'URL to the campaign management page.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), 'cost' => array( 'description' => __( 'Cost of the marketing campaign.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'type' => 'object', 'properties' => array( 'value' => array( 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), 'currency' => array( 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), ), ), 'sales' => array( 'description' => __( 'Sales of the marketing campaign.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'type' => 'object', 'properties' => array( 'value' => array( 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), 'currency' => array( 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), ), ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Retrieves the query params for the collections. * * @return array Query parameters for the collection. */ public function get_collection_params() { $params = parent::get_collection_params(); unset( $params['search'] ); return $params; } } API/Coupons.php 0000777 00000004232 15252240713 0007325 0 ustar 00 <?php /** * REST API Coupons Controller * * Handles requests to /coupons/* */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Coupons controller. * * @internal * @extends WC_REST_Coupons_Controller */ class Coupons extends \WC_REST_Coupons_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['search'] = array( 'description' => __( 'Limit results to coupons with codes matching a given string.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Add coupon code searching to the WC API. * * @param WP_REST_Request $request Request data. * @return array */ protected function prepare_objects_query( $request ) { $args = parent::prepare_objects_query( $request ); if ( ! empty( $request['search'] ) ) { $args['search'] = $request['search']; $args['s'] = false; } return $args; } /** * Get a collection of posts and add the code search option to WP_Query. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { add_filter( 'posts_where', array( __CLASS__, 'add_wp_query_search_code_filter' ), 10, 2 ); $response = parent::get_items( $request ); remove_filter( 'posts_where', array( __CLASS__, 'add_wp_query_search_code_filter' ), 10 ); return $response; } /** * Add code searching to the WP Query * * @internal * @param string $where Where clause used to search posts. * @param object $wp_query WP_Query object. * @return string */ public static function add_wp_query_search_code_filter( $where, $wp_query ) { global $wpdb; $search = $wp_query->get( 'search' ); if ( $search ) { $code_like = '%' . $wpdb->esc_like( $search ) . '%'; $where .= $wpdb->prepare( "AND {$wpdb->posts}.post_title LIKE %s", $code_like ); } return $where; } } API/OnboardingTasks.php 0000777 00000100026 15252240713 0010765 0 ustar 00 <?php /** * REST API Onboarding Tasks Controller * * Handles requests to complete various onboarding tasks. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Enums\ProductStatus; use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingIndustries; use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile; use Automattic\WooCommerce\Admin\Features\Features; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists; use Automattic\WooCommerce\Admin\Features\OnboardingTasks\DeprecatedExtendedTask; defined( 'ABSPATH' ) || exit; /** * Onboarding Tasks Controller. * * @internal * @extends WC_REST_Data_Controller */ class OnboardingTasks extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'onboarding/tasks'; /** * Duration to millisecond mapping. * * @var array */ protected $duration_to_ms = array( 'day' => DAY_IN_SECONDS * 1000, 'hour' => HOUR_IN_SECONDS * 1000, 'week' => WEEK_IN_SECONDS * 1000, ); /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/import_sample_products', array( array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'import_sample_products' ), 'permission_callback' => array( $this, 'create_products_permission_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/create_homepage', array( array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_homepage' ), 'permission_callback' => array( $this, 'create_pages_permission_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/create_product_from_template', array( array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_product_from_template' ), 'permission_callback' => array( $this, 'create_products_permission_check' ), 'args' => array_merge( $this->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE ), array( 'template_name' => array( 'required' => true, 'type' => 'string', 'description' => __( 'Product template name.', 'woocommerce' ), ), ) ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_tasks' ), 'permission_callback' => array( $this, 'get_tasks_permission_check' ), 'args' => array( 'ids' => array( 'description' => __( 'Optional parameter to get only specific task lists by id.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'enum' => TaskLists::get_list_ids(), 'type' => 'string', ), ), ), ), array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'get_tasks' ), 'permission_callback' => array( $this, 'get_tasks_permission_check' ), 'args' => $this->get_task_list_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/hide', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'hide_task_list' ), 'permission_callback' => array( $this, 'hide_task_list_permission_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/unhide', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'unhide_task_list' ), 'permission_callback' => array( $this, 'hide_task_list_permission_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/dismiss', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'dismiss_task' ), 'permission_callback' => array( $this, 'get_tasks_permission_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/undo_dismiss', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'undo_dismiss_task' ), 'permission_callback' => array( $this, 'get_tasks_permission_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[a-z0-9_-]+)/snooze', array( 'args' => array( 'duration' => array( 'description' => __( 'Time period to snooze the task.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => function( $param, $request, $key ) { return in_array( $param, array_keys( $this->duration_to_ms ), true ); }, ), 'task_list_id' => array( 'description' => __( 'Optional parameter to query specific task list.', 'woocommerce' ), 'type' => 'string', ), ), array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'snooze_task' ), 'permission_callback' => array( $this, 'snooze_task_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/action', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'action_task' ), 'permission_callback' => array( $this, 'get_tasks_permission_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[a-z0-9_\-]+)/undo_snooze', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'undo_snooze_task' ), 'permission_callback' => array( $this, 'snooze_task_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Check if a given request has access to create a product. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function create_products_permission_check( $request ) { if ( ! wc_rest_check_post_permissions( 'product', 'create' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_create', __( 'Sorry, you are not allowed to create resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Check if a given request has access to create a product. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function create_pages_permission_check( $request ) { if ( ! wc_rest_check_post_permissions( 'page', 'create' ) || ! current_user_can( 'manage_options' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_create', __( 'Sorry, you are not allowed to create new pages.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Check if a given request has access to manage woocommerce. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function get_tasks_permission_check( $request ) { if ( ! current_user_can( 'manage_woocommerce' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_create', __( 'Sorry, you are not allowed to retrieve onboarding tasks.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Check if a given request has permission to hide task lists. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function hide_task_list_permission_check( $request ) { if ( ! current_user_can( 'manage_woocommerce' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you are not allowed to hide task lists.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Check if a given request has access to manage woocommerce. * * @deprecated 7.8.0 snooze task is deprecated. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function snooze_task_permissions_check( $request ) { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.8.0' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_create', __( 'Sorry, you are not allowed to snooze onboarding tasks.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Import sample products from given CSV path. * * @param string $csv_file CSV file path. * @return WP_Error|WP_REST_Response */ public static function import_sample_products_from_csv( $csv_file ) { include_once WC_ABSPATH . 'includes/import/class-wc-product-csv-importer.php'; if ( file_exists( $csv_file ) && class_exists( 'WC_Product_CSV_Importer' ) ) { // Override locale so we can return mappings from WooCommerce in English language stores. add_filter( 'locale', '__return_false', 9999 ); $importer_class = apply_filters( 'woocommerce_product_csv_importer_class', 'WC_Product_CSV_Importer' ); $args = array( 'parse' => true, 'mapping' => self::get_header_mappings( $csv_file ), ); $args = apply_filters( 'woocommerce_product_csv_importer_args', $args, $importer_class ); $importer = new $importer_class( $csv_file, $args ); $import = $importer->import(); return $import; } else { return new \WP_Error( 'woocommerce_rest_import_error', __( 'Sorry, the sample products data file was not found.', 'woocommerce' ) ); } } /** * Import sample products from WooCommerce sample CSV. * * @internal * @return WP_Error|WP_REST_Response */ public static function import_sample_products() { $sample_csv_file = Features::is_enabled( 'experimental-fashion-sample-products' ) ? WC_ABSPATH . 'sample-data/experimental_fashion_sample_9_products.csv' : WC_ABSPATH . 'sample-data/experimental_sample_9_products.csv'; $import = self::import_sample_products_from_csv( $sample_csv_file ); return rest_ensure_response( $import ); } /** * Creates a product from a template name passed in through the template_name param. * * @internal * @param WP_REST_Request $request Request data. * @return WP_REST_Response|WP_Error */ public static function create_product_from_template( $request ) { $template_name = basename( $request->get_param( 'template_name' ) ); $template_path = __DIR__ . '/Templates/' . $template_name . '_product.csv'; $template_path = apply_filters( 'woocommerce_product_template_csv_file_path', $template_path, $template_name ); $import = self::import_sample_products_from_csv( $template_path ); if ( is_wp_error( $import ) || ! is_array( $import['imported'] ) || 0 === count( $import['imported'] ) ) { return new \WP_Error( 'woocommerce_rest_product_creation_error', /* translators: %s is template name */ __( 'Sorry, creating the product with template failed.', 'woocommerce' ), array( 'status' => 500 ) ); } $product = wc_get_product( $import['imported'][0] ); $product->set_status( ProductStatus::AUTO_DRAFT ); $product->save(); return rest_ensure_response( array( 'id' => $product->get_id(), ) ); } /** * Get header mappings from CSV columns. * * @internal * @param string $file File path. * @return array Mapped headers. */ public static function get_header_mappings( $file ) { include_once WC_ABSPATH . 'includes/admin/importers/mappings/mappings.php'; $importer_class = apply_filters( 'woocommerce_product_csv_importer_class', 'WC_Product_CSV_Importer' ); $importer = new $importer_class( $file, array() ); $raw_headers = $importer->get_raw_keys(); $default_columns = wc_importer_default_english_mappings( array() ); $special_columns = wc_importer_default_special_english_mappings( array() ); $headers = array(); foreach ( $raw_headers as $key => $field ) { $index = $field; $headers[ $index ] = $field; if ( isset( $default_columns[ $field ] ) ) { $headers[ $index ] = $default_columns[ $field ]; } else { foreach ( $special_columns as $regex => $special_key ) { if ( preg_match( self::sanitize_special_column_name_regex( $regex ), $field, $matches ) ) { $headers[ $index ] = $special_key . $matches[1]; break; } } } } return $headers; } /** * Sanitize special column name regex. * * @internal * @param string $value Raw special column name. * @return string */ public static function sanitize_special_column_name_regex( $value ) { return '/' . str_replace( array( '%d', '%s' ), '(.*)', trim( quotemeta( $value ) ) ) . '/'; } /** * Returns a valid cover block with an image, if one exists, or background as a fallback. * * @internal * @param array $image Image to use for the cover block. Should contain a media ID and image URL. * @return string Block content. */ private static function get_homepage_cover_block( $image ) { $shop_url = wc_get_page_permalink( 'shop' ); if ( ! empty( $image['url'] ) && ! empty( $image['id'] ) ) { return '<!-- wp:cover {"url":"' . esc_url( $image['url'] ) . '","id":' . intval( $image['id'] ) . ',"dimRatio":0} --> <div class="wp-block-cover" style="background-image:url(' . esc_url( $image['url'] ) . ')"><div class="wp-block-cover__inner-container"><!-- wp:paragraph {"align":"center","placeholder":"' . __( 'Write title…', 'woocommerce' ) . '","textColor":"white","fontSize":"large"} --> <p class="has-text-align-center has-large-font-size">' . __( 'Welcome to the store', 'woocommerce' ) . '</p> <!-- /wp:paragraph --> <!-- wp:paragraph {"align":"center","textColor":"white"} --> <p class="has-text-color has-text-align-center">' . __( 'Write a short welcome message here', 'woocommerce' ) . '</p> <!-- /wp:paragraph --> <!-- wp:buttons {"layout":{"type":"flex","justifyContent":"center"}} --> <div class="wp-block-buttons"><!-- wp:button --> <div class="wp-block-button"><a class="wp-block-button__link" href="' . esc_url( $shop_url ) . '">' . __( 'Go shopping', 'woocommerce' ) . '</a></div> <!-- /wp:button --></div> <!-- /wp:buttons --></div></div> <!-- /wp:cover -->'; } return '<!-- wp:cover {"dimRatio":0} --> <div class="wp-block-cover"><div class="wp-block-cover__inner-container"><!-- wp:paragraph {"align":"center","placeholder":"' . __( 'Write title…', 'woocommerce' ) . '","textColor":"white","fontSize":"large"} --> <p class="has-text-color has-text-align-center has-large-font-size">' . __( 'Welcome to the store', 'woocommerce' ) . '</p> <!-- /wp:paragraph --> <!-- wp:paragraph {"align":"center","textColor":"white"} --> <p class="has-text-color has-text-align-center">' . __( 'Write a short welcome message here', 'woocommerce' ) . '</p> <!-- /wp:paragraph --> <!-- wp:buttons {"layout":{"type":"flex","justifyContent":"center"}} --> <div class="wp-block-buttons"><!-- wp:button --> <div class="wp-block-button"><a class="wp-block-button__link" href="' . esc_url( $shop_url ) . '">' . __( 'Go shopping', 'woocommerce' ) . '</a></div> <!-- /wp:button --></div> <!-- /wp:buttons --></div></div> <!-- /wp:cover -->'; } /** * Returns a valid media block with an image, if one exists, or a uninitialized media block the user can set. * * @internal * @param array $image Image to use for the cover block. Should contain a media ID and image URL. * @param string $align If the image should be aligned to the left or right. * @return string Block content. */ private static function get_homepage_media_block( $image, $align = 'left' ) { $media_position = 'right' === $align ? '"mediaPosition":"right",' : ''; $css_class = 'right' === $align ? ' has-media-on-the-right' : ''; if ( ! empty( $image['url'] ) && ! empty( $image['id'] ) ) { return '<!-- wp:media-text {' . $media_position . '"mediaId":' . intval( $image['id'] ) . ',"mediaType":"image"} --> <div class="wp-block-media-text alignwide' . $css_class . '""><figure class="wp-block-media-text__media"><img src="' . esc_url( $image['url'] ) . '" alt="" class="wp-image-' . intval( $image['id'] ) . '"/></figure><div class="wp-block-media-text__content"><!-- wp:paragraph {"placeholder":"' . __( 'Content…', 'woocommerce' ) . '","fontSize":"large"} --> <p class="has-large-font-size"></p> <!-- /wp:paragraph --></div></div> <!-- /wp:media-text -->'; } return '<!-- wp:media-text {' . $media_position . '} --> <div class="wp-block-media-text alignwide' . $css_class . '"><figure class="wp-block-media-text__media"></figure><div class="wp-block-media-text__content"><!-- wp:paragraph {"placeholder":"' . __( 'Content…', 'woocommerce' ) . '","fontSize":"large"} --> <p class="has-large-font-size"></p> <!-- /wp:paragraph --></div></div> <!-- /wp:media-text -->'; } /** * Returns a homepage template to be inserted into a post. A different template will be used depending on the number of products. * * @internal * @param int $post_id ID of the homepage template. * @return string Template contents. */ private static function get_homepage_template( $post_id ) { $products = wp_count_posts( 'product' ); if ( $products->publish >= 4 ) { $images = self::sideload_homepage_images( $post_id, 1 ); $image_1 = ! empty( $images[0] ) ? $images[0] : ''; $template = self::get_homepage_cover_block( $image_1 ) . ' <!-- wp:heading {"align":"center"} --> <h2 style="text-align:center">' . __( 'Shop by Category', 'woocommerce' ) . '</h2> <!-- /wp:heading --> <!-- wp:shortcode --> [product_categories number="0" parent="0"] <!-- /wp:shortcode --> <!-- wp:heading {"align":"center"} --> <h2 style="text-align:center">' . __( 'New In', 'woocommerce' ) . '</h2> <!-- /wp:heading --> <!-- wp:woocommerce/product-new {"columns":4} /--> <!-- wp:heading {"align":"center"} --> <h2 style="text-align:center">' . __( 'Fan Favorites', 'woocommerce' ) . '</h2> <!-- /wp:heading --> <!-- wp:woocommerce/product-top-rated {"columns":4} /--> <!-- wp:heading {"align":"center"} --> <h2 style="text-align:center">' . __( 'On Sale', 'woocommerce' ) . '</h2> <!-- /wp:heading --> <!-- wp:woocommerce/product-on-sale {"columns":4} /--> <!-- wp:heading {"align":"center"} --> <h2 style="text-align:center">' . __( 'Best Sellers', 'woocommerce' ) . '</h2> <!-- /wp:heading --> <!-- wp:woocommerce/product-best-sellers {"columns":4} /--> '; /** * Modify the template/content of the default homepage. * * @param string $template The default homepage template. */ return apply_filters( 'woocommerce_admin_onboarding_homepage_template', $template ); } $images = self::sideload_homepage_images( $post_id, 3 ); $image_1 = ! empty( $images[0] ) ? $images[0] : ''; $image_2 = ! empty( $images[1] ) ? $images[1] : ''; $image_3 = ! empty( $images[2] ) ? $images[2] : ''; $template = self::get_homepage_cover_block( $image_1 ) . ' <!-- wp:heading {"align":"center"} --> <h2 style="text-align:center">' . __( 'New Products', 'woocommerce' ) . '</h2> <!-- /wp:heading --> <!-- wp:woocommerce/product-new /--> ' . self::get_homepage_media_block( $image_1, 'right' ) . self::get_homepage_media_block( $image_2, 'left' ) . self::get_homepage_media_block( $image_3, 'right' ) . ' <!-- wp:woocommerce/featured-product /-->'; /** This filter is documented in src/API/OnboardingTasks.php. */ return apply_filters( 'woocommerce_admin_onboarding_homepage_template', $template ); } /** * Gets the possible industry images from the plugin folder for sideloading. If an image doesn't exist, other.jpg is used a fallback. * * @internal * @return array An array of images by industry. */ private static function get_available_homepage_images() { $industry_images = array(); $industries = OnboardingIndustries::get_allowed_industries(); foreach ( $industries as $industry_slug => $label ) { $industry_images[ $industry_slug ] = apply_filters( 'woocommerce_admin_onboarding_industry_image', WC_ADMIN_IMAGES_FOLDER_URL . '/onboarding/other-small.jpg', $industry_slug ); } return $industry_images; } /** * Uploads a number of images to a homepage template, depending on the selected industry from the profile wizard. * * @internal * @param int $post_id ID of the homepage template. * @param int $number_of_images The number of images that should be sideloaded (depending on how many media slots are in the template). * @return array An array of images that have been attached to the post. */ private static function sideload_homepage_images( $post_id, $number_of_images ) { $profile = get_option( OnboardingProfile::DATA_OPTION, array() ); $images_to_sideload = array(); $available_images = self::get_available_homepage_images(); require_once ABSPATH . 'wp-admin/includes/image.php'; require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; if ( ! empty( $profile['industry'] ) ) { foreach ( $profile['industry'] as $selected_industry ) { if ( is_string( $selected_industry ) ) { $industry_slug = $selected_industry; } elseif ( is_array( $selected_industry ) && ! empty( $selected_industry['slug'] ) ) { $industry_slug = $selected_industry['slug']; } else { continue; } // Capture the first industry for use in our minimum images logic. $first_industry = isset( $first_industry ) ? $first_industry : $industry_slug; $images_to_sideload[] = ! empty( $available_images[ $industry_slug ] ) ? $available_images[ $industry_slug ] : $available_images['other']; } } // Make sure we have at least {$number_of_images} images. if ( count( $images_to_sideload ) < $number_of_images ) { for ( $i = count( $images_to_sideload ); $i < $number_of_images; $i++ ) { // Fill up missing image slots with the first selected industry, or other. $industry = isset( $first_industry ) ? $first_industry : 'other'; $images_to_sideload[] = empty( $available_images[ $industry ] ) ? $available_images['other'] : $available_images[ $industry ]; } } $already_sideloaded = array(); $images_for_post = array(); foreach ( $images_to_sideload as $image ) { // Avoid uploading two of the same image, if an image is repeated. if ( ! empty( $already_sideloaded[ $image ] ) ) { $images_for_post[] = $already_sideloaded[ $image ]; continue; } $sideload_id = \media_sideload_image( $image, $post_id, null, 'id' ); if ( ! is_wp_error( $sideload_id ) ) { $sideload_url = wp_get_attachment_url( $sideload_id ); $already_sideloaded[ $image ] = array( 'id' => $sideload_id, 'url' => $sideload_url, ); $images_for_post[] = $already_sideloaded[ $image ]; } } return $images_for_post; } /** * Create a homepage from a template. * * @return WP_Error|array */ public static function create_homepage() { $post_id = wp_insert_post( array( 'post_title' => __( 'Homepage', 'woocommerce' ), 'post_type' => 'page', 'post_status' => 'publish', 'post_content' => '', // Template content is updated below, so images can be attached to the post. ) ); if ( ! is_wp_error( $post_id ) && 0 < $post_id ) { $template = self::get_homepage_template( $post_id ); wp_update_post( array( 'ID' => $post_id, 'post_content' => $template, ) ); update_option( 'show_on_front', 'page' ); update_option( 'page_on_front', $post_id ); update_option( 'woocommerce_onboarding_homepage_post_id', $post_id ); // Use the full width template on stores using Storefront. if ( 'storefront' === get_stylesheet() ) { update_post_meta( $post_id, '_wp_page_template', 'template-fullwidth.php' ); } return array( 'status' => 'success', 'message' => __( 'Homepage created', 'woocommerce' ), 'post_id' => $post_id, 'edit_post_link' => htmlspecialchars_decode( get_edit_post_link( $post_id ) ), ); } else { return $post_id; } } /** * Get the query params for task lists. * * @return array */ public function get_task_list_params() { $params = array(); $params['ids'] = array( 'description' => __( 'Optional parameter to get only specific task lists by id.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'enum' => TaskLists::get_list_ids(), 'type' => 'string', ), ); $params['extended_tasks'] = array( 'description' => __( 'List of extended deprecated tasks from the client side filter.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => function( $param, $request, $key ) { $has_valid_keys = true; foreach ( $param as $task ) { if ( $has_valid_keys ) { $has_valid_keys = array_key_exists( 'list_id', $task ) && array_key_exists( 'id', $task ); } } return $has_valid_keys; }, ); return $params; } /** * Get the onboarding tasks. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error */ public function get_tasks( $request ) { $extended_tasks = $request->get_param( 'extended_tasks' ); $task_list_ids = $request->get_param( 'ids' ); TaskLists::maybe_add_extended_tasks( $extended_tasks ); $lists = is_array( $task_list_ids ) && count( $task_list_ids ) > 0 ? TaskLists::get_lists_by_ids( $task_list_ids ) : TaskLists::get_lists(); $json = array_map( function( $list ) { return $list->sort_tasks()->get_json(); }, $lists ); return rest_ensure_response( array_values( apply_filters( 'woocommerce_admin_onboarding_tasks', $json ) ) ); } /** * Dismiss a single task. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Request|WP_Error */ public function dismiss_task( $request ) { $id = $request->get_param( 'id' ); $task = TaskLists::get_task( $id ); if ( ! $task && $id ) { $task = new DeprecatedExtendedTask( null, array( 'id' => $id, 'is_dismissable' => true, ) ); } if ( ! $task || ! $task->is_dismissable() ) { return new \WP_Error( 'woocommerce_rest_invalid_task', __( 'Sorry, no dismissable task with that ID was found.', 'woocommerce' ), array( 'status' => 404, ) ); } $task->dismiss(); return rest_ensure_response( $task->get_json() ); } /** * Undo dismissal of a single task. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Request|WP_Error */ public function undo_dismiss_task( $request ) { $id = $request->get_param( 'id' ); $task = TaskLists::get_task( $id ); if ( ! $task && $id ) { $task = new DeprecatedExtendedTask( null, array( 'id' => $id, 'is_dismissable' => true, ) ); } if ( ! $task || ! $task->is_dismissable() ) { return new \WP_Error( 'woocommerce_rest_invalid_task', __( 'Sorry, no dismissable task with that ID was found.', 'woocommerce' ), array( 'status' => 404, ) ); } $task->undo_dismiss(); return rest_ensure_response( $task->get_json() ); } /** * Snooze an onboarding task. * * @deprecated 7.8.0 snooze task is deprecated. * * @param WP_REST_Request $request Request data. * * @return WP_REST_Response|WP_Error */ public function snooze_task( $request ) { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.8.0' ); $task_id = $request->get_param( 'id' ); $task_list_id = $request->get_param( 'task_list_id' ); $duration = $request->get_param( 'duration' ); $task = TaskLists::get_task( $task_id, $task_list_id ); if ( ! $task && $task_id ) { $task = new DeprecatedExtendedTask( null, array( 'id' => $task_id, 'is_snoozeable' => true, ) ); } if ( ! $task || ! $task->is_snoozeable() ) { return new \WP_Error( 'woocommerce_rest_invalid_task', __( 'Sorry, no snoozeable task with that ID was found.', 'woocommerce' ), array( 'status' => 404, ) ); } $task->snooze( isset( $duration ) ? $duration : 'day' ); return rest_ensure_response( $task->get_json() ); } /** * Undo snooze of a single task. * * @deprecated 7.8.0 undo snooze task is deprecated. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Request|WP_Error */ public function undo_snooze_task( $request ) { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '7.8.0' ); $id = $request->get_param( 'id' ); $task = TaskLists::get_task( $id ); if ( ! $task && $id ) { $task = new DeprecatedExtendedTask( null, array( 'id' => $id, 'is_snoozeable' => true, ) ); } if ( ! $task || ! $task->is_snoozeable() ) { return new \WP_Error( 'woocommerce_rest_invalid_task', __( 'Sorry, no snoozeable task with that ID was found.', 'woocommerce' ), array( 'status' => 404, ) ); } $task->undo_snooze(); return rest_ensure_response( $task->get_json() ); } /** * Hide a task list. * * @param WP_REST_Request $request Request data. * * @return WP_REST_Response|WP_Error */ public function hide_task_list( $request ) { $id = $request->get_param( 'id' ); $task_list = TaskLists::get_list( $id ); if ( ! $task_list ) { return new \WP_Error( 'woocommerce_rest_invalid_task_list', __( 'Sorry, that task list was not found', 'woocommerce' ), array( 'status' => 404, ) ); } $update = $task_list->hide(); $json = $task_list->get_json(); return rest_ensure_response( $json ); } /** * Unhide a task list. * * @param WP_REST_Request $request Request data. * * @return WP_REST_Response|WP_Error */ public function unhide_task_list( $request ) { $id = $request->get_param( 'id' ); $task_list = TaskLists::get_list( $id ); if ( ! $task_list ) { return new \WP_Error( 'woocommerce_tasks_invalid_task_list', __( 'Sorry, that task list was not found', 'woocommerce' ), array( 'status' => 404, ) ); } $update = $task_list->unhide(); $json = $task_list->get_json(); return rest_ensure_response( $json ); } /** * Action a single task. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Request|WP_Error */ public function action_task( $request ) { $id = $request->get_param( 'id' ); $task = TaskLists::get_task( $id ); if ( ! $task && $id ) { $task = new DeprecatedExtendedTask( null, array( 'id' => $id, ) ); } if ( ! $task ) { return new \WP_Error( 'woocommerce_rest_invalid_task', __( 'Sorry, no task with that ID was found.', 'woocommerce' ), array( 'status' => 404, ) ); } $task->mark_actioned(); return rest_ensure_response( $task->get_json() ); } } API/NoteActions.php 0000777 00000004621 15252240713 0010127 0 ustar 00 <?php /** * REST API Admin Note Action controller * * Handles requests to the admin note action endpoint. */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\Notes\Note; use Automattic\WooCommerce\Admin\Notes\Notes as NotesFactory; /** * REST API Admin Note Action controller class. * * @internal * @extends WC_REST_CRUD_Controller */ class NoteActions extends Notes { /** * Register the routes for admin notes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<note_id>[\d-]+)/action/(?P<action_id>[\d-]+)', array( 'args' => array( 'note_id' => array( 'description' => __( 'Unique ID for the Note.', 'woocommerce' ), 'type' => 'integer', ), 'action_id' => array( 'description' => __( 'Unique ID for the Note Action.', 'woocommerce' ), 'type' => 'integer', ), ), array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'trigger_note_action' ), // @todo - double check these permissions for taking note actions. 'permission_callback' => array( $this, 'get_item_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Trigger a note action. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Request|WP_Error */ public function trigger_note_action( $request ) { $note = NotesFactory::get_note( $request->get_param( 'note_id' ) ); if ( ! $note ) { return new \WP_Error( 'woocommerce_note_invalid_id', __( 'Sorry, there is no resource with that ID.', 'woocommerce' ), array( 'status' => 404 ) ); } $note->set_is_read( true ); $note->save(); $triggered_action = NotesFactory::get_action_by_id( $note, $request->get_param( 'action_id' ) ); if ( ! $triggered_action ) { return new \WP_Error( 'woocommerce_note_action_invalid_id', __( 'Sorry, there is no resource with that ID.', 'woocommerce' ), array( 'status' => 404 ) ); } $triggered_note = NotesFactory::trigger_note_action( $note, $triggered_action ); $data = $triggered_note->get_data(); $data = $this->prepare_item_for_response( $data, $request ); $data = $this->prepare_response_for_collection( $data ); return rest_ensure_response( $data ); } } API/Marketing.php 0000777 00000011540 15252240713 0007620 0 ustar 00 <?php /** * REST API Marketing Controller * * Handles requests to /marketing. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Admin\PluginsHelper; use Automattic\WooCommerce\Internal\Admin\Marketing\MarketingSpecs; use Automattic\WooCommerce\Admin\Features\MarketingRecommendations\Init as MarketingRecommendationsInit; defined( 'ABSPATH' ) || exit; /** * Marketing Controller. * * @internal * @extends WC_REST_Data_Controller */ class Marketing extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'marketing'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/recommended', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_recommended_plugins' ), 'permission_callback' => array( $this, 'get_recommended_plugins_permissions_check' ), 'args' => array( 'per_page' => $this->get_collection_params()['per_page'], 'category' => array( 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'sanitize_title_with_dashes', ), ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/knowledge-base', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_knowledge_base_posts' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => array( 'category' => array( 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'sanitize_title_with_dashes', ), ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/misc-recommendations', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_misc_recommendations' ), 'permission_callback' => array( $this, 'get_recommended_plugins_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Check whether a given request has permission to install plugins. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function get_recommended_plugins_permissions_check( $request ) { if ( ! current_user_can( 'install_plugins' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Return installed marketing extensions data. * * @param \WP_REST_Request $request Request data. * * @return \WP_Error|\WP_REST_Response */ public function get_recommended_plugins( $request ) { // Default to marketing category (if no category set). $category = ( ! empty( $request->get_param( 'category' ) ) ) ? $request->get_param( 'category' ) : 'marketing'; $all_plugins = MarketingRecommendationsInit::get_recommended_plugins(); $valid_plugins = []; $per_page = $request->get_param( 'per_page' ); foreach ( $all_plugins as $plugin ) { // default to marketing if 'categories' is empty on the plugin object (support for legacy api while testing). $plugin_categories = ( ! empty( $plugin['categories'] ) ) ? $plugin['categories'] : [ 'marketing' ]; if ( ! PluginsHelper::is_plugin_installed( $plugin['plugin'] ) && in_array( $category, $plugin_categories, true ) ) { $valid_plugins[] = $plugin; } } return rest_ensure_response( array_slice( $valid_plugins, 0, $per_page ) ); } /** * Return installed marketing extensions data. * * @param \WP_REST_Request $request Request data. * * @return \WP_Error|\WP_REST_Response */ public function get_knowledge_base_posts( $request ) { /** * MarketingSpecs class. * * @var MarketingSpecs $marketing_specs */ $marketing_specs = wc_get_container()->get( MarketingSpecs::class ); $category = $request->get_param( 'category' ); return rest_ensure_response( $marketing_specs->get_knowledge_base_posts( $category ) ); } /** * Return misc recommendations. * * @param \WP_REST_Request $request Request data. * * @since 9.5.0 * * @return \WP_Error|\WP_REST_Response */ public function get_misc_recommendations( $request ) { $misc_recommendations = MarketingRecommendationsInit::get_misc_recommendations(); return rest_ensure_response( $misc_recommendations ); } } API/Notice.php 0000777 00000004605 15252240713 0007124 0 ustar 00 <?php /** * REST API Notice controller * * Handles requests to /notice/ */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Admin\PluginsHelper; defined( 'ABSPATH' ) || exit; /** * Notice Controller. * * @internal * @extends WC_REST_Data_Controller */ class Notice extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'notice'; /** * Register the routes for admin notes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/dismiss', array( array( 'methods' => 'POST', 'callback' => array( $this, 'dissmiss_notice' ), 'permission_callback' => array( $this, 'get_permission' ), ), ) ); } /** * Save notice dismiss information in user meta. * * @param WP_REST_Request $request Request object. * @return WP_REST_Response|WP_Error */ public function dissmiss_notice( $request ) { if ( ! isset( $request['dismiss_notice_nonce'] ) || ! wp_verify_nonce( $request['dismiss_notice_nonce'], 'dismiss_notice' ) ) { return new WP_Error( 'unauthorized', 'Invalid nonce.', array( 'status' => 401 ) ); } $notice_id = isset( $request['notice_id'] ) ? sanitize_text_field( wp_unslash( $request['notice_id'] ) ) : ''; $dismissed = false; switch ( $notice_id ) { case 'woo-subscription-expired-notice': update_user_meta( get_current_user_id(), PluginsHelper::DISMISS_EXPIRED_SUBS_NOTICE, time() ); $dismissed = true; break; case 'woo-subscription-expiring-notice': update_user_meta( get_current_user_id(), PluginsHelper::DISMISS_EXPIRING_SUBS_NOTICE, time() ); $dismissed = true; break; case 'woo-disconnect-notice': update_user_meta( get_current_user_id(), PluginsHelper::DISMISS_DISCONNECT_NOTICE, time() ); $dismissed = true; break; case 'woo-connect-notice': update_user_meta( get_current_user_id(), PluginsHelper::DISMISS_CONNECT_NOTICE, time() ); $dismissed = true; break; } return rest_ensure_response( array( 'success' => $dismissed, ) ); } /** * Check user has the necessary permissions to perform this action. * * @return bool */ public function get_permission(): bool { return current_user_can( 'manage_woocommerce' ); } } API/ProductsLowInStock.php 0000777 00000043102 15252240713 0011456 0 ustar 00 <?php /** * REST API ProductsLowInStock Controller * * Handles request to /products/low-in-stock */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Enums\ProductStatus; use Automattic\WooCommerce\Enums\ProductType; defined( 'ABSPATH' ) || exit; /** * ProductsLowInStock controller. * * @internal * @extends WC_REST_Products_Controller */ final class ProductsLowInStock extends \WC_REST_Products_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, 'products/low-in-stock', array( 'args' => array(), array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, 'products/count-low-in-stock', array( 'args' => array(), array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_low_in_stock_count' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_low_in_stock_count_params(), ), 'schema' => array( $this, 'get_low_in_stock_count_schema' ), ) ); } /** * Return # of low in stock count. * * @param WP_REST_Request $request request object. * * @return \WP_Error|\WP_HTTP_Response|\WP_REST_Response */ public function get_low_in_stock_count( $request ) { $status = $request->get_param( 'status' ); $low_stock_threshold = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) ); $sidewide_stock_threshold_only = $this->is_using_sitewide_stock_threshold_only( $low_stock_threshold ); $total_results = $this->get_count( $sidewide_stock_threshold_only, $status, $low_stock_threshold ); $response = rest_ensure_response( array( 'total' => $total_results ) ); $response->header( 'X-WP-Total', $total_results ); $response->header( 'X-WP-TotalPages', 0 ); return $response; } /** * Get low in stock products. * * @param WP_REST_Request $request request object. * * @return WP_REST_Response|WP_ERROR */ public function get_items( $request ) { $query_results = $this->get_low_in_stock_products( $request->get_param( 'page' ), $request->get_param( 'per_page' ), $request->get_param( 'status' ) ); // set images and attributes. $query_results['results'] = array_map( function ( $query_result ) { $product = wc_get_product( $query_result ); $query_result->images = $this->get_images( $product ); $query_result->attributes = $this->get_attributes( $product ); return $query_result; }, $query_results['results'] ); // set last_order_date. $query_results['results'] = $this->set_last_order_date( $query_results['results'] ); // convert the post data to the expected API response for the backward compatibility. $query_results['results'] = array_map( array( $this, 'transform_post_to_api_response' ), $query_results['results'] ); $response = rest_ensure_response( array_values( $query_results['results'] ) ); $response->header( 'X-WP-Total', $query_results['total'] ); $response->header( 'X-WP-TotalPages', $query_results['pages'] ); return $response; } /** * Set the last order date for each data. * * @param array $results query result from get_low_in_stock_products. * * @return mixed */ protected function set_last_order_date( $results = array() ) { global $wpdb; if ( 0 === count( $results ) ) { return $results; } $wheres = array(); foreach ( $results as $result ) { 'product_variation' === $result->post_type ? array_push( $wheres, "(product_id={$result->post_parent} and variation_id={$result->ID})" ) : array_push( $wheres, "product_id={$result->ID}" ); } count( $wheres ) ? $where_clause = implode( ' or ', $wheres ) : $where_clause = $wheres[0]; $product_lookup_table = $wpdb->prefix . 'wc_order_product_lookup'; $query_string = " select product_id, variation_id, MAX( wc_order_product_lookup.date_created ) AS last_order_date from {$product_lookup_table} wc_order_product_lookup where {$where_clause} group by product_id order by date_created desc "; // phpcs:ignore -- ignore prepare() warning as we're not using any user input here. $last_order_dates = $wpdb->get_results( $query_string ); $last_order_dates_index = array(); // Make an index with product_id_variation_id as a key // so that it can be referenced back without looping the whole array. foreach ( $last_order_dates as $last_order_date ) { $last_order_dates_index[ $last_order_date->product_id . '_' . $last_order_date->variation_id ] = $last_order_date; } foreach ( $results as &$result ) { 'product_variation' === $result->post_type ? $index_key = $result->post_parent . '_' . $result->ID : $index_key = $result->ID . '_' . $result->post_parent; if ( isset( $last_order_dates_index[ $index_key ] ) ) { $result->last_order_date = $last_order_dates_index[ $index_key ]->last_order_date; } } return $results; } /** * Get low in stock products data. * * @param int $page current page. * @param int $per_page items per page. * @param string $status post status. * * @return array */ protected function get_low_in_stock_products( $page = 1, $per_page = 1, $status = ProductStatus::PUBLISH ) { global $wpdb; $offset = ( $page - 1 ) * $per_page; $low_stock_threshold = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) ); $sidewide_stock_threshold_only = $this->is_using_sitewide_stock_threshold_only( $low_stock_threshold ); $query_string = $this->get_query( $sidewide_stock_threshold_only ); $query_results = $wpdb->get_results( // phpcs:ignore -- not sure why phpcs complains about this line when prepare() is used here. $wpdb->prepare( $query_string, $status, $low_stock_threshold, $offset, $per_page ), OBJECT_K ); $total_results = $this->get_count( $sidewide_stock_threshold_only, $status, $low_stock_threshold ); return array( 'results' => $query_results, 'total' => (int) $total_results, 'pages' => (int) ceil( $total_results / (int) $per_page ), ); } /** * Get the count of low in stock products. * * @param bool $sidewide_stock_threshold_only Boolean to check if the store is using sitewide stock threshold only. * @param string $status Post status. * @param int $low_stock_threshold Low stock threshold. * * @return int */ protected function get_count( $sidewide_stock_threshold_only, $status, $low_stock_threshold ) { global $wpdb; if ( $sidewide_stock_threshold_only ) { $count_query_string = $this->get_count_query( $sidewide_stock_threshold_only ); $count_query_results = $wpdb->get_results( // phpcs:ignore -- not sure why phpcs complains about this line when prepare() is used here. $wpdb->prepare( $count_query_string, $status, $low_stock_threshold ), ); return (int) $count_query_results[0]->total; } // Split the query into two queries, one for products with a custom stock threshold and one for products without a custom stock threshold. // Splitting the queries also speeds up the query. $count_query_with_custom_stock_threshold_string = $this->get_products_with_custom_stock_threshold_count_query_str(); $count_query_without_custom_stock_threshold_string = $this->get_products_without_custom_stock_threshold_count_query_str(); $count_query_with_custom_stock_threshold_results = $wpdb->get_results( // phpcs:ignore -- not sure why phpcs complains about this line when prepare() is used here. $wpdb->prepare( $count_query_with_custom_stock_threshold_string, $status ), ); $count_query_without_custom_stock_threshold_results = $wpdb->get_results( // phpcs:ignore -- not sure why phpcs complains about this line when prepare() is used here. $wpdb->prepare( $count_query_without_custom_stock_threshold_string, $status, $low_stock_threshold ), ); return (int) $count_query_with_custom_stock_threshold_results[0]->total + (int) $count_query_without_custom_stock_threshold_results[0]->total; } /** * Check to see if store is using sitewide threshold only. Meaning that it does not have any custom * stock threshold for a product. * * @param int|null $low_stock_threshold Low stock threshold. * @return bool */ protected function is_using_sitewide_stock_threshold_only( $low_stock_threshold = null ) { global $wpdb; $query_string = " select count(*) as total from {$wpdb->postmeta} where meta_key='_low_stock_amount' AND meta_value > '' "; $args = array(); if ( $low_stock_threshold ) { $query_string .= ' AND meta_value != %d'; $args[] = $low_stock_threshold; } // phpcs:ignore -- not sure why phpcs complains about this line when prepare() is used here. $count = $wpdb->get_var( $wpdb->prepare( $query_string, $args ) ); return 0 === (int) $count; } /** * Transform post object to expected API response. * * @param object $query_result a row of query result from get_low_in_stock_products(). * * @return array */ protected function transform_post_to_api_response( $query_result ) { $low_stock_amount = null; if ( isset( $query_result->low_stock_amount ) ) { $low_stock_amount = (int) $query_result->low_stock_amount; } if ( ! isset( $query_result->last_order_date ) ) { $query_result->last_order_date = null; } return array( 'id' => (int) $query_result->ID, 'images' => $query_result->images, 'attributes' => $query_result->attributes, 'low_stock_amount' => $low_stock_amount, 'last_order_date' => wc_rest_prepare_date_response( $query_result->last_order_date ), 'name' => $query_result->post_title, 'parent_id' => (int) $query_result->post_parent, 'stock_quantity' => (int) $query_result->stock_quantity, 'type' => 'product_variation' === $query_result->post_type ? ProductType::VARIATION : ProductType::SIMPLE, ); } /** * Return a query string for low in stock products. * The query string includes the following replacement strings: * - :selects * - :postmeta_join * - :postmeta_wheres * - :orderAndLimit * * @param array $replacements of replacement strings. * * @return string */ private function get_base_query( $replacements = array() ) { global $wpdb; $query = " SELECT :selects FROM {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup LEFT JOIN {$wpdb->posts} wp_posts ON wp_posts.ID = wc_product_meta_lookup.product_id :postmeta_join WHERE wp_posts.post_type IN ('product', 'product_variation') AND wp_posts.post_status = %s AND wc_product_meta_lookup.stock_quantity IS NOT NULL AND wc_product_meta_lookup.stock_status IN('instock', 'outofstock') :postmeta_wheres :orderAndLimit "; return strtr( $query, $replacements ); } /** * Add sitewide stock query string to base query string. * * @param string $query Base query string. * * @return string */ private function add_sitewide_stock_query_str( $query ) { global $wpdb; $postmeta = array( 'select' => 'meta.meta_value AS low_stock_amount,', 'join' => "LEFT JOIN {$wpdb->postmeta} AS meta ON wp_posts.ID = meta.post_id AND meta.meta_key = '_low_stock_amount'", 'wheres' => "AND ( ( meta.meta_value > '' AND wc_product_meta_lookup.stock_quantity <= CAST( meta.meta_value AS SIGNED ) ) OR ( ( meta.meta_value IS NULL OR meta.meta_value <= '' ) AND wc_product_meta_lookup.stock_quantity <= %d ) )", ); return strtr( $query, array( ':postmeta_select' => $postmeta['select'], ':postmeta_join' => $postmeta['join'], ':postmeta_wheres' => $postmeta['wheres'], ) ); } /** * Get a query string for products with a custom stock threshold. * * @return string */ private function get_products_with_custom_stock_threshold_count_query_str() { global $wpdb; $query = $this->get_base_query( array( ':selects' => 'count(*) as total', ':orderAndLimit' => '', ) ); $postmeta = array( 'select' => 'meta.meta_value AS low_stock_amount,', 'join' => "JOIN {$wpdb->postmeta} AS meta ON wp_posts.ID = meta.post_id AND meta.meta_key = '_low_stock_amount' AND meta.meta_value > ''", 'wheres' => 'AND wc_product_meta_lookup.stock_quantity <= CAST(meta.meta_value AS SIGNED)', ); return strtr( $query, array( ':postmeta_select' => $postmeta['select'], ':postmeta_join' => $postmeta['join'], ':postmeta_wheres' => $postmeta['wheres'], ) ); } /** * Get a query string for products without a custom stock threshold. * * @return string */ private function get_products_without_custom_stock_threshold_count_query_str() { global $wpdb; $query = $this->get_base_query( array( ':selects' => 'count(*) as total', ':orderAndLimit' => '', ) ); $postmeta = array( 'select' => 'meta.meta_value AS low_stock_amount,', 'join' => "LEFT JOIN {$wpdb->postmeta} AS meta ON wp_posts.ID = meta.post_id AND meta.meta_key = '_low_stock_amount' AND meta.meta_value > ''", 'wheres' => 'AND meta.post_id IS NULL AND wc_product_meta_lookup.stock_quantity <= %d', ); return strtr( $query, array( ':postmeta_select' => $postmeta['select'], ':postmeta_join' => $postmeta['join'], ':postmeta_wheres' => $postmeta['wheres'], ) ); } /** * Generate a query. * * @param bool $sitewide_only generates a query for sitewide low stock threshold only query. * * @return string */ protected function get_query( $sitewide_only = false ) { $query = $this->get_base_query( array( ':selects' => 'wp_posts.*, :postmeta_select wc_product_meta_lookup.stock_quantity', ':orderAndLimit' => 'order by wc_product_meta_lookup.product_id DESC limit %d, %d', ) ); if ( ! $sitewide_only ) { return $this->add_sitewide_stock_query_str( $query ); } return strtr( $query, array( ':postmeta_select' => '', ':postmeta_join' => '', ':postmeta_wheres' => 'AND wc_product_meta_lookup.stock_quantity <= %d', ) ); } /** * Generate a count query. * * @param bool $sitewide_only generates a query for sitewide low stock threshold only query. * * @return string */ protected function get_count_query( $sitewide_only = false ) { $query = $this->get_base_query( array( ':selects' => 'count(*) as total', ':orderAndLimit' => '', ) ); if ( ! $sitewide_only ) { return $this->add_sitewide_stock_query_str( $query ); } return strtr( $query, array( ':postmeta_select' => '', ':postmeta_join' => '', ':postmeta_wheres' => 'AND wc_product_meta_lookup.stock_quantity <= %d', ) ); } /** * Get the query params for collections of attachments. * * @return array */ public function get_collection_params() { $params = array(); $params['context'] = $this->get_context_param(); $params['context']['default'] = 'view'; $params['page'] = array( 'description' => __( 'Current page of the collection.', 'woocommerce' ), 'type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', 'minimum' => 1, ); $params['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', ); $params['status'] = array( 'default' => 'publish', 'description' => __( 'Limit result set to products assigned a specific status.', 'woocommerce' ), 'type' => 'string', 'enum' => array_merge( array_keys( get_post_statuses() ), array( ProductStatus::FUTURE ) ), 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the query params for collections for /count-low-in-stock endpoint. * * @return array */ public function get_low_in_stock_count_params() { $params = array(); $params['context'] = $this->get_context_param(); $params['context']['default'] = 'view'; $params['status'] = array( 'default' => 'publish', 'description' => __( 'Limit result set to products assigned a specific status.', 'woocommerce' ), 'type' => 'string', 'enum' => array_merge( array_keys( get_post_statuses() ), array( ProductStatus::FUTURE ) ), 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the schema for /count-low-in-stock response. * * @return array */ public function get_low_in_stock_count_schema() { return array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'Count Low in Stock Items', 'type' => 'object', 'properties' => array( 'type' => 'object', 'properties' => array( 'total' => 'integer', ), ), ); } } API/MarketingChannels.php 0000777 00000013366 15252240713 0011304 0 ustar 00 <?php /** * REST API MarketingChannels Controller * * Handles requests to /marketing/channels. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Admin\Marketing\MarketingChannelInterface; use Automattic\WooCommerce\Admin\Marketing\MarketingChannels as MarketingChannelsService; use WC_REST_Controller; use WP_Error; use WP_REST_Request; use WP_REST_Response; defined( 'ABSPATH' ) || exit; /** * MarketingChannels Controller. * * @internal * @extends WC_REST_Controller * @since x.x.x */ class MarketingChannels extends WC_REST_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'marketing/channels'; /** * 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_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Check whether a given request has permission to view marketing channels. * * @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( 'settings', 'read' ) ) { return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Return installed marketing channels. * * @param WP_REST_Request $request Request data. * * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { /** * MarketingChannels class. * * @var MarketingChannelsService $marketing_channels_service */ $marketing_channels_service = wc_get_container()->get( MarketingChannelsService::class ); $channels = $marketing_channels_service->get_registered_channels(); $responses = []; foreach ( $channels as $item ) { $response = $this->prepare_item_for_response( $item, $request ); $responses[] = $this->prepare_response_for_collection( $response ); } return rest_ensure_response( $responses ); } /** * Prepares the item for the REST response. * * @param MarketingChannelInterface $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. */ public function prepare_item_for_response( $item, $request ) { $data = [ 'slug' => $item->get_slug(), 'is_setup_completed' => $item->is_setup_completed(), 'settings_url' => $item->get_setup_url(), 'name' => $item->get_name(), 'description' => $item->get_description(), 'product_listings_status' => $item->get_product_listings_status(), 'errors_count' => $item->get_errors_count(), 'icon' => $item->get_icon_url(), ]; $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); return rest_ensure_response( $data ); } /** * Retrieves the item's schema, conforming to JSON Schema. * * @return array Item schema data. */ public function get_item_schema() { $schema = [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'marketing_channel', 'type' => 'object', 'properties' => [ 'slug' => [ 'description' => __( 'Unique identifier string for the marketing channel extension, also known as the plugin slug.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'name' => [ 'description' => __( 'Name of the marketing channel.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'description' => [ 'description' => __( 'Description of the marketing channel.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'icon' => [ 'description' => __( 'Path to the channel icon.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'is_setup_completed' => [ 'type' => 'boolean', 'description' => __( 'Whether or not the marketing channel is set up.', 'woocommerce' ), 'context' => [ 'view' ], 'readonly' => true, ], 'settings_url' => [ 'description' => __( 'URL to the settings page, or the link to complete the setup/onboarding if the channel has not been set up yet.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'product_listings_status' => [ 'description' => __( 'Status of the marketing channel\'s product listings.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'errors_count' => [ 'description' => __( 'Number of channel issues/errors (e.g. account-related errors, product synchronization issues, etc.).', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], ], ]; return $this->add_additional_fields_schema( $schema ); } } API/SettingOptions.php 0000777 00000001556 15252240713 0010676 0 ustar 00 <?php /** * REST API Setting Options Controller * * Handles requests to /settings/{option} */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache; /** * Setting Options controller. * * @internal * @extends WC_REST_Setting_Options_Controller */ class SettingOptions extends \WC_REST_Setting_Options_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Invalidates API cache when updating settings options. * * @param WP_REST_Request $request Full details about the request. * @return array Of WP_Error or WP_REST_Response. */ public function batch_items( $request ) { // Invalidate the API cache. ReportsCache::invalidate(); // Process the request. return parent::batch_items( $request ); } } API/Data.php 0000777 00000001653 15252240713 0006554 0 ustar 00 <?php /** * REST API Data Controller * * Handles requests to /data */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Data controller. * * @internal * @extends WC_REST_Data_Controller */ class Data extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Return the list of data resources. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { $response = parent::get_items( $request ); $response->data[] = $this->prepare_response_for_collection( $this->prepare_item_for_response( (object) array( 'slug' => 'download-ips', 'description' => __( 'An endpoint used for searching download logs for a specific IP address.', 'woocommerce' ), ), $request ) ); return $response; } } API/Init.php 0000777 00000025107 15252240713 0006606 0 ustar 00 <?php /** * REST API bootstrap. */ namespace Automattic\WooCommerce\Admin\API; use AllowDynamicProperties; use Automattic\WooCommerce\Admin\Features\Features; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Utilities\RestApiUtil; /** * Init class. * * @internal */ #[AllowDynamicProperties] class Init { /** * The single instance of the class. * * @var object */ protected static $instance = null; /** * Get class instance. * * @return object Instance. */ final public static function instance() { if ( null === static::$instance ) { static::$instance = new static(); } return static::$instance; } /** * Bootstrap REST API. */ public function __construct() { // Hook in data stores. add_filter( 'woocommerce_data_stores', array( __CLASS__, 'add_data_stores' ) ); // REST API extensions init. add_action( 'rest_api_init', array( $this, 'rest_api_init' ) ); // Add currency symbol to orders endpoint response. add_filter( 'woocommerce_rest_prepare_shop_order_object', array( __CLASS__, 'add_currency_symbol_to_order_response' ) ); include_once WC_ABSPATH . 'includes/admin/class-wc-admin-upload-downloadable-product.php'; } /** * Initialize the API namespaces under WooCommerce Admin. * * @return void */ public function rest_api_init() { if ( wc_rest_should_load_namespace( 'wc-admin' ) ) { $this->rest_api_init_wc_admin(); } $rest_api_util = wc_get_container()->get( RestApiUtil::class ); $rest_api_util->lazy_load_namespace( 'wc-analytics', array( $this, 'rest_api_init_wc_analytics' ) ); if ( Features::is_enabled( 'launch-your-store' ) ) { $controller = 'Automattic\WooCommerce\Admin\API\LaunchYourStore'; $this->$controller = new $controller(); $this->$controller->register_routes(); } } /** * Load the wc-admin namespace controllers. * * @return void */ public function rest_api_init_wc_admin() { $controllers = array( 'Automattic\WooCommerce\Admin\API\Notice', 'Automattic\WooCommerce\Admin\API\Features', 'Automattic\WooCommerce\Admin\API\Experiments', 'Automattic\WooCommerce\Admin\API\Marketing', 'Automattic\WooCommerce\Admin\API\MarketingOverview', 'Automattic\WooCommerce\Admin\API\MarketingRecommendations', 'Automattic\WooCommerce\Admin\API\MarketingChannels', 'Automattic\WooCommerce\Admin\API\MarketingCampaigns', 'Automattic\WooCommerce\Admin\API\MarketingCampaignTypes', 'Automattic\WooCommerce\Admin\API\Options', 'Automattic\WooCommerce\Admin\API\Settings', 'Automattic\WooCommerce\Admin\API\PaymentGatewaySuggestions', 'Automattic\WooCommerce\Admin\API\Themes', 'Automattic\WooCommerce\Admin\API\Plugins', 'Automattic\WooCommerce\Admin\API\OnboardingFreeExtensions', 'Automattic\WooCommerce\Admin\API\OnboardingProductTypes', 'Automattic\WooCommerce\Admin\API\OnboardingProfile', 'Automattic\WooCommerce\Admin\API\OnboardingTasks', 'Automattic\WooCommerce\Admin\API\OnboardingThemes', 'Automattic\WooCommerce\Admin\API\OnboardingPlugins', 'Automattic\WooCommerce\Admin\API\OnboardingProducts', 'Automattic\WooCommerce\Admin\API\MobileAppMagicLink', 'Automattic\WooCommerce\Admin\API\ShippingPartnerSuggestions', ); if ( ! did_action( 'woocommerce_admin_rest_controllers' ) ) { /** * Filter for the WooCommerce Admin REST controllers. * * Admin and Analytics controllers were originally loaded in one place. However, with attempts to dynamically * load namespaces based on context, these were split up. However, to maintain backward compatibility, we * must run this hook if either namespace is loaded because extensions could be targeting either namespace. * * @param array $controllers List of rest API controllers. * * @since 3.5.0 */ $controllers = apply_filters( 'woocommerce_admin_rest_controllers', $controllers ); if ( ! is_array( $controllers ) ) { return; } } $controllers = array_values( array_unique( $controllers ) ); foreach ( $controllers as $controller ) { if ( is_string( $controller ) ) { $this->$controller = new $controller(); $this->$controller->register_routes(); } } } /** * Load the wc-analytics namespace controllers. * * @return void */ public function rest_api_init_wc_analytics() { // Controllers in wc-analytics namespace, but loaded irrespective of analytics feature value. $controllers = array( 'Automattic\WooCommerce\Admin\API\Notes', 'Automattic\WooCommerce\Admin\API\NoteActions', 'Automattic\WooCommerce\Admin\API\Coupons', 'Automattic\WooCommerce\Admin\API\Data', 'Automattic\WooCommerce\Admin\API\DataCountries', 'Automattic\WooCommerce\Admin\API\DataDownloadIPs', 'Automattic\WooCommerce\Admin\API\Orders', 'Automattic\WooCommerce\Admin\API\Products', 'Automattic\WooCommerce\Admin\API\ProductAttributes', 'Automattic\WooCommerce\Admin\API\ProductAttributeTerms', 'Automattic\WooCommerce\Admin\API\ProductCategories', 'Automattic\WooCommerce\Admin\API\ProductVariations', 'Automattic\WooCommerce\Admin\API\ProductReviews', 'Automattic\WooCommerce\Admin\API\ProductsLowInStock', 'Automattic\WooCommerce\Admin\API\SettingOptions', 'Automattic\WooCommerce\Admin\API\Taxes', ); $analytics_controllers = array(); if ( Features::is_enabled( 'analytics' ) ) { $analytics_controllers = array( 'Automattic\WooCommerce\Admin\API\Customers', 'Automattic\WooCommerce\Admin\API\Leaderboards', 'Automattic\WooCommerce\Admin\API\Reports\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Import\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Export\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Products\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Variations\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Products\Stats\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Variations\Stats\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Revenue\Stats\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Orders\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Categories\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Taxes\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Coupons\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Stock\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Stock\Stats\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Downloads\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Customers\Controller', 'Automattic\WooCommerce\Admin\API\Reports\Customers\Stats\Controller', ); if ( Features::is_enabled( 'analytics-scheduled-import' ) ) { $analytics_controllers[] = 'Automattic\WooCommerce\Admin\API\AnalyticsImports'; } // The performance indicators controllerq must be registered last, after other /stats endpoints have been registered. $analytics_controllers[] = 'Automattic\WooCommerce\Admin\API\Reports\PerformanceIndicators\Controller'; } $controllers = array_merge( $analytics_controllers, $controllers ); if ( ! did_action( 'woocommerce_admin_rest_controllers' ) ) { /** * Filter for the WooCommerce Admin REST controllers. * * @param array $controllers List of rest API controllers. * * @since 3.5.0 * * @see self::rest_api_init_wc_admin() for extended documentation. */ $controllers = apply_filters( 'woocommerce_admin_rest_controllers', $controllers ); if ( ! is_array( $controllers ) ) { return; } } $controllers = array_values( array_unique( $controllers ) ); foreach ( $controllers as $controller ) { if ( is_string( $controller ) ) { $this->$controller = new $controller(); $this->$controller->register_routes(); } } } /** * Adds data stores. * * @internal * @param array $data_stores List of data stores. * @return array */ public static function add_data_stores( $data_stores ) { return array_merge( $data_stores, array( 'report-revenue-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore', 'report-orders' => 'Automattic\WooCommerce\Admin\API\Reports\Orders\DataStore', 'report-orders-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore', 'report-products' => 'Automattic\WooCommerce\Admin\API\Reports\Products\DataStore', 'report-variations' => 'Automattic\WooCommerce\Admin\API\Reports\Variations\DataStore', 'report-products-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Products\Stats\DataStore', 'report-variations-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Variations\Stats\DataStore', 'report-categories' => 'Automattic\WooCommerce\Admin\API\Reports\Categories\DataStore', 'report-taxes' => 'Automattic\WooCommerce\Admin\API\Reports\Taxes\DataStore', 'report-taxes-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\DataStore', 'report-coupons' => 'Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore', 'report-coupons-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats\DataStore', 'report-downloads' => 'Automattic\WooCommerce\Admin\API\Reports\Downloads\DataStore', 'report-downloads-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats\DataStore', 'admin-note' => 'Automattic\WooCommerce\Admin\Notes\DataStore', 'report-customers' => 'Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore', 'report-customers-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Customers\Stats\DataStore', 'report-stock-stats' => 'Automattic\WooCommerce\Admin\API\Reports\Stock\Stats\DataStore', ) ); } /** * Add the currency symbol (in addition to currency code) to each Order * object in REST API responses. For use in formatAmount(). * * @internal * @param WP_REST_Response $response REST response object. * @returns WP_REST_Response */ public static function add_currency_symbol_to_order_response( $response ) { $response_data = $response->get_data(); $currency_code = $response_data['currency']; $currency_symbol = get_woocommerce_currency_symbol( $currency_code ); $response_data['currency_symbol'] = html_entity_decode( $currency_symbol ); $response->set_data( $response_data ); return $response; } } API/OnboardingProfile.php 0000777 00000044601 15252240713 0011306 0 ustar 00 <?php /** * REST API Onboarding Profile Controller * * Handles requests to /onboarding/profile */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile as Profile; use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProducts; use Automattic\Jetpack\Connection\Manager as Jetpack_Connection_Manager; use WP_Error; use WP_REST_Request; use WP_REST_Response; /** * Onboarding Profile controller. * * @internal * @extends WC_REST_Data_Controller */ class OnboardingProfile extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'onboarding/profile'; /** * 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_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_items' ), 'permission_callback' => array( $this, 'update_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); // This endpoint is experimental. For internal use only. register_rest_route( $this->namespace, '/' . $this->rest_base . '/experimental_get_email_prefill', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_email_prefill' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/progress', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_profile_progress' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/progress/core-profiler/complete', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'core_profiler_step_complete' ), 'permission_callback' => array( $this, 'update_items_permissions_check' ), 'args' => array( 'step' => array( 'required' => true, 'type' => 'string', 'description' => __( 'The Core Profiler step to mark as complete.', 'woocommerce' ), 'enum' => array( 'intro-opt-in', 'skip-guided-setup', 'user-profile', 'business-info', 'plugins', 'intro-builder', 'skip-guided-setup', ), ), ), ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/update-store-currency-and-measurement-units', array( array( 'methods' => 'POST', 'callback' => array( $this, 'update_store_currency_and_measurement_units' ), 'permission_callback' => array( $this, 'update_items_permissions_check' ), 'args' => array( 'country_code' => array( 'description' => __( 'Country code.', 'woocommerce' ), 'type' => 'string', 'required' => true, ), ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Check whether a given request has permission to read onboarding profile data. * * @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( 'settings', 'read' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Check whether a given request has permission to edit onboarding profile data. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function update_items_permissions_check( $request ) { if ( ! wc_rest_check_manager_permissions( 'settings', 'edit' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot edit this resource.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Return all onboarding profile data. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-options.php'; $onboarding_data = get_option( Profile::DATA_OPTION, array() ); $onboarding_data['industry'] = isset( $onboarding_data['industry'] ) ? $this->filter_industries( $onboarding_data['industry'] ) : null; $item_schema = $this->get_item_schema(); $items = array(); foreach ( $item_schema['properties'] as $key => $property_schema ) { $items[ $key ] = isset( $onboarding_data[ $key ] ) ? $onboarding_data[ $key ] : null; } $wccom_auth = \WC_Helper_Options::get( 'auth' ); $items['wccom_connected'] = empty( $wccom_auth['access_token'] ) ? false : true; $item = $this->prepare_item_for_response( $items, $request ); $data = $this->prepare_response_for_collection( $item ); return rest_ensure_response( $data ); } /** * Filter the industries. * * @param array $industries List of industries. * @return array */ protected function filter_industries( $industries ) { /** * Filter the list of industries. * * @since 6.5.0 * @param array $industries List of industries. */ return apply_filters( 'woocommerce_admin_onboarding_industries', $industries ); } /** * Update onboarding profile data. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function update_items( $request ) { $params = $request->get_json_params(); $query_args = $this->prepare_objects_query( $params ); $onboarding_data = (array) get_option( Profile::DATA_OPTION, array() ); $profile_data = array_merge( $onboarding_data, $query_args ); update_option( Profile::DATA_OPTION, $profile_data ); /** * Fires when onboarding profile data is updated via the REST API. * * @since 6.5.0 * @param array $onboarding_data Previous onboarding data. * @param array $query_args New data being set. */ do_action( 'woocommerce_onboarding_profile_data_updated', $onboarding_data, $query_args ); $result = array( 'status' => 'success', 'message' => __( 'Onboarding profile data has been updated.', 'woocommerce' ), ); $response = $this->prepare_item_for_response( $result, $request ); $data = $this->prepare_response_for_collection( $response ); return rest_ensure_response( $data ); } /** * Returns a default email to be pre-filled in OBW. Prioritizes Jetpack if connected, * otherwise will default to WordPress general settings. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function get_email_prefill( $request ) { $result = array( 'email' => '', ); // Attempt to get email from Jetpack. if ( class_exists( Jetpack_Connection_Manager::class ) ) { $jetpack_connection_manager = new Jetpack_Connection_Manager(); if ( $jetpack_connection_manager->is_active() ) { $jetpack_user = $jetpack_connection_manager->get_connected_user_data(); $result['email'] = $jetpack_user['email']; } } // Attempt to get email from WordPress general settings. if ( empty( $result['email'] ) ) { $result['email'] = get_option( 'admin_email' ); } return rest_ensure_response( $result ); } /** * Mark a core profiler step as complete. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function core_profiler_step_complete( $request ) { $json = $request->get_json_params(); $step = $json['step']; $onboarding_progress = (array) get_option( Profile::PROGRESS_OPTION, array() ); if ( ! isset( $onboarding_progress['core_profiler_completed_steps'] ) ) { $onboarding_progress['core_profiler_completed_steps'] = array(); } $onboarding_progress['core_profiler_completed_steps'][ $step ] = array( 'completed_at' => gmdate( 'Y-m-d\TH:i:s\Z' ), ); update_option( Profile::PROGRESS_OPTION, $onboarding_progress ); /** * Fires when a core profiler step is completed. * * @since 6.5.0 * @param string $step The completed step name. */ do_action( 'woocommerce_core_profiler_step_complete', $step ); $response_data = array( 'results' => $onboarding_progress, 'status' => 'success', ); $response = rest_ensure_response( $response_data ); return $response; } /** * Get the onboarding profile progress. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function get_profile_progress( $request ) { $onboarding_progress = (array) get_option( Profile::PROGRESS_OPTION, array() ); return rest_ensure_response( $onboarding_progress ); } /** * Update store's currency and measurement units. * Requires 'country' code to be passed in the request. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function update_store_currency_and_measurement_units( WP_REST_Request $request ) { $country_code = $request->get_param( 'country_code' ); $locale_info = include WC()->plugin_path() . '/i18n/locale-info.php'; if ( empty( $country_code ) || ! isset( $locale_info[ $country_code ] ) ) { return new WP_Error( 'woocommerce_rest_invalid_country_code', __( 'Invalid country code.', 'woocommerce' ), array( 'status' => 400 ) ); } $country_info = $locale_info[ $country_code ]; $currency_settings = array( 'woocommerce_currency' => $country_info['currency_code'], 'woocommerce_currency_pos' => $country_info['currency_pos'], 'woocommerce_price_thousand_sep' => $country_info['thousand_sep'], 'woocommerce_price_decimal_sep' => $country_info['decimal_sep'], 'woocommerce_price_num_decimals' => $country_info['num_decimals'], 'woocommerce_weight_unit' => $country_info['weight_unit'], 'woocommerce_dimension_unit' => $country_info['dimension_unit'], ); foreach ( $currency_settings as $key => $value ) { update_option( $key, $value ); } return new WP_REST_Response( array(), 204 ); } /** * Prepare objects query. * * @param array $params The params sent in the request. * @return array */ protected function prepare_objects_query( $params ) { $args = array(); $properties = self::get_profile_properties(); foreach ( $properties as $key => $property ) { if ( isset( $params[ $key ] ) ) { $args[ $key ] = $params[ $key ]; } } /** * Filter the query arguments for a request. * * Enables adding extra arguments or setting defaults for a post * collection request. * * @since 6.5.0 * @param array $args Key value array of query var to query value. * @param array $params The params sent in the request. */ $args = apply_filters( 'woocommerce_rest_onboarding_profile_object_query', $args, $params ); return $args; } /** * Prepare the data object for response. * * @param object $item Data object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response $response Response data. */ public function prepare_item_for_response( $item, $request ) { $data = $this->add_additional_fields_to_object( $item, $request ); $data = $this->filter_response_by_context( $data, 'view' ); $response = rest_ensure_response( $data ); /** * Filter the list returned from the API. * * @since 6.5.0 * @param WP_REST_Response $response The response object. * @param array $item The original item. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_onboarding_prepare_profile', $response, $item, $request ); } /** * Get onboarding profile properties. * * @return array */ public static function get_profile_properties() { $properties = array( 'completed' => array( 'type' => 'boolean', 'description' => __( 'Whether or not the profile was completed.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'validate_callback' => 'rest_validate_request_arg', ), 'skipped' => array( 'type' => 'boolean', 'description' => __( 'Whether or not the profile was skipped.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'validate_callback' => 'rest_validate_request_arg', ), 'industry' => array( 'type' => 'array', 'description' => __( 'Industry.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'nullable' => true, 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', ), ), 'business_extensions' => array( 'type' => 'array', 'description' => __( 'Extra business extensions to install.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', ), ), 'is_agree_marketing' => array( 'type' => 'boolean', 'description' => __( 'Whether or not this store agreed to receiving marketing contents from WooCommerce.com.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'validate_callback' => 'rest_validate_request_arg', ), 'store_email' => array( 'type' => 'string', 'description' => __( 'Store email address.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'nullable' => true, 'validate_callback' => array( __CLASS__, 'rest_validate_marketing_email' ), ), 'is_store_country_set' => array( 'type' => 'boolean', 'description' => __( 'Whether or not this store country is set via onboarding profiler.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'validate_callback' => 'rest_validate_request_arg', ), 'is_plugins_page_skipped' => array( 'type' => 'boolean', 'description' => __( 'Whether or not plugins step in core profiler was skipped.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'validate_callback' => 'rest_validate_request_arg', ), 'business_choice' => array( 'type' => 'string', 'description' => __( 'Business choice.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'nullable' => true, ), 'selling_online_answer' => array( 'type' => 'string', 'description' => __( 'Selling online answer.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'nullable' => true, ), 'selling_platforms' => array( 'type' => array( 'array', 'null' ), 'description' => __( 'Selling platforms.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'nullable' => true, 'items' => array( 'type' => array( 'string', 'null' ), ), ), ); /** * Filters the Onboarding Profile REST API JSON Schema. * * @since 6.5.0 * @param array $properties List of properties. */ return apply_filters( 'woocommerce_rest_onboarding_profile_properties', $properties ); } /** * Optionally validates email if user agreed to marketing or if email is not empty. * * @param mixed $value Email value. * @param WP_REST_Request $request Request object. * @param string $param Parameter name. * @return true|WP_Error */ public static function rest_validate_marketing_email( $value, $request, $param ) { $is_agree_marketing = $request->get_param( 'is_agree_marketing' ); if ( ( $is_agree_marketing || ! empty( $value ) ) && ! is_email( $value ) ) { return new \WP_Error( 'rest_invalid_email', __( 'Invalid email address', 'woocommerce' ) ); } return true; } /** * Get the schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { // Unset properties used for collection params. $properties = self::get_profile_properties(); foreach ( $properties as $key => $property ) { unset( $properties[ $key ]['default'] ); unset( $properties[ $key ]['items'] ); unset( $properties[ $key ]['validate_callback'] ); unset( $properties[ $key ]['sanitize_callback'] ); } $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'onboarding_profile', 'type' => 'object', 'properties' => $properties, ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { // Unset properties used for item schema. $params = self::get_profile_properties(); foreach ( $params as $key => $param ) { unset( $params[ $key ]['context'] ); unset( $params[ $key ]['readonly'] ); } $params['context'] = $this->get_context_param( array( 'default' => 'view' ) ); /** * Filters the Onboarding Profile REST API collection parameters. * * @since 6.5.0 * @param array $params Collection parameters. */ return apply_filters( 'woocommerce_rest_onboarding_profile_collection_params', $params ); } } API/MobileAppMagicLink.php 0000777 00000004143 15252240713 0011327 0 ustar 00 <?php /** * REST API Data countries controller. * * Handles requests to the /mobile-app endpoint. */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; use Automattic\Jetpack\Connection\Manager as Jetpack_Connection_Manager; /** * REST API Data countries controller class. * * @internal * @extends WC_REST_Data_Controller */ class MobileAppMagicLink extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'mobile-app'; /** * Register routes. * * @since 7.0.0 */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/send-magic-link', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'send_magic_link' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); parent::register_routes(); } /** * Sends request to generate magic link email. * * @return \WP_REST_Response|\WP_Error */ public function send_magic_link() { // Attempt to get email from Jetpack. if ( class_exists( Jetpack_Connection_Manager::class ) ) { $jetpack_connection_manager = new Jetpack_Connection_Manager(); if ( $jetpack_connection_manager->is_active() ) { if ( class_exists( 'Jetpack_IXR_Client' ) ) { $xml = new \Jetpack_IXR_Client( array( 'user_id' => get_current_user_id(), ) ); $xml->query( 'jetpack.sendMobileMagicLink', array( 'app' => 'woocommerce' ) ); if ( $xml->isError() ) { return new \WP_Error( 'error_sending_mobile_magic_link', sprintf( '%s: %s', $xml->getErrorCode(), $xml->getErrorMessage() ) ); } return rest_ensure_response( array( 'code' => 'success', ) ); } } } return new \WP_Error( 'jetpack_not_connected', __( 'Jetpack is not connected.', 'woocommerce' ) ); } } API/Orders.php 0000777 00000024210 15252240713 0007133 0 ustar 00 <?php /** * REST API Orders Controller * * Handles requests to /orders/* */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Controller as ReportsController; use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore; use Automattic\WooCommerce\Utilities\OrderUtil; /** * Orders controller. * * @internal * @extends WC_REST_Orders_Controller */ class Orders extends \WC_REST_Orders_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); // This needs to remain a string to support extensions that filter Order Number. $params['number'] = array( 'description' => __( 'Limit result set to orders matching part of an order number.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); // Fix the default 'status' value until it can be patched in core. $params['status']['default'] = array( 'any' ); // Analytics settings may affect the allowed status list. $params['status']['items']['enum'] = ReportsController::get_order_statuses(); return $params; } /** * Prepare objects query. * * @param WP_REST_Request $request Full details about the request. * @return array */ protected function prepare_objects_query( $request ) { $args = parent::prepare_objects_query( $request ); if ( ! empty( $request['number'] ) ) { $args = $this->search_partial_order_number( $request['number'], $args ); } return $args; } /** * Helper method to allow searching by partial order number. * * @param int $number Partial order number match. * @param array $args List of arguments for the request. * * @return array Modified args with partial order search included. */ private function search_partial_order_number( $number, $args ) { global $wpdb; $partial_number = trim( $number ); $limit = intval( $args['posts_per_page'] ); if ( OrderUtil::custom_orders_table_usage_is_enabled() ) { $order_table_name = OrdersTableDataStore::get_orders_table_name(); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $orders_table_name is hardcoded. $order_ids = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM $order_table_name WHERE type = 'shop_order' AND id LIKE %s LIMIT %d", $wpdb->esc_like( absint( $partial_number ) ) . '%', $limit ) ); // phpcs:enable } else { $order_ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->prefix}posts WHERE post_type = 'shop_order' AND ID LIKE %s LIMIT %d", $wpdb->esc_like( absint( $partial_number ) ) . '%', $limit ) ); } // Force WP_Query return empty if don't found any order. $order_ids = empty( $order_ids ) ? array( 0 ) : $order_ids; $args['post__in'] = $order_ids; return $args; } /** * Get product IDs, names, and quantity from order ID. * * @param array $order_id ID of order. * @return array */ protected function get_products_by_order_id( $order_id ) { global $wpdb; $order_items_table = $wpdb->prefix . 'woocommerce_order_items'; $order_itemmeta_table = $wpdb->prefix . 'woocommerce_order_itemmeta'; $products = $wpdb->get_results( $wpdb->prepare( // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT order_id, order_itemmeta.meta_value as product_id, order_itemmeta_2.meta_value as product_quantity, order_itemmeta_3.meta_value as variation_id, {$wpdb->posts}.post_title as product_name FROM {$order_items_table} order_items LEFT JOIN {$order_itemmeta_table} order_itemmeta on order_items.order_item_id = order_itemmeta.order_item_id LEFT JOIN {$order_itemmeta_table} order_itemmeta_2 on order_items.order_item_id = order_itemmeta_2.order_item_id LEFT JOIN {$order_itemmeta_table} order_itemmeta_3 on order_items.order_item_id = order_itemmeta_3.order_item_id LEFT JOIN {$wpdb->posts} on {$wpdb->posts}.ID = order_itemmeta.meta_value WHERE order_id = ( %d ) AND order_itemmeta.meta_key = '_product_id' AND order_itemmeta_2.meta_key = '_qty' AND order_itemmeta_3.meta_key = '_variation_id' GROUP BY product_id ", // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $order_id ), ARRAY_A ); return $products; } /** * Get customer data from customer_id. * * @param array $customer_id ID of customer. * @return array */ protected function get_customer_by_id( $customer_id ) { global $wpdb; $customer_lookup_table = $wpdb->prefix . 'wc_customer_lookup'; $customer = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT * FROM {$customer_lookup_table} WHERE customer_id = ( %d )", $customer_id ), ARRAY_A ); return $customer; } /** * Get formatted item data. * * @param WC_Data $object WC_Data instance. * @return array */ protected function get_formatted_item_data( $object ) { $extra_fields = array( 'customer', 'products' ); $fields = false; // Determine if the response fields were specified. if ( ! empty( $this->request['_fields'] ) ) { $fields = wp_parse_list( $this->request['_fields'] ); if ( 0 === count( $fields ) ) { $fields = false; } else { $fields = array_map( 'trim', $fields ); } } // Initially skip line items if we can. $using_order_class_override = is_a( $object, '\Automattic\WooCommerce\Admin\Overrides\Order' ); if ( $using_order_class_override ) { $data = $object->get_data_without_line_items(); } else { $data = $object->get_data(); } $extra_fields = false === $fields ? array() : array_intersect( $extra_fields, $fields ); $format_decimal = array( 'discount_total', 'discount_tax', 'shipping_total', 'shipping_tax', 'shipping_total', 'shipping_tax', 'cart_tax', 'total', 'total_tax' ); $format_date = array( 'date_created', 'date_modified', 'date_completed', 'date_paid' ); $format_line_items = array( 'line_items', 'tax_lines', 'shipping_lines', 'fee_lines', 'coupon_lines' ); // Add extra data as necessary. $extra_data = array(); foreach ( $extra_fields as $field ) { switch ( $field ) { case 'customer': $extra_data['customer'] = $this->get_customer_by_id( $data['customer_id'] ); break; case 'products': $extra_data['products'] = $this->get_products_by_order_id( $object->get_id() ); break; } } // Format decimal values. foreach ( $format_decimal as $key ) { $data[ $key ] = wc_format_decimal( $data[ $key ], $this->request['dp'] ); } // format total with order currency. if ( $object instanceof \WC_Order ) { $data['total_formatted'] = wp_strip_all_tags( html_entity_decode( $object->get_formatted_order_total() ), true ); } // Format date values. foreach ( $format_date as $key ) { $datetime = $data[ $key ]; $data[ $key ] = wc_rest_prepare_date_response( $datetime, false ); $data[ $key . '_gmt' ] = wc_rest_prepare_date_response( $datetime ); } // Format the order status. $data['status'] = OrderUtil::remove_status_prefix( $data['status'] ); // Format requested line items. $formatted_line_items = array(); foreach ( $format_line_items as $key ) { if ( false === $fields || in_array( $key, $fields, true ) ) { if ( $using_order_class_override ) { $line_item_data = $object->get_line_item_data( $key ); } else { $line_item_data = $data[ $key ]; } $formatted_line_items[ $key ] = array_values( array_map( array( $this, 'get_order_item_data' ), $line_item_data ) ); } } // Refunds. $data['refunds'] = array(); foreach ( $object->get_refunds() as $refund ) { $data['refunds'][] = array( 'id' => $refund->get_id(), 'reason' => $refund->get_reason() ? $refund->get_reason() : '', 'total' => '-' . wc_format_decimal( $refund->get_amount(), $this->request['dp'] ), ); } return array_merge( array( 'id' => $object->get_id(), 'parent_id' => $data['parent_id'], 'number' => $data['number'], 'order_key' => $data['order_key'], 'created_via' => $data['created_via'], 'version' => $data['version'], 'status' => $data['status'], 'currency' => $data['currency'], 'date_created' => $data['date_created'], 'date_created_gmt' => $data['date_created_gmt'], 'date_modified' => $data['date_modified'], 'date_modified_gmt' => $data['date_modified_gmt'], 'discount_total' => $data['discount_total'], 'discount_tax' => $data['discount_tax'], 'shipping_total' => $data['shipping_total'], 'shipping_tax' => $data['shipping_tax'], 'cart_tax' => $data['cart_tax'], 'total' => $data['total'], 'total_formatted' => isset( $data['total_formatted'] ) ? $data['total_formatted'] : $data['total'], 'total_tax' => $data['total_tax'], 'prices_include_tax' => $data['prices_include_tax'], 'customer_id' => $data['customer_id'], 'customer_ip_address' => $data['customer_ip_address'], 'customer_user_agent' => $data['customer_user_agent'], 'customer_note' => $data['customer_note'], 'billing' => $data['billing'], 'shipping' => $data['shipping'], 'payment_method' => $data['payment_method'], 'payment_method_title' => $data['payment_method_title'], 'transaction_id' => $data['transaction_id'], 'date_paid' => $data['date_paid'], 'date_paid_gmt' => $data['date_paid_gmt'], 'date_completed' => $data['date_completed'], 'date_completed_gmt' => $data['date_completed_gmt'], 'cart_hash' => $data['cart_hash'], 'meta_data' => $data['meta_data'], 'refunds' => $data['refunds'], ), $formatted_line_items, $extra_data ); } } API/PaymentGatewaySuggestions.php 0000777 00000013556 15252240713 0013102 0 ustar 00 <?php /** * REST API Payment Gateway Suggestions Controller * * Handles requests to install and activate dependent plugins. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\DefaultPaymentGateways; use Automattic\WooCommerce\Admin\Features\PaymentGatewaySuggestions\Init as Suggestions; defined( 'ABSPATH' ) || exit; /** * PaymentGatewaySuggetsions Controller. * * @internal * @extends WC_REST_Data_Controller */ class PaymentGatewaySuggestions extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'payment-gateway-suggestions'; /** * 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_suggestions' ), 'permission_callback' => array( $this, 'user_can_manage_woocommerce' ), 'args' => array( 'force_default_suggestions' => array( 'type' => 'boolean', 'description' => __( 'Return the default payment suggestions when woocommerce_show_marketplace_suggestions and woocommerce_setting_payments_recommendations_hidden options are set to no', 'woocommerce' ), ), ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/dismiss', array( array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'dismiss_payment_gateway_suggestion' ), 'permission_callback' => array( $this, 'get_permission_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); } /** * Check if a given request has access to manage plugins. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function get_permission_check( $request ) { if ( ! current_user_can( 'install_plugins' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Check if a given request has access to manage woocommerce. * * @return \WP_Error|boolean */ public function user_can_manage_woocommerce() { if ( current_user_can( 'manage_woocommerce' ) ) { return true; } return new \WP_Error( 'woocommerce_rest_invalid_user', __( 'You are not allowed to make this request.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } /** * Return suggested payment gateways. * * @param WP_REST_Request $request Full details about the request. * @return \WP_Error|\WP_HTTP_Response|\WP_REST_Response */ public function get_suggestions( $request ) { $should_display = Suggestions::should_display(); $force_default = $request->get_param( 'force_default_suggestions' ); if ( $should_display ) { return Suggestions::get_suggestions(); } elseif ( false === $should_display && true === $force_default ) { return rest_ensure_response( Suggestions::get_suggestions( DefaultPaymentGateways::get_all() ) ); } return rest_ensure_response( array() ); } /** * Dismisses suggested payment gateways. * * @return \WP_Error|\WP_HTTP_Response|\WP_REST_Response */ public function dismiss_payment_gateway_suggestion() { $success = Suggestions::dismiss(); return rest_ensure_response( $success ); } /** * Get the schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'payment-gateway-suggestions', 'type' => 'object', 'properties' => array( 'content' => array( 'description' => __( 'Suggestion description.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'id' => array( 'description' => __( 'Suggestion ID.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'image' => array( 'description' => __( 'Gateway image.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'is_visible' => array( 'description' => __( 'Suggestion visibility.', 'woocommerce' ), 'type' => 'boolean', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'plugins' => array( 'description' => __( 'Array of plugin slugs.', 'woocommerce' ), 'type' => 'array', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'recommendation_priority' => array( 'description' => __( 'Priority of recommendation.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'title' => array( 'description' => __( 'Gateway title.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'transaction_processors' => array( 'description' => __( 'Array of transaction processors and their images.', 'woocommerce' ), 'type' => 'object', 'addtionalProperties' => array( 'type' => 'string', 'format' => 'uri', ), 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } } API/Taxes.php 0000777 00000011634 15252240713 0006767 0 ustar 00 <?php /** * REST API Taxes Controller * * Handles requests to /taxes/* */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Taxes controller. * * @internal * @extends WC_REST_Taxes_Controller */ class Taxes extends \WC_REST_Taxes_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['search'] = array( 'description' => __( 'Search by similar tax code.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['include'] = array( 'description' => __( 'Limit result set to items that have the specified rate ID(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get all taxes and allow filtering by tax code. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { global $wpdb; $prepared_args = array(); $prepared_args['order'] = $request['order']; $prepared_args['number'] = $request['per_page']; if ( ! empty( $request['offset'] ) ) { $prepared_args['offset'] = $request['offset']; } else { $prepared_args['offset'] = ( $request['page'] - 1 ) * $prepared_args['number']; } $orderby_possibles = array( 'id' => 'tax_rate_id', 'order' => 'tax_rate_order', ); $prepared_args['orderby'] = $orderby_possibles[ $request['orderby'] ]; $prepared_args['class'] = $request['class']; $prepared_args['search'] = $request['search']; $prepared_args['include'] = $request['include']; /** * Filter arguments, before passing to $wpdb->get_results(), when querying taxes via the REST API. * * @param array $prepared_args Array of arguments for $wpdb->get_results(). * @param WP_REST_Request $request The current request. */ $prepared_args = apply_filters( 'woocommerce_rest_tax_query', $prepared_args, $request ); $query = " SELECT * FROM {$wpdb->prefix}woocommerce_tax_rates WHERE 1 = 1 "; // Filter by tax class. if ( ! empty( $prepared_args['class'] ) ) { $class = 'standard' !== $prepared_args['class'] ? sanitize_title( $prepared_args['class'] ) : ''; $query .= " AND tax_rate_class = '$class'"; } // Filter by tax code. $tax_code_search = $prepared_args['search']; if ( $tax_code_search ) { $code_like = '%' . $wpdb->esc_like( $tax_code_search ) . '%'; $query .= $wpdb->prepare( ' AND CONCAT_WS( "-", NULLIF(tax_rate_country, ""), NULLIF(tax_rate_state, ""), NULLIF(tax_rate_name, ""), NULLIF(tax_rate_priority, "") ) LIKE %s', $code_like ); } // Filter by included tax rate IDs. $included_taxes = array_map( 'absint', $prepared_args['include'] ); if ( ! empty( $included_taxes ) ) { $included_taxes = implode( ',', $prepared_args['include'] ); $query .= " AND tax_rate_id IN ({$included_taxes})"; } // Order tax rates. $order_by = sprintf( ' ORDER BY %s', sanitize_key( $prepared_args['orderby'] ) ); // Pagination. $pagination = sprintf( ' LIMIT %d, %d', $prepared_args['offset'], $prepared_args['number'] ); // Query taxes. $results = $wpdb->get_results( $query . $order_by . $pagination ); // @codingStandardsIgnoreLine. $taxes = array(); foreach ( $results as $tax ) { $data = $this->prepare_item_for_response( $tax, $request ); $taxes[] = $this->prepare_response_for_collection( $data ); } $response = rest_ensure_response( $taxes ); // Store pagination values for headers then unset for count query. $per_page = (int) $prepared_args['number']; $page = ceil( ( ( (int) $prepared_args['offset'] ) / $per_page ) + 1 ); // Query only for ids. $wpdb->get_results( str_replace( 'SELECT *', 'SELECT tax_rate_id', $query ) ); // @codingStandardsIgnoreLine. // Calculate totals. $total_taxes = (int) $wpdb->num_rows; $response->header( 'X-WP-Total', (int) $total_taxes ); $max_pages = ceil( $total_taxes / $per_page ); $response->header( 'X-WP-TotalPages', (int) $max_pages ); $base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) ); if ( $page > 1 ) { $prev_page = $page - 1; if ( $prev_page > $max_pages ) { $prev_page = $max_pages; } $prev_link = add_query_arg( 'page', $prev_page, $base ); $response->link_header( 'prev', $prev_link ); } if ( $max_pages > $page ) { $next_page = $page + 1; $next_link = add_query_arg( 'page', $next_page, $base ); $response->link_header( 'next', $next_link ); } return $response; } } API/DataDownloadIPs.php 0000777 00000010230 15252240713 0010647 0 ustar 00 <?php /** * REST API Data Download IP Controller * * Handles requests to /data/download-ips */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Data Download IP controller. * * @internal * @extends WC_REST_Data_Controller */ class DataDownloadIPs extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Route base. * * @var string */ protected $rest_base = 'data/download-ips'; /** * Register routes. * * @since 3.5.0 */ 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' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Return the download IPs matching the passed parameters. * * @since 3.5.0 * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { global $wpdb; if ( isset( $request['match'] ) ) { $downloads = $wpdb->get_results( $wpdb->prepare( "SELECT DISTINCT( user_ip_address ) FROM {$wpdb->prefix}wc_download_log WHERE user_ip_address LIKE %s LIMIT 10", $request['match'] . '%' ) ); } else { return new \WP_Error( 'woocommerce_rest_data_download_ips_invalid_request', __( 'Invalid request. Please pass the match parameter.', 'woocommerce' ), array( 'status' => 400 ) ); } $data = array(); if ( ! empty( $downloads ) ) { foreach ( $downloads as $download ) { $response = $this->prepare_item_for_response( $download, $request ); $data[] = $this->prepare_response_for_collection( $response ); } } return rest_ensure_response( $data ); } /** * Prepare the data object for response. * * @since 3.5.0 * @param object $item Data object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response $response Response data. */ public function prepare_item_for_response( $item, $request ) { $data = $this->add_additional_fields_to_object( $item, $request ); $data = $this->filter_response_by_context( $data, 'view' ); $response = rest_ensure_response( $data ); $response->add_links( $this->prepare_links( $item ) ); /** * Filter the list returned from the API. * * @param WP_REST_Response $response The response object. * @param array $item The original item. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_data_download_ip', $response, $item, $request ); } /** * Prepare links for the request. * * @param object $item Data object. * @return array Links for the given object. */ protected function prepare_links( $item ) { $links = array( 'collection' => array( 'href' => rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ), ), ); return $links; } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = array(); $params['context'] = $this->get_context_param( array( 'default' => 'view' ) ); $params['match'] = array( 'description' => __( 'A partial IP address can be passed and matching results will be returned.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'data_download_ips', 'type' => 'object', 'properties' => array( 'user_ip_address' => array( 'type' => 'string', 'description' => __( 'IP address.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } } API/LaunchYourStore.php 0000777 00000012231 15252240713 0011003 0 ustar 00 <?php /** * REST API Launch Your Store Controller * * Handles requests to /launch-your-store/* */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Admin\WCAdminHelper; defined( 'ABSPATH' ) || exit; /** * Launch Your Store controller. * * @internal */ class LaunchYourStore { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'launch-your-store'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/initialize-coming-soon', array( array( 'methods' => 'POST', 'callback' => array( $this, 'initialize_coming_soon' ), 'permission_callback' => array( $this, 'must_be_shop_manager_or_admin' ), ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/update-survey-status', array( array( 'methods' => 'POST', 'callback' => array( $this, 'update_survey_status' ), 'permission_callback' => array( $this, 'must_be_shop_manager_or_admin' ), 'args' => array( 'status' => array( 'type' => 'string', 'enum' => array( 'yes', 'no' ), ), ), ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/survey-completed', array( array( 'methods' => 'GET', 'callback' => array( $this, 'has_survey_completed' ), 'permission_callback' => array( $this, 'must_be_shop_manager_or_admin' ), ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/woopayments/test-orders/count', array( array( 'methods' => 'GET', 'callback' => array( $this, 'get_woopay_test_orders_count' ), 'permission_callback' => array( $this, 'must_be_shop_manager_or_admin' ), ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/woopayments/test-orders', array( array( 'methods' => 'DELETE', 'callback' => array( $this, 'delete_woopay_test_orders' ), 'permission_callback' => array( $this, 'must_be_shop_manager_or_admin' ), ), ) ); } /** * User must be either shop_manager or administrator. * * @return bool */ public function must_be_shop_manager_or_admin() { // phpcs:ignore if ( ! current_user_can( 'manage_woocommerce' ) && ! current_user_can( 'administrator' ) ) { return false; } return true; } /** * Initializes options for coming soon. Overwrites existing coming soon status but keeps the private link and share key. * * @return bool|void */ public function initialize_coming_soon() { $current_user_id = get_current_user_id(); // Abort if we don't have a user id for some reason. if ( ! $current_user_id ) { return; } $coming_soon = 'yes'; $store_pages_only = WCAdminHelper::is_site_fresh() ? 'no' : 'yes'; $private_link = 'no'; $share_key = wp_generate_password( 32, false ); update_option( 'woocommerce_coming_soon', $coming_soon ); update_option( 'woocommerce_store_pages_only', $store_pages_only ); add_option( 'woocommerce_private_link', $private_link ); add_option( 'woocommerce_share_key', $share_key ); wc_admin_record_tracks_event( 'launch_your_store_initialize_coming_soon', array( 'coming_soon' => $coming_soon, 'store_pages_only' => $store_pages_only, 'private_link' => $private_link, ) ); return true; } /** * Count the test orders created during Woo Payments test mode. * * @return \WP_REST_Response */ public function get_woopay_test_orders_count() { $return = function ( $count ) { return new \WP_REST_Response( array( 'count' => $count ) ); }; $orders = wc_get_orders( array( // phpcs:ignore 'meta_key' => '_wcpay_mode', // phpcs:ignore 'meta_value' => 'test', 'return' => 'ids', ) ); return $return( count( $orders ) ); } /** * Delete WooPayments test orders. * * @return \WP_REST_Response */ public function delete_woopay_test_orders() { $return = function ( $status = 204 ) { return new \WP_REST_Response( null, $status ); }; $orders = wc_get_orders( array( // phpcs:ignore 'meta_key' => '_wcpay_mode', // phpcs:ignore 'meta_value' => 'test', ) ); foreach ( $orders as $order ) { $order->delete(); } return $return(); } /** * Update woocommerce_admin_launch_your_store_survey_completed to yes or no * * @param \WP_REST_Request $request WP_REST_Request object. * * @return \WP_REST_Response */ public function update_survey_status( \WP_REST_Request $request ) { update_option( 'woocommerce_admin_launch_your_store_survey_completed', $request->get_param( 'status' ) ); return new \WP_REST_Response(); } /** * Return woocommerce_admin_launch_your_store_survey_completed option. * * @return \WP_REST_Response */ public function has_survey_completed() { return new \WP_REST_Response( get_option( 'woocommerce_admin_launch_your_store_survey_completed', 'no' ) ); } } API/OnboardingThemes.php 0000777 00000012764 15252240713 0011140 0 ustar 00 <?php /** * REST API Onboarding Themes Controller * * Handles requests to install and activate themes. */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Onboarding Themes Controller. * * @internal * @extends WC_REST_Data_Controller */ class OnboardingThemes extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'onboarding/themes'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/install', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'install_theme' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/activate', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'activate_theme' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); } /** * Check if a given request has access to manage themes. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function update_item_permissions_check( $request ) { if ( ! current_user_can( 'switch_themes' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage themes.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Installs the requested theme. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|array Theme installation status. */ public function install_theme( $request ) { $theme = sanitize_text_field( $request['theme'] ); $installed_themes = wp_get_themes(); if ( in_array( $theme, array_keys( $installed_themes ), true ) ) { return( array( 'slug' => $theme, 'name' => $installed_themes[ $theme ]->get( 'Name' ), 'status' => 'success', ) ); } include_once ABSPATH . '/wp-admin/includes/admin.php'; include_once ABSPATH . '/wp-admin/includes/theme-install.php'; include_once ABSPATH . '/wp-admin/includes/theme.php'; include_once ABSPATH . '/wp-admin/includes/class-wp-upgrader.php'; include_once ABSPATH . '/wp-admin/includes/class-theme-upgrader.php'; $api = themes_api( 'theme_information', array( 'slug' => $theme, 'fields' => array( 'sections' => false, ), ) ); if ( is_wp_error( $api ) ) { return new \WP_Error( 'woocommerce_rest_theme_install', sprintf( /* translators: %s: theme slug (example: woocommerce-services) */ __( 'The requested theme `%s` could not be installed. Theme API call failed.', 'woocommerce' ), $theme ), 500 ); } $upgrader = new \Theme_Upgrader( new \Automatic_Upgrader_Skin() ); $result = $upgrader->install( $api->download_link ); if ( is_wp_error( $result ) || is_null( $result ) ) { return new \WP_Error( 'woocommerce_rest_theme_install', sprintf( /* translators: %s: theme slug (example: woocommerce-services) */ __( 'The requested theme `%s` could not be installed.', 'woocommerce' ), $theme ), 500 ); } return array( 'slug' => $theme, 'name' => $api->name, 'status' => 'success', ); } /** * Activate the requested theme. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|array Theme activation status. */ public function activate_theme( $request ) { $theme = sanitize_text_field( $request['theme'] ); require_once ABSPATH . 'wp-admin/includes/theme.php'; $installed_themes = wp_get_themes(); if ( ! in_array( $theme, array_keys( $installed_themes ), true ) ) { /* translators: %s: theme slug (example: woocommerce-services) */ return new \WP_Error( 'woocommerce_rest_invalid_theme', sprintf( __( 'Invalid theme %s.', 'woocommerce' ), $theme ), 404 ); } $result = switch_theme( $theme ); if ( ! is_null( $result ) ) { return new \WP_Error( 'woocommerce_rest_invalid_theme', sprintf( __( 'The requested theme could not be activated.', 'woocommerce' ), $theme ), 500 ); } return( array( 'slug' => $theme, 'name' => $installed_themes[ $theme ]->get( 'Name' ), 'status' => 'success', ) ); } /** * Get the schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'onboarding_theme', 'type' => 'object', 'properties' => array( 'slug' => array( 'description' => __( 'Theme slug.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'name' => array( 'description' => __( 'Theme name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'status' => array( 'description' => __( 'Theme status.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } } API/OnboardingPlugins.php 0000777 00000025250 15252240713 0011326 0 ustar 00 <?php /** * REST API Onboarding Profile Controller * * Handles requests to /onboarding/profile */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\PluginsHelper; use Automattic\WooCommerce\Internal\Jetpack\JetpackConnection; use WC_REST_Data_Controller; use WP_Error; use WP_REST_Request; use WP_REST_Response; /** * Onboarding Plugins controller. * * @internal * @extends WC_REST_Data_Controller */ class OnboardingPlugins extends WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'onboarding/plugins'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/install-and-activate-async', array( array( 'methods' => 'POST', 'callback' => array( $this, 'install_and_activate_async' ), 'permission_callback' => array( $this, 'can_install_and_activate_plugins' ), 'args' => array( 'plugins' => array( 'description' => 'A list of plugins to install', 'type' => 'array', 'items' => 'string', 'sanitize_callback' => function ( $value ) { return array_map( function ( $value ) { return sanitize_text_field( $value ); }, $value ); }, 'required' => true, ), 'source' => array( 'description' => 'The source of the request', 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'required' => false, ), ), ), 'schema' => array( $this, 'get_install_async_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/install-and-activate', array( array( 'methods' => 'POST', 'callback' => array( $this, 'install_and_activate' ), 'permission_callback' => array( $this, 'can_install_and_activate_plugins' ), ), 'schema' => array( $this, 'get_install_activate_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/scheduled-installs/(?P<job_id>\w+)', array( array( 'methods' => 'GET', 'callback' => array( $this, 'get_scheduled_installs' ), 'permission_callback' => array( $this, 'can_install_plugins' ), ), 'schema' => array( $this, 'get_install_async_schema' ), ) ); // This is an experimental endpoint and is subject to change in the future. register_rest_route( $this->namespace, '/' . $this->rest_base . '/jetpack-authorization-url', array( array( 'methods' => 'GET', 'callback' => array( $this, 'get_jetpack_authorization_url' ), 'permission_callback' => array( $this, 'can_install_plugins' ), 'args' => array( 'redirect_url' => array( 'description' => 'The URL to redirect to after authorization', 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'required' => true, ), 'from' => array( 'description' => 'from value for the jetpack authorization page', 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'required' => false, 'default' => 'woocommerce-onboarding', ), ), ), ) ); add_action( 'woocommerce_plugins_install_error', array( $this, 'log_plugins_install_error' ), 10, 4 ); add_action( 'woocommerce_plugins_install_api_error', array( $this, 'log_plugins_install_api_error' ), 10, 2 ); } /** * Install and activate a plugin. * * @param WP_REST_Request $request WP Request object. * * @return WP_REST_Response */ public function install_and_activate( WP_REST_Request $request ) { $response = array(); $response['install'] = PluginsHelper::install_plugins( $request->get_param( 'plugins' ) ); $response['activate'] = PluginsHelper::activate_plugins( $response['install']['installed'] ); return new WP_REST_Response( $response ); } /** * Queue plugin install request. * * @param WP_REST_Request $request WP_REST_Request object. * * @return array */ public function install_and_activate_async( WP_REST_Request $request ) { $plugins = $request->get_param( 'plugins' ); $source = $request->get_param( 'source' ); $job_id = uniqid(); WC()->queue()->add( 'woocommerce_plugins_install_and_activate_async_callback', array( $plugins, $job_id, $source ) ); $plugin_status = array(); foreach ( $plugins as $plugin ) { $plugin_status[ $plugin ] = array( 'status' => 'pending', 'errors' => array(), ); } return array( 'job_id' => $job_id, 'status' => 'pending', 'plugins' => $plugin_status, ); } /** * Returns current status of given job. * * @param WP_REST_Request $request WP_REST_Request object. * * @return array|WP_REST_Response */ public function get_scheduled_installs( WP_REST_Request $request ) { $job_id = $request->get_param( 'job_id' ); $actions = WC()->queue()->search( array( 'hook' => 'woocommerce_plugins_install_and_activate_async_callback', 'search' => $job_id, 'orderby' => 'date', 'order' => 'DESC', ) ); $actions = array_filter( PluginsHelper::get_action_data( $actions ), function ( $action ) use ( $job_id ) { return $action['job_id'] === $job_id; } ); if ( empty( $actions ) ) { return new WP_REST_Response( null, 404 ); } $response = array( 'job_id' => $actions[0]['job_id'], 'status' => $actions[0]['status'], ); $option = get_option( 'woocommerce_onboarding_plugins_install_and_activate_async_' . $job_id ); if ( isset( $option['plugins'] ) ) { $response['plugins'] = $option['plugins']; } return $response; } /** * Return Jetpack authorization URL. * * @param WP_REST_Request $request WP_REST_Request object. * * @return array */ public function get_jetpack_authorization_url( WP_REST_Request $request ) { return JetpackConnection::get_authorization_url( $request->get_param( 'redirect_url' ), $request->get_param( 'from' ) ); } /** * Check whether the current user has permission to install plugins * * @return WP_Error|boolean */ public function can_install_plugins() { if ( ! current_user_can( 'install_plugins' ) ) { return new WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Check whether the current user has permission to install and activate plugins * * @return WP_Error|boolean */ public function can_install_and_activate_plugins() { if ( ! current_user_can( 'install_plugins' ) || ! current_user_can( 'activate_plugins' ) ) { return new WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * JSON Schema for both install-async and scheduled-installs endpoints. * * @return array */ public function get_install_async_schema() { return array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'Install Async Schema', 'type' => 'object', 'properties' => array( 'type' => 'object', 'properties' => array( 'job_id' => 'integer', 'status' => array( 'type' => 'string', 'enum' => array( 'pending', 'complete', 'failed' ), ), ), ), ); } /** * JSON Schema for install-and-activate endpoint. * * @return array */ public function get_install_activate_schema() { $error_schema = array( 'type' => 'object', 'patternProperties' => array( '^.*$' => array( 'type' => 'string', ), ), 'items' => array( 'type' => 'string', ), ); $install_schema = array( 'type' => 'object', 'properties' => array( 'installed' => array( 'type' => 'array', 'items' => array( 'type' => 'string', ), ), 'results' => array( 'type' => 'array', 'items' => array( 'type' => 'string', ), ), 'errors' => array( 'type' => 'object', 'properties' => array( 'errors' => $error_schema, 'error_data' => $error_schema, ), ), ), ); $activate_schema = array( 'type' => 'object', 'properties' => array( 'activated' => array( 'type' => 'array', 'items' => array( 'type' => 'string', ), ), 'active' => array( 'type' => 'array', 'items' => array( 'type' => 'string', ), ), 'errors' => array( 'type' => 'object', 'properties' => array( 'errors' => $error_schema, 'error_data' => $error_schema, ), ), ), ); return array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'Install and Activate Schema', 'type' => 'object', 'properties' => array( 'type' => 'object', 'properties' => array( 'install' => $install_schema, 'activate' => $activate_schema, ), ), ); } public function log_plugins_install_error( $slug, $api, $result, $upgrader ) { $properties = array( 'error_message' => sprintf( /* translators: %s: plugin slug (example: woocommerce-services) */ __( 'The requested plugin `%s` could not be installed.', 'woocommerce' ), $slug ), 'type' => 'plugin_info_api_error', 'slug' => $slug, 'api_version' => $api->version, 'api_download_link' => $api->download_link, 'upgrader_skin_message' => implode( ',', $upgrader->skin->get_upgrade_messages() ), 'result' => is_wp_error( $result ) ? $result->get_error_message() : 'null', ); wc_admin_record_tracks_event( 'coreprofiler_install_plugin_error', $properties ); } public function log_plugins_install_api_error( $slug, $api ) { $properties = array( 'error_message' => sprintf( // translators: %s: plugin slug (example: woocommerce-services). __( 'The requested plugin `%s` could not be installed. Plugin API call failed.', 'woocommerce' ), $slug ), 'type' => 'plugin_install_error', 'api_error_message' => $api->get_error_message(), 'slug' => $slug, ); wc_admin_record_tracks_event( 'coreprofiler_install_plugin_error', $properties ); } } API/Notes.php 0000777 00000063447 15252240713 0007004 0 ustar 00 <?php /** * REST API Admin Notes controller * * Handles requests to the admin notes endpoint. */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\Notes\Note; use Automattic\WooCommerce\Admin\Notes\Notes as NotesRepository; /** * REST API Admin Notes controller class. * * @internal * @extends WC_REST_CRUD_Controller */ class Notes extends \WC_REST_CRUD_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Route base. * * @var string */ protected $rest_base = 'admin/notes'; /** * Register the routes for admin notes. */ 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' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[\d-]+)', array( 'args' => array( 'id' => array( 'description' => __( 'Unique ID 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' ), ), array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_item' ), 'permission_callback' => array( $this, 'update_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/delete/(?P<id>[\d-]+)', array( array( 'methods' => \WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_item' ), 'permission_callback' => array( $this, 'update_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/delete/all', array( array( 'methods' => \WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_all_items' ), 'permission_callback' => array( $this, 'update_items_permissions_check' ), 'args' => array( 'status' => array( 'description' => __( 'Status of note.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'enum' => Note::get_allowed_statuses(), 'type' => 'string', ), ), ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/tracker/(?P<note_id>[\d-]+)/user/(?P<user_id>[\d-]+)', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'track_opened_email' ), 'permission_callback' => '__return_true', ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/update', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'batch_update_items' ), 'permission_callback' => array( $this, 'update_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/experimental-activate-promo/(?P<promo_note_name>[\w-]+)', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'activate_promo_note' ), 'permission_callback' => array( $this, 'update_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Get a single note. * * @param WP_REST_Request $request Request data. * @return WP_REST_Response|WP_Error */ public function get_item( $request ) { $note = NotesRepository::get_note( $request->get_param( 'id' ) ); if ( ! $note ) { return new \WP_Error( 'woocommerce_note_invalid_id', __( 'Sorry, there is no resource with that ID.', 'woocommerce' ), array( 'status' => 404 ) ); } if ( is_wp_error( $note ) ) { return $note; } $data = $this->prepare_note_data_for_response( $note, $request ); return rest_ensure_response( $data ); } /** * Get all notes. * * @param WP_REST_Request $request Request data. * @return WP_REST_Response */ public function get_items( $request ) { $query_args = $this->prepare_objects_query( $request ); $notes = NotesRepository::get_notes( 'edit', $query_args ); $data = array(); foreach ( (array) $notes as $note_obj ) { $note = $this->prepare_item_for_response( $note_obj, $request ); $note = $this->prepare_response_for_collection( $note ); $data[] = $note; } $response = rest_ensure_response( $data ); $response->header( 'X-WP-Total', count( $data ) ); return $response; } /** * Checks if user is in tasklist experiment. * * @return bool Whether remote inbox notifications are enabled. */ private function is_tasklist_experiment_assigned_treatment() { $anon_id = isset( $_COOKIE['tk_ai'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['tk_ai'] ) ) : ''; $allow_tracking = 'yes' === get_option( 'woocommerce_allow_tracking' ); $abtest = new \WooCommerce\Admin\Experimental_Abtest( $anon_id, 'woocommerce', $allow_tracking ); $date = new \DateTime(); $date->setTimeZone( new \DateTimeZone( 'UTC' ) ); $experiment_name = sprintf( 'woocommerce_tasklist_progression_headercard_%s_%s', $date->format( 'Y' ), $date->format( 'm' ) ); $experiment_name_2col = sprintf( 'woocommerce_tasklist_progression_headercard_2col_%s_%s', $date->format( 'Y' ), $date->format( 'm' ) ); return $abtest->get_variation( $experiment_name ) === 'treatment' || $abtest->get_variation( $experiment_name_2col ) === 'treatment'; } /** * Prepare objects query. * * @param WP_REST_Request $request Full details about the request. * @return array */ protected function prepare_objects_query( $request ) { $args = array(); $args['order'] = $request['order']; $args['orderby'] = $request['orderby']; $args['per_page'] = $request['per_page']; $args['page'] = $request['page']; $args['type'] = isset( $request['type'] ) ? $request['type'] : array(); $args['status'] = isset( $request['status'] ) ? $request['status'] : array(); $args['source'] = isset( $request['source'] ) ? $request['source'] : array(); $args['is_deleted'] = 0; if ( isset( $request['is_read'] ) ) { $args['is_read'] = filter_var( $request['is_read'], FILTER_VALIDATE_BOOLEAN ); } if ( 'date' === $args['orderby'] ) { $args['orderby'] = 'date_created'; } /** * Filter the query arguments for a request. * * Enables adding extra arguments or setting defaults for a post * collection request. * * @param array $args Key value array of query var to query value. * @param WP_REST_Request $request The request used. * @since 3.9.0 */ $args = apply_filters( 'woocommerce_rest_notes_object_query', $args, $request ); return $args; } /** * Check whether a given request has permission to read a single note. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function get_item_permissions_check( $request ) { if ( ! wc_rest_check_manager_permissions( 'system_status', 'read' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Check whether a given request has permission to read notes. * * @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( 'system_status', 'read' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Update a single note. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Request|WP_Error */ public function update_item( $request ) { $note = NotesRepository::get_note( $request->get_param( 'id' ) ); if ( ! $note ) { return new \WP_Error( 'woocommerce_note_invalid_id', __( 'Sorry, there is no resource with that ID.', 'woocommerce' ), array( 'status' => 404 ) ); } NotesRepository::update_note( $note, $this->get_requested_updates( $request ) ); return $this->get_item( $request ); } /** * Delete a single note. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Request|WP_Error */ public function delete_item( $request ) { $note = NotesRepository::get_note( $request->get_param( 'id' ) ); if ( ! $note ) { return new \WP_Error( 'woocommerce_note_invalid_id', __( 'Sorry, there is no note with that ID.', 'woocommerce' ), array( 'status' => 404 ) ); } NotesRepository::delete_note( $note ); $data = $this->prepare_note_data_for_response( $note, $request ); return rest_ensure_response( $data ); } /** * Delete all notes. * * @param WP_REST_Request $request Request object. * @return WP_REST_Request|WP_Error */ public function delete_all_items( $request ) { $args = array(); if ( isset( $request['status'] ) ) { $args['status'] = $request['status']; } $notes = NotesRepository::delete_all_notes( $args ); $data = array(); foreach ( (array) $notes as $note_obj ) { $data[] = $this->prepare_note_data_for_response( $note_obj, $request ); } $response = rest_ensure_response( $data ); $response->header( 'X-WP-Total', NotesRepository::get_notes_count( array( 'info', 'warning' ), array() ) ); return $response; } /** * Prepare note data. * * @param Note $note Note data. * @param WP_REST_Request $request Request object. * * @return WP_REST_Response $response Response data. */ public function prepare_note_data_for_response( $note, $request ) { $note = $note->get_data(); $note = $this->prepare_item_for_response( $note, $request ); return $this->prepare_response_for_collection( $note ); } /** * Prepare an array with the requested updates. * * @param WP_REST_Request $request Request object. * @return array A list of the requested updates values. */ protected function get_requested_updates( $request ) { $requested_updates = array(); if ( ! is_null( $request->get_param( 'status' ) ) ) { $requested_updates['status'] = $request->get_param( 'status' ); } if ( ! is_null( $request->get_param( 'date_reminder' ) ) ) { $requested_updates['date_reminder'] = $request->get_param( 'date_reminder' ); } if ( ! is_null( $request->get_param( 'is_deleted' ) ) ) { $requested_updates['is_deleted'] = $request->get_param( 'is_deleted' ); } if ( ! is_null( $request->get_param( 'is_read' ) ) ) { $requested_updates['is_read'] = $request->get_param( 'is_read' ); } return $requested_updates; } /** * Batch update a set of notes. * * @param WP_REST_Request $request Request object. * @return WP_REST_Request|WP_Error */ public function batch_update_items( $request ) { $data = array(); $note_ids = $request->get_param( 'noteIds' ); if ( ! isset( $note_ids ) || ! is_array( $note_ids ) ) { return new \WP_Error( 'woocommerce_note_invalid_ids', __( 'Please provide an array of IDs through the noteIds param.', 'woocommerce' ), array( 'status' => 422 ) ); } foreach ( (array) $note_ids as $note_id ) { $note = NotesRepository::get_note( (int) $note_id ); if ( $note ) { NotesRepository::update_note( $note, $this->get_requested_updates( $request ) ); $data[] = $this->prepare_note_data_for_response( $note, $request ); } } $response = rest_ensure_response( $data ); $response->header( 'X-WP-Total', NotesRepository::get_notes_count( array( 'info', 'warning' ), array() ) ); return $response; } /** * Activate a promo note, create if not exist. * * @param WP_REST_Request $request Request object. * @return WP_REST_Request|WP_Error */ public function activate_promo_note( $request ) { /** * Filter allowed promo notes for experimental-activate-promo. * * @param array $promo_notes Array of allowed promo notes. * @since 7.8.0 */ $allowed_promo_notes = apply_filters( 'woocommerce_admin_allowed_promo_notes', [] ); $promo_note_name = $request->get_param( 'promo_note_name' ); if ( ! in_array( $promo_note_name, $allowed_promo_notes, true ) ) { return new \WP_Error( 'woocommerce_note_invalid_promo_note_name', __( 'Please provide a valid promo note name.', 'woocommerce' ), array( 'status' => 422 ) ); } $data_store = NotesRepository::load_data_store(); $note_ids = $data_store->get_notes_with_name( $promo_note_name ); if ( empty( $note_ids ) ) { // Promo note doesn't exist, this could happen in cases where // user might have disabled RemoteInboxNotications via disabling // marketing suggestions. Thus we'd have to manually add the note. $note = new Note(); $note->set_name( $promo_note_name ); $note->set_status( Note::E_WC_ADMIN_NOTE_ACTIONED ); $data_store->create( $note ); } else { $note = NotesRepository::get_note( $note_ids[0] ); NotesRepository::update_note( $note, [ 'status' => Note::E_WC_ADMIN_NOTE_ACTIONED, ] ); } return rest_ensure_response( array( 'success' => true, ) ); } /** * Makes sure the current user has access to WRITE the settings APIs. * * @param WP_REST_Request $request Full data about the request. * @return WP_Error|bool */ public function update_items_permissions_check( $request ) { if ( ! wc_rest_check_manager_permissions( 'settings', 'edit' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_edit', __( 'Sorry, you cannot edit this resource.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Prepare a path or query for serialization to the client. * * @param string $query The query, path, or URL to transform. * @return string A fully formed URL. */ public function prepare_query_for_response( $query ) { if ( empty( $query ) ) { return $query; } if ( 'https://' === substr( $query, 0, 8 ) ) { return $query; } if ( 'http://' === substr( $query, 0, 7 ) ) { return $query; } if ( '?' === substr( $query, 0, 1 ) ) { return admin_url( 'admin.php' . $query ); } return admin_url( $query ); } /** * Maybe add a nonce to a URL. * * @link https://codex.wordpress.org/WordPress_Nonces * * @param string $url The URL needing a nonce. * @param string $action The nonce action. * @param string $name The nonce name. * @return string A fully formed URL. */ private function maybe_add_nonce_to_url( string $url, string $action = '', string $name = '' ) : string { if ( empty( $action ) ) { return $url; } if ( empty( $name ) ) { // Default parameter name. $name = '_wpnonce'; } return add_query_arg( $name, wp_create_nonce( $action ), $url ); } /** * Prepare a note object for serialization. * * @param array $data Note data. * @param WP_REST_Request $request Request object. * @return WP_REST_Response $response Response data. */ public function prepare_item_for_response( $data, $request ) { $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data['date_created_gmt'] = wc_rest_prepare_date_response( $data['date_created'] ); $data['date_created'] = wc_rest_prepare_date_response( $data['date_created'], false ); $data['date_reminder_gmt'] = wc_rest_prepare_date_response( $data['date_reminder'] ); $data['date_reminder'] = wc_rest_prepare_date_response( $data['date_reminder'], false ); $data['title'] = stripslashes( $data['title'] ); $data['content'] = stripslashes( $data['content'] ); $data['is_snoozable'] = (bool) $data['is_snoozable']; $data['is_deleted'] = (bool) $data['is_deleted']; $data['is_read'] = (bool) $data['is_read']; foreach ( (array) $data['actions'] as $key => $value ) { $data['actions'][ $key ]->label = stripslashes( $data['actions'][ $key ]->label ); $data['actions'][ $key ]->url = $this->maybe_add_nonce_to_url( $this->prepare_query_for_response( $data['actions'][ $key ]->query ), (string) $data['actions'][ $key ]->nonce_action, (string) $data['actions'][ $key ]->nonce_name ); $data['actions'][ $key ]->status = stripslashes( $data['actions'][ $key ]->status ); } $data = $this->filter_response_by_context( $data, $context ); // Wrap the data in a response object. $response = rest_ensure_response( $data ); $response->add_links( array( 'self' => array( 'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $data['id'] ) ), ), 'collection' => array( 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ), ), ) ); /** * Filter a note returned from the API. * * Allows modification of the note data right before it is returned. * * @param WP_REST_Response $response The response object. * @param array $data The original note. * @param WP_REST_Request $request Request used to generate the response. * @since 3.9.0 */ return apply_filters( 'woocommerce_rest_prepare_note', $response, $data, $request ); } /** * Track opened emails. * * @param WP_REST_Request $request Request object. */ public function track_opened_email( $request ) { $note = NotesRepository::get_note( $request->get_param( 'note_id' ) ); if ( ! $note ) { return; } NotesRepository::record_tracks_event_with_user( $request->get_param( 'user_id' ), 'email_note_opened', array( 'note_name' => $note->get_name() ) ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = array(); $params['context'] = $this->get_context_param( array( 'default' => 'view' ) ); $params['order'] = array( 'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce' ), 'type' => 'string', 'default' => 'desc', 'enum' => array( 'asc', 'desc' ), 'validate_callback' => 'rest_validate_request_arg', ); $params['orderby'] = array( 'description' => __( 'Sort collection by object attribute.', 'woocommerce' ), 'type' => 'string', 'default' => 'date', 'enum' => array( 'note_id', 'date', 'type', 'title', 'status', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['page'] = array( 'description' => __( 'Current page of the collection.', 'woocommerce' ), 'type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', 'minimum' => 1, ); $params['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', ); $params['type'] = array( 'description' => __( 'Type of note.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'enum' => Note::get_allowed_types(), 'type' => 'string', ), ); $params['status'] = array( 'description' => __( 'Status of note.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'enum' => Note::get_allowed_statuses(), 'type' => 'string', ), ); $params['source'] = array( 'description' => __( 'Source of note.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', ), ); return $params; } /** * Get the note's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'note', 'type' => 'object', 'properties' => array( 'id' => array( 'description' => __( 'ID of the note record.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view' ), 'readonly' => true, ), 'name' => array( 'description' => __( 'Name of the note.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'type' => array( 'description' => __( 'The type of the note (e.g. error, warning, etc.).', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'locale' => array( 'description' => __( 'Locale used for the note title and content.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'title' => array( 'description' => __( 'Title of the note.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'content' => array( 'description' => __( 'Content of the note.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'content_data' => array( 'description' => __( 'Content data for the note. JSON string. Available for re-localization.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'status' => array( 'description' => __( 'The status of the note (e.g. unactioned, actioned).', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), ), 'source' => array( 'description' => __( 'Source of the note.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_created' => array( 'description' => __( 'Date the note was created.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_created_gmt' => array( 'description' => __( 'Date the note was created (GMT).', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_reminder' => array( 'description' => __( 'Date after which the user should be reminded of the note, if any.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, // @todo Allow date_reminder to be updated. ), 'date_reminder_gmt' => array( 'description' => __( 'Date after which the user should be reminded of the note, if any (GMT).', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'is_snoozable' => array( 'description' => __( 'Whether or not a user can request to be reminded about the note.', 'woocommerce' ), 'type' => 'boolean', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'actions' => array( 'description' => __( 'An array of actions, if any, for the note.', 'woocommerce' ), 'type' => 'array', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'layout' => array( 'description' => __( 'The layout of the note (e.g. banner, thumbnail, plain).', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'image' => array( 'description' => __( 'The image of the note, if any.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'is_deleted' => array( 'description' => __( 'Registers whether the note is deleted or not', 'woocommerce' ), 'type' => 'boolean', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'is_read' => array( 'description' => __( 'Registers whether the note is read or not', 'woocommerce' ), 'type' => 'boolean', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } } API/ProductReviews.php 0000777 00000002462 15252240713 0010667 0 ustar 00 <?php /** * REST API Product Reviews Controller * * Handles requests to /products/reviews. */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Product reviews controller. * * @internal * @extends WC_REST_Product_Reviews_Controller */ class ProductReviews extends \WC_REST_Product_Reviews_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Prepare links for the request. * * @param WP_Comment $review Product review object. * @return array Links for the given product review. */ protected function prepare_links( $review ) { $links = array( 'self' => array( 'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $review->comment_ID ) ), ), 'collection' => array( 'href' => rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ), ), ); if ( 0 !== (int) $review->comment_post_ID ) { $links['up'] = array( 'href' => rest_url( sprintf( '/%s/products/%d', $this->namespace, $review->comment_post_ID ) ), 'embeddable' => true, ); } if ( 0 !== (int) $review->user_id ) { $links['reviewer'] = array( 'href' => rest_url( 'wp/v2/users/' . $review->user_id ), 'embeddable' => true, ); } return $links; } } API/OnboardingProductTypes.php 0000777 00000003460 15252240713 0012351 0 ustar 00 <?php /** * REST API Onboarding Product Types Controller * * Handles requests to /onboarding/product-types */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProducts; defined( 'ABSPATH' ) || exit; /** * Onboarding Product Types Controller. * * @internal * @extends WC_REST_Data_Controller */ class OnboardingProductTypes extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'onboarding/product-types'; /** * 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_product_types' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Check whether a given request has permission to read onboarding profile data. * * @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( 'settings', 'read' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Return available product types. * * @param \WP_REST_Request $request Request data. * * @return \WP_Error|\WP_REST_Response */ public function get_product_types( $request ) { return OnboardingProducts::get_product_types_with_data(); } } API/Settings.php 0000777 00000010313 15252240713 0007474 0 ustar 00 <?php /** * REST API Settings Controller * * Handles requests to save Settings. */ declare( strict_types = 1); namespace Automattic\WooCommerce\Admin\API; use WC_Admin_Settings; use Automattic\WooCommerce\Admin\Features\Settings\Init; defined( 'ABSPATH' ) || exit; /** * Settings Controller. * * @extends WC_REST_Data_Controller */ class Settings extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'legacy-settings'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'save_settings' ), 'permission_callback' => array( $this, 'save_items_permissions_check' ), 'args' => array( 'schema' => array( $this, 'save_items_schema' ), ), ), ) ); } /** * Check if a given request has access to update settings. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function save_items_permissions_check( $request ) { return current_user_can( 'manage_woocommerce' ); } /** * Save settings. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|WP_REST_Response */ public function save_settings( $request ) { global $current_section, $current_tab; // Verify nonce. if ( ! check_ajax_referer( 'wp_rest', false, false ) ) { return new \WP_Error( 'woocommerce_settings_invalid_nonce', __( 'Invalid nonce.', 'woocommerce' ), array( 'status' => 403 ) ); } $params = $request->get_params(); try { // Get current tab/section and set global variables. $current_tab = empty( $params['tab'] ) ? 'general' : sanitize_title( wp_unslash( $params['tab'] ) ); // WPCS: input var okay, CSRF ok. $current_section = empty( $params['section'] ) ? '' : sanitize_title( wp_unslash( $params['section'] ) ); // WPCS: input var okay, CSRF ok. $filter_name = '' === $current_section ? "woocommerce_save_settings_{$current_tab}" : "woocommerce_save_settings_{$current_tab}_{$current_section}"; /** * Filters whether to save settings. * * @since 3.7.0 * * @param bool $save Whether to save settings. */ if ( apply_filters( $filter_name, ! empty( $_POST['save'] ) ) ) { // WPCS: input var okay, CSRF ok. WC_Admin_Settings::save(); } $setting_pages = \WC_Admin_Settings::get_settings_pages(); // Reinitialize all setting pages in case behavior is dependent on saved values. foreach ( $setting_pages as $key => $setting_page ) { $class_name = get_class( $setting_page ); $setting_pages[ $key ] = new $class_name(); } $data = Init::get_page_data( array(), $setting_pages ); return new \WP_REST_Response( array( 'status' => 'success', 'data' => $data, ) ); } catch ( \Exception $e ) { return new \WP_Error( 'woocommerce_settings_save_error', // translators: %s: error message. sprintf( __( 'Failed to save settings: %s', 'woocommerce' ), $e->getMessage() ), array( 'status' => 500 ) ); } } /** * Get the schema, conforming to JSON Schema. * * @return array */ public function save_items_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'options', 'type' => 'object', 'properties' => array( 'options' => array( 'type' => 'array', 'description' => __( 'Array of options with associated values.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'tab' => array( 'type' => 'string', 'description' => __( 'Settings tab.', 'woocommerce' ), 'context' => array( 'view', 'edit' ), 'default' => 'general', ), 'section' => array( 'type' => 'string', 'description' => __( 'Settings section.', 'woocommerce' ), 'context' => array( 'view', 'edit' ), 'default' => '', ), ), ); return $schema; } } API/MarketingRecommendations.php 0000777 00000013705 15252240713 0012675 0 ustar 00 <?php /** * REST API MarketingRecommendations Controller * * Handles requests to /marketing/recommendations. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Admin\Features\MarketingRecommendations\Init as MarketingRecommendationsInit; use WC_REST_Controller; use WP_Error; use WP_REST_Request; use WP_REST_Response; defined( 'ABSPATH' ) || exit; /** * MarketingRecommendations Controller. * * @internal * @extends WC_REST_Controller * @since x.x.x */ class MarketingRecommendations extends WC_REST_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'marketing/recommendations'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_items' ], 'permission_callback' => [ $this, 'get_items_permissions_check' ], 'args' => [ 'category' => [ 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'sanitize_title_with_dashes', 'enum' => [ 'channels', 'extensions' ], 'required' => true, ], ], ], 'schema' => [ $this, 'get_public_item_schema' ], ] ); } /** * Check whether a given request has permission to view marketing recommendations. * * @param WP_REST_Request $request Full details about the request. * * @return WP_Error|boolean */ public function get_items_permissions_check( $request ) { if ( ! current_user_can( 'install_plugins' ) ) { return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot view marketing channels.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Retrieves a collection of recommendations. * * @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_items( $request ) { $category = $request->get_param( 'category' ); if ( 'channels' === $category ) { $items = MarketingRecommendationsInit::get_recommended_marketing_channels(); } elseif ( 'extensions' === $category ) { $items = MarketingRecommendationsInit::get_recommended_marketing_extensions_excluding_channels(); } else { return new WP_Error( 'woocommerce_rest_invalid_category', __( 'The specified category for recommendations is invalid. Allowed values: "channels", "extensions".', 'woocommerce' ), array( 'status' => 400 ) ); } $responses = []; foreach ( $items as $item ) { $response = $this->prepare_item_for_response( $item, $request ); $responses[] = $this->prepare_response_for_collection( $response ); } return rest_ensure_response( $responses ); } /** * Prepares the item for the REST response. * * @param array $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. */ public function prepare_item_for_response( $item, $request ) { $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $item, $request ); $data = $this->filter_response_by_context( $data, $context ); return rest_ensure_response( $data ); } /** * Retrieves the item's schema, conforming to JSON Schema. * * @return array Item schema data. */ public function get_item_schema() { $schema = [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'marketing_recommendation', 'type' => 'object', 'properties' => [ 'title' => [ 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'description' => [ 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'url' => [ 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'direct_install' => [ 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'icon' => [ 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'product' => [ 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'plugin' => [ 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'categories' => [ 'type' => 'array', 'context' => [ 'view' ], 'readonly' => true, 'items' => [ 'type' => 'string', ], ], 'subcategories' => [ 'type' => 'array', 'context' => [ 'view' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'context' => [ 'view' ], 'readonly' => true, 'properties' => [ 'slug' => [ 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'name' => [ 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], ], ], ], 'tags' => [ 'type' => 'array', 'context' => [ 'view' ], 'readonly' => true, 'items' => [ 'type' => 'object', 'context' => [ 'view' ], 'readonly' => true, 'properties' => [ 'slug' => [ 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'name' => [ 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], ], ], ], ], ]; return $this->add_additional_fields_schema( $schema ); } } API/Reports/GenericController.php 0000777 00000023607 15252240713 0012764 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\API\Reports; defined( 'ABSPATH' ) || exit; use WP_REST_Request; use WP_REST_Response; /** * {@see WC_REST_Reports_Controller WC REST API Reports Controller} extended to be shared as a generic base for all Analytics reports controllers. * * Handles pagination HTTP headers and links, basic, conventional params. * Does all the REST API plumbing as `WC_REST_Controller`. * * * Minimalistic example: * <pre><code class="language-php">class MyController extends GenericController { * /** Route of your new REST endpoint. */ * protected $rest_base = 'reports/my-thing'; * /** * * Provide JSON schema for the response item. * * @override WC_REST_Reports_Controller::get_item_schema() * */ * public function get_item_schema() { * $schema = array( * '$schema' => 'http://json-schema.org/draft-04/schema#', * 'title' => 'report_my_thing', * 'type' => 'object', * 'properties' => array( * 'product_id' => array( * 'type' => 'integer', * 'readonly' => true, * 'context' => array( 'view', 'edit' ), * 'description' => __( 'Product ID.', 'my_extension' ), * ), * ), * ); * // Add additional fields from `get_additional_fields` method and apply `woocommerce_rest_' . $schema['title'] . '_schema` filter. * return $this->add_additional_fields_schema( $schema ); * } * } * </code></pre> * * The above Controller will get the data from a {@see DataStore data store} registered as `$rest_base` (`reports/my-thing`). * (To change this behavior, override the `get_datastore_data()` method). * * To use the controller, please register it with the filter `woocommerce_admin_rest_controllers` filter. * * @extends WC_REST_Reports_Controller */ abstract class GenericController extends \WC_REST_Reports_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Add pagination headers and links. * * @param \WP_REST_Request $request Request data. * @param \WP_REST_Response|array $response Response data. * @param int $total Total results. * @param int $page Current page. * @param int $max_pages Total amount of pages. * @return \WP_REST_Response */ public function add_pagination_headers( $request, $response, int $total, int $page, int $max_pages ) { $response = rest_ensure_response( $response ); $response->header( 'X-WP-Total', $total ); $response->header( 'X-WP-TotalPages', $max_pages ); $base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) ); if ( $page > 1 ) { $prev_page = $page - 1; if ( $prev_page > $max_pages ) { $prev_page = $max_pages; } $prev_link = add_query_arg( 'page', $prev_page, $base ); $response->link_header( 'prev', $prev_link ); } if ( $max_pages > $page ) { $next_page = $page + 1; $next_link = add_query_arg( 'page', $next_page, $base ); $response->link_header( 'next', $next_link ); } return $response; } /** * Get data from `{$this->rest_base}` store, based on the given query vars. * * @throws Exception When the data store is not found {@see WC_Data_Store WC_Data_Store}. * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $data_store = \WC_Data_Store::load( $this->rest_base ); return $data_store->get_data( $query_args ); } /** * Get the query params definition for collections. * * @return array */ public function get_collection_params() { $params = array(); $params['context'] = $this->get_context_param( array( 'default' => 'view' ) ); $params['page'] = array( 'description' => __( 'Current page of the collection.', 'woocommerce' ), 'type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', 'minimum' => 1, ); $params['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', ); $params['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', ); $params['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', ); $params['order'] = array( 'description' => __( 'Order sort attribute ascending or descending.', 'woocommerce' ), 'type' => 'string', 'default' => 'desc', 'enum' => array( 'asc', 'desc' ), 'validate_callback' => 'rest_validate_request_arg', ); $params['orderby'] = array( 'description' => __( 'Sort collection by object attribute.', 'woocommerce' ), 'type' => 'string', 'default' => 'date', 'enum' => array( 'date', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['force_cache_refresh'] = array( 'description' => __( 'Force retrieval of fresh data instead of from the cache.', 'woocommerce' ), 'type' => 'boolean', 'sanitize_callback' => 'wp_validate_boolean', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the report data. * * Prepares query params, fetches the report data from the data store, * prepares it for the response, and packs it into the convention-conforming response object. * * @throws \WP_Error When the queried data is invalid. * @param \WP_REST_Request $request Request data. * @return \WP_Error|\WP_REST_Response */ public function get_items( $request ) { $query_args = $this->prepare_reports_query( $request ); $report_data = $this->get_datastore_data( $query_args ); if ( is_wp_error( $report_data ) ) { return $report_data; } if ( ! isset( $report_data->data ) || ! isset( $report_data->page_no ) || ! isset( $report_data->pages ) ) { return new \WP_Error( 'woocommerce_rest_reports_invalid_response', __( 'Invalid response from data store.', 'woocommerce' ), array( 'status' => 500 ) ); } $out_data = array(); foreach ( $report_data->data as $datum ) { $item = $this->prepare_item_for_response( $datum, $request ); $out_data[] = $this->prepare_response_for_collection( $item ); } return $this->add_pagination_headers( $request, $out_data, (int) $report_data->total, (int) $report_data->page_no, (int) $report_data->pages ); } /** * Prepare a report data item for serialization. * * This method is called by `get_items` to prepare a single report data item for serialization. * Calls `add_additional_fields_to_object` and `filter_response_by_context`, * then wpraps the data with `rest_ensure_response`. * * You can extend it to add or filter some fields. * * @override WP_REST_Posts_Controller::prepare_item_for_response() * * @param mixed $report_item Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report_item, $request ) { $data = $report_item; $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); // Wrap the data in a response object. return rest_ensure_response( $data ); } /** * Maps query arguments from the REST request, to be used to query the datastore. * * `WP_REST_Request` does not expose a method to return all params covering defaults, * as it does for `$request['param']` accessor. * Therefore, we re-implement defaults resolution. * * @param \WP_REST_Request $request Full request object. * @return array Simplified array of params. */ protected function prepare_reports_query( $request ) { $args = wp_parse_args( array_intersect_key( $request->get_query_params(), $this->get_collection_params() ), $request->get_default_params() ); return $args; } /** * Apply a filter for custom orderby enum. * * @param array $orderby_enum An array of orderby enum options. * * @return array An array of filtered orderby enum options. * * @since 9.4.0 */ protected function apply_custom_orderby_filters( $orderby_enum ) { /** * Filter orderby query parameter enum. * * There was an initial concern about potential SQL injection with the custom orderby. * However, testing shows it is safely blocked by validation in the controller, * which results in an "Invalid parameter(s): orderby" error. * * Additionally, it's the responsibility of the merchant/developer to ensure the custom orderby is valid, * or a WordPress database error will occur for unknown columns. * * @since 9.4.0 * * @param array $orderby_enum The orderby query parameter enum. */ return apply_filters( "woocommerce_analytics_orderby_enum_{$this->rest_base}", $orderby_enum ); } } API/Reports/GenericStatsController.php 0000777 00000017772 15252240713 0014011 0 ustar 00 <?php namespace Automattic\WooCommerce\Admin\API\Reports; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use WP_Error; /** * Generic base for all stats controllers. * * {@see GenericController Generic Controller} extended to be shared as a generic base for all Analytics stats controllers. * * Besides the `GenericController` functionality, it adds conventional stats-specific collection params and item schema. * So, you may want to extend only your report-specific {@see get_item_properties_schema() get_item_properties_schema()}`. * It also uses the stats-specific {@see get_items() get_items()} method, * which packs report data into `totals` and `intervals`. * * * Minimalistic example: * <pre><code class="language-php">class StatsController extends GenericStatsController { * /** Route of your new REST endpoint. */ * protected $rest_base = 'reports/my-thing/stats'; * /** Define your proeprties schema. */ * protected function get_item_properties_schema() { * return array( * 'my_property' => array( * 'title' => __( 'My property', 'my-extension' ), * 'type' => 'integer', * 'readonly' => true, * 'context' => array( 'view', 'edit' ), * 'description' => __( 'Amazing thing.', 'my-extension' ), * 'indicator' => true, * ), * ); * } * /** Define overall schema. You can use the defaults, * * just remember to provide your title and call `add_additional_fields_schema` * * to run the filters * */ * public function get_item_schema() { * $schema = parent::get_item_schema(); * $schema['title'] = 'report_my_thing_stats'; * * return $this->add_additional_fields_schema( $schema ); * } * } * </code></pre> * * @extends GenericController */ abstract class GenericStatsController extends GenericController { /** * Get the query params definition for collections. * Adds `fields` & `intervals` to the generic list. * * @override GenericController::get_collection_params() * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['fields'] = array( 'description' => __( 'Limit stats fields to the specified items.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', ), ); $params['interval'] = array( 'description' => __( 'Time interval to use for buckets in the returned data.', 'woocommerce' ), 'type' => 'string', 'default' => 'week', 'enum' => array( 'hour', 'day', 'week', 'month', 'quarter', 'year', ), 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the report's item properties schema. * Will be used by `get_item_schema` as `totals` and `subtotals`. * * @return array */ abstract protected function get_item_properties_schema(); /** * Get the Report's schema, conforming to JSON Schema. * * Please note that it does not call add_additional_fields_schema, * as you may want to update the `title` first. * * @return array */ public function get_item_schema() { $data_values = $this->get_item_properties_schema(); $segments = array( 'segments' => array( 'description' => __( 'Reports data grouped by segment condition.', 'woocommerce' ), 'type' => 'array', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'items' => array( 'type' => 'object', 'properties' => array( 'segment_id' => array( 'description' => __( 'Segment identificator.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'subtotals' => array( 'description' => __( 'Interval subtotals.', 'woocommerce' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'properties' => $data_values, ), ), ), ), ); $totals = array_merge( $data_values, $segments ); return array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_stats', 'type' => 'object', 'properties' => array( 'totals' => array( 'description' => __( 'Totals data.', 'woocommerce' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'properties' => $totals, ), 'intervals' => array( 'description' => __( 'Reports data grouped by intervals.', 'woocommerce' ), 'type' => 'array', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'items' => array( 'type' => 'object', 'properties' => array( 'interval' => array( 'description' => __( 'Type of interval.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'enum' => array( 'day', 'week', 'month', 'year' ), ), 'date_start' => array( 'description' => __( "The date the report start, in the site's timezone.", 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_start_gmt' => array( 'description' => __( 'The date the report start, as GMT.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_end' => array( 'description' => __( "The date the report end, in the site's timezone.", 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_end_gmt' => array( 'description' => __( 'The date the report end, as GMT.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'subtotals' => array( 'description' => __( 'Interval subtotals.', 'woocommerce' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'properties' => $totals, ), ), ), ), ), ); } /** * Get the report data. * * Prepares query params, fetches the report data from the data store, * prepares it for the response, and packs it into the convention-conforming response object. * * @override GenericController::get_items() * * @throws \WP_Error When the queried data is invalid. * @param \WP_REST_Request $request Request data. * @return \WP_REST_Response|\WP_Error */ public function get_items( $request ) { $query_args = $this->prepare_reports_query( $request ); try { $report_data = $this->get_datastore_data( $query_args ); } catch ( ParameterException $e ) { return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) ); } $out_data = array( 'totals' => $report_data->totals ? get_object_vars( $report_data->totals ) : null, 'intervals' => array(), ); foreach ( $report_data->intervals as $interval_data ) { $item = $this->prepare_item_for_response( $interval_data, $request ); $out_data['intervals'][] = $this->prepare_response_for_collection( $item ); } return $this->add_pagination_headers( $request, $out_data, (int) $report_data->total, (int) $report_data->page_no, (int) $report_data->pages ); } } API/Reports/ParameterException.php 0000777 00000000506 15252240713 0013134 0 ustar 00 <?php /** * WooCommerce Admin Input Parameter Exception Class * * Exception class thrown when user provides incorrect parameters. */ namespace Automattic\WooCommerce\Admin\API\Reports; defined( 'ABSPATH' ) || exit; /** * API\Reports\ParameterException class. */ class ParameterException extends \WC_Data_Exception {} API/Reports/Taxes/Query.php 0000777 00000003505 15252240713 0011530 0 ustar 00 <?php /** * Class for parameter-based Taxes Report querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'page' => 2, * 'taxes' => array(1,2,3) * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Taxes\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Taxes; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Taxes\Query * * @deprecated 9.3.0 Taxes\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Taxes report. * * @deprecated 9.3.0 Taxes\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array(); } /** * Get product data based on the current query vars. * * @deprecated 9.3.0 Taxes\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_taxes_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-taxes' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_taxes_select_query', $results, $args ); } } API/Reports/Taxes/Stats/Query.php 0000777 00000003660 15252240713 0012630 0 ustar 00 <?php /** * Class for parameter-based Taxes Stats Report querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'page' => 2, * 'categories' => array(15, 18), * 'product_ids' => array(1,2,3) * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Taxes\Stats\Query * * @deprecated 9.3.0 Taxes\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Taxes report. * * @deprecated 9.3.0 Taxes\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array(); } /** * Get tax stats data based on the current query vars. * * @deprecated 9.3.0 Taxes\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_taxes_stats_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-taxes-stats' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_taxes_stats_select_query', $results, $args ); } } API/Reports/Taxes/Stats/Controller.php 0000777 00000014351 15252240713 0013645 0 ustar 00 <?php /** * REST API Reports taxes stats controller * * Handles requests to the /reports/taxes/stats endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController; use WP_REST_Request; use WP_REST_Response; /** * REST API Reports taxes stats controller class. * * @internal * @extends GenericStatsController */ class Controller extends GenericStatsController { /** * Route base. * * @var string */ protected $rest_base = 'reports/taxes/stats'; /** * Constructor. */ public function __construct() { add_filter( 'woocommerce_analytics_taxes_stats_select_query', array( $this, 'set_default_report_data' ) ); } /** * Set the default results to 0 if API returns an empty array * * @internal * @param Mixed $results Report data. * @return object */ public function set_default_report_data( $results ) { if ( empty( $results ) ) { $results = new \stdClass(); $results->total = 0; $results->totals = new \stdClass(); $results->totals->tax_codes = 0; $results->totals->total_tax = 0; $results->totals->order_tax = 0; $results->totals->shipping_tax = 0; $results->totals->orders = 0; $results->intervals = array(); $results->pages = 1; $results->page_no = 1; } return $results; } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['before'] = $request['before']; $args['after'] = $request['after']; $args['interval'] = $request['interval']; $args['page'] = $request['page']; $args['per_page'] = $request['per_page']; $args['orderby'] = $request['orderby']; $args['order'] = $request['order']; $args['taxes'] = (array) $request['taxes']; $args['segmentby'] = $request['segmentby']; $args['fields'] = $request['fields']; $args['force_cache_refresh'] = $request['force_cache_refresh']; return $args; } /** * Get data from `'taxes-stats'` GenericQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new GenericQuery( $query_args, 'taxes-stats' ); return $query->get_data(); } /** * Prepare a report data item for serialization. * * @param mixed $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $response = parent::prepare_item_for_response( $report, $request ); // Map to `object` for backwards compatibility. $report = (object) $report; /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_taxes_stats', $response, $report, $request ); } /** * Get the Report's item properties schema. * Will be used by `get_item_schema` as `totals` and `subtotals`. * * @return array */ protected function get_item_properties_schema() { return array( 'total_tax' => array( 'description' => __( 'Total tax.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, 'format' => 'currency', ), 'order_tax' => array( 'description' => __( 'Order tax.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, 'format' => 'currency', ), 'shipping_tax' => array( 'description' => __( 'Shipping tax.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, 'format' => 'currency', ), 'orders_count' => array( 'description' => __( 'Number of orders.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'tax_codes' => array( 'description' => __( 'Amount of tax codes.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ); } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = parent::get_item_schema(); $schema['title'] = 'report_taxes_stats'; return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'date', 'items_sold', 'total_sales', 'orders_count', 'products_count', ) ); $params['taxes'] = array( 'description' => __( 'Limit result set to all items that have the specified term assigned in the taxes taxonomy.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['segmentby'] = array( 'description' => __( 'Segment the response by additional constraint.', 'woocommerce' ), 'type' => 'string', 'enum' => array( 'tax_rate_id', ), 'validate_callback' => 'rest_validate_request_arg', ); return $params; } } API/Reports/Taxes/Stats/DataStore.php 0000777 00000022562 15252240713 0013413 0 ustar 00 <?php /** * API\Reports\Taxes\Stats\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\StatsDataStoreTrait; /** * API\Reports\Taxes\Stats\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { use StatsDataStoreTrait; /** * Table used to get the data. * * @override ReportsDataStore::$table_name * * @var string */ protected static $table_name = 'wc_order_tax_lookup'; /** * Cache identifier. * * @override ReportsDataStore::$cache_key * * @var string */ protected $cache_key = 'taxes_stats'; /** * Mapping columns to data type to return correct response types. * * @override ReportsDataStore::$column_types * * @var array */ protected $column_types = array( 'tax_codes' => 'intval', 'total_tax' => 'floatval', 'order_tax' => 'floatval', 'shipping_tax' => 'floatval', 'orders_count' => 'intval', ); /** * Data store context used to pass to filters. * * @override ReportsDataStore::$context * * @var string */ protected $context = 'taxes_stats'; /** * Assign report columns once full table name has been assigned. * * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); $this->report_columns = array( 'tax_codes' => 'COUNT(DISTINCT tax_rate_id) as tax_codes', 'total_tax' => 'SUM(total_tax) AS total_tax', 'order_tax' => 'SUM(order_tax) as order_tax', 'shipping_tax' => 'SUM(shipping_tax) as shipping_tax', 'orders_count' => "COUNT( DISTINCT ( CASE WHEN parent_id = 0 THEN {$table_name}.order_id END ) ) as orders_count", ); } /** * Updates the database query with parameters used for Taxes Stats report * * @see Automattic\WooCommerce\Admin\API\Reports\Taxes\DataStore::add_sql_query_params() * @param array $query_args Query arguments supplied by the user. */ protected function update_sql_query_params( $query_args ) { global $wpdb; $order_tax_lookup_table = self::get_db_table_name(); $this->add_time_period_sql_params( $query_args, $order_tax_lookup_table ); $taxes_where_clause = ''; $order_status_filter = $this->get_status_subquery( $query_args ); if ( isset( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) { $allowed_taxes = self::get_filtered_ids( $query_args, 'taxes' ); /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- `$allowed_taxes` was prepared by get_filtered_ids above. */ $taxes_where_clause .= " AND {$order_tax_lookup_table}.tax_rate_id IN ({$allowed_taxes})"; /* phpcs:enable */ } if ( $order_status_filter ) { $taxes_where_clause .= " AND ( {$order_status_filter} )"; } $this->total_query->add_sql_clause( 'where', $taxes_where_clause ); $this->add_intervals_sql_params( $query_args, $order_tax_lookup_table ); $this->interval_query->add_sql_clause( 'where', $taxes_where_clause ); $this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' ); $this->interval_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); } /** * Get taxes associated with a store. * * @param array $args Array of args to filter the query by. Supports `include`. * @return array An array of all taxes. */ public static function get_taxes( $args ) { global $wpdb; $query = " SELECT tax_rate_id, tax_rate_country, tax_rate_state, tax_rate_name, tax_rate_priority FROM {$wpdb->prefix}woocommerce_tax_rates "; if ( ! empty( $args['include'] ) ) { $args['include'] = (array) $args['include']; /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ $tax_placeholders = implode( ',', array_fill( 0, count( $args['include'] ), '%d' ) ); $query .= $wpdb->prepare( " WHERE tax_rate_id IN ({$tax_placeholders})", $args['include'] ); /* phpcs:enable */ } return $wpdb->get_results( $query, ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override ReportsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['orderby'] = 'tax_rate_id'; $defaults['taxes'] = array(); return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override ReportsDataStore::get_noncached_data() * * @see get_data * @see get_noncached_stats_data * @param array $query_args Query parameters. * @param array $params Query limit parameters. * @param stdClass $data Reference to the data object to fill. * @param int $expected_interval_count Number of expected intervals. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_stats_data( $query_args, $params, &$data, $expected_interval_count ) { global $wpdb; $table_name = self::get_db_table_name(); $this->initialize_queries(); $selections = $this->selected_columns( $query_args ); $order_stats_join = "JOIN {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id"; $this->update_sql_query_params( $query_args ); $this->interval_query->add_sql_clause( 'join', $order_stats_join ); $db_intervals = $wpdb->get_col( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->interval_query->get_query_statement() ); $db_interval_count = count( $db_intervals ); $this->total_query->add_sql_clause( 'select', $selections ); $this->total_query->add_sql_clause( 'join', $order_stats_join ); $this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); $totals = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->total_query->get_query_statement(), ARRAY_A ); if ( null === $totals ) { return new \WP_Error( 'woocommerce_analytics_taxes_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); } // phpcs:ignore Generic.Commenting.Todo.TaskFound // @todo remove these assignements when refactoring segmenter classes to use query objects. $totals_query = array( 'from_clause' => $this->total_query->get_sql_clause( 'join' ), 'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ), 'where_clause' => $this->total_query->get_sql_clause( 'where' ), ); $intervals_query = array( 'select_clause' => $this->get_sql_clause( 'select' ), 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), 'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ), 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), ); $segmenter = new Segmenter( $query_args, $this->report_columns ); $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); if ( '' !== $selections ) { $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $intervals = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->interval_query->get_query_statement(), ARRAY_A ); if ( null === $intervals ) { return new \WP_Error( 'woocommerce_analytics_taxes_stats_result_failed', __( 'Sorry, fetching tax data failed.', 'woocommerce' ) ); } $totals = (object) $this->cast_numbers( $totals[0] ); $data->totals = $totals; $data->intervals = $intervals; if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); } else { $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); } $segmenter->add_intervals_segments( $data, $intervals_query, $table_name ); return $data; } } API/Reports/Taxes/Stats/Segmenter.php 0000777 00000013031 15252240713 0013445 0 ustar 00 <?php /** * Class for adding segmenting support without cluttering the data stores. */ namespace Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Segmenter as ReportsSegmenter; /** * Date & time interval and numeric range handling class for Reporting API. */ class Segmenter extends ReportsSegmenter { /** * Returns column => query mapping to be used for order-related order-level segmenting query (e.g. tax_rate_id). * * @param string $lookup_table Name of SQL table containing the order-level segmenting info. * * @return array Column => SELECT query mapping. */ protected function get_segment_selections_order_level( $lookup_table ) { $columns_mapping = array( 'tax_codes' => "COUNT(DISTINCT $lookup_table.tax_rate_id) as tax_codes", 'total_tax' => "SUM($lookup_table.total_tax) AS total_tax", 'order_tax' => "SUM($lookup_table.order_tax) as order_tax", 'shipping_tax' => "SUM($lookup_table.shipping_tax) as shipping_tax", 'orders_count' => "COUNT(DISTINCT $lookup_table.order_id) as orders_count", ); return $columns_mapping; } /** * Calculate segments for totals query where the segmenting property is bound to order (e.g. coupon or customer type). * * @param string $segmenting_select SELECT part of segmenting SQL query. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $totals_query Array of SQL clauses for intervals query. * * @return array */ protected function get_order_related_totals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $totals_query ) { global $wpdb; $totals_segments = $wpdb->get_results( "SELECT $segmenting_groupby $segmenting_select FROM $table_name $segmenting_from {$totals_query['from_clause']} WHERE 1=1 {$totals_query['where_time_clause']} {$totals_query['where_clause']} $segmenting_where GROUP BY $segmenting_groupby", ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. // Reformat result. $totals_segments = $this->reformat_totals_segments( $totals_segments, $segmenting_groupby ); return $totals_segments; } /** * Calculate segments for intervals query where the segmenting property is bound to order (e.g. coupon or customer type). * * @param string $segmenting_select SELECT part of segmenting SQL query. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $intervals_query Array of SQL clauses for intervals query. * * @return array */ protected function get_order_related_intervals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $intervals_query ) { global $wpdb; $segmenting_limit = ''; $limit_parts = explode( ',', $intervals_query['limit'] ); if ( 2 === count( $limit_parts ) ) { $orig_rowcount = intval( $limit_parts[1] ); $segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() ); } $intervals_segments = $wpdb->get_results( "SELECT MAX($table_name.date_created) AS datetime_anchor, {$intervals_query['select_clause']} AS time_interval, $segmenting_groupby $segmenting_select FROM $table_name $segmenting_from {$intervals_query['from_clause']} WHERE 1=1 {$intervals_query['where_time_clause']} {$intervals_query['where_clause']} $segmenting_where GROUP BY time_interval, $segmenting_groupby $segmenting_limit", ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. // Reformat result. $intervals_segments = $this->reformat_intervals_segments( $intervals_segments, $segmenting_groupby ); return $intervals_segments; } /** * Return array of segments formatted for REST response. * * @param string $type Type of segments to return--'totals' or 'intervals'. * @param array $query_params SQL query parameter array. * @param string $table_name Name of main SQL table for the data store (used as basis for JOINS). * * @return array * @throws \Automattic\WooCommerce\Admin\API\Reports\ParameterException In case of segmenting by variations, when no parent product is specified. */ protected function get_segments( $type, $query_params, $table_name ) { if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) { return array(); } $segmenting_where = ''; $segmenting_from = ''; $segments = array(); if ( 'tax_rate_id' === $this->query_args['segmentby'] ) { $tax_rate_level_columns = $this->get_segment_selections_order_level( $table_name ); $segmenting_select = $this->prepare_selections( $tax_rate_level_columns ); $this->report_columns = $tax_rate_level_columns; $segmenting_groupby = $table_name . '.tax_rate_id'; $segments = $this->get_order_related_segments( $type, $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params ); } return $segments; } } API/Reports/Taxes/DataStore.php 0000777 00000030016 15252240713 0012306 0 ustar 00 <?php /** * API\Reports\Taxes\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Taxes; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache; /** * API\Reports\Taxes\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Table used to get the data. * * @override ReportsDataStore::$table_name * * @var string */ protected static $table_name = 'wc_order_tax_lookup'; /** * Cache identifier. * * @override ReportsDataStore::$cache_key * * @var string */ protected $cache_key = 'taxes'; /** * Mapping columns to data type to return correct response types. * * @override ReportsDataStore::$column_types * * @var array */ protected $column_types = array( 'tax_rate_id' => 'intval', 'name' => 'strval', 'tax_rate' => 'floatval', 'country' => 'strval', 'state' => 'strval', 'priority' => 'intval', 'total_tax' => 'floatval', 'order_tax' => 'floatval', 'shipping_tax' => 'floatval', 'orders_count' => 'intval', ); /** * Data store context used to pass to filters. * * @override ReportsDataStore::$context * * @var string */ protected $context = 'taxes'; /** * Assign report columns once full table name has been assigned. * * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { global $wpdb; $table_name = self::get_db_table_name(); // Using wp_woocommerce_tax_rates table limits the result to only the existing tax rates and // omits the historical records which differs from the purpose of wp_wc_order_tax_lookup table. // So in order to get the same data present in wp_woocommerce_tax_rates without breaking the // API contract the values are now retrieved from wp_woocommerce_order_items and wp_woocommerce_order_itemmeta. // And given that country, state and priority are not separate columns within the woocommerce_order_items, // a split to order_item_name column value is required to separate those values. This is not ideal, // but given this query is paginated and cached, then it is not a big deal. There is always room for // improvements here. $this->report_columns = array( 'tax_rate_id' => "{$table_name}.tax_rate_id", 'name' => "SUBSTRING_INDEX(SUBSTRING_INDEX({$wpdb->prefix}woocommerce_order_items.order_item_name,'-',-2), '-', 1) as name", 'tax_rate' => 'CAST(itemmeta_rate_percent.meta_value AS DECIMAL(7,4)) as tax_rate', 'country' => "SUBSTRING_INDEX({$wpdb->prefix}woocommerce_order_items.order_item_name,'-',1) as country", 'state' => "SUBSTRING_INDEX(SUBSTRING_INDEX({$wpdb->prefix}woocommerce_order_items.order_item_name,'-',-3), '-', 1) as state", 'priority' => "SUBSTRING_INDEX({$wpdb->prefix}woocommerce_order_items.order_item_name,'-',-1) as priority", 'total_tax' => 'SUM(total_tax) as total_tax', 'order_tax' => 'SUM(order_tax) as order_tax', 'shipping_tax' => 'SUM(shipping_tax) as shipping_tax', 'orders_count' => "COUNT( DISTINCT ( CASE WHEN parent_id = 0 THEN {$table_name}.order_id END ) ) as orders_count", ); } /** * Set up all the hooks for maintaining and populating table data. */ public static function init() { add_action( 'woocommerce_analytics_delete_order_stats', array( __CLASS__, 'sync_on_order_delete' ), 15 ); } /** * Fills FROM clause of SQL request based on user supplied parameters. * * @param array $query_args Query arguments supplied by the user. * @param string $order_status_filter Order status subquery. */ protected function add_from_sql_params( $query_args, $order_status_filter ) { global $wpdb; $table_name = self::get_db_table_name(); if ( $order_status_filter ) { $this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id" ); } $this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}woocommerce_order_items ON {$table_name}.order_id = {$wpdb->prefix}woocommerce_order_items.order_id AND {$wpdb->prefix}woocommerce_order_items.order_item_type = 'tax'" ); $this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}woocommerce_order_itemmeta itemmeta_rate_id ON itemmeta_rate_id.order_item_id = {$wpdb->prefix}woocommerce_order_items.order_item_id AND itemmeta_rate_id.meta_key = 'rate_id'" ); $this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}woocommerce_order_itemmeta itemmeta_rate_percent ON itemmeta_rate_percent.order_item_id = {$wpdb->prefix}woocommerce_order_items.order_item_id AND itemmeta_rate_percent.meta_key = 'rate_percent'" ); } /** * Updates the database query with parameters used for Taxes report: categories and order status. * * @see Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\DataStore::update_sql_query_params() * @param array $query_args Query arguments supplied by the user. */ protected function add_sql_query_params( $query_args ) { global $wpdb; $order_tax_lookup_table = self::get_db_table_name(); $this->add_time_period_sql_params( $query_args, $order_tax_lookup_table ); $this->get_limit_sql_params( $query_args ); $this->add_order_by_sql_params( $query_args ); $order_status_filter = $this->get_status_subquery( $query_args ); $this->add_from_sql_params( $query_args, $order_status_filter ); $this->subquery->add_sql_clause( 'where', "AND itemmeta_rate_id.meta_value = {$order_tax_lookup_table}.tax_rate_id" ); if ( isset( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) { $allowed_taxes = self::get_filtered_ids( $query_args, 'taxes' ); $this->subquery->add_sql_clause( 'where', "AND {$order_tax_lookup_table}.tax_rate_id IN ({$allowed_taxes})" ); } if ( $order_status_filter ) { $this->subquery->add_sql_clause( 'where', "AND ( {$order_status_filter} )" ); } } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override ReportsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['orderby'] = 'tax_rate_id'; $defaults['taxes'] = array(); return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override ReportsDataStore::get_noncached_data() * * @see get_data * @param array $query_args Query parameters. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_data( $query_args ) { global $wpdb; $this->initialize_queries(); $data = (object) array( 'data' => array(), 'total' => 0, 'pages' => 0, 'page_no' => 0, ); $this->add_sql_query_params( $query_args ); $params = $this->get_limit_params( $query_args ); if ( isset( $query_args['taxes'] ) && is_array( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) { $total_results = count( $query_args['taxes'] ); $total_pages = (int) ceil( $total_results / $params['per_page'] ); } else { $db_records_count = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- cache ok, DB call ok, unprepared SQL ok. "SELECT COUNT(*) FROM ( {$this->subquery->get_query_statement()} ) AS tt" ); $total_results = $db_records_count; $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return $data; } } $this->subquery->clear_sql_clause( 'select' ); $this->subquery->add_sql_clause( 'select', $this->selected_columns( $query_args ) ); if ( in_array( $query_args['orderby'], array( 'total_tax', 'order_tax', 'shipping_tax', 'orders_count' ), true ) ) { $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) . ', tax_rate_id' ); } else { $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); } $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $taxes_query = $this->subquery->get_query_statement(); $tax_data = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $taxes_query, ARRAY_A ); if ( null === $tax_data ) { return $data; } $tax_data = array_map( array( $this, 'cast_numbers' ), $tax_data ); $data = (object) array( 'data' => $tax_data, 'total' => $total_results, 'pages' => $total_pages, 'page_no' => (int) $query_args['page'], ); return $data; } /** * Maps ordering specified by the user to columns in the database/fields in the data. * * @override ReportsDataStore::normalize_order_by() * * @param string $order_by Sorting criterion. * @return string */ protected function normalize_order_by( $order_by ) { global $wpdb; if ( 'tax_code' === $order_by ) { return "{$wpdb->prefix}woocommerce_order_items.order_item_name"; } elseif ( 'rate' === $order_by ) { return 'tax_rate'; } return $order_by; } /** * Create or update an entry in the wc_order_tax_lookup table for an order. * * @param int $order_id Order ID. * @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success. */ public static function sync_order_taxes( $order_id ) { global $wpdb; $order = wc_get_order( $order_id ); if ( ! $order ) { return -1; } $tax_items = $order->get_items( 'tax' ); $num_updated = 0; foreach ( $tax_items as $tax_item ) { $result = $wpdb->replace( self::get_db_table_name(), array( 'order_id' => $order->get_id(), 'date_created' => $order->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ), 'tax_rate_id' => $tax_item->get_rate_id(), 'shipping_tax' => $tax_item->get_shipping_tax_total(), 'order_tax' => $tax_item->get_tax_total(), 'total_tax' => (float) $tax_item->get_tax_total() + (float) $tax_item->get_shipping_tax_total(), ), array( '%d', '%s', '%d', '%f', '%f', '%f', ) ); /** * Fires when tax's reports are updated. * * @param int $tax_rate_id Tax Rate ID. * @param int $order_id Order ID. */ do_action( 'woocommerce_analytics_update_tax', $tax_item->get_rate_id(), $order->get_id() ); // Sum the rows affected. Using REPLACE can affect 2 rows if the row already exists. $num_updated += 2 === intval( $result ) ? 1 : intval( $result ); } return ( count( $tax_items ) === $num_updated ); } /** * Clean taxes data when an order is deleted. * * @param int $order_id Order ID. */ public static function sync_on_order_delete( $order_id ) { global $wpdb; $wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) ); /** * Fires when tax's reports are removed from database. * * @param int $tax_rate_id Tax Rate ID. * @param int $order_id Order ID. */ do_action( 'woocommerce_analytics_delete_tax', 0, $order_id ); ReportsCache::invalidate(); } /** * Initialize query objects. */ protected function initialize_queries() { global $wpdb; $this->clear_all_clauses(); $this->subquery = new SqlQuery( $this->context . '_subquery' ); $this->subquery->add_sql_clause( 'select', self::get_db_table_name() . '.tax_rate_id' ); $this->subquery->add_sql_clause( 'from', self::get_db_table_name() ); $this->subquery->add_sql_clause( 'group_by', self::get_db_table_name() . '.tax_rate_id' ); $this->subquery->add_sql_clause( 'group_by', ", {$wpdb->prefix}woocommerce_order_items.order_item_name, itemmeta_rate_percent.meta_value" ); } } API/Reports/Taxes/Controller.php 0000777 00000016772 15252240713 0012560 0 ustar 00 <?php /** * REST API Reports taxes controller * * Handles requests to the /reports/taxes endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Taxes; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Admin\API\Reports\ExportableTraits; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use WP_REST_Request; use WP_REST_Response; /** * REST API Reports taxes controller class. * * @internal * @extends GenericController */ class Controller extends GenericController implements ExportableInterface { /** * Exportable traits. */ use ExportableTraits; /** * Route base. * * @var string */ protected $rest_base = 'reports/taxes'; /** * Get data from `'taxes'` GenericQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new GenericQuery( $query_args, 'taxes' ); return $query->get_data(); } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['before'] = $request['before']; $args['after'] = $request['after']; $args['page'] = $request['page']; $args['per_page'] = $request['per_page']; $args['orderby'] = $request['orderby']; $args['order'] = $request['order']; $args['taxes'] = $request['taxes']; $args['force_cache_refresh'] = $request['force_cache_refresh']; return $args; } /** * Prepare a report data item for serialization. * * @param mixed $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $response = parent::prepare_item_for_response( $report, $request ); // Map to `object` for backwards compatibility. $report = (object) $report; $response->add_links( $this->prepare_links( $report ) ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_taxes', $response, $report, $request ); } /** * Prepare links for the request. * * @param WC_Reports_Query $object Object data. * @return array */ protected function prepare_links( $object ) { $links = array( 'tax' => array( 'href' => rest_url( sprintf( '/%s/taxes/%d', $this->namespace, $object->tax_rate_id ) ), ), ); return $links; } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_taxes', 'type' => 'object', 'properties' => array( 'tax_rate_id' => array( 'description' => __( 'Tax rate ID.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'name' => array( 'description' => __( 'Tax rate name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'tax_rate' => array( 'description' => __( 'Tax rate.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'country' => array( 'description' => __( 'Country / Region.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'state' => array( 'description' => __( 'State.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'priority' => array( 'description' => __( 'Priority.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'total_tax' => array( 'description' => __( 'Total tax.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'order_tax' => array( 'description' => __( 'Order tax.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'shipping_tax' => array( 'description' => __( 'Shipping tax.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'orders_count' => array( 'description' => __( 'Number of orders.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['default'] = 'tax_rate_id'; $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'name', 'tax_rate_id', 'tax_code', 'rate', 'order_tax', 'total_tax', 'shipping_tax', 'orders_count', ) ); $params['taxes'] = array( 'description' => __( 'Limit result set to items assigned one or more tax rates.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', ), ); return $params; } /** * Get the column names for export. * * @return array Key value pair of Column ID => Label. */ public function get_export_columns() { return array( 'tax_code' => __( 'Tax code', 'woocommerce' ), 'rate' => __( 'Rate', 'woocommerce' ), 'total_tax' => __( 'Total tax', 'woocommerce' ), 'order_tax' => __( 'Order tax', 'woocommerce' ), 'shipping_tax' => __( 'Shipping tax', 'woocommerce' ), 'orders_count' => __( 'Orders', 'woocommerce' ), ); } /** * Get the column values for export. * * @param array $item Single report item/row. * @return array Key value pair of Column ID => Row Value. */ public function prepare_item_for_export( $item ) { return array( 'tax_code' => \WC_Tax::get_rate_code( (object) array( 'tax_rate_id' => $item['tax_rate_id'], 'tax_rate_country' => $item['country'], 'tax_rate_state' => $item['state'], 'tax_rate_name' => $item['name'], 'tax_rate_priority' => $item['priority'], ) ), 'rate' => $item['tax_rate'], 'total_tax' => self::csv_number_format( $item['total_tax'] ), 'order_tax' => self::csv_number_format( $item['order_tax'] ), 'shipping_tax' => self::csv_number_format( $item['shipping_tax'] ), 'orders_count' => $item['orders_count'], ); } } API/Reports/ExportableTraits.php 0000777 00000001160 15252240713 0012626 0 ustar 00 <?php /** * REST API Reports exportable traits * * Collection of utility methods for exportable reports. */ namespace Automattic\WooCommerce\Admin\API\Reports; defined( 'ABSPATH' ) || exit; /** * ExportableTraits class. */ trait ExportableTraits { /** * Format numbers for CSV using store precision setting. * * @param string|float $value Numeric value. * @return string Formatted value. */ public static function csv_number_format( $value ) { $decimals = wc_get_price_decimals(); // See: @woocommerce/currency: getCurrencyFormatDecimal(). return number_format( $value, $decimals, '.', '' ); } } API/Reports/Categories/Query.php 0000777 00000003661 15252240713 0012534 0 ustar 00 <?php /** * Class for parameter-based Categories Report querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'page' => 2, * 'order' => 'desc', * 'orderby' => 'items_sold', * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Categories\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Categories; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Categories\Query * * @deprecated 9.3.0 Categories\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { const REPORT_NAME = 'report-categories'; /** * Valid fields for Categories report. * * @deprecated 9.3.0 Categories\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array(); } /** * Get categories data based on the current query vars. * * @deprecated 9.3.0 Categories\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_categories_query_args', $this->get_query_vars() ); $results = \WC_Data_Store::load( self::REPORT_NAME )->get_data( $args ); return apply_filters( 'woocommerce_analytics_categories_select_query', $results, $args ); } } API/Reports/Categories/DataStore.php 0000777 00000025306 15252240713 0013315 0 ustar 00 <?php /** * API\Reports\Categories\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Categories; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; /** * API\Reports\Categories\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Table used to get the data. * * @override ReportsDataStore::$table_name * * @var string */ protected static $table_name = 'wc_order_product_lookup'; /** * Cache identifier. * * @override ReportsDataStore::$cache_key * * @var string */ protected $cache_key = 'categories'; /** * Order by setting used for sorting categories data. * * @var string */ private $order_by = ''; /** * Order setting used for sorting categories data. * * @var string */ private $order = ''; /** * Mapping columns to data type to return correct response types. * * @override ReportsDataStore::$column_types * * @var array */ protected $column_types = array( 'category_id' => 'intval', 'items_sold' => 'intval', 'net_revenue' => 'floatval', 'orders_count' => 'intval', 'products_count' => 'intval', ); /** * Data store context used to pass to filters. * * @override ReportsDataStore::$context * * @var string */ protected $context = 'categories'; /** * Assign report columns once full table name has been assigned. * * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); $this->report_columns = array( 'items_sold' => 'SUM(product_qty) as items_sold', 'net_revenue' => 'SUM(product_net_revenue) AS net_revenue', 'orders_count' => "COUNT(DISTINCT {$table_name}.order_id) as orders_count", 'products_count' => "COUNT(DISTINCT {$table_name}.product_id) as products_count", ); } /** * Return the database query with parameters used for Categories report: time span and order status. * * @param array $query_args Query arguments supplied by the user. */ protected function add_sql_query_params( $query_args ) { global $wpdb; $order_product_lookup_table = self::get_db_table_name(); $this->add_time_period_sql_params( $query_args, $order_product_lookup_table ); // join wp_order_product_lookup_table with relationships and taxonomies // @todo How to handle custom product tables? $this->subquery->add_sql_clause( 'left_join', "LEFT JOIN {$wpdb->term_relationships} ON {$order_product_lookup_table}.product_id = {$wpdb->term_relationships}.object_id" ); // Adding this (inner) JOIN as a LEFT JOIN for ordering purposes. See comment in add_order_by_params(). $this->subquery->add_sql_clause( 'left_join', "JOIN {$wpdb->term_taxonomy} ON {$wpdb->term_taxonomy}.term_taxonomy_id = {$wpdb->term_relationships}.term_taxonomy_id" ); $included_categories = $this->get_included_categories( $query_args ); if ( $included_categories ) { $this->subquery->add_sql_clause( 'where', "AND {$wpdb->term_relationships}.term_taxonomy_id IN ({$included_categories})" ); // Limit is left out here so that the grouping in code by PHP can be applied correctly. // This also needs to be put after the term_taxonomy JOIN so that we can match the correct term name. $this->add_order_by_params( $query_args, 'outer', 'default_results.category_id' ); } else { $this->add_order_by_params( $query_args, 'inner', "{$wpdb->term_relationships}.term_taxonomy_id" ); } $this->add_order_status_clause( $query_args, $order_product_lookup_table, $this->subquery ); $this->subquery->add_sql_clause( 'where', "AND {$wpdb->term_taxonomy}.taxonomy = 'product_cat'" ); } /** * Fills ORDER BY clause of SQL request based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. * @param string $from_arg Target of the JOIN sql param. * @param string $id_cell ID cell identifier, like `table_name.id_column_name`. */ protected function add_order_by_params( $query_args, $from_arg, $id_cell ) { global $wpdb; // Sanitize input: guarantee that the id cell in the join is quoted with backticks. $id_cell_segments = explode( '.', str_replace( '`', '', $id_cell ) ); $id_cell_identifier = '`' . implode( '`.`', $id_cell_segments ) . '`'; $lookup_table = self::get_db_table_name(); $order_by_clause = $this->add_order_by_clause( $query_args, $this ); $this->add_orderby_order_clause( $query_args, $this ); if ( false !== strpos( $order_by_clause, '_terms' ) ) { $join = "JOIN {$wpdb->terms} AS _terms ON {$id_cell_identifier} = _terms.term_id"; if ( 'inner' === $from_arg ) { // Even though this is an (inner) JOIN, we're adding it as a `left_join` to // affect its order in the query statement. The SqlQuery::$sql_filters variable // determines the order in which joins are concatenated. // See: https://github.com/woocommerce/woocommerce-admin/blob/1f261998e7287b77bc13c3d4ee2e84b717da7957/src/API/Reports/SqlQuery.php#L46-L50. $this->subquery->add_sql_clause( 'left_join', $join ); } else { $this->add_sql_clause( 'join', $join ); } } } /** * Maps ordering specified by the user to columns in the database/fields in the data. * * @override ReportsDataStore::normalize_order_by() * * @param string $order_by Sorting criterion. * @return string */ protected function normalize_order_by( $order_by ) { if ( 'date' === $order_by ) { return 'time_interval'; } if ( 'category' === $order_by ) { return '_terms.name'; } return $order_by; } /** * Returns an array of ids of included categories, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return array */ protected function get_included_categories_array( $query_args ) { if ( isset( $query_args['category_includes'] ) && is_array( $query_args['category_includes'] ) && count( $query_args['category_includes'] ) > 0 ) { return $query_args['category_includes']; } return array(); } /** * Returns the page of data according to page number and items per page. * * @param array $data Data to paginate. * @param integer $page_no Page number. * @param integer $items_per_page Number of items per page. * @return array */ protected function page_records( $data, $page_no, $items_per_page ) { $offset = ( $page_no - 1 ) * $items_per_page; return array_slice( $data, $offset, $items_per_page ); } /** * Enriches the category data. * * @param array $categories_data Categories data. * @param array $query_args Query parameters. */ protected function include_extended_info( &$categories_data, $query_args ) { foreach ( $categories_data as $key => $category_data ) { $extended_info = new \ArrayObject(); if ( $query_args['extended_info'] ) { $extended_info['name'] = get_the_category_by_ID( $category_data['category_id'] ); } $categories_data[ $key ]['extended_info'] = $extended_info; } } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override ReportsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['category_includes'] = array(); $defaults['extended_info'] = false; return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @see get_data * @override ReportsDataStore::get_noncached_data() * * @param array $query_args Query parameters. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_data( $query_args ) { global $wpdb; $table_name = self::get_db_table_name(); $this->initialize_queries(); $data = (object) array( 'data' => array(), 'total' => 0, 'pages' => 0, 'page_no' => 0, ); $this->subquery->add_sql_clause( 'select', $this->selected_columns( $query_args ) ); $included_categories = $this->get_included_categories_array( $query_args ); $this->add_sql_query_params( $query_args ); if ( count( $included_categories ) > 0 ) { $fields = $this->get_fields( $query_args ); $ids_table = $this->get_ids_table( $included_categories, 'category_id' ); $this->add_sql_clause( 'select', $this->format_join_selections( array_merge( array( 'category_id' ), $fields ), array( 'category_id' ) ) ); $this->add_sql_clause( 'from', '(' ); $this->add_sql_clause( 'from', $this->subquery->get_query_statement() ); $this->add_sql_clause( 'from', ") AS {$table_name}" ); $this->add_sql_clause( 'right_join', "RIGHT JOIN ( {$ids_table} ) AS default_results ON default_results.category_id = {$table_name}.category_id" ); $categories_query = $this->get_query_statement(); } else { $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $categories_query = $this->subquery->get_query_statement(); } $categories_data = $wpdb->get_results( $categories_query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ARRAY_A ); if ( null === $categories_data ) { return new \WP_Error( 'woocommerce_analytics_categories_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ), array( 'status' => 500 ) ); } $record_count = count( $categories_data ); $total_pages = (int) ceil( $record_count / $query_args['per_page'] ); if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return $data; } $categories_data = $this->page_records( $categories_data, $query_args['page'], $query_args['per_page'] ); $this->include_extended_info( $categories_data, $query_args ); $categories_data = array_map( array( $this, 'cast_numbers' ), $categories_data ); $data = (object) array( 'data' => $categories_data, 'total' => $record_count, 'pages' => $total_pages, 'page_no' => (int) $query_args['page'], ); return $data; } /** * Initialize query objects. * * @override ReportsDataStore::initialize_queries() */ protected function initialize_queries() { global $wpdb; $this->subquery = new SqlQuery( $this->context . '_subquery' ); $this->subquery->add_sql_clause( 'select', "{$wpdb->term_taxonomy}.term_id as category_id," ); $this->subquery->add_sql_clause( 'from', self::get_db_table_name() ); $this->subquery->add_sql_clause( 'group_by', "{$wpdb->term_taxonomy}.term_id" ); } } API/Reports/Categories/Controller.php 0000777 00000021260 15252240713 0013545 0 ustar 00 <?php /** * REST API Reports categories controller * * Handles requests to the /reports/categories endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Categories; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use Automattic\WooCommerce\Admin\API\Reports\OrderAwareControllerTrait; /** * REST API Reports categories controller class. * * @internal * @extends \Automattic\WooCommerce\Admin\API\Reports\GenericController */ class Controller extends GenericController implements ExportableInterface { use OrderAwareControllerTrait; /** * Route base. * * @var string */ protected $rest_base = 'reports/categories'; /** * Get data from `'categories'` GenericQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new GenericQuery( $query_args, 'categories' ); return $query->get_data(); } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['before'] = $request['before']; $args['after'] = $request['after']; $args['interval'] = $request['interval']; $args['page'] = $request['page']; $args['per_page'] = $request['per_page']; $args['orderby'] = $request['orderby']; $args['order'] = $request['order']; $args['extended_info'] = $request['extended_info']; $args['category_includes'] = (array) $request['categories']; $args['status_is'] = (array) $request['status_is']; $args['status_is_not'] = (array) $request['status_is_not']; $args['force_cache_refresh'] = $request['force_cache_refresh']; return $args; } /** * Prepare a report data item for serialization. * * @param mixed $report Report data item as returned from Data Store. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { // Wrap the data in a response object. $response = parent::prepare_item_for_response( $report, $request ); $response->add_links( $this->prepare_links( $report ) ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_categories', $response, $report, $request ); } /** * Prepare links for the request. * * @param \Automattic\WooCommerce\Admin\API\Reports\GenericQuery $object Object data. * @return array */ protected function prepare_links( $object ) { $links = array( 'category' => array( 'href' => rest_url( sprintf( '/%s/products/categories/%d', $this->namespace, $object['category_id'] ) ), ), ); return $links; } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_categories', 'type' => 'object', 'properties' => array( 'category_id' => array( 'description' => __( 'Category ID.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'items_sold' => array( 'description' => __( 'Amount of items sold.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'net_revenue' => array( 'description' => __( 'Total sales.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'orders_count' => array( 'description' => __( 'Number of orders.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'products_count' => array( 'description' => __( 'Amount of products.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'extended_info' => array( 'name' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Category name.', 'woocommerce' ), ), ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['default'] = 'category_id'; $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'category_id', 'items_sold', 'net_revenue', 'orders_count', 'products_count', 'category', ) ); $params['interval'] = array( 'description' => __( 'Time interval to use for buckets in the returned data.', 'woocommerce' ), 'type' => 'string', 'default' => 'week', 'enum' => array( 'hour', 'day', 'week', 'month', 'quarter', 'year', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['status_is'] = array( 'description' => __( 'Limit result set to items that have the specified order status.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'enum' => self::get_order_statuses(), 'type' => 'string', ), ); $params['status_is_not'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified order status.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'enum' => self::get_order_statuses(), 'type' => 'string', ), ); $params['categories'] = array( 'description' => __( 'Limit result set to all items that have the specified term assigned in the categories taxonomy.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['extended_info'] = array( 'description' => __( 'Add additional piece of info about each category to the report.', 'woocommerce' ), 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'wc_string_to_bool', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the column names for export. * * @return array Key value pair of Column ID => Label. */ public function get_export_columns() { $export_columns = array( 'category' => __( 'Category', 'woocommerce' ), 'items_sold' => __( 'Items sold', 'woocommerce' ), 'net_revenue' => __( 'Net Revenue', 'woocommerce' ), 'products_count' => __( 'Products', 'woocommerce' ), 'orders_count' => __( 'Orders', 'woocommerce' ), ); /** * Filter to add or remove column names from the categories report for * export. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_categories_export_columns', $export_columns ); } /** * Get the column values for export. * * @param array $item Single report item/row. * @return array Key value pair of Column ID => Row Value. */ public function prepare_item_for_export( $item ) { $export_item = array( 'category' => $item['extended_info']['name'], 'items_sold' => $item['items_sold'], 'net_revenue' => $item['net_revenue'], 'products_count' => $item['products_count'], 'orders_count' => $item['orders_count'], ); /** * Filter to prepare extra columns in the export item for the * categories export. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_categories_prepare_export_item', $export_item, $item ); } } API/Reports/ExportableInterface.php 0000777 00000001160 15252240713 0013260 0 ustar 00 <?php /** * Reports Exportable Controller Interface */ namespace Automattic\WooCommerce\Admin\API\Reports; if ( ! defined( 'ABSPATH' ) ) { exit; } /** * WooCommerce Reports exportable controller interface. * * @since 3.5.0 */ interface ExportableInterface { /** * Get the column names for export. * * @return array Key value pair of Column ID => Label. */ public function get_export_columns(); /** * Get the column values for export. * * @param array $item Single report item/row. * @return array Key value pair of Column ID => Value. */ public function prepare_item_for_export( $item ); } API/Reports/DataStoreInterface.php 0000777 00000000621 15252240713 0013042 0 ustar 00 <?php /** * Reports Data Store Interface */ namespace Automattic\WooCommerce\Admin\API\Reports; if ( ! defined( 'ABSPATH' ) ) { exit; } /** * WooCommerce Reports data store interface. * * @since 3.5.0 */ interface DataStoreInterface { /** * Get the data based on args. * * @param array $args Query parameters. * @return stdClass|WP_Error */ public function get_data( $args ); } API/Reports/Controller.php 0000777 00000015417 15252240713 0011467 0 ustar 00 <?php /** * REST API Reports controller extended to handle requests to the reports endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\OrderAwareControllerTrait; /** * Reports controller class. * * Controller that handles the endpoint that returns all available analytics endpoints. * * @internal * @extends GenericController */ class Controller extends GenericController { use OrderAwareControllerTrait; /** * Get all reports. * * @param WP_REST_Request $request Request data. * @return array|WP_Error */ public function get_items( $request ) { $data = array(); $reports = array( array( 'slug' => 'performance-indicators', 'description' => __( 'Batch endpoint for getting specific performance indicators from `stats` endpoints.', 'woocommerce' ), ), array( 'slug' => 'revenue/stats', 'description' => __( 'Stats about revenue.', 'woocommerce' ), ), array( 'slug' => 'orders/stats', 'description' => __( 'Stats about orders.', 'woocommerce' ), ), array( 'slug' => 'products', 'description' => __( 'Products detailed reports.', 'woocommerce' ), ), array( 'slug' => 'products/stats', 'description' => __( 'Stats about products.', 'woocommerce' ), ), array( 'slug' => 'variations', 'description' => __( 'Variations detailed reports.', 'woocommerce' ), ), array( 'slug' => 'variations/stats', 'description' => __( 'Stats about variations.', 'woocommerce' ), ), array( 'slug' => 'categories', 'description' => __( 'Product categories detailed reports.', 'woocommerce' ), ), array( 'slug' => 'categories/stats', 'description' => __( 'Stats about product categories.', 'woocommerce' ), ), array( 'slug' => 'coupons', 'description' => __( 'Coupons detailed reports.', 'woocommerce' ), ), array( 'slug' => 'coupons/stats', 'description' => __( 'Stats about coupons.', 'woocommerce' ), ), array( 'slug' => 'taxes', 'description' => __( 'Taxes detailed reports.', 'woocommerce' ), ), array( 'slug' => 'taxes/stats', 'description' => __( 'Stats about taxes.', 'woocommerce' ), ), array( 'slug' => 'downloads', 'description' => __( 'Product downloads detailed reports.', 'woocommerce' ), ), array( 'slug' => 'downloads/files', 'description' => __( 'Product download files detailed reports.', 'woocommerce' ), ), array( 'slug' => 'downloads/stats', 'description' => __( 'Stats about product downloads.', 'woocommerce' ), ), array( 'slug' => 'customers', 'description' => __( 'Customers detailed reports.', 'woocommerce' ), ), array( 'slug' => 'customers/stats', 'description' => __( 'Stats about groups of customers.', 'woocommerce' ), ), ); /** * Filter the list of allowed reports, so that data can be loaded from third party extensions in addition to WooCommerce core. * Array items should be in format of array( 'slug' => 'downloads/stats', 'description' => '', * 'url' => '', and 'path' => '/wc-ext/v1/...'. * * @param array $endpoints The list of allowed reports.. */ $reports = apply_filters( 'woocommerce_admin_reports', $reports ); foreach ( $reports as $report ) { // Silently skip non-compliant reports. Like the ones for WC_Admin_Reports::get_reports(). if ( empty( $report['slug'] ) ) { continue; } if ( empty( $report['path'] ) ) { $report['path'] = '/' . $this->namespace . '/reports/' . $report['slug']; } // Allows a different admin page to be loaded here, // or allows an empty url if no report exists for a set of performance indicators. if ( ! isset( $report['url'] ) ) { if ( '/stats' === substr( $report['slug'], -6 ) ) { $url_slug = substr( $report['slug'], 0, -6 ); } else { $url_slug = $report['slug']; } $report['url'] = '/analytics/' . $url_slug; } $item = $this->prepare_item_for_response( (object) $report, $request ); $data[] = $this->prepare_response_for_collection( $item ); } return rest_ensure_response( $data ); } /** * Prepare a report object for serialization. * * @param stdClass $report Report data. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $data = array( 'slug' => $report->slug, 'description' => $report->description, 'path' => $report->path, ); // Wrap the data in a response object. $response = parent::prepare_item_for_response( $data, $request ); $response->add_links( array( 'self' => array( 'href' => rest_url( $report->path ), ), 'report' => array( 'href' => $report->url, ), 'collection' => array( 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ), ), ) ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report', $response, $report, $request ); } /** * Get the Report's schema, conforming to JSON Schema. * * @override WP_REST_Controller::get_item_schema() * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report', 'type' => 'object', 'properties' => array( 'slug' => array( 'description' => __( 'An alphanumeric identifier for the resource.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), 'description' => array( 'description' => __( 'A human-readable description of the resource.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), 'path' => array( 'description' => __( 'API path.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { return array( 'context' => $this->get_context_param( array( 'default' => 'view' ) ), ); } } API/Reports/Customers/DataStore.php 0000777 00000105570 15252240713 0013216 0 ustar 00 <?php /** * Admin\API\Reports\Customers\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Customers; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache; use Automattic\WooCommerce\Utilities\OrderUtil; /** * Admin\API\Reports\Customers\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Table used to get the data. * * @override ReportsDataStore::$table_name * * @var string */ protected static $table_name = 'wc_customer_lookup'; /** * Cache identifier. * * @override ReportsDataStore::$cache_key * * @var string */ protected $cache_key = 'customers'; /** * Mapping columns to data type to return correct response types. * * @override ReportsDataStore::$column_types * * @var array */ protected $column_types = array( 'id' => 'intval', 'user_id' => 'intval', 'orders_count' => 'intval', 'total_spend' => 'floatval', 'avg_order_value' => 'floatval', ); /** * Data store context used to pass to filters. * * @override ReportsDataStore::$context * * @var string */ protected $context = 'customers'; /** * Assign report columns once full table name has been assigned. * * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { global $wpdb; $table_name = self::get_db_table_name(); $orders_count = 'SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END )'; $total_spend = 'SUM( total_sales )'; $this->report_columns = array( 'id' => "{$table_name}.customer_id as id", 'user_id' => 'user_id', 'username' => 'username', 'name' => "CONCAT_WS( ' ', first_name, last_name ) as name", // @xxx: What does this mean for RTL? 'first_name' => 'first_name', 'last_name' => 'last_name', 'email' => 'email', 'country' => 'country', 'city' => 'city', 'state' => 'state', 'postcode' => 'postcode', 'date_registered' => 'date_registered', // Use single quotes for string literals to ensure compatibility with sql_mode=ANSI_QUOTES. 'date_last_active' => "IF( date_last_active <= '0000-00-00 00:00:00', NULL, date_last_active ) AS date_last_active", 'date_last_order' => "MAX( {$wpdb->prefix}wc_order_stats.date_created ) as date_last_order", 'orders_count' => "{$orders_count} as orders_count", 'total_spend' => "{$total_spend} as total_spend", 'avg_order_value' => "CASE WHEN {$orders_count} = 0 THEN NULL ELSE {$total_spend} / {$orders_count} END AS avg_order_value", ); } /** * Set up all the hooks for maintaining and populating table data. */ public static function init() { add_action( 'woocommerce_new_customer', array( __CLASS__, 'update_registered_customer' ) ); add_action( 'woocommerce_update_customer', array( __CLASS__, 'update_registered_customer' ) ); add_action( 'profile_update', array( __CLASS__, 'update_registered_customer' ) ); add_action( 'added_user_meta', array( __CLASS__, 'update_registered_customer_via_last_active' ), 10, 3 ); add_action( 'updated_user_meta', array( __CLASS__, 'update_registered_customer_via_last_active' ), 10, 3 ); add_action( 'delete_user', array( __CLASS__, 'delete_customer_by_user_id' ) ); add_action( 'remove_user_from_blog', array( __CLASS__, 'delete_customer_by_user_id' ) ); add_action( 'woocommerce_privacy_remove_order_personal_data', array( __CLASS__, 'anonymize_customer' ) ); add_action( 'woocommerce_analytics_delete_order_stats', array( __CLASS__, 'sync_on_order_delete' ), 15, 2 ); } /** * Sync customers data after an order was deleted. * * When an order is deleted, the customer record is deleted from the * table if the customer has no other orders. * * @param int $order_id Order ID. * @param int $customer_id Customer ID. */ public static function sync_on_order_delete( $order_id, $customer_id ) { $customer_id = absint( $customer_id ); if ( 0 === $customer_id ) { return; } // Calculate the amount of orders remaining for this customer. $order_count = self::get_order_count( $customer_id ); if ( 0 === $order_count ) { self::delete_customer( $customer_id ); } } /** * Sync customers data after an order was updated. * * Only updates customer if it is the customers last order. * * @param int $post_id of order. * @return true|-1 */ public static function sync_order_customer( $post_id ) { global $wpdb; if ( ! OrderUtil::is_order( $post_id, array( 'shop_order', 'shop_order_refund' ) ) ) { return -1; } $order = wc_get_order( $post_id ); $customer_id = self::get_existing_customer_id_from_order( $order ); if ( false === $customer_id ) { return -1; } $last_order = self::get_last_order( $customer_id ); if ( ! $last_order || $order->get_id() !== $last_order->get_id() ) { return -1; } list($data, $format) = self::get_customer_order_data_and_format( $order ); $result = $wpdb->update( self::get_db_table_name(), $data, array( 'customer_id' => $customer_id ), $format ); /** * Fires when a customer is updated. * * @param int $customer_id Customer ID. * @since 4.0.0 */ do_action( 'woocommerce_analytics_update_customer', $customer_id ); return 1 === $result; } /** * Fills ORDER BY clause of SQL request based on user supplied parameters. Overridden here to allow multiple direction * clauses. * * @since 10.5.0 * @param array $query_args Parameters supplied by the user. * @return void */ protected function add_order_by_sql_params( $query_args ) { $order_by_clause = $this->normalize_order_by_clause( $query_args['orderby'] ?? 'date_registered', $query_args['order'] ?? 'desc' ); $this->clear_sql_clause( 'order_by' ); $this->add_sql_clause( 'order_by', $order_by_clause ); } /** * Maps ordering specified by the user to columns in the database/fields in the data. * * Handles both order_by and direction. * * @since 10.5.0 * @param string $order_by Sorting criterion. * @param string $order Order direction. * @return string */ protected function normalize_order_by_clause( $order_by, $order = 'desc' ) { $order_by = esc_sql( $order_by ); $order = strtolower( $order ) === 'asc' ? 'ASC' : 'DESC'; $order_by_clause = ''; if ( 'location' === $order_by ) { $order_by_clause = "state {$order}, country {$order}"; } else { $order_by_clause = "{$order_by} {$order}"; } return $order_by_clause; } /** * Fills WHERE clause of SQL request with date-related constraints. * * @override ReportsDataStore::add_time_period_sql_params() * * @param array $query_args Parameters supplied by the user. * @param string $table_name Name of the db table relevant for the date constraint. */ protected function add_time_period_sql_params( $query_args, $table_name ) { global $wpdb; $this->clear_sql_clause( array( 'where', 'where_time', 'having' ) ); $date_param_mapping = array( 'registered' => array( 'clause' => 'where', 'column' => $table_name . '.date_registered', ), 'order' => array( 'clause' => 'where', 'column' => $wpdb->prefix . 'wc_order_stats.date_created', ), 'last_active' => array( 'clause' => 'where', 'column' => $table_name . '.date_last_active', ), 'last_order' => array( 'clause' => 'having', 'column' => "MAX( {$wpdb->prefix}wc_order_stats.date_created )", ), ); $match_operator = $this->get_match_operator( $query_args ); $where_time_clauses = array(); $having_time_clauses = array(); foreach ( $date_param_mapping as $query_param => $param_info ) { $subclauses = array(); $before_arg = $query_param . '_before'; $after_arg = $query_param . '_after'; $column_name = $param_info['column']; if ( ! empty( $query_args[ $before_arg ] ) ) { $datetime = new \DateTime( $query_args[ $before_arg ] ); $datetime_str = $datetime->format( TimeInterval::$sql_datetime_format ); $subclauses[] = "{$column_name} <= '$datetime_str'"; } if ( ! empty( $query_args[ $after_arg ] ) ) { $datetime = new \DateTime( $query_args[ $after_arg ] ); $datetime_str = $datetime->format( TimeInterval::$sql_datetime_format ); $subclauses[] = "{$column_name} >= '$datetime_str'"; } if ( $subclauses && ( 'where' === $param_info['clause'] ) ) { $where_time_clauses[] = '(' . implode( ' AND ', $subclauses ) . ')'; } if ( $subclauses && ( 'having' === $param_info['clause'] ) ) { $having_time_clauses[] = '(' . implode( ' AND ', $subclauses ) . ')'; } } if ( $where_time_clauses ) { $this->subquery->add_sql_clause( 'where_time', 'AND ' . implode( " {$match_operator} ", $where_time_clauses ) ); } if ( $having_time_clauses ) { $this->subquery->add_sql_clause( 'having', 'AND ' . implode( " {$match_operator} ", $having_time_clauses ) ); } } /** * Updates the database query with parameters used for Customers report: categories and order status. * * @param array $query_args Query arguments supplied by the user. */ protected function add_sql_query_params( $query_args ) { global $wpdb; $customer_lookup_table = self::get_db_table_name(); $order_stats_table_name = $wpdb->prefix . 'wc_order_stats'; $this->add_time_period_sql_params( $query_args, $customer_lookup_table ); $this->get_limit_sql_params( $query_args ); $this->add_order_by_sql_params( $query_args ); $this->subquery->add_sql_clause( 'left_join', "LEFT JOIN {$order_stats_table_name} ON {$customer_lookup_table}.customer_id = {$order_stats_table_name}.customer_id" ); $match_operator = $this->get_match_operator( $query_args ); $where_clauses = array(); $having_clauses = array(); $exact_match_params = array( 'name', 'username', 'email', 'country', ); foreach ( $exact_match_params as $exact_match_param ) { if ( ! empty( $query_args[ $exact_match_param . '_includes' ] ) ) { $exact_match_arguments = $query_args[ $exact_match_param . '_includes' ]; $exact_match_arguments_escaped = array_map( 'esc_sql', explode( ',', $exact_match_arguments ) ); $included = implode( "','", $exact_match_arguments_escaped ); // 'country_includes' is a list of country codes, the others will be a list of customer ids. $table_column = 'country' === $exact_match_param ? $exact_match_param : 'customer_id'; $where_clauses[] = "{$customer_lookup_table}.{$table_column} IN ('{$included}')"; } if ( ! empty( $query_args[ $exact_match_param . '_excludes' ] ) ) { $exact_match_arguments = $query_args[ $exact_match_param . '_excludes' ]; $exact_match_arguments_escaped = array_map( 'esc_sql', explode( ',', $exact_match_arguments ) ); $excluded = implode( "','", $exact_match_arguments_escaped ); // 'country_includes' is a list of country codes, the others will be a list of customer ids. $table_column = 'country' === $exact_match_param ? $exact_match_param : 'customer_id'; $where_clauses[] = "{$customer_lookup_table}.{$table_column} NOT IN ('{$excluded}')"; } } $search_params = array( 'name', 'username', 'email', 'all', ); if ( ! empty( $query_args['search'] ) ) { $name_like = '%' . $wpdb->esc_like( $query_args['search'] ) . '%'; if ( empty( $query_args['searchby'] ) || 'name' === $query_args['searchby'] || ! in_array( $query_args['searchby'], $search_params, true ) ) { $searchby = "CONCAT_WS( ' ', first_name, last_name )"; } elseif ( 'all' === $query_args['searchby'] ) { $searchby = "CONCAT_WS( ' ', first_name, last_name, username, email )"; } else { $searchby = $query_args['searchby']; } $where_clauses[] = $wpdb->prepare( "{$searchby} LIKE %s", $name_like ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared } $filter_empty_params = array( 'email', 'name', 'country', 'city', 'state', 'postcode', ); if ( ! empty( $query_args['filter_empty'] ) ) { $fields_to_filter_by = array_intersect( $query_args['filter_empty'], $filter_empty_params ); if ( in_array( 'name', $fields_to_filter_by, true ) ) { $fields_to_filter_by = array_diff( $fields_to_filter_by, array( 'name' ) ); $fields_to_filter_by[] = "CONCAT_WS( ' ', first_name, last_name )"; } $fields_with_not_condition = array_map( function ( $field ) { return $field . ' <> \'\''; }, $fields_to_filter_by ); $where_clauses[] = '(' . implode( ' AND ', $fields_with_not_condition ) . ')'; } // Allow a list of customer IDs to be specified. if ( ! empty( $query_args['customers'] ) ) { $included_customers = $this->get_filtered_ids( $query_args, 'customers' ); $where_clauses[] = "{$customer_lookup_table}.customer_id IN ({$included_customers})"; } // Allow a list of user IDs to be specified. if ( ! empty( $query_args['users'] ) ) { $included_users = $this->get_filtered_ids( $query_args, 'users' ); $where_clauses[] = "{$customer_lookup_table}.user_id IN ({$included_users})"; } // Allow a list of locations to be specified (includes). if ( ! empty( $query_args['location_includes'] ) ) { $location_clause = $this->build_location_filter_clause( $query_args['location_includes'], true ); if ( '' !== $location_clause ) { $where_clauses[] = $location_clause; } } // Allow a list of locations to be excluded. if ( ! empty( $query_args['location_excludes'] ) ) { $location_clause = $this->build_location_filter_clause( $query_args['location_excludes'], false ); if ( '' !== $location_clause ) { $where_clauses[] = $location_clause; } } // Filter by user type. if ( ! empty( $query_args['user_type'] ) && 'all' !== $query_args['user_type'] ) { $user_type = $query_args['user_type']; $where_clauses[] = "{$customer_lookup_table}.user_id IS " . ( 'registered' === $user_type ? 'NOT NULL' : 'NULL' ); } $numeric_params = array( 'orders_count' => array( 'column' => 'COUNT( order_id )', 'format' => '%d', ), 'total_spend' => array( 'column' => 'SUM( total_sales )', 'format' => '%f', ), 'avg_order_value' => array( 'column' => '( SUM( total_sales ) / COUNT( order_id ) )', 'format' => '%f', ), ); foreach ( $numeric_params as $numeric_param => $param_info ) { $subclauses = array(); $min_param = $numeric_param . '_min'; $max_param = $numeric_param . '_max'; $or_equal = isset( $query_args[ $min_param ] ) && isset( $query_args[ $max_param ] ) ? '=' : ''; if ( isset( $query_args[ $min_param ] ) ) { $subclauses[] = $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare "{$param_info['column']} >{$or_equal} {$param_info['format']}", $query_args[ $min_param ] ); } if ( isset( $query_args[ $max_param ] ) ) { $subclauses[] = $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare "{$param_info['column']} <{$or_equal} {$param_info['format']}", $query_args[ $max_param ] ); } if ( $subclauses ) { $having_clauses[] = '(' . implode( ' AND ', $subclauses ) . ')'; } } if ( $where_clauses ) { $preceding_match = empty( $this->get_sql_clause( 'where_time' ) ) ? ' AND ' : " {$match_operator} "; $this->subquery->add_sql_clause( 'where', $preceding_match . implode( " {$match_operator} ", $where_clauses ) ); } $order_status_filter = $this->get_status_subquery( $query_args ); if ( $order_status_filter ) { $this->subquery->add_sql_clause( 'left_join', "AND ( {$order_status_filter} )" ); } if ( $having_clauses ) { $preceding_match = empty( $this->get_sql_clause( 'having' ) ) ? ' AND ' : " {$match_operator} "; $this->subquery->add_sql_clause( 'having', $preceding_match . implode( " {$match_operator} ", $having_clauses ) ); } } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override ReportsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['orderby'] = 'date_registered'; $defaults['order_before'] = TimeInterval::default_before(); $defaults['order_after'] = TimeInterval::default_after(); return $defaults; } /** * Returns an existing customer ID for an order if one exists. * * @param object $order WC Order. * @return int|bool */ public static function get_existing_customer_id_from_order( $order ) { global $wpdb; if ( ! is_a( $order, 'WC_Order' ) ) { return false; } $user_id = $order->get_customer_id(); if ( 0 === $user_id ) { $customer_id = $wpdb->get_var( $wpdb->prepare( "SELECT customer_id FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d", $order->get_id() ) ); if ( $customer_id ) { return $customer_id; } $email = $order->get_billing_email( 'edit' ); if ( $email ) { return self::get_customer_id_by_email( $email ); } else { return false; } } else { return self::get_customer_id_by_user_id( $user_id ); } } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override ReportsDataStore::get_noncached_data() * * @see get_data * @param array $query_args Query parameters. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_data( $query_args ) { global $wpdb; $this->initialize_queries(); $data = (object) array( 'data' => array(), 'total' => 0, 'pages' => 0, 'page_no' => 0, ); $selections = $this->selected_columns( $query_args ); $sql_query_params = $this->add_sql_query_params( $query_args ); $count_query = "SELECT COUNT(*) FROM ( {$this->subquery->get_query_statement()} ) as tt "; $db_records_count = (int) $wpdb->get_var( $count_query // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ); $params = $this->get_limit_params( $query_args ); $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return $data; } $this->subquery->clear_sql_clause( 'select' ); $this->subquery->add_sql_clause( 'select', $selections ); // For aggregated fields, ensure deterministic ordering by including GROUP BY field. $order_by = $this->get_sql_clause( 'order_by' ); $aggregated_fields = array( 'orders_count', 'total_spend', 'avg_order_value' ); $has_aggregated_field = false; foreach ( $aggregated_fields as $field ) { if ( false !== strpos( $order_by, $field ) ) { $has_aggregated_field = true; break; } } if ( $has_aggregated_field ) { $customer_lookup_table = self::get_db_table_name(); $this->subquery->add_sql_clause( 'order_by', $order_by . ", {$customer_lookup_table}.customer_id" ); } else { $this->subquery->add_sql_clause( 'order_by', $order_by ); } $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $customer_data = $wpdb->get_results( $this->subquery->get_query_statement(), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ARRAY_A ); if ( null === $customer_data ) { return $data; } $customer_data = array_map( array( $this, 'cast_numbers' ), $customer_data ); $data = (object) array( 'data' => $customer_data, 'total' => $db_records_count, 'pages' => $total_pages, 'page_no' => (int) $query_args['page'], ); return $data; } /** * Get or create a customer from a given order. * * @param object $order WC Order. * @return int|bool */ public static function get_or_create_customer_from_order( $order ) { if ( ! $order ) { return false; } global $wpdb; if ( ! is_a( $order, 'WC_Order' ) ) { return false; } $returning_customer_id = self::get_existing_customer_id_from_order( $order ); if ( $returning_customer_id ) { return $returning_customer_id; } list($data, $format) = self::get_customer_order_data_and_format( $order ); $result = $wpdb->insert( self::get_db_table_name(), $data, $format ); $customer_id = $wpdb->insert_id; /** * Fires when a new report customer is created. * * @param int $customer_id Customer ID. * @since 4.0.0 */ do_action( 'woocommerce_analytics_new_customer', $customer_id ); return $result ? $customer_id : false; } /** * Returns a data object and format object of the customers data coming from the order. * * @param object $order WC_Order where we get customer info from. * @param object|null $customer_user WC_Customer registered customer WP user. * @return array ($data, $format) */ public static function get_customer_order_data_and_format( $order, $customer_user = null ) { $data = array( 'first_name' => $order->get_customer_first_name(), 'last_name' => $order->get_customer_last_name(), 'email' => $order->get_billing_email( 'edit' ), 'city' => $order->get_billing_city( 'edit' ), 'state' => $order->get_billing_state( 'edit' ), 'postcode' => $order->get_billing_postcode( 'edit' ), 'country' => $order->get_billing_country( 'edit' ), 'date_last_active' => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ), ); $format = array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', ); // Add registered customer data. if ( 0 !== $order->get_user_id() ) { $user_id = $order->get_user_id(); if ( is_null( $customer_user ) ) { $customer_user = new \WC_Customer( $user_id ); } // Set email as customer email instead of Order Billing Email if we have a customer. $data['email'] = $customer_user->get_email( 'edit' ); // Adding other relevant customer data. $data['user_id'] = $user_id; $data['username'] = $customer_user->get_username( 'edit' ); $data['date_registered'] = $customer_user->get_date_created( 'edit' ) ? $customer_user->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ) : null; $format[] = '%d'; $format[] = '%s'; $format[] = '%s'; } return array( $data, $format ); } /** * Retrieve a guest ID (when user_id is null) by email. * * @param string $email Email address. * @return false|array Customer array if found, boolean false if not. */ public static function get_guest_id_by_email( $email ) { global $wpdb; $table_name = self::get_db_table_name(); $customer_id = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT customer_id FROM {$table_name} WHERE email = %s AND user_id IS NULL LIMIT 1", $email ) ); return $customer_id ? (int) $customer_id : false; } /** * Retrieve a customer ID by email address, regardless of user registration status. * Prioritizes registered customers over guest customers when both exist. * * @param string $email Email address. * @return false|int Customer ID if found, boolean false if not. */ public static function get_customer_id_by_email( $email ) { global $wpdb; if ( empty( $email ) || ! is_email( $email ) ) { return false; } $table_name = self::get_db_table_name(); $customer_id = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT customer_id FROM {$table_name} WHERE email = %s ORDER BY user_id IS NOT NULL DESC LIMIT 1", $email ) ); return $customer_id ? (int) $customer_id : false; } /** * Retrieve a registered customer row id by user_id. * * @param string|int $user_id User ID. * @return false|int Customer ID if found, boolean false if not. */ public static function get_customer_id_by_user_id( $user_id ) { global $wpdb; $table_name = self::get_db_table_name(); $customer_id = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT customer_id FROM {$table_name} WHERE user_id = %d LIMIT 1", $user_id ) ); return $customer_id ? (int) $customer_id : false; } /** * Retrieve the last order made by a customer. * * @param int $customer_id Customer ID. * @return object WC_Order|false. */ public static function get_last_order( $customer_id ) { global $wpdb; $orders_table = $wpdb->prefix . 'wc_order_stats'; $last_order = $wpdb->get_var( $wpdb->prepare( // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT order_id, date_created_gmt FROM {$orders_table} WHERE customer_id = %d ORDER BY date_created_gmt DESC, order_id DESC LIMIT 1", // phpcs:enable $customer_id ) ); if ( ! $last_order ) { return false; } return wc_get_order( absint( $last_order ) ); } /** * Retrieve the oldest orders made by a customer. * * @param int $customer_id Customer ID. * @return array Orders. */ public static function get_oldest_orders( $customer_id ) { global $wpdb; $orders_table = $wpdb->prefix . 'wc_order_stats'; $excluded_statuses = array_map( array( __CLASS__, 'normalize_order_status' ), self::get_excluded_report_order_statuses() ); $excluded_statuses_condition = ''; if ( ! empty( $excluded_statuses ) ) { $excluded_statuses_str = implode( "','", $excluded_statuses ); $excluded_statuses_condition = "AND status NOT IN ('{$excluded_statuses_str}')"; } return $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT order_id, date_created FROM {$orders_table} WHERE customer_id = %d {$excluded_statuses_condition} ORDER BY date_created, order_id ASC LIMIT 2", $customer_id ) ); } /** * Retrieve the amount of orders made by a customer. * * @param int $customer_id Customer ID. * @return int|null Amount of orders for customer or null on failure. */ public static function get_order_count( $customer_id ) { global $wpdb; $customer_id = absint( $customer_id ); if ( 0 === $customer_id ) { return null; } $result = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT( order_id ) FROM {$wpdb->prefix}wc_order_stats WHERE customer_id = %d", $customer_id ) ); if ( is_null( $result ) ) { return null; } return (int) $result; } /** * Update the database with customer data. * * @param int $user_id WP User ID to update customer data for. * @return int|bool|null Number or rows modified or false on failure. */ public static function update_registered_customer( $user_id ) { global $wpdb; $customer = new \WC_Customer( $user_id ); if ( ! self::is_valid_customer( $user_id ) ) { return false; } $first_name = $customer->get_first_name(); $last_name = $customer->get_last_name(); if ( empty( $first_name ) ) { $first_name = $customer->get_billing_first_name(); } if ( empty( $last_name ) ) { $last_name = $customer->get_billing_last_name(); } $last_active = $customer->get_meta( 'wc_last_active', true, 'edit' ); $data = array( 'user_id' => $user_id, 'username' => $customer->get_username( 'edit' ), 'first_name' => $first_name, 'last_name' => $last_name, 'email' => $customer->get_email( 'edit' ), 'city' => $customer->get_billing_city( 'edit' ), 'state' => $customer->get_billing_state( 'edit' ), 'postcode' => $customer->get_billing_postcode( 'edit' ), 'country' => $customer->get_billing_country( 'edit' ), 'date_registered' => $customer->get_date_created( 'edit' ) ? $customer->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ) : null, 'date_last_active' => $last_active ? gmdate( 'Y-m-d H:i:s', $last_active ) : null, ); $format = array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', ); $customer_id = self::get_customer_id_by_user_id( $user_id ); if ( $customer_id ) { // Preserve customer_id for existing user_id. $data['customer_id'] = $customer_id; $format[] = '%d'; } $results = $wpdb->replace( self::get_db_table_name(), $data, $format ); /** * Fires when customser's reports are updated. * * @param int $customer_id Customer ID. * @since 4.0.0 */ do_action( 'woocommerce_analytics_update_customer', $customer_id ); ReportsCache::invalidate(); return $results; } /** * Update the database if the "last active" meta value was changed. * Function expects to be hooked into the `added_user_meta` and `updated_user_meta` actions. * * @param int $meta_id ID of updated metadata entry. * @param int $user_id ID of the user being updated. * @param string $meta_key Meta key being updated. */ public static function update_registered_customer_via_last_active( $meta_id, $user_id, $meta_key ) { if ( 'wc_last_active' === $meta_key ) { self::update_registered_customer( $user_id ); } } /** * Check if a user ID is a valid customer or other user role with past orders. * * @param int $user_id User ID. * @return bool */ protected static function is_valid_customer( $user_id ) { $user = new \WP_User( $user_id ); if ( (int) $user_id !== $user->ID ) { return false; } /** * Filter the customer roles, used to check if the user is a customer. * * @param array List of customer roles. * @since 4.0.0 */ $customer_roles = (array) apply_filters( 'woocommerce_analytics_customer_roles', array( 'customer' ) ); if ( empty( $user->roles ) || empty( array_intersect( $user->roles, $customer_roles ) ) ) { return false; } return true; } /** * Delete a customer lookup row. * * @param int $customer_id Customer ID. */ public static function delete_customer( $customer_id ) { global $wpdb; $customer_id = (int) $customer_id; $num_deleted = $wpdb->delete( self::get_db_table_name(), array( 'customer_id' => $customer_id ) ); if ( $num_deleted ) { /** * Fires when a customer is deleted. * * @param int $order_id Order ID. * @since 4.0.0 */ do_action( 'woocommerce_analytics_delete_customer', $customer_id ); ReportsCache::invalidate(); } } /** * Delete a customer lookup row by WordPress User ID. * * @param int $user_id WordPress User ID. */ public static function delete_customer_by_user_id( $user_id ) { global $wpdb; if ( (int) $user_id < 1 || doing_action( 'wp_uninitialize_site' ) ) { // Skip the deletion. return; } $user_id = (int) $user_id; $num_deleted = $wpdb->delete( self::get_db_table_name(), array( 'user_id' => $user_id ) ); if ( $num_deleted ) { ReportsCache::invalidate(); } } /** * Anonymize the customer data for a single order. * * @internal * @param int|WC_Order $order Order instance or ID. * @return void */ public static function anonymize_customer( $order ) { global $wpdb; if ( ! is_object( $order ) ) { $order = wc_get_order( absint( $order ) ); } $customer_id = $wpdb->get_var( $wpdb->prepare( "SELECT customer_id FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d", $order->get_id() ) ); if ( ! $customer_id ) { return; } // Long form query because $wpdb->update rejects [deleted]. $deleted_text = __( '[deleted]', 'woocommerce' ); $updated = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}wc_customer_lookup SET user_id = NULL, username = %s, first_name = %s, last_name = %s, email = %s, country = '', postcode = %s, city = %s, state = %s WHERE customer_id = %d", array( $deleted_text, $deleted_text, $deleted_text, 'deleted@site.invalid', $deleted_text, $deleted_text, $deleted_text, $customer_id, ) ) ); // If the customer row was anonymized, flush the cache. if ( $updated ) { ReportsCache::invalidate(); } } /** * Build location filter SQL clause for includes or excludes. * * @since 10.5.0 * @param string $locations_string Comma-separated list of locations (e.g., "US:CA,US:NY,GB"). * @param bool $is_include True for IN clause, false for NOT IN clause. * @return string SQL WHERE clause condition. */ protected function build_location_filter_clause( $locations_string, $is_include = true ) { $customer_lookup_table = self::get_db_table_name(); $locations_array = explode( ',', $locations_string ); $country_state_pairs = array(); $countries = array(); foreach ( $locations_array as $location ) { $location = trim( $location ); if ( empty( $location ) ) { continue; } if ( false !== strpos( $location, ':' ) ) { $parts = explode( ':', $location ); if ( 2 === count( $parts ) ) { $country_state_pairs[] = array( 'country' => esc_sql( $parts[0] ), 'state' => esc_sql( $parts[1] ), ); } } else { $countries[] = esc_sql( $location ); } } $conditions = array(); // Build country:state pair conditions. if ( ! empty( $country_state_pairs ) ) { $pair_conditions = array(); foreach ( $country_state_pairs as $pair ) { if ( $is_include ) { $pair_conditions[] = "({$customer_lookup_table}.country = '{$pair['country']}' AND {$customer_lookup_table}.state = '{$pair['state']}')"; } else { $pair_conditions[] = "({$customer_lookup_table}.country != '{$pair['country']}' OR {$customer_lookup_table}.state != '{$pair['state']}')"; } } $pair_connector = $is_include ? ' OR ' : ' AND '; $conditions[] = '(' . implode( $pair_connector, $pair_conditions ) . ')'; } // Build country-only conditions. if ( ! empty( $countries ) ) { $operator = $is_include ? 'IN' : 'NOT IN'; $conditions[] = "{$customer_lookup_table}.country {$operator} ('" . implode( "','", $countries ) . "')"; } if ( empty( $conditions ) ) { return ''; } // Combine conditions with OR for includes, AND for excludes. $connector = $is_include ? ' OR ' : ' AND '; return '(' . implode( $connector, $conditions ) . ')'; } /** * Initialize query objects. */ protected function initialize_queries() { $this->clear_all_clauses(); $table_name = self::get_db_table_name(); $this->subquery = new SqlQuery( $this->context . '_subquery' ); $this->subquery->add_sql_clause( 'from', $table_name ); $this->subquery->add_sql_clause( 'select', "{$table_name}.customer_id" ); $this->subquery->add_sql_clause( 'group_by', "{$table_name}.customer_id" ); } } API/Reports/Customers/Query.php 0000777 00000002366 15252240713 0012434 0 ustar 00 <?php /** * Class for parameter-based Customers Report querying * * Example usage: * $args = array( * 'registered_before' => '2018-07-19 00:00:00', * 'registered_after' => '2018-07-05 00:00:00', * 'page' => 2, * 'avg_order_value_min' => 100, * 'country' => 'GB', * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Customers\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Customers; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; defined( 'ABSPATH' ) || exit; /** * API\Reports\Customers\Query */ class Query extends GenericQuery { /** * Specific query name. * Will be used to load the `report-{name}` data store, * and to call `woocommerce_analytics_{snake_case(name)}_*` filters. * * @var string */ protected $name = 'customers'; /** * Valid fields for Customers report. * * @return array */ protected function get_default_query_vars() { return array( 'per_page' => get_option( 'posts_per_page' ), // not sure if this should be the default. 'page' => 1, 'order' => 'DESC', 'orderby' => 'date_registered', 'fields' => '*', ); } } API/Reports/Customers/Controller.php 0000777 00000062223 15252240713 0013450 0 ustar 00 <?php /** * REST API Reports customers controller * * Handles requests to the /reports/customers endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Customers; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\ExportableTraits; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; /** * REST API Reports customers controller class. * * @internal * @extends GenericController */ class Controller extends GenericController implements ExportableInterface { /** * Exportable traits. */ use ExportableTraits; /** * Route base. * * @var string */ protected $rest_base = 'reports/customers'; /** * Get data from Customers\Query. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new Query( $query_args ); return $query->get_data(); } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['registered_before'] = $request['registered_before']; $args['registered_after'] = $request['registered_after']; $args['order_before'] = $request['before']; $args['order_after'] = $request['after']; $args['page'] = $request['page']; $args['per_page'] = $request['per_page']; $args['order'] = $request['order']; $args['orderby'] = $request['orderby']; $args['match'] = $request['match']; $args['search'] = $request['search']; $args['searchby'] = $request['searchby']; $args['name_includes'] = $request['name_includes']; $args['name_excludes'] = $request['name_excludes']; $args['username_includes'] = $request['username_includes']; $args['username_excludes'] = $request['username_excludes']; $args['email_includes'] = $request['email_includes']; $args['email_excludes'] = $request['email_excludes']; $args['country_includes'] = $request['country_includes']; $args['country_excludes'] = $request['country_excludes']; $args['last_active_before'] = $request['last_active_before']; $args['last_active_after'] = $request['last_active_after']; $args['orders_count_min'] = $request['orders_count_min']; $args['orders_count_max'] = $request['orders_count_max']; $args['total_spend_min'] = $request['total_spend_min']; $args['total_spend_max'] = $request['total_spend_max']; $args['avg_order_value_min'] = $request['avg_order_value_min']; $args['avg_order_value_max'] = $request['avg_order_value_max']; $args['last_order_before'] = $request['last_order_before']; $args['last_order_after'] = $request['last_order_after']; $args['customers'] = $request['customers']; $args['users'] = $request['users']; $args['force_cache_refresh'] = $request['force_cache_refresh']; $args['filter_empty'] = $request['filter_empty']; $args['user_type'] = $request['user_type']; $args['location_includes'] = $request['location_includes']; $args['location_excludes'] = $request['location_excludes']; $between_params_numeric = array( 'orders_count', 'total_spend', 'avg_order_value' ); $normalized_params_numeric = TimeInterval::normalize_between_params( $request, $between_params_numeric, false ); $between_params_date = array( 'last_active', 'registered' ); $normalized_params_date = TimeInterval::normalize_between_params( $request, $between_params_date, true ); $args = array_merge( $args, $normalized_params_numeric, $normalized_params_date ); return $args; } /** * Get one report. * * @param WP_REST_Request $request Request data. * @return array|WP_Error */ public function get_item( $request ) { $query_args = $this->prepare_reports_query( $request ); $query_args['customers'] = array( $request->get_param( 'id' ) ); $customers_query = new Query( $query_args ); $report_data = $customers_query->get_data(); $data = array(); foreach ( $report_data->data as $customer_data ) { $item = $this->prepare_item_for_response( $customer_data, $request ); $data[] = $this->prepare_response_for_collection( $item ); } $response = rest_ensure_response( $data ); $response->header( 'X-WP-Total', (int) $report_data->total ); $response->header( 'X-WP-TotalPages', (int) $report_data->pages ); return $response; } /** * Prepare a report data item for serialization. * * @param array $report Report data item as returned from Data Store. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $report, $request ); // Trim name field to prevent whitespace issues. $data['name'] = trim( $data['name'] ); // Registered date is UTC. $data['date_registered_gmt'] = wc_rest_prepare_date_response( $data['date_registered'] ); $data['date_registered'] = wc_rest_prepare_date_response( $data['date_registered'], false ); // Last active date is local time. $data['date_last_active_gmt'] = wc_rest_prepare_date_response( $data['date_last_active'], false ); $data['date_last_active'] = wc_rest_prepare_date_response( $data['date_last_active'] ); $data = $this->filter_response_by_context( $data, $context ); // Wrap the data in a response object. $response = rest_ensure_response( $data ); $response->add_links( $this->prepare_links( $report ) ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. * @since 4.0.0 */ return apply_filters( 'woocommerce_rest_prepare_report_customers', $response, $report, $request ); } /** * Prepare links for the request. * * @param array $object Object data. * @return array */ protected function prepare_links( $object ) { if ( empty( $object['user_id'] ) ) { return array(); } return array( 'customer' => array( 'href' => rest_url( sprintf( '/%s/customers/%d', $this->namespace, $object['id'] ) ), ), 'collection' => array( 'href' => rest_url( sprintf( '/%s/customers', $this->namespace ) ), ), ); } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_customers', 'type' => 'object', 'properties' => array( 'id' => array( 'description' => __( 'Customer ID.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'user_id' => array( 'description' => __( 'User ID.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'name' => array( 'description' => __( 'Name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'first_name' => array( 'description' => __( 'First name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'last_name' => array( 'description' => __( 'Last name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'email' => array( 'description' => __( 'Email address.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'username' => array( 'description' => __( 'Username.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'country' => array( 'description' => __( 'Country / Region.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'city' => array( 'description' => __( 'City.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'state' => array( 'description' => __( 'Region.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'postcode' => array( 'description' => __( 'Postal code.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_registered' => array( 'description' => __( 'Date registered.', 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_registered_gmt' => array( 'description' => __( 'Date registered GMT.', 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_last_active' => array( 'description' => __( 'Date last active.', 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_last_active_gmt' => array( 'description' => __( 'Date last active GMT.', 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'orders_count' => array( 'description' => __( 'Order count.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'total_spend' => array( 'description' => __( 'Total spend.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'avg_order_value' => array( 'description' => __( 'Avg order value.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['registered_before'] = array( 'description' => __( 'Limit response to objects registered before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['registered_after'] = array( 'description' => __( 'Limit response to objects registered after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['orderby']['default'] = 'date_registered'; $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'username', 'name', 'first_name', 'last_name', 'email', 'location', 'country', 'city', 'state', 'postcode', 'date_registered', 'date_last_active', 'orders_count', 'total_spend', 'avg_order_value', ) ); $params['match'] = array( 'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ), 'type' => 'string', 'default' => 'all', 'enum' => array( 'all', 'any', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['search'] = array( 'description' => __( 'Limit response to objects with a customer field containing the search term. Searches the field provided by `searchby`.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['searchby'] = array( 'description' => 'Limit results with `search` and `searchby` to specific fields containing the search term.', 'type' => 'string', 'default' => 'name', 'enum' => array( 'name', 'username', 'email', 'all', ), ); $params['name_includes'] = array( 'description' => __( 'Limit response to objects with specific names.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['name_excludes'] = array( 'description' => __( 'Limit response to objects excluding specific names.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['username_includes'] = array( 'description' => __( 'Limit response to objects with specific usernames.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['username_excludes'] = array( 'description' => __( 'Limit response to objects excluding specific usernames.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['email_includes'] = array( 'description' => __( 'Limit response to objects including emails.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['email_excludes'] = array( 'description' => __( 'Limit response to objects excluding emails.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['country_includes'] = array( 'description' => __( 'Limit response to objects with specific countries.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['country_excludes'] = array( 'description' => __( 'Limit response to objects excluding specific countries.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['last_active_before'] = array( 'description' => __( 'Limit response to objects last active before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['last_active_after'] = array( 'description' => __( 'Limit response to objects last active after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['last_active_between'] = array( 'description' => __( 'Limit response to objects last active between two given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_date_arg' ), 'items' => array( 'type' => 'string', ), ); $params['registered_before'] = array( 'description' => __( 'Limit response to objects registered before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['registered_after'] = array( 'description' => __( 'Limit response to objects registered after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['registered_between'] = array( 'description' => __( 'Limit response to objects last active between two given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_date_arg' ), 'items' => array( 'type' => 'string', ), ); $params['orders_count_min'] = array( 'description' => __( 'Limit response to objects with an order count greater than or equal to given integer.', 'woocommerce' ), 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ); $params['orders_count_max'] = array( 'description' => __( 'Limit response to objects with an order count less than or equal to given integer.', 'woocommerce' ), 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ); $params['orders_count_between'] = array( 'description' => __( 'Limit response to objects with an order count between two given integers.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ), 'items' => array( 'type' => 'integer', ), ); $params['total_spend_min'] = array( 'description' => __( 'Limit response to objects with a total order spend greater than or equal to given number.', 'woocommerce' ), 'type' => 'number', 'validate_callback' => 'rest_validate_request_arg', ); $params['total_spend_max'] = array( 'description' => __( 'Limit response to objects with a total order spend less than or equal to given number.', 'woocommerce' ), 'type' => 'number', 'validate_callback' => 'rest_validate_request_arg', ); $params['total_spend_between'] = array( 'description' => __( 'Limit response to objects with a total order spend between two given numbers.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ), 'items' => array( 'type' => 'integer', ), ); $params['avg_order_value_min'] = array( 'description' => __( 'Limit response to objects with an average order spend greater than or equal to given number.', 'woocommerce' ), 'type' => 'number', 'validate_callback' => 'rest_validate_request_arg', ); $params['avg_order_value_max'] = array( 'description' => __( 'Limit response to objects with an average order spend less than or equal to given number.', 'woocommerce' ), 'type' => 'number', 'validate_callback' => 'rest_validate_request_arg', ); $params['avg_order_value_between'] = array( 'description' => __( 'Limit response to objects with an average order spend between two given numbers.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ), 'items' => array( 'type' => 'integer', ), ); $params['last_order_before'] = array( 'description' => __( 'Limit response to objects with last order before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['last_order_after'] = array( 'description' => __( 'Limit response to objects with last order after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['customers'] = array( 'description' => __( 'Limit result to items with specified customer ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['users'] = array( 'description' => __( 'Limit result to items with specified user ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['filter_empty'] = array( 'description' => __( 'Filter out results where any of the passed fields are empty', 'woocommerce' ), 'type' => 'array', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', 'enum' => array( 'email', 'name', 'country', 'city', 'state', 'postcode', ), ), ); $params['user_type'] = array( 'description' => __( 'Limit result to items with specified user type.', 'woocommerce' ), 'type' => 'string', 'default' => 'all', 'validate_callback' => 'rest_validate_request_arg', 'enum' => array( 'all', 'registered', 'guest', ), ); $params['location_includes'] = array( 'description' => __( 'Includes customers by location (state, country). Provide a comma-separated list of locations. Each location can be a country code (e.g. GB) or combination of country and state (e.g. US:CA).', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['location_excludes'] = array( 'description' => __( 'Excludes customers by location (state, country). Provide a comma-separated list of locations. Each location can be a country code (e.g. GB) or combination of country and state (e.g. US:CA).', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the column names for export. * * @return array Key value pair of Column ID => Label. */ public function get_export_columns() { $export_columns = array( 'name' => __( 'Name', 'woocommerce' ), 'username' => __( 'Username', 'woocommerce' ), 'last_active' => __( 'Last Active', 'woocommerce' ), 'registered' => __( 'Sign Up', 'woocommerce' ), 'email' => __( 'Email', 'woocommerce' ), 'orders_count' => __( 'Orders', 'woocommerce' ), 'total_spend' => __( 'Total Spend', 'woocommerce' ), 'avg_order_value' => __( 'AOV', 'woocommerce' ), 'country' => __( 'Country / Region', 'woocommerce' ), 'city' => __( 'City', 'woocommerce' ), 'region' => __( 'Region', 'woocommerce' ), 'postcode' => __( 'Postal Code', 'woocommerce' ), ); /** * Filter to add or remove column names from the customers report for * export. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_customers_export_columns', $export_columns ); } /** * Get the column values for export. * * @param array $item Single report item/row. * @return array Key value pair of Column ID => Row Value. */ public function prepare_item_for_export( $item ) { $export_item = array( 'name' => $item['name'], 'username' => $item['username'], 'last_active' => $item['date_last_active'], 'registered' => $item['date_registered'], 'email' => $item['email'], 'orders_count' => $item['orders_count'], 'total_spend' => self::csv_number_format( $item['total_spend'] ), 'avg_order_value' => self::csv_number_format( $item['avg_order_value'] ), 'country' => $item['country'], 'city' => $item['city'], 'region' => $item['state'], 'postcode' => $item['postcode'], ); /** * Filter the column values of an item being exported. * * @param object $export_item Key value pair of Column ID => Row Value. * @param object $item Single report item/row. * @since 4.0.0 */ return apply_filters( 'woocommerce_report_customers_prepare_export_item', $export_item, $item ); } } API/Reports/Customers/Stats/Controller.php 0000777 00000040746 15252240713 0014554 0 ustar 00 <?php /** * REST API Reports customers stats controller * * Handles requests to the /reports/customers/stats endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Customers\Stats; use Automattic\WooCommerce\Admin\API\Reports\Customers\Query; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; /** * REST API Reports customers stats controller class. * * @internal * @extends WC_REST_Reports_Controller */ class Controller extends \WC_REST_Reports_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Route base. * * @var string */ protected $rest_base = 'reports/customers/stats'; /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['registered_before'] = $request['registered_before']; $args['registered_after'] = $request['registered_after']; $args['match'] = $request['match']; $args['search'] = $request['search']; $args['name_includes'] = $request['name_includes']; $args['name_excludes'] = $request['name_excludes']; $args['username_includes'] = $request['username_includes']; $args['username_excludes'] = $request['username_excludes']; $args['email_includes'] = $request['email_includes']; $args['email_excludes'] = $request['email_excludes']; $args['country_includes'] = $request['country_includes']; $args['country_excludes'] = $request['country_excludes']; $args['last_active_before'] = $request['last_active_before']; $args['last_active_after'] = $request['last_active_after']; $args['orders_count_min'] = $request['orders_count_min']; $args['orders_count_max'] = $request['orders_count_max']; $args['total_spend_min'] = $request['total_spend_min']; $args['total_spend_max'] = $request['total_spend_max']; $args['avg_order_value_min'] = $request['avg_order_value_min']; $args['avg_order_value_max'] = $request['avg_order_value_max']; $args['last_order_before'] = $request['last_order_before']; $args['last_order_after'] = $request['last_order_after']; $args['customers'] = $request['customers']; $args['fields'] = $request['fields']; $args['force_cache_refresh'] = $request['force_cache_refresh']; $between_params_numeric = array( 'orders_count', 'total_spend', 'avg_order_value' ); $normalized_params_numeric = TimeInterval::normalize_between_params( $request, $between_params_numeric, false ); $between_params_date = array( 'last_active', 'registered' ); $normalized_params_date = TimeInterval::normalize_between_params( $request, $between_params_date, true ); $args = array_merge( $args, $normalized_params_numeric, $normalized_params_date ); return $args; } /** * Get all reports. * * @param WP_REST_Request $request Request data. * @return array|WP_Error */ public function get_items( $request ) { $query_args = $this->prepare_reports_query( $request ); $customers_query = new Query( $query_args, 'customers-stats' ); $report_data = $customers_query->get_data(); $out_data = array( 'totals' => $report_data, ); return rest_ensure_response( $out_data ); } /** * Prepare a report data item for serialization. * * @param array $report Report data item as returned from Data Store. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $data = $report; $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); // Wrap the data in a response object. $response = rest_ensure_response( $data ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_customers_stats', $response, $report, $request ); } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { // @todo Should any of these be 'indicator's? $totals = array( 'customers_count' => array( 'description' => __( 'Number of customers.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'avg_orders_count' => array( 'description' => __( 'Average number of orders.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'avg_total_spend' => array( 'description' => __( 'Average total spend per customer.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'format' => 'currency', ), 'avg_avg_order_value' => array( 'description' => __( 'Average AOV per customer.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'format' => 'currency', ), ); $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_customers_stats', 'type' => 'object', 'properties' => array( 'totals' => array( 'description' => __( 'Totals data.', 'woocommerce' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'properties' => $totals, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = array(); $params['context'] = $this->get_context_param( array( 'default' => 'view' ) ); $params['registered_before'] = array( 'description' => __( 'Limit response to objects registered before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['registered_after'] = array( 'description' => __( 'Limit response to objects registered after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['match'] = array( 'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ), 'type' => 'string', 'default' => 'all', 'enum' => array( 'all', 'any', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['search'] = array( 'description' => __( 'Limit response to objects with a customer field containing the search term. Searches the field provided by `searchby`.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['searchby'] = array( 'description' => 'Limit results with `search` and `searchby` to specific fields containing the search term.', 'type' => 'string', 'default' => 'name', 'enum' => array( 'name', 'username', 'email', 'all', ), ); $params['name_includes'] = array( 'description' => __( 'Limit response to objects with specific names.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['name_excludes'] = array( 'description' => __( 'Limit response to objects excluding specific names.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['username_includes'] = array( 'description' => __( 'Limit response to objects with specific usernames.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['username_excludes'] = array( 'description' => __( 'Limit response to objects excluding specific usernames.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['email_includes'] = array( 'description' => __( 'Limit response to objects including emails.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['email_excludes'] = array( 'description' => __( 'Limit response to objects excluding emails.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['country_includes'] = array( 'description' => __( 'Limit response to objects with specific countries.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['country_excludes'] = array( 'description' => __( 'Limit response to objects excluding specific countries.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); $params['last_active_before'] = array( 'description' => __( 'Limit response to objects last active before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['last_active_after'] = array( 'description' => __( 'Limit response to objects last active after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['last_active_between'] = array( 'description' => __( 'Limit response to objects last active between two given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_date_arg' ), 'items' => array( 'type' => 'string', ), ); $params['registered_before'] = array( 'description' => __( 'Limit response to objects registered before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['registered_after'] = array( 'description' => __( 'Limit response to objects registered after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['registered_between'] = array( 'description' => __( 'Limit response to objects last active between two given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_date_arg' ), 'items' => array( 'type' => 'string', ), ); $params['orders_count_min'] = array( 'description' => __( 'Limit response to objects with an order count greater than or equal to given integer.', 'woocommerce' ), 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ); $params['orders_count_max'] = array( 'description' => __( 'Limit response to objects with an order count less than or equal to given integer.', 'woocommerce' ), 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ); $params['orders_count_between'] = array( 'description' => __( 'Limit response to objects with an order count between two given integers.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ), 'items' => array( 'type' => 'integer', ), ); $params['total_spend_min'] = array( 'description' => __( 'Limit response to objects with a total order spend greater than or equal to given number.', 'woocommerce' ), 'type' => 'number', 'validate_callback' => 'rest_validate_request_arg', ); $params['total_spend_max'] = array( 'description' => __( 'Limit response to objects with a total order spend less than or equal to given number.', 'woocommerce' ), 'type' => 'number', 'validate_callback' => 'rest_validate_request_arg', ); $params['total_spend_between'] = array( 'description' => __( 'Limit response to objects with a total order spend between two given numbers.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ), 'items' => array( 'type' => 'integer', ), ); $params['avg_order_value_min'] = array( 'description' => __( 'Limit response to objects with an average order spend greater than or equal to given number.', 'woocommerce' ), 'type' => 'number', 'validate_callback' => 'rest_validate_request_arg', ); $params['avg_order_value_max'] = array( 'description' => __( 'Limit response to objects with an average order spend less than or equal to given number.', 'woocommerce' ), 'type' => 'number', 'validate_callback' => 'rest_validate_request_arg', ); $params['avg_order_value_between'] = array( 'description' => __( 'Limit response to objects with an average order spend between two given numbers.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => array( '\Automattic\WooCommerce\Admin\API\Reports\TimeInterval', 'rest_validate_between_numeric_arg' ), 'items' => array( 'type' => 'integer', ), ); $params['last_order_before'] = array( 'description' => __( 'Limit response to objects with last order before (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['last_order_after'] = array( 'description' => __( 'Limit response to objects with last order after (or at) a given ISO8601 compliant datetime.', 'woocommerce' ), 'type' => 'string', 'format' => 'date-time', 'validate_callback' => 'rest_validate_request_arg', ); $params['customers'] = array( 'description' => __( 'Limit result to items with specified customer ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['fields'] = array( 'description' => __( 'Limit stats fields to the specified items.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', ), ); $params['force_cache_refresh'] = array( 'description' => __( 'Force retrieval of fresh data instead of from the cache.', 'woocommerce' ), 'type' => 'boolean', 'sanitize_callback' => 'wp_validate_boolean', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } } API/Reports/Customers/Stats/DataStore.php 0000777 00000007635 15252240713 0014317 0 ustar 00 <?php /** * API\Reports\Customers\Stats\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Customers\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; /** * API\Reports\Customers\Stats\DataStore. */ class DataStore extends CustomersDataStore implements DataStoreInterface { /** * Mapping columns to data type to return correct response types. * * @override CustomersDataStore::$column_types * * @var array */ protected $column_types = array( 'customers_count' => 'intval', 'avg_orders_count' => 'floatval', 'avg_total_spend' => 'floatval', 'avg_avg_order_value' => 'floatval', ); /** * Cache identifier. * * @override CustomersDataStore::$cache_key * * @var string */ protected $cache_key = 'customers_stats'; /** * Data store context used to pass to filters. * * @override CustomersDataStore::$context * * @var string */ protected $context = 'customers_stats'; /** * Assign report columns once full table name has been assigned. * * @override CustomersDataStore::assign_report_columns() */ protected function assign_report_columns() { $this->report_columns = array( 'customers_count' => 'COUNT( * ) as customers_count', 'avg_orders_count' => 'AVG( orders_count ) as avg_orders_count', 'avg_total_spend' => 'AVG( total_spend ) as avg_total_spend', 'avg_avg_order_value' => 'AVG( avg_order_value ) as avg_avg_order_value', ); } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override CustomersDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = ReportsDataStore::get_default_query_vars(); $defaults['orderby'] = 'date_registered'; // Do not set `order_before` and `order_after` here, like in the parent class. return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override CustomersDataStore::get_noncached_data() * * @see get_data * @param array $query_args Query parameters. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_data( $query_args ) { global $wpdb; $this->initialize_queries(); $data = (object) array( 'customers_count' => 0, 'avg_orders_count' => 0, 'avg_total_spend' => 0.0, 'avg_avg_order_value' => 0.0, ); $selections = $this->selected_columns( $query_args ); $this->add_sql_query_params( $query_args ); // Clear SQL clauses set for parent class queries that are different here. $this->subquery->clear_sql_clause( 'select' ); $this->subquery->add_sql_clause( 'select', 'SUM( total_sales ) AS total_spend,' ); $this->subquery->add_sql_clause( 'select', 'SUM( CASE WHEN parent_id = 0 THEN 1 END ) as orders_count,' ); $this->subquery->add_sql_clause( 'select', 'CASE WHEN SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END ) = 0 THEN NULL ELSE SUM( total_sales ) / SUM( CASE WHEN parent_id = 0 THEN 1 ELSE 0 END ) END AS avg_order_value' ); $this->clear_sql_clause( array( 'order_by', 'limit' ) ); $this->add_sql_clause( 'select', $selections ); $this->add_sql_clause( 'from', "({$this->subquery->get_query_statement()}) AS tt" ); $report_data = $wpdb->get_results( $this->get_query_statement(), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ARRAY_A ); if ( null === $report_data ) { return $data; } $data = (object) $this->cast_numbers( $report_data[0] ); return $data; } } API/Reports/Customers/Stats/Query.php 0000777 00000004654 15252240713 0013534 0 ustar 00 <?php /** * Class for parameter-based Customers Report Stats querying * * Example usage: * $args = array( * 'registered_before' => '2018-07-19 00:00:00', * 'registered_after' => '2018-07-05 00:00:00', * 'page' => 2, * 'avg_order_value_min' => 100, * 'country' => 'GB', * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Customers\Stats\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Customers\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Customers\Stats\Query * * @deprecated 9.3.0 Customers\Stats\Query class is deprecated, please use `Reports\Customers\Query` with a custom name, `GenericQuery`, `\WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Customers report. * * @deprecated 9.3.0 Customers\Stats\Query class is deprecated, please use `Reports\Customers\Query` with a custom name, `GenericQuery`, `\WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`Reports\Customers\Query` with a custom name, `GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array( 'per_page' => get_option( 'posts_per_page' ), // not sure if this should be the default. 'page' => 1, 'order' => 'DESC', 'orderby' => 'date_registered', 'fields' => '*', // @todo Needed? ); } /** * Get product data based on the current query vars. * * @deprecated 9.3.0 Customers\Stats\Query class is deprecated, please use `Reports\Customers\Query` with a custom name, `GenericQuery`, `\WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, 'x.x.x', '`Reports\Customers\Query` with a custom name, `GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_customers_stats_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-customers-stats' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_customers_stats_select_query', $results, $args ); } } API/Reports/Import/Controller.php 0000777 00000021110 15252240713 0012724 0 ustar 00 <?php /** * REST API Reports Import Controller * * Handles requests to /reports/import */ namespace Automattic\WooCommerce\Admin\API\Reports\Import; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\ReportsSync; /** * Reports Imports controller. * * @internal * @extends \Automattic\WooCommerce\Admin\API\Reports\Controller */ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { /** * Route base. * * @var string */ protected $rest_base = 'reports/import'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'import_items' ), 'permission_callback' => array( $this, 'import_permissions_check' ), 'args' => $this->get_import_collection_params(), ), 'schema' => array( $this, 'get_import_public_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/cancel', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'cancel_import' ), 'permission_callback' => array( $this, 'import_permissions_check' ), ), 'schema' => array( $this, 'get_import_public_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/delete', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'delete_imported_items' ), 'permission_callback' => array( $this, 'import_permissions_check' ), ), 'schema' => array( $this, 'get_import_public_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/status', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_import_status' ), 'permission_callback' => array( $this, 'import_permissions_check' ), ), 'schema' => array( $this, 'get_import_public_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/totals', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_import_totals' ), 'permission_callback' => array( $this, 'import_permissions_check' ), 'args' => $this->get_import_collection_params(), ), 'schema' => array( $this, 'get_import_public_schema' ), ) ); } /** * Makes sure the current user has access to WRITE the settings APIs. * * @param WP_REST_Request $request Full data about the request. * @return WP_Error|bool */ public function import_permissions_check( $request ) { if ( ! wc_rest_check_manager_permissions( 'settings', 'edit' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_edit', __( 'Sorry, you cannot edit this resource.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Import data based on user request params. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function import_items( $request ) { $query_args = $this->prepare_objects_query( $request ); $import = ReportsSync::regenerate_report_data( $query_args['days'], $query_args['skip_existing'] ); if ( is_wp_error( $import ) ) { $result = array( 'status' => 'error', 'message' => $import->get_error_message(), ); } else { $result = array( 'status' => 'success', 'message' => $import, ); } $response = $this->prepare_item_for_response( $result, $request ); $data = $this->prepare_response_for_collection( $response ); return rest_ensure_response( $data ); } /** * Prepare request object as query args. * * @param WP_REST_Request $request Request data. * @return array */ protected function prepare_objects_query( $request ) { $args = array(); $args['skip_existing'] = $request['skip_existing']; $args['days'] = $request['days']; return $args; } /** * Prepare the data object for response. * * @param object $item Data object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response $response Response data. */ public function prepare_item_for_response( $item, $request ) { $data = $this->add_additional_fields_to_object( $item, $request ); $data = $this->filter_response_by_context( $data, 'view' ); $response = rest_ensure_response( $data ); /** * Filter the list returned from the API. * * @param WP_REST_Response $response The response object. * @param array $item The original item. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_reports_import', $response, $item, $request ); } /** * Get the query params for collections. * * @return array */ public function get_import_collection_params() { $params = array(); $params['days'] = array( 'description' => __( 'Number of days to import.', 'woocommerce' ), 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', 'minimum' => 0, ); $params['skip_existing'] = array( 'description' => __( 'Skip importing existing order data.', 'woocommerce' ), 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'wc_string_to_bool', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_import_public_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_import', 'type' => 'object', 'properties' => array( 'status' => array( 'description' => __( 'Regeneration status.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'message' => array( 'description' => __( 'Regenerate data message.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Cancel all queued import actions. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function cancel_import( $request ) { ReportsSync::clear_queued_actions(); $result = array( 'status' => 'success', 'message' => __( 'All pending and in-progress import actions have been cancelled.', 'woocommerce' ), ); $response = $this->prepare_item_for_response( $result, $request ); $data = $this->prepare_response_for_collection( $response ); return rest_ensure_response( $data ); } /** * Delete all imported items. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function delete_imported_items( $request ) { $delete = ReportsSync::delete_report_data(); if ( is_wp_error( $delete ) ) { $result = array( 'status' => 'error', 'message' => $delete->get_error_message(), ); } else { $result = array( 'status' => 'success', 'message' => $delete, ); } $response = $this->prepare_item_for_response( $result, $request ); $data = $this->prepare_response_for_collection( $response ); return rest_ensure_response( $data ); } /** * Get the status of the current import. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function get_import_status( $request ) { $result = ReportsSync::get_import_stats(); $response = $this->prepare_item_for_response( $result, $request ); $data = $this->prepare_response_for_collection( $response ); return rest_ensure_response( $data ); } /** * Get the total orders and customers based on user supplied params. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function get_import_totals( $request ) { $query_args = $this->prepare_objects_query( $request ); $totals = ReportsSync::get_import_totals( $query_args['days'], $query_args['skip_existing'] ); $response = $this->prepare_item_for_response( $totals, $request ); $data = $this->prepare_response_for_collection( $response ); return rest_ensure_response( $data ); } } API/Reports/Export/Controller.php 0000777 00000015431 15252240713 0012744 0 ustar 00 <?php /** * REST API Reports Export Controller * * Handles requests to: * - /reports/[report]/export * - /reports/[report]/export/[id]/status */ namespace Automattic\WooCommerce\Admin\API\Reports\Export; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\ReportExporter; /** * Reports Export controller. * * @internal * @extends \Automattic\WooCommerce\Admin\API\Reports\Controller */ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller { /** * Route base. * * @var string */ protected $rest_base = 'reports/(?P<type>[a-z]+)/export'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'export_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_export_collection_params(), ), 'schema' => array( $this, 'get_export_public_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<export_id>[a-z0-9]+)/status', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'export_status' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_export_status_public_schema' ), ) ); } /** * Get the query params for collections. * * @return array */ protected function get_export_collection_params() { $params = array(); $params['report_args'] = array( 'description' => __( 'Parameters to pass on to the exported report.', 'woocommerce' ), 'type' => 'object', 'validate_callback' => 'rest_validate_request_arg', // @todo: use each controller's schema? ); $params['email'] = array( 'description' => __( 'When true, email a link to download the export to the requesting user.', 'woocommerce' ), 'type' => 'boolean', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the Report Export's schema, conforming to JSON Schema. * * @return array */ public function get_export_public_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_export', 'type' => 'object', 'properties' => array( 'status' => array( 'description' => __( 'Export status.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'message' => array( 'description' => __( 'Export status message.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'export_id' => array( 'description' => __( 'Export ID.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the Export status schema, conforming to JSON Schema. * * @return array */ public function get_export_status_public_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_export_status', 'type' => 'object', 'properties' => array( 'percent_complete' => array( 'description' => __( 'Percentage complete.', 'woocommerce' ), 'type' => 'int', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'download_url' => array( 'description' => __( 'Export download URL.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Export data based on user request params. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function export_items( $request ) { $report_type = $request['type']; $report_args = empty( $request['report_args'] ) ? array() : $request['report_args']; $send_email = isset( $request['email'] ) ? $request['email'] : false; $default_export_id = str_replace( '.', '', microtime( true ) ); $export_id = apply_filters( 'woocommerce_admin_export_id', $default_export_id ); $export_id = (string) sanitize_file_name( $export_id ); $total_rows = ReportExporter::queue_report_export( $export_id, $report_type, $report_args, $send_email ); if ( 0 === $total_rows ) { return rest_ensure_response( array( 'message' => __( 'There is no data to export for the given request.', 'woocommerce' ), ) ); } ReportExporter::update_export_percentage_complete( $report_type, $export_id, 0 ); $response = rest_ensure_response( array( 'message' => __( 'Your report file is being generated.', 'woocommerce' ), 'export_id' => $export_id, ) ); // Include a link to the export status endpoint. $response->add_links( array( 'status' => array( 'href' => rest_url( sprintf( '%s/reports/%s/export/%s/status', $this->namespace, $report_type, $export_id ) ), ), ) ); $data = $this->prepare_response_for_collection( $response ); return rest_ensure_response( $data ); } /** * Export status based on user request params. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function export_status( $request ) { $report_type = $request['type']; $export_id = $request['export_id']; $percentage = ReportExporter::get_export_percentage_complete( $report_type, $export_id ); if ( false === $percentage ) { return new \WP_Error( 'woocommerce_admin_reports_export_invalid_id', __( 'Sorry, there is no export with that ID.', 'woocommerce' ), array( 'status' => 404 ) ); } $result = array( 'percent_complete' => $percentage, ); // @todo - add thing in the links below instead? if ( 100 === $percentage ) { $query_args = array( 'action' => ReportExporter::DOWNLOAD_EXPORT_ACTION, 'filename' => "wc-{$report_type}-report-export-{$export_id}", ); $result['download_url'] = add_query_arg( $query_args, admin_url() ); } // Wrap the data in a response object. $response = rest_ensure_response( $result ); // Include a link to the export status endpoint. $response->add_links( array( 'self' => array( 'href' => rest_url( sprintf( '%s/reports/%s/export/%s/status', $this->namespace, $report_type, $export_id ) ), ), ) ); $data = $this->prepare_response_for_collection( $response ); return rest_ensure_response( $data ); } } API/Reports/Revenue/Stats/Controller.php 0000777 00000022224 15252240713 0014170 0 ustar 00 <?php /** * REST API Reports revenue stats controller * * Handles requests to the /reports/revenue/stats endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Revenue\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController; use Automattic\WooCommerce\Admin\API\Reports\Revenue\Query as RevenueQuery; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Admin\API\Reports\ExportableTraits; use WP_REST_Request; use WP_REST_Response; /** * REST API Reports revenue stats controller class. * * @internal * @extends GenericStatsController */ class Controller extends GenericStatsController implements ExportableInterface { /** * Exportable traits. */ use ExportableTraits; /** * Route base. * * @var string */ protected $rest_base = 'reports/revenue/stats'; /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['before'] = $request['before']; $args['after'] = $request['after']; $args['interval'] = $request['interval']; $args['page'] = $request['page']; $args['per_page'] = $request['per_page']; $args['orderby'] = $request['orderby']; $args['order'] = $request['order']; $args['segmentby'] = $request['segmentby']; $args['fields'] = $request['fields']; $args['force_cache_refresh'] = $request['force_cache_refresh']; $args['date_type'] = $request['date_type']; return $args; } /** * Get data from RevenueQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new RevenueQuery( $query_args ); return $query->get_data(); } /** * Get report items for export. * * Returns only the interval data. * * @param WP_REST_Request $request Request data. * @return WP_REST_Response */ public function get_export_items( $request ) { $response = $this->get_items( $request ); $data = $response->get_data(); $intervals = $data['intervals']; $response->set_data( $intervals ); return $response; } /** * Prepare a report data item for serialization. * * @param array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $response = parent::prepare_item_for_response( $report, $request ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_revenue_stats', $response, $report, $request ); } /** * Get the Report's item properties schema. * Will be used by `get_item_schema` as `totals` and `subtotals`. * * @return array */ protected function get_item_properties_schema() { return array( 'total_sales' => array( 'description' => __( 'Total sales.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, 'format' => 'currency', ), 'net_revenue' => array( 'description' => __( 'Net sales.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, 'format' => 'currency', ), 'coupons' => array( 'description' => __( 'Amount discounted by coupons.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'coupons_count' => array( 'description' => __( 'Unique coupons count.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'format' => 'currency', ), 'shipping' => array( 'title' => __( 'Shipping', 'woocommerce' ), 'description' => __( 'Total of shipping.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, 'format' => 'currency', ), 'taxes' => array( 'description' => __( 'Total of taxes.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'format' => 'currency', ), 'refunds' => array( 'title' => __( 'Returns', 'woocommerce' ), 'description' => __( 'Total of returns.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, 'format' => 'currency', ), 'orders_count' => array( 'description' => __( 'Number of orders.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'num_items_sold' => array( 'description' => __( 'Items sold.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'gross_sales' => array( 'description' => __( 'Gross sales.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, 'format' => 'currency', ), ); } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = parent::get_item_schema(); $schema['title'] = 'report_revenue_stats'; // Products is not shown in intervals, only in totals. $schema['properties']['totals']['properties']['products'] = array( 'description' => __( 'Products sold.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'date', 'total_sales', 'coupons', 'refunds', 'shipping', 'taxes', 'net_revenue', 'orders_count', 'items_sold', 'gross_sales', ) ); $params['segmentby'] = array( 'description' => __( 'Segment the response by additional constraint.', 'woocommerce' ), 'type' => 'string', 'enum' => array( 'product', 'category', 'variation', 'coupon', 'customer_type', // new vs returning. ), 'validate_callback' => 'rest_validate_request_arg', ); $params['date_type'] = array( 'description' => __( 'Override the "woocommerce_date_type" option that is used for the database date field considered for revenue reports.', 'woocommerce' ), 'type' => 'string', 'enum' => array( 'date_paid', 'date_created', 'date_completed', ), 'validate_callback' => 'rest_validate_request_arg', ); unset( $params['fields'] ); return $params; } /** * Get the column names for export. * * @return array Key value pair of Column ID => Label. */ public function get_export_columns() { return array( 'date' => __( 'Date', 'woocommerce' ), 'orders_count' => __( 'Orders', 'woocommerce' ), 'gross_sales' => __( 'Gross sales', 'woocommerce' ), 'refunds' => __( 'Returns', 'woocommerce' ), 'coupons' => __( 'Coupons', 'woocommerce' ), 'net_revenue' => __( 'Net sales', 'woocommerce' ), 'taxes' => __( 'Taxes', 'woocommerce' ), 'shipping' => __( 'Shipping', 'woocommerce' ), 'total_sales' => __( 'Total sales', 'woocommerce' ), ); } /** * Get the column values for export. * * @param array $item Single report item/row. * @return array Key value pair of Column ID => Row Value. */ public function prepare_item_for_export( $item ) { $subtotals = (array) $item['subtotals']; return array( 'date' => $item['date_start'], 'orders_count' => $subtotals['orders_count'], 'gross_sales' => self::csv_number_format( $subtotals['gross_sales'] ), 'refunds' => self::csv_number_format( $subtotals['refunds'] ), 'coupons' => self::csv_number_format( $subtotals['coupons'] ), 'net_revenue' => self::csv_number_format( $subtotals['net_revenue'] ), 'taxes' => self::csv_number_format( $subtotals['taxes'] ), 'shipping' => self::csv_number_format( $subtotals['shipping'] ), 'total_sales' => self::csv_number_format( $subtotals['total_sales'] ), ); } } API/Reports/Revenue/Query.php 0000777 00000003277 15252240713 0012063 0 ustar 00 <?php /** * Class for parameter-based Revenue Reports querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'interval' => 'week', * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Revenue\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Revenue; defined( 'ABSPATH' ) || exit; /** * API\Reports\Revenue\Query * * This query uses inconsistent names: * - `report-revenue-stats` data store * - `woocommerce_analytics_revenue_*` filters * So, for backward compatibility, we cannot use GenericQuery. */ class Query extends \WC_Object_Query { /** * Valid fields for Revenue report. * * @return array */ protected function get_default_query_vars() { return array( 'per_page' => get_option( 'posts_per_page' ), // not sure if this should be the default. 'page' => 1, 'order' => 'DESC', 'orderby' => 'date', 'before' => '', 'after' => '', 'interval' => 'week', 'fields' => array( 'orders_count', 'num_items_sold', 'total_sales', 'coupons', 'coupons_count', 'refunds', 'taxes', 'shipping', 'net_revenue', 'gross_sales', ), ); } /** * Get revenue data based on the current query vars. * * @return array */ public function get_data() { $args = apply_filters( 'woocommerce_analytics_revenue_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-revenue-stats' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_revenue_select_query', $results, $args ); } } API/Reports/Orders/Controller.php 0000777 00000043132 15252240713 0012720 0 ustar 00 <?php /** * REST API Reports orders controller * * Handles requests to the /reports/orders endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Orders; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\OrderAwareControllerTrait; /** * REST API Reports orders controller class. * * @internal * @extends \Automattic\WooCommerce\Admin\API\Reports\GenericController */ class Controller extends GenericController implements ExportableInterface { use OrderAwareControllerTrait; /** * Route base. * * @var string */ protected $rest_base = 'reports/orders'; /** * Get data from Orders\Query. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new Query( $query_args ); return $query->get_data(); } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['before'] = $request['before']; $args['after'] = $request['after']; $args['page'] = $request['page']; $args['per_page'] = $request['per_page']; $args['orderby'] = $request['orderby']; $args['order'] = $request['order']; $args['product_includes'] = (array) $request['product_includes']; $args['product_excludes'] = (array) $request['product_excludes']; $args['variation_includes'] = (array) $request['variation_includes']; $args['variation_excludes'] = (array) $request['variation_excludes']; $args['coupon_includes'] = (array) $request['coupon_includes']; $args['coupon_excludes'] = (array) $request['coupon_excludes']; $args['tax_rate_includes'] = (array) $request['tax_rate_includes']; $args['tax_rate_excludes'] = (array) $request['tax_rate_excludes']; $args['status_is'] = (array) $request['status_is']; $args['status_is_not'] = (array) $request['status_is_not']; $args['customer_type'] = $request['customer_type']; $args['extended_info'] = $request['extended_info']; $args['refunds'] = $request['refunds']; $args['match'] = $request['match']; $args['order_includes'] = $request['order_includes']; $args['order_excludes'] = $request['order_excludes']; $args['attribute_is'] = (array) $request['attribute_is']; $args['attribute_is_not'] = (array) $request['attribute_is_not']; $args['force_cache_refresh'] = $request['force_cache_refresh']; return $args; } /** * Prepare a report data item for serialization. * * @param array $report Report data item as returned from Data Store. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $report['order_number'] = $this->get_order_number( $report['order_id'] ); $report['total_formatted'] = $this->get_total_formatted( $report['order_id'] ); // Wrap the data in a response object. $response = parent::prepare_item_for_response( $report, $request ); $response->add_links( $this->prepare_links( $report ) ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_orders', $response, $report, $request ); } /** * Prepare links for the request. * * @param WC_Reports_Query $object Object data. * @return array */ protected function prepare_links( $object ) { $links = array( 'order' => array( 'href' => rest_url( sprintf( '/%s/orders/%d', $this->namespace, $object['order_id'] ) ), ), ); return $links; } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_orders', 'type' => 'object', 'properties' => array( 'order_id' => array( 'description' => __( 'Order ID.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'order_number' => array( 'description' => __( 'Order Number.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_created' => array( 'description' => __( "Date the order was created, in the site's timezone.", 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_created_gmt' => array( 'description' => __( 'Date the order was created, as GMT.', 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'status' => array( 'description' => __( 'Order status.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'customer_id' => array( 'description' => __( 'Customer ID.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'num_items_sold' => array( 'description' => __( 'Number of items sold.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'net_total' => array( 'description' => __( 'Net total revenue.', 'woocommerce' ), 'type' => 'float', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'total_formatted' => array( 'description' => __( 'Net total revenue (formatted).', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'customer_type' => array( 'description' => __( 'Returning or new customer.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'extended_info' => array( 'products' => array( 'type' => 'array', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'List of order product IDs, names, quantities.', 'woocommerce' ), ), 'coupons' => array( 'type' => 'array', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'List of order coupons.', 'woocommerce' ), ), 'customer' => array( 'type' => 'object', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Order customer information.', 'woocommerce' ), ), 'attribution' => array( 'type' => 'object', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Order attribution information.', 'woocommerce' ), ), ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['per_page']['minimum'] = 0; $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'date', 'num_items_sold', 'net_total', ) ); $params['product_includes'] = array( 'description' => __( 'Limit result set to items that have the specified product(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); $params['product_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified product(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'wp_parse_id_list', ); $params['variation_includes'] = array( 'description' => __( 'Limit result set to items that have the specified variation(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); $params['variation_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified variation(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'wp_parse_id_list', ); $params['coupon_includes'] = array( 'description' => __( 'Limit result set to items that have the specified coupon(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); $params['coupon_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified coupon(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'wp_parse_id_list', ); $params['tax_rate_includes'] = array( 'description' => __( 'Limit result set to items that have the specified tax rate(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); $params['tax_rate_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified tax rate(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'wp_parse_id_list', ); $params['status_is'] = array( 'description' => __( 'Limit result set to items that have the specified order status.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'enum' => self::get_order_statuses(), 'type' => 'string', ), ); $params['status_is_not'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified order status.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'enum' => self::get_order_statuses(), 'type' => 'string', ), ); $params['customer_type'] = array( 'description' => __( 'Limit result set to returning or new customers.', 'woocommerce' ), 'type' => 'string', 'default' => '', 'enum' => array( '', 'returning', 'new', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['refunds'] = array( 'description' => __( 'Limit result set to specific types of refunds.', 'woocommerce' ), 'type' => 'string', 'default' => '', 'enum' => array( '', 'all', 'partial', 'full', 'none', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['extended_info'] = array( 'description' => __( 'Add additional piece of info about each coupon to the report.', 'woocommerce' ), 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'wc_string_to_bool', 'validate_callback' => 'rest_validate_request_arg', ); $params['order_includes'] = array( 'description' => __( 'Limit result set to items that have the specified order ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['order_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified order ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['attribute_is'] = array( 'description' => __( 'Limit result set to orders that include products with the specified attributes.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'array', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); $params['attribute_is_not'] = array( 'description' => __( 'Limit result set to orders that don\'t include products with the specified attributes.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'array', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get customer name column export value. * * @param array $customer Customer from report row. * @return string */ protected function get_customer_name( $customer ) { return $customer['first_name'] . ' ' . $customer['last_name']; } /** * Get products column export value. * * @param array $products Products from report row. * @return string */ protected function get_products( $products ) { $products_list = array(); foreach ( $products as $product ) { $products_list[] = sprintf( /* translators: 1: numeric product quantity, 2: name of product */ __( '%1$s× %2$s', 'woocommerce' ), $product['quantity'], $product['name'] ); } return implode( ', ', $products_list ); } /** * Get coupons column export value. * * @param array $coupons Coupons from report row. * @return string */ protected function get_coupons( $coupons ) { return implode( ', ', wp_list_pluck( $coupons, 'code' ) ); } /** * Get the column names for export. * * @return array Key value pair of Column ID => Label. */ public function get_export_columns() { $export_columns = array( 'date_created' => __( 'Date', 'woocommerce' ), 'order_number' => __( 'Order #', 'woocommerce' ), 'total_formatted' => __( 'N. Revenue (formatted)', 'woocommerce' ), 'status' => __( 'Status', 'woocommerce' ), 'customer_name' => __( 'Customer', 'woocommerce' ), 'customer_type' => __( 'Customer type', 'woocommerce' ), 'products' => __( 'Product(s)', 'woocommerce' ), 'num_items_sold' => __( 'Items sold', 'woocommerce' ), 'coupons' => __( 'Coupon(s)', 'woocommerce' ), 'net_total' => __( 'Net Sales', 'woocommerce' ), 'attribution' => __( 'Attribution', 'woocommerce' ), ); /** * Filter to add or remove column names from the orders report for * export. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_orders_export_columns', $export_columns ); } /** * Get the column values for export. * * @param array $item Single report item/row. * @return array Key value pair of Column ID => Row Value. */ public function prepare_item_for_export( $item ) { $export_item = array( 'date_created' => $item['date'], 'order_number' => $item['order_number'], 'total_formatted' => $item['total_formatted'], 'status' => $item['status'], 'customer_name' => isset( $item['extended_info']['customer'] ) ? $this->get_customer_name( $item['extended_info']['customer'] ) : null, 'customer_type' => $item['customer_type'], 'products' => isset( $item['extended_info']['products'] ) ? $this->get_products( $item['extended_info']['products'] ) : null, 'num_items_sold' => $item['num_items_sold'], 'coupons' => isset( $item['extended_info']['coupons'] ) ? $this->get_coupons( $item['extended_info']['coupons'] ) : null, 'net_total' => $item['net_total'], 'attribution' => $item['extended_info']['attribution']['origin'], ); /** * Filter to prepare extra columns in the export item for the orders * report. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_orders_prepare_export_item', $export_item, $item ); } } API/Reports/Orders/Stats/DataStore.php 0000777 00000072546 15252240713 0013574 0 ustar 00 <?php /** * API\Reports\Orders\Stats\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Orders\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Internal\Fulfillments\FulfillmentUtils; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache; use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore; use Automattic\WooCommerce\Utilities\OrderUtil; use Automattic\WooCommerce\Admin\API\Reports\StatsDataStoreTrait; use Automattic\WooCommerce\Utilities\FeaturesUtil; use WC_Order; /** * API\Reports\Orders\Stats\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { use StatsDataStoreTrait; /** * Option name to store whether the wc_order_stats table has a column `fulfillment_status` * * @var string */ const OPTION_ORDER_STATS_TABLE_HAS_COLUMN_ORDER_FULFILLMENT_STATUS = 'woocommerce_order_stats_has_fulfillment_column'; /** * Table used to get the data. * * @override ReportsDataStore::$table_name * * @var string */ protected static $table_name = 'wc_order_stats'; /** * Cron event name. */ const CRON_EVENT = 'wc_order_stats_update'; /** * Cache identifier. * * @override ReportsDataStore::$cache_key * * @var string */ protected $cache_key = 'orders_stats'; /** * Type for each column to cast values correctly later. * * @override ReportsDataStore::$column_types * * @var array */ protected $column_types = array( 'orders_count' => 'intval', 'num_items_sold' => 'intval', 'gross_sales' => 'floatval', 'total_sales' => 'floatval', 'coupons' => 'floatval', 'coupons_count' => 'intval', 'refunds' => 'floatval', 'taxes' => 'floatval', 'shipping' => 'floatval', 'net_revenue' => 'floatval', 'avg_items_per_order' => 'floatval', 'avg_order_value' => 'floatval', 'total_customers' => 'intval', 'products' => 'intval', 'segment_id' => 'intval', ); /** * Data store context used to pass to filters. * * @override ReportsDataStore::$context * * @var string */ protected $context = 'orders_stats'; /** * Dynamically sets the date column name based on configuration * * @override ReportsDataStore::__construct() */ public function __construct() { $this->date_column_name = get_option( 'woocommerce_date_type', 'date_paid' ); parent::__construct(); } /** * Assign report columns once full table name has been assigned. * * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); // Avoid ambiguous columns in SQL query. $refunds = "ABS( SUM( CASE WHEN {$table_name}.net_total < 0 THEN {$table_name}.net_total + {$table_name}.tax_total + {$table_name}.shipping_total ELSE 0 END ) )"; if ( ! OrderUtil::uses_new_full_refund_data() ) { $refunds = "ABS( SUM( CASE WHEN {$table_name}.net_total < 0 THEN {$table_name}.net_total ELSE 0 END ) )"; } $gross_sale_sum = "{$table_name}.total_sales - {$table_name}.tax_total - {$table_name}.shipping_total"; $gross_sales = "SUM( CASE WHEN {$table_name}.parent_id = 0 THEN {$gross_sale_sum} ELSE 0 END ) + COALESCE( SUM(discount_amount), 0 ) as gross_sales"; $this->report_columns = array( 'orders_count' => "SUM( CASE WHEN {$table_name}.parent_id = 0 THEN 1 ELSE 0 END ) as orders_count", 'num_items_sold' => "SUM({$table_name}.num_items_sold) as num_items_sold", 'gross_sales' => $gross_sales, 'total_sales' => "SUM({$table_name}.total_sales) AS total_sales", 'coupons' => 'COALESCE( SUM(discount_amount), 0 ) AS coupons', // SUM() all nulls gives null. 'coupons_count' => 'COALESCE( coupons_count, 0 ) as coupons_count', 'refunds' => "{$refunds} AS refunds", 'taxes' => "SUM({$table_name}.tax_total) AS taxes", 'shipping' => "SUM({$table_name}.shipping_total) AS shipping", 'net_revenue' => "SUM({$table_name}.net_total) AS net_revenue", 'avg_items_per_order' => "SUM( CASE WHEN {$table_name}.parent_id = 0 THEN {$table_name}.num_items_sold ELSE 0 END ) / SUM( CASE WHEN {$table_name}.parent_id = 0 THEN 1 ELSE 0 END ) AS avg_items_per_order", 'avg_order_value' => "SUM( CASE WHEN {$table_name}.parent_id = 0 THEN {$table_name}.net_total ELSE 0 END ) / SUM( CASE WHEN {$table_name}.parent_id = 0 THEN 1 ELSE 0 END ) AS avg_order_value", 'total_customers' => "COUNT( DISTINCT( {$table_name}.customer_id ) ) as total_customers", ); } /** * Set up all the hooks for maintaining and populating table data. */ public static function init() { add_action( 'woocommerce_before_delete_order', array( __CLASS__, 'delete_order' ) ); add_action( 'delete_post', array( __CLASS__, 'delete_order' ) ); } /** * Updates the totals and intervals database queries with parameters used for Orders report: categories, coupons and order status. * * @param array $query_args Query arguments supplied by the user. */ protected function orders_stats_sql_filter( $query_args ) { // phpcs:ignore Generic.Commenting.Todo.TaskFound // @todo Performance of all of this? global $wpdb; $from_clause = ''; $orders_stats_table = self::get_db_table_name(); $product_lookup = $wpdb->prefix . 'wc_order_product_lookup'; $coupon_lookup = $wpdb->prefix . 'wc_order_coupon_lookup'; $tax_rate_lookup = $wpdb->prefix . 'wc_order_tax_lookup'; $operator = $this->get_match_operator( $query_args ); $where_filters = array(); // Products filters. $where_filters[] = $this->get_object_where_filter( $orders_stats_table, 'order_id', $product_lookup, 'product_id', 'IN', $this->get_included_products( $query_args ) ); $where_filters[] = $this->get_object_where_filter( $orders_stats_table, 'order_id', $product_lookup, 'product_id', 'NOT IN', $this->get_excluded_products( $query_args ) ); // Variations filters. $where_filters[] = $this->get_object_where_filter( $orders_stats_table, 'order_id', $product_lookup, 'variation_id', 'IN', $this->get_included_variations( $query_args ) ); $where_filters[] = $this->get_object_where_filter( $orders_stats_table, 'order_id', $product_lookup, 'variation_id', 'NOT IN', $this->get_excluded_variations( $query_args ) ); // Coupons filters. $where_filters[] = $this->get_object_where_filter( $orders_stats_table, 'order_id', $coupon_lookup, 'coupon_id', 'IN', $this->get_included_coupons( $query_args ) ); $where_filters[] = $this->get_object_where_filter( $orders_stats_table, 'order_id', $coupon_lookup, 'coupon_id', 'NOT IN', $this->get_excluded_coupons( $query_args ) ); // Tax rate filters. $where_filters[] = $this->get_object_where_filter( $orders_stats_table, 'order_id', $tax_rate_lookup, 'tax_rate_id', 'IN', implode( ',', $query_args['tax_rate_includes'] ) ); $where_filters[] = $this->get_object_where_filter( $orders_stats_table, 'order_id', $tax_rate_lookup, 'tax_rate_id', 'NOT IN', implode( ',', $query_args['tax_rate_excludes'] ) ); // Product attribute filters. $attribute_subqueries = $this->get_attribute_subqueries( $query_args ); if ( $attribute_subqueries['join'] && $attribute_subqueries['where'] ) { // Build a subquery for getting order IDs by product attribute(s). // Done here since our use case is a little more complicated than get_object_where_filter() can handle. $attribute_subquery = new SqlQuery(); $attribute_subquery->add_sql_clause( 'select', "{$orders_stats_table}.order_id" ); $attribute_subquery->add_sql_clause( 'from', $orders_stats_table ); // JOIN on product lookup. $attribute_subquery->add_sql_clause( 'join', "JOIN {$product_lookup} ON {$orders_stats_table}.order_id = {$product_lookup}.order_id" ); // Add JOINs for matching attributes. foreach ( $attribute_subqueries['join'] as $attribute_join ) { $attribute_subquery->add_sql_clause( 'join', $attribute_join ); } // Add WHEREs for matching attributes. $attribute_subquery->add_sql_clause( 'where', 'AND (' . implode( " {$operator} ", $attribute_subqueries['where'] ) . ')' ); // Generate subquery statement and add to our where filters. $where_filters[] = "{$orders_stats_table}.order_id IN (" . $attribute_subquery->get_query_statement() . ')'; } $where_filters[] = $this->get_customer_subquery( $query_args ); $refund_subquery = $this->get_refund_subquery( $query_args ); $from_clause .= $refund_subquery['from_clause']; if ( $refund_subquery['where_clause'] ) { $where_filters[] = $refund_subquery['where_clause']; } $where_filters = array_filter( $where_filters ); $where_subclause = implode( " $operator ", $where_filters ); // Append status filter after to avoid matching ANY on default statuses. $order_status_filter = $this->get_status_subquery( $query_args, $operator ); if ( $order_status_filter ) { if ( empty( $query_args['status_is'] ) && empty( $query_args['status_is_not'] ) ) { $operator = 'AND'; } $where_subclause = implode( " $operator ", array_filter( array( $where_subclause, $order_status_filter ) ) ); } // To avoid requesting the subqueries twice, the result is applied to all queries passed to the method. if ( $where_subclause ) { $this->total_query->add_sql_clause( 'where', "AND ( $where_subclause )" ); $this->total_query->add_sql_clause( 'join', $from_clause ); $this->interval_query->add_sql_clause( 'where', "AND ( $where_subclause )" ); $this->interval_query->add_sql_clause( 'join', $from_clause ); } } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override ReportsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = array_merge( parent::get_default_query_vars(), array( 'interval' => 'week', 'segmentby' => '', 'match' => 'all', 'status_is' => array(), 'status_is_not' => array(), 'product_includes' => array(), 'product_excludes' => array(), 'coupon_includes' => array(), 'coupon_excludes' => array(), 'tax_rate_includes' => array(), 'tax_rate_excludes' => array(), 'customer_type' => '', 'category_includes' => array(), ) ); return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override ReportsDataStore::get_noncached_stats_data() * * @see get_data * @see get_noncached_stats_data * @param array $query_args Query parameters. * @param array $params Query limit parameters. * @param stdClass $data Reference to the data object to fill. * @param int $expected_interval_count Number of expected intervals. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_stats_data( $query_args, $params, &$data, $expected_interval_count ) { global $wpdb; $table_name = self::get_db_table_name(); if ( isset( $query_args['date_type'] ) ) { $this->date_column_name = $query_args['date_type']; } $this->initialize_queries(); $selections = $this->selected_columns( $query_args ); $this->add_time_period_sql_params( $query_args, $table_name ); $this->add_intervals_sql_params( $query_args, $table_name ); $this->add_order_by_sql_params( $query_args ); $where_time = $this->get_sql_clause( 'where_time' ); $params = $this->get_limit_sql_params( $query_args ); $coupon_join = "LEFT JOIN ( SELECT order_id, SUM(discount_amount) AS discount_amount, COUNT(DISTINCT coupon_id) AS coupons_count FROM {$wpdb->prefix}wc_order_coupon_lookup GROUP BY order_id ) order_coupon_lookup ON order_coupon_lookup.order_id = {$wpdb->prefix}wc_order_stats.order_id"; // Additional filtering for Orders report. $this->orders_stats_sql_filter( $query_args ); $this->total_query->add_sql_clause( 'select', $selections ); $this->total_query->add_sql_clause( 'left_join', $coupon_join ); $this->total_query->add_sql_clause( 'where_time', $where_time ); $totals = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->total_query->get_query_statement(), ARRAY_A ); if ( null === $totals ) { return new \WP_Error( 'woocommerce_analytics_revenue_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); } // phpcs:ignore Generic.Commenting.Todo.TaskFound // @todo Remove these assignements when refactoring segmenter classes to use query objects. $totals_query = array( 'from_clause' => $this->total_query->get_sql_clause( 'join' ), 'where_time_clause' => $where_time, 'where_clause' => $this->total_query->get_sql_clause( 'where' ), ); $intervals_query = array( 'select_clause' => $this->get_sql_clause( 'select' ), 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), 'where_time_clause' => $where_time, 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), 'limit' => $this->get_sql_clause( 'limit' ), ); $unique_products = $this->get_unique_product_count( $totals_query['from_clause'], $totals_query['where_time_clause'], $totals_query['where_clause'] ); $totals[0]['products'] = $unique_products; $segmenter = new Segmenter( $query_args, $this->report_columns ); $unique_coupons = $this->get_unique_coupon_count( $totals_query['from_clause'], $totals_query['where_time_clause'], $totals_query['where_clause'] ); $totals[0]['coupons_count'] = $unique_coupons; $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); $totals = (object) $this->cast_numbers( $totals[0] ); $this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' ); $this->interval_query->add_sql_clause( 'left_join', $coupon_join ); $this->interval_query->add_sql_clause( 'where_time', $where_time ); $db_intervals = $wpdb->get_col( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, , unprepared SQL ok. $this->interval_query->get_query_statement() ); $db_interval_count = count( $db_intervals ); $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.{$this->date_column_name}) AS datetime_anchor" ); if ( '' !== $selections ) { $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } $intervals = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, , unprepared SQL ok. $this->interval_query->get_query_statement(), ARRAY_A ); if ( null === $intervals ) { return new \WP_Error( 'woocommerce_analytics_revenue_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); } if ( isset( $intervals[0] ) ) { $unique_coupons = $this->get_unique_coupon_count( $intervals_query['from_clause'], $intervals_query['where_time_clause'], $intervals_query['where_clause'], true ); $intervals[0]['coupons_count'] = $unique_coupons; } $data->totals = $totals; $data->intervals = $intervals; if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); } else { $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); } $segmenter->add_intervals_segments( $data, $intervals_query, $table_name ); return $data; } /** * Get unique products based on user time query * * @param string $from_clause From clause with date query. * @param string $where_time_clause Where clause with date query. * @param string $where_clause Where clause with date query. * @return integer Unique product count. */ public function get_unique_product_count( $from_clause, $where_time_clause, $where_clause ) { global $wpdb; $table_name = self::get_db_table_name(); return $wpdb->get_var( "SELECT COUNT( DISTINCT {$wpdb->prefix}wc_order_product_lookup.product_id ) FROM {$wpdb->prefix}wc_order_product_lookup JOIN {$table_name} ON {$wpdb->prefix}wc_order_product_lookup.order_id = {$table_name}.order_id {$from_clause} WHERE 1=1 {$where_time_clause} {$where_clause}" ); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok. } /** * Get unique coupons based on user time query * * @param string $from_clause From clause with date query. * @param string $where_time_clause Where clause with date query. * @param string $where_clause Where clause with date query. * @return integer Unique product count. */ public function get_unique_coupon_count( $from_clause, $where_time_clause, $where_clause ) { global $wpdb; $table_name = self::get_db_table_name(); return $wpdb->get_var( "SELECT COUNT(DISTINCT coupon_id) FROM {$wpdb->prefix}wc_order_coupon_lookup JOIN {$table_name} ON {$wpdb->prefix}wc_order_coupon_lookup.order_id = {$table_name}.order_id {$from_clause} WHERE 1=1 {$where_time_clause} {$where_clause}" ); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok. } /** * Add order information to the lookup table when orders are created or modified. * * @param int $post_id Post ID. * @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success. */ public static function sync_order( $post_id ) { if ( ! OrderUtil::is_order( $post_id, array( 'shop_order', 'shop_order_refund' ) ) ) { return -1; } $order = wc_get_order( $post_id ); if ( ! $order ) { return -1; } return self::update( $order ); } /** * Update the database with stats data. * * @param WC_Order|WC_Order_Refund $order Order or refund to update row for. * @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success. */ public static function update( $order ) { global $wpdb; $table_name = self::get_db_table_name(); if ( ! $order->get_id() || ! $order->get_date_created() ) { return -1; } $format = array( '%d', '%d', '%s', '%s', '%s', '%s', '%d', '%f', '%f', '%f', '%f', '%s', '%d', '%d', ); $data = array( 'order_id' => $order->get_id(), 'parent_id' => $order->get_parent_id(), 'date_created' => $order->get_date_created()->date( 'Y-m-d H:i:s' ), 'date_paid' => $order->get_date_paid() ? $order->get_date_paid()->date( 'Y-m-d H:i:s' ) : null, 'date_completed' => $order->get_date_completed() ? $order->get_date_completed()->date( 'Y-m-d H:i:s' ) : null, 'date_created_gmt' => gmdate( 'Y-m-d H:i:s', $order->get_date_created()->getTimestamp() ), 'num_items_sold' => self::get_num_items_sold( $order ), 'total_sales' => $order->get_total(), 'tax_total' => $order->get_total_tax(), 'shipping_total' => $order->get_shipping_total(), 'net_total' => self::get_net_total( $order ), 'status' => self::normalize_order_status( $order->get_status() ), 'customer_id' => $order->get_report_customer_id(), 'returning_customer' => $order->is_returning_customer(), ); $order_fulfillment_status = ''; if ( FeaturesUtil::feature_is_enabled( 'fulfillments' ) && true === self::has_fulfillment_status_column() && $order instanceof WC_Order ) { $order_fulfillment_status = FulfillmentUtils::get_order_fulfillment_status( $order ); $data['fulfillment_status'] = ( 'no_fulfillments' !== $order_fulfillment_status ) ? $order_fulfillment_status : null; $format[] = '%s'; } /** * Filters order stats data. * * @param array $data Data written to order stats lookup table. * @param WC_Order $order Order object. * * @since 4.0.0 */ $data = apply_filters( 'woocommerce_analytics_update_order_stats_data', $data, $order ); if ( 'shop_order_refund' === $order->get_type() ) { $parent_order = wc_get_order( $order->get_parent_id() ); if ( $parent_order ) { $data['parent_id'] = $parent_order->get_id(); $data['status'] = self::normalize_order_status( $parent_order->get_status() ); $refund_type = $order->get_meta( '_refund_type' ); $uses_new_full_refund_data = OrderUtil::uses_new_full_refund_data(); if ( 'full' === $refund_type && $uses_new_full_refund_data ) { $data['num_items_sold'] = -1 * self::get_num_items_sold( $parent_order ); $data['tax_total'] = -1 * $parent_order->get_total_tax(); $data['net_total'] = -1 * self::get_net_total( $parent_order ); $data['shipping_total'] = -1 * $parent_order->get_shipping_total(); } } /** * Set date_completed and date_paid the same as date_created to avoid problems * when they are being used to sort the data, as refunds don't have them filled */ $data['date_completed'] = $data['date_created']; $data['date_paid'] = $data['date_created']; } // Update or add the information to the DB. $result = $wpdb->replace( $table_name, $data, $format ); /** * Fires when order's stats reports are updated. * * @param int $order_id Order ID. * * @since 4.0.0. */ do_action( 'woocommerce_analytics_update_order_stats', $order->get_id() ); // Check the rows affected for success. Using REPLACE can affect 2 rows if the row already exists. return ( 1 === $result || 2 === $result ); } /** * Deletes the order stats when an order is deleted. * * @param int $post_id Post ID. */ public static function delete_order( $post_id ) { global $wpdb; $order_id = (int) $post_id; if ( ! OrderUtil::is_order( $post_id, array( 'shop_order', 'shop_order_refund' ) ) ) { return; } // Retrieve customer details before the order is deleted. $order = wc_get_order( $order_id ); $customer_id = absint( CustomersDataStore::get_existing_customer_id_from_order( $order ) ); // Delete the order. $wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) ); /** * Fires when orders stats are deleted. * * @param int $order_id Order ID. * @param int $customer_id Customer ID. * * @since 4.0.0 */ do_action( 'woocommerce_analytics_delete_order_stats', $order_id, $customer_id ); ReportsCache::invalidate(); } /** * Calculation methods. */ /** * Get number of items sold among all orders. * * @param WC_Order $order WC_Order object. * @return int */ protected static function get_num_items_sold( $order ) { $num_items = 0; $line_items = $order->get_items( 'line_item' ); foreach ( $line_items as $line_item ) { $num_items += $line_item->get_quantity(); } return $num_items; } /** * Get the net amount from an order without shipping, tax, or refunds. * * @param WC_Order $order WC_Order object. * @return float */ protected static function get_net_total( $order ) { $net_total = floatval( $order->get_total() ) - floatval( $order->get_total_tax() ) - floatval( $order->get_shipping_total() ); return (float) $net_total; } /** * Check if the wc_order_stats table has the fulfillment_status column. * * @return boolean */ public static function has_fulfillment_status_column() { $column_status = get_option( self::OPTION_ORDER_STATS_TABLE_HAS_COLUMN_ORDER_FULFILLMENT_STATUS ); if ( ! empty( $column_status ) ) { return 'yes' === $column_status; } global $wpdb; $table_name = self::get_db_table_name(); // Check if the table exists. $table_exists = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be prepared. 'SHOW TABLES LIKE %s', $table_name ) ); // If table still does not exist, return false without setting the option to allow for table to be created with the column. if ( ! $table_exists ) { return false; } $column_exists = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be prepared. "SHOW COLUMNS FROM `{$table_name}` LIKE %s", 'fulfillment_status' ) ); if ( ! empty( $column_exists ) ) { update_option( self::OPTION_ORDER_STATS_TABLE_HAS_COLUMN_ORDER_FULFILLMENT_STATUS, 'yes', false ); return true; } // Update the option to indicate that the column does not exist. update_option( self::OPTION_ORDER_STATS_TABLE_HAS_COLUMN_ORDER_FULFILLMENT_STATUS, 'no', false ); return false; } /** * Check to see if an order's customer has made previous orders or not * * @param WC_Order $order WC_Order object. * @param int|false $customer_id Customer ID. Optional. * @return bool */ public static function is_returning_customer( $order, $customer_id = null ) { if ( is_null( $customer_id ) ) { $customer_id = \Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore::get_existing_customer_id_from_order( $order ); } if ( ! $customer_id ) { return false; } $oldest_orders = \Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore::get_oldest_orders( $customer_id ); if ( empty( $oldest_orders ) ) { return false; } $first_order = $oldest_orders[0]; $second_order = isset( $oldest_orders[1] ) ? $oldest_orders[1] : false; $excluded_statuses = self::get_excluded_report_order_statuses(); // Order is older than previous first order. if ( $order->get_date_created() < wc_string_to_datetime( $first_order->date_created ) && ! in_array( $order->get_status(), $excluded_statuses, true ) ) { self::set_customer_first_order( $customer_id, $order->get_id() ); return false; } // The current order is the oldest known order. $is_first_order = (int) $order->get_id() === (int) $first_order->order_id; // Order date has changed and next oldest is now the first order. $date_change = $second_order && $order->get_date_created() > wc_string_to_datetime( $first_order->date_created ) && wc_string_to_datetime( $second_order->date_created ) < $order->get_date_created(); // Status has changed to an excluded status and next oldest order is now the first order. $status_change = $second_order && in_array( $order->get_status(), $excluded_statuses, true ); if ( $is_first_order && ( $date_change || $status_change ) ) { self::set_customer_first_order( $customer_id, $second_order->order_id ); return true; } return (int) $order->get_id() !== (int) $first_order->order_id; } /** * Set a customer's first order and all others to returning. * * @param int $customer_id Customer ID. * @param int $order_id Order ID. */ protected static function set_customer_first_order( $customer_id, $order_id ) { global $wpdb; $orders_stats_table = self::get_db_table_name(); $wpdb->query( $wpdb->prepare( // phpcs:ignore Generic.Commenting.Todo.TaskFound // TODO: use the %i placeholder to prepare the table name when available in the minimum required WordPress version. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "UPDATE {$orders_stats_table} SET returning_customer = CASE WHEN order_id = %d THEN false ELSE true END WHERE customer_id = %d", $order_id, $customer_id ) ); } /** * Add fulfillment_status column to wc_order_stats table. * * @return bool|string True on success, error message string on failure. */ public static function add_fulfillment_status_column() { if ( self::has_fulfillment_status_column() ) { return true; } global $wpdb; $result = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching "ALTER TABLE {$wpdb->prefix}wc_order_stats ADD COLUMN fulfillment_status VARCHAR(50) DEFAULT NULL, ADD INDEX fulfillment_status (fulfillment_status)" ); if ( false === $result ) { return $wpdb->last_error ? $wpdb->last_error : __( 'Unknown database error occurred while adding fulfillment_status column.', 'woocommerce' ); } // Update the option to indicate that the column has been added. update_option( self::OPTION_ORDER_STATS_TABLE_HAS_COLUMN_ORDER_FULFILLMENT_STATUS, 'yes', false ); return true; } } API/Reports/Orders/Stats/Controller.php 0000777 00000032667 15252240713 0014031 0 ustar 00 <?php /** * REST API Reports orders stats controller * * Handles requests to the /reports/orders/stats endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Orders\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController; use Automattic\WooCommerce\Admin\API\Reports\OrderAwareControllerTrait; use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\Query; /** * REST API Reports orders stats controller class. * * @internal * @extends \Automattic\WooCommerce\Admin\API\Reports\GenericStatsController */ class Controller extends GenericStatsController { use OrderAwareControllerTrait; /** * Route base. * * @var string */ protected $rest_base = 'reports/orders/stats'; /** * Get data from Orders\Stats\Query. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new Query( $query_args ); return $query->get_data(); } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['before'] = $request['before']; $args['after'] = $request['after']; $args['interval'] = $request['interval']; $args['page'] = $request['page']; $args['per_page'] = $request['per_page']; $args['orderby'] = $request['orderby']; $args['order'] = $request['order']; $args['fields'] = $request['fields']; $args['match'] = $request['match']; $args['status_is'] = (array) $request['status_is']; $args['status_is_not'] = (array) $request['status_is_not']; $args['product_includes'] = (array) $request['product_includes']; $args['product_excludes'] = (array) $request['product_excludes']; $args['variation_includes'] = (array) $request['variation_includes']; $args['variation_excludes'] = (array) $request['variation_excludes']; $args['coupon_includes'] = (array) $request['coupon_includes']; $args['coupon_excludes'] = (array) $request['coupon_excludes']; $args['tax_rate_includes'] = (array) $request['tax_rate_includes']; $args['tax_rate_excludes'] = (array) $request['tax_rate_excludes']; $args['customer_type'] = $request['customer_type']; $args['refunds'] = $request['refunds']; $args['attribute_is'] = (array) $request['attribute_is']; $args['attribute_is_not'] = (array) $request['attribute_is_not']; $args['category_includes'] = (array) $request['categories']; $args['segmentby'] = $request['segmentby']; $args['force_cache_refresh'] = $request['force_cache_refresh']; // For backwards compatibility, `customer` is aliased to `customer_type`. if ( empty( $request['customer_type'] ) && ! empty( $request['customer'] ) ) { $args['customer_type'] = $request['customer']; } return $args; } /** * Prepare a report data item for serialization. * * @param Array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { // Wrap the data in a response object. $response = parent::prepare_item_for_response( $report, $request ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_orders_stats', $response, $report, $request ); } /** * Get the Report's item properties schema. * Will be used by `get_item_schema` as `totals` and `subtotals`. * * @return array */ protected function get_item_properties_schema() { return array( 'net_revenue' => array( 'description' => __( 'Net sales.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'format' => 'currency', ), 'orders_count' => array( 'title' => __( 'Orders', 'woocommerce' ), 'description' => __( 'Number of orders', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, ), 'avg_order_value' => array( 'description' => __( 'Average order value.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, 'format' => 'currency', ), 'avg_items_per_order' => array( 'description' => __( 'Average items per order', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'num_items_sold' => array( 'description' => __( 'Number of items sold', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'coupons' => array( 'description' => __( 'Amount discounted by coupons.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'coupons_count' => array( 'description' => __( 'Unique coupons count.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'total_customers' => array( 'description' => __( 'Total distinct customers.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'products' => array( 'description' => __( 'Number of distinct products sold.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ); } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = parent::get_item_schema(); $schema['title'] = 'report_orders_stats'; // Products is not shown in intervals. unset( $schema['properties']['intervals']['items']['properties']['subtotals']['properties']['products'] ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'date', 'net_revenue', 'orders_count', 'avg_order_value', ) ); $params['match'] = array( 'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ), 'type' => 'string', 'default' => 'all', 'enum' => array( 'all', 'any', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['status_is'] = array( 'description' => __( 'Limit result set to items that have the specified order status.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'default' => null, 'items' => array( 'enum' => self::get_order_statuses(), 'type' => 'string', ), ); $params['status_is_not'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified order status.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_slug_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'enum' => self::get_order_statuses(), 'type' => 'string', ), ); $params['product_includes'] = array( 'description' => __( 'Limit result set to items that have the specified product(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', ); $params['product_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified product(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', ); // Split assignments for PHPCS complaining on aligned. $params['variation_includes'] = array( 'description' => __( 'Limit result set to items that have the specified variation(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); $params['variation_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified variation(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'wp_parse_id_list', ); $params['coupon_includes'] = array( 'description' => __( 'Limit result set to items that have the specified coupon(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', ); $params['coupon_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified coupon(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', ); $params['tax_rate_includes'] = array( 'description' => __( 'Limit result set to items that have the specified tax rate(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); $params['tax_rate_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified tax rate(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'wp_parse_id_list', ); $params['customer'] = array( 'description' => __( 'Alias for customer_type (deprecated).', 'woocommerce' ), 'type' => 'string', 'enum' => array( 'new', 'returning', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['customer_type'] = array( 'description' => __( 'Limit result set to orders that have the specified customer_type', 'woocommerce' ), 'type' => 'string', 'enum' => array( 'new', 'returning', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['refunds'] = array( 'description' => __( 'Limit result set to specific types of refunds.', 'woocommerce' ), 'type' => 'string', 'default' => '', 'enum' => array( '', 'all', 'partial', 'full', 'none', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['attribute_is'] = array( 'description' => __( 'Limit result set to orders that include products with the specified attributes.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'array', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); $params['attribute_is_not'] = array( 'description' => __( 'Limit result set to orders that don\'t include products with the specified attributes.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'array', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); $params['segmentby'] = array( 'description' => __( 'Segment the response by additional constraint.', 'woocommerce' ), 'type' => 'string', 'enum' => array( 'product', 'category', 'variation', 'coupon', 'customer_type', // new vs returning. ), 'validate_callback' => 'rest_validate_request_arg', ); unset( $params['intervals'] ); unset( $params['fields'] ); return $params; } } API/Reports/Orders/Stats/Segmenter.php 0000777 00000051722 15252240713 0013630 0 ustar 00 <?php /** * Class for adding segmenting support without cluttering the data stores. */ namespace Automattic\WooCommerce\Admin\API\Reports\Orders\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Segmenter as ReportsSegmenter; use Automattic\WooCommerce\Admin\API\Reports\ParameterException; /** * Date & time interval and numeric range handling class for Reporting API. */ class Segmenter extends ReportsSegmenter { /** * Returns column => query mapping to be used for product-related product-level segmenting query * (e.g. products sold, revenue from product X when segmenting by category). * * @param string $products_table Name of SQL table containing the product-level segmenting info. * * @return array Column => SELECT query mapping. */ protected function get_segment_selections_product_level( $products_table ) { $columns_mapping = array( 'num_items_sold' => "SUM($products_table.product_qty) as num_items_sold", 'total_sales' => "SUM($products_table.product_gross_revenue) AS total_sales", 'coupons' => 'SUM( coupon_lookup_left_join.discount_amount ) AS coupons', 'coupons_count' => 'COUNT( DISTINCT( coupon_lookup_left_join.coupon_id ) ) AS coupons_count', 'refunds' => "SUM( CASE WHEN $products_table.product_gross_revenue < 0 THEN $products_table.product_gross_revenue ELSE 0 END ) AS refunds", 'taxes' => "SUM($products_table.tax_amount) AS taxes", 'shipping' => "SUM($products_table.shipping_amount) AS shipping", 'net_revenue' => "SUM($products_table.product_net_revenue) AS net_revenue", ); return $columns_mapping; } /** * Returns column => query mapping to be used for order-related product-level segmenting query * (e.g. avg items per order when segmented by category). * * @param string $unique_orders_table Name of SQL table containing the order-level segmenting info. * * @return array Column => SELECT query mapping. */ protected function get_segment_selections_order_level( $unique_orders_table ) { $columns_mapping = array( 'orders_count' => "COUNT($unique_orders_table.order_id) AS orders_count", 'avg_items_per_order' => "AVG($unique_orders_table.num_items_sold) AS avg_items_per_order", 'avg_order_value' => "SUM($unique_orders_table.net_total) / COUNT($unique_orders_table.order_id) AS avg_order_value", 'total_customers' => "COUNT( DISTINCT( $unique_orders_table.customer_id ) ) AS total_customers", ); return $columns_mapping; } /** * Returns column => query mapping to be used for order-level segmenting query * (e.g. avg items per order or Net sales when segmented by coupons). * * @param string $order_stats_table Name of SQL table containing the order-level info. * @param array $overrides Array of overrides for default column calculations. * * @return array Column => SELECT query mapping. */ protected function segment_selections_orders( $order_stats_table, $overrides = array() ) { $columns_mapping = array( 'num_items_sold' => "SUM($order_stats_table.num_items_sold) as num_items_sold", 'total_sales' => "SUM($order_stats_table.total_sales) AS total_sales", 'coupons' => "SUM($order_stats_table.discount_amount) AS coupons", 'coupons_count' => 'COUNT( DISTINCT(coupon_lookup_left_join.coupon_id) ) AS coupons_count', 'refunds' => "SUM( CASE WHEN $order_stats_table.parent_id != 0 THEN $order_stats_table.total_sales END ) AS refunds", 'taxes' => "SUM($order_stats_table.tax_total) AS taxes", 'shipping' => "SUM($order_stats_table.shipping_total) AS shipping", 'net_revenue' => "SUM($order_stats_table.net_total) AS net_revenue", 'orders_count' => "COUNT($order_stats_table.order_id) AS orders_count", 'avg_items_per_order' => "AVG($order_stats_table.num_items_sold) AS avg_items_per_order", 'avg_order_value' => "SUM($order_stats_table.net_total) / COUNT($order_stats_table.order_id) AS avg_order_value", 'total_customers' => "COUNT( DISTINCT( $order_stats_table.customer_id ) ) AS total_customers", ); if ( $overrides ) { $columns_mapping = array_merge( $columns_mapping, $overrides ); } return $columns_mapping; } /** * Calculate segments for totals where the segmenting property is bound to product (e.g. category, product_id, variation_id). * * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $segmenting_dimension_name Name of the segmenting dimension. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $totals_query Array of SQL clauses for totals query. * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. * * @return array */ protected function get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $totals_query, $unique_orders_table ) { global $wpdb; $product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup'; // Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued). // Product-level numbers. $segments_products = $wpdb->get_results( "SELECT $segmenting_groupby AS $segmenting_dimension_name {$segmenting_selections['product_level']} FROM $table_name $segmenting_from {$totals_query['from_clause']} WHERE 1=1 {$totals_query['where_time_clause']} {$totals_query['where_clause']} $segmenting_where GROUP BY $segmenting_groupby", ARRAY_A ); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok. // Order level numbers. // As there can be 2 same product ids (or variation ids) per one order, the orders first need to be uniqued before calculating averages, customer counts, etc. $segments_orders = $wpdb->get_results( "SELECT $unique_orders_table.$segmenting_dimension_name AS $segmenting_dimension_name {$segmenting_selections['order_level']} FROM ( SELECT $table_name.order_id, $segmenting_groupby AS $segmenting_dimension_name, MAX( num_items_sold ) AS num_items_sold, MAX( net_total ) as net_total, MAX( returning_customer ) AS returning_customer, MAX( $table_name.customer_id ) as customer_id FROM $table_name $segmenting_from {$totals_query['from_clause']} WHERE 1=1 {$totals_query['where_time_clause']} {$totals_query['where_clause']} $segmenting_where GROUP BY $product_segmenting_table.order_id, $segmenting_groupby ) AS $unique_orders_table GROUP BY $unique_orders_table.$segmenting_dimension_name", ARRAY_A ); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok. $totals_segments = $this->merge_segment_totals_results( $segmenting_dimension_name, $segments_products, $segments_orders ); return $totals_segments; } /** * Calculate segments for intervals where the segmenting property is bound to product (e.g. category, product_id, variation_id). * * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $segmenting_dimension_name Name of the segmenting dimension. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $intervals_query Array of SQL clauses for intervals query. * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. * * @return array */ protected function get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $intervals_query, $unique_orders_table ) { global $wpdb; $product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup'; // LIMIT offset, rowcount needs to be updated to LIMIT offset, rowcount * max number of segments. $limit_parts = explode( ',', $intervals_query['limit'] ); $orig_rowcount = intval( $limit_parts[1] ); $segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() ); // Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued). // Product-level numbers. $segments_products = $wpdb->get_results( "SELECT {$intervals_query['select_clause']} AS time_interval, $segmenting_groupby AS $segmenting_dimension_name {$segmenting_selections['product_level']} FROM $table_name $segmenting_from {$intervals_query['from_clause']} WHERE 1=1 {$intervals_query['where_time_clause']} {$intervals_query['where_clause']} $segmenting_where GROUP BY time_interval, $segmenting_groupby $segmenting_limit", ARRAY_A ); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok. // Order level numbers. // As there can be 2 same product ids (or variation ids) per one order, the orders first need to be uniqued before calculating averages, customer counts, etc. $segments_orders = $wpdb->get_results( "SELECT $unique_orders_table.time_interval AS time_interval, $unique_orders_table.$segmenting_dimension_name AS $segmenting_dimension_name {$segmenting_selections['order_level']} FROM ( SELECT MAX( $table_name.date_created ) AS datetime_anchor, {$intervals_query['select_clause']} AS time_interval, $table_name.order_id, $segmenting_groupby AS $segmenting_dimension_name, MAX( num_items_sold ) AS num_items_sold, MAX( net_total ) as net_total, MAX( returning_customer ) AS returning_customer, MAX( $table_name.customer_id ) as customer_id FROM $table_name $segmenting_from {$intervals_query['from_clause']} WHERE 1=1 {$intervals_query['where_time_clause']} {$intervals_query['where_clause']} $segmenting_where GROUP BY time_interval, $product_segmenting_table.order_id, $segmenting_groupby ) AS $unique_orders_table GROUP BY time_interval, $unique_orders_table.$segmenting_dimension_name $segmenting_limit", ARRAY_A ); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok. $intervals_segments = $this->merge_segment_intervals_results( $segmenting_dimension_name, $segments_products, $segments_orders ); return $intervals_segments; } /** * Calculate segments for totals query where the segmenting property is bound to order (e.g. coupon or customer type). * * @param string $segmenting_select SELECT part of segmenting SQL query. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $totals_query Array of SQL clauses for intervals query. * * @return array */ protected function get_order_related_totals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $totals_query ) { global $wpdb; $totals_segments = $wpdb->get_results( "SELECT $segmenting_groupby $segmenting_select FROM $table_name $segmenting_from {$totals_query['from_clause']} WHERE 1=1 {$totals_query['where_time_clause']} {$totals_query['where_clause']} $segmenting_where GROUP BY $segmenting_groupby", ARRAY_A ); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok. // Reformat result. $totals_segments = $this->reformat_totals_segments( $totals_segments, $segmenting_groupby ); return $totals_segments; } /** * Calculate segments for intervals query where the segmenting property is bound to order (e.g. coupon or customer type). * * @param string $segmenting_select SELECT part of segmenting SQL query. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $intervals_query Array of SQL clauses for intervals query. * * @return array */ protected function get_order_related_intervals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $intervals_query ) { global $wpdb; $segmenting_limit = ''; $limit_parts = explode( ',', $intervals_query['limit'] ); if ( 2 === count( $limit_parts ) ) { $orig_rowcount = intval( $limit_parts[1] ); $segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() ); } $intervals_segments = $wpdb->get_results( "SELECT MAX($table_name.date_created) AS datetime_anchor, {$intervals_query['select_clause']} AS time_interval, $segmenting_groupby $segmenting_select FROM $table_name $segmenting_from {$intervals_query['from_clause']} WHERE 1=1 {$intervals_query['where_time_clause']} {$intervals_query['where_clause']} $segmenting_where GROUP BY time_interval, $segmenting_groupby $segmenting_limit", ARRAY_A ); // phpcs:ignore cache ok, DB call ok, unprepared SQL ok. // Reformat result. $intervals_segments = $this->reformat_intervals_segments( $intervals_segments, $segmenting_groupby ); return $intervals_segments; } /** * Return array of segments formatted for REST response. * * @param string $type Type of segments to return--'totals' or 'intervals'. * @param array $query_params SQL query parameter array. * @param string $table_name Name of main SQL table for the data store (used as basis for JOINS). * * @return array * @throws \Automattic\WooCommerce\Admin\API\Reports\ParameterException In case of segmenting by variations, when no parent product is specified. */ protected function get_segments( $type, $query_params, $table_name ) { global $wpdb; if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) { return array(); } $product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup'; $unique_orders_table = 'uniq_orders'; $segmenting_from = "LEFT JOIN {$wpdb->prefix}wc_order_coupon_lookup AS coupon_lookup_left_join ON ($table_name.order_id = coupon_lookup_left_join.order_id) "; $segmenting_where = ''; // Product, variation, and category are bound to product, so here product segmenting table is required, // while coupon and customer are bound to order, so we don't need the extra JOIN for those. // This also means that segment selections need to be calculated differently. if ( 'product' === $this->query_args['segmentby'] ) { // @todo How to handle shipping taxes when grouped by product? $product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table ); $order_level_columns = $this->get_segment_selections_order_level( $unique_orders_table ); $segmenting_selections = array( 'product_level' => $this->prepare_selections( $product_level_columns ), 'order_level' => $this->prepare_selections( $order_level_columns ), ); $this->report_columns = array_merge( $product_level_columns, $order_level_columns ); $segmenting_from .= "INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)"; $segmenting_groupby = $product_segmenting_table . '.product_id'; $segmenting_dimension_name = 'product_id'; $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } elseif ( 'variation' === $this->query_args['segmentby'] ) { if ( ! isset( $this->query_args['product_includes'] ) || ! is_array( $this->query_args['product_includes'] ) || count( $this->query_args['product_includes'] ) !== 1 ) { throw new ParameterException( 'wc_admin_reports_invalid_segmenting_variation', __( 'product_includes parameter need to specify exactly one product when segmenting by variation.', 'woocommerce' ) ); } $product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table ); $order_level_columns = $this->get_segment_selections_order_level( $unique_orders_table ); $segmenting_selections = array( 'product_level' => $this->prepare_selections( $product_level_columns ), 'order_level' => $this->prepare_selections( $order_level_columns ), ); $this->report_columns = array_merge( $product_level_columns, $order_level_columns ); $segmenting_from .= "INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)"; $segmenting_where = "AND $product_segmenting_table.product_id = {$this->query_args['product_includes'][0]}"; $segmenting_groupby = $product_segmenting_table . '.variation_id'; $segmenting_dimension_name = 'variation_id'; $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } elseif ( 'category' === $this->query_args['segmentby'] ) { $product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table ); $order_level_columns = $this->get_segment_selections_order_level( $unique_orders_table ); $segmenting_selections = array( 'product_level' => $this->prepare_selections( $product_level_columns ), 'order_level' => $this->prepare_selections( $order_level_columns ), ); $this->report_columns = array_merge( $product_level_columns, $order_level_columns ); $segmenting_from .= " INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id) LEFT JOIN {$wpdb->term_relationships} ON {$product_segmenting_table}.product_id = {$wpdb->term_relationships}.object_id JOIN {$wpdb->term_taxonomy} ON {$wpdb->term_taxonomy}.term_taxonomy_id = {$wpdb->term_relationships}.term_taxonomy_id LEFT JOIN {$wpdb->wc_category_lookup} ON {$wpdb->term_taxonomy}.term_id = {$wpdb->wc_category_lookup}.category_id "; $segmenting_where = " AND {$wpdb->wc_category_lookup}.category_tree_id IS NOT NULL"; $segmenting_groupby = "{$wpdb->wc_category_lookup}.category_tree_id"; $segmenting_dimension_name = 'category_id'; $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } elseif ( 'coupon' === $this->query_args['segmentby'] ) { // As there can be 2 or more coupons applied per one order, coupon amount needs to be split. $coupon_override = array( 'coupons' => 'SUM(coupon_lookup.discount_amount) AS coupons', ); $coupon_level_columns = $this->segment_selections_orders( $table_name, $coupon_override ); $segmenting_selections = $this->prepare_selections( $coupon_level_columns ); $this->report_columns = $coupon_level_columns; $segmenting_from .= " INNER JOIN {$wpdb->prefix}wc_order_coupon_lookup AS coupon_lookup ON ($table_name.order_id = coupon_lookup.order_id) "; $segmenting_groupby = 'coupon_lookup.coupon_id'; $segments = $this->get_order_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params ); } elseif ( 'customer_type' === $this->query_args['segmentby'] ) { $customer_level_columns = $this->segment_selections_orders( $table_name ); $segmenting_selections = $this->prepare_selections( $customer_level_columns ); $this->report_columns = $customer_level_columns; $segmenting_groupby = "$table_name.returning_customer"; $segments = $this->get_order_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params ); } return $segments; } } API/Reports/Orders/Stats/Query.php 0000777 00000002451 15252240713 0012777 0 ustar 00 <?php /** * Class for parameter-based Order Stats Reports querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'interval' => 'week', * 'categories' => array(15, 18), * 'coupons' => array(138), * 'status_in' => array('completed'), * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Orders\Stats; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; defined( 'ABSPATH' ) || exit; /** * API\Reports\Orders\Stats\Query */ class Query extends GenericQuery { /** * Specific query name. * Will be used to load the `report-{name}` data store, * and to call `woocommerce_analytics_{snake_case(name)}_*` filters. * * @var string */ protected $name = 'orders-stats'; /** * Valid fields for Orders report. * * @return array */ protected function get_default_query_vars() { return array( 'fields' => array( 'net_revenue', 'avg_order_value', 'orders_count', 'avg_items_per_order', 'num_items_sold', 'coupons', 'coupons_count', 'total_customers', ), ); } } API/Reports/Orders/Query.php 0000777 00000002310 15252240713 0011673 0 ustar 00 <?php /** * Class for parameter-based Orders Reports querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'interval' => 'week', * 'products' => array(15, 18), * 'coupons' => array(138), * 'status_is' => array('completed'), * 'status_is_not' => array('failed'), * 'new_customers' => false, * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Orders\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Orders; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; defined( 'ABSPATH' ) || exit; /** * API\Reports\Orders\Query */ class Query extends GenericQuery { /** * Specific query name. * Will be used to load the `report-{name}` data store, * and to call `woocommerce_analytics_{snake_case(name)}_*` filters. * * @var string */ protected $name = 'orders'; /** * Get the default allowed query vars. * * @return array */ protected function get_default_query_vars() { return \WC_Object_Query::get_default_query_vars(); } } API/Reports/Orders/DataStore.php 0000777 00000054743 15252240713 0012475 0 ustar 00 <?php /** * API\Reports\Orders\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Orders; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore; use Automattic\WooCommerce\Internal\Traits\OrderAttributionMeta; use Automattic\WooCommerce\Utilities\OrderUtil; /** * API\Reports\Orders\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { use OrderAttributionMeta; /** * The cache key for order statuses. */ const ORDERS_STATUSES_ALL_CACHE_KEY = 'woocommerce_analytics_orders_statuses_all'; /** * Dynamically sets the date column name based on configuration * * @override ReportsDataStore::__construct() */ public function __construct() { $this->date_column_name = get_option( 'woocommerce_date_type', 'date_paid' ); parent::__construct(); } /** * Set up all the hooks for maintaining data consistency (transients and co). * * @internal */ final public static function init() { add_action( 'woocommerce_analytics_update_order_stats', array( __CLASS__, 'maybe_update_order_statuses_cache' ) ); } /** * Table used to get the data. * * @override ReportsDataStore::$table_name * * @var string */ protected static $table_name = 'wc_order_stats'; /** * Cache identifier. * * @override ReportsDataStore::$cache_key * * @var string */ protected $cache_key = 'orders'; /** * Mapping columns to data type to return correct response types. * * @override ReportsDataStore::$column_types * * @var array */ protected $column_types = array( 'order_id' => 'intval', 'parent_id' => 'intval', 'date_created' => 'strval', 'date_created_gmt' => 'strval', 'status' => 'strval', 'customer_id' => 'intval', 'net_total' => 'floatval', 'total_sales' => 'floatval', 'num_items_sold' => 'intval', 'customer_type' => 'strval', ); /** * Data store context used to pass to filters. * * @override ReportsDataStore::$context * * @var string */ protected $context = 'orders'; /** * Assign report columns once full table name has been assigned. * * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); // Avoid ambiguous columns in SQL query. $this->report_columns = array( 'order_id' => "DISTINCT {$table_name}.order_id", 'parent_id' => "{$table_name}.parent_id", // Add 'date' field based on date type setting. 'date' => "{$table_name}.{$this->date_column_name} AS date", 'date_created' => "{$table_name}.date_created", 'date_created_gmt' => "{$table_name}.date_created_gmt", 'status' => "REPLACE({$table_name}.status, 'wc-', '') as status", 'customer_id' => "{$table_name}.customer_id", 'net_total' => "{$table_name}.net_total", 'total_sales' => "{$table_name}.total_sales", 'num_items_sold' => "{$table_name}.num_items_sold", 'customer_type' => "(CASE WHEN {$table_name}.returning_customer = 0 THEN 'new' ELSE 'returning' END) as customer_type", ); } /** * Updates the database query with parameters used for orders report: coupons and products filters. * * @param array $query_args Query arguments supplied by the user. */ protected function add_sql_query_params( $query_args ) { global $wpdb; $order_stats_lookup_table = self::get_db_table_name(); $order_coupon_lookup_table = $wpdb->prefix . 'wc_order_coupon_lookup'; $order_product_lookup_table = $wpdb->prefix . 'wc_order_product_lookup'; $order_tax_lookup_table = $wpdb->prefix . 'wc_order_tax_lookup'; $operator = $this->get_match_operator( $query_args ); $where_subquery = array(); $have_joined_products_table = false; $this->add_time_period_sql_params( $query_args, $order_stats_lookup_table ); $this->get_limit_sql_params( $query_args ); $this->add_order_by_sql_params( $query_args ); $status_subquery = $this->get_status_subquery( $query_args ); if ( $status_subquery ) { if ( empty( $query_args['status_is'] ) && empty( $query_args['status_is_not'] ) ) { $this->subquery->add_sql_clause( 'where', "AND {$status_subquery}" ); } else { $where_subquery[] = $status_subquery; } } $included_orders = $this->get_included_orders( $query_args ); if ( $included_orders ) { $where_subquery[] = "{$order_stats_lookup_table}.order_id IN ({$included_orders})"; } $excluded_orders = $this->get_excluded_orders( $query_args ); if ( $excluded_orders ) { $where_subquery[] = "{$order_stats_lookup_table}.order_id NOT IN ({$excluded_orders})"; } if ( $query_args['customer_type'] ) { $returning_customer = 'returning' === $query_args['customer_type'] ? 1 : 0; $where_subquery[] = "{$order_stats_lookup_table}.returning_customer = {$returning_customer}"; } $refund_subquery = $this->get_refund_subquery( $query_args ); $this->subquery->add_sql_clause( 'from', $refund_subquery['from_clause'] ); if ( $refund_subquery['where_clause'] ) { $where_subquery[] = $refund_subquery['where_clause']; } $included_coupons = $this->get_included_coupons( $query_args ); $excluded_coupons = $this->get_excluded_coupons( $query_args ); if ( $included_coupons || $excluded_coupons ) { $this->subquery->add_sql_clause( 'join', "LEFT JOIN {$order_coupon_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_coupon_lookup_table}.order_id" ); } if ( $included_coupons ) { $where_subquery[] = "{$order_coupon_lookup_table}.coupon_id IN ({$included_coupons})"; } if ( $excluded_coupons ) { $where_subquery[] = "({$order_coupon_lookup_table}.coupon_id IS NULL OR {$order_coupon_lookup_table}.coupon_id NOT IN ({$excluded_coupons}))"; } $included_products = $this->get_included_products( $query_args ); $excluded_products = $this->get_excluded_products( $query_args ); if ( $included_products || $excluded_products ) { $this->subquery->add_sql_clause( 'join', "LEFT JOIN {$order_product_lookup_table} product_lookup" ); $this->subquery->add_sql_clause( 'join', "ON {$order_stats_lookup_table}.order_id = product_lookup.order_id" ); } if ( $included_products ) { $this->subquery->add_sql_clause( 'join', "AND product_lookup.product_id IN ({$included_products})" ); $where_subquery[] = 'product_lookup.order_id IS NOT NULL'; } if ( $excluded_products ) { $this->subquery->add_sql_clause( 'join', "AND product_lookup.product_id IN ({$excluded_products})" ); $where_subquery[] = 'product_lookup.order_id IS NULL'; } $included_variations = $this->get_included_variations( $query_args ); $excluded_variations = $this->get_excluded_variations( $query_args ); if ( $included_variations || $excluded_variations ) { $this->subquery->add_sql_clause( 'join', "LEFT JOIN {$order_product_lookup_table} variation_lookup" ); $this->subquery->add_sql_clause( 'join', "ON {$order_stats_lookup_table}.order_id = variation_lookup.order_id" ); } if ( $included_variations ) { $this->subquery->add_sql_clause( 'join', "AND variation_lookup.variation_id IN ({$included_variations})" ); $where_subquery[] = 'variation_lookup.order_id IS NOT NULL'; } if ( $excluded_variations ) { $this->subquery->add_sql_clause( 'join', "AND variation_lookup.variation_id IN ({$excluded_variations})" ); $where_subquery[] = 'variation_lookup.order_id IS NULL'; } $included_tax_rates = ! empty( $query_args['tax_rate_includes'] ) ? implode( ',', array_map( 'esc_sql', $query_args['tax_rate_includes'] ) ) : false; $excluded_tax_rates = ! empty( $query_args['tax_rate_excludes'] ) ? implode( ',', array_map( 'esc_sql', $query_args['tax_rate_excludes'] ) ) : false; if ( $included_tax_rates || $excluded_tax_rates ) { $this->subquery->add_sql_clause( 'join', "LEFT JOIN {$order_tax_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_tax_lookup_table}.order_id" ); } if ( $included_tax_rates ) { $where_subquery[] = "{$order_tax_lookup_table}.tax_rate_id IN ({$included_tax_rates})"; } if ( $excluded_tax_rates ) { $where_subquery[] = "{$order_tax_lookup_table}.tax_rate_id NOT IN ({$excluded_tax_rates}) OR {$order_tax_lookup_table}.tax_rate_id IS NULL"; } $attribute_subqueries = $this->get_attribute_subqueries( $query_args ); if ( $attribute_subqueries['join'] && $attribute_subqueries['where'] ) { $this->subquery->add_sql_clause( 'join', "JOIN {$order_product_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_product_lookup_table}.order_id" ); // Add JOINs for matching attributes. foreach ( $attribute_subqueries['join'] as $attribute_join ) { $this->subquery->add_sql_clause( 'join', $attribute_join ); } // Add WHEREs for matching attributes. $where_subquery = array_merge( $where_subquery, $attribute_subqueries['where'] ); } if ( 0 < count( $where_subquery ) ) { $this->subquery->add_sql_clause( 'where', 'AND (' . implode( " {$operator} ", $where_subquery ) . ')' ); } } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override ReportsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = array_merge( parent::get_default_query_vars(), array( 'orderby' => $this->date_column_name, 'product_includes' => array(), 'product_excludes' => array(), 'coupon_includes' => array(), 'coupon_excludes' => array(), 'tax_rate_includes' => array(), 'tax_rate_excludes' => array(), 'customer_type' => null, 'status_is' => array(), 'extended_info' => false, 'refunds' => null, 'order_includes' => array(), 'order_excludes' => array(), ) ); return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override ReportsDataStore::get_noncached_data() * * @see get_data * @param array $query_args Query parameters. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_data( $query_args ) { global $wpdb; $this->initialize_queries(); $data = (object) array( 'data' => array(), 'total' => 0, 'pages' => 0, 'page_no' => 0, ); $selections = $this->selected_columns( $query_args ); $params = $this->get_limit_params( $query_args ); $this->add_sql_query_params( $query_args ); /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ $db_records_count = (int) $wpdb->get_var( "SELECT COUNT( DISTINCT tt.order_id ) FROM ( {$this->subquery->get_query_statement()} ) AS tt" ); /* phpcs:enable */ if ( 0 === $params['per_page'] ) { $total_pages = 0; } else { $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); } if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { $data = (object) array( 'data' => array(), 'total' => $db_records_count, 'pages' => 0, 'page_no' => 0, ); return $data; } $this->subquery->clear_sql_clause( 'select' ); $this->subquery->add_sql_clause( 'select', $selections ); $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); /* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */ $orders_data = $wpdb->get_results( $this->subquery->get_query_statement(), ARRAY_A ); /* phpcs:enable */ if ( null === $orders_data ) { return $data; } if ( $query_args['extended_info'] ) { $this->include_extended_info( $orders_data, $query_args ); } $orders_data = array_map( array( $this, 'cast_numbers' ), $orders_data ); $data = (object) array( 'data' => $orders_data, 'total' => $db_records_count, 'pages' => $total_pages, 'page_no' => (int) $query_args['page'], ); return $data; } /** * Normalizes order_by clause to match to SQL query. * * @override ReportsDataStore::normalize_order_by() * * @param string $order_by Order by option requeste by user. * @return string */ protected function normalize_order_by( $order_by ) { if ( 'date' === $order_by ) { return $this->date_column_name; } return $order_by; } /** * Enriches the order data. * * @param array $orders_data Orders data. * @param array $query_args Query parameters. */ protected function include_extended_info( &$orders_data, $query_args ) { $mapped_orders = $this->map_array_by_key( $orders_data, 'order_id' ); $related_orders = $this->get_orders_with_parent_id( $mapped_orders ); $order_ids = array_merge( array_keys( $mapped_orders ), array_keys( $related_orders ) ); $products = $this->get_products_by_order_ids( $order_ids ); $coupons = $this->get_coupons_by_order_ids( array_keys( $mapped_orders ) ); $order_attributions = $this->get_order_attributions_by_order_ids( array_keys( $mapped_orders ) ); $customers = $this->get_customers_by_orders( $orders_data ); $mapped_customers = $this->map_array_by_key( $customers, 'customer_id' ); $mapped_data = array(); foreach ( $products as $product ) { if ( ! isset( $mapped_data[ $product['order_id'] ] ) ) { $mapped_data[ $product['order_id'] ]['products'] = array(); } $is_variation = '0' !== $product['variation_id']; $product_data = array( 'id' => $is_variation ? $product['variation_id'] : $product['product_id'], 'name' => $product['product_name'], 'quantity' => $product['product_quantity'], ); if ( $is_variation ) { $variation = wc_get_product( $product_data['id'] ); /** * Used to determine the separator for products and their variations titles. * * @since 4.0.0 */ $separator = apply_filters( 'woocommerce_product_variation_title_attributes_separator', ' - ', $variation ); if ( false === strpos( $product_data['name'], $separator ) ) { $attributes = wc_get_formatted_variation( $variation, true, false ); $product_data['name'] .= $separator . $attributes; } } $mapped_data[ $product['order_id'] ]['products'][] = $product_data; // If this product's order has another related order, it will be added to our mapped_data. if ( isset( $related_orders [ $product['order_id'] ] ) ) { $mapped_data[ $related_orders[ $product['order_id'] ]['order_id'] ] ['products'] [] = $product_data; } } foreach ( $coupons as $coupon ) { if ( ! isset( $mapped_data[ $coupon['order_id'] ] ) ) { $mapped_data[ $coupon['order_id'] ]['coupons'] = array(); } $mapped_data[ $coupon['order_id'] ]['coupons'][] = array( 'id' => $coupon['coupon_id'], 'code' => wc_format_coupon_code( $coupon['coupon_code'] ), ); } foreach ( $orders_data as $key => $order_data ) { $defaults = array( 'products' => array(), 'coupons' => array(), 'customer' => array(), 'attribution' => array(), ); $order_id = $order_data['order_id']; $orders_data[ $key ]['extended_info'] = isset( $mapped_data[ $order_id ] ) ? array_merge( $defaults, $mapped_data[ $order_id ] ) : $defaults; if ( $order_data['customer_id'] && isset( $mapped_customers[ $order_data['customer_id'] ] ) ) { $orders_data[ $key ]['extended_info']['customer'] = $mapped_customers[ $order_data['customer_id'] ]; } $source_type = $order_attributions[ $order_id ]['_wc_order_attribution_source_type'] ?? ''; $utm_source = $order_attributions[ $order_id ]['_wc_order_attribution_utm_source'] ?? ''; $orders_data[ $key ]['extended_info']['attribution']['origin'] = $this->get_origin_label( $source_type, $utm_source ); } } /** * Returns oreders that have a parent id * * @param array $orders Orders array. * @return array */ protected function get_orders_with_parent_id( $orders ) { $related_orders = array(); foreach ( $orders as $order ) { if ( '0' !== $order['parent_id'] ) { $related_orders[ $order['parent_id'] ] = $order; } } return $related_orders; } /** * Returns the same array index by a given key * * @param array $array Array to be looped over. * @param string $key Key of values used for new array. * @return array */ protected function map_array_by_key( $array, $key ) { $mapped = array(); foreach ( $array as $item ) { $mapped[ $item[ $key ] ] = $item; } return $mapped; } /** * Get product IDs, names, and quantity from order IDs. * * @param array $order_ids Array of order IDs. * @return array */ protected function get_products_by_order_ids( $order_ids ) { global $wpdb; $order_product_lookup_table = $wpdb->prefix . 'wc_order_product_lookup'; $included_order_ids = implode( ',', $order_ids ); /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ $products = $wpdb->get_results( "SELECT order_id, product_id, variation_id, post_title as product_name, product_qty as product_quantity FROM {$wpdb->posts} JOIN {$order_product_lookup_table} ON {$wpdb->posts}.ID = ( CASE WHEN variation_id > 0 THEN variation_id ELSE product_id END ) WHERE order_id IN ({$included_order_ids}) AND product_qty > 0 ", ARRAY_A ); /* phpcs:enable */ return $products; } /** * Get customer data from Order data. * * @param array $orders Array of orders data. * @return array */ protected function get_customers_by_orders( $orders ) { global $wpdb; $customer_lookup_table = $wpdb->prefix . 'wc_customer_lookup'; $customer_ids = array(); foreach ( $orders as $order ) { if ( $order['customer_id'] ) { $customer_ids[] = intval( $order['customer_id'] ); } } if ( empty( $customer_ids ) ) { return array(); } /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ $customer_ids = implode( ',', $customer_ids ); $customers = $wpdb->get_results( "SELECT * FROM {$customer_lookup_table} WHERE customer_id IN ({$customer_ids})", ARRAY_A ); /* phpcs:enable */ return $customers; } /** * Get coupon information from order IDs. * * @param array $order_ids Array of order IDs. * @return array */ protected function get_coupons_by_order_ids( $order_ids ) { global $wpdb; $order_coupon_lookup_table = $wpdb->prefix . 'wc_order_coupon_lookup'; $included_order_ids = implode( ',', $order_ids ); /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ $coupons = $wpdb->get_results( "SELECT order_id, coupon_id, post_title as coupon_code FROM {$wpdb->posts} JOIN {$order_coupon_lookup_table} ON {$order_coupon_lookup_table}.coupon_id = {$wpdb->posts}.ID WHERE order_id IN ({$included_order_ids}) ", ARRAY_A ); /* phpcs:enable */ return $coupons; } /** * Get order attributions data from order IDs. * * @param array $order_ids Array of order IDs. * @return array */ protected function get_order_attributions_by_order_ids( $order_ids ) { global $wpdb; $order_meta_table = OrdersTableDataStore::get_meta_table_name(); $included_order_ids = implode( ',', array_map( 'absint', $order_ids ) ); if ( OrderUtil::custom_orders_table_usage_is_enabled() ) { /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ $order_attributions_meta = $wpdb->get_results( "SELECT order_id, meta_key, meta_value FROM $order_meta_table WHERE order_id IN ({$included_order_ids}) AND meta_key IN ( '_wc_order_attribution_source_type', '_wc_order_attribution_utm_source' ) ", ARRAY_A ); /* phpcs:enable */ } else { /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ $order_attributions_meta = $wpdb->get_results( "SELECT post_id as order_id, meta_key, meta_value FROM $wpdb->postmeta WHERE post_id IN ({$included_order_ids}) AND meta_key IN ( '_wc_order_attribution_source_type', '_wc_order_attribution_utm_source' ) ", ARRAY_A ); /* phpcs:enable */ } $order_attributions = array(); foreach ( $order_attributions_meta as $meta ) { if ( ! isset( $order_attributions[ $meta['order_id'] ] ) ) { $order_attributions[ $meta['order_id'] ] = array(); } $order_attributions[ $meta['order_id'] ][ $meta['meta_key'] ] = $meta['meta_value']; } return $order_attributions; } /** * Get all statuses that have been synced. * * @return string[] Unique order statuses. */ public static function get_all_statuses() { global $wpdb; $statuses = wp_cache_get( self::ORDERS_STATUSES_ALL_CACHE_KEY, 'woocommerce_analytics' ); if ( false === $statuses ) { $table_name = self::get_db_table_name(); $statuses = $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT status FROM %i', $table_name ) ); wp_cache_set( self::ORDERS_STATUSES_ALL_CACHE_KEY, $statuses, 'woocommerce_analytics', YEAR_IN_SECONDS ); } return $statuses; } /** * Ensure the order status will present in `get_all_statuses` call result. * * @internal * @param int $order_id Order ID. * @return void */ public static function maybe_update_order_statuses_cache( $order_id ) { $order = wc_get_order( $order_id ); if ( $order ) { $status = self::normalize_order_status( $order->get_status() ); $statuses = self::get_all_statuses(); if ( ! in_array( $status, $statuses, true ) ) { $statuses[] = $status; wp_cache_set( self::ORDERS_STATUSES_ALL_CACHE_KEY, $statuses, 'woocommerce_analytics', YEAR_IN_SECONDS ); } } } /** * Ensure the order status will present in `get_all_statuses` call result. * * @deprecated 10.3.0 Use maybe_update_order_statuses_cache(). * @param int $order_id Order ID. * @return void */ public static function maybe_update_order_statuses_transient( $order_id ) { wc_deprecated_function( __METHOD__, '10.3.0', __CLASS__ . '::maybe_update_order_statuses_cache()' ); self::maybe_update_order_statuses_cache( $order_id ); } /** * Initialize query objects. */ protected function initialize_queries() { $this->clear_all_clauses(); $this->subquery = new SqlQuery( $this->context . '_subquery' ); $this->subquery->add_sql_clause( 'select', self::get_db_table_name() . '.order_id' ); $this->subquery->add_sql_clause( 'from', self::get_db_table_name() ); } } API/Reports/OrderAwareControllerTrait.php 0000777 00000007361 15252240713 0014446 0 ustar 00 <?php declare( strict_types = 1); namespace Automattic\WooCommerce\Admin\API\Reports; // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Trait to contain shared methods for reports Controllers that use order and orders statuses. * * If your analytics controller needs to work with orders, * you will most probably need to use at least {@see get_order_statuses() get_order_statuses()} * to filter only "actionable" statuses to produce consistent results among other analytics. * * @see GenericController */ trait OrderAwareControllerTrait { /** * Get the order number for an order. If no filter is present for `woocommerce_order_number`, we can just return the ID. * Returns the parent order number if the order is actually a refund. * * @param int $order_id Order ID. * @return string|null The Order Number or null if the order doesn't exist. */ protected function get_order_number( $order_id ) { $order = wc_get_order( $order_id ); if ( ! $this->is_valid_order( $order ) ) { return null; } if ( 'shop_order_refund' === $order->get_type() ) { $order = wc_get_order( $order->get_parent_id() ); // If the parent order doesn't exist, return null. if ( ! $this->is_valid_order( $order ) ) { return null; } } if ( ! has_filter( 'woocommerce_order_number' ) ) { return $order->get_id(); } return $order->get_order_number(); } /** * Whether the order is valid. * * @param bool|WC_Order|WC_Order_Refund $order Order object. * @return bool True if the order is valid, false otherwise. */ protected function is_valid_order( $order ) { return $order instanceof \WC_Order || $order instanceof \WC_Order_Refund; } /** * Get the order total with the related currency formatting. * Returns the parent order total if the order is actually a refund. * * @param int $order_id Order ID. * @return string|null The Order Number or null if the order doesn't exist. */ protected function get_total_formatted( $order_id ) { $order = wc_get_order( $order_id ); if ( ! $this->is_valid_order( $order ) ) { return null; } if ( 'shop_order_refund' === $order->get_type() ) { $order = wc_get_order( $order->get_parent_id() ); if ( ! $this->is_valid_order( $order ) ) { return null; } } return wp_strip_all_tags( html_entity_decode( $order->get_formatted_order_total() ), true ); } /** * Get order statuses without prefixes. * Includes unregistered statuses that have been marked "actionable". * * @return array */ public static function get_order_statuses() { // Allow all statuses selected as "actionable" - this may include unregistered statuses. // See: https://github.com/woocommerce/woocommerce-admin/issues/5592. $actionable_statuses = get_option( 'woocommerce_actionable_order_statuses', array() ); // Prevent errors if the database entry is not the expected type (array). if ( ! is_array( $actionable_statuses ) ) { $actionable_statuses = array(); } // See WC_REST_Orders_V2_Controller::get_collection_params() re: any/trash statuses. $registered_statuses = array_merge( array( 'any', 'trash' ), array_keys( self::get_order_status_labels() ) ); // Merge the status arrays (using flip to avoid array_unique()). $allowed_statuses = array_keys( array_merge( array_flip( $registered_statuses ), array_flip( $actionable_statuses ) ) ); return $allowed_statuses; } /** * Get order statuses (and labels) without prefixes. * * @internal * @return array */ public static function get_order_status_labels() { $order_statuses = array(); foreach ( wc_get_order_statuses() as $key => $label ) { $new_key = str_replace( 'wc-', '', $key ); $order_statuses[ $new_key ] = $label; } return $order_statuses; } } API/Reports/PerformanceIndicators/Controller.php 0000777 00000046466 15252240713 0015760 0 ustar 00 <?php /** * REST API Performance indicators controller * * Handles requests to the /reports/store-performance endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\PerformanceIndicators; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use WP_REST_Request; use WP_REST_Response; defined( 'ABSPATH' ) || exit; /** * REST API Reports Performance indicators controller class. * * @internal * @extends GenericController */ class Controller extends GenericController { /** * Route base. * * @var string */ protected $rest_base = 'reports/performance-indicators'; /** * Contains a list of endpoints by report slug. * * @var array */ protected $endpoints = array(); /** * Contains a list of active Jetpack module slugs. * * @var array */ protected $active_jetpack_modules = null; /** * Contains a list of allowed stats. * * @var array */ protected $allowed_stats = array(); /** * Contains a list of stat labels. * * @var array */ protected $labels = array(); /** * Contains a list of endpoints by url. * * @var array */ protected $urls = array(); /** * Contains a cache of retrieved stats data, grouped by report slug. * * @var array */ protected $stats_data = array(); /** * Constructor. */ public function __construct() { add_filter( 'woocommerce_rest_performance_indicators_data_value', array( $this, 'format_data_value' ), 10, 5 ); } /** * Register the routes for reports. */ public function register_routes() { parent::register_routes(); register_rest_route( $this->namespace, '/' . $this->rest_base . '/allowed', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_allowed_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_allowed_item_schema' ), ) ); } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['before'] = $request['before']; $args['after'] = $request['after']; $args['stats'] = $request['stats']; return $args; } /** * Get analytics report data and endpoints. */ private function get_analytics_report_data() { $request = new \WP_REST_Request( 'GET', '/wc-analytics/reports' ); /** * Performance hack to strip the `rel=self` link from the report response as it is built by the Reports/Controller * to avoid the expensive calls to WP_REST_Server::get_target_hints_for_link(). * * @param WP_REST_Response $response The response object. * * @return mixed */ $remove_self_link_from_prepared_internal_response = function ( $response ) { if ( is_callable( array( $response, 'remove_link' ) ) ) { $response->remove_link( 'self' ); } return $response; }; add_filter( 'woocommerce_rest_prepare_report', $remove_self_link_from_prepared_internal_response ); $response = rest_do_request( $request ); remove_filter( 'woocommerce_rest_prepare_report', $remove_self_link_from_prepared_internal_response ); if ( is_wp_error( $response ) ) { return $response; } if ( 200 !== $response->get_status() ) { return new \WP_Error( 'woocommerce_analytics_performance_indicators_result_failed', __( 'Sorry, fetching performance indicators failed.', 'woocommerce' ) ); } $endpoints = $response->get_data(); foreach ( $endpoints as $endpoint ) { if ( '/stats' === substr( $endpoint['slug'], -6 ) ) { $request = new \WP_REST_Request( 'OPTIONS', $endpoint['path'] ); $response = rest_do_request( $request ); if ( is_wp_error( $response ) ) { return $response; } $data = $response->get_data(); $prefix = substr( $endpoint['slug'], 0, -6 ); if ( empty( $data['schema']['properties']['totals']['properties'] ) ) { continue; } foreach ( $data['schema']['properties']['totals']['properties'] as $property_key => $schema_info ) { if ( empty( $schema_info['indicator'] ) || ! $schema_info['indicator'] ) { continue; } $stat = $prefix . '/' . $property_key; $this->allowed_stats[] = $stat; $stat_label = empty( $schema_info['title'] ) ? $schema_info['description'] : $schema_info['title']; $this->labels[ $stat ] = trim( $stat_label, '.' ); $this->formats[ $stat ] = isset( $schema_info['format'] ) ? $schema_info['format'] : 'number'; } $this->endpoints[ $prefix ] = $endpoint['path']; $this->urls[ $prefix ] = $endpoint['_links']['report'][0]['href']; } } } /** * Get active Jetpack modules. * * @return array List of active Jetpack module slugs. */ private function get_active_jetpack_modules() { if ( is_null( $this->active_jetpack_modules ) ) { if ( class_exists( '\Jetpack' ) && method_exists( '\Jetpack', 'get_active_modules' ) ) { $active_modules = \Jetpack::get_active_modules(); $this->active_jetpack_modules = is_array( $active_modules ) ? $active_modules : array(); } else { $this->active_jetpack_modules = array(); } } return $this->active_jetpack_modules; } /** * Set active Jetpack modules. * * @internal * @param array $modules List of active Jetpack module slugs. */ public function set_active_jetpack_modules( $modules ) { $this->active_jetpack_modules = $modules; } /** * Get active Jetpack modules and endpoints. */ private function get_jetpack_modules_data() { $active_modules = $this->get_active_jetpack_modules(); if ( empty( $active_modules ) ) { return; } $items = apply_filters( 'woocommerce_rest_performance_indicators_jetpack_items', array( 'stats/visitors' => array( 'label' => __( 'Visitors', 'woocommerce' ), 'permission' => 'view_stats', 'format' => 'number', 'module' => 'stats', ), 'stats/views' => array( 'label' => __( 'Views', 'woocommerce' ), 'permission' => 'view_stats', 'format' => 'number', 'module' => 'stats', ), ) ); foreach ( $items as $item_key => $item ) { if ( ! in_array( $item['module'], $active_modules, true ) ) { return; } if ( $item['permission'] && ! current_user_can( $item['permission'] ) ) { return; } $stat = 'jetpack/' . $item_key; $endpoint = 'jetpack/' . $item['module']; $this->allowed_stats[] = $stat; $this->labels[ $stat ] = $item['label']; $this->endpoints[ $endpoint ] = '/jetpack/v4/module/' . $item['module'] . '/data'; $this->formats[ $stat ] = $item['format']; } $this->urls['jetpack/stats'] = '/jetpack'; } /** * Get information such as allowed stats, stat labels, and endpoint data from stats reports. * * @return WP_Error|True */ private function get_indicator_data() { // Data already retrieved. if ( ! empty( $this->endpoints ) && ! empty( $this->labels ) && ! empty( $this->allowed_stats ) ) { return true; } $this->get_analytics_report_data(); $this->get_jetpack_modules_data(); return true; } /** * Returns a list of allowed performance indicators. * * @param WP_REST_Request $request Request data. * @return array|WP_Error */ public function get_allowed_items( $request ) { $indicator_data = $this->get_indicator_data(); if ( is_wp_error( $indicator_data ) ) { return $indicator_data; } $data = array(); foreach ( $this->allowed_stats as $stat ) { $pieces = $this->get_stats_parts( $stat ); $report = $pieces[0]; $chart = $pieces[1]; $data[] = (object) array( 'stat' => $stat, 'chart' => $chart, 'label' => $this->labels[ $stat ], ); } usort( $data, array( $this, 'sort' ) ); $objects = array(); foreach ( $data as $item ) { $prepared = $this->prepare_item_for_response( $item, $request ); $objects[] = $this->prepare_response_for_collection( $prepared ); } return $this->add_pagination_headers( $request, $objects, (int) count( $data ), 1, 1 ); } /** * Sorts the list of stats. Sorted by custom arrangement. * * @internal * @see https://github.com/woocommerce/woocommerce-admin/issues/1282 * @param object $a First item. * @param object $b Second item. * @return order */ public function sort( $a, $b ) { /** * Custom ordering for store performance indicators. * * @see https://github.com/woocommerce/woocommerce-admin/issues/1282 * @param array $indicators A list of ordered indicators. */ $stat_order = apply_filters( 'woocommerce_rest_report_sort_performance_indicators', array( 'revenue/total_sales', 'revenue/net_revenue', 'orders/orders_count', 'orders/avg_order_value', 'products/items_sold', 'revenue/refunds', 'coupons/orders_count', 'coupons/amount', 'taxes/total_tax', 'taxes/order_tax', 'taxes/shipping_tax', 'revenue/shipping', 'downloads/download_count', ) ); $a = array_search( $a->stat, $stat_order, true ); $b = array_search( $b->stat, $stat_order, true ); if ( false === $a && false === $b ) { return 0; } elseif ( false === $a ) { return 1; } elseif ( false === $b ) { return -1; } else { return $a - $b; } } /** * Get report stats data, avoiding duplicate requests for stats that use the same endpoint. * * @param string $report Report slug to request data for. * @param array $query_args Report query args. * @return WP_REST_Response|WP_Error Report stats data. */ private function get_stats_data( $report, $query_args ) { // Return from cache if we've already requested these report stats. if ( isset( $this->stats_data[ $report ] ) ) { return $this->stats_data[ $report ]; } // Request the report stats. $request_url = $this->endpoints[ $report ]; $request = new \WP_REST_Request( 'GET', $request_url ); $request->set_param( 'before', $query_args['before'] ); $request->set_param( 'after', $query_args['after'] ); $response = rest_do_request( $request ); // Cache the response. $this->stats_data[ $report ] = $response; return $response; } /** * Get all reports. * * @param WP_REST_Request $request Request data. * @return array|WP_Error */ public function get_items( $request ) { $indicator_data = $this->get_indicator_data(); if ( is_wp_error( $indicator_data ) ) { return $indicator_data; } $query_args = $this->prepare_reports_query( $request ); if ( empty( $query_args['stats'] ) ) { return new \WP_Error( 'woocommerce_analytics_performance_indicators_empty_query', __( 'A list of stats to query must be provided.', 'woocommerce' ), 400 ); } $stats = array(); foreach ( $query_args['stats'] as $stat ) { $is_error = false; $pieces = $this->get_stats_parts( $stat ); $report = $pieces[0]; $chart = $pieces[1]; if ( ! in_array( $stat, $this->allowed_stats, true ) ) { continue; } $response = $this->get_stats_data( $report, $query_args ); if ( is_wp_error( $response ) ) { return $response; } $data = $response->get_data(); $format = $this->formats[ $stat ]; $label = $this->labels[ $stat ]; if ( 200 !== $response->get_status() ) { $stats[] = (object) array( 'stat' => $stat, 'chart' => $chart, 'label' => $label, 'format' => $format, 'value' => null, ); continue; } $stats[] = (object) array( 'stat' => $stat, 'chart' => $chart, 'label' => $label, 'format' => $format, 'value' => apply_filters( 'woocommerce_rest_performance_indicators_data_value', $data, $stat, $report, $chart, $query_args ), ); } usort( $stats, array( $this, 'sort' ) ); $objects = array(); foreach ( $stats as $stat ) { $data = $this->prepare_item_for_response( $stat, $request ); $objects[] = $this->prepare_response_for_collection( $data ); } $response = rest_ensure_response( $objects ); $response->header( 'X-WP-Total', count( $stats ) ); $response->header( 'X-WP-TotalPages', 1 ); $base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) ); return $response; } /** * Prepare a report data item for serialization. * * @param array $stat_data Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $stat_data, $request ) { $response = parent::prepare_item_for_response( $stat_data, $request ); $response->add_links( $this->prepare_links( $stat_data ) ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_performance_indicators', $response, $stat_data, $request ); } /** * Prepare links for the request. * * @param object $object data. * @return array */ protected function prepare_links( $object ) { $pieces = $this->get_stats_parts( $object->stat ); $endpoint = $pieces[0]; $stat = $pieces[1]; $url = isset( $this->urls[ $endpoint ] ) ? $this->urls[ $endpoint ] : ''; $links = array( 'api' => array( 'href' => rest_url( $this->endpoints[ $endpoint ] ), ), 'report' => array( 'href' => $url, ), ); return $links; } /** * Returns the endpoint part of a stat request (prefix) and the actual stat total we want. * To allow extensions to namespace (example: fue/emails/sent), we break on the last forward slash. * * @param string $full_stat A stat request string like orders/avg_order_value or fue/emails/sent. * @return array Containing the prefix (endpoint) and suffix (stat). */ private function get_stats_parts( $full_stat ) { $endpoint = substr( $full_stat, 0, strrpos( $full_stat, '/' ) ); $stat = substr( $full_stat, ( strrpos( $full_stat, '/' ) + 1 ) ); return array( $endpoint, $stat, ); } /** * Format the data returned from the API for given stats. * * @param array $data Data from external endpoint. * @param string $stat Name of the stat. * @param string $report Name of the report. * @param string $chart Name of the chart. * @param array $query_args Query args. * @return mixed */ public function format_data_value( $data, $stat, $report, $chart, $query_args ) { if ( 'jetpack/stats' === $report ) { $index = false; // Get the index of the field to tally. if ( isset( $data['general']->visits->fields ) && is_array( $data['general']->visits->fields ) ) { $index = array_search( $chart, $data['general']->visits->fields, true ); } if ( ! $index ) { return null; } // Loop over provided data and filter by the queried date. // Note that this is currently limited to 30 days via the Jetpack API // but the WordPress.com endpoint allows up to 90 days. $total = 0; $before = gmdate( 'Y-m-d', strtotime( isset( $query_args['before'] ) ? $query_args['before'] : TimeInterval::default_before() ) ); $after = gmdate( 'Y-m-d', strtotime( isset( $query_args['after'] ) ? $query_args['after'] : TimeInterval::default_after() ) ); foreach ( $data['general']->visits->data as $datum ) { if ( $datum[0] >= $after && $datum[0] <= $before ) { $total += $datum[ $index ]; } } return $total; } if ( isset( $data['totals'] ) && isset( $data['totals'][ $chart ] ) ) { return $data['totals'][ $chart ]; } return null; } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $indicator_data = $this->get_indicator_data(); if ( is_wp_error( $indicator_data ) ) { $allowed_stats = array(); } else { $allowed_stats = $this->allowed_stats; } $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_performance_indicator', 'type' => 'object', 'properties' => array( 'stat' => array( 'description' => __( 'Unique identifier for the resource.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'enum' => $allowed_stats, ), 'chart' => array( 'description' => __( 'The specific chart this stat referrers to.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'label' => array( 'description' => __( 'Human readable label for the stat.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'format' => array( 'description' => __( 'Format of the stat.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'enum' => array( 'number', 'currency' ), ), 'value' => array( 'description' => __( 'Value of the stat. Returns null if the stat does not exist or cannot be loaded.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get schema for the list of allowed performance indicators. * * @return array $schema */ public function get_public_allowed_item_schema() { $schema = $this->get_public_item_schema(); unset( $schema['properties']['value'] ); unset( $schema['properties']['format'] ); return $schema; } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $indicator_data = $this->get_indicator_data(); if ( is_wp_error( $indicator_data ) ) { $allowed_stats = __( 'There was an issue loading the report endpoints', 'woocommerce' ); } else { $allowed_stats = implode( ', ', $this->allowed_stats ); } $params = array(); $params['context'] = $this->get_context_param( array( 'default' => 'view' ) ); $params['stats'] = array( 'description' => sprintf( /* translators: Allowed values is a list of stat endpoints. */ __( 'Limit response to specific report stats. Allowed values: %s.', 'woocommerce' ), $allowed_stats ), 'type' => 'array', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', 'enum' => $this->allowed_stats, ), 'default' => $this->allowed_stats, ); $params['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', ); $params['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', ); return $params; } } API/Reports/Products/Stats/Query.php 0000777 00000003720 15252240713 0013344 0 ustar 00 <?php /** * Class for parameter-based Products Stats Report querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'page' => 2, * 'categories' => array(15, 18), * 'product_ids' => array(1,2,3) * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Products\Stats\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Products\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Products\Stats\Query * * @deprecated 9.3.0 Products\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Products report. * * @deprecated 9.3.0 Products\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array(); } /** * Get product data based on the current query vars. * * @deprecated 9.3.0 Products\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_products_stats_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-products-stats' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_products_stats_select_query', $results, $args ); } } API/Reports/Products/Stats/Segmenter.php 0000777 00000024401 15252240713 0014167 0 ustar 00 <?php /** * Class for adding segmenting support without cluttering the data stores. */ namespace Automattic\WooCommerce\Admin\API\Reports\Products\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Segmenter as ReportsSegmenter; use Automattic\WooCommerce\Admin\API\Reports\ParameterException; /** * Date & time interval and numeric range handling class for Reporting API. */ class Segmenter extends ReportsSegmenter { /** * Returns column => query mapping to be used for product-related product-level segmenting query * (e.g. products sold, revenue from product X when segmenting by category). * * @param string $products_table Name of SQL table containing the product-level segmenting info. * * @return array Column => SELECT query mapping. */ protected function get_segment_selections_product_level( $products_table ) { $columns_mapping = array( 'items_sold' => "SUM($products_table.product_qty) as items_sold", 'net_revenue' => "SUM($products_table.product_net_revenue ) AS net_revenue", 'orders_count' => "COUNT( DISTINCT $products_table.order_id ) AS orders_count", 'products_count' => "COUNT( DISTINCT $products_table.product_id ) AS products_count", 'variations_count' => "COUNT( DISTINCT $products_table.variation_id ) AS variations_count", ); return $columns_mapping; } /** * Calculate segments for totals where the segmenting property is bound to product (e.g. category, product_id, variation_id). * * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $segmenting_dimension_name Name of the segmenting dimension. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $totals_query Array of SQL clauses for totals query. * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. * * @return array */ protected function get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $totals_query, $unique_orders_table ) { global $wpdb; $product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup'; // Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued). // Product-level numbers. $segments_products = $wpdb->get_results( "SELECT $segmenting_groupby AS $segmenting_dimension_name {$segmenting_selections['product_level']} FROM $table_name $segmenting_from {$totals_query['from_clause']} WHERE 1=1 {$totals_query['where_time_clause']} {$totals_query['where_clause']} $segmenting_where GROUP BY $segmenting_groupby", ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. $totals_segments = $this->merge_segment_totals_results( $segmenting_dimension_name, $segments_products, array() ); return $totals_segments; } /** * Calculate segments for intervals where the segmenting property is bound to product (e.g. category, product_id, variation_id). * * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $segmenting_dimension_name Name of the segmenting dimension. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $intervals_query Array of SQL clauses for intervals query. * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. * * @return array */ protected function get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $intervals_query, $unique_orders_table ) { global $wpdb; $product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup'; // LIMIT offset, rowcount needs to be updated to a multiple of the number of segments. preg_match( '/LIMIT (\d+)\s?,\s?(\d+)/', $intervals_query['limit'], $limit_parts ); $segment_count = count( $this->get_all_segments() ); $orig_offset = intval( $limit_parts[1] ); $orig_rowcount = intval( $limit_parts[2] ); $segmenting_limit = $wpdb->prepare( 'LIMIT %d, %d', $orig_offset * $segment_count, $orig_rowcount * $segment_count ); // Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued). // Product-level numbers. $segments_products = $wpdb->get_results( "SELECT {$intervals_query['select_clause']} AS time_interval, $segmenting_groupby AS $segmenting_dimension_name {$segmenting_selections['product_level']} FROM $table_name $segmenting_from {$intervals_query['from_clause']} WHERE 1=1 {$intervals_query['where_time_clause']} {$intervals_query['where_clause']} $segmenting_where GROUP BY time_interval, $segmenting_groupby $segmenting_limit", ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. $intervals_segments = $this->merge_segment_intervals_results( $segmenting_dimension_name, $segments_products, array() ); return $intervals_segments; } /** * Return array of segments formatted for REST response. * * @param string $type Type of segments to return--'totals' or 'intervals'. * @param array $query_params SQL query parameter array. * @param string $table_name Name of main SQL table for the data store (used as basis for JOINS). * * @return array * @throws \Automattic\WooCommerce\Admin\API\Reports\ParameterException In case of segmenting by variations, when no parent product is specified. */ protected function get_segments( $type, $query_params, $table_name ) { global $wpdb; if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) { return array(); } $product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup'; $unique_orders_table = 'uniq_orders'; $segmenting_where = ''; // Product, variation, and category are bound to product, so here product segmenting table is required, // while coupon and customer are bound to order, so we don't need the extra JOIN for those. // This also means that segment selections need to be calculated differently. if ( 'product' === $this->query_args['segmentby'] ) { $product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table ); $segmenting_selections = array( 'product_level' => $this->prepare_selections( $product_level_columns ), ); $this->report_columns = $product_level_columns; $segmenting_from = ''; $segmenting_groupby = $product_segmenting_table . '.product_id'; $segmenting_dimension_name = 'product_id'; $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } elseif ( 'variation' === $this->query_args['segmentby'] ) { if ( ! isset( $this->query_args['product_includes'] ) || ! is_array( $this->query_args['product_includes'] ) || count( $this->query_args['product_includes'] ) !== 1 ) { throw new ParameterException( 'wc_admin_reports_invalid_segmenting_variation', __( 'product_includes parameter need to specify exactly one product when segmenting by variation.', 'woocommerce' ) ); } $product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table ); $segmenting_selections = array( 'product_level' => $this->prepare_selections( $product_level_columns ), ); $this->report_columns = $product_level_columns; $segmenting_from = ''; $segmenting_where = "AND $product_segmenting_table.product_id = {$this->query_args['product_includes'][0]}"; $segmenting_groupby = $product_segmenting_table . '.variation_id'; $segmenting_dimension_name = 'variation_id'; $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } elseif ( 'category' === $this->query_args['segmentby'] ) { $product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table ); $segmenting_selections = array( 'product_level' => $this->prepare_selections( $product_level_columns ), ); $this->report_columns = $product_level_columns; $segmenting_from = " LEFT JOIN {$wpdb->term_relationships} ON {$product_segmenting_table}.product_id = {$wpdb->term_relationships}.object_id JOIN {$wpdb->term_taxonomy} ON {$wpdb->term_taxonomy}.term_taxonomy_id = {$wpdb->term_relationships}.term_taxonomy_id LEFT JOIN {$wpdb->wc_category_lookup} ON {$wpdb->term_taxonomy}.term_id = {$wpdb->wc_category_lookup}.category_id "; $segmenting_where = " AND {$wpdb->wc_category_lookup}.category_tree_id IS NOT NULL"; $segmenting_groupby = "{$wpdb->wc_category_lookup}.category_tree_id"; $segmenting_dimension_name = 'category_id'; // Restrict our search space for category comparisons. if ( isset( $this->query_args['category_includes'] ) ) { $category_ids = implode( ',', $this->get_all_segments() ); $segmenting_where .= " AND {$wpdb->wc_category_lookup}.category_id IN ( $category_ids )"; } $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } return $segments; } } API/Reports/Products/Stats/Controller.php 0000777 00000016340 15252240713 0014364 0 ustar 00 <?php /** * REST API Reports products stats controller * * Handles requests to the /reports/products/stats endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Products\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController; use WP_REST_Request; use WP_REST_Response; /** * REST API Reports products stats controller class. * * @internal * @extends GenericStatsController */ class Controller extends GenericStatsController { /** * Route base. * * @var string */ protected $rest_base = 'reports/products/stats'; /** * Mapping between external parameter name and name used in query class. * * @var array */ protected $param_mapping = array( 'categories' => 'category_includes', 'products' => 'product_includes', 'variations' => 'variation_includes', ); /** * Constructor. */ public function __construct() { add_filter( 'woocommerce_analytics_products_stats_select_query', array( $this, 'set_default_report_data' ) ); } /** * Get data from `'products-stats'` GenericQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new GenericQuery( $query_args, 'products-stats' ); return $query->get_data(); } /** * Maps query arguments from the REST request to be used to query the datastore. * * @param \WP_REST_Request $request Full request object. * @return array Simplified array of params. */ protected function prepare_reports_query( $request ) { $query_args = array( 'fields' => array( 'items_sold', 'net_revenue', 'orders_count', 'products_count', 'variations_count', ), ); $registered = array_keys( $this->get_collection_params() ); foreach ( $registered as $param_name ) { if ( isset( $request[ $param_name ] ) ) { if ( isset( $this->param_mapping[ $param_name ] ) ) { $query_args[ $this->param_mapping[ $param_name ] ] = $request[ $param_name ]; } else { $query_args[ $param_name ] = $request[ $param_name ]; } } } return $query_args; } /** * Prepare a report data item for serialization. * * @param array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $response = parent::prepare_item_for_response( $report, $request ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_products_stats', $response, $report, $request ); } /** * Get the Report's item properties schema. * Will be used by `get_item_schema` as `totals` and `subtotals`. * * @return array */ protected function get_item_properties_schema() { return array( 'items_sold' => array( 'title' => __( 'Products sold', 'woocommerce' ), 'description' => __( 'Number of product items sold.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, ), 'net_revenue' => array( 'description' => __( 'Net sales.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'format' => 'currency', ), 'orders_count' => array( 'description' => __( 'Number of orders.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ); } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = parent::get_item_schema(); $schema['title'] = 'report_products_stats'; $segment_label = array( 'description' => __( 'Human readable segment label, either product or variation name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'enum' => array( 'day', 'week', 'month', 'year' ), ); $schema['properties']['totals']['properties']['segments']['items']['properties']['segment_label'] = $segment_label; $schema['properties']['intervals']['items']['properties']['subtotals']['properties']['segments']['items']['properties']['segment_label'] = $segment_label; return $this->add_additional_fields_schema( $schema ); } /** * Set the default results to 0 if API returns an empty array * * @internal * @param Mixed $results Report data. * @return object */ public function set_default_report_data( $results ) { if ( empty( $results ) ) { $results = new \stdClass(); $results->total = 0; $results->totals = new \stdClass(); $results->totals->items_sold = 0; $results->totals->net_revenue = 0; $results->totals->orders_count = 0; $results->intervals = array(); $results->pages = 1; $results->page_no = 1; } return $results; } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'date', 'net_revenue', 'coupons', 'refunds', 'shipping', 'taxes', 'net_revenue', 'orders_count', 'items_sold', ) ); $params['categories'] = array( 'description' => __( 'Limit result to items from the specified categories.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['products'] = array( 'description' => __( 'Limit result to items with specified product ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['variations'] = array( 'description' => __( 'Limit result to items with specified variation ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['segmentby'] = array( 'description' => __( 'Segment the response by additional constraint.', 'woocommerce' ), 'type' => 'string', 'enum' => array( 'product', 'category', 'variation', ), 'validate_callback' => 'rest_validate_request_arg', ); return $params; } } API/Reports/Products/Stats/DataStore.php 0000777 00000023126 15252240713 0014127 0 ustar 00 <?php /** * API\Reports\Products\Stats\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Products\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Products\DataStore as ProductsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\StatsDataStoreTrait; /** * API\Reports\Products\Stats\DataStore. */ class DataStore extends ProductsDataStore implements DataStoreInterface { use StatsDataStoreTrait; /** * Mapping columns to data type to return correct response types. * * @override ProductsDataStore::$column_types * * @var array */ protected $column_types = array( 'date_start' => 'strval', 'date_end' => 'strval', 'product_id' => 'intval', 'items_sold' => 'intval', 'net_revenue' => 'floatval', 'orders_count' => 'intval', 'products_count' => 'intval', 'variations_count' => 'intval', ); /** * Cache identifier. * * @override ProductsDataStore::$cache_key * * @var string */ protected $cache_key = 'products_stats'; /** * Data store context used to pass to filters. * * @override ProductsDataStore::$context * * @var string */ protected $context = 'products_stats'; /** * Assign report columns once full table name has been assigned. * * @override ProductsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); $this->report_columns = array( 'items_sold' => 'SUM(product_qty) as items_sold', 'net_revenue' => 'SUM(product_net_revenue) AS net_revenue', 'orders_count' => "COUNT( DISTINCT ( CASE WHEN product_gross_revenue >= 0 THEN {$table_name}.order_id END ) ) as orders_count", 'products_count' => 'COUNT(DISTINCT product_id) as products_count', 'variations_count' => 'COUNT(DISTINCT variation_id) as variations_count', ); } /** * Updates the database query with parameters used for Products Stats report: categories and order status. * * @param array $query_args Query arguments supplied by the user. */ protected function update_sql_query_params( $query_args ) { global $wpdb; $products_where_clause = ''; $products_from_clause = ''; $order_product_lookup_table = self::get_db_table_name(); $included_products = $this->get_included_products( $query_args ); if ( $included_products ) { $products_where_clause .= " AND {$order_product_lookup_table}.product_id IN ({$included_products})"; } $included_variations = $this->get_included_variations( $query_args ); if ( $included_variations ) { $products_where_clause .= " AND {$order_product_lookup_table}.variation_id IN ({$included_variations})"; } $order_status_filter = $this->get_status_subquery( $query_args ); if ( $order_status_filter ) { $products_from_clause .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id"; $products_where_clause .= " AND ( {$order_status_filter} )"; } $this->add_time_period_sql_params( $query_args, $order_product_lookup_table ); $this->total_query->add_sql_clause( 'where', $products_where_clause ); $this->total_query->add_sql_clause( 'join', $products_from_clause ); $this->add_intervals_sql_params( $query_args, $order_product_lookup_table ); $this->interval_query->add_sql_clause( 'where', $products_where_clause ); $this->interval_query->add_sql_clause( 'join', $products_from_clause ); $this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' ); } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override ProductsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['interval'] = 'week'; unset( $defaults['extended_info'] ); return $defaults; } /** * Returns the report data based on parameters supplied by the user. * * @override ProductsDataStore::get_data() * * @param array $query_args Query parameters. * @return stdClass|WP_Error Data. */ public function get_data( $query_args ) { // Do not include extended info like `ProductsDataStore` does. return ReportsDataStore::get_data( $query_args ); } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override ProductsDataStore::get_noncached_data() * * @see get_data * @see get_noncached_stats_data * @param array $query_args Query parameters. * @param array $params Query limit parameters. * @param stdClass $data Reference to the data object to fill. * @param int $expected_interval_count Number of expected intervals. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_stats_data( $query_args, $params, &$data, $expected_interval_count ) { global $wpdb; $table_name = self::get_db_table_name(); $this->initialize_queries(); $selections = $this->selected_columns( $query_args ); $this->update_sql_query_params( $query_args ); $this->get_limit_sql_params( $query_args ); $this->interval_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); $db_intervals = $wpdb->get_col( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->interval_query->get_query_statement() ); $db_interval_count = count( $db_intervals ); $intervals = array(); $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); $this->total_query->add_sql_clause( 'select', $selections ); $this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); $totals = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->total_query->get_query_statement(), ARRAY_A ); // phpcs:ignore Generic.Commenting.Todo.TaskFound // @todo remove these assignements when refactoring segmenter classes to use query objects. $totals_query = array( 'from_clause' => $this->total_query->get_sql_clause( 'join' ), 'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ), 'where_clause' => $this->total_query->get_sql_clause( 'where' ), ); $intervals_query = array( 'select_clause' => $this->get_sql_clause( 'select' ), 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), 'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ), 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), 'order_by' => $this->get_sql_clause( 'order_by' ), 'limit' => $this->get_sql_clause( 'limit' ), ); $segmenter = new Segmenter( $query_args, $this->report_columns ); $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); if ( null === $totals ) { return new \WP_Error( 'woocommerce_analytics_products_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); } $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); if ( '' !== $selections ) { $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } $intervals = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->interval_query->get_query_statement(), ARRAY_A ); if ( null === $intervals ) { return new \WP_Error( 'woocommerce_analytics_products_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); } $totals = (object) $this->cast_numbers( $totals[0] ); $data->totals = $totals; $data->intervals = $intervals; if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); } else { $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); } $segmenter->add_intervals_segments( $data, $intervals_query, $table_name ); return $data; } /** * Normalizes order_by clause to match to SQL query. * * @override ProductsDataStore::normalize_order_by() * * @param string $order_by Order by option requeste by user. * @return string */ protected function normalize_order_by( $order_by ) { if ( 'date' === $order_by ) { return 'time_interval'; } return $order_by; } } API/Reports/Products/DataStore.php 0000777 00000056644 15252240713 0013044 0 ustar 00 <?php /** * API\Reports\Products\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Products; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; use Automattic\WooCommerce\Utilities\OrderUtil; use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache; use Automattic\WooCommerce\Enums\ProductType; /** * API\Reports\Products\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Table used to get the data. * * @override ReportsDataStore::$table_name * * @var string */ protected static $table_name = 'wc_order_product_lookup'; /** * Cache identifier. * * @override ReportsDataStore::$cache_key * * @var string */ protected $cache_key = 'products'; /** * Mapping columns to data type to return correct response types. * * @override ReportsDataStore::$column_types * * @var array */ protected $column_types = array( 'date_start' => 'strval', 'date_end' => 'strval', 'product_id' => 'intval', 'items_sold' => 'intval', 'net_revenue' => 'floatval', 'orders_count' => 'intval', // Extended attributes. 'name' => 'strval', 'price' => 'floatval', 'image' => 'strval', 'permalink' => 'strval', 'stock_status' => 'strval', 'stock_quantity' => 'intval', 'low_stock_amount' => 'intval', 'category_ids' => 'array_values', 'variations' => 'array_values', 'sku' => 'strval', ); /** * Extended product attributes to include in the data. * * @var array */ protected $extended_attributes = array( 'name', 'price', 'image', 'permalink', 'stock_status', 'stock_quantity', 'manage_stock', 'low_stock_amount', 'category_ids', 'variations', 'sku', ); /** * Data store context used to pass to filters. * * @override ReportsDataStore::$context * * @var string */ protected $context = 'products'; /** * Assign report columns once full table name has been assigned. * * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); $this->report_columns = array( 'product_id' => 'product_id', 'items_sold' => 'SUM(product_qty) as items_sold', 'net_revenue' => 'SUM(product_net_revenue) AS net_revenue', 'orders_count' => "COUNT( DISTINCT ( CASE WHEN product_gross_revenue >= 0 THEN {$table_name}.order_id END ) ) as orders_count", ); } /** * Set up all the hooks for maintaining and populating table data. */ public static function init() { add_action( 'woocommerce_analytics_delete_order_stats', array( __CLASS__, 'sync_on_order_delete' ), 10 ); add_action( 'woocommerce_order_partially_refunded', array( __CLASS__, 'add_partial_refund_type_meta' ), 10, 2 ); add_action( 'woocommerce_order_fully_refunded', array( __CLASS__, 'add_full_refund_type_meta' ), 10, 2 ); } /** * Add a partial refund type meta to the order. * * @param int $order_id Order ID. * @param int $refund_id Refund ID. */ public static function add_partial_refund_type_meta( $order_id, $refund_id ) { self::add_refund_type_meta( $refund_id, 'partial' ); } /** * Add a full refund type meta to the order. * * @param int $order_id Order ID. * @param int $refund_id Refund ID. */ public static function add_full_refund_type_meta( $order_id, $refund_id ) { self::add_refund_type_meta( $refund_id, 'full' ); } /** * Add a refund type meta to the order. * * @param int $refund_id Refund ID. * @param string $type Refund type. */ public static function add_refund_type_meta( $refund_id, $type ) { $order = wc_get_order( $refund_id ); $order->update_meta_data( '_refund_type', $type ); $order->save_meta_data(); } /** * Fills FROM clause of SQL request based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. * @param string $arg_name Target of the JOIN sql param. * @param string $id_cell ID cell identifier, like `table_name.id_column_name`. */ protected function add_from_sql_params( $query_args, $arg_name, $id_cell ) { global $wpdb; $type = 'join'; // Order by product name requires extra JOIN. switch ( $query_args['orderby'] ) { case 'product_name': $join = " JOIN {$wpdb->posts} AS _products ON {$id_cell} = _products.ID"; break; case 'sku': $join = " LEFT JOIN {$wpdb->postmeta} AS postmeta ON {$id_cell} = postmeta.post_id AND postmeta.meta_key = '_sku'"; break; case 'variations': $type = 'left_join'; $join = "LEFT JOIN ( SELECT post_parent, COUNT(*) AS variations FROM {$wpdb->posts} WHERE post_type = 'product_variation' GROUP BY post_parent ) AS _variations ON {$id_cell} = _variations.post_parent"; break; default: $join = ''; break; } if ( $join ) { if ( 'inner' === $arg_name ) { $this->subquery->add_sql_clause( $type, $join ); } else { $this->add_sql_clause( $type, $join ); } } } /** * Updates the database query with parameters used for Products report: categories and order status. * * @param array $query_args Query arguments supplied by the user. */ protected function add_sql_query_params( $query_args ) { global $wpdb; $order_product_lookup_table = self::get_db_table_name(); $this->add_time_period_sql_params( $query_args, $order_product_lookup_table ); $this->get_limit_sql_params( $query_args ); $this->add_order_by_sql_params( $query_args ); $included_products = $this->get_included_products( $query_args ); if ( $included_products ) { $this->add_from_sql_params( $query_args, 'outer', 'default_results.product_id' ); $this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.product_id IN ({$included_products})" ); } else { $this->add_from_sql_params( $query_args, 'inner', "{$order_product_lookup_table}.product_id" ); } $included_variations = $this->get_included_variations( $query_args ); if ( $included_variations ) { $this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.variation_id IN ({$included_variations})" ); } $order_status_filter = $this->get_status_subquery( $query_args ); if ( $order_status_filter ) { $this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id" ); $this->subquery->add_sql_clause( 'where', "AND ( {$order_status_filter} )" ); } } /** * Maps ordering specified by the user to columns in the database/fields in the data. * * @override ReportsDataStore::normalize_order_by() * * @param string $order_by Sorting criterion. * @return string */ protected function normalize_order_by( $order_by ) { if ( 'date' === $order_by ) { return self::get_db_table_name() . '.date_created'; } if ( 'product_name' === $order_by ) { return 'post_title'; } if ( 'sku' === $order_by ) { return 'meta_value'; } return $order_by; } /** * Enriches the product data with attributes specified by the extended_attributes. * * @param array $products_data Product data. * @param array $query_args Query parameters. */ protected function include_extended_info( &$products_data, $query_args ) { global $wpdb; $product_names = array(); foreach ( $products_data as $key => $product_data ) { $extended_info = new \ArrayObject(); if ( $query_args['extended_info'] ) { $product_id = $product_data['product_id']; $product = wc_get_product( $product_id ); // Product was deleted. if ( ! $product ) { if ( ! isset( $product_names[ $product_id ] ) ) { $product_names[ $product_id ] = $wpdb->get_var( $wpdb->prepare( "SELECT i.order_item_name FROM {$wpdb->prefix}wc_order_product_lookup l JOIN {$wpdb->prefix}woocommerce_order_items i ON i.order_item_id = l.order_item_id WHERE l.product_id = %d ORDER BY l.order_item_id DESC LIMIT 1", $product_id ) ); } /* translators: %s is product name */ $products_data[ $key ]['extended_info']['name'] = $product_names[ $product_id ] ? sprintf( __( '%s (Deleted)', 'woocommerce' ), $product_names[ $product_id ] ) : __( '(Deleted)', 'woocommerce' ); continue; } $extended_attributes = apply_filters( 'woocommerce_rest_reports_products_extended_attributes', $this->extended_attributes, $product_data ); foreach ( $extended_attributes as $extended_attribute ) { if ( 'variations' === $extended_attribute ) { if ( ! $product->is_type( ProductType::VARIABLE ) ) { continue; } $function = 'get_children'; } else { $function = 'get_' . $extended_attribute; } if ( is_callable( array( $product, $function ) ) ) { $value = $product->{$function}(); $extended_info[ $extended_attribute ] = $value; } } // If there is no set low_stock_amount, use the one in user settings. if ( '' === $extended_info['low_stock_amount'] ) { $extended_info['low_stock_amount'] = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) ); } $extended_info = $this->cast_numbers( $extended_info ); } $products_data[ $key ]['extended_info'] = $extended_info; } } /** * Returns the report data based on parameters supplied by the user. * * @override ReportsDataStore::get_data() * * @param array $query_args Query parameters. * @return stdClass|WP_Error Data. */ public function get_data( $query_args ) { $data = parent::get_data( $query_args ); /* * Do not cache extended info -- this is required to get the latest stock data. * `include_extended_info` checks only `extended_info` key, * so we don't need to bother about normalizing timestamps. */ $defaults = $this->get_default_query_vars(); $query_args = wp_parse_args( $query_args, $defaults ); $this->include_extended_info( $data->data, $query_args ); return $data; } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override ReportsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['category_includes'] = array(); $defaults['product_includes'] = array(); $defaults['extended_info'] = false; return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override ReportsDataStore::get_noncached_data() * * @see get_data * @param array $query_args Query parameters. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_data( $query_args ) { global $wpdb; $table_name = self::get_db_table_name(); $this->initialize_queries(); $data = (object) array( 'data' => array(), 'total' => 0, 'pages' => 0, 'page_no' => 0, ); $selections = $this->selected_columns( $query_args ); $included_products = $this->get_included_products_array( $query_args ); $params = $this->get_limit_params( $query_args ); $this->add_sql_query_params( $query_args ); if ( count( $included_products ) > 0 ) { $filtered_products = array_diff( $included_products, array( '-1' ) ); $total_results = count( $filtered_products ); $total_pages = (int) ceil( $total_results / $params['per_page'] ); if ( 'date' === $query_args['orderby'] ) { $selections .= ", {$table_name}.date_created"; } $fields = $this->get_fields( $query_args ); $join_selections = $this->format_join_selections( $fields, array( 'product_id' ) ); $ids_table = $this->get_ids_table( $included_products, 'product_id' ); $this->subquery->clear_sql_clause( 'select' ); $this->subquery->add_sql_clause( 'select', $selections ); $this->add_sql_clause( 'select', $join_selections ); $this->add_sql_clause( 'from', '(' ); $this->add_sql_clause( 'from', $this->subquery->get_query_statement() ); $this->add_sql_clause( 'from', ") AS {$table_name}" ); $this->add_sql_clause( 'right_join', "RIGHT JOIN ( {$ids_table} ) AS default_results ON default_results.product_id = {$table_name}.product_id" ); $this->add_sql_clause( 'where', 'AND default_results.product_id != -1' ); $products_query = $this->get_query_statement(); } else { $count_query = "SELECT COUNT(*) FROM ( {$this->subquery->get_query_statement()} ) AS tt"; $db_records_count = (int) $wpdb->get_var( $count_query // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ); $total_results = $db_records_count; $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); if ( ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) ) { return $data; } $this->subquery->clear_sql_clause( 'select' ); $this->subquery->add_sql_clause( 'select', $selections ); if ( in_array( $query_args['orderby'], array( 'items_sold', 'net_revenue', 'orders_count', 'variations' ), true ) ) { $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) . ', product_id' ); } else { $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); } $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $products_query = $this->subquery->get_query_statement(); } $product_data = $wpdb->get_results( $products_query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ARRAY_A ); if ( null === $product_data ) { return $data; } $product_data = array_map( array( $this, 'cast_numbers' ), $product_data ); $data = (object) array( 'data' => $product_data, 'total' => $total_results, 'pages' => $total_pages, 'page_no' => (int) $query_args['page'], ); return $data; } /** * Create or update an entry in the wc_admin_order_product_lookup table for an order. * * @since 3.5.0 * @param int $order_id Order ID. * @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success. */ public static function sync_order_products( $order_id ) { global $wpdb; $order = wc_get_order( $order_id ); if ( ! $order ) { return -1; } $table_name = self::get_db_table_name(); $existing_items = $wpdb->get_col( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT order_item_id FROM {$table_name} WHERE order_id = %d", $order_id ) ); $existing_items = array_flip( $existing_items ); $order_items = $order->get_items(); $num_updated = 0; $decimals = wc_get_price_decimals(); $round_tax = 'no' === get_option( 'woocommerce_tax_round_at_subtotal' ); $is_full_refund_without_line_items = false; $partial_refund_product_revenue = array(); $refund_type = $order->get_meta( '_refund_type' ); $uses_new_full_refund_data = OrderUtil::uses_new_full_refund_data(); $parent_order = null; // When changing the order status to "Refunded", the refund order's type will be full refund, and the order items will be empty. // We need to get the parent order items, and exclude the items that are already being partially refunded. if ( 'shop_order_refund' === $order->get_type() && 'full' === $refund_type && empty( $order_items ) && $uses_new_full_refund_data ) { $is_full_refund_without_line_items = true; $parent_order_id = $order->get_parent_id(); $parent_order = wc_get_order( $parent_order_id ); $order_items = $parent_order->get_items(); // Get the partially refunded product and variation IDs along with their sum of product_net_revenue from the parent order. $partial_refund_products = $wpdb->get_results( $wpdb->prepare( " SELECT product_lookup.product_id, product_lookup.variation_id, SUM( product_lookup.product_net_revenue ) AS product_net_revenue FROM %i AS product_lookup INNER JOIN {$wpdb->prefix}wc_order_stats AS order_stats ON order_stats.order_id = product_lookup.order_id WHERE 1 = 1 AND order_stats.parent_id = %d AND product_lookup.product_net_revenue < 0 GROUP BY product_lookup.product_id, product_lookup.variation_id ", $table_name, $parent_order_id ) ); /** * Create a lookup table for partially refunded products. * E.g. [ * '1' => -20, * '2' => -40, * '51' => -10, * '52' => -30, * ] */ foreach ( $partial_refund_products as $product ) { $id = $product->variation_id ? $product->variation_id : $product->product_id; $partial_refund_product_revenue[ $id ] = (float) $product->product_net_revenue; } } foreach ( $order_items as $order_item ) { $order_item_id = $order_item->get_id(); unset( $existing_items[ $order_item_id ] ); $product_qty = $order_item->get_quantity( 'edit' ); $product_id = $order_item->get_product_id( 'edit' ); $variation_id = $order_item->get_variation_id( 'edit' ); $shipping_amount = $order->get_item_shipping_amount( $order_item ); $shipping_tax_amount = $order->get_item_shipping_tax_amount( $order_item ); $coupon_amount = $order->get_item_coupon_amount( $order_item ); $tax_amount = $order->get_item_cart_tax_amount( $order_item ); $net_revenue = round( $order_item->get_total( 'edit' ), $decimals ); // If the order is a full refund and there is no order items. The order item here is the parent order item. if ( $is_full_refund_without_line_items ) { $id = $variation_id ? $variation_id : $product_id; $partial_refund = $partial_refund_product_revenue[ $id ] ?? 0; // If a single line item was refunded 60% then fully refunded after, we need store the difference in the product lookup table. // E.g. A product costs $100, it was previously partially refunded $60, then fully refunded $40. // So it will be -abs( 100 + (-60) ) = -40. $net_revenue = -abs( $net_revenue + $partial_refund ); // Skip items that have already been fully refunded (single or multiple partial refunds). if ( 0.0 === $net_revenue ) { continue; } $product_qty = -abs( $product_qty ); // Set coupon amount to 0 for full refunds without line items. $coupon_amount = 0; if ( $parent_order ) { $remaining_refund_items = $parent_order->get_remaining_refund_items(); // Calculate the shipping amount to refund from the parent order. $total_shipping_refunded = $parent_order->get_total_shipping_refunded(); $shipping_total = (float) $parent_order->get_shipping_total(); $total_shipping_to_refund = $shipping_total - $total_shipping_refunded; if ( $total_shipping_to_refund > 0 ) { $shipping_amount = -abs( $parent_order->get_item_shipping_amount( $order_item, $remaining_refund_items, $total_shipping_to_refund ) ); } // Calculate the shipping tax amount to refund from the parent order. $shipping_tax = (float) $parent_order->get_shipping_tax(); $total_shipping_tax_refunded = $parent_order->get_total_shipping_tax_refunded(); $total_shipping_tax_to_refund = $shipping_tax - $total_shipping_tax_refunded; if ( $total_shipping_tax_to_refund > 0 ) { $shipping_tax_amount = -abs( $parent_order->get_item_shipping_tax_amount( $order_item, $remaining_refund_items, $total_shipping_tax_to_refund ) ); } // Calculate cart tax amount of the item from the parent order. $tax_amount = -abs( $parent_order->get_item_cart_tax_amount( $order_item ) ); } } $is_refund = $net_revenue < 0; // Skip line items without changes to product quantity. if ( ! $product_qty && ! $is_refund ) { ++$num_updated; continue; } if ( $round_tax ) { $tax_amount = round( $tax_amount, $decimals ); } $result = $wpdb->replace( self::get_db_table_name(), array( 'order_item_id' => $order_item_id, 'order_id' => $order->get_id(), 'product_id' => $product_id, 'variation_id' => $variation_id, 'customer_id' => $order->get_report_customer_id(), 'product_qty' => $product_qty, 'product_net_revenue' => $net_revenue, 'date_created' => $order->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ), 'coupon_amount' => $coupon_amount, 'tax_amount' => $tax_amount, 'shipping_amount' => $shipping_amount, 'shipping_tax_amount' => $shipping_tax_amount, // @todo Can this be incorrect if modified by filters? 'product_gross_revenue' => $net_revenue + $tax_amount + $shipping_amount + $shipping_tax_amount, ), array( '%d', // order_item_id. '%d', // order_id. '%d', // product_id. '%d', // variation_id. '%d', // customer_id. '%d', // product_qty. '%f', // product_net_revenue. '%s', // date_created. '%f', // coupon_amount. '%f', // tax_amount. '%f', // shipping_amount. '%f', // shipping_tax_amount. '%f', // product_gross_revenue. ) ); // WPCS: cache ok, DB call ok, unprepared SQL ok. /** * Fires when product's reports are updated. * * @param int $order_item_id Order Item ID. * @param int $order_id Order ID. */ do_action( 'woocommerce_analytics_update_product', $order_item_id, $order->get_id() ); // Sum the rows affected. Using REPLACE can affect 2 rows if the row already exists. $num_updated += 2 === intval( $result ) ? 1 : intval( $result ); } if ( ! empty( $existing_items ) ) { $existing_items = array_flip( $existing_items ); $format = array_fill( 0, count( $existing_items ), '%d' ); $format = implode( ',', $format ); array_unshift( $existing_items, $order_id ); $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared "DELETE FROM {$table_name} WHERE order_id = %d AND order_item_id in ({$format})", $existing_items ) ); } return ( count( $order_items ) === $num_updated ); } /** * Clean products data when an order is deleted. * * @param int $order_id Order ID. */ public static function sync_on_order_delete( $order_id ) { global $wpdb; $wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) ); /** * Fires when product's reports are removed from database. * * @param int $product_id Product ID. * @param int $order_id Order ID. */ do_action( 'woocommerce_analytics_delete_product', 0, $order_id ); ReportsCache::invalidate(); } /** * Initialize query objects. */ protected function initialize_queries() { $this->clear_all_clauses(); $this->subquery = new SqlQuery( $this->context . '_subquery' ); $this->subquery->add_sql_clause( 'select', 'product_id' ); $this->subquery->add_sql_clause( 'from', self::get_db_table_name() ); $this->subquery->add_sql_clause( 'group_by', 'product_id' ); } } API/Reports/Products/Controller.php 0000777 00000027200 15252240713 0013263 0 ustar 00 <?php /** * REST API Reports products controller * * Handles requests to the /reports/products endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Products; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use WP_REST_Request; use WP_REST_Response; /** * REST API Reports products controller class. * * @internal * @extends GenericController */ class Controller extends GenericController implements ExportableInterface { /** * Route base. * * @var string */ protected $rest_base = 'reports/products'; /** * Mapping between external parameter name and name used in query class. * * @var array */ protected $param_mapping = array( 'categories' => 'category_includes', 'products' => 'product_includes', 'variations' => 'variation_includes', ); /** * Get data from `'products'` GenericQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new GenericQuery( $query_args, 'products' ); return $query->get_data(); } /** * Prepare a report data item for serialization. * * @param Array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $response = parent::prepare_item_for_response( $report, $request ); $response->add_links( $this->prepare_links( $report ) ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. * * @since 6.5.0 */ $filtered_response = apply_filters( 'woocommerce_rest_prepare_report_products', $response, $report, $request ); if ( isset( $filtered_response->data['extended_info']['name'] ) ) { $filtered_response->data['extended_info']['name'] = wp_strip_all_tags( $filtered_response->data['extended_info']['name'] ); } return $filtered_response; } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $registered = array_keys( $this->get_collection_params() ); foreach ( $registered as $param_name ) { if ( isset( $request[ $param_name ] ) ) { if ( isset( $this->param_mapping[ $param_name ] ) ) { $args[ $this->param_mapping[ $param_name ] ] = $request[ $param_name ]; } else { $args[ $param_name ] = $request[ $param_name ]; } } } return $args; } /** * Prepare links for the request. * * @param Array $object Object data. * @return array Links for the given post. */ protected function prepare_links( $object ) { $links = array( 'product' => array( 'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, 'products', $object['product_id'] ) ), ), ); return $links; } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_products', 'type' => 'object', 'properties' => array( 'product_id' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product ID.', 'woocommerce' ), ), 'items_sold' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Number of items sold.', 'woocommerce' ), ), 'net_revenue' => array( 'type' => 'number', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Total Net sales of all items sold.', 'woocommerce' ), ), 'orders_count' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Number of orders product appeared in.', 'woocommerce' ), ), 'extended_info' => array( 'name' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product name.', 'woocommerce' ), ), 'price' => array( 'type' => 'number', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product price.', 'woocommerce' ), ), 'image' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product image.', 'woocommerce' ), ), 'permalink' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product link.', 'woocommerce' ), ), 'category_ids' => array( 'type' => 'array', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product category IDs.', 'woocommerce' ), ), 'stock_status' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product inventory status.', 'woocommerce' ), ), 'stock_quantity' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product inventory quantity.', 'woocommerce' ), ), 'low_stock_amount' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product inventory threshold for low stock.', 'woocommerce' ), ), 'variations' => array( 'type' => 'array', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product variations IDs.', 'woocommerce' ), ), 'sku' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product SKU.', 'woocommerce' ), ), ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'date', 'net_revenue', 'orders_count', 'items_sold', 'product_name', 'variations', 'sku', ) ); $params['categories'] = array( 'description' => __( 'Limit result to items from the specified categories.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['match'] = array( 'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ), 'type' => 'string', 'default' => 'all', 'enum' => array( 'all', 'any', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['products'] = array( 'description' => __( 'Limit result to items with specified product ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['extended_info'] = array( 'description' => __( 'Add additional piece of info about each product to the report.', 'woocommerce' ), 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'wc_string_to_bool', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get stock status column export value. * * @param array $status Stock status from report row. * @return string */ protected function get_stock_status( $status ) { $statuses = wc_get_product_stock_status_options(); return isset( $statuses[ $status ] ) ? $statuses[ $status ] : ''; } /** * Get categories column export value. * * @param array $category_ids Category IDs from report row. * @return string */ protected function get_categories( $category_ids ) { $category_names = get_terms( array( 'taxonomy' => 'product_cat', 'include' => $category_ids, 'fields' => 'names', ) ); return implode( ', ', $category_names ); } /** * Get the column names for export. * * @return array Key value pair of Column ID => Label. */ public function get_export_columns() { $export_columns = array( 'product_name' => __( 'Product title', 'woocommerce' ), 'sku' => __( 'SKU', 'woocommerce' ), 'items_sold' => __( 'Items sold', 'woocommerce' ), 'net_revenue' => __( 'N. Revenue', 'woocommerce' ), 'orders_count' => __( 'Orders', 'woocommerce' ), 'product_cat' => __( 'Category', 'woocommerce' ), 'variations' => __( 'Variations', 'woocommerce' ), ); if ( 'yes' === get_option( 'woocommerce_manage_stock' ) ) { $export_columns['stock_status'] = __( 'Status', 'woocommerce' ); $export_columns['stock'] = __( 'Stock', 'woocommerce' ); } /** * Filter to add or remove column names from the products report for * export. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_products_export_columns', $export_columns ); } /** * Get the column values for export. * * @param array $item Single report item/row. * @return array Key value pair of Column ID => Row Value. */ public function prepare_item_for_export( $item ) { $export_item = array( 'product_name' => $item['extended_info']['name'], 'sku' => $item['extended_info']['sku'], 'items_sold' => $item['items_sold'], 'net_revenue' => $item['net_revenue'], 'orders_count' => $item['orders_count'], 'product_cat' => $this->get_categories( $item['extended_info']['category_ids'] ), 'variations' => isset( $item['extended_info']['variations'] ) ? count( $item['extended_info']['variations'] ) : 0, ); if ( 'yes' === get_option( 'woocommerce_manage_stock' ) ) { if ( $item['extended_info']['manage_stock'] ) { $export_item['stock_status'] = $this->get_stock_status( $item['extended_info']['stock_status'] ); $export_item['stock'] = $item['extended_info']['stock_quantity']; } else { $export_item['stock_status'] = __( 'N/A', 'woocommerce' ); $export_item['stock'] = __( 'N/A', 'woocommerce' ); } } /** * Filter to prepare extra columns in the export item for the products * report. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_products_prepare_export_item', $export_item, $item ); } } API/Reports/Products/Query.php 0000777 00000003623 15252240713 0012250 0 ustar 00 <?php /** * Class for parameter-based Products Report querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'page' => 2, * 'categories' => array(15, 18), * 'products' => array(1,2,3) * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Products\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Products; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Products\Query * * @deprecated 9.3.0 Products\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Products report. * * @deprecated 9.3.0 Products\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array(); } /** * Get product data based on the current query vars. * * @deprecated 9.3.0 Products\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_products_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-products' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_products_select_query', $results, $args ); } } API/Reports/GenericQuery.php 0000777 00000005113 15252240713 0011736 0 ustar 00 <?php declare( strict_types = 1); namespace Automattic\WooCommerce\Admin\API\Reports; defined( 'ABSPATH' ) || exit; use WC_Data_Store; /** * A generic class for a report-specific query to be used in Analytics. * * Example usage: * <pre><code class="language-php">$args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'page' => 2, * ); * $report = new GenericQuery( $args, 'coupons' ); * $mydata = $report->get_data(); * </code></pre> * * It uses the name provided in the class property or in the constructor call to load the `report-{name}` data store. * * It's used by the {@see GenericController GenericController}. * * @since 9.3.0 */ class GenericQuery extends \WC_Object_Query { /** * Specific query name. * Will be used to load the `report-{name}` data store, * and to call `woocommerce_analytics_{snake_case(name)}_*` filters. * * @var string */ protected $name; /** * Create a new query. * * @param array $args Criteria to query on in a format similar to WP_Query. * @param string $name Query name. * @extends WC_Object_Query::_construct */ public function __construct( $args, $name = null ) { $this->name = $name ?? $this->name; return parent::__construct( $args ); // phpcs:ignore Universal.CodeAnalysis.ConstructorDestructorReturn.ReturnValueFound } /** * Valid fields for Products report. * * @return array */ protected function get_default_query_vars() { return array(); } /** * Get data from `report-{$name}` store, based on the current query vars. * Filters query vars through `woocommerce_analytics_{snake_case(name)}_query_args` filter. * Filters results through `woocommerce_analytics_{snake_case(name)}_select_query` filter. * * @return mixed filtered results from the data store. */ public function get_data() { $snake_name = str_replace( '-', '_', $this->name ); /** * Filter query args given for the report. * * @since 9.3.0 * * @param array $query_args Query args. */ $args = apply_filters( "woocommerce_analytics_{$snake_name}_query_args", $this->get_query_vars() ); $data_store = \WC_Data_Store::load( "report-{$this->name}" ); $results = $data_store->get_data( $args ); /** * Filter report query results. * * @since 9.3.0 * * @param stdClass|WP_Error $results Results from the data store. * @param array $args Query args used to get the data (potentially filtered). */ return apply_filters( "woocommerce_analytics_{$snake_name}_select_query", $results, $args ); } } API/Reports/DataStore.php 0000777 00000155525 15252240713 0011237 0 ustar 00 <?php /** * Admin\API\Reports\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports; if ( ! defined( 'ABSPATH' ) ) { exit; } use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; /** * Common parent for custom report data stores. * * We use Report DataStores to separate DB data retrieval logic from the REST API controllers. * * Handles caching, data normalization, intervals-related methods, and other common functionality. * So, in your custom report DataStore class that extends this class * you can focus on specifics by overriding the `get_noncached_data` method. * * Minimalistic example: * <pre><code class="language-php">class MyDataStore extends DataStore implements DataStoreInterface { * /** Cache identifier, used by the `DataStore` class to handle caching for you. */ * protected $cache_key = 'my_thing'; * /** Data store context used to pass to filters. */ * protected $context = 'my_thing'; * /** Table used to get the data. */ * protected static $table_name = 'my_table'; * /** * * Method that overrides the `DataStore::get_noncached_data()` to return the report data. * * Will be called by `get_data` if there is no data in cache. * */ * public function get_noncached_data( $query ) { * // Do your magic. * * // Then return your data in conforming object structure. * return (object) array( * 'data' => $product_data, * 'total' => 1, * 'page_no' => 1, * 'pages' => 1, * ); * } * } * </code></pre> * * Please use the `woocommerce_data_stores` filter to add your custom data store to the list of available ones. * Then, your store could be accessed by Controller classes ({@see GenericController::get_datastore_data() GenericController::get_datastore_data()}) * or using {@link \WC_Data_Store::load() \WC_Data_Store::load()}. * * We recommend registering using the REST base name of your Controller as the key, e.g.: * <pre><code class="language-php">add_filter( 'woocommerce_data_stores', function( $stores ) { * $stores['reports/my-thing'] = 'MyExtension\Admin\Analytics\Rest_API\MyDataStore'; * } ); * </code></pre> * This way, `GenericController` will pick it up automatically. * * Note that this class is NOT {@link https://developer.woocommerce.com/docs/how-to-manage-woocommerce-data-stores/ a CRUD data store}. * It does not implement the {@see WC_Object_Data_Store_Interface WC_Object_Data_Store_Interface} nor extend WC_Data & WC_Data_Store_WP classes. */ class DataStore extends SqlQuery implements DataStoreInterface { /** * Cache group for the reports. * * @var string */ protected $cache_group = 'reports'; /** * Time out for the cache. * * @var int */ protected $cache_timeout = 3600; /** * Cache identifier. * * @var string */ protected $cache_key = ''; /** * Table used as a data store for this report. * * @var string */ protected static $table_name = ''; /** * Date field name. * * @var string */ protected $date_column_name = 'date_created'; /** * Mapping columns to data type to return correct response types. * * @var array */ protected $column_types = array(); /** * SQL columns to select in the db query. * * @var array */ protected $report_columns = array(); // @todo This does not really belong here, maybe factor out the comparison as separate class? /** * Order by property, used in the cmp function. * * @var string */ private $order_by = ''; /** * Order property, used in the cmp function. * * @var string */ private $order = ''; /** * Query limit parameters. * * @var array */ private $limit_parameters = array(); /** * Data store context used to pass to filters. * * @override SqlQuery * * @var string */ protected $context = 'reports'; /** * Subquery object for query nesting. * * @var SqlQuery */ protected $subquery; /** * Totals query object. * * @var SqlQuery */ protected $total_query; /** * Intervals query object. * * @var SqlQuery */ protected $interval_query; /** * Refresh the cache for the current query when true. * * @var bool */ protected $force_cache_refresh = false; /** * Include debugging information in the returned data when true. * * @var bool */ protected $debug_cache = true; /** * Debugging information to include in the returned data. * * @var array */ protected $debug_cache_data = array(); /** * Class constructor. * * @override SqlQuery::__construct() */ public function __construct() { self::set_db_table_name(); $this->assign_report_columns(); if ( $this->report_columns ) { $this->report_columns = apply_filters( 'woocommerce_admin_report_columns', $this->report_columns, $this->context, self::get_db_table_name() ); } // Utilize enveloped responses to include debugging info. // See https://querymonitor.com/blog/2021/05/debugging-wordpress-rest-api-requests/ if ( isset( $_GET['_envelope'] ) ) { $this->debug_cache = true; add_filter( 'rest_envelope_response', array( $this, 'add_debug_cache_to_envelope' ), 999, 2 ); } } /** * Get the data based on args. * * Returns the report data based on parameters supplied by the user. * Fetches it from cache or returns `get_noncached_data` result. * * @param array $query_args Query parameters. * @return stdClass|WP_Error */ public function get_data( $query_args ) { $defaults = $this->get_default_query_vars(); $query_args = wp_parse_args( $query_args, $defaults ); $this->normalize_timezones( $query_args, $defaults ); /* * We need to get the cache key here because * parent::update_intervals_sql_params() modifies $query_args. */ $cache_key = $this->get_cache_key( $query_args ); $data = $this->get_cached_data( $cache_key ); if ( false === $data ) { $data = $this->get_noncached_data( $query_args ); $this->set_cached_data( $cache_key, $data ); } return $data; } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @return array Query parameters. */ public function get_default_query_vars() { return array( 'per_page' => get_option( 'posts_per_page' ), 'page' => 1, 'order' => 'DESC', 'orderby' => 'date', 'before' => TimeInterval::default_before(), 'after' => TimeInterval::default_after(), 'fields' => '*', ); } /** * Get table name from database class. */ public static function get_db_table_name() { global $wpdb; return isset( $wpdb->{static::$table_name} ) ? $wpdb->{static::$table_name} : $wpdb->prefix . static::$table_name; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @see get_data * @param array $query_args Query parameters. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_data( $query_args ) { /* translators: %s: Method name */ return new \WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be overridden in subclass.", 'woocommerce' ), __METHOD__ ), array( 'status' => 405 ) ); } /** * Set table name from database class. */ protected static function set_db_table_name() { global $wpdb; if ( static::$table_name && ! isset( $wpdb->{static::$table_name} ) ) { $wpdb->{static::$table_name} = $wpdb->prefix . static::$table_name; } } /** * Whether or not the report should use the caching layer. * * Provides an opportunity for plugins to prevent reports from using cache. * * @return boolean Whether or not to utilize caching. */ protected function should_use_cache() { /** * Determines if a report will utilize caching. * * @param bool $use_cache Whether or not to use cache. * @param string $cache_key The report's cache key. Used to identify the report. */ return (bool) apply_filters( 'woocommerce_analytics_report_should_use_cache', true, $this->cache_key ); } /** * Returns string to be used as cache key for the data. * * @param array $params Query parameters. * @return string */ protected function get_cache_key( $params ) { if ( isset( $params['force_cache_refresh'] ) ) { if ( true === $params['force_cache_refresh'] ) { $this->force_cache_refresh = true; } // We don't want this param in the key. unset( $params['force_cache_refresh'] ); } if ( true === $this->debug_cache ) { $this->debug_cache_data['query_args'] = $params; } // Normalize the $params to reduce cache misses. $params = array_filter( $params, function ( $param ) { return ! empty( $param ); } ); ksort( $params ); return implode( '_', array( 'wc_report', $this->cache_key, md5( wp_json_encode( $params ) ), ) ); } /** * Wrapper around Cache::get(). * * @param string $cache_key Cache key. * @return mixed */ protected function get_cached_data( $cache_key ) { if ( true === $this->debug_cache ) { $this->debug_cache_data['should_use_cache'] = $this->should_use_cache(); $this->debug_cache_data['force_cache_refresh'] = $this->force_cache_refresh; $this->debug_cache_data['cache_hit'] = false; } if ( $this->should_use_cache() && false === $this->force_cache_refresh ) { $cached_data = Cache::get( $cache_key ); $cache_hit = false !== $cached_data; if ( true === $this->debug_cache ) { $this->debug_cache_data['cache_hit'] = $cache_hit; } return $cached_data; } // Cached item has now functionally been refreshed. Reset the option. $this->force_cache_refresh = false; return false; } /** * Wrapper around Cache::set(). * * @param string $cache_key Cache key. * @param mixed $value New value. * @return bool */ protected function set_cached_data( $cache_key, $value ) { if ( $this->should_use_cache() ) { return Cache::set( $cache_key, $value ); } return true; } /** * Add cache debugging information to an enveloped API response. * * @param array $envelope * @param \WP_REST_Response $response * * @return array */ public function add_debug_cache_to_envelope( $envelope, $response ) { if ( 0 !== strncmp( '/wc-analytics', $response->get_matched_route(), 13 ) ) { return $envelope; } if ( ! empty( $this->debug_cache_data ) ) { $envelope['debug_cache'] = $this->debug_cache_data; } return $envelope; } /** * Compares two report data objects by pre-defined object property and ASC/DESC ordering. * * @param stdClass $a Object a. * @param stdClass $b Object b. * @return string */ private function interval_cmp( $a, $b ) { if ( '' === $this->order_by || '' === $this->order ) { return 0; // @todo Should return WP_Error here perhaps? } if ( $a[ $this->order_by ] === $b[ $this->order_by ] ) { // As relative order is undefined in case of equality in usort, second-level sorting by date needs to be enforced // so that paging is stable. if ( $a['time_interval'] === $b['time_interval'] ) { return 0; // This should never happen. } elseif ( $a['time_interval'] > $b['time_interval'] ) { return 1; } elseif ( $a['time_interval'] < $b['time_interval'] ) { return -1; } } elseif ( $a[ $this->order_by ] > $b[ $this->order_by ] ) { return strtolower( $this->order ) === 'desc' ? -1 : 1; } elseif ( $a[ $this->order_by ] < $b[ $this->order_by ] ) { return strtolower( $this->order ) === 'desc' ? 1 : -1; } } /** * Sorts intervals according to user's request. * * They are pre-sorted in SQL, but after adding gaps, they need to be sorted including the added ones. * * @param stdClass $data Data object, must contain an array under $data->intervals. * @param string $sort_by Ordering property. * @param string $direction DESC/ASC. */ protected function sort_intervals( &$data, $sort_by, $direction ) { $this->sort_array( $data->intervals, $sort_by, $direction ); } /** * Sorts array of arrays based on subarray key $sort_by. * * @param array $arr Array to sort. * @param string $sort_by Ordering property. * @param string $direction DESC/ASC. */ protected function sort_array( &$arr, $sort_by, $direction ) { $this->order_by = $this->normalize_order_by( $sort_by ); $this->order = $direction; usort( $arr, array( $this, 'interval_cmp' ) ); } /** * Fills in interval gaps from DB with 0-filled objects. * * @param array $db_intervals Array of all intervals present in the db. * @param DateTime $start_datetime Start date. * @param DateTime $end_datetime End date. * @param string $time_interval Time interval, e.g. day, week, month. * @param stdClass $data Data with SQL extracted intervals. * @return stdClass */ protected function fill_in_missing_intervals( $db_intervals, $start_datetime, $end_datetime, $time_interval, &$data ) { // @todo This is ugly and messy. $local_tz = new \DateTimeZone( wc_timezone_string() ); // At this point, we don't know when we can stop iterating, as the ordering can be based on any value. $time_ids = array_flip( wp_list_pluck( $data->intervals, 'time_interval' ) ); $db_intervals = array_flip( $db_intervals ); // Totals object used to get all needed properties. $totals_arr = get_object_vars( $data->totals ); foreach ( $totals_arr as $key => $val ) { $totals_arr[ $key ] = 0; } // @todo Should 'products' be in intervals? unset( $totals_arr['products'] ); while ( $start_datetime <= $end_datetime ) { $next_start = TimeInterval::iterate( $start_datetime, $time_interval ); $time_id = TimeInterval::time_interval_id( $time_interval, $start_datetime ); // Either create fill-zero interval or use data from db. if ( $next_start > $end_datetime ) { $interval_end = $end_datetime->format( 'Y-m-d H:i:s' ); } else { $prev_end_timestamp = (int) $next_start->format( 'U' ) - 1; $prev_end = new \DateTime(); $prev_end->setTimestamp( $prev_end_timestamp ); $prev_end->setTimezone( $local_tz ); $interval_end = $prev_end->format( 'Y-m-d H:i:s' ); } if ( array_key_exists( $time_id, $time_ids ) ) { // For interval present in the db for this time frame, just fill in dates. $record = &$data->intervals[ $time_ids[ $time_id ] ]; $record['date_start'] = $start_datetime->format( 'Y-m-d H:i:s' ); $record['date_end'] = $interval_end; } elseif ( ! array_key_exists( $time_id, $db_intervals ) ) { // For intervals present in the db outside of this time frame, do nothing. // For intervals not present in the db, fabricate it. $record_arr = array(); $record_arr['time_interval'] = $time_id; $record_arr['date_start'] = $start_datetime->format( 'Y-m-d H:i:s' ); $record_arr['date_end'] = $interval_end; $data->intervals[] = array_merge( $record_arr, $totals_arr ); } $start_datetime = $next_start; } return $data; } /** * Converts input datetime parameters to local timezone. If there are no inputs from the user in query_args, * uses default from $defaults. * * @param array $query_args Array of query arguments. * @param array $defaults Array of default values. */ protected function normalize_timezones( &$query_args, $defaults ) { $local_tz = new \DateTimeZone( wc_timezone_string() ); foreach ( array( 'before', 'after' ) as $query_arg_key ) { if ( isset( $query_args[ $query_arg_key ] ) && is_string( $query_args[ $query_arg_key ] ) ) { // Assume that unspecified timezone is a local timezone. $datetime = new \DateTime( $query_args[ $query_arg_key ], $local_tz ); // In case timezone was forced by using +HH:MM, convert to local timezone. $datetime->setTimezone( $local_tz ); $query_args[ $query_arg_key ] = $datetime; } elseif ( isset( $query_args[ $query_arg_key ] ) && is_a( $query_args[ $query_arg_key ], 'DateTime' ) ) { // In case timezone is in other timezone, convert to local timezone. $query_args[ $query_arg_key ]->setTimezone( $local_tz ); } else { $query_args[ $query_arg_key ] = isset( $defaults[ $query_arg_key ] ) ? $defaults[ $query_arg_key ] : null; } } } /** * Removes extra records from intervals so that only requested number of records get returned. * * @param stdClass $data Data from whose intervals the records get removed. * @param int $page_no Offset requested by the user. * @param int $items_per_page Number of records requested by the user. * @param int $db_interval_count Database interval count. * @param int $expected_interval_count Expected interval count on the output. * @param string $order_by Order by field. * @param string $order ASC or DESC. */ protected function remove_extra_records( &$data, $page_no, $items_per_page, $db_interval_count, $expected_interval_count, $order_by, $order ) { if ( 'date' === strtolower( $order_by ) ) { $offset = 0; } else { if ( 'asc' === strtolower( $order ) ) { $offset = ( $page_no - 1 ) * $items_per_page; } else { $offset = ( $page_no - 1 ) * $items_per_page - $db_interval_count; } $offset = $offset < 0 ? 0 : $offset; } $count = $expected_interval_count - ( $page_no - 1 ) * $items_per_page; if ( $count < 0 ) { $count = 0; } elseif ( $count > $items_per_page ) { $count = $items_per_page; } $data->intervals = array_slice( $data->intervals, $offset, $count ); } /** * Returns expected number of items on the page in case of date ordering. * * @param int $expected_interval_count Expected number of intervals in total. * @param int $items_per_page Number of items per page. * @param int $page_no Page number. * * @return float|int */ protected function expected_intervals_on_page( $expected_interval_count, $items_per_page, $page_no ) { $total_pages = (int) ceil( $expected_interval_count / $items_per_page ); if ( $page_no < $total_pages ) { return $items_per_page; } elseif ( $page_no === $total_pages ) { return $expected_interval_count - ( $page_no - 1 ) * $items_per_page; } else { return 0; } } /** * Returns true if there are any intervals that need to be filled in the response. * * @param int $expected_interval_count Expected number of intervals in total. * @param int $db_records Total number of records for given period in the database. * @param int $items_per_page Number of items per page. * @param int $page_no Page number. * @param string $order asc or desc. * @param string $order_by Column by which the result will be sorted. * @param int $intervals_count Number of records for given (possibly shortened) time interval. * * @return bool */ protected function intervals_missing( $expected_interval_count, $db_records, $items_per_page, $page_no, $order, $order_by, $intervals_count ) { if ( $expected_interval_count <= $db_records ) { return false; } if ( 'date' === $order_by ) { $expected_intervals_on_page = $this->expected_intervals_on_page( $expected_interval_count, $items_per_page, $page_no ); return $intervals_count < $expected_intervals_on_page; } if ( 'desc' === $order ) { return $page_no > floor( $db_records / $items_per_page ); } if ( 'asc' === $order ) { return $page_no <= ceil( ( $expected_interval_count - $db_records ) / $items_per_page ); } // Invalid ordering. return false; } /** * Updates the LIMIT query part for Intervals query of the report. * * If there are less records in the database than time intervals, then we need to remap offset in SQL query * to fetch correct records. * * @param array $query_args Query arguments. * @param int $db_interval_count Database interval count. * @param int $expected_interval_count Expected interval count on the output. * @param string $table_name Name of the db table relevant for the date constraint. */ protected function update_intervals_sql_params( &$query_args, $db_interval_count, $expected_interval_count, $table_name ) { if ( $db_interval_count === $expected_interval_count ) { return; } $params = $this->get_limit_params( $query_args ); $local_tz = new \DateTimeZone( wc_timezone_string() ); if ( 'date' === strtolower( $query_args['orderby'] ) ) { // page X in request translates to slightly different dates in the db, in case some // records are missing from the db. $start_iteration = 0; $end_iteration = 0; if ( 'asc' === strtolower( $query_args['order'] ) ) { // ORDER BY date ASC. $new_start_date = $query_args['after']; $intervals_to_skip = ( $query_args['page'] - 1 ) * $params['per_page']; $latest_end_date = $query_args['before']; for ( $i = 0; $i < $intervals_to_skip; $i++ ) { if ( $new_start_date > $latest_end_date ) { $new_start_date = $latest_end_date; $start_iteration = 0; break; } $new_start_date = TimeInterval::iterate( $new_start_date, $query_args['interval'] ); $start_iteration ++; } $new_end_date = clone $new_start_date; for ( $i = 0; $i < $params['per_page']; $i++ ) { if ( $new_end_date > $latest_end_date ) { break; } $new_end_date = TimeInterval::iterate( $new_end_date, $query_args['interval'] ); $end_iteration ++; } if ( $new_end_date > $latest_end_date ) { $new_end_date = $latest_end_date; $end_iteration = 0; } if ( $end_iteration ) { $new_end_date_timestamp = (int) $new_end_date->format( 'U' ) - 1; $new_end_date->setTimestamp( $new_end_date_timestamp ); } } else { // ORDER BY date DESC. $new_end_date = $query_args['before']; $intervals_to_skip = ( $query_args['page'] - 1 ) * $params['per_page']; $earliest_start_date = $query_args['after']; for ( $i = 0; $i < $intervals_to_skip; $i++ ) { if ( $new_end_date < $earliest_start_date ) { $new_end_date = $earliest_start_date; $end_iteration = 0; break; } $new_end_date = TimeInterval::iterate( $new_end_date, $query_args['interval'], true ); $end_iteration ++; } $new_start_date = clone $new_end_date; for ( $i = 0; $i < $params['per_page']; $i++ ) { if ( $new_start_date < $earliest_start_date ) { break; } $new_start_date = TimeInterval::iterate( $new_start_date, $query_args['interval'], true ); $start_iteration ++; } if ( $new_start_date < $earliest_start_date ) { $new_start_date = $earliest_start_date; $start_iteration = 0; } if ( $start_iteration ) { // @todo Is this correct? should it only be added if iterate runs? other two iterate instances, too? $new_start_date_timestamp = (int) $new_start_date->format( 'U' ) + 1; $new_start_date->setTimestamp( $new_start_date_timestamp ); } } // @todo - Do this without modifying $query_args? $query_args['adj_after'] = $new_start_date; $query_args['adj_before'] = $new_end_date; $adj_after = $new_start_date->format( TimeInterval::$sql_datetime_format ); $adj_before = $new_end_date->format( TimeInterval::$sql_datetime_format ); $this->interval_query->clear_sql_clause( array( 'where_time', 'limit' ) ); $this->interval_query->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` <= '$adj_before'" ); $this->interval_query->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` >= '$adj_after'" ); $this->clear_sql_clause( 'limit' ); $this->add_sql_clause( 'limit', 'LIMIT 0,' . $params['per_page'] ); } else { if ( 'asc' === $query_args['order'] ) { $offset = ( ( $query_args['page'] - 1 ) * $params['per_page'] ) - ( $expected_interval_count - $db_interval_count ); $offset = $offset < 0 ? 0 : $offset; $count = $query_args['page'] * $params['per_page'] - ( $expected_interval_count - $db_interval_count ); if ( $count < 0 ) { $count = 0; } elseif ( $count > $params['per_page'] ) { $count = $params['per_page']; } $this->clear_sql_clause( 'limit' ); $this->add_sql_clause( 'limit', 'LIMIT ' . $offset . ',' . $count ); } // Otherwise no change in limit clause. // @todo - Do this without modifying $query_args? $query_args['adj_after'] = $query_args['after']; $query_args['adj_before'] = $query_args['before']; } } /** * Casts strings returned from the database to appropriate data types for output. * * @param array $array Associative array of values extracted from the database. * @return array|WP_Error */ protected function cast_numbers( $array ) { $retyped_array = array(); $column_types = apply_filters( 'woocommerce_rest_reports_column_types', $this->column_types, $array ); foreach ( $array as $column_name => $value ) { if ( is_array( $value ) ) { $value = $this->cast_numbers( $value ); } if ( isset( $column_types[ $column_name ] ) ) { $retyped_array[ $column_name ] = $column_types[ $column_name ]( $value ); } else { $retyped_array[ $column_name ] = $value; } } return $retyped_array; } /** * Returns a list of columns selected by the query_args formatted as a comma separated string. * * @param array $query_args User-supplied options. * @return string */ protected function selected_columns( $query_args ) { $selections = $this->report_columns; if ( isset( $query_args['fields'] ) && is_array( $query_args['fields'] ) ) { $keep = array(); foreach ( $query_args['fields'] as $field ) { if ( isset( $selections[ $field ] ) ) { $keep[ $field ] = $selections[ $field ]; } } $selections = implode( ', ', $keep ); } else { $selections = implode( ', ', $selections ); } return $selections; } /** * Get the excluded order statuses used when calculating reports. * * @return array */ protected static function get_excluded_report_order_statuses() { $excluded_statuses = \WC_Admin_Settings::get_option( 'woocommerce_excluded_report_order_statuses', array( 'pending', 'failed', 'cancelled' ) ); $excluded_statuses = array_merge( array( 'auto-draft', 'trash' ), array_map( 'esc_sql', $excluded_statuses ) ); return apply_filters( 'woocommerce_analytics_excluded_order_statuses', $excluded_statuses ); } /** * Maps order status provided by the user to the one used in the database. * * @param string $status Order status. * @return string */ protected static function normalize_order_status( $status ) { $status = trim( $status ); return 'wc-' . $status; } /** * Normalizes order_by clause to match to SQL query. * * @param string $order_by Order by option requested by user. * @return string */ protected function normalize_order_by( $order_by ) { if ( 'date' === $order_by ) { return 'time_interval'; } return $order_by; } /** * Updates start and end dates for intervals so that they represent intervals' borders, not times when data in db were recorded. * * E.g. if there are db records for only Tuesday and Thursday this week, the actual week interval is [Mon, Sun], not [Tue, Thu]. * * @param DateTime $start_datetime Start date. * @param DateTime $end_datetime End date. * @param string $time_interval Time interval, e.g. day, week, month. * @param array $intervals Array of intervals extracted from SQL db. */ protected function update_interval_boundary_dates( $start_datetime, $end_datetime, $time_interval, &$intervals ) { $local_tz = new \DateTimeZone( wc_timezone_string() ); foreach ( $intervals as $key => $interval ) { $datetime = new \DateTime( $interval['datetime_anchor'], $local_tz ); $prev_start = TimeInterval::iterate( $datetime, $time_interval, true ); // @todo Not sure if the +1/-1 here are correct, especially as they are applied before the ?: below. $prev_start_timestamp = (int) $prev_start->format( 'U' ) + 1; $prev_start->setTimestamp( $prev_start_timestamp ); if ( $start_datetime ) { $date_start = $prev_start < $start_datetime ? $start_datetime : $prev_start; $intervals[ $key ]['date_start'] = $date_start->format( 'Y-m-d H:i:s' ); } else { $intervals[ $key ]['date_start'] = $prev_start->format( 'Y-m-d H:i:s' ); } $next_end = TimeInterval::iterate( $datetime, $time_interval ); $next_end_timestamp = (int) $next_end->format( 'U' ) - 1; $next_end->setTimestamp( $next_end_timestamp ); if ( $end_datetime ) { $date_end = $next_end > $end_datetime ? $end_datetime : $next_end; $intervals[ $key ]['date_end'] = $date_end->format( 'Y-m-d H:i:s' ); } else { $intervals[ $key ]['date_end'] = $next_end->format( 'Y-m-d H:i:s' ); } $intervals[ $key ]['interval'] = $time_interval; } } /** * Change structure of intervals to form a correct response. * * Also converts local datetimes to GMT and adds them to the intervals. * * @param array $intervals Time interval, e.g. day, week, month. */ protected function create_interval_subtotals( &$intervals ) { foreach ( $intervals as $key => $interval ) { $start_gmt = TimeInterval::convert_local_datetime_to_gmt( $interval['date_start'] ); $end_gmt = TimeInterval::convert_local_datetime_to_gmt( $interval['date_end'] ); // Move intervals result to subtotals object. $intervals[ $key ] = array( 'interval' => $interval['time_interval'], 'date_start' => $interval['date_start'], 'date_start_gmt' => $start_gmt->format( TimeInterval::$sql_datetime_format ), 'date_end' => $interval['date_end'], 'date_end_gmt' => $end_gmt->format( TimeInterval::$sql_datetime_format ), ); unset( $interval['interval'] ); unset( $interval['date_start'] ); unset( $interval['date_end'] ); unset( $interval['datetime_anchor'] ); unset( $interval['time_interval'] ); $intervals[ $key ]['subtotals'] = (object) $this->cast_numbers( $interval ); } } /** * Fills WHERE clause of SQL request with date-related constraints. * * @param array $query_args Parameters supplied by the user. * @param string $table_name Name of the db table relevant for the date constraint. */ protected function add_time_period_sql_params( $query_args, $table_name ) { $this->clear_sql_clause( array( 'from', 'where_time', 'where' ) ); if ( isset( $this->subquery ) ) { $this->subquery->clear_sql_clause( 'where_time' ); } if ( isset( $query_args['before'] ) && '' !== $query_args['before'] ) { if ( is_a( $query_args['before'], 'WC_DateTime' ) ) { $datetime_str = $query_args['before']->date( TimeInterval::$sql_datetime_format ); } else { $datetime_str = $query_args['before']->format( TimeInterval::$sql_datetime_format ); } if ( isset( $this->subquery ) ) { $this->subquery->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` <= '$datetime_str'" ); } else { $this->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` <= '$datetime_str'" ); } } if ( isset( $query_args['after'] ) && '' !== $query_args['after'] ) { if ( is_a( $query_args['after'], 'WC_DateTime' ) ) { $datetime_str = $query_args['after']->date( TimeInterval::$sql_datetime_format ); } else { $datetime_str = $query_args['after']->format( TimeInterval::$sql_datetime_format ); } if ( isset( $this->subquery ) ) { $this->subquery->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` >= '$datetime_str'" ); } else { $this->add_sql_clause( 'where_time', "AND {$table_name}.`{$this->date_column_name}` >= '$datetime_str'" ); } } } /** * Fills LIMIT clause of SQL request based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. * @return array */ protected function get_limit_sql_params( $query_args ) { global $wpdb; $params = $this->get_limit_params( $query_args ); $this->clear_sql_clause( 'limit' ); $this->add_sql_clause( 'limit', $wpdb->prepare( 'LIMIT %d, %d', $params['offset'], $params['per_page'] ) ); return $params; } /** * Fills LIMIT parameters of SQL request based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. * @return array */ protected function get_limit_params( $query_args = array() ) { if ( isset( $query_args['per_page'] ) && is_numeric( $query_args['per_page'] ) ) { $this->limit_parameters['per_page'] = (int) $query_args['per_page']; } else { $this->limit_parameters['per_page'] = get_option( 'posts_per_page' ); } $this->limit_parameters['offset'] = 0; if ( isset( $query_args['page'] ) ) { $this->limit_parameters['offset'] = ( (int) $query_args['page'] - 1 ) * $this->limit_parameters['per_page']; } return $this->limit_parameters; } /** * Generates a virtual table given a list of IDs. * * @param array $ids Array of IDs. * @param array $id_field Name of the ID field. * @param array $other_values Other values that must be contained in the virtual table. * @return array */ protected function get_ids_table( $ids, $id_field, $other_values = array() ) { global $wpdb; $selects = array(); foreach ( $ids as $id ) { // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $new_select = $wpdb->prepare( "SELECT %s AS {$id_field}", $id ); foreach ( $other_values as $key => $value ) { $new_select .= $wpdb->prepare( ", %s AS {$key}", $value ); } // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared array_push( $selects, $new_select ); } return join( ' UNION ', $selects ); } /** * Returns a comma separated list of the fields in the `query_args`, if there aren't, returns `report_columns` keys. * * @param array $query_args Parameters supplied by the user. * @return array */ protected function get_fields( $query_args ) { if ( isset( $query_args['fields'] ) && is_array( $query_args['fields'] ) ) { return $query_args['fields']; } return array_keys( $this->report_columns ); } /** * Returns a comma separated list of the field names prepared to be used for a selection after a join with `default_results`. * * @param array $fields Array of fields name. * @param array $default_results_fields Fields to load from `default_results` table. * @param array $outer_selections Array of fields that are not selected in the inner query. * @return string */ protected function format_join_selections( $fields, $default_results_fields, $outer_selections = array() ) { foreach ( $fields as $i => $field ) { foreach ( $default_results_fields as $default_results_field ) { if ( $field === $default_results_field ) { $field = esc_sql( $field ); $fields[ $i ] = "default_results.{$field} AS {$field}"; } } if ( in_array( $field, $outer_selections, true ) && array_key_exists( $field, $this->report_columns ) ) { $fields[ $i ] = $this->report_columns[ $field ]; } } return implode( ', ', $fields ); } /** * Fills ORDER BY clause of SQL request based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. */ protected function add_order_by_sql_params( $query_args ) { if ( isset( $query_args['orderby'] ) ) { $order_by_clause = $this->normalize_order_by( esc_sql( $query_args['orderby'] ) ); } else { $order_by_clause = ''; } $this->clear_sql_clause( 'order_by' ); $this->add_sql_clause( 'order_by', $order_by_clause ); $this->add_orderby_order_clause( $query_args, $this ); } /** * Fills FROM and WHERE clauses of SQL request for 'Intervals' section of data response based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. * @param string $table_name Name of the db table relevant for the date constraint. */ protected function add_intervals_sql_params( $query_args, $table_name ) { $this->clear_sql_clause( array( 'from', 'where_time', 'where' ) ); $this->add_time_period_sql_params( $query_args, $table_name ); if ( isset( $query_args['interval'] ) && '' !== $query_args['interval'] ) { $interval = $query_args['interval']; $this->clear_sql_clause( 'select' ); $this->add_sql_clause( 'select', TimeInterval::db_datetime_format( $interval, $table_name, $this->date_column_name ) ); } } /** * Get join and where clauses for refunds based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. * @return array */ protected function get_refund_subquery( $query_args ) { global $wpdb; $table_name = $wpdb->prefix . 'wc_order_stats'; $sql_query = array( 'where_clause' => '', 'from_clause' => '', ); if ( ! isset( $query_args['refunds'] ) ) { return $sql_query; } if ( 'all' === $query_args['refunds'] ) { $sql_query['where_clause'] .= 'parent_id != 0'; } if ( 'none' === $query_args['refunds'] ) { $sql_query['where_clause'] .= 'parent_id = 0'; } if ( 'full' === $query_args['refunds'] || 'partial' === $query_args['refunds'] ) { $operator = 'full' === $query_args['refunds'] ? '=' : '!='; $sql_query['from_clause'] .= " JOIN {$table_name} parent_order_stats ON {$table_name}.parent_id = parent_order_stats.order_id"; $sql_query['where_clause'] .= "parent_order_stats.status {$operator} '{$this->normalize_order_status( 'refunded' )}'"; } return $sql_query; } /** * Returns an array of products belonging to given categories. * * @param array $categories List of categories IDs. * @return array|stdClass */ protected function get_products_by_cat_ids( $categories ) { $terms = get_terms( array( 'taxonomy' => 'product_cat', 'include' => $categories, ) ); if ( is_wp_error( $terms ) || empty( $terms ) ) { return array(); } $args = array( 'category' => wc_list_pluck( $terms, 'slug' ), 'limit' => -1, 'return' => 'ids', ); return wc_get_products( $args ); } /** * Get WHERE filter by object ids subquery. * * @param string $select_table Select table name. * @param string $select_field Select table object ID field name. * @param string $filter_table Lookup table name. * @param string $filter_field Lookup table object ID field name. * @param string $compare Comparison string (IN|NOT IN). * @param string $id_list Comma separated ID list. * * @return string */ protected function get_object_where_filter( $select_table, $select_field, $filter_table, $filter_field, $compare, $id_list ) { global $wpdb; if ( empty( $id_list ) ) { return ''; } $lookup_name = isset( $wpdb->$filter_table ) ? $wpdb->$filter_table : $wpdb->prefix . $filter_table; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared return " {$select_table}.{$select_field} {$compare} ( SELECT DISTINCT {$filter_table}.{$select_field} FROM {$filter_table} WHERE {$filter_table}.{$filter_field} IN ({$id_list}) )"; // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared } /** * Returns an array of ids of allowed products, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return array */ protected function get_included_products_array( $query_args ) { $included_products = array(); $operator = $this->get_match_operator( $query_args ); if ( isset( $query_args['category_includes'] ) && is_array( $query_args['category_includes'] ) && count( $query_args['category_includes'] ) > 0 ) { $included_products = $this->get_products_by_cat_ids( $query_args['category_includes'] ); // If no products were found in the specified categories, we will force an empty set // by matching a product ID of -1, unless the filters are OR/any and products are specified. if ( empty( $included_products ) ) { $included_products = array( '-1' ); } } if ( isset( $query_args['product_includes'] ) && is_array( $query_args['product_includes'] ) && count( $query_args['product_includes'] ) > 0 ) { if ( count( $included_products ) > 0 ) { if ( 'AND' === $operator ) { // AND results in an intersection between products from selected categories and manually included products. $included_products = array_intersect( $included_products, $query_args['product_includes'] ); } elseif ( 'OR' === $operator ) { // OR results in a union of products from selected categories and manually included products. $included_products = array_merge( $included_products, $query_args['product_includes'] ); } } else { $included_products = $query_args['product_includes']; } } return $included_products; } /** * Returns comma separated ids of allowed products, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_included_products( $query_args ) { $included_products = $this->get_included_products_array( $query_args ); return implode( ',', $included_products ); } /** * Returns comma separated ids of allowed variations, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_included_variations( $query_args ) { return $this->get_filtered_ids( $query_args, 'variation_includes' ); } /** * Returns comma separated ids of excluded variations, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_excluded_variations( $query_args ) { return $this->get_filtered_ids( $query_args, 'variation_excludes' ); } /** * Returns an array of ids of disallowed products, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return array */ protected function get_excluded_products_array( $query_args ) { $excluded_products = array(); $operator = $this->get_match_operator( $query_args ); if ( isset( $query_args['category_excludes'] ) && is_array( $query_args['category_excludes'] ) && count( $query_args['category_excludes'] ) > 0 ) { $excluded_products = $this->get_products_by_cat_ids( $query_args['category_excludes'] ); } if ( isset( $query_args['product_excludes'] ) && is_array( $query_args['product_excludes'] ) && count( $query_args['product_excludes'] ) > 0 ) { $excluded_products = array_merge( $excluded_products, $query_args['product_excludes'] ); } return $excluded_products; } /** * Returns comma separated ids of excluded products, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_excluded_products( $query_args ) { $excluded_products = $this->get_excluded_products_array( $query_args ); return implode( ',', $excluded_products ); } /** * Returns comma separated ids of included categories, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_included_categories( $query_args ) { return $this->get_filtered_ids( $query_args, 'category_includes' ); } /** * Returns comma separated ids of included coupons, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @param string $field Field name in the parameter list. * @return string */ protected function get_included_coupons( $query_args, $field = 'coupon_includes' ) { return $this->get_filtered_ids( $query_args, $field ); } /** * Returns comma separated ids of excluded coupons, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_excluded_coupons( $query_args ) { return $this->get_filtered_ids( $query_args, 'coupon_excludes' ); } /** * Returns comma separated ids of included orders, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_included_orders( $query_args ) { return $this->get_filtered_ids( $query_args, 'order_includes' ); } /** * Returns comma separated ids of excluded orders, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_excluded_orders( $query_args ) { return $this->get_filtered_ids( $query_args, 'order_excludes' ); } /** * Returns comma separated ids of included users, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_included_users( $query_args ) { return $this->get_filtered_ids( $query_args, 'user_includes' ); } /** * Returns comma separated ids of excluded users, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_excluded_users( $query_args ) { return $this->get_filtered_ids( $query_args, 'user_excludes' ); } /** * Returns order status subquery to be used in WHERE SQL query, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @param string $operator AND or OR, based on match query argument. * @return string */ protected function get_status_subquery( $query_args, $operator = 'AND' ) { global $wpdb; $subqueries = array(); $excluded_statuses = array(); if ( isset( $query_args['status_is'] ) && is_array( $query_args['status_is'] ) && count( $query_args['status_is'] ) > 0 ) { $allowed_statuses = array_map( array( $this, 'normalize_order_status' ), esc_sql( $query_args['status_is'] ) ); if ( $allowed_statuses ) { $subqueries[] = "{$wpdb->prefix}wc_order_stats.status IN ( '" . implode( "','", $allowed_statuses ) . "' )"; } } if ( isset( $query_args['status_is_not'] ) && is_array( $query_args['status_is_not'] ) && count( $query_args['status_is_not'] ) > 0 ) { $excluded_statuses = array_map( array( $this, 'normalize_order_status' ), $query_args['status_is_not'] ); } if ( ( ! isset( $query_args['status_is'] ) || empty( $query_args['status_is'] ) ) && ( ! isset( $query_args['status_is_not'] ) || empty( $query_args['status_is_not'] ) ) ) { $excluded_statuses = array_map( array( $this, 'normalize_order_status' ), $this->get_excluded_report_order_statuses() ); } if ( $excluded_statuses ) { $subqueries[] = "{$wpdb->prefix}wc_order_stats.status NOT IN ( '" . implode( "','", $excluded_statuses ) . "' )"; } return implode( " $operator ", $subqueries ); } /** * Add order status SQL clauses if included in query. * * @param array $query_args Parameters supplied by the user. * @param string $table_name Database table name. * @param SqlQuery $sql_query Query object. */ protected function add_order_status_clause( $query_args, $table_name, &$sql_query ) { global $wpdb; $order_status_filter = $this->get_status_subquery( $query_args ); if ( $order_status_filter ) { $sql_query->add_sql_clause( 'join', "JOIN {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id" ); $sql_query->add_sql_clause( 'where', "AND ( {$order_status_filter} )" ); } } /** * Add order by SQL clause if included in query. * * @param array $query_args Parameters supplied by the user. * @param SqlQuery $sql_query Query object. * @return string Order by clause. */ protected function add_order_by_clause( $query_args, &$sql_query ) { $order_by_clause = ''; $sql_query->clear_sql_clause( array( 'order_by' ) ); if ( isset( $query_args['orderby'] ) ) { $order_by_clause = $this->normalize_order_by( esc_sql( $query_args['orderby'] ) ); $sql_query->add_sql_clause( 'order_by', $order_by_clause ); } // Return ORDER BY clause to allow adding the sort field(s) to query via a JOIN. return $order_by_clause; } /** * Add order by order SQL clause. * * @param array $query_args Parameters supplied by the user. * @param SqlQuery $sql_query Query object. */ protected function add_orderby_order_clause( $query_args, &$sql_query ) { if ( isset( $query_args['order'] ) ) { $sql_query->add_sql_clause( 'order_by', esc_sql( $query_args['order'] ) ); } else { $sql_query->add_sql_clause( 'order_by', 'DESC' ); } } /** * Returns customer subquery to be used in WHERE SQL query, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_customer_subquery( $query_args ) { global $wpdb; $customer_filter = ''; if ( isset( $query_args['customer_type'] ) ) { if ( 'new' === strtolower( $query_args['customer_type'] ) ) { $customer_filter = " {$wpdb->prefix}wc_order_stats.returning_customer = 0"; } elseif ( 'returning' === strtolower( $query_args['customer_type'] ) ) { $customer_filter = " {$wpdb->prefix}wc_order_stats.returning_customer = 1"; } } return $customer_filter; } /** * Returns product attribute subquery elements used in JOIN and WHERE clauses, * based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return array */ protected function get_attribute_subqueries( $query_args ) { global $wpdb; $sql_clauses = array( 'join' => array(), 'where' => array(), ); $match_operator = $this->get_match_operator( $query_args ); $post_meta_comparators = array( '=' => 'attribute_is', '!=' => 'attribute_is_not', ); foreach ( $post_meta_comparators as $comparator => $arg ) { if ( ! isset( $query_args[ $arg ] ) || ! is_array( $query_args[ $arg ] ) ) { continue; } foreach ( $query_args[ $arg ] as $attribute_term ) { // We expect tuples. if ( ! is_array( $attribute_term ) || 2 !== count( $attribute_term ) ) { continue; } $term_id = ''; // If the tuple is numeric, assume these are IDs. if ( is_numeric( $attribute_term[0] ) && is_numeric( $attribute_term[1] ) ) { $attribute_id = intval( $attribute_term[0] ); $term_id = intval( $attribute_term[1] ); // Invalid IDs. if ( 0 === $attribute_id || 0 === $term_id ) { continue; } // @todo: Use wc_get_attribute () instead ? $attr_taxonomy = wc_attribute_taxonomy_name_by_id( $attribute_id ); // Invalid attribute ID. if ( empty( $attr_taxonomy ) ) { continue; } $attr_term = get_term_by( 'id', $term_id, $attr_taxonomy ); // Invalid term ID. if ( false === $attr_term ) { continue; } $meta_key = sanitize_title( $attr_taxonomy ); $meta_value = $attr_term->slug; } else { // Assume these are a custom attribute slug/value pair. $meta_key = esc_sql( $attribute_term[0] ); $meta_value = esc_sql( $attribute_term[1] ); $attr_term = get_term_by( 'slug', $meta_value, $meta_key ); if ( false !== $attr_term ) { $term_id = $attr_term->term_id; } } $join_alias = 'orderitemmeta1'; $table_to_join_on = "{$wpdb->prefix}wc_order_product_lookup"; if ( empty( $sql_clauses['join'] ) ) { $sql_clauses['join'][] = "JOIN {$wpdb->prefix}woocommerce_order_items orderitems ON orderitems.order_id = {$table_to_join_on}.order_id"; } // If we're matching all filters (AND), we'll need multiple JOINs on postmeta. // If not, just one. if ( 'AND' === $match_operator || 1 === count( $sql_clauses['join'] ) ) { $join_idx = count( $sql_clauses['join'] ); $join_alias = 'orderitemmeta' . $join_idx; $sql_clauses['join'][] = "JOIN {$wpdb->prefix}woocommerce_order_itemmeta as {$join_alias} ON {$join_alias}.order_item_id = {$table_to_join_on}.order_item_id"; } $in_comparator = '=' === $comparator ? 'in' : 'not in'; // Add subquery for products ordered using attributes not used in variations. $term_attribute_subquery = "select product_id from {$wpdb->prefix}wc_product_attributes_lookup where is_variation_attribute=0 and term_id = %s"; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber $sql_clauses['where'][] = $wpdb->prepare( " ( ( {$join_alias}.meta_key = %s AND {$join_alias}.meta_value {$comparator} %s ) or ( {$wpdb->prefix}wc_order_product_lookup.variation_id = 0 and {$wpdb->prefix}wc_order_product_lookup.product_id {$in_comparator} ({$term_attribute_subquery}) ) )", $meta_key, $meta_value, $term_id, ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber } } // If we're matching multiple attributes and all filters (AND), make sure // we're matching attributes on the same product. $num_attribute_filters = count( $sql_clauses['join'] ); for ( $i = 2; $i < $num_attribute_filters; $i++ ) { $join_alias = 'orderitemmeta' . $i; $sql_clauses['join'][] = "AND orderitemmeta1.order_item_id = {$join_alias}.order_item_id"; } return $sql_clauses; } /** * Returns logic operator for WHERE subclause based on 'match' query argument. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_match_operator( $query_args ) { $operator = 'AND'; if ( ! isset( $query_args['match'] ) ) { return $operator; } if ( 'all' === strtolower( $query_args['match'] ) ) { $operator = 'AND'; } elseif ( 'any' === strtolower( $query_args['match'] ) ) { $operator = 'OR'; } return $operator; } /** * Returns filtered comma separated ids, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @param string $field Query field to filter. * @param string $separator Field separator. * @return string */ protected function get_filtered_ids( $query_args, $field, $separator = ',' ) { global $wpdb; $ids_str = ''; $ids = isset( $query_args[ $field ] ) && is_array( $query_args[ $field ] ) ? $query_args[ $field ] : array(); /** * Filter the IDs before retrieving report data. * * Allows filtering of the objects included or excluded from reports. * * @param array $ids List of object Ids. * @param array $query_args The original arguments for the request. * @param string $field The object type. * @param string $context The data store context. */ $ids = apply_filters( 'woocommerce_analytics_' . $field, $ids, $query_args, $field, $this->context ); if ( ! empty( $ids ) ) { $placeholders = implode( $separator, array_fill( 0, count( $ids ), '%d' ) ); /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ $ids_str = $wpdb->prepare( "{$placeholders}", $ids ); /* phpcs:enable */ } return $ids_str; } /** * Assign report columns once full table name has been assigned. */ protected function assign_report_columns() {} } API/Reports/Variations/DataStore.php 0000777 00000043206 15252240713 0013346 0 ustar 00 <?php /** * API\Reports\Variations\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Variations; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; /** * API\Reports\Variations\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Table used to get the data. * * @override ReportsDataStore::$table_name * * @var string */ protected static $table_name = 'wc_order_product_lookup'; /** * Cache identifier. * * @override ReportsDataStore::$cache_key * * @var string */ protected $cache_key = 'variations'; /** * Mapping columns to data type to return correct response types. * * @override ReportsDataStore::$column_types * * @var array */ protected $column_types = array( 'date_start' => 'strval', 'date_end' => 'strval', 'product_id' => 'intval', 'variation_id' => 'intval', 'items_sold' => 'intval', 'net_revenue' => 'floatval', 'orders_count' => 'intval', 'name' => 'strval', 'price' => 'floatval', 'image' => 'strval', 'permalink' => 'strval', 'sku' => 'strval', ); /** * Extended product attributes to include in the data. * * @var array */ protected $extended_attributes = array( 'name', 'price', 'image', 'permalink', 'stock_status', 'stock_quantity', 'low_stock_amount', 'sku', ); /** * Data store context used to pass to filters. * * @override ReportsDataStore::$context * * @var string */ protected $context = 'variations'; /** * Assign report columns once full table name has been assigned. * * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); $this->report_columns = array( 'product_id' => 'product_id', 'variation_id' => 'variation_id', 'items_sold' => 'SUM(product_qty) as items_sold', 'net_revenue' => 'SUM(product_net_revenue) AS net_revenue', 'orders_count' => "COUNT(DISTINCT {$table_name}.order_id) as orders_count", ); } /** * Fills FROM clause of SQL request based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. * @param string $arg_name Target of the JOIN sql param. */ protected function add_from_sql_params( $query_args, $arg_name ) { global $wpdb; if ( 'sku' !== $query_args['orderby'] ) { return; } $table_name = self::get_db_table_name(); $join = "LEFT JOIN {$wpdb->postmeta} AS postmeta ON {$table_name}.variation_id = postmeta.post_id AND postmeta.meta_key = '_sku'"; if ( 'inner' === $arg_name ) { $this->subquery->add_sql_clause( 'join', $join ); } else { $this->add_sql_clause( 'join', $join ); } } /** * Generate a subquery for order_item_id based on the attribute filters. * * @param array $query_args Query arguments supplied by the user. * @return string */ protected function get_order_item_by_attribute_subquery( $query_args ) { $order_product_lookup_table = self::get_db_table_name(); $attribute_subqueries = $this->get_attribute_subqueries( $query_args ); if ( $attribute_subqueries['join'] && $attribute_subqueries['where'] ) { // Perform a subquery for DISTINCT order items that match our attribute filters. $attr_subquery = new SqlQuery( $this->context . '_attribute_subquery' ); $attr_subquery->add_sql_clause( 'select', "DISTINCT {$order_product_lookup_table}.order_item_id" ); $attr_subquery->add_sql_clause( 'from', $order_product_lookup_table ); if ( $this->should_exclude_simple_products( $query_args ) ) { $attr_subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.variation_id != 0" ); } foreach ( $attribute_subqueries['join'] as $attribute_join ) { $attr_subquery->add_sql_clause( 'join', $attribute_join ); } $operator = $this->get_match_operator( $query_args ); $attr_subquery->add_sql_clause( 'where', 'AND (' . implode( " {$operator} ", $attribute_subqueries['where'] ) . ')' ); return "AND {$order_product_lookup_table}.order_item_id IN ({$attr_subquery->get_query_statement()})"; } return false; } /** * Updates the database query with parameters used for Products report: categories and order status. * * @param array $query_args Query arguments supplied by the user. */ protected function add_sql_query_params( $query_args ) { global $wpdb; $order_product_lookup_table = self::get_db_table_name(); $order_stats_lookup_table = $wpdb->prefix . 'wc_order_stats'; $order_item_meta_table = $wpdb->prefix . 'woocommerce_order_itemmeta'; $where_subquery = array(); $this->add_time_period_sql_params( $query_args, $order_product_lookup_table ); $this->get_limit_sql_params( $query_args ); $this->add_order_by_sql_params( $query_args ); $included_variations = $this->get_included_variations( $query_args ); if ( $included_variations > 0 ) { $this->add_from_sql_params( $query_args, 'outer' ); } else { $this->add_from_sql_params( $query_args, 'inner' ); } $included_products = $this->get_included_products( $query_args ); if ( $included_products ) { $this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.product_id IN ({$included_products})" ); } $excluded_products = $this->get_excluded_products( $query_args ); if ( $excluded_products ) { $this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.product_id NOT IN ({$excluded_products})" ); } if ( $included_variations ) { $this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.variation_id IN ({$included_variations})" ); } elseif ( $this->should_exclude_simple_products( $query_args ) ) { $this->subquery->add_sql_clause( 'where', "AND {$order_product_lookup_table}.variation_id != 0" ); } $order_status_filter = $this->get_status_subquery( $query_args ); if ( $order_status_filter ) { $this->subquery->add_sql_clause( 'join', "JOIN {$order_stats_lookup_table} ON {$order_product_lookup_table}.order_id = {$order_stats_lookup_table}.order_id" ); $this->subquery->add_sql_clause( 'where', "AND ( {$order_status_filter} )" ); } $attribute_order_items_subquery = $this->get_order_item_by_attribute_subquery( $query_args ); if ( $attribute_order_items_subquery ) { // JOIN on product lookup if we haven't already. if ( ! $order_status_filter ) { $this->subquery->add_sql_clause( 'join', "JOIN {$order_product_lookup_table} ON {$order_stats_lookup_table}.order_id = {$order_product_lookup_table}.order_id" ); } // Add subquery for matching attributes to WHERE. $this->subquery->add_sql_clause( 'where', $attribute_order_items_subquery ); } if ( 0 < count( $where_subquery ) ) { $operator = $this->get_match_operator( $query_args ); $this->subquery->add_sql_clause( 'where', 'AND (' . implode( " {$operator} ", $where_subquery ) . ')' ); } } /** * Maps ordering specified by the user to columns in the database/fields in the data. * * @override ReportsDataStore::normalize_order_by() * * @param string $order_by Sorting criterion. * * @return string */ protected function normalize_order_by( $order_by ) { if ( 'date' === $order_by ) { return self::get_db_table_name() . '.date_created'; } if ( 'sku' === $order_by ) { return 'meta_value'; } return $order_by; } /** * Enriches the product data with attributes specified by the extended_attributes. * * @param array $products_data Product data. * @param array $query_args Query parameters. */ protected function include_extended_info( &$products_data, $query_args ) { foreach ( $products_data as $key => $product_data ) { $extended_info = new \ArrayObject(); if ( $query_args['extended_info'] ) { $extended_attributes = apply_filters( 'woocommerce_rest_reports_variations_extended_attributes', $this->extended_attributes, $product_data ); $parent_product = wc_get_product( $product_data['product_id'] ); $attributes = array(); // Base extended info off the parent variable product if the variation ID is 0. // This is caused by simple products with prior sales being converted into variable products. // See: https://github.com/woocommerce/woocommerce-admin/issues/2719. $variation_id = (int) $product_data['variation_id']; $variation_product = ( 0 === $variation_id ) ? $parent_product : wc_get_product( $variation_id ); // Fall back to the parent product if the variation can't be found. $extended_attributes_product = is_a( $variation_product, 'WC_Product' ) ? $variation_product : $parent_product; // If both product and variation is not found, set deleted to true. if ( ! $extended_attributes_product ) { $extended_info['deleted'] = true; } foreach ( $extended_attributes as $extended_attribute ) { $function = 'get_' . $extended_attribute; if ( is_callable( array( $extended_attributes_product, $function ) ) ) { $value = $extended_attributes_product->{$function}(); $extended_info[ $extended_attribute ] = $value; } } // If this is a variation, add its attributes. // NOTE: We don't fall back to the parent product here because it will include all possible attribute options. if ( 0 < $variation_id && is_callable( array( $variation_product, 'get_variation_attributes' ) ) ) { $variation_attributes = $variation_product->get_variation_attributes(); foreach ( $variation_attributes as $attribute_name => $attribute ) { $name = str_replace( 'attribute_', '', $attribute_name ); $option_term = get_term_by( 'slug', $attribute, $name ); $attributes[] = array( 'id' => wc_attribute_taxonomy_id_by_name( $name ), 'name' => str_replace( 'pa_', '', $name ), 'option' => $option_term && ! is_wp_error( $option_term ) ? $option_term->name : $attribute, ); } } $extended_info['attributes'] = $attributes; // If there is no set low_stock_amount, use the one in user settings. if ( '' === $extended_info['low_stock_amount'] ) { $extended_info['low_stock_amount'] = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) ); } $extended_info = $this->cast_numbers( $extended_info ); } $products_data[ $key ]['extended_info'] = $extended_info; } } /** * Returns if simple products should be excluded from the report. * * @internal * * @param array $query_args Query parameters. * * @return boolean */ protected function should_exclude_simple_products( array $query_args ) { return apply_filters( 'experimental_woocommerce_analytics_variations_should_exclude_simple_products', true, $query_args ); } /** * Fill missing extended_info.name for the deleted products. * * @param array $products Product data. */ protected function fill_deleted_product_name( array &$products ) { global $wpdb; $product_variation_ids = array(); // Find products with missing extended_info.name. foreach ( $products as $key => $product ) { if ( ! isset( $product['extended_info']['name'] ) ) { $product_variation_ids[ $key ] = array( 'product_id' => $product['product_id'], 'variation_id' => $product['variation_id'], ); } } if ( ! count( $product_variation_ids ) ) { return; } $where_clauses = implode( ' or ', array_map( function ( $ids ) { return "( product_lookup.product_id = {$ids['product_id']} and product_lookup.variation_id = {$ids['variation_id']} )"; }, $product_variation_ids ) ); $query = " select product_lookup.product_id, product_lookup.variation_id, order_items.order_item_name from {$wpdb->prefix}wc_order_product_lookup as product_lookup left join {$wpdb->prefix}woocommerce_order_items as order_items on product_lookup.order_item_id = order_items.order_item_id where {$where_clauses} group by product_lookup.product_id, product_lookup.variation_id, order_items.order_item_name "; // phpcs:ignore $results = $wpdb->get_results( $query ); $index = array(); foreach ( $results as $result ) { $index[ $result->product_id . '_' . $result->variation_id ] = $result->order_item_name; } foreach ( $product_variation_ids as $product_key => $ids ) { $product = $products[ $product_key ]; $index_key = $product['product_id'] . '_' . $product['variation_id']; if ( isset( $index[ $index_key ] ) ) { $products[ $product_key ]['extended_info']['name'] = $index[ $index_key ]; } } } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override ReportsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['product_includes'] = array(); $defaults['variation_includes'] = array(); $defaults['extended_info'] = false; return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override ReportsDataStore::get_noncached_data() * * @see get_data * @param array $query_args Query parameters. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_data( $query_args ) { global $wpdb; $table_name = self::get_db_table_name(); $this->initialize_queries(); $data = (object) array( 'data' => array(), 'total' => 0, 'pages' => 0, 'page_no' => 0, ); $selections = $this->selected_columns( $query_args ); $included_variations = ( isset( $query_args['variation_includes'] ) && is_array( $query_args['variation_includes'] ) ) ? $query_args['variation_includes'] : array(); $params = $this->get_limit_params( $query_args ); $this->add_sql_query_params( $query_args ); if ( count( $included_variations ) > 0 ) { $total_results = count( $included_variations ); $total_pages = (int) ceil( $total_results / $params['per_page'] ); $this->subquery->clear_sql_clause( 'select' ); $this->subquery->add_sql_clause( 'select', $selections ); if ( 'date' === $query_args['orderby'] ) { $this->subquery->add_sql_clause( 'select', ", {$table_name}.date_created" ); } $fields = $this->get_fields( $query_args ); $join_selections = $this->format_join_selections( $fields, array( 'variation_id' ) ); $ids_table = $this->get_ids_table( $included_variations, 'variation_id' ); $this->add_sql_clause( 'select', $join_selections ); $this->add_sql_clause( 'from', '(' ); $this->add_sql_clause( 'from', $this->subquery->get_query_statement() ); $this->add_sql_clause( 'from', ") AS {$table_name}" ); $this->add_sql_clause( 'right_join', "RIGHT JOIN ( {$ids_table} ) AS default_results ON default_results.variation_id = {$table_name}.variation_id" ); $variations_query = $this->get_query_statement(); } else { $this->subquery->clear_sql_clause( 'select' ); $this->subquery->add_sql_clause( 'select', $selections ); /** * Experimental: Filter the Variations SQL query allowing extensions to add additional SQL clauses. * * @since 7.4.0 * @param array $query_args Query parameters. * @param SqlQuery $subquery Variations query class. */ apply_filters( 'experimental_woocommerce_analytics_variations_additional_clauses', $query_args, $this->subquery ); /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ $db_records_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ( {$this->subquery->get_query_statement()} ) AS tt" ); /* phpcs:enable */ $total_results = $db_records_count; $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return $data; } if ( in_array( $query_args['orderby'], array( 'items_sold', 'net_revenue', 'orders_count' ), true ) ) { $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) . ', product_id, variation_id' ); } else { $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); } $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $variations_query = $this->subquery->get_query_statement(); } /* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */ $product_data = $wpdb->get_results( $variations_query, ARRAY_A ); /* phpcs:enable */ if ( null === $product_data ) { return $data; } $this->include_extended_info( $product_data, $query_args ); if ( $query_args['extended_info'] ) { $this->fill_deleted_product_name( $product_data ); } $product_data = array_map( array( $this, 'cast_numbers' ), $product_data ); $data = (object) array( 'data' => $product_data, 'total' => $total_results, 'pages' => $total_pages, 'page_no' => (int) $query_args['page'], ); return $data; } /** * Initialize query objects. */ protected function initialize_queries() { $this->clear_all_clauses(); $this->subquery = new SqlQuery( $this->context . '_subquery' ); $this->subquery->add_sql_clause( 'select', 'product_id' ); $this->subquery->add_sql_clause( 'from', self::get_db_table_name() ); $this->subquery->add_sql_clause( 'group_by', 'product_id, variation_id' ); } } API/Reports/Variations/Controller.php 0000777 00000033421 15252240713 0013601 0 ustar 00 <?php /** * REST API Reports products controller * * Handles requests to the /reports/products endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Variations; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Admin\API\Reports\ExportableTraits; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use Automattic\WooCommerce\Admin\API\Reports\OrderAwareControllerTrait; /** * REST API Reports products controller class. * * @internal * @extends GenericController */ class Controller extends GenericController implements ExportableInterface { // The controller does not use this trait. It's here for API backward compatibility. use OrderAwareControllerTrait; /** * Exportable traits. */ use ExportableTraits; /** * Route base. * * @var string */ protected $rest_base = 'reports/variations'; /** * Mapping between external parameter name and name used in query class. * * @var array */ protected $param_mapping = array( 'variations' => 'variation_includes', 'products' => 'product_includes', ); /** * Get data from `'variations'` GenericQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new GenericQuery( $query_args, 'variations' ); return $query->get_data(); } /** * Prepare a report data item for serialization. * * @param array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { // Wrap the data in a response object. $response = parent::prepare_item_for_response( $report, $request ); $response->add_links( $this->prepare_links( $report ) ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @since 6.5.0 * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_variations', $response, $report, $request ); } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); /** * Experimental: Filter the list of parameters provided when querying data from the data store. * * @ignore * * @param array $collection_params List of parameters. * * @since 6.5.0 */ $collection_params = apply_filters( 'experimental_woocommerce_analytics_variations_collection_params', $this->get_collection_params() ); $registered = array_keys( $collection_params ); foreach ( $registered as $param_name ) { if ( isset( $request[ $param_name ] ) ) { if ( isset( $this->param_mapping[ $param_name ] ) ) { $args[ $this->param_mapping[ $param_name ] ] = $request[ $param_name ]; } else { $args[ $param_name ] = $request[ $param_name ]; } } } return $args; } /** * Prepare links for the request. * * @param array $object Object data. * @return array Links for the given post. */ protected function prepare_links( $object ) { $links = array( 'product' => array( 'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, 'products', $object['product_id'] ) ), ), 'variation' => array( 'href' => rest_url( sprintf( '/%s/%s/%d/%s/%d', $this->namespace, 'products', $object['product_id'], 'variation', $object['variation_id'] ) ), ), ); return $links; } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_varitations', 'type' => 'object', 'properties' => array( 'product_id' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product ID.', 'woocommerce' ), ), 'variation_id' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product ID.', 'woocommerce' ), ), 'items_sold' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Number of items sold.', 'woocommerce' ), ), 'net_revenue' => array( 'type' => 'number', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Total Net sales of all items sold.', 'woocommerce' ), ), 'orders_count' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Number of orders product appeared in.', 'woocommerce' ), ), 'extended_info' => array( 'name' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product name.', 'woocommerce' ), ), 'price' => array( 'type' => 'number', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product price.', 'woocommerce' ), ), 'image' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product image.', 'woocommerce' ), ), 'permalink' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product link.', 'woocommerce' ), ), 'attributes' => array( 'type' => 'array', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product attributes.', 'woocommerce' ), ), 'stock_status' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product inventory status.', 'woocommerce' ), ), 'stock_quantity' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product inventory quantity.', 'woocommerce' ), ), 'low_stock_amount' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product inventory threshold for low stock.', 'woocommerce' ), ), ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'date', 'net_revenue', 'orders_count', 'items_sold', 'sku', ) ); $params['match'] = array( 'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ), 'type' => 'string', 'default' => 'all', 'enum' => array( 'all', 'any', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['product_includes'] = array( 'description' => __( 'Limit result set to items that have the specified parent product(s).', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); $params['product_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified parent product(s).', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'wp_parse_id_list', ); $params['variations'] = array( 'description' => __( 'Limit result to items with specified variation ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['extended_info'] = array( 'description' => __( 'Add additional piece of info about each variation to the report.', 'woocommerce' ), 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'wc_string_to_bool', 'validate_callback' => 'rest_validate_request_arg', ); $params['attribute_is'] = array( 'description' => __( 'Limit result set to variations that include the specified attributes.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'array', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); $params['attribute_is_not'] = array( 'description' => __( 'Limit result set to variations that don\'t include the specified attributes.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'array', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); $params['category_includes'] = array( 'description' => __( 'Limit result set to variations in the specified categories.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['category_excludes'] = array( 'description' => __( 'Limit result set to variations not in the specified categories.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['products'] = array( 'description' => __( 'Limit result to items with specified product ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); return $params; } /** * Get stock status column export value. * * @param array $status Stock status from report row. * @return string */ protected function get_stock_status( $status ) { $statuses = wc_get_product_stock_status_options(); return isset( $statuses[ $status ] ) ? $statuses[ $status ] : ''; } /** * Get the column names for export. * * @return array Key value pair of Column ID => Label. */ public function get_export_columns() { $export_columns = array( 'product_name' => __( 'Product / Variation title', 'woocommerce' ), 'sku' => __( 'SKU', 'woocommerce' ), 'items_sold' => __( 'Items sold', 'woocommerce' ), 'net_revenue' => __( 'N. Revenue', 'woocommerce' ), 'orders_count' => __( 'Orders', 'woocommerce' ), ); if ( 'yes' === get_option( 'woocommerce_manage_stock' ) ) { $export_columns['stock_status'] = __( 'Status', 'woocommerce' ); $export_columns['stock'] = __( 'Stock', 'woocommerce' ); } return $export_columns; } /** * Get the column values for export. * * @param array $item Single report item/row. * @return array Key value pair of Column ID => Row Value. */ public function prepare_item_for_export( $item ) { $product_name = $item['extended_info']['name']; /** * Filter the separator used in the product variation title. * * @since 10.2.0 * @param string $separator The separator. * @param \WC_Product $product The product object. * @return string The separator. */ $separator = apply_filters( 'woocommerce_product_variation_title_attributes_separator', ' - ', new \WC_Product() ); if ( ! empty( $item['extended_info']['attributes'] ) && strpos( $product_name, $separator ) === false ) { $attributes = array(); foreach ( $item['extended_info']['attributes'] as $attribute ) { if ( empty( $attribute['option'] ) ) { // translators: %s: the attribute name. $attributes[] = sprintf( __( 'Any %s', 'woocommerce' ), ucfirst( $attribute['name'] ) ); } else { $attributes[] = $attribute['option']; } } $product_name .= $separator . implode( ', ', $attributes ); } $export_item = array( 'product_name' => $product_name, 'sku' => $item['extended_info']['sku'], 'items_sold' => $item['items_sold'], 'net_revenue' => self::csv_number_format( $item['net_revenue'] ), 'orders_count' => $item['orders_count'], ); if ( 'yes' === get_option( 'woocommerce_manage_stock' ) ) { $export_item['stock_status'] = $this->get_stock_status( $item['extended_info']['stock_status'] ); $export_item['stock'] = $item['extended_info']['stock_quantity']; } return $export_item; } } API/Reports/Variations/Stats/Segmenter.php 0000777 00000017737 15252240713 0014521 0 ustar 00 <?php /** * Class for adding segmenting support without cluttering the data stores. */ namespace Automattic\WooCommerce\Admin\API\Reports\Variations\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Segmenter as ReportsSegmenter; use Automattic\WooCommerce\Admin\API\Reports\ParameterException; /** * Date & time interval and numeric range handling class for Reporting API. */ class Segmenter extends ReportsSegmenter { /** * Returns column => query mapping to be used for product-related product-level segmenting query * (e.g. products sold, revenue from product X when segmenting by category). * * @param string $products_table Name of SQL table containing the product-level segmenting info. * * @return array Column => SELECT query mapping. */ protected function get_segment_selections_product_level( $products_table ) { $columns_mapping = array( 'items_sold' => "SUM($products_table.product_qty) as items_sold", 'net_revenue' => "SUM($products_table.product_net_revenue ) AS net_revenue", 'orders_count' => "COUNT( DISTINCT $products_table.order_id ) AS orders_count", 'variations_count' => "COUNT( DISTINCT $products_table.variation_id ) AS variations_count", ); return $columns_mapping; } /** * Calculate segments for totals where the segmenting property is bound to product (e.g. category, product_id, variation_id). * * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $segmenting_dimension_name Name of the segmenting dimension. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $totals_query Array of SQL clauses for totals query. * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. * * @return array */ protected function get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $totals_query, $unique_orders_table ) { global $wpdb; $product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup'; // Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued). // Product-level numbers. /* phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared */ $segments_products = $wpdb->get_results( "SELECT $segmenting_groupby AS $segmenting_dimension_name {$segmenting_selections['product_level']} FROM $table_name $segmenting_from {$totals_query['from_clause']} WHERE 1=1 {$totals_query['where_time_clause']} {$totals_query['where_clause']} $segmenting_where GROUP BY $segmenting_groupby", ARRAY_A ); /* phpcs:enable */ $totals_segments = $this->merge_segment_totals_results( $segmenting_dimension_name, $segments_products, array() ); return $totals_segments; } /** * Calculate segments for intervals where the segmenting property is bound to product (e.g. category, product_id, variation_id). * * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $segmenting_dimension_name Name of the segmenting dimension. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $intervals_query Array of SQL clauses for intervals query. * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. * * @return array */ protected function get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $intervals_query, $unique_orders_table ) { global $wpdb; $product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup'; // LIMIT offset, rowcount needs to be updated to a multiple of the number of segments. preg_match( '/LIMIT (\d+)\s?,\s?(\d+)/', $intervals_query['limit'], $limit_parts ); $segment_count = count( $this->get_all_segments() ); $orig_offset = intval( $limit_parts[1] ); $orig_rowcount = intval( $limit_parts[2] ); $segmenting_limit = $wpdb->prepare( 'LIMIT %d, %d', $orig_offset * $segment_count, $orig_rowcount * $segment_count ); // Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued). // Product-level numbers. // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $segments_products = $wpdb->get_results( "SELECT {$intervals_query['select_clause']} AS time_interval, $segmenting_groupby AS $segmenting_dimension_name {$segmenting_selections['product_level']} FROM $table_name $segmenting_from {$intervals_query['from_clause']} WHERE 1=1 {$intervals_query['where_time_clause']} {$intervals_query['where_clause']} $segmenting_where GROUP BY time_interval, $segmenting_groupby $segmenting_limit", // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared ARRAY_A ); $intervals_segments = $this->merge_segment_intervals_results( $segmenting_dimension_name, $segments_products, array() ); return $intervals_segments; } /** * Return array of segments formatted for REST response. * * @param string $type Type of segments to return--'totals' or 'intervals'. * @param array $query_params SQL query parameter array. * @param string $table_name Name of main SQL table for the data store (used as basis for JOINS). * * @return array|null * @throws \Automattic\WooCommerce\Admin\API\Reports\ParameterException In case of segmenting by variations, when no parent product is specified. */ protected function get_segments( $type, $query_params, $table_name ) { global $wpdb; if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) { return array(); } $segments = null; $product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup'; $unique_orders_table = 'uniq_orders'; $segmenting_where = ''; // Product, variation, and category are bound to product, so here product segmenting table is required, // while coupon and customer are bound to order, so we don't need the extra JOIN for those. // This also means that segment selections need to be calculated differently. if ( 'variation' === $this->query_args['segmentby'] ) { $product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table ); $segmenting_selections = array( 'product_level' => $this->prepare_selections( $product_level_columns ), ); $this->report_columns = $product_level_columns; $segmenting_from = ''; $segmenting_groupby = $product_segmenting_table . '.variation_id'; $segmenting_dimension_name = 'variation_id'; // Restrict our search space for variation comparisons. if ( isset( $this->query_args['variation_includes'] ) ) { $variation_ids = implode( ',', $this->get_all_segments() ); $segmenting_where = " AND $product_segmenting_table.variation_id IN ( $variation_ids )"; } $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } return $segments; } } API/Reports/Variations/Stats/Controller.php 0000777 00000023001 15252240713 0014670 0 ustar 00 <?php /** * REST API Reports variations stats controller * * Handles requests to the /reports/variations/stats endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Variations\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController; use WP_REST_Request; use WP_REST_Response; /** * REST API Reports variations stats controller class. * * @internal * @extends GenericStatsController */ class Controller extends GenericStatsController { /** * Route base. * * @var string */ protected $rest_base = 'reports/variations/stats'; /** * Mapping between external parameter name and name used in query class. * * @var array */ protected $param_mapping = array( 'variations' => 'variation_includes', ); /** * Constructor. */ public function __construct() { add_filter( 'woocommerce_analytics_variations_stats_select_query', array( $this, 'set_default_report_data' ) ); } /** * Get data from `'variations-stats'` GenericQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new GenericQuery( $query_args, 'variations-stats' ); return $query->get_data(); } /** * Maps query arguments from the REST request, to be fed to Query. * * @param \WP_REST_Request $request Full request object. * @return array Simplified array of params. */ protected function prepare_reports_query( $request ) { $query_args = array( 'fields' => array( 'items_sold', 'net_revenue', 'orders_count', 'variations_count', ), ); /** * Experimental: Filter the list of parameters provided when querying data from the data store. * * @ignore * * @param array $collection_params List of parameters. */ $collection_params = apply_filters( 'experimental_woocommerce_analytics_variations_stats_collection_params', $this->get_collection_params() ); $registered = array_keys( $collection_params ); foreach ( $registered as $param_name ) { if ( isset( $request[ $param_name ] ) ) { if ( isset( $this->param_mapping[ $param_name ] ) ) { $query_args[ $this->param_mapping[ $param_name ] ] = $request[ $param_name ]; } else { $query_args[ $param_name ] = $request[ $param_name ]; } } } return $query_args; } /** * Prepare a report data item for serialization. * * @param array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $response = parent::prepare_item_for_response( $report, $request ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_variations_stats', $response, $report, $request ); } /** * Get the Report's item properties schema. * Will be used by `get_item_schema` as `totals` and `subtotals`. * * @return array */ protected function get_item_properties_schema() { return array( 'items_sold' => array( 'title' => __( 'Variations Sold', 'woocommerce' ), 'description' => __( 'Number of variation items sold.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, ), 'net_revenue' => array( 'description' => __( 'Net sales.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'format' => 'currency', ), 'orders_count' => array( 'description' => __( 'Number of orders.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ); } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = parent::get_item_schema(); $schema['title'] = 'report_variations_stats'; $segment_label = array( 'description' => __( 'Human readable segment label, either product or variation name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'enum' => array( 'day', 'week', 'month', 'year' ), ); $schema['properties']['totals']['properties']['segments']['items']['properties']['segment_label'] = $segment_label; $schema['properties']['intervals']['items']['properties']['subtotals']['properties']['segments']['items']['properties']['segment_label'] = $segment_label; return $this->add_additional_fields_schema( $schema ); } /** * Set the default results to 0 if API returns an empty array * * @param Mixed $results Report data. * @return object */ public function set_default_report_data( $results ) { if ( empty( $results ) ) { $results = new \stdClass(); $results->total = 0; $results->totals = new \stdClass(); $results->totals->items_sold = 0; $results->totals->net_revenue = 0; $results->totals->orders_count = 0; $results->intervals = array(); $results->pages = 1; $results->page_no = 1; } return $results; } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['match'] = array( 'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ), 'type' => 'string', 'default' => 'all', 'enum' => array( 'all', 'any', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'date', 'net_revenue', 'coupons', 'refunds', 'shipping', 'taxes', 'net_revenue', 'orders_count', 'items_sold', ) ); $params['category_includes'] = array( 'description' => __( 'Limit result to items from the specified categories.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['category_excludes'] = array( 'description' => __( 'Limit result set to variations not in the specified categories.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['product_includes'] = array( 'description' => __( 'Limit result set to items that have the specified parent product(s).', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); $params['product_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified parent product(s).', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'wp_parse_id_list', ); $params['variations'] = array( 'description' => __( 'Limit result to items with specified variation ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['segmentby'] = array( 'description' => __( 'Segment the response by additional constraint.', 'woocommerce' ), 'type' => 'string', 'enum' => array( 'product', 'category', 'variation', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['attribute_is'] = array( 'description' => __( 'Limit result set to orders that include products with the specified attributes.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'array', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); $params['attribute_is_not'] = array( 'description' => __( 'Limit result set to orders that don\'t include products with the specified attributes.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'array', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', ); return $params; } } API/Reports/Variations/Stats/DataStore.php 0000777 00000025011 15252240713 0014436 0 ustar 00 <?php /** * API\Reports\Products\Stats\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Variations\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Variations\DataStore as VariationsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\StatsDataStoreTrait; /** * API\Reports\Variations\Stats\DataStore. */ class DataStore extends VariationsDataStore implements DataStoreInterface { use StatsDataStoreTrait; /** * Mapping columns to data type to return correct response types. * * @override VariationsDataStore::$column_types * * @var array */ protected $column_types = array( 'items_sold' => 'intval', 'net_revenue' => 'floatval', 'orders_count' => 'intval', 'variations_count' => 'intval', ); /** * Cache identifier. * * @override VariationsDataStore::$cache_key * * @var string */ protected $cache_key = 'variations_stats'; /** * Data store context used to pass to filters. * * @override VariationsDataStore::$context * * @var string */ protected $context = 'variations_stats'; /** * Assign report columns once full table name has been assigned. * * @override VariationsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); $this->report_columns = array( 'items_sold' => 'SUM(product_qty) as items_sold', 'net_revenue' => 'SUM(product_net_revenue) AS net_revenue', 'orders_count' => "COUNT( DISTINCT ( CASE WHEN product_gross_revenue >= 0 THEN {$table_name}.order_id END ) ) as orders_count", 'variations_count' => 'COUNT(DISTINCT variation_id) as variations_count', ); } /** * Updates the database query with parameters used for Products Stats report: categories and order status. * * @param array $query_args Query arguments supplied by the user. */ protected function update_sql_query_params( $query_args ) { global $wpdb; $products_where_clause = ''; $products_from_clause = ''; $where_subquery = array(); $order_product_lookup_table = self::get_db_table_name(); $order_item_meta_table = $wpdb->prefix . 'woocommerce_order_itemmeta'; $included_products = $this->get_included_products( $query_args ); if ( $included_products ) { $products_where_clause .= " AND {$order_product_lookup_table}.product_id IN ({$included_products})"; } $excluded_products = $this->get_excluded_products( $query_args ); if ( $excluded_products ) { $products_where_clause .= "AND {$order_product_lookup_table}.product_id NOT IN ({$excluded_products})"; } $included_variations = $this->get_included_variations( $query_args ); if ( $included_variations ) { $products_where_clause .= " AND {$order_product_lookup_table}.variation_id IN ({$included_variations})"; } elseif ( $this->should_exclude_simple_products( $query_args ) ) { $products_where_clause .= " AND {$order_product_lookup_table}.variation_id != 0"; } $order_status_filter = $this->get_status_subquery( $query_args ); if ( $order_status_filter ) { $products_from_clause .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id"; $products_where_clause .= " AND ( {$order_status_filter} )"; } $attribute_order_items_subquery = $this->get_order_item_by_attribute_subquery( $query_args ); if ( $attribute_order_items_subquery ) { // JOIN on product lookup if we haven't already. if ( ! $order_status_filter ) { $products_from_clause .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_product_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id"; } // Add subquery for matching attributes to WHERE. $products_where_clause .= $attribute_order_items_subquery; } if ( 0 < count( $where_subquery ) ) { $operator = $this->get_match_operator( $query_args ); $products_where_clause .= 'AND (' . implode( " {$operator} ", $where_subquery ) . ')'; } $this->add_time_period_sql_params( $query_args, $order_product_lookup_table ); $this->total_query->add_sql_clause( 'where', $products_where_clause ); $this->total_query->add_sql_clause( 'join', $products_from_clause ); $this->add_intervals_sql_params( $query_args, $order_product_lookup_table ); $this->interval_query->add_sql_clause( 'where', $products_where_clause ); $this->interval_query->add_sql_clause( 'join', $products_from_clause ); $this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' ); } /** * Returns if simple products should be excluded from the report. * * @internal * * @param array $query_args Query parameters. * * @return boolean */ protected function should_exclude_simple_products( array $query_args ) { return apply_filters( 'experimental_woocommerce_analytics_variations_stats_should_exclude_simple_products', true, $query_args ); } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override VariationsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['category_includes'] = array(); $defaults['interval'] = 'week'; unset( $defaults['extended_info'] ); return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override VariationsDataStore::get_noncached_stats_data() * * @see get_data * @see get_noncached_stats_data * @param array $query_args Query parameters. * @param array $params Query limit parameters. * @param stdClass $data Reference to the data object to fill. * @param int $expected_interval_count Number of expected intervals. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_stats_data( $query_args, $params, &$data, $expected_interval_count ) { global $wpdb; $table_name = self::get_db_table_name(); $this->initialize_queries(); $selections = $this->selected_columns( $query_args ); $this->update_sql_query_params( $query_args ); $this->get_limit_sql_params( $query_args ); $this->interval_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); /* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */ $db_intervals = $wpdb->get_col( $this->interval_query->get_query_statement() ); /* phpcs:enable */ $db_interval_count = count( $db_intervals ); $intervals = array(); $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); $this->total_query->add_sql_clause( 'select', $selections ); $this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); /* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */ $totals = $wpdb->get_results( $this->total_query->get_query_statement(), ARRAY_A ); /* phpcs:enable */ // phpcs:ignore Generic.Commenting.Todo.TaskFound // @todo remove these assignements when refactoring segmenter classes to use query objects. $totals_query = array( 'from_clause' => $this->total_query->get_sql_clause( 'join' ), 'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ), 'where_clause' => $this->total_query->get_sql_clause( 'where' ), ); $intervals_query = array( 'select_clause' => $this->get_sql_clause( 'select' ), 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), 'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ), 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), 'order_by' => $this->get_sql_clause( 'order_by' ), 'limit' => $this->get_sql_clause( 'limit' ), ); $segmenter = new Segmenter( $query_args, $this->report_columns ); $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); if ( null === $totals ) { return new \WP_Error( 'woocommerce_analytics_variations_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); } $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); if ( '' !== $selections ) { $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } /* phpcs:disable WordPress.DB.PreparedSQL.NotPrepared */ $intervals = $wpdb->get_results( $this->interval_query->get_query_statement(), ARRAY_A ); /* phpcs:enable */ if ( null === $intervals ) { return new \WP_Error( 'woocommerce_analytics_variations_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'woocommerce' ) ); } $totals = (object) $this->cast_numbers( $totals[0] ); $data->totals = $totals; $data->intervals = $intervals; if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); } else { $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); } $segmenter->add_intervals_segments( $data, $intervals_query, $table_name ); return $data; } /** * Normalizes order_by clause to match to SQL query. * * @override VariationsDataStore::normalize_order_by() * * @param string $order_by Order by option requeste by user. * @return string */ protected function normalize_order_by( $order_by ) { if ( 'date' === $order_by ) { return 'time_interval'; } return $order_by; } } API/Reports/Variations/Stats/Query.php 0000777 00000003747 15252240713 0013671 0 ustar 00 <?php /** * Class for parameter-based Variations Stats Report querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'page' => 2, * 'categories' => array(15, 18), * 'product_ids' => array(1,2,3) * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Variations\Stats\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Variations\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Variations\Stats\Query * * @deprecated 9.3.0 Variations\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Products report. * * @deprecated 9.3.0 Variations\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array(); } /** * Get variations data based on the current query vars. * * @deprecated 9.3.0 Variations\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_variations_stats_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-variations-stats' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_variations_stats_select_query', $results, $args ); } } API/Reports/Variations/Query.php 0000777 00000003645 15252240713 0012570 0 ustar 00 <?php /** * Class for parameter-based Products Report querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'page' => 2, * 'categories' => array(15, 18), * 'products' => array(1,2,3) * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Variations\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Variations; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Variations\Query * * @deprecated 9.3.0 Variations\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Products report. * * @deprecated 9.3.0 Variations\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array(); } /** * Get product data based on the current query vars. * * @deprecated 9.3.0 Variations\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_variations_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-variations' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_variations_select_query', $results, $args ); } } API/Reports/FilteredGetDataTrait.php 0000777 00000003065 15252240713 0013334 0 ustar 00 <?php declare( strict_types = 1); namespace Automattic\WooCommerce\Admin\API\Reports; // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Trait to call filters on `get_data` methods for data stores. * * It calls the filters `woocommerce_analytics_{$this->context}_query_args` and * `woocommerce_analytics_{$this->context}_select_query` on the `get_data` method. * * Example: * <pre><code class="language-php">class MyStatsDataStore extends DataStore implements DataStoreInterface { * // Use the trait. * use FilteredGetDataTrait; * // Provide all the necessary properties and methods for a regular DataStore. * // ... * } * </code></pre> * * @see DataStore */ trait FilteredGetDataTrait { /** * Get the data based on args. * * Filters query args, calls DataStore::get_data, and returns the filtered data. * * @override ReportsDataStore::get_data() * * @param array $query_args Query parameters. * @return stdClass|WP_Error */ public function get_data( $query_args ) { /** * Called before the data is fetched. * * @since 9.3.0 * @param array $query_args Query parameters. */ $args = apply_filters( "woocommerce_analytics_{$this->context}_query_args", $query_args ); $results = parent::get_data( $args ); /** * Called after the data is fetched. * The results can be modified here. * * @since 9.3.0 * @param stdClass|WP_Error $results The results of the query. */ return apply_filters( "woocommerce_analytics_{$this->context}_select_query", $results, $args ); } } API/Reports/Downloads/DataStore.php 0000777 00000031062 15252240713 0013156 0 ustar 00 <?php /** * API\Reports\Downloads\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Downloads; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; /** * API\Reports\Downloads\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Table used to get the data. * * @override ReportsDataStore::$table_name * * @var string */ protected static $table_name = 'wc_download_log'; /** * Cache identifier. * * @override ReportsDataStore::$cache_key * * @var string */ protected $cache_key = 'downloads'; /** * Mapping columns to data type to return correct response types. * * @override ReportsDataStore::$column_types * * @var array */ protected $column_types = array( 'id' => 'intval', 'date' => 'strval', 'date_gmt' => 'strval', 'download_id' => 'strval', // String because this can sometimes be a hash. 'file_name' => 'strval', 'product_id' => 'intval', 'order_id' => 'intval', 'user_id' => 'intval', 'ip_address' => 'strval', ); /** * Data store context used to pass to filters. * * @override ReportsDataStore::$context * * @var string */ protected $context = 'downloads'; /** * Assign report columns once full table name has been assigned. * * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $this->report_columns = array( 'id' => 'download_log_id as id', 'date' => 'timestamp as date_gmt', 'download_id' => 'product_permissions.download_id', 'product_id' => 'product_permissions.product_id', 'order_id' => 'product_permissions.order_id', 'user_id' => 'product_permissions.user_id', 'ip_address' => 'user_ip_address as ip_address', ); } /** * Updates the database query with parameters used for downloads report. * * @param array $query_args Query arguments supplied by the user. */ protected function add_sql_query_params( $query_args ) { global $wpdb; $lookup_table = self::get_db_table_name(); $permission_table = $wpdb->prefix . 'woocommerce_downloadable_product_permissions'; $operator = $this->get_match_operator( $query_args ); $where_filters = array(); $join = "JOIN {$permission_table} as product_permissions ON {$lookup_table}.permission_id = product_permissions.permission_id"; $where_time = $this->add_time_period_sql_params( $query_args, $lookup_table ); if ( $where_time ) { if ( isset( $this->subquery ) ) { $this->subquery->add_sql_clause( 'where_time', $where_time ); } else { $this->interval_query->add_sql_clause( 'where_time', $where_time ); } } $this->get_limit_sql_params( $query_args ); $where_filters[] = $this->get_object_where_filter( $lookup_table, 'permission_id', $permission_table, 'product_id', 'IN', $this->get_included_products( $query_args ) ); $where_filters[] = $this->get_object_where_filter( $lookup_table, 'permission_id', $permission_table, 'product_id', 'NOT IN', $this->get_excluded_products( $query_args ) ); $where_filters[] = $this->get_object_where_filter( $lookup_table, 'permission_id', $permission_table, 'order_id', 'IN', $this->get_included_orders( $query_args ) ); $where_filters[] = $this->get_object_where_filter( $lookup_table, 'permission_id', $permission_table, 'order_id', 'NOT IN', $this->get_excluded_orders( $query_args ) ); $customer_lookup_table = $wpdb->prefix . 'wc_customer_lookup'; $customer_lookup = "SELECT {$customer_lookup_table}.user_id FROM {$customer_lookup_table} WHERE {$customer_lookup_table}.customer_id IN (%s)"; $included_customers = $this->get_included_customers( $query_args ); $excluded_customers = $this->get_excluded_customers( $query_args ); if ( $included_customers ) { $where_filters[] = $this->get_object_where_filter( $lookup_table, 'permission_id', $permission_table, 'user_id', 'IN', sprintf( $customer_lookup, $included_customers ) ); } if ( $excluded_customers ) { $where_filters[] = $this->get_object_where_filter( $lookup_table, 'permission_id', $permission_table, 'user_id', 'NOT IN', sprintf( $customer_lookup, $excluded_customers ) ); } $included_ip_addresses = $this->get_included_ip_addresses( $query_args ); $excluded_ip_addresses = $this->get_excluded_ip_addresses( $query_args ); if ( $included_ip_addresses ) { $where_filters[] = "{$lookup_table}.user_ip_address IN ('{$included_ip_addresses}')"; } if ( $excluded_ip_addresses ) { $where_filters[] = "{$lookup_table}.user_ip_address NOT IN ('{$excluded_ip_addresses}')"; } $where_filters = array_filter( $where_filters ); $where_subclause = implode( " $operator ", $where_filters ); if ( $where_subclause ) { if ( isset( $this->subquery ) ) { $this->subquery->add_sql_clause( 'where', "AND ( $where_subclause )" ); } else { $this->interval_query->add_sql_clause( 'where', "AND ( $where_subclause )" ); } } if ( isset( $this->subquery ) ) { $this->subquery->add_sql_clause( 'join', $join ); } else { $this->interval_query->add_sql_clause( 'join', $join ); } $this->add_order_by( $query_args ); } /** * Returns comma separated ids of included ip address, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_included_ip_addresses( $query_args ) { return $this->get_filtered_ip_addresses( $query_args, 'ip_address_includes' ); } /** * Returns comma separated ids of excluded ip address, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_excluded_ip_addresses( $query_args ) { return $this->get_filtered_ip_addresses( $query_args, 'ip_address_excludes' ); } /** * Returns filtered comma separated ids, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @param string $field Query field to filter. * @return string */ protected function get_filtered_ip_addresses( $query_args, $field ) { if ( isset( $query_args[ $field ] ) && is_array( $query_args[ $field ] ) && count( $query_args[ $field ] ) > 0 ) { $ip_addresses = array_map( 'esc_sql', $query_args[ $field ] ); /** * Filter the IDs before retrieving report data. * * Allows filtering of the objects included or excluded from reports. * * @param array $ids List of object Ids. * @param array $query_args The original arguments for the request. * @param string $field The object type. * @param string $context The data store context. */ $ip_addresses = apply_filters( 'woocommerce_analytics_' . $field, $ip_addresses, $query_args, $field, $this->context ); return implode( "','", $ip_addresses ); } return ''; } /** * Returns comma separated ids of included customers, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_included_customers( $query_args ) { return self::get_filtered_ids( $query_args, 'customer_includes' ); } /** * Returns comma separated ids of excluded customers, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return string */ protected function get_excluded_customers( $query_args ) { return self::get_filtered_ids( $query_args, 'customer_excludes' ); } /** * Gets WHERE time clause of SQL request with date-related constraints. * * @override ReportsDataStore::add_time_period_sql_params() * * @param array $query_args Parameters supplied by the user. * @param string $table_name Name of the db table relevant for the date constraint. * @return string */ protected function add_time_period_sql_params( $query_args, $table_name ) { $where_time = ''; if ( $query_args['before'] ) { $datetime_str = $query_args['before']->format( TimeInterval::$sql_datetime_format ); $where_time .= " AND {$table_name}.timestamp <= '$datetime_str'"; } if ( $query_args['after'] ) { $datetime_str = $query_args['after']->format( TimeInterval::$sql_datetime_format ); $where_time .= " AND {$table_name}.timestamp >= '$datetime_str'"; } return $where_time; } /** * Fills ORDER BY clause of SQL request based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. */ protected function add_order_by( $query_args ) { global $wpdb; $this->clear_sql_clause( 'order_by' ); $order_by = ''; if ( isset( $query_args['orderby'] ) ) { $order_by = $this->normalize_order_by( esc_sql( $query_args['orderby'] ) ); $this->add_sql_clause( 'order_by', $order_by ); } if ( false !== strpos( $order_by, '_products' ) ) { $this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->posts} AS _products ON product_permissions.product_id = _products.ID" ); } $this->add_orderby_order_clause( $query_args, $this ); } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override ReportsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['orderby'] = 'timestamp'; return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override ReportsDataStore::get_noncached_data() * * @see get_data * @param array $query_args Query parameters. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_data( $query_args ) { global $wpdb; $this->initialize_queries(); $data = (object) array( 'data' => array(), 'total' => 0, 'pages' => 0, 'page_no' => 0, ); $selections = $this->selected_columns( $query_args ); $this->add_sql_query_params( $query_args ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $db_records_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ( {$this->subquery->get_query_statement()} ) AS tt" ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $params = $this->get_limit_params( $query_args ); $total_pages = (int) ceil( $db_records_count / $params['per_page'] ); if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return $data; } $this->subquery->clear_sql_clause( 'select' ); $this->subquery->add_sql_clause( 'select', $selections ); $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $download_data = $wpdb->get_results( $this->subquery->get_query_statement(), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ARRAY_A ); if ( null === $download_data ) { return $data; } $download_data = array_map( array( $this, 'cast_numbers' ), $download_data ); $data = (object) array( 'data' => $download_data, 'total' => $db_records_count, 'pages' => $total_pages, 'page_no' => (int) $query_args['page'], ); return $data; } /** * Maps ordering specified by the user to columns in the database/fields in the data. * * @override ReportsDataStore::normalize_order_by() * * @param string $order_by Sorting criterion. * @return string */ protected function normalize_order_by( $order_by ) { global $wpdb; if ( 'date' === $order_by ) { return $wpdb->prefix . 'wc_download_log.timestamp'; } if ( 'product' === $order_by ) { return '_products.post_title'; } return $order_by; } /** * Initialize query objects. */ protected function initialize_queries() { $this->clear_all_clauses(); $table_name = self::get_db_table_name(); $this->subquery = new SqlQuery( $this->context . '_subquery' ); $this->subquery->add_sql_clause( 'from', $table_name ); $this->subquery->add_sql_clause( 'select', "{$table_name}.download_log_id" ); $this->subquery->add_sql_clause( 'group_by', "{$table_name}.download_log_id" ); } } API/Reports/Downloads/Controller.php 0000777 00000027370 15252240713 0013422 0 ustar 00 <?php /** * REST API Reports downloads controller * * Handles requests to the /reports/downloads endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Downloads; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use Automattic\WooCommerce\Admin\API\Reports\OrderAwareControllerTrait; /** * REST API Reports downloads controller class. * * @internal * @extends Automattic\WooCommerce\Admin\API\Reports\GenericController */ class Controller extends GenericController implements ExportableInterface { use OrderAwareControllerTrait; /** * Route base. * * @var string */ protected $rest_base = 'reports/downloads'; /** * Get data from `'downloads'` GenericQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new GenericQuery( $query_args, 'downloads' ); return $query->get_data(); } /** * Prepare a report data item for serialization. * * @param Array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { // Wrap the data in a response object. $response = parent::prepare_item_for_response( $report, $request ); $response->add_links( $this->prepare_links( $report ) ); $response->data['date'] = get_date_from_gmt( $report['date_gmt'], 'Y-m-d H:i:s' ); // Figure out file name. // Matches https://github.com/woocommerce/woocommerce/blob/4be0018c092e617c5d2b8c46b800eb71ece9ddef/includes/class-wc-download-handler.php#L197. $product_id = intval( $report['product_id'] ); $_product = wc_get_product( $product_id ); // Make sure the product hasn't been deleted. if ( $_product ) { $file_path = $_product->get_file_download_path( $report['download_id'] ); $filename = basename( $file_path ); $response->data['file_name'] = apply_filters( 'woocommerce_file_download_filename', $filename, $product_id ); $response->data['file_path'] = $file_path; } else { $response->data['file_name'] = ''; $response->data['file_path'] = ''; } $customer = new \WC_Customer( $report['user_id'] ); $response->data['username'] = $customer->get_username(); $response->data['order_number'] = $this->get_order_number( $report['order_id'] ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_downloads', $response, $report, $request ); } /** * Prepare links for the request. * * @param Array $object Object data. * @return array Links for the given post. */ protected function prepare_links( $object ) { $links = array( 'product' => array( 'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, 'products', $object['product_id'] ) ), 'embeddable' => true, ), ); return $links; } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $registered = array_keys( $this->get_collection_params() ); foreach ( $registered as $param_name ) { if ( isset( $request[ $param_name ] ) ) { $args[ $param_name ] = $request[ $param_name ]; } } return $args; } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_downloads', 'type' => 'object', 'properties' => array( 'id' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'ID.', 'woocommerce' ), ), 'product_id' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Product ID.', 'woocommerce' ), ), 'date' => array( 'description' => __( "The date of the download, in the site's timezone.", 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_gmt' => array( 'description' => __( 'The date of the download, as GMT.', 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'download_id' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Download ID.', 'woocommerce' ), ), 'file_name' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'File name.', 'woocommerce' ), ), 'file_path' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'File URL.', 'woocommerce' ), ), 'order_id' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Order ID.', 'woocommerce' ), ), 'order_number' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Order Number.', 'woocommerce' ), ), 'user_id' => array( 'type' => 'integer', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'User ID for the downloader.', 'woocommerce' ), ), 'username' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'User name of the downloader.', 'woocommerce' ), ), 'ip_address' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'IP address for the downloader.', 'woocommerce' ), ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'date', 'product', ) ); $params['match'] = array( 'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: products, orders, username, ip_address.', 'woocommerce' ), 'type' => 'string', 'default' => 'all', 'enum' => array( 'all', 'any', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['product_includes'] = array( 'description' => __( 'Limit result set to items that have the specified product(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', ); $params['product_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified product(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'wp_parse_id_list', ); $params['order_includes'] = array( 'description' => __( 'Limit result set to items that have the specified order ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['order_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified order ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['customer_includes'] = array( 'description' => __( 'Limit response to objects that have the specified user ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['customer_excludes'] = array( 'description' => __( 'Limit response to objects that don\'t have the specified user ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['ip_address_includes'] = array( 'description' => __( 'Limit response to objects that have a specified ip address.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', ), ); $params['ip_address_excludes'] = array( 'description' => __( 'Limit response to objects that don\'t have a specified ip address.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', ), ); return $params; } /** * Get the column names for export. * * @return array Key value pair of Column ID => Label. */ public function get_export_columns() { $export_columns = array( 'date' => __( 'Date', 'woocommerce' ), 'product' => __( 'Product title', 'woocommerce' ), 'file_name' => __( 'File name', 'woocommerce' ), 'order_number' => __( 'Order #', 'woocommerce' ), 'user_id' => __( 'User Name', 'woocommerce' ), 'ip_address' => __( 'IP', 'woocommerce' ), ); /** * Filter to add or remove column names from the downloads report for * export. * * @since 1.6.0 */ return apply_filters( 'woocommerce_filter_downloads_export_columns', $export_columns ); } /** * Get the column values for export. * * @param array $item Single report item/row. * @return array Key value pair of Column ID => Row Value. */ public function prepare_item_for_export( $item ) { $export_item = array( 'date' => $item['date'], 'product' => $item['_embedded']['product'][0]['name'], 'file_name' => $item['file_name'], 'order_number' => $item['order_number'], 'user_id' => $item['username'], 'ip_address' => $item['ip_address'], ); /** * Filter to prepare extra columns in the export item for the downloads * report. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_downloads_prepare_export_item', $export_item, $item ); } } API/Reports/Downloads/Files/Controller.php 0000777 00000001122 15252240713 0014447 0 ustar 00 <?php /** * REST API Reports downloads files controller * * Handles requests to the /reports/downloads/files endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Downloads\Files; defined( 'ABSPATH' ) || exit; /** * REST API Reports downloads files controller class. * * @internal * @extends WC_REST_Reports_Controller */ class Controller extends \WC_REST_Reports_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Route base. * * @var string */ protected $rest_base = 'reports/downloads/files'; } API/Reports/Downloads/Query.php 0000777 00000003534 15252240713 0012400 0 ustar 00 <?php /** * Class for parameter-based downloads report querying. * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'page' => 2, * 'products' => array(1,2,3) * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Downloads\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Downloads; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Downloads\Query * * @deprecated 9.3.0 Downloads\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for downloads report. * * @deprecated 9.3.0 Downloads\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array(); } /** * Get downloads data based on the current query vars. * * @deprecated 9.3.0 Downloads\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_downloads_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-downloads' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_downloads_select_query', $results, $args ); } } API/Reports/Downloads/Stats/Controller.php 0000777 00000023417 15252240713 0014516 0 ustar 00 <?php /** * REST API Reports downloads stats controller * * Handles requests to the /reports/downloads/stats endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController; use WP_REST_Request; use WP_REST_Response; /** * REST API Reports downloads stats controller class. * * @internal * @extends GenericStatsController */ class Controller extends GenericStatsController { /** * Route base. * * @var string */ protected $rest_base = 'reports/downloads/stats'; /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['before'] = $request['before']; $args['after'] = $request['after']; $args['interval'] = $request['interval']; $args['page'] = $request['page']; $args['per_page'] = $request['per_page']; $args['orderby'] = $request['orderby']; $args['order'] = $request['order']; $args['match'] = $request['match']; $args['product_includes'] = (array) $request['product_includes']; $args['product_excludes'] = (array) $request['product_excludes']; $args['customer_includes'] = (array) $request['customer_includes']; $args['customer_excludes'] = (array) $request['customer_excludes']; $args['order_includes'] = (array) $request['order_includes']; $args['order_excludes'] = (array) $request['order_excludes']; $args['ip_address_includes'] = (array) $request['ip_address_includes']; $args['ip_address_excludes'] = (array) $request['ip_address_excludes']; $args['fields'] = $request['fields']; $args['force_cache_refresh'] = $request['force_cache_refresh']; return $args; } /** * Get data from `'downloads-stats'` GenericQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new GenericQuery( $query_args, 'downloads-stats' ); return $query->get_data(); } /** * Prepare a report data item for serialization. * * @param array $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $response = parent::prepare_item_for_response( $report, $request ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_downloads_stats', $response, $report, $request ); } /** * Get the Report's item properties schema. * Will be used by `get_item_schema` as `totals` and `subtotals`. * * @return array */ protected function get_item_properties_schema() { return array( 'download_count' => array( 'title' => __( 'Downloads', 'woocommerce' ), 'description' => __( 'Number of downloads.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, ), ); } /** * Get the Report's schema, conforming to JSON Schema. * It does not have the segments as in GenericStatsController. * * @return array */ public function get_item_schema() { $totals = $this->get_item_properties_schema(); $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_orders_stats', 'type' => 'object', 'properties' => array( 'totals' => array( 'description' => __( 'Totals data.', 'woocommerce' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'properties' => $totals, ), 'intervals' => array( 'description' => __( 'Reports data grouped by intervals.', 'woocommerce' ), 'type' => 'array', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'items' => array( 'type' => 'object', 'properties' => array( 'interval' => array( 'description' => __( 'Type of interval.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'enum' => array( 'day', 'week', 'month', 'year' ), ), 'date_start' => array( 'description' => __( "The date the report start, in the site's timezone.", 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_start_gmt' => array( 'description' => __( 'The date the report start, as GMT.', 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_end' => array( 'description' => __( "The date the report end, in the site's timezone.", 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'date_end_gmt' => array( 'description' => __( 'The date the report end, as GMT.', 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'subtotals' => array( 'description' => __( 'Interval subtotals.', 'woocommerce' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'properties' => $totals, ), ), ), ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'date', 'download_count', ) ); $params['match'] = array( 'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'woocommerce' ), 'type' => 'string', 'default' => 'all', 'enum' => array( 'all', 'any', ), 'validate_callback' => 'rest_validate_request_arg', ); $params['product_includes'] = array( 'description' => __( 'Limit result set to items that have the specified product(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', ); $params['product_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified product(s) assigned.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', ); $params['order_includes'] = array( 'description' => __( 'Limit result set to items that have the specified order ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['order_excludes'] = array( 'description' => __( 'Limit result set to items that don\'t have the specified order ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['customer_includes'] = array( 'description' => __( 'Limit response to objects that have the specified customer ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['customer_excludes'] = array( 'description' => __( 'Limit response to objects that don\'t have the specified customer ids.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['ip_address_includes'] = array( 'description' => __( 'Limit response to objects that have a specified ip address.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', ), ); $params['ip_address_excludes'] = array( 'description' => __( 'Limit response to objects that don\'t have a specified ip address.', 'woocommerce' ), 'type' => 'array', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'string', ), ); return $params; } } API/Reports/Downloads/Stats/DataStore.php 0000777 00000014226 15252240713 0014257 0 ustar 00 <?php /** * API\Reports\Downloads\Stats\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Downloads\DataStore as DownloadsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\StatsDataStoreTrait; /** * API\Reports\Downloads\Stats\DataStore. */ class DataStore extends DownloadsDataStore implements DataStoreInterface { use StatsDataStoreTrait; /** * Mapping columns to data type to return correct response types. * * @override DownloadsDataStore::$column_types * * @var array */ protected $column_types = array( 'download_count' => 'intval', ); /** * Cache identifier. * * @override DownloadsDataStore::$cache_key * * @var string */ protected $cache_key = 'downloads_stats'; /** * Data store context used to pass to filters. * * @override DownloadsDataStore::$context * * @var string */ protected $context = 'downloads_stats'; /** * Assign report columns once full table name has been assigned. * * @override DownloadsDataStore::assign_report_columns() */ protected function assign_report_columns() { $this->report_columns = array( 'download_count' => 'COUNT(DISTINCT download_log_id) as download_count', ); } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override DownloadsDataStore::default_query_args() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['interval'] = 'week'; return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override DownloadsDataStore::get_noncached_data() * * @see get_data * @see get_noncached_stats_data * @param array $query_args Query parameters. * @param array $params Query limit parameters. * @param stdClass $data Reference to the data object to fill. * @param int $expected_interval_count Number of expected intervals. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_stats_data( $query_args, $params, &$data, $expected_interval_count ) { global $wpdb; $table_name = self::get_db_table_name(); $this->initialize_queries(); $selections = $this->selected_columns( $query_args ); $this->add_sql_query_params( $query_args ); $where_time = $this->add_time_period_sql_params( $query_args, $table_name ); $this->add_intervals_sql_params( $query_args, $table_name ); $this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' ); $this->interval_query->str_replace_clause( 'select', 'date_created', 'timestamp' ); $this->interval_query->str_replace_clause( 'where_time', 'date_created', 'timestamp' ); $db_intervals = $wpdb->get_col( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->interval_query->get_query_statement() ); $db_records_count = count( $db_intervals ); $this->update_intervals_sql_params( $query_args, $db_records_count, $expected_interval_count, $table_name ); $this->interval_query->str_replace_clause( 'where_time', 'date_created', 'timestamp' ); $this->total_query->add_sql_clause( 'select', $selections ); $this->total_query->add_sql_clause( 'where', $this->interval_query->get_sql_clause( 'where' ) ); if ( $where_time ) { $this->total_query->add_sql_clause( 'where_time', $where_time ); } $totals = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->total_query->get_query_statement(), ARRAY_A ); if ( null === $totals ) { return new \WP_Error( 'woocommerce_analytics_downloads_stats_result_failed', __( 'Sorry, fetching downloads data failed.', 'woocommerce' ) ); } $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $this->interval_query->add_sql_clause( 'select', ', MAX(timestamp) AS datetime_anchor' ); if ( '' !== $selections ) { $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } $intervals = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->interval_query->get_query_statement(), ARRAY_A ); if ( null === $intervals ) { return new \WP_Error( 'woocommerce_analytics_downloads_stats_result_failed', __( 'Sorry, fetching downloads data failed.', 'woocommerce' ) ); } $totals = (object) $this->cast_numbers( $totals[0] ); $data->totals = $totals; $data->intervals = $intervals; if ( $this->intervals_missing( $expected_interval_count, $db_records_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_records_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); } else { $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); } return $data; } /** * Normalizes order_by clause to match to SQL query. * * @override DownloadsDataStore::normalize_order_by() * * @param string $order_by Order by option requeste by user. * @return string */ protected function normalize_order_by( $order_by ) { if ( 'date' === $order_by ) { return 'time_interval'; } return $order_by; } } API/Reports/Downloads/Stats/Query.php 0000777 00000003075 15252240713 0013476 0 ustar 00 <?php /** * Class for parameter-based downloads Reports querying */ namespace Automattic\WooCommerce\Admin\API\Reports\Downloads\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Downloads\Stats\Query * * @deprecated 9.3.0 Downloads\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Orders report. * * @deprecated 9.3.0 Downloads\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array(); } /** * Get revenue data based on the current query vars. * * @deprecated 9.3.0 Downloads\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_downloads_stats_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-downloads-stats' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_downloads_stats_select_query', $results, $args ); } } API/Reports/Stock/Controller.php 0000777 00000041016 15252240713 0012544 0 ustar 00 <?php /** * REST API Reports stock controller * * Handles requests to the /reports/stock endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Stock; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Enums\ProductType; use WP_REST_Request; use WP_REST_Response; use Automattic\WooCommerce\Enums\ProductStockStatus; /** * REST API Reports stock controller class. * * @internal * @extends GenericController */ class Controller extends GenericController implements ExportableInterface { /** * Route base. * * @var string */ protected $rest_base = 'reports/stock'; /** * Registered stock status options. * * @var array */ protected $status_options; /** * Constructor. */ public function __construct() { $this->status_options = wc_get_product_stock_status_options(); } /** * Maps query arguments from the REST request. * * @param WP_REST_Request $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['offset'] = $request['offset']; $args['order'] = $request['order']; $args['orderby'] = $request['orderby']; $args['paged'] = $request['page']; $args['post__in'] = $request['include']; $args['post__not_in'] = $request['exclude']; $args['posts_per_page'] = $request['per_page']; $args['post_parent__in'] = $request['parent']; $args['post_parent__not_in'] = $request['parent_exclude']; if ( 'date' === $args['orderby'] ) { $args['orderby'] = 'date ID'; } elseif ( 'include' === $args['orderby'] ) { $args['orderby'] = 'post__in'; } elseif ( 'id' === $args['orderby'] ) { $args['orderby'] = 'ID'; // ID must be capitalized. } $args['post_type'] = array( 'product', 'product_variation' ); if ( ProductStockStatus::LOW_STOCK === $request['type'] ) { $args['low_in_stock'] = true; } elseif ( in_array( $request['type'], array_keys( $this->status_options ), true ) ) { $args['stock_status'] = $request['type']; } $args['ignore_sticky_posts'] = true; return $args; } /** * Query products. * * @param array $query_args Query args. * @return array */ protected function get_products( $query_args ) { $query = new \WP_Query(); $result = $query->query( $query_args ); $total_posts = $query->found_posts; if ( $total_posts < 1 && isset( $query_args['paged'] ) && absint( $query_args['paged'] ) > 1 ) { // Out-of-bounds, run the query again without LIMIT for total count. unset( $query_args['paged'] ); $count_query = new \WP_Query(); $count_query->query( $query_args ); $total_posts = $count_query->found_posts; } return array( 'objects' => array_map( 'wc_get_product', $result ), 'total' => (int) $total_posts, 'pages' => (int) ceil( $total_posts / (int) $query->query_vars['posts_per_page'] ), ); } /** * Get all reports. * * @param WP_REST_Request $request Request data. * @return array|WP_Error */ public function get_items( $request ) { add_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10, 2 ); add_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10, 2 ); add_filter( 'posts_groupby', array( __CLASS__, 'add_wp_query_group_by' ), 10, 2 ); add_filter( 'posts_clauses', array( __CLASS__, 'add_wp_query_orderby' ), 10, 2 ); $query_args = $this->prepare_reports_query( $request ); $query_results = $this->get_products( $query_args ); remove_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10 ); remove_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10 ); remove_filter( 'posts_groupby', array( __CLASS__, 'add_wp_query_group_by' ), 10 ); remove_filter( 'posts_clauses', array( __CLASS__, 'add_wp_query_orderby' ), 10 ); $objects = array(); foreach ( $query_results['objects'] as $object ) { $data = $this->prepare_item_for_response( $object, $request ); $objects[] = $this->prepare_response_for_collection( $data ); } return $this->add_pagination_headers( $request, $objects, (int) $query_results['total'], (int) $query_args['paged'], (int) $query_results['pages'] ); } /** * Add in conditional search filters for products. * * @internal * @param string $where Where clause used to search posts. * @param object $wp_query WP_Query object. * @return string */ public static function add_wp_query_filter( $where, $wp_query ) { global $wpdb; $stock_status = $wp_query->get( 'stock_status' ); if ( $stock_status ) { $where .= $wpdb->prepare( ' AND wc_product_meta_lookup.stock_status = %s ', $stock_status ); } if ( $wp_query->get( 'low_in_stock' ) ) { // We want products with stock < low stock amount, but greater than no stock amount. $no_stock_amount = absint( max( get_option( 'woocommerce_notify_no_stock_amount' ), 0 ) ); $low_stock_amount = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) ); $where .= " AND wc_product_meta_lookup.stock_quantity IS NOT NULL AND wc_product_meta_lookup.stock_status = 'instock' AND ( ( low_stock_amount_meta.meta_value > '' AND wc_product_meta_lookup.stock_quantity <= CAST(low_stock_amount_meta.meta_value AS SIGNED) AND wc_product_meta_lookup.stock_quantity > {$no_stock_amount} ) OR ( ( low_stock_amount_meta.meta_value IS NULL OR low_stock_amount_meta.meta_value <= '' ) AND wc_product_meta_lookup.stock_quantity <= {$low_stock_amount} AND wc_product_meta_lookup.stock_quantity > {$no_stock_amount} ) )"; } return $where; } /** * Join posts meta tables when product search or low stock query is present. * * @internal * @param string $join Join clause used to search posts. * @param object $wp_query WP_Query object. * @return string */ public static function add_wp_query_join( $join, $wp_query ) { global $wpdb; $stock_status = $wp_query->get( 'stock_status' ); if ( $stock_status ) { $join = self::append_product_sorting_table_join( $join ); } if ( $wp_query->get( 'low_in_stock' ) ) { $join = self::append_product_sorting_table_join( $join ); $join .= " LEFT JOIN {$wpdb->postmeta} AS low_stock_amount_meta ON {$wpdb->posts}.ID = low_stock_amount_meta.post_id AND low_stock_amount_meta.meta_key = '_low_stock_amount' "; } return $join; } /** * Join wc_product_meta_lookup to posts if not already joined. * * @internal * @param string $sql SQL join. * @return string */ protected static function append_product_sorting_table_join( $sql ) { 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; } /** * Group by post ID to prevent duplicates. * * @internal * @param string $groupby Group by clause used to organize posts. * @param object $wp_query WP_Query object. * @return string */ public static function add_wp_query_group_by( $groupby, $wp_query ) { global $wpdb; if ( empty( $groupby ) ) { $groupby = $wpdb->posts . '.ID'; } return $groupby; } /** * Custom orderby clauses using the lookup tables. * * @internal * @param array $args Query args. * @param object $wp_query WP_Query object. * @return array */ public static function add_wp_query_orderby( $args, $wp_query ) { global $wpdb; $orderby = $wp_query->get( 'orderby' ); $order = esc_sql( $wp_query->get( 'order' ) ? $wp_query->get( 'order' ) : 'desc' ); switch ( $orderby ) { case 'stock_quantity': $args['join'] = self::append_product_sorting_table_join( $args['join'] ); $args['orderby'] = " wc_product_meta_lookup.stock_quantity {$order}, wc_product_meta_lookup.product_id {$order} "; break; case 'stock_status': $args['join'] = self::append_product_sorting_table_join( $args['join'] ); $args['orderby'] = " wc_product_meta_lookup.stock_status {$order}, wc_product_meta_lookup.stock_quantity {$order} "; break; case 'sku': $args['join'] = self::append_product_sorting_table_join( $args['join'] ); $args['orderby'] = " wc_product_meta_lookup.sku {$order}, wc_product_meta_lookup.product_id {$order} "; break; } return $args; } /** * Prepare a report data item for serialization. * * @param WC_Product $product Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $product, $request ) { $data = array( 'id' => $product->get_id(), 'parent_id' => $product->get_parent_id(), 'name' => wp_strip_all_tags( $product->get_name() ), 'sku' => $product->get_sku(), 'stock_status' => $product->get_stock_status(), 'stock_quantity' => (float) $product->get_stock_quantity(), 'manage_stock' => $product->get_manage_stock(), 'low_stock_amount' => $product->get_low_stock_amount(), ); if ( '' === $data['low_stock_amount'] ) { $data['low_stock_amount'] = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) ); } $response = parent::prepare_item_for_response( $data, $request ); $response->add_links( $this->prepare_links( $product ) ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param WC_Product $product The original product object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_stock', $response, $product, $request ); } /** * Prepare links for the request. * * @param WC_Product $product Object data. * @return array */ protected function prepare_links( $product ) { if ( $product->is_type( ProductType::VARIATION ) ) { $links = array( 'product' => array( 'href' => rest_url( sprintf( '/%s/products/%d/variations/%d', $this->namespace, $product->get_parent_id(), $product->get_id() ) ), ), 'parent' => array( 'href' => rest_url( sprintf( '/%s/products/%d', $this->namespace, $product->get_parent_id() ) ), ), ); } elseif ( $product->get_parent_id() ) { $links = array( 'product' => array( 'href' => rest_url( sprintf( '/%s/products/%d', $this->namespace, $product->get_id() ) ), ), 'parent' => array( 'href' => rest_url( sprintf( '/%s/products/%d', $this->namespace, $product->get_parent_id() ) ), ), ); } else { $links = array( 'product' => array( 'href' => rest_url( sprintf( '/%s/products/%d', $this->namespace, $product->get_id() ) ), ), ); } return $links; } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_stock', 'type' => 'object', 'properties' => array( 'id' => array( 'description' => __( 'Unique identifier for the resource.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'parent_id' => array( 'description' => __( 'Product parent ID.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'name' => array( 'description' => __( 'Product name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'sku' => array( 'description' => __( 'Unique identifier.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'stock_status' => array( 'description' => __( 'Stock status.', 'woocommerce' ), 'type' => 'string', 'enum' => array_keys( wc_get_product_stock_status_options() ), 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'stock_quantity' => array( 'description' => __( 'Stock quantity.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'manage_stock' => array( 'description' => __( 'Manage stock.', 'woocommerce' ), 'type' => 'boolean', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); unset( $params['after'], $params['before'], $params['force_cache_refresh'] ); $params['exclude'] = array( 'description' => __( 'Ensure result set excludes specific IDs.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', ); $params['include'] = array( 'description' => __( 'Limit result set to specific ids.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'default' => array(), 'sanitize_callback' => 'wp_parse_id_list', ); $params['offset'] = array( 'description' => __( 'Offset the result set by a specific number of items.', 'woocommerce' ), 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ); $params['order']['default'] = 'asc'; $params['orderby']['default'] = 'stock_status'; $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'stock_status', 'stock_quantity', 'date', 'id', 'include', 'title', 'sku', ) ); $params['parent'] = array( 'description' => __( 'Limit result set to those of particular parent IDs.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'sanitize_callback' => 'wp_parse_id_list', 'default' => array(), ); $params['parent_exclude'] = array( 'description' => __( 'Limit result set to all items except those of a particular parent ID.', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer', ), 'sanitize_callback' => 'wp_parse_id_list', 'default' => array(), ); $params['type'] = array( 'description' => __( 'Limit result set to items assigned a stock report type.', 'woocommerce' ), 'type' => 'string', 'default' => 'all', 'enum' => array_merge( array( 'all', 'lowstock' ), array_keys( wc_get_product_stock_status_options() ) ), ); return $params; } /** * Get the column names for export. * * @return array Key value pair of Column ID => Label. */ public function get_export_columns() { $export_columns = array( 'title' => __( 'Product / Variation', 'woocommerce' ), 'sku' => __( 'SKU', 'woocommerce' ), 'stock_status' => __( 'Status', 'woocommerce' ), 'stock_quantity' => __( 'Stock', 'woocommerce' ), ); /** * Filter to add or remove column names from the stock report for * export. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_stock_export_columns', $export_columns ); } /** * Get the column values for export. * * @param array $item Single report item/row. * @return array Key value pair of Column ID => Row Value. */ public function prepare_item_for_export( $item ) { $status = $item['stock_status']; if ( array_key_exists( $item['stock_status'], $this->status_options ) ) { $status = $this->status_options[ $item['stock_status'] ]; } $export_item = array( 'title' => $item['name'], 'sku' => $item['sku'], 'stock_status' => $status, 'stock_quantity' => $item['stock_quantity'], ); /** * Filter to prepare extra columns in the export item for the stock * report. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_stock_prepare_export_item', $export_item, $item ); } } API/Reports/Stock/Stats/Controller.php 0000777 00000007203 15252240713 0013642 0 ustar 00 <?php /** * REST API Reports stock stats controller * * Handles requests to the /reports/stock/stats endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Stock\Stats; defined( 'ABSPATH' ) || exit; /** * REST API Reports stock stats controller class. * * @internal * @extends WC_REST_Reports_Controller */ class Controller extends \WC_REST_Reports_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Route base. * * @var string */ protected $rest_base = 'reports/stock/stats'; /** * Get Stock Status Totals. * * @param WP_REST_Request $request Request data. * @return array|WP_Error */ public function get_items( $request ) { $stock_query = new Query(); $report_data = $stock_query->get_data(); $out_data = array( 'totals' => $report_data, ); return rest_ensure_response( $out_data ); } /** * Prepare a report data item for serialization. * * @param WC_Product $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $data = $report; $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); // Wrap the data in a response object. $response = rest_ensure_response( $data ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @since 6.5.0 * * @param WP_REST_Response $response The response object. * @param WC_Product $report The original object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_stock_stats', $response, $report, $request ); } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $totals = array( 'products' => array( 'description' => __( 'Number of products.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'lowstock' => array( 'description' => __( 'Number of low stock products.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ); $status_options = wc_get_product_stock_status_options(); foreach ( $status_options as $status => $label ) { $totals[ $status ] = array( /* translators: Stock status. Example: "Number of low stock products */ 'description' => sprintf( __( 'Number of %s products.', 'woocommerce' ), $label ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ); } $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_customers_stats', 'type' => 'object', 'properties' => array( 'totals' => array( 'description' => __( 'Totals data.', 'woocommerce' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'properties' => $totals, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = array(); $params['context'] = $this->get_context_param( array( 'default' => 'view' ) ); return $params; } } API/Reports/Stock/Stats/DataStore.php 0000777 00000010756 15252240713 0013414 0 ustar 00 <?php /** * API\Reports\Stock\Stats\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Stock\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Enums\ProductStockStatus; /** * API\Reports\Stock\Stats\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Get stock counts for the whole store. * * @override ReportsDataStore::get_data() * * @param array $query Not used for the stock stats data store, but needed for the interface. * @return array Array of counts. */ public function get_data( $query ) { $report_data = array(); $cache_expire = DAY_IN_SECONDS * 30; $low_stock_transient_name = 'wc_admin_stock_count_lowstock'; $low_stock_count = get_transient( $low_stock_transient_name ); if ( false === $low_stock_count ) { $low_stock_count = $this->get_low_stock_count(); set_transient( $low_stock_transient_name, $low_stock_count, $cache_expire ); } else { $low_stock_count = intval( $low_stock_count ); } $report_data[ ProductStockStatus::LOW_STOCK ] = $low_stock_count; $status_options = wc_get_product_stock_status_options(); foreach ( $status_options as $status => $label ) { $transient_name = 'wc_admin_stock_count_' . $status; $count = get_transient( $transient_name ); if ( false === $count ) { $count = $this->get_count( $status ); set_transient( $transient_name, $count, $cache_expire ); } else { $count = intval( $count ); } $report_data[ $status ] = $count; } $product_count_transient_name = 'wc_admin_product_count'; $product_count = get_transient( $product_count_transient_name ); if ( false === $product_count ) { $product_count = $this->get_product_count(); set_transient( $product_count_transient_name, $product_count, $cache_expire ); } else { $product_count = intval( $product_count ); } $report_data['products'] = $product_count; return $report_data; } /** * Get low stock count (products with stock < low stock amount, but greater than no stock amount). * * @return int Low stock count. */ private function get_low_stock_count() { global $wpdb; $no_stock_amount = absint( max( get_option( 'woocommerce_notify_no_stock_amount' ), 0 ) ); $low_stock_amount = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) ); return (int) $wpdb->get_var( $wpdb->prepare( " SELECT count( DISTINCT posts.ID ) FROM {$wpdb->posts} posts LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON posts.ID = wc_product_meta_lookup.product_id LEFT JOIN {$wpdb->postmeta} low_stock_amount_meta ON posts.ID = low_stock_amount_meta.post_id AND low_stock_amount_meta.meta_key = '_low_stock_amount' WHERE posts.post_type IN ( 'product', 'product_variation' ) AND wc_product_meta_lookup.stock_quantity IS NOT NULL AND wc_product_meta_lookup.stock_status = 'instock' AND ( ( low_stock_amount_meta.meta_value > '' AND wc_product_meta_lookup.stock_quantity <= CAST(low_stock_amount_meta.meta_value AS SIGNED) AND wc_product_meta_lookup.stock_quantity > %d ) OR ( ( low_stock_amount_meta.meta_value IS NULL OR low_stock_amount_meta.meta_value <= '' ) AND wc_product_meta_lookup.stock_quantity <= %d AND wc_product_meta_lookup.stock_quantity > %d ) ) ", $no_stock_amount, $low_stock_amount, $no_stock_amount ) ); } /** * Get count for the passed in stock status. * * @param string $status Status slug. * @return int Count. */ private function get_count( $status ) { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( " SELECT count( DISTINCT posts.ID ) FROM {$wpdb->posts} posts LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON posts.ID = wc_product_meta_lookup.product_id WHERE posts.post_type IN ( 'product', 'product_variation' ) AND wc_product_meta_lookup.stock_status = %s ", $status ) ); } /** * Get product count for the store. * * @return int Product count. */ private function get_product_count() { $query_args = array(); $query_args['post_type'] = array( 'product', 'product_variation' ); $query = new \WP_Query(); $query->query( $query_args ); return intval( $query->found_posts ); } } API/Reports/Stock/Stats/Query.php 0000777 00000001327 15252240713 0012625 0 ustar 00 <?php /** * Class for stock stats report querying * * $report = new \Automattic\WooCommerce\Admin\API\Reports\Stock\Stats\Query(); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Stock\Stats; defined( 'ABSPATH' ) || exit; /** * API\Reports\Stock\Stats\Query * This query takes no arguments, so we do not inherit from GenericQuery. */ class Query extends \WC_Object_Query { /** * Get product data based on the current query vars. * * @return array */ public function get_data() { $data_store = \WC_Data_Store::load( 'report-stock-stats' ); $results = $data_store->get_data(); return apply_filters( 'woocommerce_analytics_stock_stats_query', $results ); } } API/Reports/Query.php 0000777 00000002646 15252240713 0010451 0 ustar 00 <?php /** * Class for parameter-based Reports querying */ namespace Automattic\WooCommerce\Admin\API\Reports; defined( 'ABSPATH' ) || exit; /** * Admin\API\Reports\Query * * @deprecated 9.3.0 Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ abstract class Query extends \WC_Object_Query { /** * Create a new query. * * @deprecated 9.3.0 Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @param array $args Criteria to query on in a format similar to WP_Query. */ public function __construct( $args = array() ) { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); parent::__construct( $args ); } /** * Get report data matching the current query vars. * * @deprecated 9.3.0 Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array|object of WC_Product objects */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); /* translators: %s: Method name */ return new \WP_Error( 'invalid-method', sprintf( __( "Method '%s' not implemented. Must be overridden in subclass.", 'woocommerce' ), __METHOD__ ), array( 'status' => 405 ) ); } } API/Reports/Coupons/DataStore.php 0000777 00000040313 15252240713 0012651 0 ustar 00 <?php /** * API\Reports\Coupons\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Coupons; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\DataStore as ReportsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache; /** * API\Reports\Coupons\DataStore. */ class DataStore extends ReportsDataStore implements DataStoreInterface { /** * Table used to get the data. * * @override ReportsDataStore::$table_name * * @var string */ protected static $table_name = 'wc_order_coupon_lookup'; /** * Cache identifier. * * @override ReportsDataStore::$cache_key * * @var string */ protected $cache_key = 'coupons'; /** * Mapping columns to data type to return correct response types. * * @override ReportsDataStore::$column_types * * @var array */ protected $column_types = array( 'coupon_id' => 'intval', 'amount' => 'floatval', 'orders_count' => 'intval', ); /** * Data store context used to pass to filters. * * @override ReportsDataStore::$context * * @var string */ protected $context = 'coupons'; /** * Assign report columns once full table name has been assigned. * * @override ReportsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); $this->report_columns = array( 'coupon_id' => 'coupon_id', 'amount' => 'SUM(discount_amount) as amount', 'orders_count' => "COUNT(DISTINCT {$table_name}.order_id) as orders_count", ); } // This method was already available as non-final, marking it as final now would make it backwards-incompatible. // phpcs:disable WooCommerce.Functions.InternalInjectionMethod.MissingFinal /** * Set up all the hooks for maintaining and populating table data. * * @internal */ public static function init() { add_action( 'woocommerce_analytics_delete_order_stats', array( __CLASS__, 'sync_on_order_delete' ), 5 ); } // phpcs:enable WooCommerce.Functions.InternalInjectionMethod.MissingFinal /** * Returns an array of ids of included coupons, based on query arguments from the user. * * @param array $query_args Parameters supplied by the user. * @return array */ protected function get_included_coupons_array( $query_args ) { if ( isset( $query_args['coupons'] ) && is_array( $query_args['coupons'] ) && count( $query_args['coupons'] ) > 0 ) { return $query_args['coupons']; } return array(); } /** * Updates the database query with parameters used for Products report: categories and order status. * * @param array $query_args Query arguments supplied by the user. */ protected function add_sql_query_params( $query_args ) { global $wpdb; $order_coupon_lookup_table = self::get_db_table_name(); $this->add_time_period_sql_params( $query_args, $order_coupon_lookup_table ); $this->get_limit_sql_params( $query_args ); $included_coupons = $this->get_included_coupons( $query_args, 'coupons' ); if ( $included_coupons ) { $this->subquery->add_sql_clause( 'where', "AND {$order_coupon_lookup_table}.coupon_id IN ({$included_coupons})" ); $this->add_order_by_params( $query_args, 'outer', 'default_results.coupon_id' ); } else { $this->add_order_by_params( $query_args, 'inner', "{$order_coupon_lookup_table}.coupon_id" ); } $this->add_order_status_clause( $query_args, $order_coupon_lookup_table, $this->subquery ); } /** * Fills ORDER BY clause of SQL request based on user supplied parameters. * * @param array $query_args Parameters supplied by the user. * @param string $from_arg Target of the JOIN sql param. * @param string $id_cell ID cell identifier, like `table_name.id_column_name`. */ protected function add_order_by_params( $query_args, $from_arg, $id_cell ) { global $wpdb; // Sanitize input: guarantee that the id cell in the join is quoted with backticks. $id_cell_segments = explode( '.', str_replace( '`', '', $id_cell ) ); $id_cell_identifier = '`' . implode( '`.`', $id_cell_segments ) . '`'; $lookup_table = self::get_db_table_name(); $order_by_clause = $this->add_order_by_clause( $query_args, $this ); $join = "JOIN {$wpdb->posts} AS _coupons ON {$id_cell_identifier} = _coupons.ID"; $this->add_orderby_order_clause( $query_args, $this ); if ( 'inner' === $from_arg ) { $this->subquery->clear_sql_clause( 'join' ); if ( false !== strpos( $order_by_clause, '_coupons' ) ) { $this->subquery->add_sql_clause( 'join', $join ); } } else { $this->clear_sql_clause( 'join' ); if ( false !== strpos( $order_by_clause, '_coupons' ) ) { $this->add_sql_clause( 'join', $join ); } } } /** * Maps ordering specified by the user to columns in the database/fields in the data. * * @override ReportsDataStore::normalize_order_by() * * @param string $order_by Sorting criterion. * @return string */ protected function normalize_order_by( $order_by ) { if ( 'date' === $order_by ) { return 'time_interval'; } if ( 'code' === $order_by ) { return '_coupons.post_title'; } return $order_by; } /** * Enriches the coupon data with extra attributes. * * @param array $coupon_data Coupon data. * @param array $query_args Query parameters. */ protected function include_extended_info( &$coupon_data, $query_args ) { foreach ( $coupon_data as $idx => $coupon_datum ) { $extended_info = new \ArrayObject(); if ( $query_args['extended_info'] ) { $coupon_id = $coupon_datum['coupon_id']; $coupon = new \WC_Coupon( $coupon_id ); if ( 0 === $coupon->get_id() ) { // Deleted or otherwise invalid coupon. $extended_info = array( 'code' => __( '(Deleted)', 'woocommerce' ), 'date_created' => '', 'date_created_gmt' => '', 'date_expires' => '', 'date_expires_gmt' => '', 'discount_type' => __( 'N/A', 'woocommerce' ), ); } else { $gmt_timzone = new \DateTimeZone( 'UTC' ); $date_expires = $coupon->get_date_expires(); if ( is_a( $date_expires, 'DateTime' ) ) { $date_expires = $date_expires->format( TimeInterval::$iso_datetime_format ); $date_expires_gmt = new \DateTime( $date_expires ); $date_expires_gmt->setTimezone( $gmt_timzone ); $date_expires_gmt = $date_expires_gmt->format( TimeInterval::$iso_datetime_format ); } else { $date_expires = ''; $date_expires_gmt = ''; } $date_created = $coupon->get_date_created(); if ( is_a( $date_created, 'DateTime' ) ) { $date_created = $date_created->format( TimeInterval::$iso_datetime_format ); $date_created_gmt = new \DateTime( $date_created ); $date_created_gmt->setTimezone( $gmt_timzone ); $date_created_gmt = $date_created_gmt->format( TimeInterval::$iso_datetime_format ); } else { $date_created = ''; $date_created_gmt = ''; } $extended_info = array( 'code' => $coupon->get_code(), 'date_created' => $date_created, 'date_created_gmt' => $date_created_gmt, 'date_expires' => $date_expires, 'date_expires_gmt' => $date_expires_gmt, 'discount_type' => $coupon->get_discount_type(), ); } } $coupon_data[ $idx ]['extended_info'] = $extended_info; } } /** * Get coupon ID for an order. * * Tries to get the ID from order item meta, then falls back to a query of published coupons. * * @param \WC_Order_Item_Coupon $coupon_item The coupon order item object. * @return int Coupon ID on success, 0 on failure. */ public static function get_coupon_id( \WC_Order_Item_Coupon $coupon_item ) { // First attempt to get coupon ID from order item data. $coupon_info = $coupon_item->get_meta( 'coupon_info', true ); if ( $coupon_info ) { return json_decode( $coupon_info, true )[0]; } $coupon_data = $coupon_item->get_meta( 'coupon_data', true ); // Normal checkout orders should have this data. // See: https://github.com/woocommerce/woocommerce/blob/3dc7df7af9f7ca0c0aa34ede74493e856f276abe/includes/abstracts/abstract-wc-order.php#L1206. if ( isset( $coupon_data['id'] ) ) { return $coupon_data['id']; } // Try to get the coupon ID using the code. return wc_get_coupon_id_by_code( $coupon_item->get_code() ); } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override ReportsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['orderby'] = 'coupon_id'; $defaults['coupons'] = array(); $defaults['extended_info'] = false; return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override ReportsDataStore::get_noncached_data() * * @see get_data * @param array $query_args Query parameters. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_data( $query_args ) { global $wpdb; $table_name = self::get_db_table_name(); $this->initialize_queries(); $data = (object) array( 'data' => array(), 'total' => 0, 'pages' => 0, 'page_no' => 0, ); $selections = $this->selected_columns( $query_args ); $included_coupons = $this->get_included_coupons_array( $query_args ); $limit_params = $this->get_limit_params( $query_args ); $this->subquery->add_sql_clause( 'select', $selections ); $this->add_sql_query_params( $query_args ); if ( count( $included_coupons ) > 0 ) { $total_results = count( $included_coupons ); $total_pages = (int) ceil( $total_results / $limit_params['per_page'] ); $fields = $this->get_fields( $query_args ); $ids_table = $this->get_ids_table( $included_coupons, 'coupon_id' ); $this->add_sql_clause( 'select', $this->format_join_selections( $fields, array( 'coupon_id' ) ) ); $this->add_sql_clause( 'from', '(' ); $this->add_sql_clause( 'from', $this->subquery->get_query_statement() ); $this->add_sql_clause( 'from', ") AS {$table_name}" ); $this->add_sql_clause( 'right_join', "RIGHT JOIN ( {$ids_table} ) AS default_results ON default_results.coupon_id = {$table_name}.coupon_id" ); $coupons_query = $this->get_query_statement(); } else { if ( in_array( $query_args['orderby'], array( 'amount', 'orders_count' ), true ) ) { $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) . ', coupon_id' ); } else { $this->subquery->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); } $this->subquery->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $coupons_query = $this->subquery->get_query_statement(); $this->subquery->clear_sql_clause( array( 'select', 'order_by', 'limit' ) ); $this->subquery->add_sql_clause( 'select', 'coupon_id' ); $coupon_subquery = "SELECT COUNT(*) FROM ( {$this->subquery->get_query_statement()} ) AS tt"; $db_records_count = (int) $wpdb->get_var( $coupon_subquery // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ); $total_results = $db_records_count; $total_pages = (int) ceil( $db_records_count / $limit_params['per_page'] ); if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { return $data; } } $coupon_data = $wpdb->get_results( $coupons_query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ARRAY_A ); if ( null === $coupon_data ) { return $data; } $this->include_extended_info( $coupon_data, $query_args ); $coupon_data = array_map( array( $this, 'cast_numbers' ), $coupon_data ); $data = (object) array( 'data' => $coupon_data, 'total' => $total_results, 'pages' => $total_pages, 'page_no' => (int) $query_args['page'], ); return $data; } /** * Create or update an an entry in the wc_order_coupon_lookup table for an order. * * @since 3.5.0 * @param int $order_id Order ID. * @return int|bool Returns -1 if order won't be processed, or a boolean indicating processing success. */ public static function sync_order_coupons( $order_id ) { global $wpdb; $order = wc_get_order( $order_id ); if ( ! $order ) { return -1; } // Refunds don't affect coupon stats so return successfully if one is called here. if ( 'shop_order_refund' === $order->get_type() ) { return true; } $table_name = self::get_db_table_name(); $existing_items = $wpdb->get_col( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT coupon_id FROM {$table_name} WHERE order_id = %d", $order_id ) ); $existing_items = array_flip( $existing_items ); $coupon_items = $order->get_items( 'coupon' ); $coupon_items_count = count( $coupon_items ); $num_updated = 0; $num_deleted = 0; foreach ( $coupon_items as $coupon_item ) { $coupon_id = self::get_coupon_id( $coupon_item ); unset( $existing_items[ $coupon_id ] ); if ( ! $coupon_id ) { // Insert a unique, but obviously invalid ID for this deleted coupon. ++$num_deleted; $coupon_id = -1 * $num_deleted; } $result = $wpdb->replace( self::get_db_table_name(), array( 'order_id' => $order_id, 'coupon_id' => $coupon_id, 'discount_amount' => $coupon_item->get_discount(), 'date_created' => $order->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ), ), array( '%d', '%d', '%f', '%s', ) ); /** * Fires when coupon's reports are updated. * * @param int $coupon_id Coupon ID. * @param int $order_id Order ID. */ do_action( 'woocommerce_analytics_update_coupon', $coupon_id, $order_id ); // Sum the rows affected. Using REPLACE can affect 2 rows if the row already exists. $num_updated += 2 === intval( $result ) ? 1 : intval( $result ); } if ( ! empty( $existing_items ) ) { $existing_items = array_flip( $existing_items ); $format = array_fill( 0, count( $existing_items ), '%d' ); $format = implode( ',', $format ); array_unshift( $existing_items, $order_id ); $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "DELETE FROM {$table_name} WHERE order_id = %d AND coupon_id in ({$format})", $existing_items ) ); } return ( $coupon_items_count === $num_updated ); } /** * Clean coupons data when an order is deleted. * * @param int $order_id Order ID. */ public static function sync_on_order_delete( $order_id ) { global $wpdb; $wpdb->delete( self::get_db_table_name(), array( 'order_id' => $order_id ) ); /** * Fires when coupon's reports are removed from database. * * @param int $coupon_id Coupon ID. * @param int $order_id Order ID. */ do_action( 'woocommerce_analytics_delete_coupon', 0, $order_id ); ReportsCache::invalidate(); } /** * Gets coupons based on the provided arguments. * * @todo Upon core merge, including this in core's `class-wc-coupon-data-store-cpt.php` might make more sense. * @param array $args Array of args to filter the query by. Supports `include`. * @return array Array of results. */ public function get_coupons( $args ) { global $wpdb; $query = "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_type='shop_coupon'"; $included_coupons = $this->get_included_coupons( $args, 'include' ); if ( ! empty( $included_coupons ) ) { $query .= " AND ID IN ({$included_coupons})"; } return $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared } /** * Initialize query objects. */ protected function initialize_queries() { $this->clear_all_clauses(); $this->subquery = new SqlQuery( $this->context . '_subquery' ); $this->subquery->add_sql_clause( 'from', self::get_db_table_name() ); $this->subquery->add_sql_clause( 'group_by', 'coupon_id' ); } } API/Reports/Coupons/Controller.php 0000777 00000017765 15252240713 0013125 0 ustar 00 <?php /** * REST API Reports coupons controller * * Handles requests to the /reports/coupons endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Coupons; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericController; use Automattic\WooCommerce\Admin\API\Reports\ExportableInterface; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use WP_REST_Request; use WP_REST_Response; /** * REST API Reports coupons controller class. * * @internal * @extends GenericController */ class Controller extends GenericController implements ExportableInterface { /** * Route base. * * @var string */ protected $rest_base = 'reports/coupons'; /** * Get data from `'coupons'` GenericQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new GenericQuery( $query_args, 'coupons' ); return $query->get_data(); } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['before'] = $request['before']; $args['after'] = $request['after']; $args['page'] = $request['page']; $args['per_page'] = $request['per_page']; $args['orderby'] = $request['orderby']; $args['order'] = $request['order']; $args['coupons'] = (array) $request['coupons']; $args['extended_info'] = $request['extended_info']; $args['force_cache_refresh'] = $request['force_cache_refresh']; return $args; } /** * Prepare a report data item for serialization. * * @param array $report Report data item as returned from Data Store. * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $response = parent::prepare_item_for_response( $report, $request ); $response->add_links( $this->prepare_links( $report ) ); /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_coupons', $response, $report, $request ); } /** * Prepare links for the request. * * @param WC_Reports_Query $object Object data. * @return array */ protected function prepare_links( $object ) { $links = array( 'coupon' => array( 'href' => rest_url( sprintf( '/%s/coupons/%d', $this->namespace, $object['coupon_id'] ) ), ), ); return $links; } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'report_coupons', 'type' => 'object', 'properties' => array( 'coupon_id' => array( 'description' => __( 'Coupon ID.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'amount' => array( 'description' => __( 'Net discount amount.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'orders_count' => array( 'description' => __( 'Number of orders.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'extended_info' => array( 'code' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Coupon code.', 'woocommerce' ), ), 'date_created' => array( 'type' => 'date-time', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Coupon creation date.', 'woocommerce' ), ), 'date_created_gmt' => array( 'type' => 'date-time', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Coupon creation date in GMT.', 'woocommerce' ), ), 'date_expires' => array( 'type' => 'date-time', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Coupon expiration date.', 'woocommerce' ), ), 'date_expires_gmt' => array( 'type' => 'date-time', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'description' => __( 'Coupon expiration date in GMT.', 'woocommerce' ), ), 'discount_type' => array( 'type' => 'string', 'readonly' => true, 'context' => array( 'view', 'edit' ), 'enum' => array_keys( wc_get_coupon_types() ), 'description' => __( 'Coupon discount type.', 'woocommerce' ), ), ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['default'] = 'coupon_id'; $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'coupon_id', 'code', 'amount', 'orders_count', ) ); $params['coupons'] = array( 'description' => __( 'Limit result set to coupons assigned specific coupon IDs.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['extended_info'] = array( 'description' => __( 'Add additional piece of info about each coupon to the report.', 'woocommerce' ), 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'wc_string_to_bool', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the column names for export. * * @return array Key value pair of Column ID => Label. */ public function get_export_columns() { $export_columns = array( 'code' => __( 'Coupon code', 'woocommerce' ), 'orders_count' => __( 'Orders', 'woocommerce' ), 'amount' => __( 'Amount discounted', 'woocommerce' ), 'created' => __( 'Created', 'woocommerce' ), 'expires' => __( 'Expires', 'woocommerce' ), 'type' => __( 'Type', 'woocommerce' ), ); /** * Filter to add or remove column names from the coupons report for * export. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_coupons_export_columns', $export_columns ); } /** * Get the column values for export. * * @param array $item Single report item/row. * @return array Key value pair of Column ID => Row Value. */ public function prepare_item_for_export( $item ) { $date_expires = empty( $item['extended_info']['date_expires'] ) ? __( 'N/A', 'woocommerce' ) : $item['extended_info']['date_expires']; $export_item = array( 'code' => $item['extended_info']['code'], 'orders_count' => $item['orders_count'], 'amount' => $item['amount'], 'created' => $item['extended_info']['date_created'], 'expires' => $date_expires, 'type' => $item['extended_info']['discount_type'], ); /** * Filter to prepare extra columns in the export item for the coupons * report. * * @since 1.6.0 */ return apply_filters( 'woocommerce_report_coupons_prepare_export_item', $export_item, $item ); } } API/Reports/Coupons/Stats/Query.php 0000777 00000003574 15252240713 0013176 0 ustar 00 <?php /** * Class for parameter-based Products Report querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'page' => 2, * 'coupons' => array(5, 120), * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Coupons\Stats\Query * * @deprecated 9.3.0 Coupons\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Products report. * * @deprecated 9.3.0 Coupons\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array(); } /** * Get product data based on the current query vars. * * @deprecated 9.3.0 Coupons\Stats\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_coupons_stats_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-coupons-stats' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_coupons_select_query', $results, $args ); } } API/Reports/Coupons/Stats/Controller.php 0000777 00000011741 15252240713 0014207 0 ustar 00 <?php /** * REST API Reports coupons stats controller * * Handles requests to the /reports/coupons/stats endpoint. */ namespace Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\GenericStatsController; use Automattic\WooCommerce\Admin\API\Reports\GenericQuery; use WP_REST_Request; use WP_REST_Response; /** * REST API Reports coupons stats controller class. * * @internal * @extends GenericStatsController */ class Controller extends GenericStatsController { /** * Route base. * * @var string */ protected $rest_base = 'reports/coupons/stats'; /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = array(); $args['before'] = $request['before']; $args['after'] = $request['after']; $args['interval'] = $request['interval']; $args['page'] = $request['page']; $args['per_page'] = $request['per_page']; $args['orderby'] = $request['orderby']; $args['order'] = $request['order']; $args['coupons'] = (array) $request['coupons']; $args['segmentby'] = $request['segmentby']; $args['fields'] = $request['fields']; $args['force_cache_refresh'] = $request['force_cache_refresh']; return $args; } /** * Get data from `'coupons-stats'` GenericQuery. * * @override GenericController::get_datastore_data() * * @param array $query_args Query arguments. * @return mixed Results from the data store. */ protected function get_datastore_data( $query_args = array() ) { $query = new GenericQuery( $query_args, 'coupons-stats' ); return $query->get_data(); } /** * Prepare a report data item for serialization. * * @param mixed $report Report data item as returned from Data Store. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_item_for_response( $report, $request ) { $response = parent::prepare_item_for_response( $report, $request ); // Map to `object` for backwards compatibility. $report = (object) $report; /** * Filter a report returned from the API. * * Allows modification of the report data right before it is returned. * * @param WP_REST_Response $response The response object. * @param object $report The original report object. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_report_coupons_stats', $response, $report, $request ); } /** * Get the Report's item properties schema. * Will be used by `get_item_schema` as `totals` and `subtotals`. * * @return array */ protected function get_item_properties_schema() { return array( 'amount' => array( 'description' => __( 'Net discount amount.', 'woocommerce' ), 'type' => 'number', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, 'format' => 'currency', ), 'coupons_count' => array( 'description' => __( 'Number of coupons.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'orders_count' => array( 'title' => __( 'Discounted orders', 'woocommerce' ), 'description' => __( 'Number of discounted orders.', 'woocommerce' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, 'indicator' => true, ), ); } /** * Get the Report's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = parent::get_item_schema(); $schema['title'] = 'report_coupons_stats'; return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['orderby']['enum'] = $this->apply_custom_orderby_filters( array( 'date', 'amount', 'coupons_count', 'orders_count', ) ); $params['coupons'] = array( 'description' => __( 'Limit result set to coupons assigned specific coupon IDs.', 'woocommerce' ), 'type' => 'array', 'sanitize_callback' => 'wp_parse_id_list', 'validate_callback' => 'rest_validate_request_arg', 'items' => array( 'type' => 'integer', ), ); $params['segmentby'] = array( 'description' => __( 'Segment the response by additional constraint.', 'woocommerce' ), 'type' => 'string', 'enum' => array( 'product', 'variation', 'category', 'coupon', ), 'validate_callback' => 'rest_validate_request_arg', ); return $params; } } API/Reports/Coupons/Stats/DataStore.php 0000777 00000020213 15252240713 0013744 0 ustar 00 <?php /** * API\Reports\Coupons\Stats\DataStore class file. */ namespace Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore as CouponsDataStore; use Automattic\WooCommerce\Admin\API\Reports\DataStoreInterface; use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\Admin\API\Reports\StatsDataStoreTrait; /** * API\Reports\Coupons\Stats\DataStore. */ class DataStore extends CouponsDataStore implements DataStoreInterface { use StatsDataStoreTrait; /** * Mapping columns to data type to return correct response types. * * @override CouponsDataStore::$column_types * * @var array */ protected $column_types = array( 'date_start' => 'strval', 'date_end' => 'strval', 'date_start_gmt' => 'strval', 'date_end_gmt' => 'strval', 'amount' => 'floatval', 'coupons_count' => 'intval', 'orders_count' => 'intval', ); /** * SQL columns to select in the db query. * * @override CouponsDataStore::$report_columns * * @var array */ protected $report_columns; /** * Data store context used to pass to filters. * * @override CouponsDataStore::$context * * @var string */ protected $context = 'coupons_stats'; /** * Cache identifier. * * @override CouponsDataStore::get_default_query_vars() * * @var string */ protected $cache_key = 'coupons_stats'; /** * Assign report columns once full table name has been assigned. * * @override CouponsDataStore::assign_report_columns() */ protected function assign_report_columns() { $table_name = self::get_db_table_name(); $this->report_columns = array( 'amount' => 'SUM(discount_amount) as amount', 'coupons_count' => 'COUNT(DISTINCT coupon_id) as coupons_count', 'orders_count' => "COUNT(DISTINCT {$table_name}.order_id) as orders_count", ); } /** * Updates the database query with parameters used for Products Stats report: categories and order status. * * @param array $query_args Query arguments supplied by the user. */ protected function update_sql_query_params( $query_args ) { global $wpdb; $clauses = array( 'where' => '', 'join' => '', ); $order_coupon_lookup_table = self::get_db_table_name(); $included_coupons = $this->get_included_coupons( $query_args, 'coupons' ); if ( $included_coupons ) { $clauses['where'] .= " AND {$order_coupon_lookup_table}.coupon_id IN ({$included_coupons})"; } $order_status_filter = $this->get_status_subquery( $query_args ); if ( $order_status_filter ) { $clauses['join'] .= " JOIN {$wpdb->prefix}wc_order_stats ON {$order_coupon_lookup_table}.order_id = {$wpdb->prefix}wc_order_stats.order_id"; $clauses['where'] .= " AND ( {$order_status_filter} )"; } $this->add_time_period_sql_params( $query_args, $order_coupon_lookup_table ); $this->add_intervals_sql_params( $query_args, $order_coupon_lookup_table ); $clauses['where_time'] = $this->get_sql_clause( 'where_time' ); $this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) ); $this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) ); $this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) ); $this->interval_query->add_sql_clause( 'select', 'AS time_interval' ); foreach ( array( 'join', 'where_time', 'where' ) as $clause ) { $this->interval_query->add_sql_clause( $clause, $clauses[ $clause ] ); $this->total_query->add_sql_clause( $clause, $clauses[ $clause ] ); } } /** * Get the default query arguments to be used by get_data(). * These defaults are only partially applied when used via REST API, as that has its own defaults. * * @override CouponsDataStore::get_default_query_vars() * * @return array Query parameters. */ public function get_default_query_vars() { $defaults = parent::get_default_query_vars(); $defaults['coupons'] = array(); $defaults['interval'] = 'week'; return $defaults; } /** * Returns the report data based on normalized parameters. * Will be called by `get_data` if there is no data in cache. * * @override CouponsDataStore::get_noncached_stats_data() * * @see get_data * @see get_noncached_stats_data * @param array $query_args Query parameters. * @param array $params Query limit parameters. * @param stdClass $data Reference to the data object to fill. * @param int $expected_interval_count Number of expected intervals. * @return stdClass|WP_Error Data object `{ totals: *, intervals: array, total: int, pages: int, page_no: int }`, or error. */ public function get_noncached_stats_data( $query_args, $params, &$data, $expected_interval_count ) { global $wpdb; $table_name = self::get_db_table_name(); $this->initialize_queries(); $selections = $this->selected_columns( $query_args ); $totals_query = array(); $intervals_query = array(); $limit_params = $this->get_limit_sql_params( $query_args ); $this->update_sql_query_params( $query_args, $totals_query, $intervals_query ); $db_intervals = $wpdb->get_col( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->interval_query->get_query_statement() ); $db_interval_count = count( $db_intervals ); $this->total_query->add_sql_clause( 'select', $selections ); $totals = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->total_query->get_query_statement(), ARRAY_A ); if ( null === $totals ) { return $data; } // phpcs:ignore Generic.Commenting.Todo.TaskFound // @todo remove these assignements when refactoring segmenter classes to use query objects. $totals_query = array( 'from_clause' => $this->total_query->get_sql_clause( 'join' ), 'where_time_clause' => $this->total_query->get_sql_clause( 'where_time' ), 'where_clause' => $this->total_query->get_sql_clause( 'where' ), ); $intervals_query = array( 'select_clause' => $this->get_sql_clause( 'select' ), 'from_clause' => $this->interval_query->get_sql_clause( 'join' ), 'where_time_clause' => $this->interval_query->get_sql_clause( 'where_time' ), 'where_clause' => $this->interval_query->get_sql_clause( 'where' ), 'limit' => $this->get_sql_clause( 'limit' ), ); $segmenter = new Segmenter( $query_args, $this->report_columns ); $totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name ); $totals = (object) $this->cast_numbers( $totals[0] ); // Intervals. $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); $this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" ); if ( '' !== $selections ) { $this->interval_query->add_sql_clause( 'select', ', ' . $selections ); } $intervals = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- cache ok, DB call ok, unprepared SQL ok. $this->interval_query->get_query_statement(), ARRAY_A ); if ( null === $intervals ) { return $data; } $data->totals = $totals; $data->intervals = $intervals; if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $limit_params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); $this->remove_extra_records( $data, $query_args['page'], $limit_params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); } else { $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); } $segmenter->add_intervals_segments( $data, $intervals_query, $table_name ); return $data; } } API/Reports/Coupons/Stats/Segmenter.php 0000777 00000036274 15252240713 0014025 0 ustar 00 <?php /** * Class for adding segmenting support to coupons/stats without cluttering the data store. */ namespace Automattic\WooCommerce\Admin\API\Reports\Coupons\Stats; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Segmenter as ReportsSegmenter; use Automattic\WooCommerce\Admin\API\Reports\ParameterException; /** * Date & time interval and numeric range handling class for Reporting API. */ class Segmenter extends ReportsSegmenter { /** * Returns column => query mapping to be used for product-related product-level segmenting query * (e.g. coupon discount amount for product X when segmenting by product id or category). * * @param string $products_table Name of SQL table containing the product-level segmenting info. * * @return array Column => SELECT query mapping. */ protected function get_segment_selections_product_level( $products_table ) { $columns_mapping = array( 'amount' => "SUM($products_table.coupon_amount) as amount", ); return $columns_mapping; } /** * Returns column => query mapping to be used for order-related product-level segmenting query * (e.g. orders_count when segmented by category). * * @param string $coupons_lookup_table Name of SQL table containing the order-level segmenting info. * * @return array Column => SELECT query mapping. */ protected function get_segment_selections_order_level( $coupons_lookup_table ) { $columns_mapping = array( 'coupons_count' => "COUNT(DISTINCT $coupons_lookup_table.coupon_id) as coupons_count", 'orders_count' => "COUNT(DISTINCT $coupons_lookup_table.order_id) as orders_count", ); return $columns_mapping; } /** * Returns column => query mapping to be used for order-level segmenting query * (e.g. discount amount when segmented by coupons). * * @param string $coupons_lookup_table Name of SQL table containing the order-level info. * @param array $overrides Array of overrides for default column calculations. * * @return array Column => SELECT query mapping. */ protected function segment_selections_orders( $coupons_lookup_table, $overrides = array() ) { $columns_mapping = array( 'amount' => "SUM($coupons_lookup_table.discount_amount) as amount", 'coupons_count' => "COUNT(DISTINCT $coupons_lookup_table.coupon_id) as coupons_count", 'orders_count' => "COUNT(DISTINCT $coupons_lookup_table.order_id) as orders_count", ); if ( $overrides ) { $columns_mapping = array_merge( $columns_mapping, $overrides ); } return $columns_mapping; } /** * Calculate segments for totals where the segmenting property is bound to product (e.g. category, product_id, variation_id). * * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $segmenting_dimension_name Name of the segmenting dimension. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $totals_query Array of SQL clauses for totals query. * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. * * @return array */ protected function get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $totals_query, $unique_orders_table ) { global $wpdb; // Product-level numbers and order-level numbers can be fetched by the same query. $segments_products = $wpdb->get_results( "SELECT $segmenting_groupby AS $segmenting_dimension_name {$segmenting_selections['product_level']} {$segmenting_selections['order_level']} FROM $table_name $segmenting_from {$totals_query['from_clause']} WHERE 1=1 {$totals_query['where_time_clause']} {$totals_query['where_clause']} $segmenting_where GROUP BY $segmenting_groupby", ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. $totals_segments = $this->merge_segment_totals_results( $segmenting_dimension_name, $segments_products, array() ); return $totals_segments; } /** * Calculate segments for intervals where the segmenting property is bound to product (e.g. category, product_id, variation_id). * * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $segmenting_dimension_name Name of the segmenting dimension. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $intervals_query Array of SQL clauses for intervals query. * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. * * @return array */ protected function get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $intervals_query, $unique_orders_table ) { global $wpdb; // LIMIT offset, rowcount needs to be updated to LIMIT offset, rowcount * max number of segments. $limit_parts = explode( ',', $intervals_query['limit'] ); $orig_rowcount = intval( $limit_parts[1] ); $segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() ); // Product-level numbers and order-level numbers can be fetched by the same query. $segments_products = $wpdb->get_results( "SELECT {$intervals_query['select_clause']} AS time_interval, $segmenting_groupby AS $segmenting_dimension_name {$segmenting_selections['product_level']} {$segmenting_selections['order_level']} FROM $table_name $segmenting_from {$intervals_query['from_clause']} WHERE 1=1 {$intervals_query['where_time_clause']} {$intervals_query['where_clause']} $segmenting_where GROUP BY time_interval, $segmenting_groupby $segmenting_limit", ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. $intervals_segments = $this->merge_segment_intervals_results( $segmenting_dimension_name, $segments_products, array() ); return $intervals_segments; } /** * Calculate segments for totals query where the segmenting property is bound to order (e.g. coupon or customer type). * * @param string $segmenting_select SELECT part of segmenting SQL query. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $totals_query Array of SQL clauses for intervals query. * * @return array */ protected function get_order_related_totals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $totals_query ) { global $wpdb; $totals_segments = $wpdb->get_results( "SELECT $segmenting_groupby $segmenting_select FROM $table_name $segmenting_from {$totals_query['from_clause']} WHERE 1=1 {$totals_query['where_time_clause']} {$totals_query['where_clause']} $segmenting_where GROUP BY $segmenting_groupby", ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. // Reformat result. $totals_segments = $this->reformat_totals_segments( $totals_segments, $segmenting_groupby ); return $totals_segments; } /** * Calculate segments for intervals query where the segmenting property is bound to order (e.g. coupon or customer type). * * @param string $segmenting_select SELECT part of segmenting SQL query. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $intervals_query Array of SQL clauses for intervals query. * * @return array */ protected function get_order_related_intervals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $intervals_query ) { global $wpdb; $limit_parts = explode( ',', $intervals_query['limit'] ); $orig_rowcount = intval( $limit_parts[1] ); $segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() ); $intervals_segments = $wpdb->get_results( "SELECT MAX($table_name.date_created) AS datetime_anchor, {$intervals_query['select_clause']} AS time_interval, $segmenting_groupby $segmenting_select FROM $table_name $segmenting_from {$intervals_query['from_clause']} WHERE 1=1 {$intervals_query['where_time_clause']} {$intervals_query['where_clause']} $segmenting_where GROUP BY time_interval, $segmenting_groupby $segmenting_limit", ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok. // Reformat result. $intervals_segments = $this->reformat_intervals_segments( $intervals_segments, $segmenting_groupby ); return $intervals_segments; } /** * Return array of segments formatted for REST response. * * @param string $type Type of segments to return--'totals' or 'intervals'. * @param array $query_params SQL query parameter array. * @param string $table_name Name of main SQL table for the data store (used as basis for JOINS). * * @return array * @throws \Automattic\WooCommerce\Admin\API\Reports\ParameterException In case of segmenting by variations, when no parent product is specified. */ protected function get_segments( $type, $query_params, $table_name ) { global $wpdb; if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) { return array(); } $segments = null; $product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup'; $unique_orders_table = ''; $segmenting_where = ''; // Product, variation, and category are bound to product, so here product segmenting table is required, // while coupon and customer are bound to order, so we don't need the extra JOIN for those. // This also means that segment selections need to be calculated differently. if ( 'product' === $this->query_args['segmentby'] ) { $product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table ); $order_level_columns = $this->get_segment_selections_order_level( $table_name ); $segmenting_selections = array( 'product_level' => $this->prepare_selections( $product_level_columns ), 'order_level' => $this->prepare_selections( $order_level_columns ), ); $this->report_columns = array_merge( $product_level_columns, $order_level_columns ); $segmenting_from = "INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)"; $segmenting_groupby = $product_segmenting_table . '.product_id'; $segmenting_dimension_name = 'product_id'; $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } elseif ( 'variation' === $this->query_args['segmentby'] ) { if ( ! isset( $this->query_args['product_includes'] ) || ! is_array( $this->query_args['product_includes'] ) || count( $this->query_args['product_includes'] ) !== 1 ) { throw new ParameterException( 'wc_admin_reports_invalid_segmenting_variation', __( 'product_includes parameter need to specify exactly one product when segmenting by variation.', 'woocommerce' ) ); } $product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table ); $order_level_columns = $this->get_segment_selections_order_level( $table_name ); $segmenting_selections = array( 'product_level' => $this->prepare_selections( $product_level_columns ), 'order_level' => $this->prepare_selections( $order_level_columns ), ); $this->report_columns = array_merge( $product_level_columns, $order_level_columns ); $segmenting_from = "INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)"; $segmenting_where = "AND $product_segmenting_table.product_id = {$this->query_args['product_includes'][0]}"; $segmenting_groupby = $product_segmenting_table . '.variation_id'; $segmenting_dimension_name = 'variation_id'; $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } elseif ( 'category' === $this->query_args['segmentby'] ) { $product_level_columns = $this->get_segment_selections_product_level( $product_segmenting_table ); $order_level_columns = $this->get_segment_selections_order_level( $table_name ); $segmenting_selections = array( 'product_level' => $this->prepare_selections( $product_level_columns ), 'order_level' => $this->prepare_selections( $order_level_columns ), ); $this->report_columns = array_merge( $product_level_columns, $order_level_columns ); $segmenting_from = " INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id) LEFT JOIN {$wpdb->term_relationships} ON {$product_segmenting_table}.product_id = {$wpdb->term_relationships}.object_id JOIN {$wpdb->term_taxonomy} ON {$wpdb->term_taxonomy}.term_taxonomy_id = {$wpdb->term_relationships}.term_taxonomy_id LEFT JOIN {$wpdb->wc_category_lookup} ON {$wpdb->term_taxonomy}.term_id = {$wpdb->wc_category_lookup}.category_id "; $segmenting_where = " AND {$wpdb->wc_category_lookup}.category_tree_id IS NOT NULL"; $segmenting_groupby = "{$wpdb->wc_category_lookup}.category_tree_id"; $segmenting_dimension_name = 'category_id'; $segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } elseif ( 'coupon' === $this->query_args['segmentby'] ) { $coupon_level_columns = $this->segment_selections_orders( $table_name ); $segmenting_selections = $this->prepare_selections( $coupon_level_columns ); $this->report_columns = $coupon_level_columns; $segmenting_from = ''; $segmenting_groupby = "$table_name.coupon_id"; $segments = $this->get_order_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params ); } return $segments; } } API/Reports/Coupons/Query.php 0000777 00000003513 15252240713 0012071 0 ustar 00 <?php /** * Class for parameter-based Coupons Report querying * * Example usage: * $args = array( * 'before' => '2018-07-19 00:00:00', * 'after' => '2018-07-05 00:00:00', * 'page' => 2, * 'coupons' => array(5, 120), * ); * $report = new \Automattic\WooCommerce\Admin\API\Reports\Coupons\Query( $args ); * $mydata = $report->get_data(); */ namespace Automattic\WooCommerce\Admin\API\Reports\Coupons; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Query as ReportsQuery; /** * API\Reports\Coupons\Query * * @deprecated 9.3.0 Coupons\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. */ class Query extends ReportsQuery { /** * Valid fields for Products report. * * @deprecated 9.3.0 Coupons\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ protected function get_default_query_vars() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); return array(); } /** * Get product data based on the current query vars. * * @deprecated 9.3.0 Coupons\Query class is deprecated. Please use `GenericQuery`, \WC_Object_Query`, or use `DataStore` directly. * * @return array */ public function get_data() { wc_deprecated_function( __CLASS__ . '::' . __FUNCTION__, '9.3.0', '`GenericQuery`, `\WC_Object_Query`, or direct `DataStore` use' ); $args = apply_filters( 'woocommerce_analytics_coupons_query_args', $this->get_query_vars() ); $data_store = \WC_Data_Store::load( 'report-coupons' ); $results = $data_store->get_data( $args ); return apply_filters( 'woocommerce_analytics_coupons_select_query', $results, $args ); } } API/Reports/SqlQuery.php 0000777 00000012236 15252240713 0011125 0 ustar 00 <?php /** * Admin\API\Reports\SqlQuery class file. */ namespace Automattic\WooCommerce\Admin\API\Reports; if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Admin\API\Reports\SqlQuery: Common parent for manipulating SQL query clauses. */ class SqlQuery { /** * List of SQL clauses. * * @var array */ private $sql_clauses = array( 'select' => array(), 'from' => array(), 'left_join' => array(), 'join' => array(), 'right_join' => array(), 'where' => array(), 'where_time' => array(), 'group_by' => array(), 'having' => array(), 'limit' => array(), 'order_by' => array(), 'union' => array(), ); /** * SQL clause merge filters. * * @var array */ private $sql_filters = array( 'where' => array( 'where', 'where_time', ), 'join' => array( 'right_join', 'join', 'left_join', ), ); /** * Data store context used to pass to filters. * * @var string */ protected $context; /** * Constructor. * * @param string $context Optional context passed to filters. Default empty string. */ public function __construct( $context = '' ) { $this->context = $context; } /** * Add a SQL clause to be included when get_data is called. * * @param string $type Clause type. * @param string $clause SQL clause. */ public function add_sql_clause( $type, $clause ) { if ( isset( $this->sql_clauses[ $type ] ) && ! empty( $clause ) ) { $this->sql_clauses[ $type ][] = $clause; } } /** * Get SQL clause by type. * * @param string $type Clause type. * @param string $handling Whether to filter the return value (filtered|unfiltered). Default unfiltered. * * @return string SQL clause. */ protected function get_sql_clause( $type, $handling = 'unfiltered' ) { if ( ! isset( $this->sql_clauses[ $type ] ) ) { return ''; } /** * Default to bypassing filters for clause retrieval internal to data stores. * The filters are applied when the full SQL statement is retrieved. */ if ( 'unfiltered' === $handling ) { return implode( ' ', $this->sql_clauses[ $type ] ); } if ( isset( $this->sql_filters[ $type ] ) ) { $clauses = array(); foreach ( $this->sql_filters[ $type ] as $subset ) { $clauses = array_merge( $clauses, $this->sql_clauses[ $subset ] ); } } else { $clauses = $this->sql_clauses[ $type ]; } /** * Filter SQL clauses by type and context. * * @param array $clauses The original arguments for the request. * @param string $context The data store context. */ $clauses = apply_filters( "woocommerce_analytics_clauses_{$type}", $clauses, $this->context ); /** * Filter SQL clauses by type and context. * * @param array $clauses The original arguments for the request. */ $clauses = apply_filters( "woocommerce_analytics_clauses_{$type}_{$this->context}", $clauses ); return implode( ' ', $clauses ); } /** * Clear SQL clauses by type. * * @param string|array $types Clause type. */ protected function clear_sql_clause( $types ) { foreach ( (array) $types as $type ) { if ( isset( $this->sql_clauses[ $type ] ) ) { $this->sql_clauses[ $type ] = array(); } } } /** * Replace strings within SQL clauses by type. * * @param string $type Clause type. * @param string $search String to search for. * @param string $replace Replacement string. */ protected function str_replace_clause( $type, $search, $replace ) { if ( isset( $this->sql_clauses[ $type ] ) ) { foreach ( $this->sql_clauses[ $type ] as $key => $sql ) { $this->sql_clauses[ $type ][ $key ] = str_replace( $search, $replace, $sql ); } } } /** * Get the full SQL statement. * * @return string */ public function get_query_statement() { $join = $this->get_sql_clause( 'join', 'filtered' ); $where = $this->get_sql_clause( 'where', 'filtered' ); $group_by = $this->get_sql_clause( 'group_by', 'filtered' ); $having = $this->get_sql_clause( 'having', 'filtered' ); $order_by = $this->get_sql_clause( 'order_by', 'filtered' ); $union = $this->get_sql_clause( 'union', 'filtered' ); $statement = ''; $statement .= " SELECT {$this->get_sql_clause( 'select', 'filtered' )} FROM {$this->get_sql_clause( 'from', 'filtered' )} {$join} WHERE 1=1 {$where} "; if ( ! empty( $group_by ) ) { $statement .= " GROUP BY {$group_by} "; if ( ! empty( $having ) ) { $statement .= " HAVING 1=1 {$having} "; } } if ( ! empty( $union ) ) { $statement .= " UNION {$union} "; } if ( ! empty( $order_by ) ) { $statement .= " ORDER BY {$order_by} "; } return $statement . $this->get_sql_clause( 'limit', 'filtered' ); } /** * Reinitialize the clause array. */ public function clear_all_clauses() { $this->sql_clauses = array( 'select' => array(), 'from' => array(), 'left_join' => array(), 'join' => array(), 'right_join' => array(), 'where' => array(), 'where_time' => array(), 'group_by' => array(), 'having' => array(), 'limit' => array(), 'order_by' => array(), 'union' => array(), ); } } API/Reports/TimeInterval.php 0000777 00000056743 15252240713 0011756 0 ustar 00 <?php /** * Class for time interval and numeric range handling for reports. */ namespace Automattic\WooCommerce\Admin\API\Reports; defined( 'ABSPATH' ) || exit; /** * Date & time interval and numeric range handling class for Reporting API. */ class TimeInterval { /** * Format string for ISO DateTime formatter. * * @var string */ public static $iso_datetime_format = 'Y-m-d\TH:i:s'; /** * Format string for use in SQL queries. * * @var string */ public static $sql_datetime_format = 'Y-m-d H:i:s'; /** * Converts local datetime to GMT/UTC time. * * @param string $datetime_string String representation of local datetime. * @return DateTime */ public static function convert_local_datetime_to_gmt( $datetime_string ) { $datetime = new \DateTime( $datetime_string, new \DateTimeZone( wc_timezone_string() ) ); $datetime->setTimezone( new \DateTimeZone( 'GMT' ) ); return $datetime; } /** * Returns default 'before' parameter for the reports. * * @return DateTime */ public static function default_before() { $datetime = new \WC_DateTime(); // Set local timezone or offset. if ( get_option( 'timezone_string' ) ) { $datetime->setTimezone( new \DateTimeZone( wc_timezone_string() ) ); } else { $datetime->set_utc_offset( wc_timezone_offset() ); } return $datetime; } /** * Returns default 'after' parameter for the reports. * * @return DateTime */ public static function default_after() { $now = time(); $week_back = $now - WEEK_IN_SECONDS; $datetime = new \WC_DateTime(); $datetime->setTimestamp( $week_back ); // Set local timezone or offset. if ( get_option( 'timezone_string' ) ) { $datetime->setTimezone( new \DateTimeZone( wc_timezone_string() ) ); } else { $datetime->set_utc_offset( wc_timezone_offset() ); } return $datetime; } /** * Returns date format to be used as grouping clause in SQL. * * @param string $time_interval Time interval. * @param string $table_name Name of the db table relevant for the date constraint. * @param string $date_column_name Name of the date table column. * @return mixed */ public static function db_datetime_format( $time_interval, $table_name, $date_column_name = 'date_created' ) { $first_day_of_week = absint( get_option( 'start_of_week' ) ); if ( 1 === $first_day_of_week ) { // Week begins on Monday, ISO 8601. $week_format = "DATE_FORMAT({$table_name}.`{$date_column_name}`, '%x-%v')"; } else { // Week begins on day other than specified by ISO 8601, needs to be in sync with function simple_week_number. $week_format = "CONCAT(YEAR({$table_name}.`{$date_column_name}`), '-', LPAD( FLOOR( ( DAYOFYEAR({$table_name}.`{$date_column_name}`) + ( ( DATE_FORMAT(MAKEDATE(YEAR({$table_name}.`{$date_column_name}`),1), '%w') - $first_day_of_week + 7 ) % 7 ) - 1 ) / 7 ) + 1 , 2, '0'))"; } // Whenever this is changed, double check method time_interval_id to make sure they are in sync. $mysql_date_format_mapping = array( 'hour' => "DATE_FORMAT({$table_name}.`{$date_column_name}`, '%Y-%m-%d %H')", 'day' => "DATE_FORMAT({$table_name}.`{$date_column_name}`, '%Y-%m-%d')", 'week' => $week_format, 'month' => "DATE_FORMAT({$table_name}.`{$date_column_name}`, '%Y-%m')", 'quarter' => "CONCAT(YEAR({$table_name}.`{$date_column_name}`), '-', QUARTER({$table_name}.`{$date_column_name}`))", 'year' => "YEAR({$table_name}.`{$date_column_name}`)", ); return $mysql_date_format_mapping[ $time_interval ]; } /** * Returns quarter for the DateTime. * * @param DateTime $datetime Local date & time. * @return int|null */ public static function quarter( $datetime ) { switch ( (int) $datetime->format( 'm' ) ) { case 1: case 2: case 3: return 1; case 4: case 5: case 6: return 2; case 7: case 8: case 9: return 3; case 10: case 11: case 12: return 4; } return null; } /** * Returns simple week number for the DateTime, for week starting on $first_day_of_week. * * The first week of the year is considered to be the week containing January 1. * The second week starts on the next $first_day_of_week. * * @param DateTime $datetime Local date for which the week number is to be calculated. * @param int $first_day_of_week 0 for Sunday to 6 for Saturday. * @return int */ public static function simple_week_number( $datetime, $first_day_of_week ) { $beg_of_year_day = new \DateTime( "{$datetime->format('Y')}-01-01" ); $adj_day_beg_of_year = ( (int) $beg_of_year_day->format( 'w' ) - $first_day_of_week + 7 ) % 7; $days_since_start_of_year = (int) $datetime->format( 'z' ) + 1; return (int) floor( ( ( $days_since_start_of_year + $adj_day_beg_of_year - 1 ) / 7 ) ) + 1; } /** * Returns ISO 8601 week number for the DateTime, if week starts on Monday, * otherwise returns simple week number. * * @see TimeInterval::simple_week_number() * * @param DateTime $datetime Local date for which the week number is to be calculated. * @param int $first_day_of_week 0 for Sunday to 6 for Saturday. * @return int */ public static function week_number( $datetime, $first_day_of_week ) { if ( 1 === $first_day_of_week ) { $week_number = (int) $datetime->format( 'W' ); } else { $week_number = self::simple_week_number( $datetime, $first_day_of_week ); } return $week_number; } /** * Returns time interval id for the DateTime. * * @param string $time_interval Time interval type (week, day, etc). * @param DateTime $datetime Date & time. * @return string */ public static function time_interval_id( $time_interval, $datetime ) { // Whenever this is changed, double check method db_datetime_format to make sure they are in sync. $php_time_format_for = array( 'hour' => 'Y-m-d H', 'day' => 'Y-m-d', 'week' => 'o-W', 'month' => 'Y-m', 'quarter' => 'Y-' . self::quarter( $datetime ), 'year' => 'Y', ); // If the week does not begin on Monday. $first_day_of_week = absint( get_option( 'start_of_week' ) ); if ( 'week' === $time_interval && 1 !== $first_day_of_week ) { $week_no = self::simple_week_number( $datetime, $first_day_of_week ); $week_no = str_pad( $week_no, 2, '0', STR_PAD_LEFT ); $year_no = $datetime->format( 'Y' ); return "$year_no-$week_no"; } return $datetime->format( $php_time_format_for[ $time_interval ] ); } /** * Calculates number of time intervals between two dates, closed interval on both sides. * * @param DateTime $start_datetime Start date & time. * @param DateTime $end_datetime End date & time. * @param string $interval Time interval increment, e.g. hour, day, week. * * @return int */ public static function intervals_between( $start_datetime, $end_datetime, $interval ) { switch ( $interval ) { case 'hour': $end_timestamp = (int) $end_datetime->format( 'U' ); $start_timestamp = (int) $start_datetime->format( 'U' ); $addendum = 0; // modulo HOUR_IN_SECONDS would normally work, but there are non-full hour timezones, e.g. Nepal. $start_min_sec = (int) $start_datetime->format( 'i' ) * MINUTE_IN_SECONDS + (int) $start_datetime->format( 's' ); $end_min_sec = (int) $end_datetime->format( 'i' ) * MINUTE_IN_SECONDS + (int) $end_datetime->format( 's' ); if ( $end_min_sec < $start_min_sec ) { $addendum = 1; } $diff_timestamp = $end_timestamp - $start_timestamp; return (int) floor( ( (int) $diff_timestamp ) / HOUR_IN_SECONDS ) + 1 + $addendum; case 'day': $days = $start_datetime->diff( $end_datetime )->format( '%r%a' ); $end_hour_min_sec = (int) $end_datetime->format( 'H' ) * HOUR_IN_SECONDS + (int) $end_datetime->format( 'i' ) * MINUTE_IN_SECONDS + (int) $end_datetime->format( 's' ); $start_hour_min_sec = (int) $start_datetime->format( 'H' ) * HOUR_IN_SECONDS + (int) $start_datetime->format( 'i' ) * MINUTE_IN_SECONDS + (int) $start_datetime->format( 's' ); if ( $end_hour_min_sec < $start_hour_min_sec ) { $days++; } return $days + 1; case 'week': // @todo Optimize? approximately day count / 7, but year end is tricky, a week can have fewer days. $week_count = 0; do { $start_datetime = self::next_week_start( $start_datetime ); $week_count++; } while ( $start_datetime <= $end_datetime ); return $week_count; case 'month': // Year diff in months: (end_year - start_year - 1) * 12. $year_diff_in_months = ( (int) $end_datetime->format( 'Y' ) - (int) $start_datetime->format( 'Y' ) - 1 ) * 12; // All the months in end_date year plus months from X to 12 in the start_date year. $month_diff = (int) $end_datetime->format( 'n' ) + ( 12 - (int) $start_datetime->format( 'n' ) ); // Add months for number of years between end_date and start_date. $month_diff += $year_diff_in_months + 1; return $month_diff; case 'quarter': // Year diff in quarters: (end_year - start_year - 1) * 4. $year_diff_in_quarters = ( (int) $end_datetime->format( 'Y' ) - (int) $start_datetime->format( 'Y' ) - 1 ) * 4; // All the quarters in end_date year plus quarters from X to 4 in the start_date year. $quarter_diff = self::quarter( $end_datetime ) + ( 4 - self::quarter( $start_datetime ) ); // Add quarters for number of years between end_date and start_date. $quarter_diff += $year_diff_in_quarters + 1; return $quarter_diff; case 'year': $year_diff = (int) $end_datetime->format( 'Y' ) - (int) $start_datetime->format( 'Y' ); return $year_diff + 1; } return 0; } /** * Returns a new DateTime object representing the next hour start/previous hour end if reversed. * * @param DateTime $datetime Date and time. * @param bool $reversed Going backwards in time instead of forward. * @return DateTime */ public static function next_hour_start( $datetime, $reversed = false ) { $hour_increment = $reversed ? 0 : 1; $timestamp = (int) $datetime->format( 'U' ); $seconds_into_hour = (int) $datetime->format( 'i' ) * MINUTE_IN_SECONDS + (int) $datetime->format( 's' ); $hours_offset_timestamp = $timestamp + ( $hour_increment * HOUR_IN_SECONDS - $seconds_into_hour ); if ( $reversed ) { $hours_offset_timestamp --; } $hours_offset_time = new \DateTime(); $hours_offset_time->setTimestamp( $hours_offset_timestamp ); $hours_offset_time->setTimezone( new \DateTimeZone( wc_timezone_string() ) ); return $hours_offset_time; } /** * Returns a new DateTime object representing the next day start, or previous day end if reversed. * * @param DateTime $datetime Date and time. * @param bool $reversed Going backwards in time instead of forward. * @return DateTime */ public static function next_day_start( $datetime, $reversed = false ) { $oneday = new \DateInterval( 'P1D' ); $new_datetime = clone $datetime; if ( $reversed ) { $new_datetime->sub( $oneday ); $new_datetime->setTime( 23, 59, 59 ); } else { $new_datetime->add( $oneday ); $new_datetime->setTime( 0, 0, 0 ); } return $new_datetime; } /** * Returns DateTime object representing the next week start, or previous week end if reversed. * * The next week start is the first day of the next week at 00:00:00. * The previous week end is the last day of the previous week at 23:59:59. * The start day is determined by the "start_of_week" wp_option. * * @param DateTime $datetime Date and time. * @param bool $reversed Going backwards in time instead of forward. * @return DateTime */ public static function next_week_start( $datetime, $reversed = false ) { $seven_days = new \DateInterval( 'P7D' ); // Default timezone set in wp-settings.php. $default_timezone = date_default_timezone_get(); // Timezone that the WP site uses in Settings > General. $original_timezone = $datetime->getTimezone(); // @codingStandardsIgnoreStart date_default_timezone_set( 'UTC' ); $start_end_timestamp = get_weekstartend( $datetime->format( 'Y-m-d' ) ); date_default_timezone_set( $default_timezone ); // @codingStandardsIgnoreEnd if ( $reversed ) { $result = \DateTime::createFromFormat( 'U', $start_end_timestamp['end'] )->sub( $seven_days ); } else { $result = \DateTime::createFromFormat( 'U', $start_end_timestamp['start'] )->add( $seven_days ); } return \DateTime::createFromFormat( 'Y-m-d H:i:s', $result->format( 'Y-m-d H:i:s' ), $original_timezone ); } /** * Returns a new DateTime object representing the next month start, or previous month end if reversed. * * @param DateTime $datetime Date and time. * @param bool $reversed Going backwards in time instead of forward. * @return DateTime */ public static function next_month_start( $datetime, $reversed = false ) { $month_increment = 1; $year = $datetime->format( 'Y' ); $month = (int) $datetime->format( 'm' ); if ( $reversed ) { $beg_of_month_datetime = new \DateTime( "$year-$month-01 00:00:00", new \DateTimeZone( wc_timezone_string() ) ); $timestamp = (int) $beg_of_month_datetime->format( 'U' ); $end_of_prev_month_timestamp = $timestamp - 1; $datetime->setTimestamp( $end_of_prev_month_timestamp ); } else { $month += $month_increment; if ( $month > 12 ) { $month = 1; $year ++; } $day = '01'; $datetime = new \DateTime( "$year-$month-$day 00:00:00", new \DateTimeZone( wc_timezone_string() ) ); } return $datetime; } /** * Returns a new DateTime object representing the next quarter start, or previous quarter end if reversed. * * @param DateTime $datetime Date and time. * @param bool $reversed Going backwards in time instead of forward. * @return DateTime */ public static function next_quarter_start( $datetime, $reversed = false ) { $year = $datetime->format( 'Y' ); $month = (int) $datetime->format( 'n' ); switch ( $month ) { case 1: case 2: case 3: if ( $reversed ) { $month = 1; } else { $month = 4; } break; case 4: case 5: case 6: if ( $reversed ) { $month = 4; } else { $month = 7; } break; case 7: case 8: case 9: if ( $reversed ) { $month = 7; } else { $month = 10; } break; case 10: case 11: case 12: if ( $reversed ) { $month = 10; } else { $month = 1; $year ++; } break; } $datetime = new \DateTime( "$year-$month-01 00:00:00", new \DateTimeZone( wc_timezone_string() ) ); if ( $reversed ) { $timestamp = (int) $datetime->format( 'U' ); $end_of_prev_month_timestamp = $timestamp - 1; $datetime->setTimestamp( $end_of_prev_month_timestamp ); } return $datetime; } /** * Return a new DateTime object representing the next year start, or previous year end if reversed. * * @param DateTime $datetime Date and time. * @param bool $reversed Going backwards in time instead of forward. * @return DateTime */ public static function next_year_start( $datetime, $reversed = false ) { $year_increment = 1; $year = (int) $datetime->format( 'Y' ); $month = '01'; $day = '01'; if ( $reversed ) { $datetime = new \DateTime( "$year-$month-$day 00:00:00", new \DateTimeZone( wc_timezone_string() ) ); $timestamp = (int) $datetime->format( 'U' ); $end_of_prev_year_timestamp = $timestamp - 1; $datetime->setTimestamp( $end_of_prev_year_timestamp ); } else { $year += $year_increment; $datetime = new \DateTime( "$year-$month-$day 00:00:00", new \DateTimeZone( wc_timezone_string() ) ); } return $datetime; } /** * Returns beginning of next time interval for provided DateTime. * * E.g. for current DateTime, beginning of next day, week, quarter, etc. * * @param DateTime $datetime Date and time. * @param string $time_interval Time interval, e.g. week, day, hour. * @param bool $reversed Going backwards in time instead of forward. * @return DateTime */ public static function iterate( $datetime, $time_interval, $reversed = false ) { return call_user_func( array( __CLASS__, "next_{$time_interval}_start" ), $datetime, $reversed ); } /** * Returns expected number of items on the page in case of date ordering. * * @param int $expected_interval_count Expected number of intervals in total. * @param int $items_per_page Number of items per page. * @param int $page_no Page number. * * @return float|int */ public static function expected_intervals_on_page( $expected_interval_count, $items_per_page, $page_no ) { $total_pages = (int) ceil( $expected_interval_count / $items_per_page ); if ( $page_no < $total_pages ) { return $items_per_page; } elseif ( $page_no === $total_pages ) { return $expected_interval_count - ( $page_no - 1 ) * $items_per_page; } else { return 0; } } /** * Returns true if there are any intervals that need to be filled in the response. * * @param int $expected_interval_count Expected number of intervals in total. * @param int $db_records Total number of records for given period in the database. * @param int $items_per_page Number of items per page. * @param int $page_no Page number. * @param string $order asc or desc. * @param string $order_by Column by which the result will be sorted. * @param int $intervals_count Number of records for given (possibly shortened) time interval. * * @return bool */ public static function intervals_missing( $expected_interval_count, $db_records, $items_per_page, $page_no, $order, $order_by, $intervals_count ) { if ( $expected_interval_count <= $db_records ) { return false; } if ( 'date' === $order_by ) { $expected_intervals_on_page = self::expected_intervals_on_page( $expected_interval_count, $items_per_page, $page_no ); return $intervals_count < $expected_intervals_on_page; } if ( 'desc' === $order ) { return $page_no > floor( $db_records / $items_per_page ); } if ( 'asc' === $order ) { return $page_no <= ceil( ( $expected_interval_count - $db_records ) / $items_per_page ); } // Invalid ordering. return false; } /** * Normalize "*_between" parameters to "*_min" and "*_max" for numeric values * and "*_after" and "*_before" for date values. * * @param array $request Query params from REST API request. * @param string|array $param_names One or more param names to handle. Should not include "_between" suffix. * @param bool $is_date Boolean if the param is date is related. * @return array Normalized query values. */ public static function normalize_between_params( $request, $param_names, $is_date ) { if ( ! is_array( $param_names ) ) { $param_names = array( $param_names ); } $normalized = array(); foreach ( $param_names as $param_name ) { if ( ! is_array( $request[ $param_name . '_between' ] ) ) { continue; } $range = $request[ $param_name . '_between' ]; if ( 2 !== count( $range ) ) { continue; } $min = $is_date ? '_after' : '_min'; $max = $is_date ? '_before' : '_max'; if ( $range[0] < $range[1] ) { $normalized[ $param_name . $min ] = $range[0]; $normalized[ $param_name . $max ] = $range[1]; } else { $normalized[ $param_name . $min ] = $range[1]; $normalized[ $param_name . $max ] = $range[0]; } } return $normalized; } /** * Validate a "*_between" range argument (an array with 2 numeric items). * * @param mixed $value Parameter value. * @param WP_REST_Request $request REST Request. * @param string $param Parameter name. * @return WP_Error|boolean */ public static function rest_validate_between_numeric_arg( $value, $request, $param ) { if ( ! wp_is_numeric_array( $value ) ) { return new \WP_Error( 'rest_invalid_param', /* translators: 1: parameter name */ sprintf( __( '%1$s is not a numerically indexed array.', 'woocommerce' ), $param ) ); } if ( ! is_array( $value ) || 2 !== count( $value ) || ! is_numeric( $value[0] ) || ! is_numeric( $value[1] ) ) { return new \WP_Error( 'rest_invalid_param', /* translators: %s: parameter name */ sprintf( __( '%s must contain 2 numbers.', 'woocommerce' ), $param ) ); } return true; } /** * Validate a "*_between" range argument (an array with 2 date items). * * @param mixed $value Parameter value. * @param WP_REST_Request $request REST Request. * @param string $param Parameter name. * @return WP_Error|boolean */ public static function rest_validate_between_date_arg( $value, $request, $param ) { if ( ! wp_is_numeric_array( $value ) ) { return new \WP_Error( 'rest_invalid_param', /* translators: 1: parameter name */ sprintf( __( '%1$s is not a numerically indexed array.', 'woocommerce' ), $param ) ); } if ( ! is_array( $value ) || 2 !== count( $value ) || ! rest_parse_date( $value[0] ) || ! rest_parse_date( $value[1] ) ) { return new \WP_Error( 'rest_invalid_param', /* translators: %s: parameter name */ sprintf( __( '%s must contain 2 valid dates.', 'woocommerce' ), $param ) ); } return true; } /** * Get dates from a timeframe string. * * @param int $timeframe Timeframe to use. One of: last_week|last_month|last_quarter|last_6_months|last_year. * @param DateTime|null $current_date DateTime of current date to compare. * @return array */ public static function get_timeframe_dates( $timeframe, $current_date = null ) { if ( ! $current_date ) { $current_date = new \DateTime(); } $current_year = $current_date->format( 'Y' ); $current_month = $current_date->format( 'm' ); if ( 'last_week' === $timeframe ) { return array( 'start' => $current_date->modify( 'last week monday' )->format( 'Y-m-d 00:00:00' ), 'end' => $current_date->modify( 'this sunday' )->format( 'Y-m-d 23:59:59' ), ); } if ( 'last_month' === $timeframe ) { return array( 'start' => $current_date->modify( 'first day of previous month' )->format( 'Y-m-d 00:00:00' ), 'end' => $current_date->modify( 'last day of this month' )->format( 'Y-m-d 23:59:59' ), ); } if ( 'last_quarter' === $timeframe ) { switch ( $current_month ) { case $current_month >= 1 && $current_month <= 3: return array( 'start' => ( $current_year - 1 ) . '-10-01 00:00:00', 'end' => ( $current_year - 1 ) . '-12-31 23:59:59', ); case $current_month >= 4 && $current_month <= 6: return array( 'start' => $current_year . '-01-01 00:00:00', 'end' => $current_year . '-03-31 23:59:59', ); case $current_month >= 7 && $current_month <= 9: return array( 'start' => $current_year . '-04-01 00:00:00', 'end' => $current_year . '-06-30 23:59:59', ); case $current_month >= 10 && $current_month <= 12: return array( 'start' => $current_year . '-07-01 00:00:00', 'end' => $current_year . '-09-31 23:59:59', ); } } if ( 'last_6_months' === $timeframe ) { if ( $current_month >= 1 && $current_month <= 6 ) { return array( 'start' => ( $current_year - 1 ) . '-07-01 00:00:00', 'end' => ( $current_year - 1 ) . '-12-31 23:59:59', ); } return array( 'start' => $current_year . '-01-01 00:00:00', 'end' => $current_year . '-06-30 23:59:59', ); } if ( 'last_year' === $timeframe ) { return array( 'start' => ( $current_year - 1 ) . '-01-01 00:00:00', 'end' => ( $current_year - 1 ) . '-12-31 23:59:59', ); } return false; } } API/Reports/StatsDataStoreTrait.php 0000777 00000011407 15252240713 0013250 0 ustar 00 <?php declare( strict_types = 1); namespace Automattic\WooCommerce\Admin\API\Reports; // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { exit; } use Automattic\WooCommerce\Admin\API\Reports\SqlQuery; /** * Trait to contain *stats-specific methods for data stores. * * It does preliminary intervals & page calculations * and prepares intervals & totals data structure by implementing the `get_noncached_data()` method. * So, this time, you'll need to prepare `get_noncached_stats_data()` which will be called only if * the requested page is within the date range. * * The trait also exposes the `initialize_queries()` method to initialize the interval and total queries. * * Example: * <pre><code class="language-php">class MyStatsDataStore extends DataStore implements DataStoreInterface { * // Use the trait. * use StatsDataStoreTrait; * // Provide all the necessary properties and methods for a regular DataStore. * // ... * /** * * Return your results with the help of the interval & total methods and queries. * * @return stdClass|WP_Error $data filled with your results. * */ * public function get_noncached_stats_data( $query_args, $params, &$data, $expected_interval_count ) { * $this->initialize_queries(); * // Do your magic ... * // ... with a help of things like: * $this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name ); * $this->total_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) ); * * $totals = $wpdb->get_results( * $this->total_query->get_query_statement(), * ARRAY_A * ); * * $intervals = $wpdb->get_results( * $this->interval_query->get_query_statement(), * ARRAY_A * ); * * $data->totals = (object) $this->cast_numbers( $totals[0] ); * $data->intervals = $intervals; * * if ( TimeInterval::intervals_missing( $expected_interval_count, $db_interval_count, $params['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) { * $this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data ); * $this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] ); * $this->remove_extra_records( $data, $query_args['page'], $params['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'], $query_args['order'] ); * } else { * $this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals ); * } * * return $data; * } * } * </code></pre> * * @see DataStore */ trait StatsDataStoreTrait { /** * Initialize query objects. */ protected function initialize_queries() { $this->clear_all_clauses(); unset( $this->subquery ); $table_name = self::get_db_table_name(); $this->total_query = new SqlQuery( $this->context . '_total' ); $this->total_query->add_sql_clause( 'from', $table_name ); $this->interval_query = new SqlQuery( $this->context . '_interval' ); $this->interval_query->add_sql_clause( 'from', $table_name ); $this->interval_query->add_sql_clause( 'group_by', 'time_interval' ); } /** * Returns the stats report data based on normalized parameters. * Prepares the basic intervals and object structure * Will be called by `get_data` if there is no data in cache. * Will call `get_noncached_stats_data` to fetch the actual data. * * @see get_data * @param array $query_args Query parameters. * @return stdClass|WP_Error Data object, or error. */ public function get_noncached_data( $query_args ) { $params = $this->get_limit_params( $query_args ); $expected_interval_count = TimeInterval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] ); $total_pages = (int) ceil( $expected_interval_count / $params['per_page'] ); // Default, empty data object. $data = (object) array( 'totals' => null, 'intervals' => array(), 'total' => $expected_interval_count, 'pages' => $total_pages, 'page_no' => (int) $query_args['page'], ); // If the requested page is out off range, return the default empty object. if ( $query_args['page'] >= 1 && $query_args['page'] <= $total_pages ) { // Fetch the actual data. $data = $this->get_noncached_stats_data( $query_args, $params, $data, $expected_interval_count ); if ( ! is_wp_error( $data ) && is_array( $data->intervals ) ) { $this->create_interval_subtotals( $data->intervals ); } } return $data; } } API/Reports/Segmenter.php 0000777 00000062656 15252240713 0011304 0 ustar 00 <?php /** * Class for adding segmenting support without cluttering the data stores. */ namespace Automattic\WooCommerce\Admin\API\Reports; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore as CouponsDataStore; use Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\DataStore as TaxesStatsDataStore; use Automattic\WooCommerce\Enums\ProductType; /** * Date & time interval and numeric range handling class for Reporting API. */ class Segmenter { /** * Array of all segment ids. * * @var array|bool */ protected $all_segment_ids = false; /** * Array of all segment labels. * * @var array */ protected $segment_labels = array(); /** * Query arguments supplied by the user for data store. * * @var array */ protected $query_args = ''; /** * SQL definition for each column. * * @var array */ protected $report_columns = array(); /** * Constructor. * * @param array $query_args Query arguments supplied by the user for data store. * @param array $report_columns Report columns lookup from data store. */ public function __construct( $query_args, $report_columns ) { $this->query_args = $query_args; $this->report_columns = $report_columns; } /** * Filters definitions for SELECT clauses based on query_args and joins them into one string usable in SELECT clause. * * @param array $columns_mapping Column name -> SQL statememt mapping. * * @return string to be used in SELECT clause statements. */ protected function prepare_selections( $columns_mapping ) { if ( isset( $this->query_args['fields'] ) && is_array( $this->query_args['fields'] ) ) { $keep = array(); foreach ( $this->query_args['fields'] as $field ) { if ( isset( $columns_mapping[ $field ] ) ) { $keep[ $field ] = $columns_mapping[ $field ]; } } $selections = implode( ', ', $keep ); } else { $selections = implode( ', ', $columns_mapping ); } if ( $selections ) { $selections = ',' . $selections; } return $selections; } /** * Update row-level db result for segments in 'totals' section to the format used for output. * * @param array $segments_db_result Results from the SQL db query for segmenting. * @param string $segment_dimension Name of column used for grouping the result. * * @return array Reformatted array. */ protected function reformat_totals_segments( $segments_db_result, $segment_dimension ) { $segment_result = array(); if ( strpos( $segment_dimension, '.' ) ) { $segment_dimension = substr( strstr( $segment_dimension, '.' ), 1 ); } $segment_labels = $this->get_segment_labels(); foreach ( $segments_db_result as $segment_data ) { $segment_id = $segment_data[ $segment_dimension ]; if ( ! isset( $segment_labels[ $segment_id ] ) ) { continue; } unset( $segment_data[ $segment_dimension ] ); $segment_datum = array( 'segment_id' => $segment_id, 'segment_label' => $segment_labels[ $segment_id ], 'subtotals' => $segment_data, ); $segment_result[ $segment_id ] = $segment_datum; } return $segment_result; } /** * Merges segmented results for totals response part. * * E.g. $r1 = array( * 0 => array( * 'product_id' => 3, * 'net_amount' => 15, * ), * ); * $r2 = array( * 0 => array( * 'product_id' => 3, * 'avg_order_value' => 25, * ), * ); * * $merged = array( * 3 => array( * 'segment_id' => 3, * 'subtotals' => array( * 'net_amount' => 15, * 'avg_order_value' => 25, * ) * ), * ); * * @param string $segment_dimension Name of the segment dimension=key in the result arrays used to match records from result sets. * @param array $result1 Array 1 of segmented figures. * @param array $result2 Array 2 of segmented figures. * * @return array */ protected function merge_segment_totals_results( $segment_dimension, $result1, $result2 ) { $result_segments = array(); $segment_labels = $this->get_segment_labels(); foreach ( $result1 as $segment_data ) { $segment_id = $segment_data[ $segment_dimension ]; if ( ! isset( $segment_labels[ $segment_id ] ) ) { continue; } unset( $segment_data[ $segment_dimension ] ); $result_segments[ $segment_id ] = array( 'segment_label' => $segment_labels[ $segment_id ], 'segment_id' => $segment_id, 'subtotals' => $segment_data, ); } foreach ( $result2 as $segment_data ) { $segment_id = $segment_data[ $segment_dimension ]; if ( ! isset( $segment_labels[ $segment_id ] ) ) { continue; } unset( $segment_data[ $segment_dimension ] ); if ( ! isset( $result_segments[ $segment_id ] ) ) { $result_segments[ $segment_id ] = array( 'segment_label' => $segment_labels[ $segment_id ], 'segment_id' => $segment_id, 'subtotals' => array(), ); } $result_segments[ $segment_id ]['subtotals'] = array_merge( $result_segments[ $segment_id ]['subtotals'], $segment_data ); } return $result_segments; } /** * Merges segmented results for intervals response part. * * E.g. $r1 = array( * 0 => array( * 'product_id' => 3, * 'time_interval' => '2018-12' * 'net_amount' => 15, * ), * ); * $r2 = array( * 0 => array( * 'product_id' => 3, * 'time_interval' => '2018-12' * 'avg_order_value' => 25, * ), * ); * * $merged = array( * '2018-12' => array( * 'segments' => array( * 3 => array( * 'segment_id' => 3, * 'subtotals' => array( * 'net_amount' => 15, * 'avg_order_value' => 25, * ), * ), * ), * ), * ); * * @param string $segment_dimension Name of the segment dimension=key in the result arrays used to match records from result sets. * @param array $result1 Array 1 of segmented figures. * @param array $result2 Array 2 of segmented figures. * * @return array */ protected function merge_segment_intervals_results( $segment_dimension, $result1, $result2 ) { $result_segments = array(); $segment_labels = $this->get_segment_labels(); foreach ( $result1 as $segment_data ) { $segment_id = $segment_data[ $segment_dimension ]; if ( ! isset( $segment_labels[ $segment_id ] ) ) { continue; } $time_interval = $segment_data['time_interval']; if ( ! isset( $result_segments[ $time_interval ] ) ) { $result_segments[ $time_interval ] = array(); $result_segments[ $time_interval ]['segments'] = array(); } unset( $segment_data['time_interval'] ); unset( $segment_data['datetime_anchor'] ); unset( $segment_data[ $segment_dimension ] ); $segment_datum = array( 'segment_label' => $segment_labels[ $segment_id ], 'segment_id' => $segment_id, 'subtotals' => $segment_data, ); $result_segments[ $time_interval ]['segments'][ $segment_id ] = $segment_datum; } foreach ( $result2 as $segment_data ) { $segment_id = $segment_data[ $segment_dimension ]; if ( ! isset( $segment_labels[ $segment_id ] ) ) { continue; } $time_interval = $segment_data['time_interval']; if ( ! isset( $result_segments[ $time_interval ] ) ) { $result_segments[ $time_interval ] = array(); $result_segments[ $time_interval ]['segments'] = array(); } unset( $segment_data['time_interval'] ); unset( $segment_data['datetime_anchor'] ); unset( $segment_data[ $segment_dimension ] ); if ( ! isset( $result_segments[ $time_interval ]['segments'][ $segment_id ] ) ) { $result_segments[ $time_interval ]['segments'][ $segment_id ] = array( 'segment_label' => $segment_labels[ $segment_id ], 'segment_id' => $segment_id, 'subtotals' => array(), ); } $result_segments[ $time_interval ]['segments'][ $segment_id ]['subtotals'] = array_merge( $result_segments[ $time_interval ]['segments'][ $segment_id ]['subtotals'], $segment_data ); } return $result_segments; } /** * Update row-level db result for segments in 'intervals' section to the format used for output. * * @param array $segments_db_result Results from the SQL db query for segmenting. * @param string $segment_dimension Name of column used for grouping the result. * * @return array Reformatted array. */ protected function reformat_intervals_segments( $segments_db_result, $segment_dimension ) { $aggregated_segment_result = array(); if ( strpos( $segment_dimension, '.' ) ) { $segment_dimension = substr( strstr( $segment_dimension, '.' ), 1 ); } $segment_labels = $this->get_segment_labels(); foreach ( $segments_db_result as $segment_data ) { $segment_id = $segment_data[ $segment_dimension ]; if ( ! isset( $segment_labels[ $segment_id ] ) ) { continue; } $time_interval = $segment_data['time_interval']; if ( ! isset( $aggregated_segment_result[ $time_interval ] ) ) { $aggregated_segment_result[ $time_interval ] = array(); $aggregated_segment_result[ $time_interval ]['segments'] = array(); } unset( $segment_data['time_interval'] ); unset( $segment_data['datetime_anchor'] ); unset( $segment_data[ $segment_dimension ] ); $segment_datum = array( 'segment_label' => $segment_labels[ $segment_id ], 'segment_id' => $segment_id, 'subtotals' => $segment_data, ); $aggregated_segment_result[ $time_interval ]['segments'][ $segment_id ] = $segment_datum; } return $aggregated_segment_result; } /** * Fetches all segment ids from db and stores it for later use. * * @return void */ protected function set_all_segments() { global $wpdb; if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) { $this->all_segment_ids = array(); return; } $segments = array(); $segment_labels = array(); if ( 'product' === $this->query_args['segmentby'] ) { $args = array( 'return' => 'objects', 'limit' => -1, ); if ( isset( $this->query_args['product_includes'] ) ) { $args['include'] = $this->query_args['product_includes']; } if ( isset( $this->query_args['category_includes'] ) ) { $categories = $this->query_args['category_includes']; $args['category'] = array(); foreach ( $categories as $category_id ) { $terms = get_term_by( 'id', $category_id, 'product_cat' ); $args['category'][] = $terms->slug; } } $segment_objects = wc_get_products( $args ); foreach ( $segment_objects as $segment ) { $id = $segment->get_id(); $segments[] = $id; $segment_labels[ $id ] = $segment->get_name(); } } elseif ( 'variation' === $this->query_args['segmentby'] ) { $args = array( 'return' => 'objects', 'limit' => -1, 'type' => ProductType::VARIATION, ); if ( isset( $this->query_args['product_includes'] ) && is_array( $this->query_args['product_includes'] ) && count( $this->query_args['product_includes'] ) === 1 ) { $args['parent'] = $this->query_args['product_includes'][0]; } if ( isset( $this->query_args['variation_includes'] ) ) { $args['include'] = $this->query_args['variation_includes']; } $segment_objects = wc_get_products( $args ); foreach ( $segment_objects as $segment ) { $id = $segment->get_id(); $segments[] = $id; $product_name = $segment->get_name(); $separator = apply_filters( 'woocommerce_product_variation_title_attributes_separator', ' - ', $segment ); $attributes = wc_get_formatted_variation( $segment, true, false ); $segment_labels[ $id ] = $product_name . $separator . $attributes; } // If no variations were specified, add a segment for the parent product (variation = 0). // This is to catch simple products with prior sales converted into variable products. // See: https://github.com/woocommerce/woocommerce-admin/issues/2719. if ( isset( $args['parent'] ) && empty( $args['include'] ) ) { $parent_object = wc_get_product( $args['parent'] ); $segments[] = 0; $segment_labels[0] = $parent_object->get_name(); } } elseif ( 'category' === $this->query_args['segmentby'] ) { $args = array( 'taxonomy' => 'product_cat', ); if ( isset( $this->query_args['category_includes'] ) ) { $args['include'] = $this->query_args['category_includes']; } // @todo: Look into `wc_get_products` or data store methods and not directly touching the database or post types. $categories = get_categories( $args ); $segments = wp_list_pluck( $categories, 'cat_ID' ); $segment_labels = wp_list_pluck( $categories, 'name', 'cat_ID' ); } elseif ( 'coupon' === $this->query_args['segmentby'] ) { $args = array(); if ( isset( $this->query_args['coupons'] ) ) { $args['include'] = $this->query_args['coupons']; } $coupons_store = new CouponsDataStore(); $coupons = $coupons_store->get_coupons( $args ); $segments = wp_list_pluck( $coupons, 'ID' ); $segment_labels = wp_list_pluck( $coupons, 'post_title', 'ID' ); $segment_labels = array_map( 'wc_format_coupon_code', $segment_labels ); } elseif ( 'customer_type' === $this->query_args['segmentby'] ) { // 0 -- new customer // 1 -- returning customer $segments = array( 0, 1 ); } elseif ( 'tax_rate_id' === $this->query_args['segmentby'] ) { $args = array(); if ( isset( $this->query_args['taxes'] ) ) { $args['include'] = $this->query_args['taxes']; } $taxes = TaxesStatsDataStore::get_taxes( $args ); foreach ( $taxes as $tax ) { $id = $tax['tax_rate_id']; $segments[] = $id; $segment_labels[ $id ] = \WC_Tax::get_rate_code( (object) $tax ); } } else { // Catch all default. $segments = array(); } $this->all_segment_ids = $segments; $this->segment_labels = $segment_labels; } /** * Return all segment ids for given segmentby query parameter. * * @return array */ protected function get_all_segments() { if ( ! is_array( $this->all_segment_ids ) ) { $this->set_all_segments(); } return $this->all_segment_ids; } /** * Return all segment labels for given segmentby query parameter. * * @return array */ protected function get_segment_labels() { if ( ! is_array( $this->all_segment_ids ) ) { $this->set_all_segments(); } return $this->segment_labels; } /** * Compares two report data objects by pre-defined object property and ASC/DESC ordering. * * @param stdClass $a Object a. * @param stdClass $b Object b. * @return string */ private function segment_cmp( $a, $b ) { if ( $a['segment_id'] === $b['segment_id'] ) { return 0; } elseif ( $a['segment_id'] > $b['segment_id'] ) { return 1; } elseif ( $a['segment_id'] < $b['segment_id'] ) { return - 1; } } /** * Adds zeroes for segments not present in the data selection. * * @param array $segments Array of segments from the database for given data points. * * @return array */ protected function fill_in_missing_segments( $segments ) { $segment_subtotals = array(); if ( isset( $this->query_args['fields'] ) && is_array( $this->query_args['fields'] ) ) { foreach ( $this->query_args['fields'] as $field ) { if ( isset( $this->report_columns[ $field ] ) ) { $segment_subtotals[ $field ] = 0; } } } else { foreach ( $this->report_columns as $field => $sql_clause ) { $segment_subtotals[ $field ] = 0; } } if ( ! is_array( $segments ) ) { $segments = array(); } $all_segment_ids = $this->get_all_segments(); $segment_labels = $this->get_segment_labels(); foreach ( $all_segment_ids as $segment_id ) { if ( ! isset( $segments[ $segment_id ] ) ) { $segments[ $segment_id ] = array( 'segment_id' => $segment_id, 'segment_label' => $segment_labels[ $segment_id ], 'subtotals' => $segment_subtotals, ); } } // Using array_values to remove custom keys, so that it gets later converted to JSON as an array. $segments_no_keys = array_values( $segments ); usort( $segments_no_keys, array( $this, 'segment_cmp' ) ); return $segments_no_keys; } /** * Adds missing segments to intervals, modifies $data. * * @param stdClass $data Response data. */ protected function fill_in_missing_interval_segments( &$data ) { foreach ( $data->intervals as $order_id => $interval_data ) { $data->intervals[ $order_id ]['segments'] = $this->fill_in_missing_segments( $data->intervals[ $order_id ]['segments'] ); } } /** * Calculate segments for totals where the segmenting property is bound to product (e.g. category, product_id, variation_id). * * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $segmenting_dimension_name Name of the segmenting dimension. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $totals_query Array of SQL clauses for totals query. * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. * * @return array */ protected function get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $totals_query, $unique_orders_table ) { return array(); } /** * Calculate segments for intervals where the segmenting property is bound to product (e.g. category, product_id, variation_id). * * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $segmenting_dimension_name Name of the segmenting dimension. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $intervals_query Array of SQL clauses for intervals query. * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. * * @return array */ protected function get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $intervals_query, $unique_orders_table ) { return array(); } /** * Calculate segments for totals query where the segmenting property is bound to order (e.g. coupon or customer type). * * @param string $segmenting_select SELECT part of segmenting SQL query. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $totals_query Array of SQL clauses for intervals query. * * @return array */ protected function get_order_related_totals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $totals_query ) { return array(); } /** * Calculate segments for intervals query where the segmenting property is bound to order (e.g. coupon or customer type). * * @param string $segmenting_select SELECT part of segmenting SQL query. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $intervals_query Array of SQL clauses for intervals query. * * @return array */ protected function get_order_related_intervals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $intervals_query ) { return array(); } /** * Return array of segments formatted for REST response. * * @param string $type Type of segments to return--'totals' or 'intervals'. * @param array $query_params SQL query parameter array. * @param string $table_name Name of main SQL table for the data store (used as basis for JOINS). * * @return array */ protected function get_segments( $type, $query_params, $table_name ) { return array(); } /** * Calculate segments for segmenting property bound to product (e.g. category, product_id, variation_id). * * @param string $type Type of segments to return--'totals' or 'intervals'. * @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $segmenting_dimension_name Name of the segmenting dimension. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $query_params Array of SQL clauses for intervals/totals query. * @param string $unique_orders_table Name of temporary SQL table that holds unique orders. * * @return array */ protected function get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ) { if ( 'totals' === $type ) { return $this->get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } elseif ( 'intervals' === $type ) { return $this->get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ); } } /** * Calculate segments for segmenting property bound to order (e.g. coupon or customer type). * * @param string $type Type of segments to return--'totals' or 'intervals'. * @param string $segmenting_select SELECT part of segmenting SQL query. * @param string $segmenting_from FROM part of segmenting SQL query. * @param string $segmenting_where WHERE part of segmenting SQL query. * @param string $segmenting_groupby GROUP BY part of segmenting SQL query. * @param string $table_name Name of SQL table which is the stats table for orders. * @param array $query_params Array of SQL clauses for intervals/totals query. * * @return array */ protected function get_order_related_segments( $type, $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params ) { if ( 'totals' === $type ) { return $this->get_order_related_totals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params ); } elseif ( 'intervals' === $type ) { return $this->get_order_related_intervals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params ); } } /** * Assign segments to time intervals by updating original $intervals array. * * @param array $intervals Result array from intervals SQL query. * @param array $intervals_segments Result array from interval segments SQL query. */ protected function assign_segments_to_intervals( &$intervals, $intervals_segments ) { $old_keys = array_keys( $intervals ); foreach ( $intervals as $interval ) { $intervals[ $interval['time_interval'] ] = $interval; $intervals[ $interval['time_interval'] ]['segments'] = array(); } foreach ( $old_keys as $key ) { unset( $intervals[ $key ] ); } foreach ( $intervals_segments as $time_interval => $segment ) { if ( isset( $intervals[ $time_interval ] ) ) { $intervals[ $time_interval ]['segments'] = $segment['segments']; } } // To remove time interval keys (so that REST response is formatted correctly). $intervals = array_values( $intervals ); } /** * Returns an array of segments for totals part of REST response. * * @param array $query_params Totals SQL query parameters. * @param string $table_name Name of the SQL table that is the main order stats table. * * @return array */ public function get_totals_segments( $query_params, $table_name ) { $segments = $this->get_segments( 'totals', $query_params, $table_name ); $segments = $this->fill_in_missing_segments( $segments ); return $segments; } /** * Adds an array of segments to data->intervals object. * * @param stdClass $data Data object representing the REST response. * @param array $intervals_query Intervals SQL query parameters. * @param string $table_name Name of the SQL table that is the main order stats table. */ public function add_intervals_segments( &$data, $intervals_query, $table_name ) { $intervals_segments = $this->get_segments( 'intervals', $intervals_query, $table_name ); $this->assign_segments_to_intervals( $data->intervals, $intervals_segments ); $this->fill_in_missing_interval_segments( $data ); } } API/Reports/Cache.php 0000777 00000002753 15252240713 0010346 0 ustar 00 <?php /** * REST API Reports Cache. * * Handles report data object caching. */ namespace Automattic\WooCommerce\Admin\API\Reports; defined( 'ABSPATH' ) || exit; /** * REST API Reports Cache class. */ class Cache { /** * Cache version. Used to invalidate all cached values. */ const VERSION_OPTION = 'woocommerce_reports'; /** * Invalidate cache. */ public static function invalidate() { \WC_Cache_Helper::get_transient_version( self::VERSION_OPTION, true ); } /** * Get cache version number. * * @return string */ public static function get_version() { $version = \WC_Cache_Helper::get_transient_version( self::VERSION_OPTION ); return $version; } /** * Get cached value. * * @param string $key Cache key. * @return mixed */ public static function get( $key ) { $transient_version = self::get_version(); $transient_value = get_transient( $key ); if ( isset( $transient_value['value'], $transient_value['version'] ) && $transient_value['version'] === $transient_version ) { return $transient_value['value']; } return false; } /** * Update cached value. * * @param string $key Cache key. * @param mixed $value New value. * @return bool */ public static function set( $key, $value ) { $transient_version = self::get_version(); $transient_value = array( 'version' => $transient_version, 'value' => $value, ); $result = set_transient( $key, $transient_value, WEEK_IN_SECONDS ); return $result; } } API/Plugins.php 0000777 00000052341 15252240713 0007324 0 ustar 00 <?php /** * REST API Plugins Controller * * Handles requests to install and activate dependent plugins. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile; use Automattic\WooCommerce\Admin\PluginsHelper; defined( 'ABSPATH' ) || exit; /** * Plugins Controller. * * @internal * @extends \WC_REST_Data_Controller */ class Plugins extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'plugins'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/install', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'install_plugins' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/install/status', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_installation_status' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/install/status/(?P<job_id>[a-z0-9_\-]+)', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_job_installation_status' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/active', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'active_plugins' ), 'permission_callback' => array( $this, 'get_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/installed', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'installed_plugins' ), 'permission_callback' => array( $this, 'get_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/activate', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'activate_plugins' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/activate/status', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_activation_status' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/activate/status/(?P<job_id>[a-z0-9_\-]+)', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_job_activation_status' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/connect-jetpack', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'connect_jetpack' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_connect_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/request-wccom-connect', array( array( 'methods' => 'POST', 'callback' => array( $this, 'request_wccom_connect' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_connect_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/finish-wccom-connect', array( array( 'methods' => 'POST', 'callback' => array( $this, 'finish_wccom_connect' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_connect_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/connect-wcpay', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'connect_wcpay' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_connect_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/connect-square', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'connect_square' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_connect_schema' ), ) ); } /** * Check if a given request has access to manage plugins. * * @param \WP_REST_Request $request Full details about the request. * @return \WP_Error|boolean */ public function update_item_permissions_check( $request ) { if ( ! current_user_can( 'install_plugins' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Install the requested plugin. * * @param \WP_REST_Request $request Full details about the request. * @return \WP_Error|array Plugin Status */ public function install_plugin( $request ) { wc_deprecated_function( 'install_plugin', '4.3', '\Automattic\WooCommerce\Admin\API\Plugins()->install_plugins' ); // This method expects a `plugin` argument to be sent, install plugins requires plugins. $request['plugins'] = $request['plugin']; return self::install_plugins( $request ); } /** * Installs the requested plugins. * * @param \WP_REST_Request $request Full details about the request. * @return \WP_Error|array Plugin Status */ public function install_plugins( $request ) { $plugins = explode( ',', $request['plugins'] ); $source = ! empty( $request['source'] ) ? $request['source'] : null; if ( empty( $request['plugins'] ) || ! is_array( $plugins ) ) { return new \WP_Error( 'woocommerce_rest_invalid_plugins', __( 'Plugins must be a non-empty array.', 'woocommerce' ), 404 ); } if ( isset( $request['async'] ) && $request['async'] ) { $job_id = PluginsHelper::schedule_install_plugins( $plugins ); return array( 'data' => array( 'job_id' => $job_id, 'plugins' => $plugins, ), 'message' => __( 'Plugin installation has been scheduled.', 'woocommerce' ), ); } $data = PluginsHelper::install_plugins( $plugins, null, $source ); // Gather some plugin details for each installed plugin. $plugin_details = array(); if ( is_array( $data['installed'] ) ) { foreach ( $data['installed'] as $plugin_slug ) { $plugin_data = PluginsHelper::get_plugin_data( $plugin_slug ); if ( empty( $plugin_data ) ) { continue; } $plugin_details[ $plugin_slug ] = array( 'name' => $plugin_data['Name'], 'description' => $plugin_data['Description'], 'uri' => $plugin_data['PluginURI'], 'version' => $plugin_data['Version'], ); } } return array( 'data' => array( 'installed' => $data['installed'], 'results' => $data['results'], 'install_time' => $data['time'], 'plugin_details' => $plugin_details, ), 'errors' => $data['errors'], 'success' => count( $data['errors']->errors ) === 0, 'message' => count( $data['errors']->errors ) === 0 ? __( 'Plugins were successfully installed.', 'woocommerce' ) : __( 'There was a problem installing some of the requested plugins.', 'woocommerce' ), ); } /** * Returns a list of recently scheduled installation jobs. * * @param \WP_REST_Request $request Full details about the request. * @return array Jobs. */ public function get_installation_status( $request ) { return PluginsHelper::get_installation_status(); } /** * Returns a list of recently scheduled installation jobs. * * @param \WP_REST_Request $request Full details about the request. * @return array Job. */ public function get_job_installation_status( $request ) { $job_id = $request->get_param( 'job_id' ); $jobs = PluginsHelper::get_installation_status( $job_id ); return reset( $jobs ); } /** * Returns a list of active plugins in API format. * * @return array Active plugins */ public static function active_plugins() { return( array( 'plugins' => array_values( PluginsHelper::get_active_plugin_slugs() ), ) ); } /** * Returns a list of active plugins. * * @internal * @return array Active plugins */ public static function get_active_plugins() { $data = self::active_plugins(); return $data['plugins']; } /** * Returns a list of installed plugins. * * @return array Installed plugins */ public function installed_plugins() { return( array( 'plugins' => PluginsHelper::get_installed_plugin_slugs(), ) ); } /** * Activate the requested plugin. * * @param \WP_REST_Request $request Full details about the request. * @return \WP_Error|array Plugin Status */ public function activate_plugins( $request ) { $plugins = explode( ',', $request['plugins'] ); if ( empty( $request['plugins'] ) || ! is_array( $plugins ) ) { return new \WP_Error( 'woocommerce_rest_invalid_plugins', __( 'Plugins must be a non-empty array.', 'woocommerce' ), 404 ); } if ( isset( $request['async'] ) && $request['async'] ) { $job_id = PluginsHelper::schedule_activate_plugins( $plugins ); return array( 'data' => array( 'job_id' => $job_id, 'plugins' => $plugins, ), 'message' => __( 'Plugin activation has been scheduled.', 'woocommerce' ), ); } $data = PluginsHelper::activate_plugins( $plugins ); // Gather some plugin details for each activated plugin. $plugin_details = array(); if ( is_array( $data['activated'] ) ) { foreach ( $data['activated'] as $plugin_slug ) { $plugin_data = PluginsHelper::get_plugin_data( $plugin_slug ); if ( empty( $plugin_data ) ) { continue; } $plugin_details[ $plugin_slug ] = array( 'name' => $plugin_data['Name'], 'description' => $plugin_data['Description'], 'uri' => $plugin_data['PluginURI'], 'version' => $plugin_data['Version'], ); } } return ( array( 'data' => array( 'activated' => $data['activated'], 'active' => $data['active'], 'plugin_details' => $plugin_details, ), 'errors' => $data['errors'], 'success' => count( $data['errors']->errors ) === 0, 'message' => count( $data['errors']->errors ) === 0 ? __( 'Plugins were successfully activated.', 'woocommerce' ) : __( 'There was a problem activating some of the requested plugins.', 'woocommerce' ), ) ); } /** * Returns a list of recently scheduled activation jobs. * * @param \WP_REST_Request $request Full details about the request. * @return array Job. */ public function get_activation_status( $request ) { return PluginsHelper::get_activation_status(); } /** * Returns a list of recently scheduled activation jobs. * * @param \WP_REST_Request $request Full details about the request. * @return array Jobs. */ public function get_job_activation_status( $request ) { $job_id = $request->get_param( 'job_id' ); $jobs = PluginsHelper::get_activation_status( $job_id ); return reset( $jobs ); } /** * Generates a Jetpack Connect URL. * * @param \WP_REST_Request $request Full details about the request. * @return \WP_Error|array Connection URL for Jetpack */ public function connect_jetpack( $request ) { if ( ! class_exists( '\Jetpack' ) ) { return new \WP_Error( 'woocommerce_rest_jetpack_not_active', __( 'Jetpack is not installed or active.', 'woocommerce' ), 404 ); } // phpcs:disable WooCommerce.Commenting.CommentHooks.MissingHookComment $redirect_url = apply_filters( 'woocommerce_admin_onboarding_jetpack_connect_redirect_url', esc_url_raw( $request['redirect_url'] ) ); $connect_url = \Jetpack::init()->build_connect_url( true, $redirect_url, 'woocommerce-onboarding' ); $calypso_env = defined( 'WOOCOMMERCE_CALYPSO_ENVIRONMENT' ) && in_array( WOOCOMMERCE_CALYPSO_ENVIRONMENT, array( 'development', 'wpcalypso', 'horizon', 'stage' ), true ) ? WOOCOMMERCE_CALYPSO_ENVIRONMENT : 'production'; $connect_url = add_query_arg( array( 'calypso_env' => $calypso_env ), $connect_url ); return( array( 'slug' => 'jetpack', 'name' => __( 'Jetpack', 'woocommerce' ), 'connectAction' => $connect_url, ) ); } /** * Kicks off the WCCOM Connect process. * * @return \WP_Error|array Connection URL for WooCommerce.com */ public function request_wccom_connect() { include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-api.php'; if ( ! class_exists( 'WC_Helper_API' ) ) { return new \WP_Error( 'woocommerce_rest_helper_not_active', __( 'There was an error loading the WooCommerce.com Helper API.', 'woocommerce' ), 404 ); } $redirect_uri = wc_admin_url( '&task=connect&wccom-connected=1' ); $request = \WC_Helper_API::post( 'oauth/request_token', array( 'body' => array( 'home_url' => home_url(), 'redirect_uri' => $redirect_uri, ), ) ); $code = wp_remote_retrieve_response_code( $request ); if ( 200 !== $code ) { return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to WooCommerce.com. Please try again.', 'woocommerce' ), 500 ); } $secret = json_decode( wp_remote_retrieve_body( $request ) ); if ( empty( $secret ) ) { return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to WooCommerce.com. Please try again.', 'woocommerce' ), 500 ); } do_action( 'woocommerce_helper_connect_start' ); $connect_url = add_query_arg( array( 'home_url' => rawurlencode( home_url() ), 'redirect_uri' => rawurlencode( $redirect_uri ), 'secret' => rawurlencode( $secret ), 'wccom-from' => 'onboarding', ), \WC_Helper_API::url( 'oauth/authorize' ) ); if ( defined( 'WOOCOMMERCE_CALYPSO_ENVIRONMENT' ) && in_array( WOOCOMMERCE_CALYPSO_ENVIRONMENT, array( 'development', 'wpcalypso', 'horizon', 'stage' ), true ) ) { $connect_url = add_query_arg( array( 'calypso_env' => WOOCOMMERCE_CALYPSO_ENVIRONMENT, ), $connect_url ); } return( array( 'connectAction' => $connect_url, ) ); } /** * Finishes connecting to WooCommerce.com. * * @param object $rest_request Request details. * @return \WP_Error|array Contains success status. */ public function finish_wccom_connect( $rest_request ) { include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper.php'; include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-api.php'; include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-updater.php'; include_once WC_ABSPATH . 'includes/admin/helper/class-wc-helper-options.php'; if ( ! class_exists( 'WC_Helper_API' ) ) { return new \WP_Error( 'woocommerce_rest_helper_not_active', __( 'There was an error loading the WooCommerce.com Helper API.', 'woocommerce' ), 404 ); } // Obtain an access token. $request = \WC_Helper_API::post( 'oauth/access_token', array( 'body' => array( 'request_token' => wp_unslash( $rest_request['request_token'] ), // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized 'home_url' => home_url(), ), ) ); $code = wp_remote_retrieve_response_code( $request ); if ( 200 !== $code ) { return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to WooCommerce.com. Please try again.', 'woocommerce' ), 500 ); } $access_token = json_decode( wp_remote_retrieve_body( $request ), true ); if ( ! $access_token ) { return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to WooCommerce.com. Please try again.', 'woocommerce' ), 500 ); } \WC_Helper_Options::update( 'auth', array( 'access_token' => $access_token['access_token'], 'access_token_secret' => $access_token['access_token_secret'], 'site_id' => $access_token['site_id'], 'user_id' => get_current_user_id(), 'updated' => time(), ) ); if ( ! \WC_Helper::_flush_authentication_cache() ) { \WC_Helper_Options::update( 'auth', array() ); return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to WooCommerce.com. Please try again.', 'woocommerce' ), 500 ); } delete_transient( '_woocommerce_helper_subscriptions' ); \WC_Helper_Updater::flush_updates_cache(); do_action( 'woocommerce_helper_connected' ); return array( 'success' => true, ); } /** * Returns a URL that can be used to connect to Square. * * @return \WP_Error|array Connect URL. */ public function connect_square() { if ( ! class_exists( '\WooCommerce\Square\Handlers\Connection' ) ) { return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error connecting to Square.', 'woocommerce' ), 500 ); } $has_cbd_industry = false; if ( 'US' === WC()->countries->get_base_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 ( $has_cbd_industry ) { $url = 'https://squareup.com/t/f_partnerships/d_referrals/p_woocommerce/c_general/o_none/l_us/dt_alldevice/pr_payments/?route=/solutions/cbd'; } else { $url = \WooCommerce\Square\Handlers\Connection::CONNECT_URL_PRODUCTION; } $redirect_url = wp_nonce_url( wc_admin_url( '&task=payments&method=square&square-connect-finish=1' ), 'wc_square_connected' ); $args = array( 'redirect' => rawurlencode( rawurlencode( $redirect_url ) ), 'scopes' => implode( ',', array( 'MERCHANT_PROFILE_READ', 'PAYMENTS_READ', 'PAYMENTS_WRITE', 'ORDERS_READ', 'ORDERS_WRITE', 'CUSTOMERS_READ', 'CUSTOMERS_WRITE', 'SETTLEMENTS_READ', 'ITEMS_READ', 'ITEMS_WRITE', 'INVENTORY_READ', 'INVENTORY_WRITE', ) ), ); $connect_url = add_query_arg( $args, $url ); return( array( 'connectUrl' => $connect_url, ) ); } /** * Returns a URL that can be used to point the merchant to the WooPayments onboarding flow. * * @return \WP_Error|array Connect URL. */ public function connect_wcpay() { if ( ! class_exists( 'WC_Payments' ) ) { return new \WP_Error( 'woocommerce_rest_helper_connect', __( 'There was an error communicating with the WooPayments plugin.', 'woocommerce' ), 500 ); } // Use a WooPayments connect link to let the WooPayments plugin handle the connection flow. return array( 'connectUrl' => add_query_arg( array( 'wcpay-connect' => '1', 'from' => 'WCADMIN_PAYMENT_TASK', '_wpnonce' => wp_create_nonce( 'wcpay-connect' ), ), admin_url( 'admin.php' ) ), ); } /** * Get the schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'plugins', 'type' => 'object', 'properties' => array( 'slug' => array( 'description' => __( 'Plugin slug.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'name' => array( 'description' => __( 'Plugin name.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'status' => array( 'description' => __( 'Plugin status.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the schema, conforming to JSON Schema. * * @return array */ public function get_connect_schema() { $schema = $this->get_item_schema(); unset( $schema['properties']['status'] ); $schema['properties']['connectAction'] = array( 'description' => __( 'Action that should be completed to connect Jetpack.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ); return $schema; } } API/MarketingOverview.php 0000777 00000006563 15252240713 0011360 0 ustar 00 <?php /** * REST API Marketing Overview Controller * * Handles requests to /marketing/overview. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Admin\Marketing\InstalledExtensions; use Automattic\WooCommerce\Admin\PluginsHelper; defined( 'ABSPATH' ) || exit; /** * Marketing Overview Controller. * * @internal * @extends WC_REST_Data_Controller */ class MarketingOverview extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'marketing/overview'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/activate-plugin', array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'activate_plugin' ), 'permission_callback' => array( $this, 'install_plugins_permissions_check' ), 'args' => array( 'plugin' => array( 'required' => true, 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'sanitize_title_with_dashes', ), ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/installed-plugins', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_installed_plugins' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Return installed marketing extensions data. * * @param \WP_REST_Request $request Request data. * * @return \WP_Error|\WP_REST_Response */ public function activate_plugin( $request ) { $plugin_slug = $request->get_param( 'plugin' ); if ( ! PluginsHelper::is_plugin_installed( $plugin_slug ) ) { return new \WP_Error( 'woocommerce_rest_invalid_plugin', __( 'Invalid plugin.', 'woocommerce' ), 404 ); } $result = activate_plugin( PluginsHelper::get_plugin_path_from_slug( $plugin_slug ) ); if ( ! is_null( $result ) ) { return new \WP_Error( 'woocommerce_rest_invalid_plugin', __( 'The plugin could not be activated.', 'woocommerce' ), 500 ); } // IMPORTANT - Don't return the active plugins data here. // Instead we will get that data in a separate request to ensure they are loaded. return rest_ensure_response( array( 'status' => 'success', ) ); } /** * Check if a given request has access to manage plugins. * * @param \WP_REST_Request $request Full details about the request. * * @return \WP_Error|boolean */ public function install_plugins_permissions_check( $request ) { if ( ! current_user_can( 'install_plugins' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Return installed marketing extensions data. * * @param \WP_REST_Request $request Request data. * * @return \WP_Error|\WP_REST_Response */ public function get_installed_plugins( $request ) { return rest_ensure_response( InstalledExtensions::get_data() ); } } API/Products.php 0000777 00000023353 15252240713 0007507 0 ustar 00 <?php /** * REST API Products Controller * * Handles requests to /products/* */ declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Products controller. * * @internal * @extends WC_REST_Products_Controller */ class Products extends \WC_REST_Products_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Local cache of last order dates by ID. * * @var array */ protected $last_order_dates = array(); /** * Adds properties that can be embed via ?_embed=1. * * @return array */ public function get_item_schema() { $schema = parent::get_item_schema(); $properties_to_embed = array( 'id', 'name', 'slug', 'permalink', 'images', 'description', 'short_description', ); foreach ( $properties_to_embed as $property ) { $schema['properties'][ $property ]['context'][] = 'embed'; } $schema['properties']['last_order_date'] = array( 'description' => __( "The date the last order for this product was placed, in the site's timezone.", 'woocommerce' ), 'type' => 'date-time', 'context' => array( 'view', 'edit' ), 'readonly' => true, ); return $schema; } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['low_in_stock'] = array( 'description' => __( 'Limit result set to products that are low or out of stock. (Deprecated)', 'woocommerce' ), 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'wc_string_to_bool', ); $params['search'] = array( 'description' => __( 'Search by similar product name or sku.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Add product name and sku filtering to the WC API. * * @param WP_REST_Request $request Request data. * @return array */ protected function prepare_objects_query( $request ) { $args = parent::prepare_objects_query( $request ); if ( ! empty( $request['search'] ) ) { $args['search'] = trim( $request['search'] ); unset( $args['s'] ); } if ( ! empty( $request['low_in_stock'] ) ) { $args['low_in_stock'] = $request['low_in_stock']; $args['post_type'] = array( 'product', 'product_variation' ); } return $args; } /** * Get a collection of posts and add the post title filter option to WP_Query. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { add_filter( 'posts_fields', array( __CLASS__, 'add_wp_query_fields' ), 10, 2 ); add_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10, 2 ); add_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10, 2 ); add_filter( 'posts_groupby', array( __CLASS__, 'add_wp_query_group_by' ), 10, 2 ); $response = parent::get_items( $request ); remove_filter( 'posts_fields', array( __CLASS__, 'add_wp_query_fields' ), 10 ); remove_filter( 'posts_where', array( __CLASS__, 'add_wp_query_filter' ), 10 ); remove_filter( 'posts_join', array( __CLASS__, 'add_wp_query_join' ), 10 ); remove_filter( 'posts_groupby', array( __CLASS__, 'add_wp_query_group_by' ), 10 ); /** * The low stock query caused performance issues in WooCommerce 5.5.1 * due to a) being slow, and b) multiple requests being made to this endpoint * from WC Admin. * * This is a temporary measure to trigger the user’s browser to cache the * endpoint response for 1 minute, limiting the amount of requests overall. * * https://github.com/woocommerce/woocommerce-admin/issues/7358 */ if ( $this->is_low_in_stock_request( $request ) ) { $response->header( 'Cache-Control', 'max-age=300' ); } return $response; } /** * Check whether the request is for products low in stock. * * It matches requests with parameters: * * low_in_stock = true * page = 1 * fields[0] = id * * @param string $request WP REST API request. * @return boolean Whether the request matches. */ private function is_low_in_stock_request( $request ) { if ( $request->get_param( 'low_in_stock' ) === true && $request->get_param( 'page' ) === 1 && is_array( $request->get_param( '_fields' ) ) && count( $request->get_param( '_fields' ) ) === 1 && in_array( 'id', $request->get_param( '_fields' ), true ) ) { return true; } return false; } /** * Hang onto last order date since it will get removed by wc_get_product(). * * @param stdClass $object_data Single row from query results. * @return WC_Data */ public function get_object( $object_data ) { if ( isset( $object_data->last_order_date ) ) { $this->last_order_dates[ $object_data->ID ] = $object_data->last_order_date; } return parent::get_object( $object_data ); } /** * Add `low_stock_amount` property to product data * * @param WC_Data $object Object data. * @param WP_REST_Request $request Request object. * @return WP_REST_Response */ public function prepare_object_for_response( $object, $request ) { $data = parent::prepare_object_for_response( $object, $request ); $object_data = $object->get_data(); $product_id = $object_data['id']; if ( $request->get_param( 'low_in_stock' ) ) { if ( is_numeric( $object_data['low_stock_amount'] ) ) { $data->data['low_stock_amount'] = $object_data['low_stock_amount']; } if ( isset( $this->last_order_dates[ $product_id ] ) ) { $data->data['last_order_date'] = wc_rest_prepare_date_response( $this->last_order_dates[ $product_id ] ); } } if ( isset( $data->data['name'] ) ) { $data->data['name'] = wp_strip_all_tags( $data->data['name'] ); } return $data; } /** * Add in conditional select fields to the query. * * @internal * @param string $select Select clause used to select fields from the query. * @param object $wp_query WP_Query object. * @return string */ public static function add_wp_query_fields( $select, $wp_query ) { if ( $wp_query->get( 'low_in_stock' ) ) { $fields = array( 'low_stock_amount_meta.meta_value AS low_stock_amount', 'MAX( product_lookup.date_created ) AS last_order_date', ); $select .= ', ' . implode( ', ', $fields ); } return $select; } /** * Add in conditional search filters for products. * * @internal * @param string $where Where clause used to search posts. * @param object $wp_query WP_Query object. * @return string */ public static function add_wp_query_filter( $where, $wp_query ) { global $wpdb; $search = $wp_query->get( 'search' ); if ( $search ) { $title_like = '%' . $wpdb->esc_like( $search ) . '%'; $where .= $wpdb->prepare( " AND ({$wpdb->posts}.post_title LIKE %s", $title_like ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $where .= wc_product_sku_enabled() ? $wpdb->prepare( ' OR wc_product_meta_lookup.sku LIKE %s)', $search ) : ')'; } if ( $wp_query->get( 'low_in_stock' ) ) { $low_stock_amount = absint( max( get_option( 'woocommerce_notify_low_stock_amount' ), 1 ) ); $where .= " AND wc_product_meta_lookup.stock_quantity IS NOT NULL AND wc_product_meta_lookup.stock_status IN('instock','outofstock') AND ( ( low_stock_amount_meta.meta_value > '' AND wc_product_meta_lookup.stock_quantity <= CAST(low_stock_amount_meta.meta_value AS SIGNED) ) OR ( ( low_stock_amount_meta.meta_value IS NULL OR low_stock_amount_meta.meta_value <= '' ) AND wc_product_meta_lookup.stock_quantity <= {$low_stock_amount} ) )"; } return $where; } /** * Join posts meta tables when product search or low stock query is present. * * @internal * @param string $join Join clause used to search posts. * @param object $wp_query WP_Query object. * @return string */ public static function add_wp_query_join( $join, $wp_query ) { global $wpdb; $search = $wp_query->get( 'search' ); if ( $search && wc_product_sku_enabled() ) { $join = self::append_product_sorting_table_join( $join ); } if ( $wp_query->get( 'low_in_stock' ) ) { $product_lookup_table = $wpdb->prefix . 'wc_order_product_lookup'; $join = self::append_product_sorting_table_join( $join ); $join .= " LEFT JOIN {$wpdb->postmeta} AS low_stock_amount_meta ON {$wpdb->posts}.ID = low_stock_amount_meta.post_id AND low_stock_amount_meta.meta_key = '_low_stock_amount' "; $join .= " LEFT JOIN {$product_lookup_table} product_lookup ON {$wpdb->posts}.ID = CASE WHEN {$wpdb->posts}.post_type = 'product' THEN product_lookup.product_id WHEN {$wpdb->posts}.post_type = 'product_variation' THEN product_lookup.variation_id END"; } return $join; } /** * Join wc_product_meta_lookup to posts if not already joined. * * @internal * @param string $sql SQL join. * @return string */ protected static function append_product_sorting_table_join( $sql ) { 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; } /** * Group by post ID to prevent duplicates. * * @internal * @param string $groupby Group by clause used to organize posts. * @param object $wp_query WP_Query object. * @return string */ public static function add_wp_query_group_by( $groupby, $wp_query ) { global $wpdb; $search = $wp_query->get( 'search' ); $low_in_stock = $wp_query->get( 'low_in_stock' ); if ( empty( $groupby ) && ( $search || $low_in_stock ) ) { $groupby = $wpdb->posts . '.ID'; } return $groupby; } } API/ProductAttributeTerms.php 0000777 00000010563 15252240713 0012222 0 ustar 00 <?php /** * REST API Product Attribute Terms Controller * * Handles requests to /products/attributes/<slug>/terms */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Product attribute terms controller. * * @internal * @extends WC_REST_Product_Attribute_Terms_Controller */ class ProductAttributeTerms extends \WC_REST_Product_Attribute_Terms_Controller { use CustomAttributeTraits; /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Register the routes for custom product attributes. */ public function register_routes() { parent::register_routes(); register_rest_route( $this->namespace, 'products/attributes/(?P<slug>[a-z0-9_\-]+)/terms', array( 'args' => array( 'slug' => array( 'description' => __( 'Slug identifier for the resource.', 'woocommerce' ), 'type' => 'string', ), ), array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item_by_slug' ), 'permission_callback' => array( $this, 'get_custom_attribute_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Check if a given request has access to read a custom attribute. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function get_custom_attribute_permissions_check( $request ) { if ( ! wc_rest_check_manager_permissions( 'attributes', 'read' ) ) { return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot view this resource.', 'woocommerce' ), array( 'status' => rest_authorization_required_code(), ) ); } return true; } /** * Get the Attribute's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = parent::get_item_schema(); // Custom attributes substitute slugs for numeric IDs. $schema['properties']['id']['type'] = array( 'integer', 'string' ); return $schema; } /** * Query custom attribute values by slug. * * @param string $slug Attribute slug. * @return array Attribute values, formatted for response. */ protected function get_custom_attribute_values( $slug ) { global $wpdb; if ( empty( $slug ) ) { return array(); } $attribute_values = array(); // Get the attribute properties. $attribute = $this->get_custom_attribute_by_slug( $slug ); if ( is_wp_error( $attribute ) ) { return $attribute; } // Find all attribute values assigned to products. $query_results = $wpdb->get_results( $wpdb->prepare( "SELECT meta_value, COUNT(meta_id) AS product_count FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value != '' GROUP BY meta_value", 'attribute_' . esc_sql( $slug ) ), OBJECT_K ); // Ensure all defined properties are in the response. $defined_values = wc_get_text_attributes( $attribute[ $slug ]['value'] ); foreach ( $defined_values as $defined_value ) { if ( array_key_exists( $defined_value, $query_results ) ) { continue; } $query_results[ $defined_value ] = (object) array( 'meta_value' => $defined_value, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value 'product_count' => 0, ); } foreach ( $query_results as $term_value => $term ) { // Mimic the structure of a taxonomy-backed attribute values for response. $data = array( 'id' => $term_value, 'name' => $term_value, 'slug' => $term_value, 'description' => '', 'menu_order' => 0, 'count' => (int) $term->product_count, ); $response = rest_ensure_response( $data ); $response->add_links( array( 'collection' => array( 'href' => rest_url( $this->namespace . '/products/attributes/' . $slug . '/terms' ), ), ) ); $response = $this->prepare_response_for_collection( $response ); $attribute_values[ $term_value ] = $response; } return array_values( $attribute_values ); } /** * Get a single custom attribute. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Request|WP_Error */ public function get_item_by_slug( $request ) { return $this->get_custom_attribute_values( $request['slug'] ); } } API/OnboardingProducts.php 0000777 00000003677 15252240713 0011521 0 ustar 00 <?php /** * REST API Onboarding Themes Controller * * Handles requests to install and activate themes. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Blocks\AIContent\UpdateProducts; defined( 'ABSPATH' ) || exit; /** * Onboarding Themes Controller. * * @internal * @extends WC_REST_Data_Controller */ class OnboardingProducts extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'onboarding'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/products', array( array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_products' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); } /** * Create products. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|WP_REST_Response */ public function create_products( $request ) { $update_products = new UpdateProducts(); $products = $update_products->fetch_dummy_products_to_update(); if ( is_wp_error( $products ) ) { return rest_ensure_response( array( 'success' => false ) ); } return rest_ensure_response( array( 'success' => true ) ); } /** * Check if a given request has access to manage themes. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function update_item_permissions_check( $request ) { if ( ! current_user_can( 'manage_options' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot create dummy products.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } } API/MarketingCampaignTypes.php 0000777 00000014020 15252240713 0012301 0 ustar 00 <?php /** * REST API MarketingCampaignTypes Controller * * Handles requests to /marketing/campaign-types. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Admin\Marketing\MarketingCampaignType; use Automattic\WooCommerce\Admin\Marketing\MarketingChannels as MarketingChannelsService; use WC_REST_Controller; use WP_Error; use WP_REST_Request; use WP_REST_Response; defined( 'ABSPATH' ) || exit; /** * MarketingCampaignTypes Controller. * * @internal * @extends WC_REST_Controller * @since x.x.x */ class MarketingCampaignTypes extends WC_REST_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'marketing/campaign-types'; /** * 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_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Retrieves the query params for the collections. * * @return array Query parameters for the collection. */ public function get_collection_params() { $params = parent::get_collection_params(); unset( $params['search'] ); return $params; } /** * Check whether a given request has permission to view marketing campaigns. * * @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( 'settings', 'read' ) ) { return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Returns an aggregated array of marketing campaigns for all active marketing channels. * * @param WP_REST_Request $request Request data. * * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { /** * MarketingChannels class. * * @var MarketingChannelsService $marketing_channels_service */ $marketing_channels_service = wc_get_container()->get( MarketingChannelsService::class ); // Aggregate the supported campaign types from all registered marketing channels. $responses = []; foreach ( $marketing_channels_service->get_registered_channels() as $channel ) { foreach ( $channel->get_supported_campaign_types() as $campaign_type ) { $response = $this->prepare_item_for_response( $campaign_type, $request ); $responses[] = $this->prepare_response_for_collection( $response ); } } return rest_ensure_response( $responses ); } /** * Prepares the item for the REST response. * * @param MarketingCampaignType $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. */ public function prepare_item_for_response( $item, $request ) { $data = [ 'id' => $item->get_id(), 'name' => $item->get_name(), 'description' => $item->get_description(), 'channel' => [ 'slug' => $item->get_channel()->get_slug(), 'name' => $item->get_channel()->get_name(), ], 'create_url' => $item->get_create_url(), 'icon_url' => $item->get_icon_url(), ]; $context = $request['context'] ?? 'view'; $data = $this->add_additional_fields_to_object( $data, $request ); $data = $this->filter_response_by_context( $data, $context ); return rest_ensure_response( $data ); } /** * Retrieves the item's schema, conforming to JSON Schema. * * @return array Item schema data. */ public function get_item_schema() { $schema = [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'marketing_campaign_type', 'type' => 'object', 'properties' => [ 'id' => [ 'description' => __( 'The unique identifier for the marketing campaign type.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'name' => [ 'description' => __( 'Name of the marketing campaign type.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'description' => [ 'description' => __( 'Description of the marketing campaign type.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'channel' => [ 'description' => __( 'The marketing channel that this campaign type belongs to.', 'woocommerce' ), 'type' => 'object', 'context' => [ 'view' ], 'readonly' => true, 'properties' => [ 'slug' => [ 'description' => __( 'The unique identifier of the marketing channel that this campaign type belongs to.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'name' => [ 'description' => __( 'The name of the marketing channel that this campaign type belongs to.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], ], ], 'create_url' => [ 'description' => __( 'URL to the create campaign page for this campaign type.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], 'icon_url' => [ 'description' => __( 'URL to an image/icon for the campaign type.', 'woocommerce' ), 'type' => 'string', 'context' => [ 'view' ], 'readonly' => true, ], ], ]; return $this->add_additional_fields_schema( $schema ); } } API/ShippingPartnerSuggestions.php 0000777 00000013364 15252240713 0013255 0 ustar 00 <?php /** * Handles requests for shipping partner suggestions. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Admin\Features\ShippingPartnerSuggestions\DefaultShippingPartners; use Automattic\WooCommerce\Admin\Features\ShippingPartnerSuggestions\ShippingPartnerSuggestions as Suggestions; defined( 'ABSPATH' ) || exit; /** * ShippingPartnerSuggestions Controller. * * @internal * @extends WC_REST_Data_Controller */ class ShippingPartnerSuggestions extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'shipping-partner-suggestions'; /** * 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_suggestions' ), 'permission_callback' => array( $this, 'get_permission_check' ), 'args' => array( 'force_default_suggestions' => array( 'type' => 'boolean', 'description' => __( 'Return the default shipping partner suggestions when woocommerce_show_marketplace_suggestions option is set to no', 'woocommerce' ), ), ), ), 'schema' => array( $this, 'get_suggestions_schema' ), ) ); } /** * Check if a given request has access to manage plugins. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function get_permission_check( $request ) { if ( ! current_user_can( 'install_plugins' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage plugins.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Check if suggestions should be shown in the settings screen. * * @return bool */ private function should_display() { if ( 'no' === get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) ) { return false; } /** * The return value can be controlled via woocommerce_allow_shipping_partner_suggestions filter. * * @since 7.4.1 */ return apply_filters( 'woocommerce_allow_shipping_partner_suggestions', true ); } /** * Return suggested shipping partners. * * @param WP_REST_Request $request Full details about the request. * @return \WP_Error|\WP_HTTP_Response|\WP_REST_Response */ public function get_suggestions( $request ) { $should_display = $this->should_display(); $force_default = $request->get_param( 'force_default_suggestions' ); if ( $should_display ) { return Suggestions::get_suggestions(); } elseif ( false === $should_display && true === $force_default ) { return rest_ensure_response( Suggestions::get_suggestions( DefaultShippingPartners::get_all() ) ); } return rest_ensure_response( Suggestions::get_suggestions( DefaultShippingPartners::get_all() ) ); } /** * Get the schema, conforming to JSON Schema. * * @return array */ public static function get_suggestions_schema() { $feature_def = array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'icon' => array( 'type' => 'string', ), 'title' => array( 'type' => 'string', ), 'description' => array( 'type' => 'string', ), ), ), ); $layout_def = array( 'type' => 'object', 'properties' => array( 'image' => array( 'type' => 'string', 'description' => '', ), 'features' => $feature_def, ), ); $item_schema = array( 'type' => 'object', 'required' => array( 'name', 'is_visible', 'available_layouts' ), // require layout_row or layout_column. One of them must exist. 'anyOf' => array( array( 'required' => 'layout_row', ), array( 'required' => 'layout_column', ), ), 'properties' => array( 'name' => array( 'description' => __( 'Plugin name.', 'woocommerce' ), 'type' => 'string', 'required' => true, 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'slug' => array( 'description' => __( 'Plugin slug used in https://wordpress.org/plugins/{slug}.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'layout_row' => $layout_def, 'layout_column' => $layout_def, 'description' => array( 'description' => __( 'Description', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'learn_more_link' => array( 'description' => __( 'Learn more link .', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'is_visible' => array( 'description' => __( 'Suggestion visibility.', 'woocommerce' ), 'type' => 'boolean', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'available_layouts' => array( 'description' => __( 'Available layouts -- single, dual, or both', 'woocommerce' ), 'type' => 'array', 'items' => array( 'type' => 'string', 'enum' => array( 'row', 'column' ), ), 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'shipping-partner-suggestions', 'type' => 'array', 'items' => array( $item_schema ), ); return $schema; } } API/Themes.php 0000777 00000014167 15252240713 0007134 0 ustar 00 <?php /** * REST API Themes Controller * * Handles requests to /themes */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\Overrides\ThemeUpgrader; use Automattic\WooCommerce\Admin\Overrides\ThemeUpgraderSkin; /** * Themes controller. * * @internal * @extends WC_REST_Data_Controller */ class Themes extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'themes'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'upload_theme' ), 'permission_callback' => array( $this, 'upload_theme_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Check whether a given request has permission to edit upload plugins/themes. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function upload_theme_permissions_check( $request ) { if ( ! current_user_can( 'upload_themes' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you are not allowed to install themes on this site.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Upload and install a theme. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function upload_theme( $request ) { if ( ! isset( $_FILES['pluginzip'] ) || ! isset( $_FILES['pluginzip']['tmp_name'] ) || ! is_uploaded_file( $_FILES['pluginzip']['tmp_name'] ) || ! is_file( $_FILES['pluginzip']['tmp_name'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized return new \WP_Error( 'woocommerce_rest_invalid_file', __( 'Specified file failed upload test.', 'woocommerce' ) ); } include_once ABSPATH . 'wp-admin/includes/file.php'; include_once ABSPATH . '/wp-admin/includes/admin.php'; include_once ABSPATH . '/wp-admin/includes/theme-install.php'; include_once ABSPATH . '/wp-admin/includes/theme.php'; include_once ABSPATH . '/wp-admin/includes/class-wp-upgrader.php'; include_once ABSPATH . '/wp-admin/includes/class-theme-upgrader.php'; $_GET['package'] = true; $file_upload = new \File_Upload_Upgrader( 'pluginzip', 'package' ); $upgrader = new ThemeUpgrader( new ThemeUpgraderSkin() ); $install = $upgrader->install( $file_upload->package ); if ( $install || is_wp_error( $install ) ) { $file_upload->cleanup(); } if ( ! is_wp_error( $install ) && isset( $install['destination_name'] ) ) { $theme = $install['destination_name']; $result = array( 'status' => 'success', 'message' => $upgrader->strings['process_success'], 'theme' => $theme, ); /** * Fires when a theme is successfully installed. * * @param string $theme The theme name. */ do_action( 'woocommerce_theme_installed', $theme ); } else { if ( is_wp_error( $install ) && $install->get_error_code() ) { $error_message = isset( $upgrader->strings[ $install->get_error_code() ] ) ? $upgrader->strings[ $install->get_error_code() ] : $install->get_error_data(); } else { $error_message = $upgrader->strings['process_failed']; } $result = array( 'status' => 'error', 'message' => $error_message, ); } $response = $this->prepare_item_for_response( $result, $request ); $data = $this->prepare_response_for_collection( $response ); return rest_ensure_response( $data ); } /** * Prepare the data object for response. * * @param object $item Data object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response $response Response data. */ public function prepare_item_for_response( $item, $request ) { $data = $this->add_additional_fields_to_object( $item, $request ); $data = $this->filter_response_by_context( $data, 'view' ); $response = rest_ensure_response( $data ); /** * Filter the list returned from the API. * * @param WP_REST_Response $response The response object. * @param array $item The original item. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_themes', $response, $item, $request ); } /** * Get the schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'upload_theme', 'type' => 'object', 'properties' => array( 'status' => array( 'description' => __( 'Theme installation status.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'message' => array( 'description' => __( 'Theme installation message.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'theme' => array( 'description' => __( 'Uploaded theme.', 'woocommerce' ), 'type' => 'object', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = array( 'context' => $this->get_context_param( array( 'default' => 'view' ) ) ); $params['pluginzip'] = array( 'description' => __( 'A zip file of the theme to be uploaded.', 'woocommerce' ), 'type' => 'file', 'validate_callback' => 'rest_validate_request_arg', ); return apply_filters( 'woocommerce_rest_themes_collection_params', $params ); } } API/Options.php 0000777 00000023774 15252240713 0007346 0 ustar 00 <?php /** * REST API Options Controller * * Handles requests to get and update options in the wp_options table. * * IMPORTANT: This API is for legacy support only. DO NOT add new options here. See p90Yrv-2vK-p2#comment-6482 for more details. * For new settings/options, use Settings REST API (https://woocommerce.github.io/woocommerce-rest-api-docs/#setting-option-properties) or create dedicated endpoints instead. * * Example: * - Use register_rest_route() to create a new endpoint * - Follow WooCommerce REST API standards * - Implement proper permission checks * - Add proper documentation * See Automattic\WooCommerce\Admin\API\OnboardingProfile for examples. */ declare(strict_types=1); namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Options Controller. * * @deprecated since 6.2.0 * * @extends WC_REST_Data_Controller */ class Options extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'options'; /** * 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_options' ), 'permission_callback' => array( $this, 'get_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_options' ), 'permission_callback' => array( $this, 'update_item_permissions_check' ), ), 'schema' => array( $this, 'get_item_schema' ), ) ); } /** * Check if a given request has access to get options. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function get_item_permissions_check( $request ) { $params = ( isset( $request['options'] ) && is_string( $request['options'] ) ) ? explode( ',', $request['options'] ) : array(); if ( ! $params ) { return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'You must supply an array of options.', 'woocommerce' ), 500 ); } foreach ( $params as $option ) { if ( ! $this->user_has_permission( $option, $request ) ) { return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot view these options.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } } return true; } /** * Check if the user has permission given an option name. * * @param string $option Option name. * @param WP_REST_Request $request Full details about the request. * @param bool $is_update If the request is to update the option. * @return boolean */ public function user_has_permission( $option, $request, $is_update = false ) { $permissions = $this->get_option_permissions( $request ); if ( isset( $permissions[ $option ] ) ) { return $permissions[ $option ]; } wc_deprecated_function( 'Automattic\WooCommerce\Admin\API\Options::' . ( $is_update ? 'update_options' : 'get_options' ), '6.3' ); // Disallow option updates in non-production environments unless the option is whitelisted, prompting developers to create specific endpoints in case they miss the deprecation notice. if ( 'production' !== wp_get_environment_type() ) { return false; } return current_user_can( 'manage_options' ); } /** * Check if a given request has access to update options. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function update_item_permissions_check( $request ) { $params = $request->get_json_params(); if ( ! is_array( $params ) ) { return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'You must supply an array of options and values.', 'woocommerce' ), 500 ); } foreach ( $params as $option_name => $option_value ) { if ( ! $this->user_has_permission( $option_name, $request, true ) ) { return new \WP_Error( 'woocommerce_rest_cannot_update', __( 'Sorry, you cannot manage these options.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } } return true; } /** * Get an array of options and respective permissions for the current user. * * @param WP_REST_Request $request Full details about the request. * @return array */ public function get_option_permissions( $request ) { $permissions = self::get_default_option_permissions(); return apply_filters_deprecated( 'woocommerce_rest_api_option_permissions', array( $permissions, $request ), '6.3.0' ); } /** * Get the default available option permissions. * * @return array */ public static function get_default_option_permissions() { $is_woocommerce_admin = \Automattic\WooCommerce\Internal\Admin\Homescreen::is_admin_user(); /** * IMPORTANT: This list is frozen for legacy support. * New options MUST use dedicated endpoints instead of being added here. */ $legacy_whitelisted_options = array( 'woocommerce_setup_jetpack_opted_in', 'woocommerce_stripe_settings', 'woocommerce-ppcp-settings', 'woocommerce_ppcp-gateway_setting', 'woocommerce_demo_store', 'woocommerce_demo_store_notice', 'woocommerce_ces_tracks_queue', 'woocommerce_navigation_intro_modal_dismissed', 'woocommerce_shipping_dismissed_timestamp', 'woocommerce_allow_tracking', 'woocommerce_task_list_keep_completed', 'woocommerce_default_homepage_layout', 'woocommerce_setup_jetpack_opted_in', 'woocommerce_no_sales_tax', 'woocommerce_calc_taxes', 'woocommerce_bacs_settings', 'woocommerce_bacs_accounts', 'woocommerce_settings_shipping_recommendations_hidden', 'woocommerce_task_list_dismissed_tasks', 'woocommerce_setting_payments_recommendations_hidden', 'woocommerce_navigation_favorites_tooltip_hidden', 'woocommerce_admin_transient_notices_queue', 'woocommerce_task_list_hidden', 'woocommerce_task_list_complete', 'woocommerce_extended_task_list_hidden', 'woocommerce_ces_shown_for_actions', 'woocommerce_clear_ces_tracks_queue_for_page', 'woocommerce_admin_install_timestamp', 'woocommerce_task_list_tracked_completed_tasks', 'woocommerce_show_marketplace_suggestions', 'woocommerce_task_list_reminder_bar_hidden', 'wc_connect_options', 'woocommerce_admin_created_default_shipping_zones', 'woocommerce_admin_reviewed_default_shipping_zones', 'woocommerce_admin_reviewed_store_location_settings', 'woocommerce_ces_product_feedback_shown', 'woocommerce_marketing_overview_multichannel_banner_dismissed', 'woocommerce_manage_stock', 'woocommerce_dimension_unit', 'woocommerce_weight_unit', 'woocommerce_product_editor_show_feedback_bar', 'woocommerce_single_variation_notice_dismissed', 'woocommerce_product_tour_modal_hidden', 'woocommerce_block_product_tour_shown', 'woocommerce_revenue_report_date_tour_shown', 'woocommerce_orders_report_date_tour_shown', 'woocommerce_show_prepublish_checks_enabled', 'woocommerce_date_type', 'date_format', 'time_format', 'woocommerce_onboarding_profile', 'woocommerce_default_country', 'blogname', 'wcpay_welcome_page_incentives_dismissed', 'wcpay_welcome_page_viewed_timestamp', 'wcpay_welcome_page_exit_survey_more_info_needed_timestamp', 'woocommerce_customize_store_onboarding_tour_hidden', 'woocommerce_customize_store_ai_suggestions', 'woocommerce_admin_customize_store_completed', 'woocommerce_admin_customize_store_completed_theme_id', 'woocommerce_admin_customize_store_survey_completed', 'woocommerce_coming_soon', 'woocommerce_store_pages_only', 'woocommerce_private_link', 'woocommerce_share_key', 'woocommerce_show_lys_tour', 'woocommerce_remote_variant_assignment', 'woocommerce_gateway_order', 'woocommerce_woopayments_nox_profile', // WC Test helper options. 'wc-admin-test-helper-rest-api-filters', 'wc_admin_helper_feature_values', ); $theme_permissions = array( 'theme_mods_' . get_stylesheet() => current_user_can( 'edit_theme_options' ), 'stylesheet' => current_user_can( 'edit_theme_options' ), ); return array_merge( array_fill_keys( $theme_permissions, current_user_can( 'edit_theme_options' ) ), array_fill_keys( $legacy_whitelisted_options, $is_woocommerce_admin ) ); } /** * Gets an array of options and respective values. * * @param WP_REST_Request $request Full details about the request. * @return array Options object with option values. */ public function get_options( $request ) { $options = array(); if ( empty( $request['options'] ) || ! is_string( $request['options'] ) ) { return $options; } $params = explode( ',', $request['options'] ); foreach ( $params as $option ) { $options[ $option ] = get_option( $option ); } return $options; } /** * Updates an array of objects. * * @param WP_REST_Request $request Full details about the request. * @return array Options object with a boolean if the option was updated. */ public function update_options( $request ) { $params = $request->get_json_params(); $updated = array(); if ( ! is_array( $params ) ) { return array(); } foreach ( $params as $key => $value ) { $updated[ $key ] = update_option( $key, $value ); } return $updated; } /** * Get the schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'options', 'type' => 'object', 'properties' => array( 'options' => array( 'type' => 'array', 'description' => __( 'Array of options with associated values.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } } API/ProductForm.php 0000777 00000006101 15252240713 0010140 0 ustar 00 <?php /** * REST API Product Form Controller * * Handles requests to retrieve product form data. */ namespace Automattic\WooCommerce\Admin\API; use Automattic\WooCommerce\Internal\Admin\ProductForm\FormFactory; defined( 'ABSPATH' ) || exit; /** * ProductForm Controller. * * @internal * @extends WC_REST_Data_Controller */ class ProductForm extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'product-form'; /** * 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_form_config' ), 'permission_callback' => array( $this, 'get_product_form_permission_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/fields', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_fields' ), 'permission_callback' => array( $this, 'get_product_form_permission_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Check if a given request has access to manage woocommerce. * * @param WP_REST_Request $request Full details about the request. * @return WP_Error|boolean */ public function get_product_form_permission_check( $request ) { if ( ! current_user_can( 'manage_woocommerce' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_create', __( 'Sorry, you are not allowed to retrieve product form data.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Get the form fields. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error */ public function get_fields( $request ) { $json = array_map( function( $field ) { return $field->get_json(); }, FormFactory::get_fields() ); return rest_ensure_response( $json ); } /** * Get the form config. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error */ public function get_form_config( $request ) { $fields = array_map( function( $field ) { return $field->get_json(); }, FormFactory::get_fields() ); $subsections = array_map( function( $subsection ) { return $subsection->get_json(); }, FormFactory::get_subsections() ); $sections = array_map( function( $section ) { return $section->get_json(); }, FormFactory::get_sections() ); $tabs = array_map( function( $tab ) { return $tab->get_json(); }, FormFactory::get_tabs() ); return rest_ensure_response( array( 'fields' => $fields, 'subsections' => $subsections, 'sections' => $sections, 'tabs' => $tabs, ) ); } } API/Templates/variable_product.csv 0000777 00000000040 15252240713 0013157 0 ustar 00 Type,Name,Published variable,,-1 API/Templates/physical_product.csv 0000777 00000000036 15252240713 0013213 0 ustar 00 Type,Name,Published simple,,-1 API/Templates/digital_product.csv 0000777 00000000067 15252240713 0013020 0 ustar 00 Type,Name,Published "simple, downloadable, virtual",,-1 API/Templates/external_product.csv 0000777 00000000040 15252240713 0013214 0 ustar 00 Type,Name,Published external,,-1 API/Templates/grouped_product.csv 0000777 00000000040 15252240713 0013037 0 ustar 00 Type,Name,Published grouped,,-1 API/AnalyticsImports.php 0000777 00000022130 15252240713 0011201 0 ustar 00 <?php /** * REST API Analytics Imports Controller * * Handles requests to get batch import status and trigger manual imports. */ declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\API; use WP_Error; use Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler; defined( 'ABSPATH' ) || exit; /** * REST API Analytics Imports Controller. * * @internal */ class AnalyticsImports extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Route base. * * @var string */ protected $rest_base = 'imports'; /** * Register routes. * * @return void */ public function register_routes(): void { register_rest_route( $this->namespace, '/' . $this->rest_base . '/status', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_status' ), 'permission_callback' => array( $this, 'permissions_check' ), ), 'schema' => array( $this, 'get_status_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/trigger', array( array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'trigger_import' ), 'permission_callback' => array( $this, 'permissions_check' ), ), 'schema' => array( $this, 'get_trigger_schema' ), ) ); } /** * Check if a given request has access to analytics imports. * * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request. * @return WP_Error|boolean */ public function permissions_check( $request ) { if ( ! current_user_can( 'manage_woocommerce' ) ) { return new WP_Error( 'woocommerce_rest_cannot_access', __( 'Sorry, you cannot access analytics imports.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Get the current import status. * * @param \WP_REST_Request<array<string, mixed>> $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_status( $request ) { $is_scheduled_mode = $this->is_scheduled_import_enabled(); $mode = $is_scheduled_mode ? 'scheduled' : 'immediate'; $response = array( 'mode' => $mode, 'last_processed_date' => null, 'next_scheduled' => null, 'import_in_progress_or_due' => null, ); // For scheduled mode, populate additional fields. if ( $is_scheduled_mode ) { $last_processed_gmt = get_option( OrdersScheduler::LAST_PROCESSED_ORDER_DATE_OPTION, null ); $response['last_processed_date'] = ( is_string( $last_processed_gmt ) && $last_processed_gmt ) ? get_date_from_gmt( $last_processed_gmt, 'Y-m-d H:i:s' ) : null; $response['next_scheduled'] = $this->get_next_scheduled_time(); $response['import_in_progress_or_due'] = $this->is_import_in_progress_or_due(); } return rest_ensure_response( $response ); } /** * Trigger a manual import. * * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request. * @return \WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function trigger_import( $request ) { $is_scheduled_mode = $this->is_scheduled_import_enabled(); // Return error if in immediate mode. if ( ! $is_scheduled_mode ) { return new WP_Error( 'woocommerce_rest_analytics_import_immediate_mode', __( 'Manual import is not available in immediate mode. Imports happen automatically.', 'woocommerce' ), array( 'status' => 400 ) ); } // Check if an import is already in progress or due to run soon. if ( $this->is_import_in_progress_or_due() ) { return new WP_Error( 'woocommerce_rest_analytics_import_in_progress', __( 'A batch import is already in progress or scheduled to run soon. Please wait for it to complete before triggering a new import.', 'woocommerce' ), array( 'status' => 400 ) ); } // Trigger the batch import immediately by rescheduling the recurring processor. // This unschedules the current recurring action and reschedules it to run now. $action_hook = OrdersScheduler::get_action( OrdersScheduler::PROCESS_PENDING_ORDERS_BATCH_ACTION ); if ( ! is_string( $action_hook ) ) { return new WP_Error( 'woocommerce_rest_analytics_import_invalid_action', __( 'Invalid action hook for batch import.', 'woocommerce' ), array( 'status' => 500 ) ); } WC()->queue()->cancel_all( $action_hook, array(), (string) OrdersScheduler::$group ); OrdersScheduler::schedule_recurring_batch_processor(); return rest_ensure_response( array( 'success' => true, 'message' => __( 'Batch import triggered successfully.', 'woocommerce' ), ) ); } /** * Check if scheduled import is enabled. * * @return bool */ private function is_scheduled_import_enabled() { return 'yes' === get_option( OrdersScheduler::SCHEDULED_IMPORT_OPTION, OrdersScheduler::SCHEDULED_IMPORT_OPTION_DEFAULT_VALUE ); } /** * Get the next scheduled time for the batch processor. * * @return string|null Datetime string in site timezone or null if not scheduled. */ private function get_next_scheduled_time() { $action_hook = OrdersScheduler::get_action( OrdersScheduler::PROCESS_PENDING_ORDERS_BATCH_ACTION ); if ( ! is_string( $action_hook ) ) { return null; } $next_time = WC()->queue()->get_next( $action_hook, array(), (string) OrdersScheduler::$group ); if ( ! $next_time ) { return null; } // Convert UTC timestamp to site timezone. return get_date_from_gmt( $next_time->format( 'Y-m-d H:i:s' ), 'Y-m-d H:i:s' ); } /** * Get the schema for the status endpoint, conforming to JSON Schema. * * @return array */ public function get_status_schema() { $schema = array( '$schema' => 'https://json-schema.org/draft-04/schema#', 'title' => 'analytics_import_status', 'type' => 'object', 'properties' => array( 'mode' => array( 'type' => 'string', 'enum' => array( 'scheduled', 'immediate' ), 'description' => __( 'Current import mode.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'last_processed_date' => array( 'type' => array( 'string', 'null' ), 'description' => __( 'Last processed order date (null in immediate mode).', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'next_scheduled' => array( 'type' => array( 'string', 'null' ), 'description' => __( 'Next scheduled import time (null in immediate mode).', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'import_in_progress_or_due' => array( 'type' => array( 'boolean', 'null' ), 'description' => __( 'Whether a batch import is currently running or scheduled to run within the next minute (null in immediate mode).', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get the schema for the trigger endpoint, conforming to JSON Schema. * * @return array */ public function get_trigger_schema() { $schema = array( '$schema' => 'https://json-schema.org/draft-04/schema#', 'title' => 'analytics_import_trigger', 'type' => 'object', 'properties' => array( 'success' => array( 'type' => 'boolean', 'description' => __( 'Whether the trigger was successful.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'message' => array( 'type' => 'string', 'description' => __( 'Result message.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Check if a batch import is currently in progress or due to run soon. * * @return bool True if a batch import is in progress or scheduled to run within the next minute, false otherwise. */ private function is_import_in_progress_or_due() { $hook = OrdersScheduler::get_action( OrdersScheduler::PROCESS_PENDING_ORDERS_BATCH_ACTION ); if ( ! is_string( $hook ) ) { return false; } // Check for actions with 'in-progress' status. $in_progress_actions = WC()->queue()->search( array( 'hook' => $hook, 'status' => 'in-progress', 'per_page' => 1, ), 'ids' ); if ( ! empty( $in_progress_actions ) ) { return true; } // Check if the next scheduled import is due within 1 minute. $next_scheduled = WC()->queue()->get_next( $hook, array(), (string) OrdersScheduler::$group ); if ( $next_scheduled ) { $time_until_next = $next_scheduled->getTimestamp() - time(); // Consider it "due" if it's scheduled to run within the next 60 seconds. if ( $time_until_next <= MINUTE_IN_SECONDS ) { return true; } } return false; } } API/Leaderboards.php 0000777 00000044344 15252240713 0010276 0 ustar 00 <?php /** * REST API Leaderboards Controller * * Handles requests to /leaderboards */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\API\Reports\Categories\DataStore as CategoriesDataStore; 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\Products\DataStore as ProductsDataStore; /** * Leaderboards controller. * * @internal * @extends WC_REST_Data_Controller */ class Leaderboards extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Route base. * * @var string */ protected $rest_base = 'leaderboards'; /** * 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_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/allowed', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_allowed_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_allowed_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<leaderboard>\w+)', array( 'args' => array( 'leaderboard' => array( 'type' => 'string', 'enum' => array( 'customers', 'coupons', 'categories', 'products' ), ), ), array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Get the data for the coupons leaderboard. * * @param int $per_page Number of rows. * @param string $after Items after date. * @param string $before Items before date. * @param string $persisted_query URL query string. */ protected function get_coupons_leaderboard( $per_page, $after, $before, $persisted_query ) { $coupons_data_store = new CouponsDataStore(); $coupons_data = $per_page > 0 ? $coupons_data_store->get_data( apply_filters( 'woocommerce_analytics_coupons_query_args', array( 'orderby' => 'orders_count', 'order' => 'desc', 'after' => $after, 'before' => $before, 'per_page' => $per_page, 'extended_info' => true, ) ) )->data : array(); $rows = array(); foreach ( $coupons_data as $coupon ) { $url_query = wp_parse_args( array( 'filter' => 'single_coupon', 'coupons' => $coupon['coupon_id'], ), $persisted_query ); $coupon_url = wc_admin_url( '/analytics/coupons', $url_query ); $coupon_code = isset( $coupon['extended_info'] ) && isset( $coupon['extended_info']['code'] ) ? $coupon['extended_info']['code'] : ''; $rows[] = array( array( 'display' => "<a href='{$coupon_url}'>{$coupon_code}</a>", 'value' => $coupon_code, ), array( 'display' => wc_admin_number_format( $coupon['orders_count'] ), 'value' => $coupon['orders_count'], 'format' => 'number', ), array( 'display' => wc_price( $coupon['amount'] ), 'value' => $coupon['amount'], 'format' => 'currency', ), ); } return array( 'id' => 'coupons', 'label' => __( 'Top Coupons - Number of Orders', 'woocommerce' ), 'headers' => array( array( 'label' => __( 'Coupon code', 'woocommerce' ), ), array( 'label' => __( 'Orders', 'woocommerce' ), ), array( 'label' => __( 'Amount discounted', 'woocommerce' ), ), ), 'rows' => $rows, ); } /** * Get the data for the categories leaderboard. * * @param int $per_page Number of rows. * @param string $after Items after date. * @param string $before Items before date. * @param string $persisted_query URL query string. */ protected function get_categories_leaderboard( $per_page, $after, $before, $persisted_query ) { $categories_data_store = new CategoriesDataStore(); $categories_data = $per_page > 0 ? $categories_data_store->get_data( apply_filters( 'woocommerce_analytics_categories_query_args', array( 'orderby' => 'items_sold', 'order' => 'desc', 'after' => $after, 'before' => $before, 'per_page' => $per_page, 'extended_info' => true, ) ) )->data : array(); $rows = array(); foreach ( $categories_data as $category ) { $url_query = wp_parse_args( array( 'filter' => 'single_category', 'categories' => $category['category_id'], ), $persisted_query ); $category_url = wc_admin_url( '/analytics/categories', $url_query ); $category_name = isset( $category['extended_info'] ) && isset( $category['extended_info']['name'] ) ? $category['extended_info']['name'] : ''; $rows[] = array( array( 'display' => "<a href='{$category_url}'>{$category_name}</a>", 'value' => $category_name, ), array( 'display' => wc_admin_number_format( $category['items_sold'] ), 'value' => $category['items_sold'], 'format' => 'number', ), array( 'display' => wc_price( $category['net_revenue'] ), 'value' => $category['net_revenue'], 'format' => 'currency', ), ); } return array( 'id' => 'categories', 'label' => __( 'Top categories - Items sold', 'woocommerce' ), 'headers' => array( array( 'label' => __( 'Category', 'woocommerce' ), ), array( 'label' => __( 'Items sold', 'woocommerce' ), ), array( 'label' => __( 'Net sales', 'woocommerce' ), ), ), 'rows' => $rows, ); } /** * Get the data for the customers leaderboard. * * @param int $per_page Number of rows. * @param string $after Items after date. * @param string $before Items before date. * @param string $persisted_query URL query string. */ protected function get_customers_leaderboard( $per_page, $after, $before, $persisted_query ) { $customers_data_store = new CustomersDataStore(); $customers_data = $per_page > 0 ? $customers_data_store->get_data( apply_filters( 'woocommerce_analytics_customers_query_args', array( 'orderby' => 'total_spend', 'order' => 'desc', 'order_after' => $after, 'order_before' => $before, 'per_page' => $per_page, ) ) )->data : array(); $rows = array(); foreach ( $customers_data as $customer ) { $url_query = wp_parse_args( array( 'filter' => 'single_customer', 'customers' => $customer['id'], ), $persisted_query ); $customer_url = wc_admin_url( '/analytics/customers', $url_query ); $rows[] = array( array( 'display' => "<a href='{$customer_url}'>{$customer['name']}</a>", 'value' => $customer['name'], ), array( 'display' => wc_admin_number_format( $customer['orders_count'] ), 'value' => $customer['orders_count'], 'format' => 'number', ), array( 'display' => wc_price( $customer['total_spend'] ), 'value' => $customer['total_spend'], 'format' => 'currency', ), ); } return array( 'id' => 'customers', 'label' => __( 'Top Customers - Total Spend', 'woocommerce' ), 'headers' => array( array( 'label' => __( 'Customer Name', 'woocommerce' ), ), array( 'label' => __( 'Orders', 'woocommerce' ), ), array( 'label' => __( 'Total Spend', 'woocommerce' ), ), ), 'rows' => $rows, ); } /** * Get the data for the products leaderboard. * * @param int $per_page Number of rows. * @param string $after Items after date. * @param string $before Items before date. * @param string $persisted_query URL query string. */ protected function get_products_leaderboard( $per_page, $after, $before, $persisted_query ) { $products_data_store = new ProductsDataStore(); $products_data = $per_page > 0 ? $products_data_store->get_data( apply_filters( 'woocommerce_analytics_products_query_args', array( 'orderby' => 'items_sold', 'order' => 'desc', 'after' => $after, 'before' => $before, 'per_page' => $per_page, 'extended_info' => true, ) ) )->data : array(); $rows = array(); foreach ( $products_data as $product ) { $url_query = wp_parse_args( array( 'filter' => 'single_product', 'products' => $product['product_id'], ), $persisted_query ); $product_url = wc_admin_url( '/analytics/products', $url_query ); $product_name = isset( $product['extended_info'] ) && isset( $product['extended_info']['name'] ) ? $product['extended_info']['name'] : ''; $rows[] = array( array( 'display' => "<a href='{$product_url}'>{$product_name}</a>", 'value' => $product_name, ), array( 'display' => wc_admin_number_format( $product['items_sold'] ), 'value' => $product['items_sold'], 'format' => 'number', ), array( 'display' => wc_price( $product['net_revenue'] ), 'value' => $product['net_revenue'], 'format' => 'currency', ), ); } return array( 'id' => 'products', 'label' => __( 'Top products - Items sold', 'woocommerce' ), 'headers' => array( array( 'label' => __( 'Product', 'woocommerce' ), ), array( 'label' => __( 'Items sold', 'woocommerce' ), ), array( 'label' => __( 'Net sales', 'woocommerce' ), ), ), 'rows' => $rows, ); } /** * Get an array of all leaderboards. * * @param int $per_page Number of rows. * @param string $after Items after date. * @param string $before Items before date. * @param string $persisted_query URL query string. * @return array */ public function get_leaderboards( $per_page, $after, $before, $persisted_query ) { $leaderboards = array( $this->get_customers_leaderboard( $per_page, $after, $before, $persisted_query ), $this->get_coupons_leaderboard( $per_page, $after, $before, $persisted_query ), $this->get_categories_leaderboard( $per_page, $after, $before, $persisted_query ), $this->get_products_leaderboard( $per_page, $after, $before, $persisted_query ), ); return apply_filters( 'woocommerce_leaderboards', $leaderboards, $per_page, $after, $before, $persisted_query ); } /** * Return all leaderboards. * * @param WP_REST_Request $request Request data. * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { $persisted_query = json_decode( $request['persisted_query'], true ); switch ( $request['leaderboard'] ) { case 'customers': $leaderboards = array( $this->get_customers_leaderboard( $request['per_page'], $request['after'], $request['before'], $persisted_query ) ); break; case 'coupons': $leaderboards = array( $this->get_coupons_leaderboard( $request['per_page'], $request['after'], $request['before'], $persisted_query ) ); break; case 'categories': $leaderboards = array( $this->get_categories_leaderboard( $request['per_page'], $request['after'], $request['before'], $persisted_query ) ); break; case 'products': $leaderboards = array( $this->get_products_leaderboard( $request['per_page'], $request['after'], $request['before'], $persisted_query ) ); break; default: $leaderboards = $this->get_leaderboards( $request['per_page'], $request['after'], $request['before'], $persisted_query ); break; } $data = array(); if ( ! empty( $leaderboards ) ) { foreach ( $leaderboards as $leaderboard ) { $response = $this->prepare_item_for_response( $leaderboard, $request ); $data[] = $this->prepare_response_for_collection( $response ); } } return rest_ensure_response( $data ); } /** * Returns a list of allowed leaderboards. * * @param WP_REST_Request $request Request data. * @return array|WP_Error */ public function get_allowed_items( $request ) { $leaderboards = $this->get_leaderboards( 0, null, null, null ); $data = array(); foreach ( $leaderboards as $leaderboard ) { $data[] = (object) array( 'id' => $leaderboard['id'], 'label' => $leaderboard['label'], 'headers' => $leaderboard['headers'], ); } $objects = array(); foreach ( $data as $item ) { $prepared = $this->prepare_item_for_response( $item, $request ); $objects[] = $this->prepare_response_for_collection( $prepared ); } $response = rest_ensure_response( $objects ); $response->header( 'X-WP-Total', count( $data ) ); $response->header( 'X-WP-TotalPages', 1 ); $base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) ); return $response; } /** * Prepare the data object for response. * * @param object $item Data object. * @param WP_REST_Request $request Request object. * @return WP_REST_Response $response Response data. */ public function prepare_item_for_response( $item, $request ) { $data = $this->add_additional_fields_to_object( $item, $request ); $data = $this->filter_response_by_context( $data, 'view' ); $response = rest_ensure_response( $data ); /** * Filter the list returned from the API. * * @param WP_REST_Response $response The response object. * @param array $item The original item. * @param WP_REST_Request $request Request used to generate the response. */ return apply_filters( 'woocommerce_rest_prepare_leaderboard', $response, $item, $request ); } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = array(); $params['page'] = array( 'description' => __( 'Current page of the collection.', 'woocommerce' ), 'type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', 'minimum' => 1, ); $params['per_page'] = array( 'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ), 'type' => 'integer', 'default' => 5, 'minimum' => 1, 'maximum' => 20, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ); $params['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', ); $params['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', ); $params['persisted_query'] = array( 'description' => __( 'URL query to persist across links.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'leaderboard', 'type' => 'object', 'properties' => array( 'id' => array( 'type' => 'string', 'description' => __( 'Leaderboard ID.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'label' => array( 'type' => 'string', 'description' => __( 'Displayed title for the leaderboard.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, ), 'headers' => array( 'type' => 'array', 'description' => __( 'Table headers.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'items' => array( 'type' => 'array', 'properties' => array( 'label' => array( 'description' => __( 'Table column header.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), ), ), ), 'rows' => array( 'type' => 'array', 'description' => __( 'Table rows.', 'woocommerce' ), 'context' => array( 'view' ), 'readonly' => true, 'items' => array( 'type' => 'array', 'properties' => array( 'display' => array( 'description' => __( 'Table cell display.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'value' => array( 'description' => __( 'Table cell value.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'format' => array( 'description' => __( 'Table cell format.', 'woocommerce' ), 'type' => 'string', 'context' => array( 'view' ), 'enum' => array( 'currency', 'number' ), 'readonly' => true, 'required' => false, ), ), ), ), ), ); return $this->add_additional_fields_schema( $schema ); } /** * Get schema for the list of allowed leaderboards. * * @return array $schema */ public function get_public_allowed_item_schema() { $schema = $this->get_public_item_schema(); unset( $schema['properties']['rows'] ); return $schema; } } API/ProductAttributes.php 0000777 00000010730 15252240713 0011366 0 ustar 00 <?php /** * REST API Product Attributes Controller * * Handles requests to /products/attributes. */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Product categories controller. * * @internal * @extends WC_REST_Product_Attributes_Controller */ class ProductAttributes extends \WC_REST_Product_Attributes_Controller { use CustomAttributeTraits; /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Register the routes for custom product attributes. */ public function register_routes() { parent::register_routes(); register_rest_route( $this->namespace, 'products/attributes/(?P<slug>[a-z0-9_\-]+)', array( 'args' => array( 'slug' => array( 'description' => __( 'Slug identifier for the resource.', 'woocommerce' ), 'type' => 'string', ), ), array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item_by_slug' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Get the query params for collections * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['search'] = array( 'description' => __( 'Search by similar attribute name.', 'woocommerce' ), 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ); return $params; } /** * Get the Attribute's schema, conforming to JSON Schema. * * @return array */ public function get_item_schema() { $schema = parent::get_item_schema(); // Custom attributes substitute slugs for numeric IDs. $schema['properties']['id']['type'] = array( 'integer', 'string' ); return $schema; } /** * Get a single attribute by it's slug. * * @param WP_REST_Request $request The API request. * @return WP_REST_Response */ public function get_item_by_slug( $request ) { if ( empty( $request['slug'] ) ) { return array(); } $attributes = $this->get_custom_attribute_by_slug( $request['slug'] ); if ( is_wp_error( $attributes ) ) { return $attributes; } $response_items = $this->format_custom_attribute_items_for_response( $attributes ); return reset( $response_items ); } /** * Format custom attribute items for response (mimic the structure of a taxonomy - backed attribute). * * @param array $custom_attributes - CustomAttributeTraits::get_custom_attributes(). * @return array */ protected function format_custom_attribute_items_for_response( $custom_attributes ) { $response = array(); foreach ( $custom_attributes as $attribute_key => $attribute_value ) { $data = array( 'id' => $attribute_key, 'name' => $attribute_value['name'], 'slug' => $attribute_key, 'type' => 'select', 'order_by' => 'menu_order', 'has_archives' => false, ); $item_response = rest_ensure_response( $data ); $item_response->add_links( $this->prepare_links( (object) array( 'attribute_id' => $attribute_key ) ) ); $item_response = $this->prepare_response_for_collection( $item_response ); $response[] = $item_response; } return $response; } /** * Get all attributes, with support for searching (which includes custom attributes). * * @param WP_REST_Request $request The API request. * @return WP_REST_Response */ public function get_items( $request ) { if ( empty( $request['search'] ) ) { return parent::get_items( $request ); } $search_string = $request['search']; $custom_attributes = $this->get_custom_attributes( array( 'name' => $search_string ) ); $matching_attributes = $this->format_custom_attribute_items_for_response( $custom_attributes ); $taxonomy_attributes = wc_get_attribute_taxonomies(); foreach ( $taxonomy_attributes as $attribute_obj ) { // Skip taxonomy attributes that didn't match the query. if ( false === stripos( $attribute_obj->attribute_label, $search_string ) ) { continue; } $attribute = $this->prepare_item_for_response( $attribute_obj, $request ); $matching_attributes[] = $this->prepare_response_for_collection( $attribute ); } $response = rest_ensure_response( $matching_attributes ); $response->header( 'X-WP-Total', count( $matching_attributes ) ); $response->header( 'X-WP-TotalPages', 1 ); return $response; } } API/Experiments.php 0000777 00000003510 15252240713 0010200 0 ustar 00 <?php /** * REST API Experiment Controller * * Handles requests to /experiment */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Data controller. * * @extends WC_REST_Data_Controller */ class Experiments extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'experiments'; /** * Register routes. */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/assignment', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_assignment' ), 'permission_callback' => array( $this, 'get_item_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Forward the experiment request to WP.com and return the WP.com response. * * @param \WP_REST_Request $request Request data. * * @return \WP_Error|\WP_REST_Response */ public function get_assignment( $request ) { $args = $request->get_query_params(); if ( ! isset( $args['experiment_name'] ) ) { return new \WP_Error( 'woocommerce_rest_experiment_name_required', __( 'Sorry, experiment_name is required.', 'woocommerce' ), array( 'status' => 400 ) ); } unset( $args['rest_route'] ); $abtest = new \WooCommerce\Admin\Experimental_Abtest( $request->get_param( 'anon_id' ) ?? '', 'woocommerce', true, // set consent to true here since frontend has checked it already. true // set true to send request as auth user. ); $response = $abtest->request_assignment( $args ); if ( is_wp_error( $response ) ) { return $response; } return json_decode( $response['body'], true ); } } API/Customers.php 0000777 00000004163 15252240713 0007666 0 ustar 00 <?php /** * REST API Customers Controller * * Handles requests to /customers/* */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * Customers controller. * * @internal * @extends \Automattic\WooCommerce\Admin\API\Reports\Customers\Controller */ class Customers extends \Automattic\WooCommerce\Admin\API\Reports\Customers\Controller { /** * Route base. * * @var string */ protected $rest_base = 'customers'; /** * Register the routes for customers. */ 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' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[\d-]+)', array( 'args' => array( 'id' => array( 'description' => __( 'Unique ID for the resource.', 'woocommerce' ), 'type' => 'integer', ), ), array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Maps query arguments from the REST request. * * @param array $request Request array. * @return array */ protected function prepare_reports_query( $request ) { $args = parent::prepare_reports_query( $request ); $args['customers'] = $request['include']; return $args; } /** * Get the query params for collections. * * @return array */ public function get_collection_params() { $params = parent::get_collection_params(); $params['include'] = $params['customers']; unset( $params['customers'] ); return $params; } } API/CustomAttributeTraits.php 0000777 00000006634 15252240713 0012234 0 ustar 00 <?php /** * Traits for handling custom product attributes and their terms. */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * CustomAttributeTraits class. * * @internal */ trait CustomAttributeTraits { /** * Get a single attribute by its slug. * * @internal * @param string $slug The attribute slug. * @return WP_Error|object The matching attribute object or WP_Error if not found. */ public function get_custom_attribute_by_slug( $slug ) { $matching_attributes = $this->get_custom_attributes( array( 'slug' => $slug ) ); if ( empty( $matching_attributes ) ) { return new \WP_Error( 'woocommerce_rest_product_attribute_not_found', __( 'No product attribute with that slug was found.', 'woocommerce' ), array( 'status' => 404 ) ); } foreach ( $matching_attributes as $attribute_key => $attribute_value ) { return array( $attribute_key => $attribute_value ); } } /** * Query custom attributes by name or slug. * * @param string $args Search arguments, either name or slug. * @return array Matching attributes, formatted for response. */ protected function get_custom_attributes( $args ) { global $wpdb; $args = wp_parse_args( $args, array( 'name' => '', 'slug' => '', ) ); if ( empty( $args['name'] ) && empty( $args['slug'] ) ) { return array(); } $mode = $args['name'] ? 'name' : 'slug'; if ( 'name' === $mode ) { $name = $args['name']; // Get as close as we can to matching the name property of custom attributes using SQL. $like = '%"name";s:%:"%' . $wpdb->esc_like( $name ) . '%"%'; } else { $slug = sanitize_title_for_query( $args['slug'] ); // Get as close as we can to matching the slug property of custom attributes using SQL. $like = '%s:' . strlen( $slug ) . ':"' . $slug . '";a:6:{%'; } // Find all serialized product attributes with names like the search string. $query_results = $wpdb->get_results( $wpdb->prepare( "SELECT meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_product_attributes' AND meta_value LIKE %s LIMIT 100", $like ), ARRAY_A ); $custom_attributes = array(); foreach ( $query_results as $raw_product_attributes ) { $meta_attributes = maybe_unserialize( $raw_product_attributes['meta_value'] ); if ( empty( $meta_attributes ) || ! is_array( $meta_attributes ) ) { continue; } foreach ( $meta_attributes as $meta_attribute_key => $meta_attribute_value ) { $meta_value = array_merge( array( 'name' => '', 'is_taxonomy' => 0, ), (array) $meta_attribute_value ); // Skip non-custom attributes. if ( ! empty( $meta_value['is_taxonomy'] ) ) { continue; } // Skip custom attributes that didn't match the query. // (There can be any number of attributes in the meta value). if ( ( 'name' === $mode ) && ( false === stripos( $meta_value['name'], $name ) ) ) { continue; } if ( ( 'slug' === $mode ) && ( $meta_attribute_key !== $slug ) ) { continue; } // Combine all values when there are multiple matching custom attributes. if ( isset( $custom_attributes[ $meta_attribute_key ] ) ) { $custom_attributes[ $meta_attribute_key ]['value'] .= ' ' . WC_DELIMITER . ' ' . $meta_value['value']; } else { $custom_attributes[ $meta_attribute_key ] = $meta_attribute_value; } } } return $custom_attributes; } } API/Features.php 0000777 00000003314 15252240713 0007455 0 ustar 00 <?php /** * REST API Features Controller * * Handles requests to /features */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Admin\Features\Features as FeaturesClass; /** * Features Controller. * * @internal * @extends WC_REST_Data_Controller */ class Features extends \WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'features'; /** * 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_features' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Check whether a given request has permission to read onboarding profile data. * * @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( 'settings', 'read' ) ) { return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Return available payment methods. * * @param \WP_REST_Request $request Request data. * * @return \WP_Error|\WP_REST_Response */ public function get_features( $request ) { return FeaturesClass::get_available_features(); } } API/DataCountries.php 0000777 00000002175 15252240713 0010450 0 ustar 00 <?php /** * REST API Data countries controller. * * Handles requests to the /data/countries endpoint. */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; /** * REST API Data countries controller class. * * @internal * @extends WC_REST_Data_Countries_Controller */ class DataCountries extends \WC_REST_Data_Countries_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-analytics'; /** * Register routes. * * @since 3.5.0 */ public function register_routes() { register_rest_route( $this->namespace, '/' . $this->rest_base . '/locales', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_locales' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); parent::register_routes(); } /** * Get country fields. * * @return array */ public function get_locales() { $locales = WC()->countries->get_country_locale(); return rest_ensure_response( $locales ); } } API/AI/BusinessDescription.php 0000777 00000000453 15252240713 0012170 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\API\AI; defined( 'ABSPATH' ) || exit; /** * Store Title controller * * @internal * @deprecated This class can't be removed due https://github.com/woocommerce/woocommerce/issues/52311. */ class BusinessDescription {} API/AI/Middleware.php 0000777 00000000376 15252240713 0010252 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\API\AI; /** * Middleware class. * * @internal * @deprecated This class can't be removed due https://github.com/woocommerce/woocommerce/issues/52311. */ class Middleware {} API/AI/StoreTitle.php 0000777 00000000442 15252240713 0010265 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\API\AI; defined( 'ABSPATH' ) || exit; /** * Store Title controller * * @internal * @deprecated This class can't be removed due https://github.com/woocommerce/woocommerce/issues/52311. */ class StoreTitle {} API/AI/Product.php 0000777 00000000433 15252240713 0007607 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\API\AI; defined( 'ABSPATH' ) || exit; /** * Product controller * * @internal * @deprecated This class can't be removed due https://github.com/woocommerce/woocommerce/issues/52311. */ class Product {} API/AI/StoreInfo.php 0000777 00000000440 15252240713 0010075 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\API\AI; defined( 'ABSPATH' ) || exit; /** * Store Info controller * * @internal * @deprecated This class can't be removed due https://github.com/woocommerce/woocommerce/issues/52311. */ class StoreInfo {} API/AI/Images.php 0000777 00000000431 15252240713 0007372 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\API\AI; defined( 'ABSPATH' ) || exit; /** * Images controller * * @internal * @deprecated This class can't be removed due https://github.com/woocommerce/woocommerce/issues/52311. */ class Images {} API/AI/Patterns.php 0000777 00000000435 15252240713 0007771 0 ustar 00 <?php declare( strict_types = 1 ); namespace Automattic\WooCommerce\Admin\API\AI; defined( 'ABSPATH' ) || exit; /** * Patterns controller * * @internal * @deprecated This class can't be removed due https://github.com/woocommerce/woocommerce/issues/52311. */ class Patterns {} API/OnboardingFreeExtensions.php 0000777 00000005117 15252240713 0012646 0 ustar 00 <?php /** * REST API Onboarding Free Extensions Controller * * Handles requests to /onboarding/free-extensions */ namespace Automattic\WooCommerce\Admin\API; defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions\Init as RemoteFreeExtensions; use WC_REST_Data_Controller; use WP_Error; use WP_REST_Request; use WP_REST_Response; use WP_REST_Server; /** * Onboarding Payments Controller. * * @internal * @extends WC_REST_Data_Controller */ class OnboardingFreeExtensions extends WC_REST_Data_Controller { /** * Endpoint namespace. * * @var string */ protected $namespace = 'wc-admin'; /** * Route base. * * @var string */ protected $rest_base = 'onboarding/free-extensions'; /** * 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_available_extensions' ), 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) ); } /** * Check whether a given request has permission to read onboarding profile data. * * @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( 'settings', 'read' ) ) { return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Return available payment methods. * * @param WP_REST_Request $request Request data. * * @return WP_Error|WP_REST_Response */ public function get_available_extensions( $request ) { $extensions = RemoteFreeExtensions::get_extensions(); /** * Allows removing Jetpack suggestions from WooCommerce Admin when false. * * In this instance it is removed from the list of extensions suggested in the Onboarding Profiler. This list is first retrieved from the WooCommerce.com API, then if a plugin with the 'jetpack' slug is found, it is removed. * * @since 7.8 */ if ( false === apply_filters( 'woocommerce_suggest_jetpack', true ) ) { foreach ( $extensions as &$extension ) { $extension['plugins'] = array_filter( $extension['plugins'], function( $plugin ) { return 'jetpack' !== $plugin->key; } ); } } return new WP_REST_Response( $extensions ); } }
| ver. 1.6 |
Github
|
.
| PHP 8.1.34 | Генерация страницы: 0.12 |
proxy
|
phpinfo
|
Настройка