WooCommerce item type. */ const ITEM_TYPES = array( 'line_items' => 'line_item', 'tax_lines' => 'tax', 'shipping_lines' => 'shipping', 'fee_lines' => 'fee', 'coupon_lines' => 'coupon', ); /** * Longest meta key (bytes) kept whole through SafeDump; stored keys are at most 255. */ const MAX_KEY_BYTES = 1000; /** * Most items kept per array inside one meta value (a foreign value that can be large). */ const MAX_META_ITEMS = 1000; /** * Order properties in the output, in REST API order. */ const ORDER_FIELDS = array( 'id', 'parent_id', 'number', 'order_key', 'created_via', 'version', 'status', 'currency', 'date_created', 'date_created_gmt', 'date_modified', 'date_modified_gmt', 'discount_total', 'discount_tax', 'shipping_total', 'shipping_tax', 'cart_tax', 'total', 'total_tax', 'prices_include_tax', 'customer_id', 'customer_ip_address', 'customer_user_agent', 'customer_note', 'billing', 'shipping', 'payment_method', 'payment_method_title', 'transaction_id', 'date_paid', 'date_paid_gmt', 'date_completed', 'date_completed_gmt', 'cart_hash' ); /** * Order properties that open the order to anyone holding them (order key, pay link) or * identify the shopper's connection or payment: shown only to people who may reveal masked * values. */ const REVEAL_ONLY_FIELDS = array( 'order_key', 'payment_url', 'customer_ip_address', 'transaction_id' ); /** * Deepest nesting walked inside a meta value when masking. */ const MAX_MASK_DEPTH = 32; /** * Money properties of the order, formatted as decimals. */ const ORDER_DECIMALS = array( 'discount_total', 'discount_tax', 'shipping_total', 'shipping_tax', 'cart_tax', 'total', 'total_tax' ); /** * Money properties of items, formatted as decimals. */ const ITEM_DECIMALS = array( 'subtotal', 'subtotal_tax', 'total', 'total_tax', 'tax_total', 'shipping_tax_total' ); /** * Date properties of the order (each also gets a `_gmt` twin). */ const ORDER_DATES = array( 'date_created', 'date_modified', 'date_completed', 'date_paid' ); /** * Most items listed per item type (clamped to 1–1000). * * @since 1.0.0 * @param WC_Order $order Order. * @return int */ public static function max_items( WC_Order $order ): int { /** * Most items of each type (line items, shipping, fees, …) in the order JSON. More are left * out and counted in `_truncated`. Clamped to 1–1000. * * @since 1.0.0 * @param int $max Items per type. Default 250. * @param WC_Order $order Order. */ $max = apply_filters( 'apwc_order_json_max_items', 250, $order ); return max( 1, min( 1000, is_numeric( $max ) ? (int) $max : 250 ) ); } /** * Longest string in the order JSON before it's cut (clamped to 100–100000). * * @since 1.0.0 * @param WC_Order $order Order. * @return int */ public static function max_chars( WC_Order $order ): int { /** * Longest string (in characters) in the order JSON. Longer ones end in * "…[truncated: N chars]". Clamped to 100–100000. * * @since 1.0.0 * @param int $max Characters. Default 10000. * @param WC_Order $order Order. */ $max = apply_filters( 'apwc_order_json_max_chars', 10000, $order ); return max( 100, min( 100000, is_numeric( $max ) ? (int) $max : 10000 ) ); } /** * The order as JSON-ready data, masked. * * @since 1.0.0 * @param WC_Order $order Order. * @return array{json: array, masked: string[], truncated: array} * `masked`: paths of masked values; `truncated`: item type => how many there are, for * types over the limit. */ public static function build( WC_Order $order ): array { $max_items = self::max_items( $order ); $max_chars = self::max_chars( $order ); $data = self::order_data( $order ); $data['meta_data'] = self::meta_list( $order->get_meta_data() ); $truncated = array(); $lists = array(); foreach ( self::ITEM_TYPES as $key => $type ) { $items = array_values( $order->get_items( $type ) ); if ( count( $items ) > $max_items ) { $truncated[ $key ] = count( $items ); $items = array_slice( $items, 0, $max_items ); } $lists[ $key ] = $items; } self::prime_products( $lists['line_items'] ); foreach ( $lists as $key => $items ) { $data[ $key ] = array_map( static fn( $item ) => self::item_data( $item ), $items ); } $data['refunds'] = array(); foreach ( $order->get_refunds() as $refund ) { $data['refunds'][] = array( 'id' => $refund->get_id(), 'reason' => (string) $refund->get_reason(), 'total' => '-' . wc_format_decimal( $refund->get_amount(), self::dp() ), 'total_tax' => wc_format_decimal( (float) $refund->get_total_tax(), self::dp() ), ); } $data['payment_url'] = $order->get_checkout_payment_url(); $data['is_editable'] = $order->is_editable(); $data['needs_payment'] = $order->needs_payment(); $data['needs_processing'] = $order->needs_processing(); $data['customer'] = self::customer( $order ); if ( $truncated ) { $data['_truncated'] = $truncated; } /** * Filter the order JSON shown by the Order meta inspector. Runs before masking: meta keys * that match the sensitive patterns are masked afterwards whatever this returns, and any * object is shown as "[object Class]" (its methods never run). * * @since 1.0.0 * @param array $data REST-shaped order data plus `customer`. * @param WC_Order $order Order. */ $data = apply_filters( 'apwc_order_json', self::safe( $data, $max_chars ), $order ); // Again after the filter, for anything it added. Room for the "…[truncated: N chars]" // marker, so strings cut before the filter aren't cut twice. $data = self::safe( $data, $max_chars + 64 ); $masked = array(); $revealer = MetaMask::can_reveal(); $data = self::mask( $data, $revealer ? MetaMask::patterns() : array_merge( MetaMask::patterns(), self::reveal_only_patterns() ), $masked ); if ( ! $revealer ) { foreach ( self::REVEAL_ONLY_FIELDS as $field ) { if ( isset( $data[ $field ] ) && '' !== $data[ $field ] && self::MASK !== $data[ $field ] ) { $data[ $field ] = self::MASK; $masked[] = $field; } } } return array( 'json' => $data, 'masked' => $masked, 'truncated' => $truncated, ); } /** * Data through SafeDump: objects become "[object Class]" labels, long strings are cut. * * @since 1.0.0 * @param mixed $data Data. * @param int $max_chars Longest string. * @return array */ private static function safe( $data, int $max_chars ): array { $data = is_array( $data ) ? $data : array(); // Meta keys are never cut: a cut key could slip past a mask pattern ("…_secret"). $keys = array(); self::each_meta_list( $data, static function ( $entries, string $path ) use ( &$keys ) { foreach ( $entries as $index => $meta ) { foreach ( array( 'key', 'display_key' ) as $field ) { $text = is_array( $meta ) ? ( $meta[ $field ] ?? null ) : null; if ( is_string( $text ) && strlen( $text ) <= self::MAX_KEY_BYTES && mb_check_encoding( $text, 'UTF-8' ) ) { $keys[ $path ][ $index ][ $field ] = $text; } } } return $entries; } ); $data = SafeDump::to_array( $data, 32, $max_chars ); $data = is_array( $data ) ? $data : array(); return self::each_meta_list( $data, static function ( $entries, string $path ) use ( $keys ) { foreach ( $keys[ $path ] ?? array() as $index => $fields ) { if ( isset( $entries[ $index ] ) && is_array( $entries[ $index ] ) ) { $entries[ $index ] = array_merge( $entries[ $index ], $fields ); } } return $entries; } ); } /** * Run a callback on every meta list: the order's `meta_data` and each item's. * * @since 1.0.0 * @param array $data Order data. * @param callable $callback Gets a list and its path; returns the new list. * @return array */ private static function each_meta_list( array $data, callable $callback ): array { if ( isset( $data['meta_data'] ) && is_array( $data['meta_data'] ) ) { $data['meta_data'] = $callback( $data['meta_data'], 'meta_data' ); } foreach ( array_keys( self::ITEM_TYPES ) as $list ) { if ( ! isset( $data[ $list ] ) || ! is_array( $data[ $list ] ) ) { continue; } foreach ( $data[ $list ] as $index => $item ) { if ( is_array( $item ) && isset( $item['meta_data'] ) && is_array( $item['meta_data'] ) ) { $data[ $list ][ $index ]['meta_data'] = $callback( $item['meta_data'], $list . '[' . $index . '].meta_data' ); } } } return $data; } /** * Where orders are stored, for the note under the JSON (not part of the JSON). * * @since 1.0.0 * @return array{storage: string, sync: bool} */ public static function context(): array { return array( 'storage' => Orders::hpos() ? 'hpos' : 'posts', 'sync' => 'yes' === get_option( 'woocommerce_custom_orders_table_data_sync_enabled' ), ); } /** * Meta key patterns masked, on top of the sensitive ones, for people who may not reveal * masked values: order keys, pay links, IP addresses and payment transaction IDs. * * @since 1.0.0 * @return string[] Wildcard patterns. */ public static function reveal_only_patterns(): array { /** * Filter the meta key patterns ("*" = anything, case and accents ignored) masked in the * Order meta box and JSON only for people who may not reveal masked values (the saved * sensitive patterns are masked for everyone). * * @since 1.0.0 * @param string[] $patterns Patterns. */ $patterns = apply_filters( 'apwc_order_meta_reveal_only_patterns', array( '*order_key*', '*payment_url*', '*customer_ip*', '*ip_address*', '*transaction_id*' ) ); return array_values( array_filter( is_array( $patterns ) ? $patterns : array(), static fn( $p ) => is_string( $p ) && '' !== trim( $p ) ) ); } /** * Mask values nested anywhere inside a value (an array read from meta) whose string key * matches a pattern, e.g. `access_token` inside a gateway's `_payment_payload`. * * @since 1.0.0 * @param mixed $value Value (already through SafeDump: arrays and scalars only). * @param string[] $patterns Patterns. * @param string $path Path of the value. * @param string[] $masked Paths of masked values (filled). * @param int $depth Current depth. * @return mixed */ public static function mask_nested( $value, array $patterns, string $path, array &$masked, int $depth = 0 ) { if ( ! $patterns ) { return $value; } if ( is_string( $value ) ) { // JSON text (a gateway response kept as a string): decoded, never unserialized. Masked // whole when a sensitive key is inside. $decoded = self::json_value( $value ); if ( null === $decoded ) { // Looks like JSON but doesn't decode: cut short (only the start is shown), nested // too deep, or broken. Fail closed on the text itself: any "key": in it counts. if ( self::looks_like_json( $value ) && self::text_has_sensitive_key( $value, $patterns ) ) { $masked[] = $path; return self::MASK; } return $value; } $hits = array(); self::mask_nested( $decoded, $patterns, $path, $hits, $depth + 1 ); if ( $hits ) { $masked[] = $path; return self::MASK; } return $value; } if ( ! is_array( $value ) ) { return $value; } foreach ( $value as $key => $child ) { $here = $path . '.' . $key; if ( is_string( $key ) && MetaMask::matches( $key, $patterns ) ) { $value[ $key ] = self::MASK; $masked[] = $here; } elseif ( is_array( $child ) || is_string( $child ) ) { if ( $depth >= self::MAX_MASK_DEPTH ) { // Too deep to check: fail closed (and say so, so a JSON string holding it is masked whole). $value[ $key ] = self::MASK; $masked[] = $here; } else { $value[ $key ] = self::mask_nested( $child, $patterns, $here, $masked, $depth + 1 ); } } } return $value; } /** * A string's JSON object or array, or null when it isn't one. * * @param string $text Text. * @return array|null */ private static function json_value( string $text ): ?array { if ( ! self::looks_like_json( $text ) ) { return null; } $decoded = json_decode( ltrim( $text ), true, 512 ); return is_array( $decoded ) ? $decoded : null; } /** * Whether a string starts (after spaces) like a JSON object or array. * * @param string $text Text. * @return bool */ private static function looks_like_json( string $text ): bool { $text = ltrim( $text ); return '' !== $text && ( '{' === $text[0] || '[' === $text[0] ); } /** * Whether JSON-like text has a `"key":` matching a pattern anywhere, read from the text * itself (for JSON that doesn't decode). One linear pass: possessive quantifiers, no * backtracking; `\u` escapes in keys are decoded before matching. * * @param string $text Text. * @param string[] $patterns Patterns. * @return bool */ private static function text_has_sensitive_key( string $text, array $patterns ): bool { if ( ! preg_match_all( '/"((?:[^"\\\\]++|\\\\.)*+)"\s*+:/s', $text, $found ) ) { // No keys, or the scan hit a PCRE limit: then fail closed. return PREG_NO_ERROR !== preg_last_error(); } foreach ( array_unique( $found[1] ) as $raw ) { $key = json_decode( '"' . $raw . '"' ); if ( MetaMask::matches( is_string( $key ) ? $key : $raw, $patterns ) ) { return true; } } return false; } /** * Mask every order and item meta value whose key matches a pattern (also `display_value`). * * @since 1.0.0 * @param array $data Order data. * @param string[] $patterns Sensitive key patterns. * @param string[] $masked Paths of masked values (filled). * @return array */ public static function mask( array $data, array $patterns, array &$masked ): array { // A meta list that isn't a list (a filter replaced it) is masked whole. if ( isset( $data['meta_data'] ) && ! is_array( $data['meta_data'] ) ) { $data['meta_data'] = self::MASK; $masked[] = 'meta_data'; } foreach ( array_keys( self::ITEM_TYPES ) as $list ) { foreach ( is_array( $data[ $list ] ?? null ) ? $data[ $list ] : array() as $index => $item ) { if ( is_array( $item ) && isset( $item['meta_data'] ) && ! is_array( $item['meta_data'] ) ) { $data[ $list ][ $index ]['meta_data'] = self::MASK; $masked[] = $list . '[' . $index . '].meta_data'; } } } return self::each_meta_list( $data, static function ( $entries, string $path ) use ( $patterns, &$masked ) { return self::mask_list( $entries, $patterns, $path, $masked ); } ); } /** * Mask matching entries of one meta list ([ {id, key, value, …}, … ]). * * Anything in the list that isn't a well-formed entry with a string key is replaced by the * mask too, so a filter can't hide a value under an unexpected shape. * * @since 1.0.0 * @param mixed $entries Meta list. * @param string[] $patterns Patterns. * @param string $path Path of the list. * @param string[] $masked Paths of masked values (filled). * @return mixed */ private static function mask_list( $entries, array $patterns, string $path, array &$masked ) { if ( ! is_array( $entries ) ) { return $entries; } foreach ( $entries as $index => $meta ) { $here = $path . '[' . $index . ']'; if ( ! is_array( $meta ) || ! isset( $meta['key'] ) || ! is_string( $meta['key'] ) ) { if ( null !== $meta ) { $entries[ $index ] = self::MASK; $masked[] = $here; } continue; } // Fail closed: a key that was cut can't be checked, so its value is masked. if ( false === strpos( $meta['key'], '…[truncated:' ) && ! MetaMask::matches( $meta['key'], $patterns ) ) { // The key is fine: mask matching keys nested inside the value. foreach ( array( 'value', 'display_value' ) as $field ) { if ( isset( $meta[ $field ] ) && ( is_array( $meta[ $field ] ) || is_string( $meta[ $field ] ) ) ) { $entries[ $index ][ $field ] = self::mask_nested( $meta[ $field ], $patterns, $here . '.' . $field, $masked ); } } continue; } foreach ( array( 'value', 'display_value' ) as $field ) { if ( array_key_exists( $field, $meta ) ) { $entries[ $index ][ $field ] = self::MASK; } } $masked[] = $here . '.value'; } return $entries; } /** * Order properties: dates local + GMT, money as decimals, status without "wc-". * * @since 1.0.0 * @param WC_Order $order Order. * @return array */ private static function order_data( WC_Order $order ): array { $data = $order->get_base_data(); foreach ( self::ORDER_DECIMALS as $key ) { $data[ $key ] = wc_format_decimal( $data[ $key ] ?? 0, self::dp() ); } foreach ( self::ORDER_DATES as $key ) { $date = $data[ $key ] ?? null; $data[ $key ] = self::date( $date, false ); $data[ $key . '_gmt' ] = self::date( $date, true ); } $status = (string) ( $data['status'] ?? '' ); $data['status'] = 0 === strpos( $status, 'wc-' ) ? substr( $status, 3 ) : $status; $data['number'] = (string) $order->get_order_number(); $out = array(); foreach ( self::ORDER_FIELDS as $key ) { $out[ $key ] = $data[ $key ] ?? null; } return $out; } /** * Decimals for money, like the REST API's default `dp` (the store's price decimals). * * @since 1.0.0 * @return int */ private static function dp(): int { return (int) wc_get_price_decimals(); } /** * A date as the REST API prints it ("2026-09-25T14:05:00"), or null. * * @since 1.0.0 * @param mixed $date WC_DateTime or null. * @param bool $gmt GMT instead of the store's time zone. * @return string|null */ private static function date( $date, bool $gmt ): ?string { if ( ! $date instanceof \WC_DateTime ) { return null; } return gmdate( 'Y-m-d\TH:i:s', $gmt ? $date->getTimestamp() : $date->getOffsetTimestamp() ); } /** * One order item, REST-shaped. * * @since 1.0.0 * @param WC_Order_Item $item Item. * @return array */ private static function item_data( WC_Order_Item $item ): array { $data = array_merge( array( 'id' => $item->get_id() ), self::item_props( $item ) ); foreach ( self::ITEM_DECIMALS as $key ) { if ( isset( $data[ $key ] ) && is_scalar( $data[ $key ] ) ) { $data[ $key ] = wc_format_decimal( $data[ $key ], self::dp() ); } } if ( $item instanceof \WC_Order_Item_Product ) { $product = $item->get_product(); $quantity = (float) $item->get_quantity(); $data['sku'] = $product ? $product->get_sku() : null; $data['price'] = $quantity ? (float) $item->get_total() / $quantity : 0; $data['parent_name'] = $product && $product->is_type( 'variation' ) ? $product->get_title() : null; } if ( isset( $data['taxes'] ) ) { $taxes = array(); $raw = is_array( $data['taxes'] ) ? $data['taxes'] : array(); foreach ( (array) ( $raw['total'] ?? array() ) as $rate_id => $tax ) { $taxes[] = array( 'id' => $rate_id, 'total' => is_scalar( $tax ) ? wc_format_decimal( $tax, self::dp() ) : '', 'subtotal' => isset( $raw['subtotal'][ $rate_id ] ) && is_scalar( $raw['subtotal'][ $rate_id ] ) ? wc_format_decimal( $raw['subtotal'][ $rate_id ], self::dp() ) : '', ); } $data['taxes'] = $taxes; } // Like the REST API: coupons, taxes and shipping carry their code or title instead. if ( isset( $data['code'] ) || isset( $data['rate_code'] ) || isset( $data['method_title'] ) ) { unset( $data['name'] ); } if ( $item instanceof \WC_Order_Item_Coupon ) { $data = array_merge( $data, self::coupon_facts( $item ) ); } $display = array(); foreach ( $item->get_formatted_meta_data( '', true ) as $meta_id => $meta ) { $display[ $meta_id ] = $meta; } $data['meta_data'] = self::meta_list( $item->get_meta_data(), $display ); return $data; } /** * A coupon line's stored discount type, nominal amount and free-shipping flag, as the REST API * shows them. Uses WC_Coupon::from_order_item() (WooCommerce 10.6+); older versions get the same * values from coupon_facts_from_meta(). * * @since 1.0.0 * @param \WC_Order_Item_Coupon $item Coupon line. * @return array{discount_type: mixed, nominal_amount: float, free_shipping: mixed} */ private static function coupon_facts( \WC_Order_Item_Coupon $item ): array { // @phpstan-ignore function.alreadyNarrowedType (the stubs are a newer WooCommerce; before 10.6 the method is missing.) if ( ! method_exists( \WC_Coupon::class, 'from_order_item' ) ) { return self::coupon_facts_from_meta( $item ); } $coupon = \WC_Coupon::from_order_item( $item ); return array( 'discount_type' => $coupon->get_discount_type(), 'nominal_amount' => (float) $coupon->get_amount(), 'free_shipping' => $coupon->get_free_shipping(), ); } /** * The same values as coupon_facts(), read straight from the line's stored meta the way * WooCommerce 10.6's WC_Coupon::from_order_item() does, without validation and without a coupon * object: `coupon_info` (short info JSON: id, code, type, amount, free shipping) when it is a * non-empty string, else only discount_type, amount and free_shipping from legacy `coupon_data` * (never the whole stored array). Two deliberate differences from WooCommerce: an array or * object stored as the amount gives 0.0 (a PHP cast would give 1.0), and the coupon getters' * view-context filters (`woocommerce_coupon_get_*`) are not applied. Public for tests only. * * @since 1.0.0 * @internal * @param \WC_Order_Item_Coupon $item Coupon line. * @return array{discount_type: mixed, nominal_amount: float, free_shipping: bool} */ public static function coupon_facts_from_meta( \WC_Order_Item_Coupon $item ): array { $type = 'fixed_cart'; $amount = 0.0; $free = false; $info = $item->get_meta( 'coupon_info', true ); if ( is_string( $info ) && '' !== $info ) { $parsed = json_decode( $info, true ); $parsed = is_array( $parsed ) ? $parsed : array(); $type = $parsed[2] ?? 'fixed_cart'; $amount = self::to_float( $parsed[3] ?? 0 ); $free = (bool) ( $parsed[4] ?? false ); } else { $legacy = $item->get_meta( 'coupon_data', true ); if ( is_object( $legacy ) || is_array( $legacy ) ) { $legacy = (array) $legacy; $type = $legacy['discount_type'] ?? 'fixed_cart'; $amount = self::to_float( $legacy['amount'] ?? 0 ); $free = (bool) ( $legacy['free_shipping'] ?? false ); } } if ( 'percent_product' === $type ) { $type = 'percent'; // Like WooCommerce: backwards compatibility. } return array( 'discount_type' => $type, 'nominal_amount' => (float) wc_format_decimal( $amount ), 'free_shipping' => $free, ); } /** * A stored amount as a float, like PHP's (float) cast but never on an array or object. * * @since 1.0.0 * @param mixed $value Stored value. * @return float */ private static function to_float( $value ): float { return is_scalar( $value ) ? (float) $value : 0.0; } /** * An item's own properties (get_data() without the order ID, type and meta). * * @since 1.0.0 * @param WC_Order_Item $item Item. * @return array */ private static function item_props( WC_Order_Item $item ): array { $data = $item->get_data(); unset( $data['id'], $data['order_id'], $data['type'], $data['meta_data'] ); return $data; } /** * Meta as [ {id, key, value} ] (items also get display_key and display_value), each value * through SafeDump (build() runs it again over everything to cut long strings). Values were unserialized once by WooCommerce and are never unserialized * again. * * @since 1.0.0 * @param \WC_Meta_Data[] $meta Meta. * @param array|null $display Formatted meta by meta ID (items only). * @return array> */ private static function meta_list( array $meta, ?array $display = null ): array { $out = array(); foreach ( $meta as $entry ) { if ( ! $entry instanceof \WC_Meta_Data ) { continue; } $row = array( 'id' => isset( $entry->id ) ? (int) $entry->id : 0, 'key' => (string) $entry->key, // Foreign value: objects become labels and arrays keep at most 1000 items here; build() // cuts long strings with the rest of the data. 'value' => SafeDump::to_array( $entry->value, 32, PHP_INT_MAX, self::MAX_META_ITEMS ), ); if ( null !== $display ) { $formatted = $display[ $row['id'] ] ?? null; $row['display_key'] = is_object( $formatted ) && isset( $formatted->display_key ) && is_scalar( $formatted->display_key ) ? Str::scalar( $formatted->display_key ) : $row['key']; $row['display_value'] = is_object( $formatted ) && isset( $formatted->display_value ) && is_scalar( $formatted->display_value ) ? Str::scalar( $formatted->display_value ) : null; } $out[] = $row; } return $out; } /** * The customer account, or null for guests and deleted accounts. * * @since 1.0.0 * @param WC_Order $order Order. * @return array|null */ private static function customer( WC_Order $order ): ?array { $user = $order->get_customer_id() ? get_userdata( $order->get_customer_id() ) : false; if ( ! $user instanceof \WP_User ) { return null; } $registered = (string) $user->user_registered; $time = '' !== $registered ? strtotime( $registered . ' UTC' ) : false; return array( 'id' => (int) $user->ID, 'email' => (string) $user->user_email, 'display_name' => (string) $user->display_name, 'roles' => array_values( (array) $user->roles ), 'date_registered' => $time ? get_date_from_gmt( gmdate( 'Y-m-d H:i:s', $time ), 'Y-m-d\TH:i:s' ) : null, 'date_registered_gmt' => $time ? gmdate( 'Y-m-d\TH:i:s', $time ) : null, 'is_paying_customer' => (bool) get_user_meta( $user->ID, 'paying_customer', true ), ); } /** * Load the line items' products in one go, so their SKUs don't cost a query each. * * @since 1.0.0 * @param WC_Order_Item[] $items Line items. * @return void */ private static function prime_products( array $items ): void { $ids = array(); foreach ( $items as $item ) { if ( $item instanceof \WC_Order_Item_Product ) { $ids[] = (int) $item->get_variation_id(); $ids[] = (int) $item->get_product_id(); } } $ids = array_values( array_unique( array_filter( $ids ) ) ); if ( $ids && function_exists( '_prime_post_caches' ) ) { _prime_post_caches( $ids, false, true ); } } }