Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/Validator/Constraints/EuropeanVatNumber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace AssoConnect\ValidatorBundle\Validator\Constraints;

use Symfony\Component\Validator\Constraint;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD)]
class EuropeanVatNumber extends Constraint
{
public const string INVALID_FORMAT_ERROR = '94d6569c-5f24-413f-be89-bf72c0116ce5';
public const string MESSAGE = 'The intra-EU VAT number {{ value }} is not valid.';
}
88 changes: 88 additions & 0 deletions src/Validator/Constraints/EuropeanVatNumberValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

declare(strict_types=1);

namespace AssoConnect\ValidatorBundle\Validator\Constraints;

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;

/**
* Format-only validation of intra-EU VAT numbers: two-letter VAT prefix (EU-27,
* with the official EL prefix for Greece) followed by the country-specific body.
* No checksum is verified, and presence stays the caller's decision.
*/
class EuropeanVatNumberValidator extends ConstraintValidator
{
private const array PATTERNS = [
'AT' => '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));
}
}
60 changes: 60 additions & 0 deletions tests/Validator/Constraints/EuropeanVatNumberValidatorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);

namespace AssoConnect\ValidatorBundle\Tests\Validator\Constraints;

use AssoConnect\ValidatorBundle\Test\ConstraintValidatorTestCase;
use AssoConnect\ValidatorBundle\Validator\Constraints\EuropeanVatNumber;
use AssoConnect\ValidatorBundle\Validator\Constraints\EuropeanVatNumberValidator;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;

/**
* @extends ConstraintValidatorTestCase<EuropeanVatNumberValidator>
*/
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];
}
}
4 changes: 4 additions & 0 deletions translations/validators.en.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
<source>The value {{ value }} is not a valid RNA identifier.</source>
<target>The value {{ value }} is not a valid RNA identifier.</target>
</trans-unit>
<trans-unit id="15">
<source>The intra-EU VAT number {{ value }} is not valid.</source>
<target>The intra-EU VAT number {{ value }} is not valid.</target>
</trans-unit>
</body>
</file>
</xliff>
4 changes: 4 additions & 0 deletions translations/validators.fr.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
<source>The value {{ value }} is not a valid RNA identifier.</source>
<target>La valeur {{ value }} n'est pas un identifiant RNA valide.</target>
</trans-unit>
<trans-unit id="15">
<source>The intra-EU VAT number {{ value }} is not valid.</source>
<target>Le numéro de TVA intra-communautaire {{ value }} n'est pas valide.</target>
</trans-unit>
</body>
</file>
</xliff>
Loading