array_merge( (array) $keys, array( self::MARK ) ) ); } // ---- Recognising sample records ------------------------------------------------------------------------------------ /** The batch a sample order, refund, product, option or customer carries; '' for the owner's own records. */ public static function batch_of( $thing ): string { if ( $thing instanceof \WC_Order_Refund ) { $own = (string) $thing->get_meta( self::MARK, true ); return $own !== '' ? $own : self::batch_of( wc_get_order( $thing->get_parent_id() ) ); } if ( $thing instanceof \WC_Abstract_Order ) { return (string) $thing->get_meta( self::MARK, true ); } if ( $thing instanceof \WC_Product ) { $own = (string) $thing->get_meta( self::MARK, true ); return $own === '' && $thing->get_parent_id() ? (string) get_post_meta( $thing->get_parent_id(), self::MARK, true ) : $own; } if ( $thing instanceof \WP_User ) { return (string) get_user_meta( $thing->ID, self::MARK, true ); } return ''; } public static function sample( $thing ): bool { return self::batch_of( $thing ) !== ''; } /** * A product of the store's current sample batch (the manifest's). A product still carrying a marker of a batch that is * gone (an older version's "1", a batch whose record was lost) is the owner's own: shoppers buy it, it is in the * sitemap and search engines are shown it. The purchase, sitemap and robots rules all ask this one question, so a * product is never sold and listed but marked noindex (21 Oct 2026: eight such products on the test store). */ public static function current_sample( $product ): bool { $batch = (string) ( self::manifest()['batch'] ?? '' ); return $batch !== '' && $product instanceof \WC_Product && self::batch_of( $product ) === $batch; } /** The manifest's ids of one kind: products, customers, orders or categories. */ public static function ids( string $kind ): array { return array_values( array_filter( array_map( 'intval', (array) ( self::manifest()[ $kind ] ?? array() ) ) ) ); } /** A count Today shows, less the sample records in it: published sample products, or sample orders in the order index. */ public static function without( string $kind, int $count ): int { global $wpdb; $ids = self::ids( $kind ); if ( $count <= 0 || ! $ids ) { return $count; } if ( $kind === 'products' ) { $samples = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type='product' AND post_status='publish' AND ID IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', $ids ) ); } elseif ( $kind === 'orders' ) { $samples = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE order_id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', array_merge( array( OrderIndex::table() ), $ids ) ) ); } else { $samples = 0; } return max( 0, $count - $samples ); } // ---- Guards -------------------------------------------------------------------------------------------------------- /** Registers the e-mail switch for every e-mail WooCommerce knows, StoreSuit's and other plugins' included. */ public static function watch_emails( $emails ) { foreach ( (array) $emails as $email ) { if ( $email instanceof \WC_Email && is_string( $email->id ) && $email->id !== '' ) { add_filter( 'woocommerce_email_enabled_' . $email->id, array( __CLASS__, 'mute' ), PHP_INT_MAX, 3 ); } } return $emails; } public static function mute( $enabled, $object = null, $email = null ) { if ( ! $enabled ) { return $enabled; } return self::about_sample( $object, $email ) ? false : $enabled; } /** An e-mail about a sample record is dropped even if its class never asked whether it is enabled. */ public static function mail_callback( $callback, $email = null ) { return $email instanceof \WC_Email && self::about_sample( $email->object ?? null, $email ) ? static fn(): bool => false : $callback; } private static function about_sample( $object, $email ): bool { if ( self::$busy ) { return true; } if ( self::sample( $object ) ) { return true; } // StoreSuit's back-in-stock e-mail keeps its product apart from the e-mail's object. return is_object( $email ) && isset( $email->product ) && self::sample( $email->product ); } public static function stock_notice( $send, $product_id = 0 ) { return $send && ( self::$busy || self::sample( wc_get_product( (int) $product_id ) ) ) ? false : $send; } /** A webhook never carries a sample record: while sample data is made or removed, nothing is delivered at all. */ public static function webhook( $deliver, $webhook = null, $arg = null ) { if ( ! $deliver ) { return $deliver; } if ( self::$busy ) { return false; } // A hook's first argument: an id, a record, or (a note to the customer) an array naming its order. $id = is_numeric( $arg ) ? (int) $arg : ( is_object( $arg ) && method_exists( $arg, 'get_id' ) ? (int) $arg->get_id() : ( is_array( $arg ) ? (int) ( $arg['order_id'] ?? 0 ) : 0 ) ); if ( $id <= 0 || ! $webhook instanceof \WC_Webhook ) { return $deliver; } $resource = is_array( $arg ) ? 'order' : (string) $webhook->get_resource(); if ( $resource === 'action' ) { // An action webhook names a hook (action.): its first argument is judged by the kind of record that hook is // about. current_action() would name this filter itself. $hook = (string) $webhook->get_event(); $resource = str_contains( $hook, 'customer' ) || str_contains( $hook, 'user' ) ? 'customer' : ( str_contains( $hook, 'product' ) ? 'product' : ( str_contains( $hook, 'order' ) ? 'order' : '' ) ); } switch ( $resource ) { case 'order': return in_array( $id, self::ids( 'orders' ), true ) || self::sample( wc_get_order( $id ) ) ? false : $deliver; case 'product': return in_array( $id, self::ids( 'products' ), true ) || self::sample( wc_get_product( $id ) ) ? false : $deliver; case 'customer': return in_array( $id, self::ids( 'customers' ), true ) || get_user_meta( $id, self::MARK, true ) !== '' ? false : $deliver; } return $deliver; } public static function order_stock( $can, $order = null ) { return $can && $order instanceof \WC_Abstract_Order && self::sample( $order ) ? false : $can; } public static function item_stock( $prevent, $item = null ) { return ! $prevent && $item instanceof \WC_Order_Item && self::sample( $item->get_order() ) ? true : $prevent; } /** Staff may still add a sample product to an order they make; a shopper cannot buy one. */ public static function purchasable( $purchasable, $product = null ) { // Without a sample batch no product carries the marker (Remove deletes every marked product or nothing): skip the // marker read, a database query for every product and option on every storefront page. if ( ! $purchasable || ! $product instanceof \WC_Product || empty( self::manifest()['batch'] ) || ! self::current_sample( $product ) ) { return $purchasable; } return current_user_can( 'storesuit_orders' ) || current_user_can( 'manage_woocommerce' ); } public static function sitemap( $args, $post_type = '' ) { $ids = $post_type === 'product' ? self::ids( 'products' ) : array(); if ( $ids && is_array( $args ) ) { $args['post__not_in'] = array_values( array_unique( array_merge( array_map( 'intval', (array) ( $args['post__not_in'] ?? array() ) ), $ids ) ) ); } return $args; } public static function robots( $robots ) { if ( is_array( $robots ) && function_exists( 'is_singular' ) && is_singular( 'product' ) && self::current_sample( wc_get_product( get_queried_object_id() ) ) ) { $robots['noindex'] = true; $robots['nofollow'] = true; } return $robots; } /** An option of a sample product (made in Quick Products or the product editor) belongs to the product's batch. */ public static function mark_variation( $variation ): void { if ( ! $variation instanceof \WC_Product_Variation || ! $variation->get_parent_id() || (string) $variation->get_meta( self::MARK, true ) !== '' ) { return; } $batch = (string) get_post_meta( $variation->get_parent_id(), self::MARK, true ); if ( $batch !== '' ) { $variation->update_meta_data( self::MARK, $batch ); } } /** A refund of a sample order belongs to the order's batch (WooCommerce saves a new refund once before announcing it). */ public static function mark_refund( $refund ): void { if ( ! $refund instanceof \WC_Order_Refund || (string) $refund->get_meta( self::MARK, true ) !== '' ) { return; } $batch = self::batch_of( wc_get_order( $refund->get_parent_id() ) ); if ( $batch !== '' ) { $refund->update_meta_data( self::MARK, $batch ); } } // ---- State --------------------------------------------------------------------------------------------------------- private static function manifest(): array { $v = get_option( self::OPTION, array() ); return is_array( $v ) ? $v : array(); } private static function hpos(): bool { return class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled(); } private static function orders_table(): string { global $wpdb; return $wpdb->prefix . 'wc_orders'; } private static function orders_meta_table(): string { global $wpdb; return $wpdb->prefix . 'wc_orders_meta'; } /** Why this store is not empty ('' when it is): products, orders or customer accounts of its own, the bin included. */ private static function occupied(): string { global $wpdb; if ( $wpdb->get_var( $wpdb->prepare( "SELECT p.ID FROM %i p WHERE p.post_type='product' AND p.post_status<>'auto-draft' AND NOT EXISTS (SELECT 1 FROM %i m WHERE m.post_id=p.ID AND m.meta_key=%s) LIMIT 1", $wpdb->posts, $wpdb->postmeta, self::MARK ) ) ) { return I18n::t( 'This store already has products. Sample data is only for a new, empty store.' ); } if ( self::hpos() ) { $order = $wpdb->get_var( $wpdb->prepare( "SELECT o.id FROM %i o WHERE o.status<>'auto-draft' AND NOT EXISTS (SELECT 1 FROM %i m WHERE m.order_id=o.id AND m.meta_key=%s) LIMIT 1", self::orders_table(), self::orders_meta_table(), self::MARK ) ); } else { $types = wc_get_order_types(); $order = $wpdb->get_var( $wpdb->prepare( 'SELECT p.ID FROM %i p WHERE p.post_type IN (' . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ") AND p.post_status<>'auto-draft' AND NOT EXISTS (SELECT 1 FROM %i m WHERE m.post_id=p.ID AND m.meta_key=%s) LIMIT 1", array_merge( array( $wpdb->posts ), $types, array( $wpdb->postmeta, self::MARK ) ) ) ); } if ( $order ) { return I18n::t( 'This store already has orders. Sample data is only for a new, empty store.' ); } if ( get_users( array( 'role__in'=>array( 'customer' ), 'number'=>1, 'fields'=>'ID', 'count_total'=>false, 'meta_query'=>array( array( 'key'=>self::MARK, 'compare'=>'NOT EXISTS' ) ) ) ) ) { return I18n::t( 'This store already has customer accounts. Sample data is only for a new, empty store.' ); } return ''; } /** * Every record of the batch that still exists: the manifest's, and any record carrying the batch the manifest missed (a * request that died between saving a record and noting it), with what WooCommerce would delete together with them. */ private static function records( string $batch, array $m ): array { global $wpdb; $ints = static fn( $ids ): array => array_values( array_unique( array_filter( array_map( 'intval', (array) $ids ) ) ) ); // The ids that still exist in $table ($column), only of one kind when $kind_column is given. $existing = static function ( array $ids, string $table, string $column, string $kind_column = '', string $kind = '' ) use ( $wpdb ): array { if ( ! $ids ) { return array(); } $found = array_map( 'intval', (array) ( $kind_column === '' ? $wpdb->get_col( $wpdb->prepare( 'SELECT %i FROM %i WHERE %i IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', array_merge( array( $column, $table, $column ), $ids ) ) ) : $wpdb->get_col( $wpdb->prepare( 'SELECT %i FROM %i WHERE %i=%s AND %i IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', array_merge( array( $column, $table, $kind_column, $kind, $column ), $ids ) ) ) ) ); sort( $found ); return $found; }; $order_table = self::hpos() ? array( self::orders_table(), 'id', 'type' ) : array( $wpdb->posts, 'ID', 'post_type' ); $marked_orders = self::hpos() ? $wpdb->get_col( $wpdb->prepare( "SELECT m.order_id FROM %i m JOIN %i o ON o.id=m.order_id AND o.type='shop_order' WHERE m.meta_key=%s AND m.meta_value=%s", self::orders_meta_table(), self::orders_table(), self::MARK, $batch ) ) : $wpdb->get_col( $wpdb->prepare( "SELECT m.post_id FROM %i m JOIN %i p ON p.ID=m.post_id AND p.post_type='shop_order' WHERE m.meta_key=%s AND m.meta_value=%s", $wpdb->postmeta, $wpdb->posts, self::MARK, $batch ) ); $set = array( 'batch' => $batch, 'orders' => $existing( $ints( array_merge( (array) ( $m['orders'] ?? array() ), (array) $marked_orders ) ), $order_table[0], $order_table[1], $order_table[2], 'shop_order' ), 'products' => $existing( $ints( array_merge( (array) ( $m['products'] ?? array() ), (array) $wpdb->get_col( $wpdb->prepare( "SELECT m.post_id FROM %i m JOIN %i p ON p.ID=m.post_id AND p.post_type='product' WHERE m.meta_key=%s AND m.meta_value=%s", $wpdb->postmeta, $wpdb->posts, self::MARK, $batch ) ) ) ), $wpdb->posts, 'ID', 'post_type', 'product' ), 'customers' => $existing( $ints( array_merge( (array) ( $m['customers'] ?? array() ), (array) $wpdb->get_col( $wpdb->prepare( 'SELECT user_id FROM %i WHERE meta_key=%s AND meta_value=%s', $wpdb->usermeta, self::MARK, $batch ) ) ) ), $wpdb->users, 'ID' ), 'categories' => $existing( $ints( array_merge( (array) ( $m['categories'] ?? array() ), (array) $wpdb->get_col( $wpdb->prepare( "SELECT tm.term_id FROM %i tm JOIN %i tt ON tt.term_id=tm.term_id AND tt.taxonomy='product_cat' WHERE tm.meta_key=%s AND tm.meta_value=%s", $wpdb->termmeta, $wpdb->term_taxonomy, self::MARK, $batch ) ) ) ), $wpdb->term_taxonomy, 'term_id', 'taxonomy', 'product_cat' ), ); // What WooCommerce deletes with an order: its child orders (refunds); with the order tables, any post under it too. $types = wc_get_order_types(); $set['children'] = $set['orders'] ? $ints( self::hpos() ? $wpdb->get_col( $wpdb->prepare( 'SELECT id FROM %i WHERE parent_order_id IN (' . implode( ',', array_fill( 0, count( $set['orders'] ), '%d' ) ) . ')', array_merge( array( self::orders_table() ), $set['orders'] ) ) ) : $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_parent IN (" . implode( ',', array_fill( 0, count( $set['orders'] ), '%d' ) ) . ') AND post_type IN (' . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ')', array_merge( $set['orders'], $types ) ) ) ) : array(); $set['order_posts'] = $set['orders'] ? array_values( array_diff( $ints( $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_parent IN (" . implode( ',', array_fill( 0, count( $set['orders'] ), '%d' ) ) . ')', $set['orders'] ) ) ), $set['children'] ) ) : array(); // What WooCommerce deletes with a product: its options; anything else under it would be detached by WordPress. $set['variations'] = $set['products'] ? $ints( $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type='product_variation' AND post_parent IN (" . implode( ',', array_fill( 0, count( $set['products'] ), '%d' ) ) . ')', $set['products'] ) ) ) : array(); $set['product_posts'] = $set['products'] ? $ints( $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type<>'product_variation' AND post_parent IN (" . implode( ',', array_fill( 0, count( $set['products'] ), '%d' ) ) . ')', $set['products'] ) ) ) : array(); return $set; } /** Before the first delete: every record carries the batch, and nothing of the owner's would go with it. Throws, deleting nothing. */ private static function verify( array $set ): void { global $wpdb; $batch = $set['batch']; foreach ( $set['orders'] as $id ) { $order = wc_get_order( $id ); if ( ! $order instanceof \WC_Order || (string) $order->get_meta( self::MARK, true ) !== $batch || $order->get_created_via() !== self::VIA ) { throw new \RuntimeException( I18n::t( 'An order no longer has the sample marker. Nothing was removed.' ) ); } } foreach ( $set['children'] as $id ) { $child = wc_get_order( $id ); if ( ! $child || (string) $child->get_meta( self::MARK, true ) !== $batch ) { throw new \RuntimeException( I18n::t( 'A sample order has a refund or linked order without the sample marker. Nothing was removed.' ) ); } } if ( $set['order_posts'] ) { throw new \RuntimeException( I18n::t( 'A file or other record is attached to a sample order. Nothing was removed.' ) ); } foreach ( $set['products'] as $id ) { $product = wc_get_product( $id ); if ( ! $product || (string) $product->get_meta( self::MARK, true ) !== $batch ) { throw new \RuntimeException( I18n::t( 'A product no longer has the sample marker. Nothing was removed.' ) ); } } foreach ( $set['variations'] as $id ) { if ( (string) get_post_meta( $id, self::MARK, true ) !== $batch ) { throw new \RuntimeException( I18n::t( 'A sample product has an option without the sample marker. Nothing was removed.' ) ); } } if ( $set['product_posts'] ) { throw new \RuntimeException( I18n::t( 'An image or file is attached to a sample product. Detach it in the Media Library first; nothing was removed.' ) ); } $reviewed = array_merge( $set['products'], $set['variations'] ); if ( $set['products'] && $wpdb->get_var( $wpdb->prepare( "SELECT comment_ID FROM {$wpdb->comments} WHERE comment_post_ID IN (" . implode( ',', array_fill( 0, count( $reviewed ), '%d' ) ) . ') LIMIT 1', $reviewed ) ) ) { throw new \RuntimeException( I18n::t( 'A sample product has reviews. Delete them first; nothing was removed.' ) ); } foreach ( $set['customers'] as $id ) { $user = get_userdata( $id ); if ( ! $user || (string) get_user_meta( $id, self::MARK, true ) !== $batch ) { throw new \RuntimeException( I18n::t( 'A customer no longer has the sample marker. Nothing was removed.' ) ); } if ( array_values( (array) $user->roles ) !== array( 'customer' ) ) { throw new \RuntimeException( I18n::t( 'A sample customer was given another role. Nothing was removed.' ) ); } if ( $wpdb->get_var( $wpdb->prepare( 'SELECT ID FROM %i WHERE post_author=%d LIMIT 1', $wpdb->posts, $id ) ) ) { throw new \RuntimeException( I18n::t( 'A sample customer is the author of content in this store. Nothing was removed.' ) ); } } foreach ( $set['categories'] as $id ) { if ( (string) get_term_meta( $id, self::MARK, true ) !== $batch ) { throw new \RuntimeException( I18n::t( 'A category no longer has the sample marker. Nothing was removed.' ) ); } } } /** Why removal must wait ('' when it need not): orders of the owner's own that use a sample product or customer. */ private static function in_use( array $set ): string { global $wpdb; $products = array_merge( $set['products'], $set['variations'] ); $found = array(); if ( $products ) { $found = (array) $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT i.order_id FROM %i i JOIN %i im ON im.order_item_id=i.order_item_id WHERE i.order_item_type='line_item' AND im.meta_key IN ('_product_id','_variation_id') AND im.meta_value IN (" . implode( ',', array_fill( 0, count( $products ), '%s' ) ) . ')', array_merge( array( $wpdb->prefix . 'woocommerce_order_items', $wpdb->prefix . 'woocommerce_order_itemmeta' ), array_map( 'strval', $products ) ) ) ); } if ( $set['customers'] ) { $types = wc_get_order_types(); $found = array_merge( $found, (array) ( self::hpos() ? $wpdb->get_col( $wpdb->prepare( 'SELECT id FROM %i WHERE customer_id IN (' . implode( ',', array_fill( 0, count( $set['customers'] ), '%d' ) ) . ')', array_merge( array( self::orders_table() ), $set['customers'] ) ) ) : $wpdb->get_col( $wpdb->prepare( "SELECT m.post_id FROM %i m JOIN %i p ON p.ID=m.post_id WHERE m.meta_key='_customer_user' AND m.meta_value IN (" . implode( ',', array_fill( 0, count( $set['customers'] ), '%s' ) ) . ') AND p.post_type IN (' . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ')', array_merge( array( $wpdb->postmeta, $wpdb->posts ), array_map( 'strval', $set['customers'] ), $types ) ) ) ) ); } $numbers = array(); foreach ( array_diff( array_unique( array_map( 'intval', $found ) ), $set['orders'], $set['children'] ) as $id ) { $order = wc_get_order( $id ); // Item rows of an order that no longer exists use nothing. if ( ! $order ) { continue; } if ( $order instanceof \WC_Order_Refund ) { $order = wc_get_order( $order->get_parent_id() ); if ( ! $order || in_array( $order->get_id(), $set['orders'], true ) ) { continue; } } // A draft (a checkout left unfinished, an order screen opened and left) holding only sample products is no sale; // WooCommerce clears such drafts itself. if ( in_array( $order->get_status(), array( 'checkout-draft', 'auto-draft', 'draft' ), true ) && ! in_array( (int) $order->get_customer_id(), $set['customers'], true ) && self::only_samples( $order, $products ) ) { continue; } $numbers[ $order->get_id() ] = '#' . $order->get_order_number() . ( $order->get_status() === 'trash' ? ' (' . I18n::t( 'in the bin' ) . ')' : '' ); } if ( ! $numbers ) { return ''; } ksort( $numbers ); return I18n::format( 'Sample data stays while orders of your own use a sample product or customer: %s. If they were only tests, delete them permanently first.', implode( ', ', array_slice( $numbers, 0, 10 ) ) . ( count( $numbers ) > 10 ? ', …' : '' ) ); } private static function only_samples( \WC_Order $order, array $products ): bool { $items = $order->get_items(); foreach ( $items as $item ) { if ( ! $item instanceof \WC_Order_Item_Product || ! in_array( (int) ( $item->get_variation_id() ?: $item->get_product_id() ), $products, true ) ) { return false; } } return (bool) $items; } /** The owner's own products (the bin included) or sub-categories use this category, or it is the store's default one. */ private static function shared_category( int $term_id, array $products ): bool { global $wpdb; if ( (int) get_option( 'default_product_cat' ) === $term_id ) { return true; } $objects = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT tr.object_id FROM %i tr JOIN %i tt ON tt.term_taxonomy_id=tr.term_taxonomy_id WHERE tt.term_id=%d AND tt.taxonomy='product_cat'", $wpdb->term_relationships, $wpdb->term_taxonomy, $term_id ) ) ); if ( array_diff( $objects, $products ) ) { return true; } return (bool) $wpdb->get_var( $wpdb->prepare( "SELECT term_id FROM %i WHERE taxonomy='product_cat' AND parent=%d LIMIT 1", $wpdb->term_taxonomy, $term_id ) ); } /** Store::lock, taking over a lock a dead request left behind (Store::lock itself never expires one). */ private static function locked( callable $work ) { global $wpdb; $name = 'storesuit_lock_' . md5( self::LOCK ); $held = $wpdb->get_var( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name=%s', $wpdb->options, $name ) ); if ( is_string( $held ) ) { $lock = maybe_unserialize( $held ); // Only the exact row that was read is removed: of two requests taking over at once, one gets the lock. if ( is_array( $lock ) && (int) ( $lock['time'] ?? 0 ) < time() - self::STALE ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name=%s AND option_value=%s', $wpdb->options, $name, $held ) ); wp_cache_delete( $name, 'options' ); } } return Store::lock( self::LOCK, $work ); } /** No e-mail, webhook or HTTP request leaves the site while sample records are made or removed (a backstop to the guards). */ private static function quiet( callable $work ) { $mail = static fn() => true; $http = static fn() => new \WP_Error( 'storesuit_demo_no_http', I18n::t( 'Sample data never calls outside services.' ) ); add_filter( 'pre_wp_mail', $mail, PHP_INT_MAX ); add_filter( 'pre_http_request', $http, PHP_INT_MAX ); $was = self::$busy; self::$busy = true; try { return $work(); } finally { self::$busy = $was; remove_filter( 'pre_wp_mail', $mail, PHP_INT_MAX ); remove_filter( 'pre_http_request', $http, PHP_INT_MAX ); } } private static function note( array &$m, string $kind, int $id ): void { $m[ $kind ][] = $id; update_option( self::OPTION, $m, false ); } private static function forget( array &$m, string $kind, int $id ): void { $m[ $kind ] = array_values( array_diff( array_map( 'intval', (array) ( $m[ $kind ] ?? array() ) ), array( $id ) ) ); update_option( self::OPTION, $m, false ); } // ---- Owner calls --------------------------------------------------------------------------------------------------- public static function status(): array { Store::require_cap( 'manage_woocommerce' ); $m = self::manifest(); $batch = (string) ( $m['batch'] ?? '' ); if ( $batch === '' ) { $why = self::occupied(); return array( 'available'=>$why === '', 'has_demo'=>false, 'can_remove'=>false, 'reason'=>$why, 'counts'=>array( 'products'=>0, 'customers'=>0, 'orders'=>0 ) ); } $set = self::records( $batch, $m ); $why = self::in_use( $set ); return array( 'available'=>false, 'has_demo'=>true, 'can_remove'=>$why === '', 'reason'=>$why, 'counts'=>array( 'products'=>count( $set['products'] ), 'customers'=>count( $set['customers'] ), 'orders'=>count( $set['orders'] ) ) ); } public static function create( array $data ): array { Store::require_cap( 'manage_woocommerce' ); if ( empty( $data['confirmed'] ) ) { throw new \InvalidArgumentException( I18n::t( 'Confirm that these are sample records before creating them.' ) ); } return self::locked( static function () { if ( ! empty( self::manifest()['batch'] ) ) { throw new \InvalidArgumentException( I18n::t( 'This store already has sample data. Remove it before creating it again.' ) ); } $why = self::occupied(); if ( $why !== '' ) { throw new \InvalidArgumentException( $why ); } $batch = wp_generate_uuid4(); $m = array( 'batch'=>$batch, 'version'=>2, 'created'=>time(), 'categories'=>array(), 'products'=>array(), 'customers'=>array(), 'orders'=>array() ); update_option( self::OPTION, $m, false ); if ( ( self::manifest()['batch'] ?? '' ) !== $batch ) { throw new \RuntimeException( I18n::t( 'The sample data record could not be saved. Nothing was created.' ) ); } try { self::quiet( static function () use ( &$m, $batch ) { self::make( $m, $batch ); } ); } catch ( \Throwable $e ) { // The manifest stays, so Remove sample data clears every marked record made so far; nothing unmarked was touched. throw new \RuntimeException( I18n::format( 'Sample setup stopped: %s. Use Remove sample data to clear marked records.', $e->getMessage() ), 0, $e ); } Store::audit( 'settings', 0, 'demo_data_created', array( 'products'=>count( $m['products'] ), 'customers'=>count( $m['customers'] ), 'orders'=>count( $m['orders'] ) ) ); return self::status(); } ); } private static function make( array &$m, string $batch ): void { $short = substr( str_replace( '-', '', $batch ), 0, 12 ); $name = I18n::t( 'Sample collection' ); $term = wp_insert_term( $name, 'product_cat', array( 'slug'=>'storesuit-sample-' . substr( $short, 0, 8 ) ) ); // The owner may already have a category of this name. if ( is_wp_error( $term ) && $term->get_error_code() === 'term_exists' ) { $term = wp_insert_term( $name . ' ' . strtoupper( substr( $short, 0, 4 ) ), 'product_cat', array( 'slug'=>'storesuit-sample-' . substr( $short, 0, 8 ) ) ); } if ( is_wp_error( $term ) ) { throw new \RuntimeException( $term->get_error_message() ); } $term_id = (int) $term['term_id']; $meta = add_term_meta( $term_id, self::MARK, $batch, true ); if ( ! $meta || is_wp_error( $meta ) ) { wp_delete_term( $term_id, 'product_cat' ); throw new \RuntimeException( I18n::t( 'The sample category could not be marked safely.' ) ); } self::note( $m, 'categories', $term_id ); $decimals = wc_get_price_decimals(); foreach ( array( array( 'Sample cotton tote', 'TOTE', '24.90', 12 ), array( 'Sample ceramic mug', 'MUG', '14.50', 8 ), array( 'Sample notebook', 'NOTE', '9.90', 20 ) ) as [ $title, $code, $price, $stock ] ) { $price = wc_format_decimal( $price, $decimals ); $product = new \WC_Product_Simple(); $product->set_name( I18n::t( $title ) ); $product->set_status( 'publish' ); $product->set_catalog_visibility( 'hidden' ); $product->set_sku( 'SS-DEMO-' . strtoupper( substr( $short, 0, 8 ) ) . '-' . $code ); $product->set_regular_price( $price ); $product->set_price( $price ); $product->set_manage_stock( true ); $product->set_stock_quantity( $stock ); $product->set_category_ids( array( $term_id ) ); $product->set_reviews_allowed( false ); $product->set_description( I18n::t( 'Sample product for learning StoreRuler. Not a real item for sale.' ) ); $product->update_meta_data( self::MARK, $batch ); $product->save(); if ( ! $product->get_id() ) { throw new \RuntimeException( I18n::t( 'A sample product could not be saved.' ) ); } self::note( $m, 'products', $product->get_id() ); } $emails = array(); for ( $i = 0; $i < 2; $i++ ) { $suffix = $short . '-' . $i; $emails[ $i ] = 'sample-' . $suffix . '@example.invalid'; // The marker is written with the account, before WordPress announces the new user. $id = wp_insert_user( array( 'user_login'=>'storesuit-sample-' . $suffix, 'user_pass'=>wp_generate_password( 32, true, true ), 'user_email'=>$emails[ $i ], 'display_name'=>I18n::format( 'Sample customer %s', $i + 1 ), 'first_name'=>I18n::t( 'Sample' ), 'last_name'=>I18n::format( 'Customer %s', $i + 1 ), 'role'=>'customer', 'meta_input'=>array( self::MARK=>$batch ) ) ); if ( is_wp_error( $id ) ) { throw new \RuntimeException( $id->get_error_message() ); } if ( (string) get_user_meta( (int) $id, self::MARK, true ) !== $batch ) { throw new \RuntimeException( I18n::t( 'A sample customer could not be marked safely.' ) ); } self::note( $m, 'customers', (int) $id ); } $gateways = WC()->payment_gateways() ? WC()->payment_gateways()->payment_gateways() : array(); $bank = isset( $gateways['bacs'] ) ? (string) $gateways['bacs']->get_title() : I18n::t( 'Bank transfer' ); // Waiting for payment and on hold by bank transfer (so the order desk can record a payment), and one paid order to prepare. foreach ( array( array( 'pending', 0, 1, 'bacs' ), array( 'on-hold', 1, 2, 'bacs' ), array( 'processing', 0, 0, '' ) ) as [ $status, $customer, $index, $method ] ) { $product = wc_get_product( $m['products'][ $index ] ); if ( ! $product ) { throw new \RuntimeException( I18n::t( 'A sample product could not be saved.' ) ); } $price = (float) $product->get_price(); $order = new \WC_Order(); $order->set_created_via( self::VIA ); $order->set_customer_id( $m['customers'][ $customer ] ); $order->set_currency( get_woocommerce_currency() ); $order->set_billing_first_name( I18n::t( 'Sample' ) ); $order->set_billing_last_name( I18n::format( 'Customer %s', $customer + 1 ) ); $order->set_billing_email( $emails[ $customer ] ); if ( $method !== '' ) { $order->set_payment_method( $method ); $order->set_payment_method_title( $bank ); } $item = new \WC_Order_Item_Product(); $item->set_product( $product ); $item->set_quantity( 1 ); $item->set_subtotal( $price ); $item->set_total( $price ); $order->add_item( $item ); $order->set_total( $price ); if ( $status === 'processing' ) { $order->set_date_paid( time() ); } $order->update_meta_data( self::MARK, $batch ); // The status is set as if read from the database, so WooCommerce records no status change: no status hook, paid // hook, stock step, sales count or status e-mail runs for a sample order. $order->set_object_read( false ); $order->set_status( $status ); $order->set_object_read( true ); $order->save(); if ( ! $order->get_id() ) { throw new \RuntimeException( I18n::t( 'A sample order could not be saved.' ) ); } self::note( $m, 'orders', $order->get_id() ); $order->add_order_note( I18n::t( 'StoreRuler sample order: it sends no e-mail, moves no stock and is removed with the sample data.' ) ); } } /** Every target is verified before the first permanent delete; the manifest keeps what is left if a delete fails. */ public static function remove( array $data ): array { Store::require_cap( 'manage_woocommerce' ); if ( empty( $data['confirmed'] ) ) { throw new \InvalidArgumentException( I18n::t( 'Confirm the permanent removal of sample data.' ) ); } return self::locked( static function () { $m = self::manifest(); $batch = (string) ( $m['batch'] ?? '' ); if ( $batch === '' ) { return self::status(); } $set = self::records( $batch, $m ); self::verify( $set ); $why = self::in_use( $set ); if ( $why !== '' ) { throw new \InvalidArgumentException( $why ); } $keep = array_values( array_filter( $set['categories'], static fn( $id ) => self::shared_category( $id, $set['products'] ) ) ); $kept = array(); self::quiet( static function () use ( $set, $keep, &$kept, &$m ) { global $wpdb; foreach ( $set['orders'] as $id ) { self::delete_order( $id, $set['children'] ); self::forget( $m, 'orders', $id ); } foreach ( $set['products'] as $id ) { self::delete_product( $id ); self::forget( $m, 'products', $id ); } foreach ( $set['customers'] as $id ) { self::delete_customer( $id ); self::forget( $m, 'customers', $id ); } foreach ( $set['categories'] as $id ) { $term = get_term( $id, 'product_cat' ); if ( $term instanceof \WP_Term ) { if ( in_array( $id, $keep, true ) ) { // The owner's products or sub-categories use it: it stays as an ordinary category of theirs. delete_term_meta( $id, self::MARK ); $kept[] = $term->name; } elseif ( true !== wp_delete_term( $id, 'product_cat' ) ) { throw new \RuntimeException( I18n::t( 'The sample category could not be removed. Try again.' ) ); } } self::forget( $m, 'categories', $id ); } // StoreSuit's own notes about the sample orders and products: payment keys and change stamps. $orders = array_merge( $set['orders'], $set['children'] ); if ( $orders && $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( Store::keys_table() ) ) ) === Store::keys_table() ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE order_id IN (' . implode( ',', array_fill( 0, count( $orders ), '%d' ) ) . ')', array_merge( array( Store::keys_table() ), $orders ) ) ); } foreach ( array_merge( $set['products'], $set['variations'] ) as $id ) { delete_option( 'storesuit_revision_stock_' . $id ); delete_option( 'storesuit_revision_cost_' . $id ); } } ); delete_option( self::OPTION ); Store::audit( 'settings', 0, 'demo_data_removed', array( 'products'=>count( $set['products'] ), 'customers'=>count( $set['customers'] ), 'orders'=>count( $set['orders'] ), 'kept'=>$keep ) ); return self::status() + array( 'removed'=>true, 'kept'=>$kept ); } ); } /** Whether any of these orders (or refunds) is still stored. */ private static function orders_left( array $ids ): bool { global $wpdb; $ids = array_values( array_map( 'intval', $ids ) ); return (bool) ( self::hpos() ? $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ') LIMIT 1', array_merge( array( self::orders_table() ), $ids ) ) ) : $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ') LIMIT 1', $ids ) ) ); } private static function delete_order( int $id, array $children ): void { global $wpdb; $order = wc_get_order( $id ); if ( ! $order ) { return; } // The order and its refunds (checked above as this batch's; WooCommerce deletes them with the order). $owners = array_merge( array( $id ), array_values( array_intersect( $children, array_map( 'intval', (array) ( self::hpos() ? $wpdb->get_col( $wpdb->prepare( 'SELECT id FROM %i WHERE parent_order_id=%d', self::orders_table(), $id ) ) : $wpdb->get_col( $wpdb->prepare( 'SELECT ID FROM %i WHERE post_parent=%d', $wpdb->posts, $id ) ) ) ) ) ) ); // Their notes go with them in either storage (the order tables would leave them behind). foreach ( array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT comment_ID FROM {$wpdb->comments} WHERE comment_post_ID IN (" . implode( ',', array_fill( 0, count( $owners ), '%d' ) ) . ')', $owners ) ) ) as $note ) { wp_delete_comment( $note, true ); } $order->delete( true ); // The order tables delete a refund without its lines; those lines are the sample refund's own. foreach ( array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT order_item_id FROM {$wpdb->prefix}woocommerce_order_items WHERE order_id IN (" . implode( ',', array_fill( 0, count( $owners ), '%d' ) ) . ')', $owners ) ) ) as $line ) { wc_delete_order_item( $line ); } if ( self::orders_left( $owners ) ) { throw new \RuntimeException( I18n::t( 'A sample order could not be removed. Try again.' ) ); } } private static function delete_product( int $id ): void { global $wpdb; $product = wc_get_product( $id ); if ( ! $product ) { return; } $product->delete( true ); if ( $wpdb->get_var( $wpdb->prepare( 'SELECT ID FROM %i WHERE ID=%d OR post_parent=%d LIMIT 1', $wpdb->posts, $id, $id ) ) ) { throw new \RuntimeException( I18n::t( 'A sample product could not be removed. Try again.' ) ); } } private static function delete_customer( int $id ): void { if ( ! get_userdata( $id ) ) { return; } require_once ABSPATH . 'wp-admin/includes/user.php'; $reassign = get_current_user_id() ?: null; if ( is_multisite() ) { require_once ABSPATH . 'wp-admin/includes/ms.php'; // A network account that only this site uses goes completely; one another site also uses only leaves this site. if ( count( (array) get_blogs_of_user( $id ) ) > 1 ) { $done = remove_user_from_blog( $id, get_current_blog_id(), (int) $reassign ); if ( true !== $done ) { throw new \RuntimeException( I18n::t( 'A sample customer could not be removed. Try again.' ) ); } delete_user_meta( $id, self::MARK ); return; } $done = wpmu_delete_user( $id ); } else { $done = wp_delete_user( $id, $reassign ); } clean_user_cache( $id ); if ( ! $done || get_userdata( $id ) ) { throw new \RuntimeException( I18n::t( 'A sample customer could not be removed. Try again.' ) ); } } }