5,
'order' => 5,
'client' => 30,
);
public const WINDOW = 3600;
private const SETTINGS = 'storesuit_order_tracking_settings';
private const COUNTER = 'storesuit_track_miss_';
private const STEPS = array(
'shipping' => array(
'received' => 'Order received',
'preparing' => 'Being prepared',
'shipped' => 'On its way',
'delivered' => 'Delivered',
),
'pickup' => array(
'received' => 'Order received',
'preparing' => 'Being prepared',
'ready_pickup' => 'Ready for pickup',
'picked_up' => 'Picked up',
),
);
private const LABELS = array(
'awaiting_payment' => 'Awaiting payment',
'failed' => 'Payment failed',
'received' => 'Order received',
'preparing' => 'Being prepared',
'shipped' => 'On its way',
'delivered' => 'Delivered',
'ready_pickup' => 'Ready for pickup',
'picked_up' => 'Picked up',
'completed' => 'Completed',
'cancelled' => 'Order cancelled',
'refunded' => 'Order refunded',
);
private static int $instances = 0;
/** The page's own answer to a post made without the script, shown once in the next form. */
private static ?array $posted = null;
public static function hooks() : void {
add_shortcode( 'storesuit_order_tracking', array(__CLASS__, 'shortcode') );
add_action( 'init', array(__CLASS__, 'register_block') );
add_action( 'wc_ajax_' . self::ACTION, array(__CLASS__, 'ajax') );
add_action( 'template_redirect', array(__CLASS__, 'posted'), 5 );
add_action( 'admin_menu', array(__CLASS__, 'menu'), 998 );
add_action( 'admin_post_storesuit_tracking_settings', array(__CLASS__, 'save') );
}
// ---- Storefront -----------------------------------------------------------------------------------------------
public static function register_block() : void {
if ( \WP_Block_Type_Registry::get_instance()->is_registered( 'storesuit/order-tracking' ) ) {
return;
}
wp_register_script(
'storesuit-order-tracking-block',
plugins_url( 'assets/order-tracking-block.js', STORESUIT_FILE ),
array(
'wp-blocks',
'wp-element',
'wp-block-editor',
'wp-components'
),
(string) filemtime( STORESUIT_DIR . 'assets/order-tracking-block.js' ),
true
);
$title = I18n::t( 'Order tracking' );
$description = I18n::t( 'Customers enter their order number and email address to see its status.' );
wp_localize_script( 'storesuit-order-tracking-block', 'StoreSuitTrackingBlock', array(
'title' => $title,
'description' => $description,
'keywords' => self::keywords(),
'heading' => I18n::t( 'Where is my order?' ),
'show' => I18n::t( 'Show the heading' ),
'settings' => I18n::t( 'Settings' ),
) );
register_block_type( 'storesuit/order-tracking', array(
'api_version' => 3,
'title' => $title,
'description' => $description,
'category' => 'widgets',
'icon' => 'location-alt',
'keywords' => self::keywords(),
'attributes' => array(
'showTitle' => array(
'type' => 'boolean',
'default' => true,
),
),
'supports' => array(
'html' => false,
),
'editor_script' => 'storesuit-order-tracking-block',
'render_callback' => array(__CLASS__, 'block'),
) );
}
private static function keywords() : array {
return array_values( array_unique( array(
'order',
'tracking',
'track',
'shipment',
'delivery',
mb_strtolower( I18n::t( 'Order tracking' ) ),
mb_strtolower( I18n::t( 'Tracking number' ) )
) ) );
}
public static function block( $attributes = array() ) : string {
return self::form( !is_array( $attributes ) || !array_key_exists( 'showTitle', $attributes ) || false !== $attributes['showTitle'] );
}
/** [storesuit_order_tracking], or [storesuit_order_tracking title="no"] without the heading. */
public static function shortcode( $atts = array() ) : string {
$atts = shortcode_atts( array(
'title' => 'yes',
), ( is_array( $atts ) ? $atts : array() ), 'storesuit_order_tracking' );
return self::form( !in_array( strtolower( (string) $atts['title'] ), array(
'no',
'0',
'false',
'off',
'hide'
), true ) );
}
/**
* The form. It posts to the page itself (no action attribute), so without the script the page answers in place; the
* script sends the same fields to the wc-ajax endpoint instead. The fields carry their own names, so no other form
* handler on the page picks them up.
*/
public static function form( bool $heading = true ) : string {
wp_enqueue_style(
'storesuit-order-tracking',
plugins_url( 'assets/order-tracking.css', STORESUIT_FILE ),
array(),
(string) filemtime( STORESUIT_DIR . 'assets/order-tracking.css' )
);
wp_enqueue_script(
'storesuit-order-tracking',
plugins_url( 'assets/order-tracking.js', STORESUIT_FILE ),
array(),
(string) filemtime( STORESUIT_DIR . 'assets/order-tracking.js' ),
array(
'in_footer' => true,
'strategy' => 'defer',
)
);
// A page already titled "Where is my order?" does not show the same heading twice (0.61.1).
if ( $heading && is_singular() && mb_strtolower( trim( wp_strip_all_tags( (string) get_the_title( get_queried_object_id() ) ) ) ) === mb_strtolower( I18n::t( 'Where is my order?' ) ) ) {
$heading = false;
}
$id = 'ss-tracking-' . ++self::$instances;
$answer = '';
if ( self::$posted !== null ) {
$answer = self::$posted['html'];
self::$posted = null;
}
return '' . (( $heading ? '' . esc_html( I18n::t( 'Where is my order?' ) ) . '
' : '' )) . '' . esc_html( I18n::t( 'Enter your order number and the email address you used at checkout.' ) ) . ' ' . esc_html( I18n::t( 'You will find the order number in your order confirmation email.' ) ) . '
' . '' . $answer . '
';
}
/** A post made without the script (or before it loaded): answered by the page itself, never cached, nothing in the URL. */
public static function posted() : void {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- A read-only lookup without a nonce, so cached pages keep working (see the class comment).
if ( !self::is_post() || empty( $_POST['storesuit_order_tracking'] ) || wp_doing_ajax() ) {
return;
}
nocache_headers();
wc_maybe_define_constant( 'DONOTCACHEPAGE', true );
// Page caches' own signal, set the way WooCommerce sets it for the cart and checkout.
self::$posted = self::answer( self::posted_fields(), self::client( Store::request_addresses() ) );
}
/** wc-ajax=storesuit_track_order: POST only, never cached, HTTP 200 with the answer (429 at the limit). */
public static function ajax() : void {
nocache_headers();
if ( !self::is_post() ) {
header( 'Allow: POST' );
wp_send_json_error( array(
'code' => 'method',
), 405 );
}
$answer = self::answer( self::posted_fields(), self::client( Store::request_addresses() ) );
$payload = array(
'code' => $answer['code'],
'html' => $answer['html'],
);
if ( $answer['code'] === 'found' ) {
wp_send_json_success( $payload, 200 );
}
if ( $answer['code'] === 'limit' ) {
header( 'Retry-After: ' . (self::WINDOW - time() % self::WINDOW) );
}
wp_send_json_error( $payload, ( $answer['code'] === 'limit' ? 429 : 200 ) );
}
/** Whether this request is a POST (the request method, unslashed and sanitized). */
private static function is_post() : bool {
return isset( $_SERVER['REQUEST_METHOD'] ) && is_string( $_SERVER['REQUEST_METHOD'] ) && sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) === 'POST';
}
/** The form's two fields, each read alone, unslashed and sanitized; nothing else of the post is read. */
private static function posted_fields() : array {
$fields = array();
foreach ( array('storesuit_track_number', 'storesuit_track_email') as $key ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- A read-only lookup without a nonce, so cached pages keep working (see the class comment).
$fields[$key] = ( isset( $_POST[$key] ) && is_string( $_POST[$key] ) ? sanitize_text_field( wp_unslash( $_POST[$key] ) ) : '' );
}
return $fields;
}
/** The lookup and the HTML both ways answer with (the page without the script, and the wc-ajax endpoint). */
public static function answer( array $post, string $client ) : array {
$field = static fn( string $key ): string => ( isset( $post[$key] ) && is_scalar( $post[$key] ) ? (string) $post[$key] : '' );
try {
$result = self::lookup( array(
'number' => $field( 'storesuit_track_number' ),
'email' => $field( 'storesuit_track_email' ),
), $client );
return $result + array(
'html' => self::html( $result ),
);
} catch ( \Throwable $e ) {
if ( function_exists( 'wc_get_logger' ) ) {
wc_get_logger()->error( 'Order tracking lookup failed: ' . get_class( $e ) . ': ' . $e->getMessage(), array(
'source' => 'storesuit-order-tracking',
) );
}
$result = array(
'code' => 'error',
);
return $result + array(
'html' => self::html( $result ),
);
}
}
/**
* The lookup itself, without output: array( 'code'=>'found', 'view'=>… ), or 'invalid' for every miss and for input
* that cannot be an order number or an email, or 'limit'. The attempt is counted before the order is read and given
* back when it finds the order, so parallel requests cannot pass a limit together and only misses stay counted.
*/
public static function lookup( array $input, string $client ) : array {
$number = self::number( (string) ($input['number'] ?? '') );
$email = self::email( (string) ($input['email'] ?? '') );
if ( $number === '' || $email === '' ) {
return array(
'code' => 'invalid',
);
}
$buckets = self::buckets( $number, $email, $client );
if ( !self::reserve( $buckets ) ) {
self::forget_old();
return array(
'code' => 'limit',
);
}
$order = self::find( $number, $email );
if ( !$order ) {
self::forget_old();
return array(
'code' => 'invalid',
);
}
self::release( $buckets );
return array(
'code' => 'found',
'view' => self::view( $order ),
);
}
/** The order number as typed: trimmed, without a leading "#"; empty when it cannot be one. */
public static function number( string $raw ) : string {
$number = trim( ltrim( trim( sanitize_text_field( $raw ) ), '#' ) );
return ( strlen( $number ) <= 64 ? $number : '' );
}
public static function email( string $raw ) : string {
$email = strtolower( trim( sanitize_email( trim( $raw ) ) ) );
return ( $email !== '' && strlen( $email ) <= 254 && is_email( $email ) ? $email : '' );
}
/**
* Exactly one order: the number goes through the same filter chain as WooCommerce's own tracking form
* (woocommerce_shortcode_order_tracking_order_id: order number plugins map their numbers to an ID there, and
* WooCommerce's wc_sanitize_order_id() keeps the digits), then that order's own number (ignoring case, "#" and leading
* zeros) and its billing email must match what was typed, so a number that sanitizing turns into another order's ID
* finds nothing. Nothing is searched by email: the answer takes as long for an address with many orders as for none.
*/
public static function find( string $number, string $email ) : ?\WC_Order {
if ( $number === '' || $email === '' || self::refused( $email ) ) {
return null;
}
// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce's own filter, applied as its tracking form does.
$resolved = apply_filters( 'woocommerce_shortcode_order_tracking_order_id', $number );
// phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
if ( $resolved instanceof \WC_Abstract_Order ) {
$resolved = $resolved->get_id();
}
$id = ( is_int( $resolved ) ? $resolved : (( is_string( $resolved ) && ctype_digit( $resolved ) ? (int) $resolved : 0 )) );
if ( $id <= 0 ) {
return null;
}
$order = wc_get_order( $id );
if ( !self::eligible( $order ) || !self::same_number( (string) $order->get_order_number(), $number ) ) {
return null;
}
return ( hash_equals( strtolower( trim( (string) $order->get_billing_email() ) ), $email ) ? $order : null );
}
/** Addresses nobody can prove they own: anonymised orders' "deleted@site.invalid" (any ".invalid"), and the store's own public addresses. */
private static function refused( string $email ) : bool {
$domain = strtolower( substr( (string) strrchr( $email, '@' ), 1 ) );
if ( $domain === 'invalid' || str_ends_with( $domain, '.invalid' ) ) {
return true;
}
$own = array_filter( array_map( static fn( $address ) => strtolower( trim( (string) $address ) ), array(get_option( 'admin_email' ), get_option( 'woocommerce_email_from_address' )) ) );
return in_array( $email, $own, true );
}
private static function eligible( $order ) : bool {
return $order instanceof \WC_Order && $order->get_type() === 'shop_order' && !$order->has_status( array(
'trash',
'checkout-draft',
'auto-draft',
'draft'
) ) && $order->get_meta( '_anonymized' ) !== 'yes' && !$order->get_meta( '_storesuit_demo' );
}
private static function same_number( string $shown, string $typed ) : bool {
$shown = mb_strtolower( trim( ltrim( trim( $shown ), '#' ) ) );
$typed = mb_strtolower( $typed );
if ( ctype_digit( $shown ) && ctype_digit( $typed ) ) {
$shown = ltrim( $shown, '0' );
$typed = ltrim( $typed, '0' );
}
return $shown !== '' && hash_equals( $shown, $typed );
}
// ---- Attempt limits --------------------------------------------------------------------------------------------
/**
* The visitor for the per-visitor backstop: REMOTE_ADDR, an IPv6 address by its /64 network (one connection gets a
* whole /64). Behind a local proxy or load balancer (a private or loopback REMOTE_ADDR) the address that proxy wrote into
* X-Forwarded-For (the right-most public one) or X-Real-IP is used; from anywhere else these headers could be forged,
* so they are ignored. A store behind a CDN that does not restore the visitor's address can name it with the
* storesuit_order_tracking_client_ip filter (which gets only these three headers; a CDN's own header is the filter's to
* read); otherwise that CDN's addresses share the generous backstop, and the limits per email and per order number still hold.
*/
public static function client( array $server ) : string {
$ip = self::plain( (string) ($server['REMOTE_ADDR'] ?? '') );
if ( $ip !== '' && self::internal( $ip ) ) {
$hops = array_reverse( explode( ',', (string) ($server['HTTP_X_FORWARDED_FOR'] ?? '') ) );
$hops[] = (string) ($server['HTTP_X_REAL_IP'] ?? '');
foreach ( $hops as $hop ) {
$hop = self::plain( $hop );
if ( $hop !== '' && !self::internal( $hop ) ) {
$ip = $hop;
break;
}
}
}
return self::network( (string) apply_filters( 'storesuit_order_tracking_client_ip', $ip, $server ) );
}
/** One key per visitor: an IPv4 address, or the /64 network of an IPv6 address. */
public static function network( string $ip ) : string {
$ip = self::plain( $ip );
if ( $ip === '' ) {
return 'unknown';
}
$packed = (string) inet_pton( $ip );
return ( strlen( $packed ) === 16 ? inet_ntop( substr( $packed, 0, 8 ) . str_repeat( "\x00", 8 ) ) . '/64' : (string) inet_ntop( $packed ) );
}
/** A valid address in its short form, an IPv4-mapped IPv6 address (a dual-stack server's IPv4 visitor) as IPv4; '' otherwise. */
private static function plain( string $ip ) : string {
$ip = trim( $ip );
if ( !filter_var( $ip, FILTER_VALIDATE_IP ) ) {
return '';
}
$packed = (string) inet_pton( $ip );
if ( strlen( $packed ) === 16 && substr( $packed, 0, 12 ) === str_repeat( "\x00", 10 ) . "\xff\xff" ) {
$packed = substr( $packed, 12 );
}
return (string) inet_ntop( $packed );
}
private static function internal( string $ip ) : bool {
return !filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE );
}
private static function window( int $at = 0 ) : string {
return sprintf( '%08d', intdiv( ( $at ?: time() ), self::WINDOW ) );
}
/** The counter names of the window around $at (now by default): keyed hashes, so no email, order number or address is stored. */
public static function buckets(
string $number,
string $email,
string $client,
int $at = 0
) : array {
$number = mb_strtolower( $number );
if ( ctype_digit( $number ) ) {
$number = ltrim( $number, '0' );
}
$prefix = self::COUNTER . self::window( $at ) . '_';
$key = static fn( string $kind, string $value ): string => $prefix . $kind . substr( hash_hmac( 'sha256', $kind . '|' . $value, wp_salt( 'nonce' ) ), 0, 32 );
return array(
'email' => $key( 'e', $email ),
'order' => $key( 'o', $number ),
'client' => $key( 'c', $client ),
);
}
/**
* Counts the attempt, then reads the counts: false when one is past its limit. The visitor's own counter comes first and
* alone: a visitor already past it counts nothing on the shared email and order counters, so one visitor cannot lock
* other customers' orders and addresses out, and adds no counter rows (security review S10). Then the email and order
* counters together, in one atomic statement.
*/
private static function reserve( array $buckets ) : bool {
if ( isset( $buckets['client'] ) ) {
if ( !self::bump( array(
'client' => $buckets['client'],
) ) ) {
return false;
}
unset($buckets['client']);
}
return !$buckets || self::bump( $buckets );
}
/** One counter (the visitor's) or two (the email's and the order's, together): each statement written out, as WordPress.org asks. */
private static function bump( array $buckets ) : bool {
global $wpdb;
$names = array_values( $buckets );
$two = count( $names ) === 2;
if ( !$two && count( $names ) !== 1 ) {
throw new \LogicException('One or two attempt counters at a time.');
}
$done = ( $two ? $wpdb->query( $wpdb->prepare(
"INSERT INTO %i (option_name,option_value,autoload) VALUES (%s,'1','no'),(%s,'1','no') ON DUPLICATE KEY UPDATE option_value=CAST(option_value AS UNSIGNED)+1",
$wpdb->options,
$names[0],
$names[1]
) ) : $wpdb->query( $wpdb->prepare( "INSERT INTO %i (option_name,option_value,autoload) VALUES (%s,'1','no') ON DUPLICATE KEY UPDATE option_value=CAST(option_value AS UNSIGNED)+1", $wpdb->options, $names[0] ) ) );
if ( false === $done ) {
throw new \RuntimeException('The attempt could not be counted.');
}
$counts = ( $two ? $wpdb->get_results( $wpdb->prepare(
'SELECT option_name,option_value FROM %i WHERE option_name IN (%s,%s)',
$wpdb->options,
$names[0],
$names[1]
), OBJECT_K ) : $wpdb->get_results( $wpdb->prepare( 'SELECT option_name,option_value FROM %i WHERE option_name=%s', $wpdb->options, $names[0] ), OBJECT_K ) );
foreach ( $buckets as $kind => $name ) {
if ( !isset( $counts[$name] ) ) {
throw new \RuntimeException('The attempt could not be counted.');
}
if ( (int) $counts[$name]->option_value > self::LIMITS[$kind] ) {
return false;
}
}
return true;
}
/** A success is not a miss: its attempt is given back. */
private static function release( array $buckets ) : void {
global $wpdb;
$wpdb->query( $wpdb->prepare(
'UPDATE %i SET option_value=GREATEST(CAST(option_value AS SIGNED)-1,0) WHERE option_name IN (%s,%s,%s)',
$wpdb->options,
(string) $buckets['email'],
(string) $buckets['order'],
(string) $buckets['client']
) );
}
/**
* Counters of earlier windows no longer count; each miss and each refused attempt removes up to 500 of them, so a visitor
* request never runs an unbounded delete and old windows still go even when every attempt is refused.
*/
private static function forget_old() : void {
global $wpdb;
$wpdb->query( $wpdb->prepare(
'DELETE FROM %i WHERE option_name LIKE %s AND option_name < %s LIMIT 500',
$wpdb->options,
$wpdb->esc_like( self::COUNTER ) . '%',
self::COUNTER . self::window()
) );
}
// ---- What the customer sees ------------------------------------------------------------------------------------
/**
* Only what helps a customer who already knows the order: its number and date, the status, the progress, parcels with
* their tracking numbers, and a partly hidden amount and destination. Never a name, street, phone, email, product,
* note, payment method, order key or admin link; in-store handover rows hold the recipient's name and are left out.
*/
public static function view( \WC_Order $order ) : array {
$flow = OrderFlow::read( $order );
$pickup = (( $order->get_meta( '_storesuit_delivery' ) ?: 'shipping' )) === 'pickup';
$status = self::status( $order, $flow, $pickup );
$path = self::STEPS[( $pickup ? 'pickup' : 'shipping' )];
$steps = array();
if ( isset( $path[$status] ) ) {
$at = array_search( $status, array_keys( $path ), true );
foreach ( array_keys( $path ) as $i => $key ) {
$steps[] = array(
'key' => $key,
'label' => I18n::t( $path[$key] ),
'state' => ( $i < $at ? 'done' : (( $i === $at ? 'current' : 'next' )) ),
);
}
}
$notes = array();
if ( !$pickup && !empty( $flow['partial_shipment'] ) && in_array( $status, array('received', 'preparing'), true ) ) {
$notes[] = I18n::t( 'Part of your order is on its way.' );
}
$open = false;
$finished = false;
foreach ( (array) (( $order->get_meta( '_storesuit_returns' ) ?: array() )) as $return ) {
$state = ( is_array( $return ) ? (string) ($return['status'] ?? '') : '' );
if ( in_array( $state, array('requested', 'approved'), true ) ) {
$open = true;
} elseif ( in_array( $state, array('refunded', 'exchanged'), true ) ) {
$finished = true;
}
}
if ( $open ) {
$notes[] = I18n::t( 'Return requested' );
} elseif ( $finished ) {
$notes[] = I18n::t( 'Return completed' );
}
$parcels = self::parcels( $order );
if ( !in_array( $status, array('shipped', 'preparing', 'received'), true ) ) {
foreach ( $parcels as &$parcel ) {
$parcel['expected'] = '';
}
unset($parcel);
}
$created = $order->get_date_created();
return array(
'number' => (string) $order->get_order_number(),
'date' => ( $created ? wc_format_datetime( $created ) : '' ),
'status' => $status,
'label' => ( $status === 'custom' ? wc_get_order_status_name( $order->get_status() ) : I18n::t( self::LABELS[$status] ) ),
'steps' => $steps,
'notes' => $notes,
'total' => self::mask_total( $order ),
'destination' => ( $pickup ? '' : self::mask_place( $order ) ),
'parcels' => $parcels,
'whatsapp' => self::whatsapp(),
);
}
/**
* WooCommerce's status first, then StoreSuit's stage. "Delivered" or "Picked up" needs a record of it: a confirmed
* delivery, an in-person handover or a store pickup, with every product shipped. WooCommerce's "Completed" alone stays
* "Completed". An order waiting for a bank transfer, a cheque or a payment link says so instead of "Being prepared".
*/
private static function status( \WC_Order $order, array $flow, bool $pickup ) : string {
$status = $order->get_status();
if ( in_array( $status, array('cancelled', 'refunded', 'failed'), true ) ) {
return $status;
}
if ( $status === 'pending' ) {
return 'awaiting_payment';
}
if ( !in_array( $status, array('processing', 'on-hold', 'completed'), true ) ) {
return 'custom';
}
$totals = $flow['totals'];
if ( $status === 'on-hold' && (float) $totals['shipped'] <= 0 && self::awaits_payment( $order ) ) {
return 'awaiting_payment';
}
$shipped = (float) $totals['quantity'] > 0 && (float) $totals['shipped'] >= (float) $totals['quantity'];
if ( $shipped && ($pickup || !empty( $flow['delivery_confirmed'] ) || self::handed_over( $order )) ) {
return ( $pickup ? 'picked_up' : 'delivered' );
}
if ( $shipped ) {
return 'shipped';
}
if ( $status === 'completed' ) {
return 'completed';
}
if ( (float) $totals['quantity'] > 0 && (float) $totals['packed'] >= (float) $totals['quantity'] ) {
return ( $pickup ? 'ready_pickup' : 'preparing' );
}
return ( $flow['stage'] === 'preparing' ? 'preparing' : 'received' );
}
/** Still to be paid by the customer before it ships: StoreSuit's payment link or manual (bank transfer) orders, WooCommerce's bank transfer and cheque. Cash on delivery and payment terms are not. */
private static function awaits_payment( \WC_Order $order ) : bool {
if ( Orders::outstanding( $order ) <= 0 ) {
return false;
}
$mode = (string) $order->get_meta( '_storesuit_payment_mode' );
if ( $mode !== '' ) {
return in_array( $mode, array('link', 'manual'), true );
}
return in_array( $order->get_payment_method(), array('bacs', 'cheque'), true );
}
private static function handed_over( \WC_Order $order ) : bool {
$rows = array_filter( (array) (( $order->get_meta( '_storesuit_shipments' ) ?: array() )), 'is_array' );
foreach ( $rows as $row ) {
if ( ($row['mode'] ?? '') !== 'handover' ) {
return false;
}
}
return (bool) $rows;
}
/** Parcels with a tracking number, newest first, one per carrier and number (a parcel shipped line by line is recorded per line), at most five. */
private static function parcels( \WC_Order $order ) : array {
$out = array();
$rows = array_reverse( array_filter( (array) (( $order->get_meta( '_storesuit_shipments' ) ?: array() )), 'is_array' ) );
foreach ( $rows as $row ) {
if ( ($row['mode'] ?? '') === 'handover' ) {
continue;
}
$tracking = mb_substr( sanitize_text_field( (string) ($row['tracking'] ?? '') ), 0, 100 );
if ( $tracking === '' ) {
continue;
}
$carrier = mb_substr( sanitize_text_field( (string) ($row['carrier_label'] ?? '') ), 0, 80 );
$key = mb_strtolower( $carrier . '|' . preg_replace( '/\\s+/', '', $tracking ) );
if ( isset( $out[$key] ) ) {
continue;
}
// EasyPost's own page for this parcel when it is linked; the carrier's tracking page otherwise. Only http(s).
$tracker = ( is_array( $row['provider_tracker'] ?? null ) ? (string) ($row['provider_tracker']['public_url'] ?? '') : '' );
$url = ( $tracker !== '' && wp_parse_url( $tracker, PHP_URL_SCHEME ) === 'https' && wp_parse_url( $tracker, PHP_URL_HOST ) === 'track.easypost.com' ? $tracker : (string) ($row['tracking_url'] ?? '') );
$expected = (string) ($row['expected_delivery'] ?? '');
$date = '';
// Only a real calendar day is shown; PHP would silently turn 2026-02-31 into another date.
if ( Store::valid_date( $expected ) ) {
$day = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i', $expected . ' 12:00', wp_timezone() );
$date = ( $day ? wp_date( (string) get_option( 'date_format' ), $day->getTimestamp() ) : '' );
}
$out[$key] = array(
'carrier' => $carrier,
'tracking' => $tracking,
'url' => esc_url_raw( $url, array('http', 'https') ),
'expected' => (string) $date,
);
if ( count( $out ) >= 5 ) {
break;
}
}
return array_values( $out );
}
/** The amount in the store's format with its whole part hidden: "$•••.45", "•••,45 €" (zero-decimal currencies: the last digits). */
public static function mask_total( \WC_Order $order ) : string {
$decimals = max( 0, (int) wc_get_price_decimals() );
$parts = explode( '.', number_format(
abs( (float) $order->get_total() ),
$decimals,
'.',
''
) );
$whole = $parts[0];
$shown = ( $decimals > 0 ? wc_get_price_decimal_separator() . ($parts[1] ?? '') : substr( $whole, ( strlen( $whole ) > 2 ? -2 : -1 ) ) );
$symbol = html_entity_decode( (string) get_woocommerce_currency_symbol( $order->get_currency() ), ENT_QUOTES, 'UTF-8' );
$format = str_replace( ' ', " ", html_entity_decode( (string) get_woocommerce_price_format(), ENT_QUOTES, 'UTF-8' ) );
return sprintf( $format, $symbol, '•••' . $shown );
}
/** The first letter of the delivery city and the country's name: "B•••, United Kingdom". */
public static function mask_place( \WC_Order $order ) : string {
$shipping = $order->has_shipping_address();
$city = trim( (string) (( $shipping ? $order->get_shipping_city() : $order->get_billing_city() )) );
$code = strtoupper( trim( (string) (( $shipping ? $order->get_shipping_country() : $order->get_billing_country() )) ) );
$countries = ( WC()->countries ? (array) WC()->countries->get_countries() : array() );
$country = ( $code !== '' ? html_entity_decode( (string) ($countries[$code] ?? $code), ENT_QUOTES, 'UTF-8' ) : '' );
return implode( ', ', array_filter( array(( $city !== '' ? mb_substr( $city, 0, 1 ) . '•••' : '' ), $country) ) );
}
/** The answer's HTML, in the site's language. Every miss, whatever its cause, gets the same text. */
public static function html( array $result ) : string {
$code = (string) ($result['code'] ?? 'invalid');
if ( $code === 'found' && is_array( $result['view'] ?? null ) ) {
return self::card( $result['view'] );
}
$lines = ( $code === 'limit' ? array('Too many attempts. Please try again in an hour.') : (( $code === 'error' ? array('The order could not be checked right now. Please try again later.') : array('We could not find an order with those details.', 'Check the order number in your order confirmation email and use the email address you ordered with.') )) );
return '
' . implode( '', array_map( static fn( $line ) => '
' . esc_html( I18n::t( $line ) ) . '
', $lines ) ) . self::contact() . '
';
}
private static function card( array $view ) : string {
$out = '' . esc_html( I18n::format( 'Order #%s', $view['number'] ) ) . (( $view['date'] !== '' ? ' · ' . esc_html( $view['date'] ) : '' )) . '
' . '
' . esc_html( $view['label'] ) . '
';
if ( $view['steps'] ) {
$out .= '
';
foreach ( $view['steps'] as $step ) {
$out .= '- ' . (( $step['state'] === 'done' ? '✓ ' . esc_html( $step['label'] ) . ' (' . esc_html( I18n::t( 'Step completed' ) ) . ')' : esc_html( $step['label'] ) )) . '
';
}
$out .= '
';
}
foreach ( $view['notes'] as $note ) {
$out .= '
' . esc_html( $note ) . '
';
}
$facts = array_filter( array(
I18n::t( 'Amount' ) => $view['total'],
I18n::t( 'Delivery to' ) => $view['destination'],
), static fn( $value ) => $value !== '' );
if ( $facts ) {
$out .= '
';
// The hidden part is not read out; the note below says it is hidden.
foreach ( $facts as $name => $value ) {
$out .= '- ' . esc_html( (string) $name ) . '
- ' . preg_replace( '/•+/u', '$0', esc_html( $value ) ) . '
';
}
$out .= '
' . esc_html( I18n::t( 'Partly hidden for your privacy.' ) ) . '
';
}
if ( $view['parcels'] ) {
$out .= '
';
foreach ( $view['parcels'] as $parcel ) {
$out .= '- ' . esc_html( ( $parcel['carrier'] !== '' ? $parcel['carrier'] : I18n::t( 'Carrier' ) ) ) . ' ' . esc_html( I18n::t( 'Tracking number' ) ) . ': ' . esc_html( $parcel['tracking'] ) . '' . (( $parcel['expected'] !== '' ? ' ' . esc_html( I18n::t( 'Estimated delivery' ) ) . ': ' . esc_html( $parcel['expected'] ) . '' : '' )) . (( $parcel['url'] !== '' ? ' ' . esc_html( I18n::t( 'Open carrier tracking' ) ) . ' ' . esc_html( I18n::t( '(opens in a new tab)' ) ) . '' : '' )) . '
';
}
$out .= '
';
}
return $out . self::contact() . '
';
}
private static function contact() : string {
$url = self::whatsapp();
return ( $url === '' ? '' : '' . esc_html( I18n::t( 'Contact the store on WhatsApp' ) ) . ' ' . esc_html( I18n::t( '(opens in a new tab)' ) ) . '
' );
}
// ---- Owner settings --------------------------------------------------------------------------------------------
public static function settings() : array {
$value = get_option( self::SETTINGS, array() );
$value = ( is_array( $value ) ? $value : array() );
return array(
'whatsapp' => !empty( $value['whatsapp'] ),
'phone' => (string) preg_replace( '/\\D+/', '', (string) ($value['phone'] ?? '') ),
);
}
/** The store's WhatsApp chat (a link only; nothing about the order goes with it), when the owner switched it on. */
public static function whatsapp() : string {
$settings = self::settings();
return ( $settings['whatsapp'] && preg_match( '/^[1-9][0-9]{6,14}$/D', $settings['phone'] ) ? 'https://wa.me/' . $settings['phone'] : '' );
}
public static function menu() : void {
$title = I18n::t( 'Order tracking' );
$hook = add_submenu_page(
'',
$title,
$title,
'manage_woocommerce',
self::PAGE,
array(__CLASS__, 'admin')
);
if ( $hook ) {
add_action( 'load-' . $hook, static function () use($title) {
$GLOBALS['title'] = $title;
} );
}
}
/**
* Where customers find the lookup: published pages holding the block or the shortcode (at most 5), and whether the
* StoreRuler theme shows its Order tracking section (Almira audit SAT-20, 28 Oct 2026: the settings gave the shortcode,
* no page had it, and nothing said so).
* @return array{pages:array,section:bool}
*/
public static function places() : array {
global $wpdb;
$ids = (array) $wpdb->get_col( $wpdb->prepare(
"SELECT ID FROM %i WHERE post_status='publish' AND post_type IN ('page','post') AND ( post_content LIKE %s OR post_content LIKE %s ) ORDER BY ID LIMIT 5",
$wpdb->posts,
'%' . $wpdb->esc_like( '[storesuit_order_tracking' ) . '%',
'%' . $wpdb->esc_like( '