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
59 changes: 58 additions & 1 deletion src/Validator/Constraints/SpanishNifValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@

class SpanishNifValidator extends ConstraintValidator
{
// Valid first letters for a legal entity NIF
private const string VALID_FIRST_LETTERS = 'ABCDEFGHJNPQRSUVW';

// First letters requiring a letter as control character
private const array LETTER_CONTROL = ['P', 'Q', 'R', 'S', 'W', 'N'];

// First letters requiring a digit as control character
private const array DIGIT_CONTROL = ['A', 'B', 'E', 'H'];

// Mapping from control digit to control letter: J=0, A=1, B=2 ... I=9
private const string CONTROL_LETTERS = 'JABCDEFGHI';

public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof SpanishNif) {
Expand All @@ -25,11 +37,56 @@ public function validate(mixed $value, Constraint $constraint): void
throw new UnexpectedValueException($value, 'string');
}

if (1 !== preg_match('/^[A-Z][A-Z0-9]{8}$/', $value)) {
if (!$this->isValid($value)) {
$this->context->buildViolation($constraint::MESSAGE)
->setParameter('{{ value }}', $this->formatValue($value))
->setCode($constraint::WRONG_FORMAT_ERROR)
->addViolation();
}
}

private function isValid(string $value): bool
{
// Strict format: known first letter + 7 digits + 1 control character
if (
1 !== preg_match('/^([' . self::VALID_FIRST_LETTERS . '])(\d{7})([0-9A-Z])$/', $value, $matches)
) {
return false;
}

[, $firstLetter, $digits, $control] = $matches;

$expectedDigit = $this->computeControlDigit($digits);
$expectedLetter = self::CONTROL_LETTERS[$expectedDigit];

if (in_array($firstLetter, self::LETTER_CONTROL, true)) {
return $control === $expectedLetter;
}

if (in_array($firstLetter, self::DIGIT_CONTROL, true)) {
return $control === (string)$expectedDigit;
}

// Other first letters accept either a digit or a letter
return $control === (string)$expectedDigit || $control === $expectedLetter;
}

private function computeControlDigit(string $digits): int
{
// Sum digits at even positions (0-based indexes 1, 3, 5)
$evenSum = (int)$digits[1] + (int)$digits[3] + (int)$digits[5];

// For each odd position (0-based indexes 0, 2, 4, 6):
// multiply by 2, then add the resulting digits together
$oddSum = 0;
foreach ([0, 2, 4, 6] as $i) {
$val = (int)$digits[$i] * 2;
$oddSum += $val >= 10 ? intdiv($val, 10) + ($val % 10) : $val;
}

$total = $evenSum + $oddSum;
$units = $total % 10;

return $units !== 0 ? 10 - $units : 0;
}
}
28 changes: 14 additions & 14 deletions tests/Validator/Constraints/SpanishNifValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,24 +29,24 @@ public static function providerValidValues(): iterable
{
yield 'null value' => [null];
yield 'empty string' => [''];
yield 'valid NIF with digit suffix' => ['A12345678'];
yield 'valid NIF with letter suffix' => ['B1234567C'];
yield 'valid NIF all zeros' => ['Z00000000'];
yield 'valid NIF mixed alphanumeric suffix' => ['H1234567A'];
yield 'type A with digit control' => ['A58818501']; // Wikipedia example
yield 'type A with digit control (Adyen)' => ['A39000013']; // Adyen docs example
yield 'type P with letter control' => ['P2813200I']; // Ayuntamiento de Madrid
}

public static function providerInvalidValues(): iterable
{
$expectedMessage = SpanishNif::MESSAGE;
$code = SpanishNif::WRONG_FORMAT_ERROR;

yield 'too short' => ['A1234567', $code, $expectedMessage];
yield 'too long' => ['A1234567890', $code, $expectedMessage];
yield 'starts with digit' => ['112345678', $code, $expectedMessage];
yield 'starts with lowercase' => ['a12345678', $code, $expectedMessage];
yield 'contains lowercase' => ['A1234567b', $code, $expectedMessage];
yield 'contains special characters' => ['A1234-678', $code, $expectedMessage];
yield 'contains spaces' => ['A1234 678', $code, $expectedMessage];
yield 'single letter' => ['A', $code, $expectedMessage];
$message = SpanishNif::MESSAGE;

yield 'too short' => ['A1234567', $code, $message];
yield 'too long' => ['A1234567890', $code, $message];
yield 'starts with digit' => ['112345678', $code, $message];
yield 'starts with lowercase' => ['a12345678', $code, $message];
yield 'invalid first letter' => ['Z12345678A', $code, $message];
yield 'wrong control digit' => ['A58818502', $code, $message];
yield 'wrong control letter' => ['P2813200Z', $code, $message];
yield 'contains special character' => ['A1234-678', $code, $message];
yield 'contains space' => ['A1234 678', $code, $message];
}
}
Loading