false` is returned only when VIES * positively said the number is not registered. * * Advisory, never blocking * ------------------------ * Nothing here decides whether an invoice can be saved or what tax it carries. * A merchant with a customer whose registration VIES cannot confirm today still * has an invoice to send. This tells them what the register says; the decision * stays theirs. */ class ViesValidator { /** The Commission's REST endpoint. No key, no account. */ const ENDPOINT = 'https://ec.europa.eu/taxation_customs/vies/rest-api/ms/%s/vat/%s'; /** Transient prefix for cached answers. */ const CACHE_PREFIX = 'ei_vies_'; /** How long a confirmed registration is trusted. */ const CACHE_VALID = WEEK_IN_SECONDS; /** How long a confirmed non-registration is trusted — shorter, because a * business registering for VAT is the change that matters. */ const CACHE_INVALID = DAY_IN_SECONDS; /** * Can VIES answer for this country at all? * * @param string $country ISO 3166-1 alpha-2 code. * @return bool */ public static function isSupportedCountry( string $country ): bool { return TaxTreatment::isEu( $country ); } /** * Ask VIES about a VAT number. * * @param string $vat VAT identifier, with or without its country prefix. * @param string $country ISO 3166-1 alpha-2 code. Taken from the number when empty. * @param bool $refresh Skip the cache. * @return array{valid:bool,name:string,address:string,country:string,number:string,checked_at:string,cached:bool}|\WP_Error */ public static function check( string $vat, string $country = '', bool $refresh = false ) { $vat = strtoupper( preg_replace( '/[^A-Za-z0-9]/', '', $vat ) ); if ( '' === $vat ) { return new \WP_Error( 'easy_invoice_vies_empty', __( 'Enter a VAT number to check.', 'easy-invoice' ) ); } // A number usually carries its own country prefix; fall back to the one // recorded against the document when it does not. if ( preg_match( '/^([A-Z]{2})([A-Z0-9]{2,13})$/', $vat, $m ) ) { $country = '' !== $country ? $country : $m[1]; $number = $m[2]; $prefix = $m[1]; } else { $number = $vat; $prefix = ''; } $country = strtoupper( trim( $country ) ); if ( '' === $country ) { return new \WP_Error( 'easy_invoice_vies_no_country', __( 'That VAT number has no country prefix, and no country is recorded for this customer.', 'easy-invoice' ) ); } if ( ! self::isSupportedCountry( $country ) ) { return new \WP_Error( 'easy_invoice_vies_not_eu', sprintf( /* translators: %s: country code. */ __( 'VIES only covers the EU VAT area, so it cannot check a %s number.', 'easy-invoice' ), $country ) ); } // Greece files VAT under EL while its ISO country code is GR. VIES wants // the tax prefix, so a customer recorded as GR would otherwise never // validate. $ms = 'GR' === $country ? 'EL' : $country; // Strip a prefix that duplicates the member state, but keep one that // does not — that is a mismatch worth reporting rather than hiding. if ( '' !== $prefix && $prefix !== $ms && ! ( 'EL' === $ms && 'GR' === $prefix ) ) { return new \WP_Error( 'easy_invoice_vies_country_mismatch', sprintf( /* translators: 1: prefix on the number, 2: country recorded. */ __( 'This VAT number starts with %1$s but the customer is recorded as being in %2$s.', 'easy-invoice' ), $prefix, $country ) ); } $cache_key = self::CACHE_PREFIX . md5( $ms . '|' . $number ); if ( ! $refresh ) { $cached = get_transient( $cache_key ); if ( is_array( $cached ) ) { $cached['cached'] = true; return $cached; } } $response = wp_remote_get( sprintf( self::ENDPOINT, rawurlencode( $ms ), rawurlencode( $number ) ), [ 'timeout' => 15, // Never relaxed. This is a check whose answer changes what tax a // merchant charges, so the identity of who answered it matters. 'sslverify' => true, 'headers' => [ 'Accept' => 'application/json' ], ] ); if ( is_wp_error( $response ) ) { return new \WP_Error( 'easy_invoice_vies_unreachable', __( 'The EU VAT register could not be reached, so this number has not been checked. It has not been found invalid — try again shortly.', 'easy-invoice' ) ); } $code = (int) wp_remote_retrieve_response_code( $response ); $body = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( 200 !== $code || ! is_array( $body ) ) { return new \WP_Error( 'easy_invoice_vies_bad_response', sprintf( /* translators: %d: HTTP status code. */ __( 'The EU VAT register answered unexpectedly (HTTP %d), so this number has not been checked.', 'easy-invoice' ), $code ) ); } $user_error = isset( $body['userError'] ) ? (string) $body['userError'] : ''; // Anything other than a straight VALID/INVALID is the service telling us // it could not answer, not telling us the number is bad. if ( '' !== $user_error && ! in_array( $user_error, [ 'VALID', 'INVALID' ], true ) ) { return new \WP_Error( 'easy_invoice_vies_indeterminate', sprintf( /* translators: %s: status reported by VIES. */ __( 'The EU VAT register could not answer for this number (%s), so it has not been checked. It has not been found invalid.', 'easy-invoice' ), $user_error ), [ 'user_error' => $user_error ] ); } $valid = ! empty( $body['isValid'] ); // Several member states return "---" rather than disclosing the trader's // name; that is a policy choice, not missing data, so it is not shown. $name = isset( $body['name'] ) ? trim( (string) $body['name'] ) : ''; $address = isset( $body['address'] ) ? trim( (string) $body['address'] ) : ''; $name = ( '---' === $name ) ? '' : $name; $address = ( '---' === $address ) ? '' : $address; $result = [ 'valid' => $valid, 'name' => $name, 'address' => $address, 'country' => $country, 'number' => $ms . $number, 'checked_at' => current_time( 'mysql' ), 'cached' => false, ]; set_transient( $cache_key, $result, $valid ? self::CACHE_VALID : self::CACHE_INVALID ); /** * Fires after a VAT number has been checked against VIES. * * @param array $result The answer. * @param string $vat The number as supplied. */ do_action( 'easy_invoice_vies_checked', $result, $vat ); return $result; } }