From 65e4f84b4d80b1c8a093eccda99e562c46eab11d Mon Sep 17 00:00:00 2001 From: Danny van Kooten Date: Sun, 10 Apr 2016 15:01:54 +0200 Subject: [PATCH] Add `Countries` class containing helper methods to deal with EC country codes and simple geo-ip. --- .travis.yml | 1 - README.md | 17 +- composer.json | 4 +- src/Countries.php | 383 +++++++++++++++++++++++++++++++++++++ src/Facades/Countries.php | 12 ++ src/Validator.php | 67 ++----- src/VatServiceProvider.php | 9 +- tests/CountriesTest.php | 35 ++++ tests/ValidatorTest.php | 17 -- 9 files changed, 466 insertions(+), 79 deletions(-) create mode 100644 src/Countries.php create mode 100644 src/Facades/Countries.php create mode 100644 tests/CountriesTest.php diff --git a/.travis.yml b/.travis.yml index a11117cb81..bfd3237876 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,6 @@ language: php php: - - 5.5 - 5.6 - 7.0 - hhvm diff --git a/README.md b/README.md index 7a83d8528d..5c721322bc 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,17 @@ Laravel VAT Laravel VAT is a simple Laravel library which helps you in dealing with European VAT rules. It helps you... -- Grab (current) VAT rates for any European member state -- Validate VAT numbers +- Grab up-to-date VAT rates for any European member state +- Validate VAT numbers (by format or existence) +- Work with ISO 3166-1 alpha-2 country codes and determine whether they're part of the EU. +- Geolocate IP addresses The library uses jsonvat.com to obtain its data for the VAT rates. Full details can be seen [here](https://github.com/adamcooke/vat-rates). For VAT number validation, this uses the [VIES VAT number validation](http://ec.europa.eu/taxation_customs/vies/). ## Installation -Either [PHP](https://php.net) 5.5+ or [HHVM](http://hhvm.com) 3.6+ are required. For VAT number validation, the PHP SOAP extension is required as well. +Either [PHP](https://php.net) 5.6+ or [HHVM](http://hhvm.com) 3.6+ are required. For VAT number validation, the PHP SOAP extension is required as well. To get the latest version of Laravel VAT, simply require the project using [Composer](https://getcomposer.org): @@ -30,6 +32,8 @@ You can register facades in the `aliases` key of your `config/app.php` file if y * `'VatRates' => 'DvK\Laravel\Vat\Facades\Rates'` * `'VatValidator' => 'DvK\Laravel\Vat\Facades\Validator'` +* `'Countries' => 'DvK\Laravel\Vat\Facades\Countries'` + ## Usage @@ -38,6 +42,7 @@ If you registered the facades then using an instance of the classes is as easy a ```php use DvK\Laravel\Vat\Facades\Rates; use DvK\Laravel\Vat\Facades\Validator; +use DvK\Laravel\Vat\Facades\Countries; Rates::country( 'NL' ); // 21 Rates::country( 'NL', 'reduced' ); // 6 @@ -48,7 +53,11 @@ Validator::validateFormat('NL203458239B01'); // true (checks just format) Validator::validate('NL203458239B01'); // false (checks format + existence) Validator::validate('NL203458239B01', 'GB'); // false (checks format + existence + country match) -Validator::isEuCountry('NL'); // true +Countries::inEurope('NL'); // true +Countries::name('NL') // Netherlands +Countries::all(); // array of country codes + names +Countries::europe(); array of EU country codes + names +Countries::ip('8.8.8.8'); // US ``` If you'd prefer to use dependency injection, you can easily inject the class like this. diff --git a/composer.json b/composer.json index 38f5c17ff6..aca83e16ed 100644 --- a/composer.json +++ b/composer.json @@ -10,10 +10,10 @@ } ], "require": { - "php": ">=5.5.9" + "php": ">=5.6" }, "require-dev": { - "phpunit/phpunit": "^4.8|^5.0" + "phpunit/phpunit": "^5.0" }, "autoload": { "psr-4": { diff --git a/src/Countries.php b/src/Countries.php new file mode 100644 index 0000000000..1f99b068b3 --- /dev/null +++ b/src/Countries.php @@ -0,0 +1,383 @@ + 'Afghanistan', + 'AX' => 'Aland Islands', + 'AL' => 'Albania', + 'DZ' => 'Algeria', + 'AS' => 'American Samoa', + 'AD' => 'Andorra', + 'AO' => 'Angola', + 'AI' => 'Anguilla', + 'AQ' => 'Antarctica', + 'AG' => 'Antigua And Barbuda', + 'AR' => 'Argentina', + 'AM' => 'Armenia', + 'AW' => 'Aruba', + 'AU' => 'Australia', + 'AT' => 'Austria', + 'AZ' => 'Azerbaijan', + 'BS' => 'Bahamas', + 'BH' => 'Bahrain', + 'BD' => 'Bangladesh', + 'BB' => 'Barbados', + 'BY' => 'Belarus', + 'BE' => 'Belgium', + 'BZ' => 'Belize', + 'BJ' => 'Benin', + 'BM' => 'Bermuda', + 'BT' => 'Bhutan', + 'BO' => 'Bolivia', + 'BA' => 'Bosnia And Herzegovina', + 'BW' => 'Botswana', + 'BV' => 'Bouvet Island', + 'BR' => 'Brazil', + 'IO' => 'British Indian Ocean Territory', + 'BN' => 'Brunei Darussalam', + 'BG' => 'Bulgaria', + 'BF' => 'Burkina Faso', + 'BI' => 'Burundi', + 'KH' => 'Cambodia', + 'CM' => 'Cameroon', + 'CA' => 'Canada', + 'CV' => 'Cape Verde', + 'KY' => 'Cayman Islands', + 'CF' => 'Central African Republic', + 'TD' => 'Chad', + 'CL' => 'Chile', + 'CN' => 'China', + 'CX' => 'Christmas Island', + 'CC' => 'Cocos (Keeling) Islands', + 'CO' => 'Colombia', + 'KM' => 'Comoros', + 'CG' => 'Congo', + 'CD' => 'Congo, Democratic Republic', + 'CK' => 'Cook Islands', + 'CR' => 'Costa Rica', + 'CI' => 'Cote D\'Ivoire', + 'HR' => 'Croatia', + 'CU' => 'Cuba', + 'CY' => 'Cyprus', + 'CZ' => 'Czech Republic', + 'DK' => 'Denmark', + 'DJ' => 'Djibouti', + 'DM' => 'Dominica', + 'DO' => 'Dominican Republic', + 'EC' => 'Ecuador', + 'EG' => 'Egypt', + 'SV' => 'El Salvador', + 'GQ' => 'Equatorial Guinea', + 'ER' => 'Eritrea', + 'EE' => 'Estonia', + 'ET' => 'Ethiopia', + 'FK' => 'Falkland Islands (Malvinas)', + 'FO' => 'Faroe Islands', + 'FJ' => 'Fiji', + 'FI' => 'Finland', + 'FR' => 'France', + 'GF' => 'French Guiana', + 'PF' => 'French Polynesia', + 'TF' => 'French Southern Territories', + 'GA' => 'Gabon', + 'GM' => 'Gambia', + 'GE' => 'Georgia', + 'DE' => 'Germany', + 'GH' => 'Ghana', + 'GI' => 'Gibraltar', + 'GR' => 'Greece', + 'GL' => 'Greenland', + 'GD' => 'Grenada', + 'GP' => 'Guadeloupe', + 'GU' => 'Guam', + 'GT' => 'Guatemala', + 'GG' => 'Guernsey', + 'GN' => 'Guinea', + 'GW' => 'Guinea-Bissau', + 'GY' => 'Guyana', + 'HT' => 'Haiti', + 'HM' => 'Heard Island & Mcdonald Islands', + 'VA' => 'Holy See (Vatican City State)', + 'HN' => 'Honduras', + 'HK' => 'Hong Kong', + 'HU' => 'Hungary', + 'IS' => 'Iceland', + 'IN' => 'India', + 'ID' => 'Indonesia', + 'IR' => 'Iran, Islamic Republic Of', + 'IQ' => 'Iraq', + 'IE' => 'Ireland', + 'IM' => 'Isle Of Man', + 'IL' => 'Israel', + 'IT' => 'Italy', + 'JM' => 'Jamaica', + 'JP' => 'Japan', + 'JE' => 'Jersey', + 'JO' => 'Jordan', + 'KZ' => 'Kazakhstan', + 'KE' => 'Kenya', + 'KI' => 'Kiribati', + 'KR' => 'Korea', + 'KW' => 'Kuwait', + 'KG' => 'Kyrgyzstan', + 'LA' => 'Lao People\'s Democratic Republic', + 'LV' => 'Latvia', + 'LB' => 'Lebanon', + 'LS' => 'Lesotho', + 'LR' => 'Liberia', + 'LY' => 'Libyan Arab Jamahiriya', + 'LI' => 'Liechtenstein', + 'LT' => 'Lithuania', + 'LU' => 'Luxembourg', + 'MO' => 'Macao', + 'MK' => 'Macedonia', + 'MG' => 'Madagascar', + 'MW' => 'Malawi', + 'MY' => 'Malaysia', + 'MV' => 'Maldives', + 'ML' => 'Mali', + 'MT' => 'Malta', + 'MH' => 'Marshall Islands', + 'MQ' => 'Martinique', + 'MR' => 'Mauritania', + 'MU' => 'Mauritius', + 'YT' => 'Mayotte', + 'MX' => 'Mexico', + 'FM' => 'Micronesia, Federated States Of', + 'MD' => 'Moldova', + 'MC' => 'Monaco', + 'MN' => 'Mongolia', + 'ME' => 'Montenegro', + 'MS' => 'Montserrat', + 'MA' => 'Morocco', + 'MZ' => 'Mozambique', + 'MM' => 'Myanmar', + 'NA' => 'Namibia', + 'NR' => 'Nauru', + 'NP' => 'Nepal', + 'NL' => 'Netherlands', + 'AN' => 'Netherlands Antilles', + 'NC' => 'New Caledonia', + 'NZ' => 'New Zealand', + 'NI' => 'Nicaragua', + 'NE' => 'Niger', + 'NG' => 'Nigeria', + 'NU' => 'Niue', + 'NF' => 'Norfolk Island', + 'MP' => 'Northern Mariana Islands', + 'NO' => 'Norway', + 'OM' => 'Oman', + 'PK' => 'Pakistan', + 'PW' => 'Palau', + 'PS' => 'Palestinian Territory, Occupied', + 'PA' => 'Panama', + 'PG' => 'Papua New Guinea', + 'PY' => 'Paraguay', + 'PE' => 'Peru', + 'PH' => 'Philippines', + 'PN' => 'Pitcairn', + 'PL' => 'Poland', + 'PT' => 'Portugal', + 'PR' => 'Puerto Rico', + 'QA' => 'Qatar', + 'RE' => 'Reunion', + 'RO' => 'Romania', + 'RU' => 'Russian Federation', + 'RW' => 'Rwanda', + 'BL' => 'Saint Barthelemy', + 'SH' => 'Saint Helena', + 'KN' => 'Saint Kitts And Nevis', + 'LC' => 'Saint Lucia', + 'MF' => 'Saint Martin', + 'PM' => 'Saint Pierre And Miquelon', + 'VC' => 'Saint Vincent And Grenadines', + 'WS' => 'Samoa', + 'SM' => 'San Marino', + 'ST' => 'Sao Tome And Principe', + 'SA' => 'Saudi Arabia', + 'SN' => 'Senegal', + 'RS' => 'Serbia', + 'SC' => 'Seychelles', + 'SL' => 'Sierra Leone', + 'SG' => 'Singapore', + 'SK' => 'Slovakia', + 'SI' => 'Slovenia', + 'SB' => 'Solomon Islands', + 'SO' => 'Somalia', + 'ZA' => 'South Africa', + 'GS' => 'South Georgia And Sandwich Isl.', + 'ES' => 'Spain', + 'LK' => 'Sri Lanka', + 'SD' => 'Sudan', + 'SR' => 'Suriname', + 'SJ' => 'Svalbard And Jan Mayen', + 'SZ' => 'Swaziland', + 'SE' => 'Sweden', + 'CH' => 'Switzerland', + 'SY' => 'Syrian Arab Republic', + 'TW' => 'Taiwan', + 'TJ' => 'Tajikistan', + 'TZ' => 'Tanzania', + 'TH' => 'Thailand', + 'TL' => 'Timor-Leste', + 'TG' => 'Togo', + 'TK' => 'Tokelau', + 'TO' => 'Tonga', + 'TT' => 'Trinidad And Tobago', + 'TN' => 'Tunisia', + 'TR' => 'Turkey', + 'TM' => 'Turkmenistan', + 'TC' => 'Turks And Caicos Islands', + 'TV' => 'Tuvalu', + 'UG' => 'Uganda', + 'UA' => 'Ukraine', + 'AE' => 'United Arab Emirates', + 'GB' => 'United Kingdom', + 'US' => 'United States', + 'UM' => 'United States Outlying Islands', + 'UY' => 'Uruguay', + 'UZ' => 'Uzbekistan', + 'VU' => 'Vanuatu', + 'VE' => 'Venezuela', + 'VN' => 'Viet Nam', + 'VG' => 'Virgin Islands, British', + 'VI' => 'Virgin Islands, U.S.', + 'WF' => 'Wallis And Futuna', + 'EH' => 'Western Sahara', + 'YE' => 'Yemen', + 'ZM' => 'Zambia', + 'ZW' => 'Zimbabwe', + ]; + + private static $eu = [ + 'AT', + 'BE', + 'BG', + 'CY', + 'CZ', + 'DE', + 'DK', + 'EE', + 'ES', + 'FI', + 'FR', + 'GB', + 'GR', + 'HU', + 'HR', + 'IE', + 'IT', + 'LT', + 'LU', + 'LV', + 'MT', + 'NL', + 'PL', + 'PT', + 'RO', + 'SE', + 'SI', + 'SK' + ]; + + /** + * Get all countries in code => name format + * + * @return array + */ + public function all() { + return self::$all; + } + + /** + * Get all EU countries in code => name format + * + * @return array + */ + public function europe() { + $codes = self::$eu; + $countries = []; + + foreach($codes as $code){ + $countries[$code] = self::$all[$code]; + } + + return $countries; + } + + /** + * Get full country name for a given country code + * + * @param string $code + * + * @return string + */ + public function name($code) { + $code = strtoupper($code); + return self::$all[$code]; + } + + /** + * Checks whether the given country is in the EU + * + * @param string $code + * + * @return bool + */ + public function inEurope($code) { + $code = strtoupper($code); + return in_array($code, self::$eu); + } + + /** + * Gets the country code by IP address + * + * @link http://about.ip2c.org/ + * + * @param string $ip + * + * @return string + */ + public function ip($ip) { + $response = file_get_contents('http://ip2c.org/' . $ip); + + if(!empty( $response)) { + $parts = explode( ';', $response ); + return $parts[1]; + } + + return ''; + } + + /** + * Get country codes which are used by European Commissions (exceptions to ISO-3166-1-alpha2) + * + * @link + * + * @param string $code + * @return string + */ + public function fixCode($code) { + static $exceptions = array( + 'GR' => 'EL', + 'UK' => 'GB', + ); + + if( isset( $exceptions[$code] ) ) { + return $exceptions[$code]; + } + + return $code; + } + +} \ No newline at end of file diff --git a/src/Facades/Countries.php b/src/Facades/Countries.php new file mode 100644 index 0000000000..fb35d3a230 --- /dev/null +++ b/src/Facades/Countries.php @@ -0,0 +1,12 @@ +fixCountryCode($country); - return isset( self::$patterns[$country] ); - } - /** * Validate a VAT number format. This does not check whether the VAT number was really issued. * @@ -104,69 +93,41 @@ public function validateFormat( $vatNumber ) { } /** - * Validates a VAT number using format + existence check. - * - * Pass a country code as the second parameter if you want to make sure a number is valid for the given ISO-3166-1-alpha2 country. + * @throws Exception * * @param string $vatNumber - * @param string $countryCode - * @return boolean * - * @throws Exception + * @return boolean */ - public function validate( $vatNumber, $countryCode = '' ) { + public function validateExistence($vatNumber) { $vatNumber = strtoupper( $vatNumber ); - $countryCode = strtoupper( $countryCode ); - - // if country code is omitted, use first two chars of vat number - if( empty( $countryCode ) ) { - $countryCode = substr( $vatNumber, 0, 2 ); - } else { - // otherwise, transform country code to ISO-3166-1-alpha2 - $countryCode = $this->fixCountryCode( $countryCode ); - } - - // strip first two characters of VAT number if it matches the country code - if( substr( $vatNumber, 0, 2 ) === $countryCode ) { - $vatNumber = substr( $vatNumber, 2 ); - } - - // check VAT number format - if( ! $this->validateFormat( $countryCode . $vatNumber ) ) { - return false; - } + $country = substr( $vatNumber, 0, 2 ); + $number = substr( $vatNumber, 2 ); // call VIES VAT Soap API try { $response = $this->client->checkVat( array( - 'countryCode' => $countryCode, - 'vatNumber' => $vatNumber + 'countryCode' => $country, + 'vatNumber' => $number ) ); } catch( SoapFault $e ) { throw new Exception( 'VAT check is currently unavailable.', $e->getCode() ); } - return !! $response->valid; + return (bool) $response->valid; } /** - * @param string $country + * Validates a VAT number using format + existence check. + * + * @param string $vatNumber Either the full VAT number (incl. country) or just the part after the country code. * - * @return string + * @return boolean */ - protected function fixCountryCode( $country ) { - static $exceptions = array( - 'GR' => 'EL', - 'UK' => 'GB', - ); - - if( isset( $exceptions[$country] ) ) { - return $exceptions[$country]; - } - - return $country; + public function validate( $vatNumber ) { + return $this->validateFormat( $vatNumber ) && $this->validateExistence( $vatNumber ); } diff --git a/src/VatServiceProvider.php b/src/VatServiceProvider.php index 2268426b37..b5e56cd65c 100644 --- a/src/VatServiceProvider.php +++ b/src/VatServiceProvider.php @@ -30,6 +30,10 @@ public function boot() */ public function register() { + $this->app->singleton( Countries::class, function (Container $app) { + return new Countries(); + }); + $this->app->singleton( Validator::class, function (Container $app) { return new Validator(); }); @@ -50,8 +54,9 @@ public function register() public function provides() { return [ - 'DvK\Laravel\Vat\Validator', - 'DvK\Laravel\Vat\Rates', + Validator::class, + Rates::class, + Countries::class, ]; } } diff --git a/tests/CountriesTest.php b/tests/CountriesTest.php new file mode 100644 index 0000000000..835093d6d5 --- /dev/null +++ b/tests/CountriesTest.php @@ -0,0 +1,35 @@ +name('US')); + } + + public function test_inEurope() { + $countries = new Countries(); + + $invalid = [ 'US', '', 'NE', 'JP', 'RU' ]; + foreach( $invalid as $country ) { + self::assertFalse( $countries->inEurope( $country ) ); + } + + $valid = [ 'NL', 'nl', 'GB', 'GR', 'BE' ]; + foreach( $valid as $country ) { + self::assertTrue( $countries->inEurope( $country ) ); + } + } +} \ No newline at end of file diff --git a/tests/ValidatorTest.php b/tests/ValidatorTest.php index 28bdd0aa74..1ea4fdfd42 100644 --- a/tests/ValidatorTest.php +++ b/tests/ValidatorTest.php @@ -15,23 +15,6 @@ class ValidatorTest extends PHPUnit_Framework_TestCase { - /** - * @covers Validator::isEuCountry - */ - public function test_isEuCountry() { - $validator = new Validator(); - - $invalid = [ 'US', '', 'NE', 'JP', 'RU' ]; - foreach( $invalid as $country ) { - self::assertFalse( $validator->isEuCountry( $country ) ); - } - - $valid = [ 'NL', 'nl', 'GB', 'UK', 'GR', 'EL', 'BE' ]; - foreach( $valid as $country ) { - self::assertTrue( $validator->isEuCountry( $country ) ); - } - } - /** * @covers Validator::validateFormat */