*/ private static $reported = array(); /** * Hooks. */ public static function init() { add_filter( 'woocommerce_validate_phone', array( __CLASS__, 'core_check' ), 10, 3 ); add_filter( 'rest_pre_dispatch', array( __CLASS__, 'remember_request' ), 10, 3 ); add_filter( 'rest_request_after_callbacks', array( __CLASS__, 'forget_request' ), 10, 3 ); if ( did_action( 'woocommerce_blocks_loaded' ) ) { self::register_store_api(); } else { add_action( 'woocommerce_blocks_loaded', array( __CLASS__, 'register_store_api' ) ); } add_action( 'woocommerce_blocks_validate_location_address_fields', array( __CLASS__, 'validate_block' ), 10, 3 ); add_action( 'woocommerce_store_api_checkout_update_order_from_request', array( __CLASS__, 'save_order' ), 10, 2 ); add_action( 'woocommerce_store_api_checkout_update_customer_from_request', array( __CLASS__, 'save_customer' ), 10, 2 ); add_filter( 'woocommerce_checkout_posted_data', array( __CLASS__, 'classic_posted' ) ); add_action( 'woocommerce_after_checkout_validation', array( __CLASS__, 'validate_classic' ), 10, 2 ); add_filter( 'woocommerce_process_myaccount_field_billing_phone', array( __CLASS__, 'account_billing' ) ); add_filter( 'woocommerce_process_myaccount_field_shipping_phone', array( __CLASS__, 'account_shipping' ) ); add_action( 'woocommerce_after_save_address_validation', array( __CLASS__, 'validate_account' ), 10, 4 ); } /** * Core phone check (WooCommerce 11.0+): a number that can be saved in E.164 is accepted. * * @param mixed $valid Core result. * @param mixed $phone Number. * @param string $country Address country. * @return bool */ public static function core_check( $valid, $phone, $country = '' ) { if ( $valid || ! is_string( $phone ) || preg_match( '/[\x{202A}-\x{202E}\x{2066}-\x{2069}]/u', $phone ) ) { return (bool) $valid; } return Phone::is_valid( $phone, is_string( $country ) ? $country : '' ); } /** * Keeps the Store API checkout request for the address checks. * * @param mixed $result Dispatch result. * @param mixed $server Server. * @param mixed $request Request. * @return mixed */ public static function remember_request( $result, $server, $request ) { if ( $request instanceof \WP_REST_Request && preg_match( '#^/wc/store(/v\d+)?/checkout(/\d+)?/?$#i', $request->get_route() ) ) { self::$request = $request; } return $result; } /** * Forgets the Store API checkout request once it is handled. * * @param mixed $response Response. * @param mixed $handler Route handler. * @param mixed $request Request. * @return mixed */ public static function forget_request( $response, $handler, $request ) { if ( $request === self::$request ) { self::$request = null; } return $response; } /** * Phone sent in the kept Store API checkout request, or null when the checked phone is empty or comes from another request. * * @param string $group billing or shipping. * @param mixed $fields Address values being checked. * @return string|null */ private static function sent_phone( $group, $fields ) { if ( ! self::$request instanceof \WP_REST_Request || ! is_array( $fields ) ) { return null; } $address = self::$request->get_param( $group . '_address' ); $sent = is_array( $address ) && isset( $address['phone'] ) && is_scalar( $address['phone'] ) ? sanitize_text_field( (string) $address['phone'] ) : ''; $checked = isset( $fields['phone'] ) && is_scalar( $fields['phone'] ) ? trim( (string) $fields['phone'] ) : ''; return '' !== $checked && preg_replace( '/[^\d+]/', '', $sent ) === preg_replace( '/[^\d+]/', '', $checked ) ? $sent : null; } /** * Whether a Store API checkout request pays with an express button (wallet). * * @param mixed $request Request. * @return bool */ public static function is_express( $request ) { $express = false; $data = $request instanceof \WP_REST_Request ? $request->get_param( 'payment_data' ) : null; if ( is_array( $data ) ) { foreach ( $data as $item ) { if ( is_array( $item ) && isset( $item['key'], $item['value'] ) && in_array( $item['key'], self::EXPRESS_KEYS, true ) && is_scalar( $item['value'] ) && '' !== (string) $item['value'] ) { $express = true; break; } } } /** * Whether an order is paid with an express button; its phone is then saved but never blocked. * * @param bool $express Express payment. * @param \WP_REST_Request|null $request Store API request; null for the classic checkout, or when the checked phone isn't the one of the kept checkout request. */ return (bool) apply_filters( 'dialtidy_is_express_payment', $express, $request instanceof \WP_REST_Request ? $request : null ); } /** * Whether the classic checkout form pays with an express button (wallet). * * @return bool */ public static function is_express_posted() { $express = false; foreach ( self::EXPRESS_KEYS as $key ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checked the checkout nonce; only presence is read. if ( isset( $_POST[ $key ] ) && is_string( $_POST[ $key ] ) && '' !== sanitize_key( wp_unslash( $_POST[ $key ] ) ) ) { $express = true; break; } } /** This filter is documented in includes/class-checkout.php */ return (bool) apply_filters( 'dialtidy_is_express_payment', $express, null ); } /** * Phone countries sent by the browser with the block checkout. */ public static function register_store_api() { if ( ! function_exists( 'woocommerce_store_api_register_endpoint_data' ) || ! class_exists( '\Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema' ) ) { return; } woocommerce_store_api_register_endpoint_data( array( 'endpoint' => \Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema::IDENTIFIER, 'namespace' => self::NS, 'schema_callback' => array( __CLASS__, 'store_api_schema' ), ) ); } /** * Schema of the extension data. * * @return array */ public static function store_api_schema() { $field = array( 'type' => array( 'string', 'null' ), 'context' => array(), 'pattern' => '^[A-Za-z]{2}$', 'required' => false, ); return array( 'billing_country' => $field, 'shipping_country' => $field, ); } /** * Block checkout: checks a phone before the order is created. * * @param \WP_Error $errors Errors. * @param mixed $fields Address values. * @param string $group billing, shipping or other. */ public static function validate_block( $errors, $fields, $group ) { if ( ! $errors instanceof \WP_Error || ! is_array( $fields ) || ! in_array( $group, Settings::GROUPS, true ) || 'off' === Settings::get( 'mode' ) ) { return; } $sent = self::sent_phone( $group, $fields ); $request = null === $sent ? null : self::$request; if ( self::is_express( $request ) ) { return; } $chosen = self::sent_countries( $request ); $phone = null !== $sent ? $sent : ( isset( $fields['phone'] ) ? $fields['phone'] : '' ); $message = self::verdict( $phone, isset( $fields['country'] ) && is_string( $fields['country'] ) ? $fields['country'] : '', $group, $chosen[ $group ], 'checkout', self::$request instanceof \WP_REST_Request ? spl_object_id( self::$request ) . '/' . $group : null ); if ( '' !== $message ) { $errors->add( 'dialtidy_' . $group . '_phone', $message ); } } /** * Block checkout: saves the order phones in E.164. * * @param \WC_Order $order Order. * @param \WP_REST_Request $request Request. */ public static function save_order( $order, $request ) { if ( ! $order instanceof \WC_Order ) { return; } $sent = self::sent_countries( $request ); foreach ( Settings::GROUPS as $group ) { $get = 'get_' . $group . '_phone'; $set = 'set_' . $group . '_phone'; $country = 'billing' === $group ? $order->get_billing_country() : $order->get_shipping_country(); $e164 = self::normalize( $order->$get(), $sent[ $group ], $country ); if ( '' !== $e164 ) { $order->$set( $e164 ); } } } /** * Block checkout: saves the customer phones in E.164. * * @param \WC_Customer $customer Customer. * @param \WP_REST_Request $request Request. */ public static function save_customer( $customer, $request ) { if ( ! $customer instanceof \WC_Customer ) { return; } $sent = self::sent_countries( $request ); foreach ( Settings::GROUPS as $group ) { $get = 'get_' . $group . '_phone'; $set = 'set_' . $group . '_phone'; $country = 'billing' === $group ? $customer->get_billing_country() : $customer->get_shipping_country(); $e164 = self::normalize( $customer->$get(), $sent[ $group ], $country ); if ( '' !== $e164 ) { $customer->$set( $e164 ); } } } /** * Classic checkout: posted phones in E.164 (left as typed when not valid). * * @param mixed $data Posted data. * @return mixed */ public static function classic_posted( $data ) { if ( ! is_array( $data ) ) { return $data; } foreach ( Settings::GROUPS as $group ) { if ( empty( $data[ $group . '_phone' ] ) ) { continue; } $country = self::posted_country( $data, $group ); $e164 = self::normalize( $data[ $group . '_phone' ], '', $country ); if ( '' !== $e164 ) { $data[ $group . '_phone' ] = $e164; } } return $data; } /** * Classic checkout: blocks an invalid phone. * * @param mixed $data Posted data. * @param \WP_Error $errors Errors. */ public static function validate_classic( $data, $errors ) { if ( ! is_array( $data ) || ! $errors instanceof \WP_Error || 'off' === Settings::get( 'mode' ) || self::is_express_posted() ) { return; } foreach ( Settings::GROUPS as $group ) { if ( 'shipping' === $group && empty( $data['ship_to_different_address'] ) ) { continue; } $message = self::verdict( isset( $data[ $group . '_phone' ] ) ? $data[ $group . '_phone' ] : '', self::posted_country( $data, $group ), $group, '', 'checkout', '' ); if ( '' !== $message ) { $errors->add( $group . '_phone_validation', $message, array( 'id' => $group . '_phone' ) ); } } } /** * My account: billing phone in E.164. * * @param mixed $value Value. * @return mixed */ public static function account_billing( $value ) { return self::account_value( $value, 'billing' ); } /** * My account: shipping phone in E.164. * * @param mixed $value Value. * @return mixed */ public static function account_shipping( $value ) { return self::account_value( $value, 'shipping' ); } /** * My account: blocks an invalid phone. * * @param int $user_id User. * @param string $address_type billing or shipping. * @param array $address Fields. * @param \WC_Customer $customer Customer with the posted values. */ public static function validate_account( $user_id, $address_type, $address = array(), $customer = null ) { if ( ! in_array( $address_type, Settings::GROUPS, true ) || ! $customer instanceof \WC_Customer || 'off' === Settings::get( 'mode' ) ) { return; } $get = 'get_' . $address_type . '_phone'; $country = 'billing' === $address_type ? $customer->get_billing_country() : $customer->get_shipping_country(); $message = self::verdict( $customer->$get(), $country, $address_type, '', 'account', '' ); if ( '' !== $message ) { wc_add_notice( $message, 'error', array( 'id' => $address_type . '_phone' ) ); } } /** * Result of checking a phone: the reason it is refused ('' when accepted or empty), its E.164 and its country. * * @param mixed $phone Number. * @param string $country Address country. * @param string $group billing or shipping. * @param string $chosen Country chosen at the phone field, '' if none. * @param string $place checkout or account. * @return array{reason: string, e164: string, country: string} */ public static function check( $phone, $country, $group, $chosen = '', $place = 'checkout' ) { $phone = is_scalar( $phone ) ? trim( (string) $phone ) : ''; $field = strtoupper( '' !== (string) $chosen ? (string) $chosen : (string) $country ); $out = array( 'reason' => '', 'e164' => '', 'country' => preg_match( '/^[A-Z]{2}$/', $field ) ? $field : '', ); if ( '' === $phone ) { return $out; } $e164 = self::normalize( $phone, (string) $chosen, (string) $country ); if ( '' === $e164 ) { $reason = Phone::reason( $phone, $out['country'] ); $out['reason'] = '' !== $reason ? $reason : 'invalid'; return $out; } $out['e164'] = $e164; $from = Phone::countries_of( $e164 ); if ( $from ) { $out['country'] = $from[0]; } $accepted = ! $from && null === Countries::only( $group ); foreach ( $from as $code ) { if ( Countries::accepts( $group, $code ) ) { $accepted = true; break; } } if ( ! $accepted ) { $out['reason'] = 'not_allowed'; } elseif ( ! Phone::type_allowed( $e164, self::allowed_types( $group, $place ) ) ) { $out['reason'] = 'wrong_type'; } return $out; } /** * What is wrong with a phone, '' when nothing (an empty phone is left to WooCommerce). * * @param mixed $phone Number. * @param string $country Address country. * @param string $group billing or shipping. * @param string $chosen Country chosen at the phone field, '' if none. * @param string $place checkout or account. * @return string */ public static function problem( $phone, $country, $group, $chosen = '', $place = 'checkout' ) { $check = self::check( $phone, $country, $group, $chosen, $place ); return '' === $check['reason'] ? '' : self::message( $check['reason'], $check['country'], $group, $place, $phone ); } /** * Message for a refused number. * * @param string $reason too_short, too_long, wrong_type, not_allowed or invalid. * @param string $country Country of the number, or of the field when the number is not valid; '' if unknown. * @param string $group billing or shipping. * @param string $place checkout or account. * @param mixed $phone Number as sent. * @return string */ public static function message( $reason, $country, $group, $place = 'checkout', $phone = '' ) { $text = 'not_allowed' === $reason ? __( 'Phone numbers from this region are not accepted here. Please enter another number.', 'dialtidy' ) : __( 'Please enter a valid phone number.', 'dialtidy' ); /** * Message for a refused phone number. * * @param string $text Message. * @param string $reason too_short, too_long, wrong_type, not_allowed or invalid. * @param string $country Country of the number, or of the field when the number is not valid (upper-case code); '' if unknown. * @param string $group billing or shipping. * @param string $place checkout or account. * @param string $phone Number as sent. */ $filtered = apply_filters( 'dialtidy_message', $text, (string) $reason, (string) $country, (string) $group, (string) $place, is_scalar( $phone ) ? trim( (string) $phone ) : '' ); return is_string( $filtered ) && '' !== trim( wp_strip_all_tags( $filtered ) ) ? trim( wp_strip_all_tags( $filtered ) ) : $text; } /** * Number types a phone field accepts; empty means every type. * * @param string $group billing or shipping. * @param string $place checkout or account. * @return array */ public static function allowed_types( $group, $place = 'checkout' ) { /** * Number types a phone field accepts; an empty list accepts every type. * * @param string[] $types Any of: mobile, fixed_line, toll_free, premium_rate, shared_cost, voip, personal_number, pager, uan, voicemail. * @param string $group billing or shipping. * @param string $place checkout or account. */ $types = apply_filters( 'dialtidy_allowed_types', array(), (string) $group, (string) $place ); return array_values( array_intersect( Phone::TYPES, array_map( 'strval', is_array( $types ) ? $types : array() ) ) ); } /** * Checks a sent phone and reports the result; the message when the order or address must be stopped. * * @param mixed $phone Number. * @param string $country Address country. * @param string $group billing or shipping. * @param string $chosen Country chosen at the phone field, '' if none. * @param string $place checkout or account. * @param string|null $once Report key: null when not a form being sent, '' to report, a key to report once. * @return string */ private static function verdict( $phone, $country, $group, $chosen, $place, $once ) { $check = self::check( $phone, $country, $group, $chosen, $place ); $mode = (string) Settings::get( 'mode' ); if ( null !== $once && ( '' === $once || empty( self::$reported[ $once ] ) ) ) { if ( '' !== $once ) { self::$reported[ $once ] = true; } $empty = ! is_scalar( $phone ) || '' === trim( (string) $phone ); /** * A phone number was checked on the server when the checkout or an address form was sent. * * @param array $result { * @type string $result valid, invalid or empty. * @type string $reason too_short, too_long, wrong_type, not_allowed or invalid; '' when valid or empty. * @type string $phone_country Country of the number, or of the field when the number is not valid; '' if unknown. * @type string $address_country Address country. * @type string $group billing or shipping. * @type string $place checkout or account. * @type string $mode block or warn. * } */ do_action( 'dialtidy_checked', array( 'result' => $empty ? 'empty' : ( '' === $check['reason'] ? 'valid' : 'invalid' ), 'reason' => $check['reason'], 'phone_country' => $check['country'], 'address_country' => strtoupper( (string) $country ), 'group' => (string) $group, 'place' => (string) $place, 'mode' => $mode, ) ); } return 'block' === $mode && '' !== $check['reason'] ? self::message( $check['reason'], $check['country'], $group, $place, $phone ) : ''; } /** * E.164 of a number: the country the shopper chose, then a + in the number, then the address country. * * @param mixed $phone Number. * @param string $chosen Country chosen at the phone field, '' if none. * @param string $address Address country. * @return string */ public static function normalize( $phone, $chosen, $address ) { if ( ! is_scalar( $phone ) || '' === trim( (string) $phone ) ) { return ''; } foreach ( array( $chosen, $address ) as $country ) { $e164 = Phone::e164( $phone, (string) $country ); if ( '' !== $e164 ) { return $e164; } } return ''; } /** * Phone countries from the Store API request, checked against the known list; none for express payments. * * @param mixed $request Request. * @return array{billing: string, shipping: string} */ private static function sent_countries( $request ) { $out = array( 'billing' => '', 'shipping' => '', ); if ( ! $request instanceof \WP_REST_Request || self::is_express( $request ) ) { return $out; } $data = $request->get_param( 'extensions' ); $data = is_array( $data ) && isset( $data[ self::NS ] ) && is_array( $data[ self::NS ] ) ? $data[ self::NS ] : array(); $all = Countries::all_codes(); foreach ( Settings::GROUPS as $group ) { $code = isset( $data[ $group . '_country' ] ) && is_string( $data[ $group . '_country' ] ) ? strtoupper( $data[ $group . '_country' ] ) : ''; if ( isset( $all[ $code ] ) ) { $out[ $group ] = $code; } } return $out; } /** * Address country of a classic checkout group (shipping falls back to billing). * * @param array $data Posted data. * @param string $group billing or shipping. * @return string */ private static function posted_country( array $data, $group ) { $key = $group . '_country'; if ( 'shipping' === $group && ( empty( $data['ship_to_different_address'] ) || empty( $data[ $key ] ) ) ) { $key = 'billing_country'; } return isset( $data[ $key ] ) && is_string( $data[ $key ] ) ? $data[ $key ] : ''; } /** * My account value in E.164 when the posted (or saved) address country makes it valid. * * @param mixed $value Value. * @param string $group billing or shipping. * @return mixed */ private static function account_value( $value, $group ) { if ( ! is_string( $value ) || '' === trim( $value ) ) { return $value; } $country = ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checked the edit-address nonce before processing the fields. $posted = isset( $_POST[ $group . '_country' ] ) && is_string( $_POST[ $group . '_country' ] ) ? strtoupper( sanitize_key( wp_unslash( $_POST[ $group . '_country' ] ) ) ) : ''; $known = Countries::all_codes(); if ( isset( $known[ $posted ] ) ) { $country = $posted; } elseif ( function_exists( 'WC' ) && WC()->customer ) { $country = 'billing' === $group ? WC()->customer->get_billing_country() : WC()->customer->get_shipping_country(); } $e164 = self::normalize( $value, '', $country ); return '' !== $e164 ? $e164 : $value; } }