d specific meta or actions, so that it can be used other order types safely. * * @param \WC_Order $order Order object. * * @return void * * @throws \Exception When unable to save data. */ protected function persist_save( &$order, bool $force_all_fields = false, $backfill = true ) { $order->set_version( Constants::get_constant( 'WC_VERSION' ) ); $order->set_currency( $order->get_currency() ? $order->get_currency() : get_woocommerce_currency() ); if ( ! $order->get_date_created( 'edit' ) ) { $order->set_date_created( time() ); } $this->update_order_meta( $order ); $this->persist_order_to_db( $order, $force_all_fields ); $order->save_meta_data(); $order->apply_changes(); if ( $backfill ) { $this->maybe_backfill_post_record( $order ); } $this->clear_caches( $order ); } /** * Method to update an order in the database. * * @param \WC_Order $order */ public function update( &$order ) { // Before updating, ensure date paid is set if missing. if ( ! $order->get_date_paid( 'edit' ) && version_compare( $order->get_version( 'edit' ), '3.0', '<' ) && $order->has_status( apply_filters( 'woocommerce_payment_complete_order_status', $order->needs_processing() ? 'processing' : 'completed', $order->get_id(), $order ) ) // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment ) { $order->set_date_paid( $order->get_date_created( 'edit' ) ); } if ( null === $order->get_date_created( 'edit' ) ) { $order->set_date_created( time() ); } $order->set_version( Constants::get_constant( 'WC_VERSION' ) ); // Fetch changes. $changes = $order->get_changes(); $this->persist_updates( $order ); // Update download permissions if necessary. if ( array_key_exists( 'billing_email', $changes ) || array_key_exists( 'customer_id', $changes ) ) { $data_store = \WC_Data_Store::load( 'customer-download' ); $data_store->update_user_by_order_id( $order->get_id(), $order->get_customer_id(), $order->get_billing_email() ); } // Mark user account as active. if ( array_key_exists( 'customer_id', $changes ) ) { wc_update_user_last_active( $order->get_customer_id() ); } $order->apply_changes(); $this->clear_caches( $order ); do_action( 'woocommerce_update_order', $order->get_id(), $order ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment } /** * Proxy to udpating order meta. Here for backward compatibility reasons. * * @param \WC_Order $order Order object. * * @return void */ protected function update_post_meta( &$order ) { $this->update_order_meta( $order ); } /** * Helper method that is responsible for persisting order updates to the database. * * This is expected to be reused by other order types, and should not contain any specific metadata updates or actions. * * @param \WC_Order $order Order object. * * @return array $changes Array of changes. * * @throws \Exception When unable to persist order. */ protected function persist_updates( &$order, $backfill = true ) { // Fetch changes. $changes = $order->get_changes(); if ( ! isset( $changes['date_modified'] ) ) { $order->set_date_modified( time() ); } if ( $backfill ) { $this->maybe_backfill_post_record( $order ); } $this->persist_order_to_db( $order ); $order->save_meta_data(); return $changes; } /** * Helper function to decide whether to backfill post record. * * @param \WC_Abstract_Order $order Order object. * * @return void */ private function maybe_backfill_post_record( $order ) { $data_sync = wc_get_container()->get( DataSynchronizer::class ); if ( $data_sync->data_sync_is_enabled() ) { $this->backfill_post_record( $order ); } } /** * Helper method that updates post meta based on an order object. * Mostly used for backwards compatibility purposes in this datastore. * * @param \WC_Order $order Order object. * * @since 7.0.0 */ public function update_order_meta( &$order ) { $changes = $order->get_changes(); $this->update_address_index_meta( $order, $changes ); } /** * Helper function to update billing and shipping address metadata. * @param \WC_Abstract_Order $order Order Object * @param array $changes Array of changes. * * @return void */ private function update_address_index_meta( $order, $changes ) { // If address changed, store concatenated version to make searches faster. foreach ( array( 'billing', 'shipping' ) as $address_type ) { if ( isset( $changes[ $address_type ] ) ) { $order->update_meta_data( "_{$address_type}_address_index", implode( ' ', $order->get_address( $address_type ) ) ); } } } /** * Return array of coupon_code => meta_key for coupon which have usage limit and have tentative keys. * Pass $coupon_id if key for only one of the coupon is needed. * * @param WC_Order $order Order object. * @param int $coupon_id If passed, will return held key for that coupon. * * @return array|string Key value pair for coupon code and meta key name. If $coupon_id is passed, returns meta_key for only that coupon. */ public function get_coupon_held_keys( $order, $coupon_id = null ) { $held_keys = $order->get_meta( '_coupon_held_keys' ); if ( $coupon_id ) { return isset( $held_keys[ $coupon_id ] ) ? $held_keys[ $coupon_id ] : null; } return $held_keys; } /** * Return array of coupon_code => meta_key for coupon which have usage limit per customer and have tentative keys. * * @param WC_Order $order Order object. * @param int $coupon_id If passed, will return held key for that coupon. * * @return mixed */ public function get_coupon_held_keys_for_users( $order, $coupon_id = null ) { $held_keys_for_user = $order->get_meta( '_coupon_held_keys_for_users' ); if ( $coupon_id ) { return isset( $held_keys_for_user[ $coupon_id ] ) ? $held_keys_for_user[ $coupon_id ] : null; } return $held_keys_for_user; } /** * Add/Update list of meta keys that are currently being used by this order to hold a coupon. * This is used to figure out what all meta entries we should delete when order is cancelled/completed. * * @param WC_Order $order Order object. * @param array $held_keys Array of coupon_code => meta_key. * @param array $held_keys_for_user Array of coupon_code => meta_key for held coupon for user. * * @return mixed */ public function set_coupon_held_keys( $order, $held_keys, $held_keys_for_user ) { if ( is_array( $held_keys ) && 0 < count( $held_keys ) ) { $order->update_meta_data( '_coupon_held_keys', $held_keys ); } if ( is_array( $held_keys_for_user ) && 0 < count( $held_keys_for_user ) ) { $order->update_meta_data( '_coupon_held_keys_for_users', $held_keys_for_user ); } } /** * Release all coupons held by this order. * * @param WC_Order $order Current order object. * @param bool $save Whether to delete keys from DB right away. Could be useful to pass `false` if you are building a bulk request. */ public function release_held_coupons( $order, $save = true ) { $coupon_held_keys = $this->get_coupon_held_keys( $order ); if ( is_array( $coupon_held_keys ) ) { foreach ( $coupon_held_keys as $coupon_id => $meta_key ) { $coupon = new \WC_Coupon( $coupon_id ); $coupon->delete_meta_data( $meta_key ); $coupon->save_meta_data(); } } $order->delete_meta_data( '_coupon_held_keys' ); $coupon_held_keys_for_users = $this->get_coupon_held_keys_for_users( $order ); if ( is_array( $coupon_held_keys_for_users ) ) { foreach ( $coupon_held_keys_for_users as $coupon_id => $meta_key ) { $coupon = new \WC_Coupon( $coupon_id ); $coupon->delete_meta_data( $meta_key ); $coupon->save_meta_data(); } } $order->delete_meta_data( '_coupon_held_keys_for_users' ); if ( $save ) { $order->save_meta_data(); } } public function query( $query_vars ) { if ( ! isset( $query_vars['paginate'] ) || ! $query_vars['paginate'] ) { $query_vars['no_found_rows'] = true; } if ( isset( $query_vars['anonymized'] ) ) { $query_vars['meta_query'] = $query_vars['meta_query'] ?? array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query if ( $query_vars['anonymized'] ) { $query_vars['meta_query'][] = array( 'key' => '_anonymized', 'value' => 'yes', ); } else { $query_vars['meta_query'][] = array( 'key' => '_anonymized', 'compare' => 'NOT EXISTS', ); } } try { $query = new OrdersTableQuery( $query_vars ); } catch ( \Exception $e ) { $query = (object) array( 'orders' => array(), 'found_orders' => 0, 'max_num_pages' => 0, ); } if ( isset( $query_vars['return'] ) && 'ids' === $query_vars['return'] ) { $orders = $query->orders; } else { $orders = WC()->order_factory->get_orders( $query->orders ); } if ( isset( $query_vars['paginate'] ) && $query_vars['paginate'] ) { return (object) array( 'orders' => $orders, 'total' => $query->found_orders, 'max_num_pages' => $query->max_num_pages, ); } return $orders; } public function get_order_item_type( $order, $order_item_id ) { return 'line_item'; } //phpcs:enable Squiz.Commenting, Generic.Commenting /** * Get the SQL needed to create all the tables needed for the custom orders table feature. * * @return string */ public function get_database_schema() { global $wpdb; $collate = $wpdb->has_cap( 'collation' ) ? $wpdb->get_charset_collate() : ''; $orders_table_name = $this->get_orders_table_name(); $addresses_table_name = $this->get_addresses_table_name(); $operational_data_table_name = $this->get_operational_data_table_name(); $meta_table = $this->get_meta_table_name(); $sql = " CREATE TABLE $orders_table_name ( id bigint(20) unsigned, status varchar(20) null, currency varchar(10) null, type varchar(20) null, tax_amount decimal(26,8) null, total_amount decimal(26,8) null, customer_id bigint(20) unsigned null, billing_email varchar(320) null, date_created_gmt datetime null, date_updated_gmt datetime null, parent_order_id bigint(20) unsigned null, payment_method varchar(100) null, payment_method_title text null, transaction_id varchar(100) null, ip_address varchar(100) null, user_agent text null, customer_note text null, PRIMARY KEY (id), KEY status (status), KEY date_created (date_created_gmt), KEY customer_id_billing_email (customer_id, billing_email), KEY billing_email (billing_email), KEY type_status (type, status), KEY parent_order_id (parent_order_id), KEY date_updated (date_updated_gmt) ) $collate; CREATE TABLE $addresses_table_name ( id bigint(20) unsigned auto_increment primary key, order_id bigint(20) unsigned NOT NULL, address_type varchar(20) null, first_name text null, last_name text null, company text null, address_1 text null, address_2 text null, city text null, state text null, postcode text null, country text null, email varchar(320) null, phone varchar(100) null, KEY order_id (order_id), UNIQUE KEY address_type_order_id (address_type, order_id), KEY email (email), KEY phone (phone) ) $collate; CREATE TABLE $operational_data_table_name ( id bigint(20) unsigned auto_increment primary key, order_id bigint(20) unsigned NULL, created_via varchar(100) NULL, woocommerce_version varchar(20) NULL, prices_include_tax tinyint(1) NULL, coupon_usages_are_counted tinyint(1) NULL, download_permission_granted tinyint(1) NULL, cart_hash varchar(100) NULL, new_order_email_sent tinyint(1) NULL, order_key varchar(100) NULL, order_stock_reduced tinyint(1) NULL, date_paid_gmt datetime NULL, date_completed_gmt datetime NULL, shipping_tax_amount decimal(26, 8) NULL, shipping_total_amount decimal(26, 8) NULL, discount_tax_amount decimal(26, 8) NULL, discount_total_amount decimal(26, 8) NULL, recorded_sales tinyint(1) NULL, UNIQUE KEY order_id (order_id), UNIQUE KEY order_key (order_key) ) $collate; CREATE TABLE $meta_table ( id bigint(20) unsigned auto_increment primary key, order_id bigint(20) unsigned null, meta_key varchar(255), meta_value text null, KEY meta_key_value (meta_key, meta_value(100)), KEY order_id_meta_key_meta_value (order_id, meta_key, meta_value(100)) ) $collate; "; return $sql; } /** * Returns an array of meta for an object. * * @param WC_Data $object WC_Data object. * @return array */ public function read_meta( &$object ) { $raw_meta_data = $this->data_store_meta->read_meta( $object ); return $this->filter_raw_meta_data( $object, $raw_meta_data ); } /** * Deletes meta based on meta ID. * * @param WC_Data $object WC_Data object. * @param stdClass $meta (containing at least ->id). */ public function delete_meta( &$object, $meta ) { return $this->data_store_meta->delete_meta( $object, $meta ); } /** * Add new piece of meta. * * @param WC_Data $object WC_Data object. * @param stdClass $meta (containing ->key and ->value). * @return int meta ID */ public function add_meta( &$object, $meta ) { return $this->data_store_meta->add_meta( $object, $meta ); } /** * Update meta. * * @param WC_Data $object WC_Data object. * @param stdClass $meta (containing ->id, ->key and ->value). */ public function update_meta( &$object, $meta ) { return $this->data_store_meta->update_meta( $object, $meta ); } } PHP redirects are disabled intentionally, return false. if ( ! empty( $options['disable_php_redirect'] ) && $options['disable_php_redirect'] === 'on' ) { return false; } // PHP redirects are the enabled method of redirecting. return true; } /** * Saves the default redirects options to the DB. */ public function save_default_redirect_options() { $redirect_option = WPSEO_Premium_Redirect_Option::get_instance(); \update_option( 'wpseo_redirect', $redirect_option->get_defaults(), true ); } /** * Gets the request URI, with fallback for super global. * * @return string */ protected function get_request_uri() { $options = [ 'options' => [ 'default' => '' ] ]; $request_uri = \filter_input( \INPUT_SERVER, 'REQUEST_URI', \FILTER_SANITIZE_URL, $options ); // Because there isn't an usable value, try the fallback. if ( empty( $request_uri ) && isset( $_SERVER['REQUEST_URI'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- this value is compared. I don't want to change the behavior. $request_uri = \filter_var( $_SERVER['REQUEST_URI'], \FILTER_SANITIZE_URL, $options ); } $request_uri = $this->strip_subdirectory( $request_uri ); return \rawurldecode( $request_uri ); } /** * Normalizes the redirects by raw url decoding the origin. * * @param array $redirects The redirects to normalize. * * @return array The normalized redirects. */ protected function normalize_redirects( $redirects ) { $normalized_redirects = []; foreach ( $redirects as $origin => $redirect ) { $normalized_redirects[ \rawurldecode( $origin ) ] = $redirect; } return $normalized_redirects; } /** * Sets the request URL and sanitize the slashes for it. * * @return void */ protected function set_request_url() { $this->request_url = $this->get_request_uri(); } /** * Finds the URL in the redirects. * * @param string $url The needed URL. * * @return bool|string The found url or false if not found. */ protected function find_url( $url ) { $redirect_url = $this->search( $url ); if ( ! empty( $redirect_url ) ) { return $redirect_url; } return $this->find_url_fallback( $url ); } /** * Searches for the given URL in the redirects array. * * @param string $url The URL to search for. * * @return string|bool The found url or false if not found. */ protected function search( $url ) { if ( ! empty( $this->redirects[ $url ] ) ) { return $this->redirects[ $url ]; } return false; } /** * Searches for alternatives with slashes if requested URL isn't found. * * This will add a slash if there isn't a slash or it will remove a trailing slash when there isn't one. * * @todo Discuss: Maybe we should add slashes to all the values we handle instead of using a fallback. * * @param string $url The URL that have to be matched. * * @return bool|string The found url or false if not found. */ protected function find_url_fallback( $url ) { $no_trailing_slash = \rtrim( $url, '/' ); $checks = [ 'no_trailing_slash' => $no_trailing_slash, 'trailing_slash' => $no_trailing_slash . '/', ]; foreach ( $checks as $check ) { $redirect_url = $this->search( $check ); if ( ! empty( $redirect_url ) ) { return $redirect_url; } } return false; } /** * Parses the target URL. * * @param string $target_url The URL to parse. When there isn't found a scheme, just parse it based on the home URL. * * @return string The parsed url. */ protected function parse_target_url( $target_url ) { if ( $this->has_url_scheme( $target_url ) ) { return $target_url; } $target_url = $this->trailingslashit( $target_url ); $target_url = $this->format_for_multisite( $target_url ); return $this->home_url( $target_url ); } /** * Checks if given url has a scheme. * * @param string $url The url to check. * * @return bool True when url has scheme. */ protected function has_url_scheme( $url ) { $scheme = \wp_parse_url( $url, \PHP_URL_SCHEME ); return ! empty( $scheme ); } /** * Determines whether the target URL ends with a slash and adds one if necessary. * * @param string $target_url The url to format. * * @return string The url with trailing slash. */ protected function trailingslashit( $target_url ) { // Adds slash to target URL when permalink structure ends with a slash. if ( $this->requires_trailing_slash( $target_url ) ) { return \trailingslashit( $target_url ); } return $target_url; } /** * Formats the target url for the multisite if needed. * * @param string $target_url The url to format. * * @return string The formatted url. */ protected function format_for_multisite( $target_url ) { if ( ! \is_multisite() ) { return $target_url; } $blog_details = \get_blog_details(); if ( $blog_details && ! empty( $blog_details->path ) ) { $blog_path = \ltrim( $blog_details->path, '/' ); if ( ! empty( $blog_path ) && \strpos( $target_url, $blog_path ) === 0 ) { $target_url = \substr( $target_url, \strlen( $blog_path ) ); } } return $target_url; } /** * Gets the redirect URL by given URL. * * @param string $redirect_url The URL that has to be redirected. * * @return string The redirect url. */ protected function home_url( $redirect_url ) { $redirect_url = $this->strip_subdirectory( $redirect_url ); return \home_url( $redirect_url ); } /** * Strips the subdirectory from the given url. * * @param string $url The url to strip the subdirectory from. * * @return string The url with the stripped subdirectory. */ protected function strip_subdirectory( $url ) { return WPSEO_Redirect_Util::strip_base_url_path_from_url( $this->get_home_url(), $url ); } /** * Returns the URL PATH from the home url. * * @return string|null The url path or null if there isn't one. */ protected function get_home_url() { return \home_url(); } /** * Returns the redirects from the option table in the database. * * @return array The stored redirects. */ protected function get_redirects_from_options() { global $wpdb; static $redirects; if ( $redirects !== null ) { return $redirects; } // The code below is needed because we used to not autoload our redirect options. This fixes that. $all_options = \wp_cache_get( 'alloptions', 'options' ); foreach ( [ $this->normal_option_name, $this->regex_option_name ] as $option ) { $redirects[ $option ] = isset( $all_options[ $option ] ) ? \maybe_unserialize( $all_options[ $option ] ) : false; if ( $redirects[ $option ] === false ) { $redirects[ $option ] = \get_option( $option, false ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Normal methods only work if the option value has changed. $wpdb->update( $wpdb->options, [ 'autoload' => 'yes' ], [ 'option_name' => $option ] ); } } return $redirects; } /** * Sets the hook for setting the template include. This is the file that we want to show. * * @param string $template_to_set The template to look for. * * @return bool True when template should be included. */ protected function set_template_include_hook( $template_to_set ) { $this->template_file_path = $this->get_query_template( $template_to_set ); if ( ! empty( $this->template_file_path ) ) { \add_filter( 'template_include', [ $this, 'set_template_include' ] ); return true; } return false; } /** * Wraps the WordPress status_header function. * * @param int $code HTTP status code. * @param string $description Optional. A custom description for the HTTP status. * * @return void */ protected function status_header( $code, $description = '' ) { \status_header( $code, $description ); } /** * Returns instance of WP_Query. * * @return WP_Query Instance of WP_Query. */ protected function get_wp_query() { global $wp_query; if ( \is_object( $wp_query ) ) { return $wp_query; } return new WP_Query(); } /** * Handles the redirects without a target by setting the needed hooks. * * @param string $redirect_type The type of the redirect. * * @return void */ protected function handle_redirect_without_target( $redirect_type ) { if ( $redirect_type === 410 ) { \add_action( 'wp', [ $this, 'do_410' ] ); } if ( $redirect_type === 451 ) { \add_action( 'wp', [ $this, 'do_451' ] ); } } /** * Wrapper method for doing the actual redirect. * * @param string $location The path to redirect to. * @param int $status Status code to use. * * @return void */ protected function redirect( $location, $status = 302 ) { if ( ! \function_exists( 'wp_redirect' ) ) { require_once \ABSPATH . 'wp-includes/pluggable.php'; } \wp_redirect( $location, $status, 'Yoast SEO Premium' ); exit; } /** * Returns whether or not a target URL requires a trailing slash. * * @param string $target_url The target URL to check. * * @return bool True when trailing slash is required. */ protected function requires_trailing_slash( $target_url ) { return WPSEO_Redirect_Util::requires_trailing_slash( $target_url ); } /** * Returns the query template. * * @param string $filename Filename without extension. * * @return string Full path to template file. */ protected function get_query_template( $filename ) { return \get_query_template( $filename ); } }