PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.1
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.1
2.4.0 2.4.1 2.3.8 2.3.7 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.2.0 2.1.21 2.1.20 2.1.19 2.1.18 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 2.1.15 2.1.16 2.1.2 All 57 releases
easy-invoice / includes / Services / ViesValidator.php

ViesValidator.php in Easy Invoice – Invoice Generator, PDF Quotes & Payments 2.4.1, at includes/Services/ViesValidator.php

227 lines 8.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Checks a VAT number against the EU's own register.
4 *
5 * @package Easy_Invoice
6 * @subpackage Services
7 */
8
9 namespace EasyInvoice\Services;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Asks VIES whether a VAT identifier is really registered.
17 *
18 * Why a syntax check is not enough
19 * --------------------------------
20 * `TaxTreatment::looksLikeValidVat()` only says the number is shaped like a VAT
21 * identifier. Whether it is *registered* is a different question, and it is the
22 * one that matters: an intra-EU reverse-charge invoice is only valid if the
23 * customer really is VAT-registered in another member state. Get it wrong and
24 * the supplier owes the VAT they did not charge — the exposure lands on the
25 * merchant, not the customer.
26 *
27 * VIES is the European Commission's own register and the evidence a tax
28 * authority expects. It needs no key and no account.
29 *
30 * "Invalid" and "could not check" are not the same
31 * ------------------------------------------------
32 * VIES goes down, and individual member states' registers go down
33 * independently of it. Reporting an outage as "this VAT number is invalid"
34 * would be worse than not checking at all, because a merchant would act on it
35 * and charge VAT they should not have. So an unreachable service, a malformed
36 * request, or a member state that did not answer all return a WP_Error saying
37 * the check could not be made. `valid => false` is returned only when VIES
38 * positively said the number is not registered.
39 *
40 * Advisory, never blocking
41 * ------------------------
42 * Nothing here decides whether an invoice can be saved or what tax it carries.
43 * A merchant with a customer whose registration VIES cannot confirm today still
44 * has an invoice to send. This tells them what the register says; the decision
45 * stays theirs.
46 */
47 class ViesValidator {
48
49 /** The Commission's REST endpoint. No key, no account. */
50 const ENDPOINT = 'https://ec.europa.eu/taxation_customs/vies/rest-api/ms/%s/vat/%s';
51
52 /** Transient prefix for cached answers. */
53 const CACHE_PREFIX = 'ei_vies_';
54
55 /** How long a confirmed registration is trusted. */
56 const CACHE_VALID = WEEK_IN_SECONDS;
57
58 /** How long a confirmed non-registration is trusted — shorter, because a
59 * business registering for VAT is the change that matters. */
60 const CACHE_INVALID = DAY_IN_SECONDS;
61
62 /**
63 * Can VIES answer for this country at all?
64 *
65 * @param string $country ISO 3166-1 alpha-2 code.
66 * @return bool
67 */
68 public static function isSupportedCountry( string $country ): bool {
69 return TaxTreatment::isEu( $country );
70 }
71
72 /**
73 * Ask VIES about a VAT number.
74 *
75 * @param string $vat VAT identifier, with or without its country prefix.
76 * @param string $country ISO 3166-1 alpha-2 code. Taken from the number when empty.
77 * @param bool $refresh Skip the cache.
78 * @return array{valid:bool,name:string,address:string,country:string,number:string,checked_at:string,cached:bool}|\WP_Error
79 */
80 public static function check( string $vat, string $country = '', bool $refresh = false ) {
81 $vat = strtoupper( preg_replace( '/[^A-Za-z0-9]/', '', $vat ) );
82
83 if ( '' === $vat ) {
84 return new \WP_Error( 'easy_invoice_vies_empty', __( 'Enter a VAT number to check.', 'easy-invoice' ) );
85 }
86
87 // A number usually carries its own country prefix; fall back to the one
88 // recorded against the document when it does not.
89 if ( preg_match( '/^([A-Z]{2})([A-Z0-9]{2,13})$/', $vat, $m ) ) {
90 $country = '' !== $country ? $country : $m[1];
91 $number = $m[2];
92 $prefix = $m[1];
93 } else {
94 $number = $vat;
95 $prefix = '';
96 }
97
98 $country = strtoupper( trim( $country ) );
99
100 if ( '' === $country ) {
101 return new \WP_Error(
102 'easy_invoice_vies_no_country',
103 __( 'That VAT number has no country prefix, and no country is recorded for this customer.', 'easy-invoice' )
104 );
105 }
106
107 if ( ! self::isSupportedCountry( $country ) ) {
108 return new \WP_Error(
109 'easy_invoice_vies_not_eu',
110 sprintf(
111 /* translators: %s: country code. */
112 __( 'VIES only covers the EU VAT area, so it cannot check a %s number.', 'easy-invoice' ),
113 $country
114 )
115 );
116 }
117
118 // Greece files VAT under EL while its ISO country code is GR. VIES wants
119 // the tax prefix, so a customer recorded as GR would otherwise never
120 // validate.
121 $ms = 'GR' === $country ? 'EL' : $country;
122
123 // Strip a prefix that duplicates the member state, but keep one that
124 // does not — that is a mismatch worth reporting rather than hiding.
125 if ( '' !== $prefix && $prefix !== $ms && ! ( 'EL' === $ms && 'GR' === $prefix ) ) {
126 return new \WP_Error(
127 'easy_invoice_vies_country_mismatch',
128 sprintf(
129 /* translators: 1: prefix on the number, 2: country recorded. */
130 __( 'This VAT number starts with %1$s but the customer is recorded as being in %2$s.', 'easy-invoice' ),
131 $prefix,
132 $country
133 )
134 );
135 }
136
137 $cache_key = self::CACHE_PREFIX . md5( $ms . '|' . $number );
138
139 if ( ! $refresh ) {
140 $cached = get_transient( $cache_key );
141 if ( is_array( $cached ) ) {
142 $cached['cached'] = true;
143 return $cached;
144 }
145 }
146
147 $response = wp_remote_get(
148 sprintf( self::ENDPOINT, rawurlencode( $ms ), rawurlencode( $number ) ),
149 [
150 'timeout' => 15,
151 // Never relaxed. This is a check whose answer changes what tax a
152 // merchant charges, so the identity of who answered it matters.
153 'sslverify' => true,
154 'headers' => [ 'Accept' => 'application/json' ],
155 ]
156 );
157
158 if ( is_wp_error( $response ) ) {
159 return new \WP_Error(
160 'easy_invoice_vies_unreachable',
161 __( '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' )
162 );
163 }
164
165 $code = (int) wp_remote_retrieve_response_code( $response );
166 $body = json_decode( (string) wp_remote_retrieve_body( $response ), true );
167
168 if ( 200 !== $code || ! is_array( $body ) ) {
169 return new \WP_Error(
170 'easy_invoice_vies_bad_response',
171 sprintf(
172 /* translators: %d: HTTP status code. */
173 __( 'The EU VAT register answered unexpectedly (HTTP %d), so this number has not been checked.', 'easy-invoice' ),
174 $code
175 )
176 );
177 }
178
179 $user_error = isset( $body['userError'] ) ? (string) $body['userError'] : '';
180
181 // Anything other than a straight VALID/INVALID is the service telling us
182 // it could not answer, not telling us the number is bad.
183 if ( '' !== $user_error && ! in_array( $user_error, [ 'VALID', 'INVALID' ], true ) ) {
184 return new \WP_Error(
185 'easy_invoice_vies_indeterminate',
186 sprintf(
187 /* translators: %s: status reported by VIES. */
188 __( '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' ),
189 $user_error
190 ),
191 [ 'user_error' => $user_error ]
192 );
193 }
194
195 $valid = ! empty( $body['isValid'] );
196
197 // Several member states return "---" rather than disclosing the trader's
198 // name; that is a policy choice, not missing data, so it is not shown.
199 $name = isset( $body['name'] ) ? trim( (string) $body['name'] ) : '';
200 $address = isset( $body['address'] ) ? trim( (string) $body['address'] ) : '';
201 $name = ( '---' === $name ) ? '' : $name;
202 $address = ( '---' === $address ) ? '' : $address;
203
204 $result = [
205 'valid' => $valid,
206 'name' => $name,
207 'address' => $address,
208 'country' => $country,
209 'number' => $ms . $number,
210 'checked_at' => current_time( 'mysql' ),
211 'cached' => false,
212 ];
213
214 set_transient( $cache_key, $result, $valid ? self::CACHE_VALID : self::CACHE_INVALID );
215
216 /**
217 * Fires after a VAT number has been checked against VIES.
218 *
219 * @param array $result The answer.
220 * @param string $vat The number as supplied.
221 */
222 do_action( 'easy_invoice_vies_checked', $result, $vat );
223
224 return $result;
225 }
226 }
227