├── LICENSE ├── README.md ├── composer.json └── src ├── Clients ├── Client.php ├── ClientException.php └── IbericodeVatRatesClient.php ├── Countries.php ├── Exception.php ├── Geolocation ├── GeolocatorInterface.php ├── IP2C.php └── IP2Country.php ├── Geolocator.php ├── Period.php ├── Rates.php ├── Validator.php └── Vies ├── Client.php └── ViesException.php /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2017 Danny van Kooten 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ibericode/vat 2 | ================ 3 | 4 | [![Build Status](https://github.com/ibericode/vat/actions/workflows/build.yml/badge.svg)](https://github.com/ibericode/vat/actions/workflows/build.yml) 5 | [![Latest Stable Version](https://img.shields.io/packagist/v/ibericode/vat.svg)](https://packagist.org/packages/ibericode/vat) 6 | ![PHP from Packagist](https://img.shields.io/packagist/php-v/ibericode/vat.svg) 7 | [![Total Downloads](https://img.shields.io/packagist/dt/dannyvankooten/vat.php.svg)](https://packagist.org/packages/ibericode/vat) 8 | ![License](https://img.shields.io/github/license/ibericode/vat.svg) 9 | 10 | This is a simple PHP library to help you deal with Europe's VAT rules. 11 | 12 | - Fetch VAT rates for any EU member state using [ibericode/vat-rates](https://github.com/ibericode/vat-rates). 13 | - Validate VAT numbers (by format and/or [existence](http://ec.europa.eu/taxation_customs/vies/)) 14 | - Validate ISO-3316 alpha-2 country codes 15 | - Determine whether a country is part of the EU 16 | - Geo-locate IP addresses 17 | 18 | ## Installation 19 | 20 | [PHP](https://php.net) version 8.2 or higher with the CURL and JSON extension is required. 21 | 22 | For VAT number existence checking, the PHP SOAP extension is required as well. 23 | 24 | To get the latest stable version, install the package using [Composer](https://getcomposer.org): 25 | 26 | ```bash 27 | composer require ibericode/vat 28 | ``` 29 | 30 | ## Usage 31 | 32 | This library exposes 4 main classes to interact with: `Rates`, `Countries`, `Validator` and `Geolocator`. 33 | 34 | #### Retrieving VAT rates. 35 | 36 | > This package relies on a [community maintained repository of vat rates](https://github.com/ibericode/vat-rates). We invite you to toggle notifications for that repository and contribute changes to VAT rates in your country once they are announced. 37 | 38 | ```php 39 | $rates = new Ibericode\Vat\Rates('/path-for-storing-cache-file.txt'); 40 | $rates->getRateForCountry('NL'); // 21 41 | $rates->getRateForCountry('NL', 'standard'); // 21 42 | $rates->getRateForCountry('NL', 'reduced'); // 9 43 | $rates->getRateForCountryOnDate('NL', new \Datetime('2010-01-01'), 'standard'); // 19 44 | ``` 45 | 46 | This fetches rate from [ibericode/vat-rates](https://github.com/ibericode/vat-rates) and stores a local copy that is periodically refreshed (once every 12 hours by default). 47 | 48 | #### Validation 49 | 50 | Validating a VAT number: 51 | ```php 52 | $validator = new Ibericode\Vat\Validator(); 53 | $validator->validateVatNumberFormat('NL203458239B01'); // true (checks format) 54 | $validator->validateVatNumber('NL203458239B01'); // false (checks format + existence) 55 | ``` 56 | 57 | Validating an IP address: 58 | ```php 59 | $validator = new Ibericode\Vat\Validator(); 60 | $validator->validateIpAddress('256.256.256.256'); // false 61 | $validator->validateIpAddress('8.8.8.8'); // true 62 | ``` 63 | 64 | Validating an ISO-3166-1-alpha2 country code: 65 | ```php 66 | $validator = new Ibericode\Vat\Validator(); 67 | $validator->validateCountryCode('DE'); // true 68 | $validator->validateCountryCode('ZZ'); // false 69 | ``` 70 | 71 | 72 | #### Dealing with ISO-3166-1-alpha2 country codes 73 | 74 | ```php 75 | $countries = new Ibericode\Vat\Countries(); 76 | 77 | // access country name using array access 78 | echo $countries['NL']; // Netherlands 79 | 80 | // loop over countries 81 | foreach ($countries as $code => $name) { 82 | // ... 83 | } 84 | 85 | // check if country is in EU 86 | $countries->isCountryCodeInEU('NL'); // true 87 | $countries->isCountryCodeInEU('US'); // false 88 | ``` 89 | 90 | #### Geo-location 91 | This library includes a simple geo-location service using [ip2c.org](https://about.ip2c.org/) or [ip2country.info](https://ip2country.info) (deprecated as of Dec 2022). 92 | 93 | ```php 94 | $geolocator = new Ibericode\Vat\Geolocator(); 95 | $geolocator->locateIpAddress('8.8.8.8'); // US 96 | ``` 97 | 98 | To use ip2c.org explicitly. 99 | 100 | ```php 101 | $geolocator = new Ibericode\Vat\Geolocator('ip2c.org'); 102 | $geolocator->locateIpAddress('8.8.8.8'); // US 103 | ``` 104 | 105 | #### Symfony support 106 | 107 | If you need to use this package in a [Symfony](https://symfony.com/) environment, check out [ibericode/vat-bundle](https://github.com/ibericode/vat-bundle). 108 | 109 | ## License 110 | 111 | ibericode/vat is licensed under the [MIT License](LICENSE). 112 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ibericode/vat", 3 | "description": "PHP library for dealing with EU VAT", 4 | "keywords": ["vat"], 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Danny van Kooten", 9 | "email": "hi@dvk.co" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=8.2", 14 | "ext-curl": "*", 15 | "ext-json": "*" 16 | }, 17 | "require-dev": { 18 | "phpunit/phpunit": "^11.1", 19 | "friendsofphp/php-cs-fixer": "^3.54" 20 | }, 21 | "autoload": { 22 | "psr-4": { 23 | "Ibericode\\Vat\\": "src/" 24 | } 25 | }, 26 | "autoload-dev": { 27 | "psr-4": { 28 | "Ibericode\\Vat\\Tests\\": "tests/" 29 | } 30 | }, 31 | "config": { 32 | "preferred-install": "dist" 33 | }, 34 | "prefer-stable": true, 35 | "suggest": { 36 | "ibericode/vat-bundle": "Symfony bundle for integrating this package", 37 | "ext-soap": "Needed to support VIES VAT number validation" 38 | }, 39 | "scripts": { 40 | "check-syntax": "find . -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 php --define error_reporting=-1 -l" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Clients/Client.php: -------------------------------------------------------------------------------- 1 | [ 12 | * new Period(DateTime $effectiveFrom, array $rates) 13 | * ] 14 | * ] 15 | * 16 | * @see https://github.com/ibericode/vat-rates* 17 | * @return array 18 | * @throws ClientException 19 | */ 20 | public function fetch(): array; 21 | } 22 | -------------------------------------------------------------------------------- /src/Clients/ClientException.php: -------------------------------------------------------------------------------- 1 | = 400) { 31 | throw new ClientException("Error fetching rates from {$url}."); 32 | } 33 | 34 | return $this->parseResponse($body); 35 | } 36 | 37 | private function parseResponse(string $response_body): array 38 | { 39 | $result = json_decode($response_body, false); 40 | 41 | $return = []; 42 | foreach ($result->items as $country => $periods) { 43 | foreach ($periods as $i => $period) { 44 | $periods[$i] = new Period(new \DateTimeImmutable($period->effective_from), (array) $period->rates); 45 | } 46 | 47 | $return[$country] = $periods; 48 | } 49 | 50 | return $return; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Countries.php: -------------------------------------------------------------------------------- 1 | 'Andorra', 22 | 'AE' => 'United Arab Emirates', 23 | 'AF' => 'Afghanistan', 24 | 'AG' => 'Antigua & Barbuda', 25 | 'AI' => 'Anguilla', 26 | 'AL' => 'Albania', 27 | 'AM' => 'Armenia', 28 | 'AO' => 'Angola', 29 | 'AQ' => 'Antarctica', 30 | 'AR' => 'Argentina', 31 | 'AS' => 'American Samoa', 32 | 'AT' => 'Austria', 33 | 'AU' => 'Australia', 34 | 'AW' => 'Aruba', 35 | 'AX' => 'Åland Islands', 36 | 'AZ' => 'Azerbaijan', 37 | 'BA' => 'Bosnia & Herzegovina', 38 | 'BB' => 'Barbados', 39 | 'BD' => 'Bangladesh', 40 | 'BE' => 'Belgium', 41 | 'BF' => 'Burkina Faso', 42 | 'BG' => 'Bulgaria', 43 | 'BH' => 'Bahrain', 44 | 'BI' => 'Burundi', 45 | 'BJ' => 'Benin', 46 | 'BL' => 'St. Barthélemy', 47 | 'BM' => 'Bermuda', 48 | 'BN' => 'Brunei', 49 | 'BO' => 'Bolivia', 50 | 'BQ' => 'Caribbean Netherlands', 51 | 'BR' => 'Brazil', 52 | 'BS' => 'Bahamas', 53 | 'BT' => 'Bhutan', 54 | 'BV' => 'Bouvet Island', 55 | 'BW' => 'Botswana', 56 | 'BY' => 'Belarus', 57 | 'BZ' => 'Belize', 58 | 'CA' => 'Canada', 59 | 'CC' => 'Cocos (Keeling) Islands', 60 | 'CD' => 'Congo - Kinshasa', 61 | 'CF' => 'Central African Republic', 62 | 'CG' => 'Congo - Brazzaville', 63 | 'CH' => 'Switzerland', 64 | 'CI' => 'Côte d’Ivoire', 65 | 'CK' => 'Cook Islands', 66 | 'CL' => 'Chile', 67 | 'CM' => 'Cameroon', 68 | 'CN' => 'China', 69 | 'CO' => 'Colombia', 70 | 'CR' => 'Costa Rica', 71 | 'CU' => 'Cuba', 72 | 'CV' => 'Cape Verde', 73 | 'CW' => 'Curaçao', 74 | 'CX' => 'Christmas Island', 75 | 'CY' => 'Cyprus', 76 | 'CZ' => 'Czechia', 77 | 'DE' => 'Germany', 78 | 'DJ' => 'Djibouti', 79 | 'DK' => 'Denmark', 80 | 'DM' => 'Dominica', 81 | 'DO' => 'Dominican Republic', 82 | 'DZ' => 'Algeria', 83 | 'EC' => 'Ecuador', 84 | 'EE' => 'Estonia', 85 | 'EG' => 'Egypt', 86 | 'EH' => 'Western Sahara', 87 | 'ER' => 'Eritrea', 88 | 'ES' => 'Spain', 89 | 'ET' => 'Ethiopia', 90 | 'FI' => 'Finland', 91 | 'FJ' => 'Fiji', 92 | 'FK' => 'Falkland Islands', 93 | 'FM' => 'Micronesia', 94 | 'FO' => 'Faroe Islands', 95 | 'FR' => 'France', 96 | 'GA' => 'Gabon', 97 | 'GB' => 'United Kingdom', 98 | 'GD' => 'Grenada', 99 | 'GE' => 'Georgia', 100 | 'GF' => 'French Guiana', 101 | 'GG' => 'Guernsey', 102 | 'GH' => 'Ghana', 103 | 'GI' => 'Gibraltar', 104 | 'GL' => 'Greenland', 105 | 'GM' => 'Gambia', 106 | 'GN' => 'Guinea', 107 | 'GP' => 'Guadeloupe', 108 | 'GQ' => 'Equatorial Guinea', 109 | 'GR' => 'Greece', 110 | 'GS' => 'South Georgia & South Sandwich Islands', 111 | 'GT' => 'Guatemala', 112 | 'GU' => 'Guam', 113 | 'GW' => 'Guinea-Bissau', 114 | 'GY' => 'Guyana', 115 | 'HK' => 'Hong Kong SAR China', 116 | 'HM' => 'Heard & McDonald Islands', 117 | 'HN' => 'Honduras', 118 | 'HR' => 'Croatia', 119 | 'HT' => 'Haiti', 120 | 'HU' => 'Hungary', 121 | 'ID' => 'Indonesia', 122 | 'IE' => 'Ireland', 123 | 'IL' => 'Israel', 124 | 'IM' => 'Isle of Man', 125 | 'IN' => 'India', 126 | 'IO' => 'British Indian Ocean Territory', 127 | 'IQ' => 'Iraq', 128 | 'IR' => 'Iran', 129 | 'IS' => 'Iceland', 130 | 'IT' => 'Italy', 131 | 'JE' => 'Jersey', 132 | 'JM' => 'Jamaica', 133 | 'JO' => 'Jordan', 134 | 'JP' => 'Japan', 135 | 'KE' => 'Kenya', 136 | 'KG' => 'Kyrgyzstan', 137 | 'KH' => 'Cambodia', 138 | 'KI' => 'Kiribati', 139 | 'KM' => 'Comoros', 140 | 'KN' => 'St. Kitts & Nevis', 141 | 'KP' => 'North Korea', 142 | 'KR' => 'South Korea', 143 | 'KW' => 'Kuwait', 144 | 'KY' => 'Cayman Islands', 145 | 'KZ' => 'Kazakhstan', 146 | 'LA' => 'Laos', 147 | 'LB' => 'Lebanon', 148 | 'LC' => 'St. Lucia', 149 | 'LI' => 'Liechtenstein', 150 | 'LK' => 'Sri Lanka', 151 | 'LR' => 'Liberia', 152 | 'LS' => 'Lesotho', 153 | 'LT' => 'Lithuania', 154 | 'LU' => 'Luxembourg', 155 | 'LV' => 'Latvia', 156 | 'LY' => 'Libya', 157 | 'MA' => 'Morocco', 158 | 'MC' => 'Monaco', 159 | 'MD' => 'Moldova', 160 | 'ME' => 'Montenegro', 161 | 'MF' => 'St. Martin', 162 | 'MG' => 'Madagascar', 163 | 'MH' => 'Marshall Islands', 164 | 'MK' => 'North Macedonia', 165 | 'ML' => 'Mali', 166 | 'MM' => 'Myanmar (Burma)', 167 | 'MN' => 'Mongolia', 168 | 'MO' => 'Macao SAR China', 169 | 'MP' => 'Northern Mariana Islands', 170 | 'MQ' => 'Martinique', 171 | 'MR' => 'Mauritania', 172 | 'MS' => 'Montserrat', 173 | 'MT' => 'Malta', 174 | 'MU' => 'Mauritius', 175 | 'MV' => 'Maldives', 176 | 'MW' => 'Malawi', 177 | 'MX' => 'Mexico', 178 | 'MY' => 'Malaysia', 179 | 'MZ' => 'Mozambique', 180 | 'NA' => 'Namibia', 181 | 'NC' => 'New Caledonia', 182 | 'NE' => 'Niger', 183 | 'NF' => 'Norfolk Island', 184 | 'NG' => 'Nigeria', 185 | 'NI' => 'Nicaragua', 186 | 'NL' => 'Netherlands', 187 | 'NO' => 'Norway', 188 | 'NP' => 'Nepal', 189 | 'NR' => 'Nauru', 190 | 'NU' => 'Niue', 191 | 'NZ' => 'New Zealand', 192 | 'OM' => 'Oman', 193 | 'PA' => 'Panama', 194 | 'PE' => 'Peru', 195 | 'PF' => 'French Polynesia', 196 | 'PG' => 'Papua New Guinea', 197 | 'PH' => 'Philippines', 198 | 'PK' => 'Pakistan', 199 | 'PL' => 'Poland', 200 | 'PM' => 'St. Pierre & Miquelon', 201 | 'PN' => 'Pitcairn Islands', 202 | 'PR' => 'Puerto Rico', 203 | 'PS' => 'Palestinian Territories', 204 | 'PT' => 'Portugal', 205 | 'PW' => 'Palau', 206 | 'PY' => 'Paraguay', 207 | 'QA' => 'Qatar', 208 | 'RE' => 'Réunion', 209 | 'RO' => 'Romania', 210 | 'RS' => 'Serbia', 211 | 'RU' => 'Russia', 212 | 'RW' => 'Rwanda', 213 | 'SA' => 'Saudi Arabia', 214 | 'SB' => 'Solomon Islands', 215 | 'SC' => 'Seychelles', 216 | 'SD' => 'Sudan', 217 | 'SE' => 'Sweden', 218 | 'SG' => 'Singapore', 219 | 'SH' => 'St. Helena', 220 | 'SI' => 'Slovenia', 221 | 'SJ' => 'Svalbard & Jan Mayen', 222 | 'SK' => 'Slovakia', 223 | 'SL' => 'Sierra Leone', 224 | 'SM' => 'San Marino', 225 | 'SN' => 'Senegal', 226 | 'SO' => 'Somalia', 227 | 'SR' => 'Suriname', 228 | 'SS' => 'South Sudan', 229 | 'ST' => 'São Tomé & Príncipe', 230 | 'SV' => 'El Salvador', 231 | 'SX' => 'Sint Maarten', 232 | 'SY' => 'Syria', 233 | 'SZ' => 'Eswatini', 234 | 'TC' => 'Turks & Caicos Islands', 235 | 'TD' => 'Chad', 236 | 'TF' => 'French Southern Territories', 237 | 'TG' => 'Togo', 238 | 'TH' => 'Thailand', 239 | 'TJ' => 'Tajikistan', 240 | 'TK' => 'Tokelau', 241 | 'TL' => 'Timor-Leste', 242 | 'TM' => 'Turkmenistan', 243 | 'TN' => 'Tunisia', 244 | 'TO' => 'Tonga', 245 | 'TR' => 'Türkiye', 246 | 'TT' => 'Trinidad & Tobago', 247 | 'TV' => 'Tuvalu', 248 | 'TW' => 'Taiwan', 249 | 'TZ' => 'Tanzania', 250 | 'UA' => 'Ukraine', 251 | 'UG' => 'Uganda', 252 | 'UM' => 'U.S. Outlying Islands', 253 | 'US' => 'United States', 254 | 'UY' => 'Uruguay', 255 | 'UZ' => 'Uzbekistan', 256 | 'VA' => 'Vatican City', 257 | 'VC' => 'St. Vincent & Grenadines', 258 | 'VE' => 'Venezuela', 259 | 'VG' => 'British Virgin Islands', 260 | 'VI' => 'U.S. Virgin Islands', 261 | 'VN' => 'Vietnam', 262 | 'VU' => 'Vanuatu', 263 | 'WF' => 'Wallis & Futuna', 264 | 'WS' => 'Samoa', 265 | 'YE' => 'Yemen', 266 | 'YT' => 'Mayotte', 267 | 'ZA' => 'South Africa', 268 | 'ZM' => 'Zambia', 269 | 'ZW' => 'Zimbabwe', 270 | ]; 271 | 272 | /** 273 | * @param string $code 274 | * @return bool 275 | */ 276 | public function hasCountryCode(string $code): bool 277 | { 278 | return $this->offsetExists($code); 279 | } 280 | 281 | /** 282 | * @return array 283 | */ 284 | public function getCountryCodesInEU(): array 285 | { 286 | return [ 287 | 'AT', // Austria 288 | 'AX', // Aland islands => Finland 289 | 'BE', // Belgium 290 | 'BG', // Bulgaria 291 | 'CY', // Cyprus 292 | 'CZ', // Czechia 293 | 'DE', // Germany 294 | 'DK', // Denmark 295 | 'EE', // Estonia 296 | 'ES', // Spain 297 | 'FI', // Finland 298 | 'FR', // France 299 | 'GF', // French guiana => France 300 | 'GP', // Guadeloupe => France 301 | 'GR', // Greece 302 | 'HU', // Hungary 303 | 'HR', // Croatia 304 | 'IE', // Ireland 305 | 'IT', // Italy 306 | 'LT', // Lithuania 307 | 'LU', // Luxembourg 308 | 'LV', // Latvia 309 | 'MT', // Malta 310 | 'MQ', // Martinique => France 311 | 'NL', // Netherlands 312 | 'PL', // Poland 313 | 'PT', // Portugal 314 | 'RE', // Reunion => France 315 | 'RO', // Romania 316 | 'SE', // Sweden 317 | 'SI', // Slovenia 318 | 'SK', // Slovakia 319 | 'YT', // Mayotte => France 320 | ]; 321 | } 322 | 323 | /** 324 | * @param string $code 325 | * @return bool 326 | */ 327 | public function isCountryCodeInEU(string $code): bool 328 | { 329 | return in_array($code, $this->getCountryCodesInEU(), true); 330 | } 331 | 332 | 333 | /** 334 | * Return the current element 335 | * @link https://php.net/manual/en/iterator.current.php 336 | * @return mixed Can return any type. 337 | * @since 5.0.0 338 | */ 339 | public function current(): mixed 340 | { 341 | return current($this->data); 342 | } 343 | 344 | /** 345 | * Move forward to next element 346 | * @link https://php.net/manual/en/iterator.next.php 347 | * @return void Any returned value is ignored. 348 | * @since 5.0.0 349 | */ 350 | public function next(): void 351 | { 352 | next($this->data); 353 | } 354 | 355 | /** 356 | * Return the key of the current element 357 | * @link https://php.net/manual/en/iterator.key.php 358 | * @return mixed scalar on success, or null on failure. 359 | * @since 5.0.0 360 | */ 361 | public function key(): mixed 362 | { 363 | return key($this->data); 364 | } 365 | 366 | /** 367 | * Checks if current position is valid 368 | * @link https://php.net/manual/en/iterator.valid.php 369 | * @return boolean The return value will be casted to boolean and then evaluated. 370 | * Returns true on success or false on failure. 371 | * @since 5.0.0 372 | */ 373 | public function valid(): bool 374 | { 375 | return key($this->data) !== null; 376 | } 377 | 378 | /** 379 | * Rewind the Iterator to the first element 380 | * @link https://php.net/manual/en/iterator.rewind.php 381 | * @return void Any returned value is ignored. 382 | * @since 5.0.0 383 | */ 384 | public function rewind(): void 385 | { 386 | reset($this->data); 387 | } 388 | 389 | /** 390 | * @param string $countryCode 391 | * @return bool 392 | */ 393 | public function offsetExists($countryCode): bool 394 | { 395 | return isset($this->data[$countryCode]); 396 | } 397 | 398 | /** 399 | * @param string $countryCode 400 | * @return string 401 | * @throws \Exception 402 | */ 403 | public function offsetGet($countryCode): mixed 404 | { 405 | if (!$this->offsetExists($countryCode)) { 406 | throw new Exception("Invalid country code {$countryCode}"); 407 | } 408 | 409 | return $this->data[$countryCode]; 410 | } 411 | 412 | /** 413 | * @param string $countryCode 414 | * @param string $name 415 | * @throws \Exception 416 | */ 417 | public function offsetSet($countryCode, $name): void 418 | { 419 | throw new Exception('Invalid use of Countries class'); 420 | } 421 | 422 | /** 423 | * @param string $countryCode 424 | * @throws \Exception 425 | */ 426 | public function offsetUnset($countryCode): void 427 | { 428 | throw new Exception('Invalid use of Countries class'); 429 | } 430 | } 431 | -------------------------------------------------------------------------------- /src/Exception.php: -------------------------------------------------------------------------------- 1 | = 400 || $response === '') { 36 | return ''; 37 | } 38 | 39 | $parts = explode(';', $response); 40 | return $parts[1] === 'ZZ' ? '' : $parts[1]; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Geolocation/IP2Country.php: -------------------------------------------------------------------------------- 1 | = 400 || $response === '') { 37 | return ''; 38 | } 39 | 40 | $data = json_decode($response); 41 | return $data->countryCode; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Geolocator.php: -------------------------------------------------------------------------------- 1 | IP2C::class, 14 | 'ip2country.info' => IP2Country::class, 15 | ]; 16 | 17 | /** 18 | * @var IP2Country|IP2C 19 | */ 20 | private $service; 21 | 22 | public function __construct(string $service = 'ip2c.org') 23 | { 24 | if (!isset($this->services[$service])) { 25 | throw new \InvalidArgumentException("Invalid service {$service}"); 26 | } 27 | 28 | $this->service = new $this->services[$service](); 29 | } 30 | 31 | public function locateIpAddress(string $ipAddress): string 32 | { 33 | if ($ipAddress === '') { 34 | return ''; 35 | } 36 | 37 | return $this->service->locateIpAddress($ipAddress); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Period.php: -------------------------------------------------------------------------------- 1 | effectiveFrom = $effectiveFrom; 24 | $this->rates = $rates; 25 | } 26 | 27 | public function getEffectiveFrom(): DateTimeInterface 28 | { 29 | return $this->effectiveFrom; 30 | } 31 | 32 | public function getRate(string $level): float 33 | { 34 | if (!isset($this->rates[$level])) { 35 | throw new InvalidArgumentException("Invalid rate level: {$level}"); 36 | } 37 | 38 | return $this->rates[$level]; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Rates.php: -------------------------------------------------------------------------------- 1 | refreshInterval = $refreshInterval; 44 | $this->storagePath = $storagePath; 45 | $this->client = $client; 46 | } 47 | 48 | private function load(): void 49 | { 50 | if (count($this->rates) > 0) { 51 | return; 52 | } 53 | 54 | if ($this->storagePath !== '' && \is_file($this->storagePath)) { 55 | $this->loadFromFile(); 56 | 57 | // bail early if file is still valid 58 | // TODO: Store timestamp in file, so we're safe from fs modifications 59 | if (filemtime($this->storagePath) > (time() - $this->refreshInterval)) { 60 | return; 61 | } 62 | } 63 | 64 | $this->loadFromRemote(); 65 | } 66 | 67 | private function loadFromFile(): void 68 | { 69 | $contents = file_get_contents($this->storagePath); 70 | if ($contents === false || $contents === '') { 71 | throw new Exception("Unserializable file content"); 72 | } 73 | 74 | $data = @unserialize($contents, [ 75 | 'allowed_classes' => [ 76 | Period::class, 77 | DateTimeImmutable::class 78 | ] 79 | ]); 80 | 81 | if (false === is_array($data)) { 82 | throw new Exception("Unserializable file content"); 83 | } 84 | 85 | $this->rates = $data; 86 | } 87 | 88 | private function loadFromRemote(): void 89 | { 90 | try { 91 | $this->client = $this->client ?: new IbericodeVatRatesClient(); 92 | $this->rates = $this->client->fetch(); 93 | } catch (ClientException $e) { 94 | // this property could have been populated from the local filesystem at this stage 95 | // this ensures the application using this package keeps on running even if the VAT rates service is down 96 | if (count($this->rates) > 0) { 97 | return; 98 | } 99 | 100 | throw $e; 101 | } 102 | 103 | // sort periods by DateTime so that later periods come first 104 | foreach ($this->rates as $country => $periods) { 105 | usort($this->rates[$country], function (Period $period1, Period $period2) { 106 | return $period1->getEffectiveFrom() > $period2->getEffectiveFrom() ? -1 : 1; 107 | }); 108 | } 109 | 110 | // update local file with updated rates 111 | if ($this->storagePath !== '') { 112 | file_put_contents($this->storagePath, serialize($this->rates)); 113 | } 114 | } 115 | 116 | private function resolvePeriod(string $countryCode, DateTimeInterface $datetime): Period 117 | { 118 | $this->load(); 119 | 120 | if (!isset($this->rates[$countryCode])) { 121 | throw new Exception("Invalid country code {$countryCode}"); 122 | } 123 | 124 | // find first active period (because periods are sorted) 125 | foreach ($this->rates[$countryCode] as $period) { 126 | /** @var Period $period */ 127 | if ($datetime >= $period->getEffectiveFrom()) { 128 | return $period; 129 | } 130 | } 131 | 132 | throw new Exception("Unable to find a rate for country {$countryCode} on {$datetime->format(DATE_ATOM)}."); 133 | } 134 | 135 | /** 136 | * @param string $countryCode ISO-3166-1-alpha2 country code 137 | * @param string $level 138 | * @return float 139 | * @throws \Exception 140 | */ 141 | public function getRateForCountry(string $countryCode, string $level = self::RATE_STANDARD): float 142 | { 143 | $todayMidnight = new \DateTimeImmutable('today midnight'); 144 | return $this->getRateForCountryOnDate($countryCode, $todayMidnight, $level); 145 | } 146 | 147 | /** 148 | * @param string $countryCode ISO-3166-1-alpha2 country code 149 | * @param DateTimeInterface $datetime 150 | * @param string $level 151 | * @return float 152 | * @throws Exception 153 | */ 154 | public function getRateForCountryOnDate(string $countryCode, \DateTimeInterface $datetime, string $level = self::RATE_STANDARD): float 155 | { 156 | return $this->resolvePeriod($countryCode, $datetime)->getRate($level); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/Validator.php: -------------------------------------------------------------------------------- 1 | 'U[A-Z\d]{8}', 17 | 'BE' => '(0|1)\d{9}', 18 | 'BG' => '\d{9,10}', 19 | 'CY' => '\d{8}[A-Z]', 20 | 'CZ' => '\d{8,10}', 21 | 'DE' => '\d{9}', 22 | 'DK' => '(\d{2} ?){3}\d{2}', 23 | 'EE' => '\d{9}', 24 | 'EL' => '\d{9}', 25 | 'ES' => '([A-Z]\d{7}[A-Z]|\d{8}[A-Z]|[A-Z]\d{8})', 26 | 'EU' => '\d{9}', 27 | 'FI' => '\d{8}', 28 | 'FR' => '[A-Z\d]{2}\d{9}', 29 | 'GB' => '(\d{9}|\d{12}|(GD|HA)\d{3})', 30 | 'HR' => '\d{11}', 31 | 'HU' => '\d{8}', 32 | 'IE' => '((\d{7}[A-Z]{1,2})|(\d[A-Z]\d{5}[A-Z]))', 33 | 'IT' => '\d{11}', 34 | 'LT' => '(\d{9}|\d{12})', 35 | 'LU' => '\d{8}', 36 | 'LV' => '\d{11}', 37 | 'MT' => '\d{8}', 38 | 'NL' => '\d{9}B\d{2}', 39 | 'PL' => '\d{10}', 40 | 'PT' => '\d{9}', 41 | 'RO' => '\d{2,10}', 42 | 'SE' => '\d{12}', 43 | 'SI' => '\d{8}', 44 | 'SK' => '\d{10}', 45 | 'SM' => '\d{5}', 46 | ]; 47 | 48 | /** 49 | * @var Vies\Client 50 | */ 51 | private $client; 52 | 53 | /** 54 | * VatValidator constructor. 55 | * 56 | * @param Vies\Client $client (optional) 57 | */ 58 | public function __construct(?Vies\Client $client = null) 59 | { 60 | $this->client = $client ?: new Vies\Client(); 61 | } 62 | 63 | /** 64 | * Checks whether the given string is a valid ISO-3166-1-alpha2 country code 65 | * 66 | * @param string $countryCode 67 | * @return bool 68 | */ 69 | public function validateCountryCode(string $countryCode): bool 70 | { 71 | $countries = new Countries(); 72 | return isset($countries[$countryCode]); 73 | } 74 | 75 | /** 76 | * Checks whether the given string is a valid public IPv4 or IPv6 address 77 | * 78 | * @param string $ipAddress 79 | * @return bool 80 | */ 81 | public function validateIpAddress(string $ipAddress): bool 82 | { 83 | if ($ipAddress === '') { 84 | return false; 85 | } 86 | 87 | return (bool) filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE); 88 | } 89 | 90 | /** 91 | * Validate a VAT number format. This does not check whether the VAT number was really issued. 92 | * 93 | * @param string $vatNumber 94 | * 95 | * @return boolean 96 | */ 97 | public function validateVatNumberFormat(string $vatNumber): bool 98 | { 99 | if ($vatNumber === '') { 100 | return false; 101 | } 102 | 103 | $vatNumber = strtoupper($vatNumber); 104 | $country = substr($vatNumber, 0, 2); 105 | $number = substr($vatNumber, 2); 106 | 107 | if (! isset($this->patterns[$country])) { 108 | return false; 109 | } 110 | 111 | return preg_match('/^' . $this->patterns[$country] . '$/', $number) > 0; 112 | } 113 | 114 | /** 115 | * 116 | * @param string $vatNumber 117 | * 118 | * @return boolean 119 | * 120 | * @throws Vies\ViesException 121 | */ 122 | protected function validateVatNumberExistence(string $vatNumber): bool 123 | { 124 | $vatNumber = strtoupper($vatNumber); 125 | $country = substr($vatNumber, 0, 2); 126 | $number = substr($vatNumber, 2); 127 | return $this->client->checkVat($country, $number); 128 | } 129 | 130 | /** 131 | * Validates a VAT number using format + existence check. 132 | * 133 | * @param string $vatNumber Either the full VAT number (incl. country) or just the part after the country code. 134 | * 135 | * @return boolean 136 | * 137 | * @throws Vies\ViesException 138 | */ 139 | public function validateVatNumber(string $vatNumber): bool 140 | { 141 | return $this->validateVatNumberFormat($vatNumber) && $this->validateVatNumberExistence($vatNumber); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Vies/Client.php: -------------------------------------------------------------------------------- 1 | timeout = $timeout; 35 | } 36 | 37 | /** 38 | * @param string $countryCode 39 | * @param string $vatNumber 40 | * 41 | * @return bool 42 | * 43 | * @throws ViesException 44 | */ 45 | public function checkVat(string $countryCode, string $vatNumber): bool 46 | { 47 | return (bool)$this->getInfo($countryCode, $vatNumber)->valid; 48 | } 49 | 50 | /** 51 | * @param string $countryCode 52 | * @param string $vatNumber 53 | * 54 | * @return object 55 | * 56 | * @throws ViesException 57 | */ 58 | public function getInfo(string $countryCode, string $vatNumber): object 59 | { 60 | try { 61 | $response = $this->getClient()->checkVat( 62 | array( 63 | 'countryCode' => $countryCode, 64 | 'vatNumber' => $vatNumber 65 | ) 66 | ); 67 | } catch (SoapFault $e) { 68 | throw new ViesException($e->getMessage(), $e->getCode()); 69 | } 70 | 71 | return $response; 72 | } 73 | 74 | /** 75 | * @return SoapClient 76 | */ 77 | protected function getClient(): SoapClient 78 | { 79 | if ($this->client === null) { 80 | $this->client = new SoapClient(self::URL, ['connection_timeout' => $this->timeout]); 81 | } 82 | 83 | return $this->client; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Vies/ViesException.php: -------------------------------------------------------------------------------- 1 |