Skip to content

Commit e15cb19

Browse files
lmasforneYaFou
authored andcommitted
Feature #36362 add Isin validator constraint
Feature #36362 typo Fix PR feedbacks Fix coding standard ticket 36362 fix PR feedbacks Update src/Symfony/Component/Validator/Constraints/IsinValidator.php Co-Authored-By: Yannis Foucher <33806646+YaFou@users.noreply.github.com>
1 parent ef19a03 commit e15cb19

File tree

6 files changed

+297
-0
lines changed

6 files changed

+297
-0
lines changed

src/Symfony/Component/Validator/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ CHANGELOG
2828
* })
2929
*/
3030
```
31+
* added the `Isin` constraint and validator
3132

3233
5.1.0
3334
-----
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Constraints;
13+
14+
use Symfony\Component\Validator\Constraint;
15+
16+
/**
17+
* @Annotation
18+
* @Target({"PROPERTY", "METHOD", "ANNOTATION"})
19+
*
20+
* @author Laurent Masforné <l.masforne@gmail.com>
21+
*/
22+
class Isin extends Constraint
23+
{
24+
const VALIDATION_LENGTH = 12;
25+
const VALIDATION_PATTERN = '/[A-Z]{2}[A-Z0-9]{9}[0-9]{1}/';
26+
27+
const INVALID_LENGTH_ERROR = '88738dfc-9ed5-ba1e-aebe-402a2a9bf58e';
28+
const INVALID_PATTERN_ERROR = '3d08ce0-ded9-a93d-9216-17ac21265b65e';
29+
const INVALID_CHECKSUM_ERROR = '32089b-0ee1-93ba-399e-aa232e62f2d29d';
30+
31+
protected static $errorNames = [
32+
self::INVALID_LENGTH_ERROR => 'INVALID_LENGTH_ERROR',
33+
self::INVALID_PATTERN_ERROR => 'INVALID_PATTERN_ERROR',
34+
self::INVALID_CHECKSUM_ERROR => 'INVALID_CHECKSUM_ERROR',
35+
];
36+
37+
public $message = 'This is not a valid International Securities Identification Number (ISIN).';
38+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Constraints;
13+
14+
use Symfony\Component\Validator\Constraint;
15+
use Symfony\Component\Validator\ConstraintValidator;
16+
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
17+
use Symfony\Component\Validator\Exception\UnexpectedValueException;
18+
19+
/**
20+
* @author Laurent Masforné <l.masforne@gmail.com>
21+
*
22+
* @see https://en.wikipedia.org/wiki/International_Securities_Identification_Number
23+
*/
24+
class IsinValidator extends ConstraintValidator
25+
{
26+
/**
27+
* {@inheritdoc}
28+
*/
29+
public function validate($value, Constraint $constraint)
30+
{
31+
if (!$constraint instanceof Isin) {
32+
throw new UnexpectedTypeException($constraint, Isin::class);
33+
}
34+
35+
if (null === $value || '' === $value) {
36+
return;
37+
}
38+
39+
if (!is_scalar($value) && !(\is_object($value) && method_exists($value, '__toString'))) {
40+
throw new UnexpectedValueException($value, 'string');
41+
}
42+
43+
$value = strtoupper($value);
44+
45+
if (Isin::VALIDATION_LENGTH !== \strlen($value)) {
46+
$this->context->buildViolation($constraint->message)
47+
->setParameter('{{ value }}', $this->formatValue($value))
48+
->setCode(Isin::INVALID_LENGTH_ERROR)
49+
->addViolation();
50+
51+
return;
52+
}
53+
54+
if (!preg_match(Isin::VALIDATION_PATTERN, $value)) {
55+
$this->context->buildViolation($constraint->message)
56+
->setParameter('{{ value }}', $this->formatValue($value))
57+
->setCode(Isin::INVALID_PATTERN_ERROR)
58+
->addViolation();
59+
60+
return;
61+
}
62+
63+
if (!$this->isCorrectChecksum($value)) {
64+
$this->context->buildViolation($constraint->message)
65+
->setParameter('{{ value }}', $this->formatValue($value))
66+
->setCode(Isin::INVALID_CHECKSUM_ERROR)
67+
->addViolation();
68+
}
69+
}
70+
71+
private function isCorrectChecksum(string $input): bool
72+
{
73+
$characters = str_split($input);
74+
foreach ($characters as $i => $char) {
75+
$characters[$i] = \intval($char, 36);
76+
}
77+
$checkDigit = array_pop($characters);
78+
$number = implode('', $characters);
79+
$expectedCheckDigit = $this->getCheckDigit($number);
80+
81+
return $checkDigit === $expectedCheckDigit;
82+
}
83+
84+
/**
85+
* This method performs the Luhn algorithm to obtain a check digit.
86+
*/
87+
private function getCheckDigit(string $input): int
88+
{
89+
$numbers = str_split($input);
90+
91+
// Calculate the positional value.
92+
// - when there is an even number of digits the second group will be multiplied, so p starts on 0
93+
// - when there is an odd number of digits the first group will be multiplied, so p starts on 1
94+
$p = \count($numbers) % 2;
95+
96+
foreach ($numbers as $i => $num) {
97+
$num = (int) $num;
98+
99+
// Every positional number needs to be multiplied by 2
100+
if (1 === $p % 2) {
101+
$num = $num * 2;
102+
103+
// If the result was more than 9, we add the individual digits
104+
$num = array_sum(str_split($num));
105+
}
106+
107+
$numbers[$i] = $num;
108+
++$p;
109+
}
110+
111+
$sum = array_sum($numbers);
112+
$mod = $sum % 10;
113+
$rem = 10 - $mod;
114+
115+
// Mod from 10 to catch if the result was 0
116+
return $rem % 10;
117+
}
118+
}

src/Symfony/Component/Validator/Resources/translations/validators.en.xlf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,10 @@
382382
<source>Each element of this collection should satisfy its own set of constraints.</source>
383383
<target>Each element of this collection should satisfy its own set of constraints.</target>
384384
</trans-unit>
385+
<trans-unit id="99">
386+
<source>This value is not a valid International Securities Identification Number (ISIN).</source>
387+
<target>This value is not a valid International Securities Identification Number (ISIN).</target>
388+
</trans-unit>
385389
</body>
386390
</file>
387391
</xliff>

src/Symfony/Component/Validator/Resources/translations/validators.fr.xlf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,10 @@
382382
<source>Each element of this collection should satisfy its own set of constraints.</source>
383383
<target>Chaque élément de cette collection doit satisfaire à son propre jeu de contraintes.</target>
384384
</trans-unit>
385+
<trans-unit id="99">
386+
<source>This value is not a valid International Securities Identification Number (ISIN).</source>
387+
<target>Cette valeur n'est pas un code international de sécurité valide (ISIN).</target>
388+
</trans-unit>
385389
</body>
386390
</file>
387391
</xliff>
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<?php
2+
3+
namespace Symfony\Component\Validator\Tests\Constraints;
4+
5+
use Symfony\Component\Validator\Constraints\Isin;
6+
use Symfony\Component\Validator\Constraints\IsinValidator;
7+
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
8+
9+
class IsinValidatorTest extends ConstraintValidatorTestCase
10+
{
11+
protected function createValidator()
12+
{
13+
return new IsinValidator();
14+
}
15+
16+
public function testNullIsValid()
17+
{
18+
$this->validator->validate(null, new Isin());
19+
20+
$this->assertNoViolation();
21+
}
22+
23+
public function testEmptyStringIsValid()
24+
{
25+
$this->validator->validate('', new Isin());
26+
27+
$this->assertNoViolation();
28+
}
29+
30+
/**
31+
* @dataProvider getValidIsin
32+
*/
33+
public function testValidIsin($isin)
34+
{
35+
$this->validator->validate($isin, new Isin());
36+
$this->assertNoViolation();
37+
}
38+
39+
public function getValidIsin()
40+
{
41+
return [
42+
['XS2125535901'], // Goldman Sachs International
43+
['DE000HZ8VA77'], // UniCredit Bank AG
44+
['CH0528261156'], // Leonteq Securities AG [Guernsey]
45+
['US0378331005'], // Apple, Inc.
46+
['AU0000XVGZA3'], // TREASURY CORP VICTORIA 5 3/4% 2005-2016
47+
['GB0002634946'], // BAE Systems
48+
['CH0528261099'], // Leonteq Securities AG [Guernsey]
49+
['XS2155672814'], // OP Corporate Bank plc
50+
['XS2155687259'], // Orbian Financial Services III, LLC
51+
['XS2155696672'], // Sheffield Receivables Company LLC
52+
];
53+
}
54+
55+
/**
56+
* @dataProvider getIsinWithInvalidLenghFormat
57+
*/
58+
public function testIsinWithInvalidFormat($isin)
59+
{
60+
$this->assertViolationRaised($isin, Isin::INVALID_LENGTH_ERROR);
61+
}
62+
63+
public function getIsinWithInvalidLenghFormat()
64+
{
65+
return [
66+
['X'],
67+
['XS'],
68+
['XS2'],
69+
['XS21'],
70+
['XS215'],
71+
['XS2155'],
72+
['XS21556'],
73+
['XS215569'],
74+
['XS2155696'],
75+
['XS21556966'],
76+
['XS215569667'],
77+
];
78+
}
79+
80+
/**
81+
* @dataProvider getIsinWithInvalidPattern
82+
*/
83+
public function testIsinWithInvalidPattern($isin)
84+
{
85+
$this->assertViolationRaised($isin, Isin::INVALID_PATTERN_ERROR);
86+
}
87+
88+
public function getIsinWithInvalidPattern()
89+
{
90+
return [
91+
['X12155696679'],
92+
['123456789101'],
93+
['XS215569667E'],
94+
['XS215E69667A'],
95+
];
96+
}
97+
98+
/**
99+
* @dataProvider getIsinWithValidFormatButIncorrectChecksum
100+
*/
101+
public function testIsinWithValidFormatButIncorrectChecksum($isin)
102+
{
103+
$this->assertViolationRaised($isin, Isin::INVALID_CHECKSUM_ERROR);
104+
}
105+
106+
public function getIsinWithValidFormatButIncorrectChecksum()
107+
{
108+
return [
109+
['XS2112212144'],
110+
['DE013228VA77'],
111+
['CH0512361156'],
112+
['XS2125660123'],
113+
['XS2012587408'],
114+
['XS2012380102'],
115+
['XS2012239364'],
116+
];
117+
}
118+
119+
private function assertViolationRaised($isin, $code)
120+
{
121+
$constraint = new Isin([
122+
'message' => 'myMessage',
123+
]);
124+
125+
$this->validator->validate($isin, $constraint);
126+
127+
$this->buildViolation('myMessage')
128+
->setParameter('{{ value }}', '"'.$isin.'"')
129+
->setCode($code)
130+
->assertRaised();
131+
}
132+
}

0 commit comments

Comments
 (0)