diff --git a/src/Validator/Constraints/EuropeanVatNumber.php b/src/Validator/Constraints/EuropeanVatNumber.php new file mode 100644 index 0000000..d44ad18 --- /dev/null +++ b/src/Validator/Constraints/EuropeanVatNumber.php @@ -0,0 +1,14 @@ + 'U[0-9]{8}', + 'BE' => '[01][0-9]{9}', + 'BG' => '[0-9]{9,10}', + 'CY' => '[0-9]{8}[A-Z]', + 'CZ' => '[0-9]{8,10}', + 'DE' => '[0-9]{9}', + 'DK' => '[0-9]{8}', + 'EE' => '[0-9]{9}', + 'EL' => '[0-9]{9}', + 'ES' => '[0-9A-Z][0-9]{7}[0-9A-Z]', + 'FI' => '[0-9]{8}', + 'FR' => '[0-9A-Z]{2}[0-9]{9}', + 'HR' => '[0-9]{11}', + 'HU' => '[0-9]{8}', + 'IE' => '([0-9]{7}[A-Z]{1,2}|[0-9][A-Z+*][0-9]{5}[A-Z])', + 'IT' => '[0-9]{11}', + 'LT' => '([0-9]{9}|[0-9]{12})', + 'LU' => '[0-9]{8}', + 'LV' => '[0-9]{11}', + 'MT' => '[0-9]{8}', + 'NL' => '[0-9]{9}B[0-9]{2}', + 'PL' => '[0-9]{10}', + 'PT' => '[0-9]{9}', + 'RO' => '[0-9]{2,10}', + 'SE' => '[0-9]{12}', + 'SI' => '[0-9]{8}', + 'SK' => '[0-9]{10}', + ]; + + public function validate(mixed $value, Constraint $constraint): void + { + if (!$constraint instanceof EuropeanVatNumber) { + throw new UnexpectedTypeException($constraint, EuropeanVatNumber::class); + } + + if (null === $value || '' === $value) { + return; + } + + if (!is_string($value)) { + throw new UnexpectedValueException($value, 'string'); + } + + if ($this->hasValidFormat(strtoupper(str_replace(' ', '', $value)))) { + return; + } + + $this->context->buildViolation($constraint::MESSAGE) + ->setParameter('{{ value }}', $this->formatValue($value)) + ->setCode($constraint::INVALID_FORMAT_ERROR) + ->addViolation(); + } + + private function hasValidFormat(string $normalized): bool + { + if (strlen($normalized) < 2) { + return false; + } + + $pattern = self::PATTERNS[substr($normalized, 0, 2)] ?? null; + + if (null === $pattern) { + return false; + } + + // \z (not $) so a trailing newline never slips through the format check. + return 1 === preg_match('#^(?:' . $pattern . ')\z#', substr($normalized, 2)); + } +} diff --git a/tests/Validator/Constraints/EuropeanVatNumberValidatorTest.php b/tests/Validator/Constraints/EuropeanVatNumberValidatorTest.php new file mode 100644 index 0000000..10bfd41 --- /dev/null +++ b/tests/Validator/Constraints/EuropeanVatNumberValidatorTest.php @@ -0,0 +1,60 @@ + + */ +class EuropeanVatNumberValidatorTest extends ConstraintValidatorTestCase +{ + protected function getConstraint(): Constraint + { + return new EuropeanVatNumber(); + } + + public function createValidator(): ConstraintValidator + { + return new EuropeanVatNumberValidator(); + } + + public static function providerValidValues(): iterable + { + yield 'null value' => [null]; + yield 'empty string' => ['']; + yield 'Belgian number starting with 0' => ['BE0123456789']; + yield 'Belgian number starting with 1' => ['BE1123456789']; + yield 'German number' => ['DE123456789']; + yield 'French number' => ['FR12345678901']; + yield 'Greek number with the official EL prefix' => ['EL123456789']; + yield 'Croatian number' => ['HR12345678901']; + yield 'Dutch number' => ['NL123456789B12']; + yield 'Cypriot number' => ['CY10259033P']; + yield 'Irish number new style with one letter' => ['IE6433435F']; + yield 'Irish number new style with two letters' => ['IE3628739UA']; + yield 'Irish number old style' => ['IE8Z49289F']; + yield 'lowercase input is normalized' => ['be0123456789']; + yield 'inner spaces are normalized' => ['BE 0123 456 789']; + } + + public static function providerInvalidValues(): iterable + { + $code = EuropeanVatNumber::INVALID_FORMAT_ERROR; + $message = EuropeanVatNumber::MESSAGE; + + yield 'GB prefix (not an EU member)' => ['GB123456789', $code, $message]; + yield 'unknown prefix' => ['XX123456789', $code, $message]; + yield 'single character' => ['B', $code, $message]; + yield 'wrong body length' => ['BE123', $code, $message]; + yield 'Cypriot number without its trailing letter' => ['CY123456789', $code, $message]; + yield 'trailing newline' => ["DE123456789\n", $code, $message]; + yield 'Greek number with the GR country code' => ['GR123456789', $code, $message]; + } +} diff --git a/translations/validators.en.xlf b/translations/validators.en.xlf index dbe4c59..2ff7956 100644 --- a/translations/validators.en.xlf +++ b/translations/validators.en.xlf @@ -58,6 +58,10 @@ The value {{ value }} is not a valid RNA identifier. The value {{ value }} is not a valid RNA identifier. + + The intra-EU VAT number {{ value }} is not valid. + The intra-EU VAT number {{ value }} is not valid. + diff --git a/translations/validators.fr.xlf b/translations/validators.fr.xlf index 3bb43b1..6ea0c89 100644 --- a/translations/validators.fr.xlf +++ b/translations/validators.fr.xlf @@ -58,6 +58,10 @@ The value {{ value }} is not a valid RNA identifier. La valeur {{ value }} n'est pas un identifiant RNA valide. + + The intra-EU VAT number {{ value }} is not valid. + Le numéro de TVA intra-communautaire {{ value }} n'est pas valide. +