From 22d6f3bdc4dfe84039ba3d13853afb56b48a3afb Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Sun, 28 Dec 2025 12:30:18 +0100 Subject: [PATCH 1/2] Add new VAT number validator --- README.md | 14 ++- src/Rules/EuropeanVatNumber.php | 163 ++++++++++++++++++++++++++ src/lang/de/validation.php | 1 + src/lang/en/validation.php | 1 + src/lang/es/validation.php | 1 + src/lang/fr/validation.php | 1 + src/lang/nl/validation.php | 1 + tests/Rules/EuropeanVatNumberTest.php | 81 +++++++++++++ 8 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 src/Rules/EuropeanVatNumber.php create mode 100644 tests/Rules/EuropeanVatNumberTest.php diff --git a/README.md b/README.md index 402ae05..7af52a0 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Require the package via Composer: The Validation library is built to work with the Laravel Framework (>=10). It comes with a service provider, which will be discovered automatically and -registers the validation rules into your installation. The package provides 37 +registers the validation rules into your installation. The package provides 38 additional validation rules including multi language error messages, which can be used like Laravel's own validation rules. @@ -112,6 +112,18 @@ Checks for a valid [European Article Number](https://en.wikipedia.org/wiki/Inter Optional integer length (8 or 13) to check only for EAN-8 or EAN-13. +### European VAT Number + +Checks for a valid [European VAT identification number](https://en.wikipedia.org/wiki/VAT_identification_number). + + public Intervention\Validation\Rules\EuropeanVatNumber::__construct(bool $withApi = false) + +#### Parameters + +**withApi** + +Set to `true` to validate the VAT number against the official [VIES API](http://ec.europa.eu/taxation_customs/vies/). Defaults to `false` for format validation only. + ### Global Release Identifier (GRid) The field under validation must be a [Global Release Identifier](https://en.wikipedia.org/wiki/Global_Release_Identifier). diff --git a/src/Rules/EuropeanVatNumber.php b/src/Rules/EuropeanVatNumber.php new file mode 100644 index 0000000..f864cbf --- /dev/null +++ b/src/Rules/EuropeanVatNumber.php @@ -0,0 +1,163 @@ + + */ + private array $patternExpressions = [ + 'AT' => 'U[A-Z\d]{8}', + 'BE' => '(0\d{9}|\d{10})', + 'BG' => '\d{9,10}', + 'CH' => '^\d{6}$|^[E]\d{9}\s?(TVA|MWST|IVA)$', + 'CY' => '\d{8}[A-Z]', + 'CZ' => '\d{8,10}', + 'DE' => '\d{9}', + 'DK' => '(\d{2} ?){3}\d{2}', + 'EE' => '\d{9}', + 'EL' => '\d{9}', + 'ES' => '[A-Z]\d{7}[A-Z]|\d{8}[A-Z]|[A-Z]\d{8}', + 'FI' => '\d{8}', + 'FR' => '([A-Z]{2}|\d{2})\d{9}', + 'GB' => '\d{9}|\d{12}|(GD|HA)\d{3}', + 'HR' => '\d{11}', + 'HU' => '\d{8}', + 'IE' => '[A-Z\d]{8}|[A-Z\d]{9}', + 'IT' => '\d{11}', + 'LT' => '(\d{9}|\d{12})', + 'LU' => '\d{8}', + 'LV' => '\d{11}', + 'MT' => '\d{8}', + 'NL' => '\d{9}B\d{2}', + 'PL' => '\d{10}', + 'PT' => '\d{9}', + 'RO' => '\d{2,10}', + 'SE' => '\d{12}', + 'SI' => '\d{8}', + 'SK' => '\d{10}', + ]; + + public function __construct(private readonly bool $withApi = false) + { + } + + /** + * @throws \SoapFault + */ + public function isValid(mixed $value): bool + { + $vatNumber = $this->vatCleaner((string) $value); + [$country, $number] = $this->splitVat($vatNumber); + + + $isValidFormat = $this->isValidFormat($country, $number); + + if (!$this->withApi) { + return $isValidFormat; + } + + return $this->checkVatViaApi($country, $number); + } + + private function isValidFormat(string $country, string $number): bool + { + if (!isset($this->patternExpressions[$country])) { + return false; + } + + $validateRule = preg_match('/^' . $this->patternExpressions[$country] . '$/', (string) $number) > 0; + + if ($validateRule && $country === 'IT') { + $result = self::luhnCheck($number); + + return $result % 10 == 0; + } + + if ($validateRule && $country === 'HU') { + return $this->validateHuVat($number); + } + + return $validateRule; + } + + /** @link https://en.wikipedia.org/wiki/Luhn_algorithm */ + private function luhnCheck(string $vat): int + { + $sum = 0; + $vat_array = str_split($vat); + $counter = count($vat_array); + for ($index = 0; $index < $counter; ++$index) { + $value = intval($vat_array[$index]); + if ($index % 2 !== 0) { + $value *= 2; + if ($value > 9) { + $value = 1 + ($value % 10); + } + } + + $sum += $value; + } + + return $sum; + } + + private function validateHuVat(string $vatNumber): bool + { + $checksum = (int) $vatNumber[7]; + $weights = [9, 7, 3, 1, 9, 7, 3]; + $sum = 0; + + foreach ($weights as $i => $weight) { + $sum += (int) $vatNumber[$i] * $weight; + } + + $calculatedChecksum = (10 - ($sum % 10)) % 10; + + return $calculatedChecksum === $checksum; + } + + private function vatCleaner(string $vatNumber): string + { + $vatNumber_no_spaces = trim($vatNumber); + + return strtoupper($vatNumber_no_spaces); + } + + /** + * @return array{0: string, 1: string} + */ + private function splitVat(string $vatNumber): array + { + return [ + substr($vatNumber, 0, 2), + substr($vatNumber, 2), + ]; + } + + /** + * @throws \SoapFault + */ + private function checkVatViaApi(string $country, string $number): bool + { + $client = new \SoapClient( + 'https://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl', + ['connection_timeout' => 10], + ); + $response = $client->checkVat( + [ + 'countryCode' => $country, + 'vatNumber' => $number, + ] + ); + + return $response->valid; + } +} diff --git a/src/lang/de/validation.php b/src/lang/de/validation.php index e5ae636..0a4d0d1 100644 --- a/src/lang/de/validation.php +++ b/src/lang/de/validation.php @@ -32,6 +32,7 @@ 'datauri' => ':attribute ist keine gültige Data-URL.', 'ulid' => ':attribute ist keine gültige ULID.', 'ean' => 'Der Wert :attribute ist keine gültige European Article Number (EAN).', + 'europeanvatnumber' => 'Der Wert :attribute ist keine gültige europäische Umsatzsteuer-Identifikationsnummer.', 'gtin' => 'Der Wert :attribute ist keine gültige Global Trade Item Number (GTIN).', 'postalcode' => 'Der Wert :attribute muss eine gültige Postleitzahl sein.', 'mimetype' => 'Der Wert :attribute einhält keinen gültigen Internet Media Type (MIME-Type).', diff --git a/src/lang/en/validation.php b/src/lang/en/validation.php index cab1661..58d6c08 100644 --- a/src/lang/en/validation.php +++ b/src/lang/en/validation.php @@ -31,6 +31,7 @@ 'datauri' => 'The :attribute must be a valid data url.', 'ulid' => 'The :attribute is not a valid ULID.', 'ean' => 'The :attribute is not a valid European Article Number (EAN).', + 'europeanvatnumber' => 'The :attribute is not a valid European VAT number.', 'gtin' => 'The :attribute is not a valid Global Trade Item Number (GTIN).', 'postalcode' => 'The value :attribute must be a valid postal code.', 'mimetype' => 'The value :attribute does not contain a valid Internet Media Type (MIME-Type).', diff --git a/src/lang/es/validation.php b/src/lang/es/validation.php index 570cfa1..780063b 100644 --- a/src/lang/es/validation.php +++ b/src/lang/es/validation.php @@ -30,6 +30,7 @@ 'datauri' => ':attribute debe ser un url de datos válido .', 'ulid' => ':attribute no es un ULID válido.', 'ean' => ':attribute no es un EAN válido.', + 'europeanvatnumber' => ':attribute no es un número de IVA europeo válido.', 'gtin' => ':attribute no es un número GTIN válido.', 'postalcode' => 'El valor de :attribute debe ser un código postal válido.', 'mimetype' => 'El valor de :attribute no contiene un tipo de media de internet (MIME-Type) válido.', diff --git a/src/lang/fr/validation.php b/src/lang/fr/validation.php index 76da756..ef9b546 100644 --- a/src/lang/fr/validation.php +++ b/src/lang/fr/validation.php @@ -38,6 +38,7 @@ 'datauri' => 'Le :attribute doit être un Data URL valide.', 'ulid' => 'Le :attribute doit être un ULID valide.', 'ean' => 'Le :attribute doit être un EAN valide.', + 'europeanvatnumber' => 'Le :attribute doit être un numéro de TVA européen valide.', 'gtin' => 'Le :attribute doit être un GTIN valide.', 'postalcode' => 'La valeur :attribute doit être un code postal valide.', 'mimetype' => 'La valeur :attribute ne contient pas de type de média Internet valide (type MIME).', diff --git a/src/lang/nl/validation.php b/src/lang/nl/validation.php index d40776a..c54063b 100644 --- a/src/lang/nl/validation.php +++ b/src/lang/nl/validation.php @@ -30,6 +30,7 @@ 'datauri' => ':attribute moet een geldige Data-URL zijn.', 'ulid' => ':attribute is geen geldig ULID.', 'ean' => ':attribute is geen geldig European Article Number (EAN).', + 'europeanvatnumber' => ':attribute is geen geldig Europees btw-nummer.', 'gtin' => ':attribute is geen geldig Global Trade Item Number (GTIN).', 'postalcode' => ':attribute moet een geldige postcode zijn.', 'mimetype' => ':attribute bevat geen geldig internetmediatype (MIME-type).', diff --git a/tests/Rules/EuropeanVatNumberTest.php b/tests/Rules/EuropeanVatNumberTest.php new file mode 100644 index 0000000..761d509 --- /dev/null +++ b/tests/Rules/EuropeanVatNumberTest.php @@ -0,0 +1,81 @@ +isValid($value); + $this->assertEquals($result, $valid); + } + + public static function dataProvider(): Generator + { + yield [true, 'ATU12345678']; + yield [true, 'BE0123456789']; + yield [true, 'BE1234567890']; + yield [true, 'BG123456789']; + yield [true, 'BG1234567890']; + yield [true, 'CY12345678A']; + yield [true, 'CZ12345678']; + yield [true, 'CZ123456789']; + yield [true, 'CZ1234567890']; + yield [true, 'DE123456789']; + yield [true, 'DK12345678']; + yield [true, 'DK12 34 56 78']; + yield [true, 'EE123456789']; + yield [true, 'EL123456789']; + yield [true, 'ESA12345678']; + yield [true, 'ES12345678A']; + yield [true, 'ESA1234567B']; + yield [true, 'FI12345678']; + yield [true, 'FR12123456789']; + yield [true, 'FRAA123456789']; + yield [true, 'GB123456789']; + yield [true, 'GB123456789012']; + yield [true, 'GBGD123']; + yield [true, 'GBHA123']; + yield [true, 'HR12345678901']; + yield [true, 'HU12345676']; + yield [true, 'IE1234567A']; + yield [true, 'IE1A234567B']; + yield [true, 'IT02182030391']; + yield [true, 'LT123456789']; + yield [true, 'LT123456789012']; + yield [true, 'LU12345678']; + yield [true, 'LV12345678901']; + yield [true, 'MT12345678']; + yield [true, 'NL123456789B01']; + yield [true, 'PL1234567890']; + yield [true, 'PT123456789']; + yield [true, 'RO12']; + yield [true, 'RO1234567890']; + yield [true, 'SE123456789012']; + yield [true, 'SI12345678']; + yield [true, 'SK1234567890']; + yield [true, 'CHE123456789MWST']; + yield [true, 'CHE123456789TVA']; + yield [true, 'CHE123456789IVA']; + yield [true, 'CH123456']; + yield [true, ' de123456789 ']; + yield [false, 'foobar']; + yield [false, 'XX123456789']; + yield [false, 'DE12345678']; + yield [false, 'DE1234567890']; + yield [false, 'AT12345678']; + yield [false, 'ATU1234567']; + yield [false, 'NL123456789B1']; + yield [false, 'IT12345678901']; + yield [false, 'HU12345678']; + yield [false, '']; + } +} From ba2801e4678fd8f69c6fb2390e4123c413584818 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Mon, 29 Dec 2025 09:35:26 +0100 Subject: [PATCH 2/2] Remove optional VAT api and extend form Luhn class for more code reusability --- README.md | 8 +--- src/Rules/EuropeanVatNumber.php | 84 +++------------------------------ 2 files changed, 8 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 7af52a0..24c7ee0 100644 --- a/README.md +++ b/README.md @@ -116,13 +116,7 @@ Optional integer length (8 or 13) to check only for EAN-8 or EAN-13. Checks for a valid [European VAT identification number](https://en.wikipedia.org/wiki/VAT_identification_number). - public Intervention\Validation\Rules\EuropeanVatNumber::__construct(bool $withApi = false) - -#### Parameters - -**withApi** - -Set to `true` to validate the VAT number against the official [VIES API](http://ec.europa.eu/taxation_customs/vies/). Defaults to `false` for format validation only. + public Intervention\Validation\Rules\EuropeanVatNumber::__construct() ### Global Release Identifier (GRid) diff --git a/src/Rules/EuropeanVatNumber.php b/src/Rules/EuropeanVatNumber.php index f864cbf..3213526 100644 --- a/src/Rules/EuropeanVatNumber.php +++ b/src/Rules/EuropeanVatNumber.php @@ -4,9 +4,7 @@ namespace Intervention\Validation\Rules; -use Intervention\Validation\AbstractRule; - -class EuropeanVatNumber extends AbstractRule +class EuropeanVatNumber extends Luhn { /** * @link http://ec.europa.eu/taxation_customs/vies/faq.html?locale=en#item_11 @@ -45,68 +43,26 @@ class EuropeanVatNumber extends AbstractRule 'SK' => '\d{10}', ]; - public function __construct(private readonly bool $withApi = false) - { - } - - /** - * @throws \SoapFault - */ public function isValid(mixed $value): bool { - $vatNumber = $this->vatCleaner((string) $value); + $vatNumber = strtoupper(trim((string) $value)); [$country, $number] = $this->splitVat($vatNumber); - - $isValidFormat = $this->isValidFormat($country, $number); - - if (!$this->withApi) { - return $isValidFormat; - } - - return $this->checkVatViaApi($country, $number); - } - - private function isValidFormat(string $country, string $number): bool - { if (!isset($this->patternExpressions[$country])) { return false; } - $validateRule = preg_match('/^' . $this->patternExpressions[$country] . '$/', (string) $number) > 0; - - if ($validateRule && $country === 'IT') { - $result = self::luhnCheck($number); + $hasMatch = preg_match('/^' . $this->patternExpressions[$country] . '$/', (string) $number) > 0; - return $result % 10 == 0; + if ($hasMatch && $country === 'IT') { + return parent::isValid($number); } - if ($validateRule && $country === 'HU') { + if ($hasMatch && $country === 'HU') { return $this->validateHuVat($number); } - return $validateRule; - } - - /** @link https://en.wikipedia.org/wiki/Luhn_algorithm */ - private function luhnCheck(string $vat): int - { - $sum = 0; - $vat_array = str_split($vat); - $counter = count($vat_array); - for ($index = 0; $index < $counter; ++$index) { - $value = intval($vat_array[$index]); - if ($index % 2 !== 0) { - $value *= 2; - if ($value > 9) { - $value = 1 + ($value % 10); - } - } - - $sum += $value; - } - - return $sum; + return $hasMatch; } private function validateHuVat(string $vatNumber): bool @@ -124,13 +80,6 @@ private function validateHuVat(string $vatNumber): bool return $calculatedChecksum === $checksum; } - private function vatCleaner(string $vatNumber): string - { - $vatNumber_no_spaces = trim($vatNumber); - - return strtoupper($vatNumber_no_spaces); - } - /** * @return array{0: string, 1: string} */ @@ -141,23 +90,4 @@ private function splitVat(string $vatNumber): array substr($vatNumber, 2), ]; } - - /** - * @throws \SoapFault - */ - private function checkVatViaApi(string $country, string $number): bool - { - $client = new \SoapClient( - 'https://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl', - ['connection_timeout' => 10], - ); - $response = $client->checkVat( - [ - 'countryCode' => $country, - 'vatNumber' => $number, - ] - ); - - return $response->valid; - } }