Skip to content

Commit

Permalink
bug #48998 [Validator] Sync IBAN formats with Swift IBAN registry (sm…
Browse files Browse the repository at this point in the history
…elesh)

This PR was merged into the 5.4 branch.

Discussion
----------

[Validator] Sync IBAN formats with Swift IBAN registry

| Q             | A
| ------------- | ---
| Branch?       | 5.4
| Bug fix?      | yes
| New feature?  | no
| Deprecations? | no
| Tickets       | n/a
| License       | MIT
| Doc PR        | n/a

Gathered IBAN formats from [IBAN Registry provided by SWIFT](https://www.swift.com/standards/data-standards/iban-international-bank-account-number).

Some countries don't exist in the registry (Angola, Burkina Faso, Benin, Congo, Ivory Coast, Cameron, Cape Verde, Algeria, Iran, Madagascar, Mali, Mozambique, Senegal). I can't verify the format, but they are marked experimental here: https://www.iban.com/structure

Some formats were changed (Burundi, Costa Rica, Kuwait, Turkey). Is it a BC break?

Commits
-------

d4e3047 [Validator] Sync IBAN formats with Swift IBAN registry
  • Loading branch information
nicolas-grekas committed Feb 22, 2023
2 parents ddfd2ac + d4e3047 commit 0955438
Show file tree
Hide file tree
Showing 4 changed files with 295 additions and 53 deletions.
1 change: 1 addition & 0 deletions src/Symfony/Component/Validator/.gitattributes
Expand Up @@ -2,3 +2,4 @@
/phpunit.xml.dist export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
/Resources/bin/sync-iban-formats.php export-ignore
116 changes: 71 additions & 45 deletions src/Symfony/Component/Validator/Constraints/IbanValidator.php
Expand Up @@ -20,8 +20,6 @@
* @author Manuel Reinhard <manu@sprain.ch>
* @author Michael Schummel
* @author Bernhard Schussek <bschussek@gmail.com>
*
* @see http://www.michael-schummel.de/2007/10/05/iban-prufung-mit-php/
*/
class IbanValidator extends ConstraintValidator
{
Expand All @@ -34,107 +32,135 @@ class IbanValidator extends ConstraintValidator
* a BBAN (Basic Bank Account Number) which has a fixed length per country and,
* included within it, a bank identifier with a fixed position and a fixed length per country
*
* @see https://www.swift.com/sites/default/files/resources/iban_registry.pdf
* @see Resources/bin/sync-iban-formats.php
* @see https://www.swift.com/swift-resource/11971/download?language=en
* @see https://en.wikipedia.org/wiki/International_Bank_Account_Number
*/
private const FORMATS = [
// auto-generated
'AD' => 'AD\d{2}\d{4}\d{4}[\dA-Z]{12}', // Andorra
'AE' => 'AE\d{2}\d{3}\d{16}', // United Arab Emirates
'AE' => 'AE\d{2}\d{3}\d{16}', // United Arab Emirates (The)
'AL' => 'AL\d{2}\d{8}[\dA-Z]{16}', // Albania
'AO' => 'AO\d{2}\d{21}', // Angola
'AT' => 'AT\d{2}\d{5}\d{11}', // Austria
'AX' => 'FI\d{2}\d{6}\d{7}\d{1}', // Aland Islands
'AX' => 'FI\d{2}\d{3}\d{11}', // Finland
'AZ' => 'AZ\d{2}[A-Z]{4}[\dA-Z]{20}', // Azerbaijan
'BA' => 'BA\d{2}\d{3}\d{3}\d{8}\d{2}', // Bosnia and Herzegovina
'BE' => 'BE\d{2}\d{3}\d{7}\d{2}', // Belgium
'BF' => 'BF\d{2}\d{23}', // Burkina Faso
'BF' => 'BF\d{2}[\dA-Z]{2}\d{22}', // Burkina Faso
'BG' => 'BG\d{2}[A-Z]{4}\d{4}\d{2}[\dA-Z]{8}', // Bulgaria
'BH' => 'BH\d{2}[A-Z]{4}[\dA-Z]{14}', // Bahrain
'BI' => 'BI\d{2}\d{12}', // Burundi
'BJ' => 'BJ\d{2}[A-Z]{1}\d{23}', // Benin
'BY' => 'BY\d{2}[\dA-Z]{4}\d{4}[\dA-Z]{16}', // Belarus - https://bank.codes/iban/structure/belarus/
'BL' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // Saint Barthelemy
'BR' => 'BR\d{2}\d{8}\d{5}\d{10}[A-Z][\dA-Z]', // Brazil
'CG' => 'CG\d{2}\d{23}', // Congo
'BI' => 'BI\d{2}\d{5}\d{5}\d{11}\d{2}', // Burundi
'BJ' => 'BJ\d{2}[\dA-Z]{2}\d{22}', // Benin
'BL' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // France
'BR' => 'BR\d{2}\d{8}\d{5}\d{10}[A-Z]{1}[\dA-Z]{1}', // Brazil
'BY' => 'BY\d{2}[\dA-Z]{4}\d{4}[\dA-Z]{16}', // Republic of Belarus
'CF' => 'CF\d{2}\d{23}', // Central African Republic
'CG' => 'CG\d{2}\d{23}', // Congo, Republic of the
'CH' => 'CH\d{2}\d{5}[\dA-Z]{12}', // Switzerland
'CI' => 'CI\d{2}[A-Z]{1}\d{23}', // Ivory Coast
'CM' => 'CM\d{2}\d{23}', // Cameron
'CR' => 'CR\d{2}0\d{3}\d{14}', // Costa Rica
'CV' => 'CV\d{2}\d{21}', // Cape Verde
'CI' => 'CI\d{2}[A-Z]{1}\d{23}', // Côte d'Ivoire
'CM' => 'CM\d{2}\d{23}', // Cameroon
'CR' => 'CR\d{2}\d{4}\d{14}', // Costa Rica
'CV' => 'CV\d{2}\d{21}', // Cabo Verde
'CY' => 'CY\d{2}\d{3}\d{5}[\dA-Z]{16}', // Cyprus
'CZ' => 'CZ\d{2}\d{20}', // Czech Republic
'CZ' => 'CZ\d{2}\d{4}\d{6}\d{10}', // Czechia
'DE' => 'DE\d{2}\d{8}\d{10}', // Germany
'DJ' => 'DJ\d{2}\d{5}\d{5}\d{11}\d{2}', // Djibouti
'DK' => 'DK\d{2}\d{4}\d{9}\d{1}', // Denmark
'DO' => 'DO\d{2}[\dA-Z]{4}\d{20}', // Dominican Republic
'DK' => 'DK\d{2}\d{4}\d{10}', // Denmark
'DZ' => 'DZ\d{2}\d{20}', // Algeria
'DZ' => 'DZ\d{2}\d{22}', // Algeria
'EE' => 'EE\d{2}\d{2}\d{2}\d{11}\d{1}', // Estonia
'ES' => 'ES\d{2}\d{4}\d{4}\d{1}\d{1}\d{10}', // Spain (also includes Canary Islands, Ceuta and Melilla)
'FI' => 'FI\d{2}\d{6}\d{7}\d{1}', // Finland
'EG' => 'EG\d{2}\d{4}\d{4}\d{17}', // Egypt
'ES' => 'ES\d{2}\d{4}\d{4}\d{1}\d{1}\d{10}', // Spain
'FI' => 'FI\d{2}\d{3}\d{11}', // Finland
'FO' => 'FO\d{2}\d{4}\d{9}\d{1}', // Faroe Islands
'FR' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // France
'GF' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // French Guyana
'GB' => 'GB\d{2}[A-Z]{4}\d{6}\d{8}', // United Kingdom of Great Britain and Northern Ireland
'GA' => 'GA\d{2}\d{23}', // Gabon
'GB' => 'GB\d{2}[A-Z]{4}\d{6}\d{8}', // United Kingdom
'GE' => 'GE\d{2}[A-Z]{2}\d{16}', // Georgia
'GF' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // France
'GG' => 'GB\d{2}[A-Z]{4}\d{6}\d{8}', // United Kingdom
'GI' => 'GI\d{2}[A-Z]{4}[\dA-Z]{15}', // Gibraltar
'GL' => 'GL\d{2}\d{4}\d{9}\d{1}', // Greenland
'GP' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // Guadeloupe
'GP' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // France
'GQ' => 'GQ\d{2}\d{23}', // Equatorial Guinea
'GR' => 'GR\d{2}\d{3}\d{4}[\dA-Z]{16}', // Greece
'GT' => 'GT\d{2}[\dA-Z]{4}[\dA-Z]{20}', // Guatemala
'GW' => 'GW\d{2}[\dA-Z]{2}\d{19}', // Guinea-Bissau
'HN' => 'HN\d{2}[A-Z]{4}\d{20}', // Honduras
'HR' => 'HR\d{2}\d{7}\d{10}', // Croatia
'HU' => 'HU\d{2}\d{3}\d{4}\d{1}\d{15}\d{1}', // Hungary
'IE' => 'IE\d{2}[A-Z]{4}\d{6}\d{8}', // Ireland
'IL' => 'IL\d{2}\d{3}\d{3}\d{13}', // Israel
'IM' => 'GB\d{2}[A-Z]{4}\d{6}\d{8}', // United Kingdom
'IQ' => 'IQ\d{2}[A-Z]{4}\d{3}\d{12}', // Iraq
'IR' => 'IR\d{2}\d{22}', // Iran
'IS' => 'IS\d{2}\d{4}\d{2}\d{6}\d{10}', // Iceland
'IT' => 'IT\d{2}[A-Z]{1}\d{5}\d{5}[\dA-Z]{12}', // Italy
'JE' => 'GB\d{2}[A-Z]{4}\d{6}\d{8}', // United Kingdom
'JO' => 'JO\d{2}[A-Z]{4}\d{4}[\dA-Z]{18}', // Jordan
'KW' => 'KW\d{2}[A-Z]{4}\d{22}', // KUWAIT
'KM' => 'KM\d{2}\d{23}', // Comoros
'KW' => 'KW\d{2}[A-Z]{4}[\dA-Z]{22}', // Kuwait
'KZ' => 'KZ\d{2}\d{3}[\dA-Z]{13}', // Kazakhstan
'LB' => 'LB\d{2}\d{4}[\dA-Z]{20}', // LEBANON
'LI' => 'LI\d{2}\d{5}[\dA-Z]{12}', // Liechtenstein (Principality of)
'LB' => 'LB\d{2}\d{4}[\dA-Z]{20}', // Lebanon
'LC' => 'LC\d{2}[A-Z]{4}[\dA-Z]{24}', // Saint Lucia
'LI' => 'LI\d{2}\d{5}[\dA-Z]{12}', // Liechtenstein
'LT' => 'LT\d{2}\d{5}\d{11}', // Lithuania
'LU' => 'LU\d{2}\d{3}[\dA-Z]{13}', // Luxembourg
'LV' => 'LV\d{2}[A-Z]{4}[\dA-Z]{13}', // Latvia
'LY' => 'LY\d{2}\d{3}\d{3}\d{15}', // Libya
'MA' => 'MA\d{2}\d{24}', // Morocco
'MC' => 'MC\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // Monaco
'MD' => 'MD\d{2}[\dA-Z]{2}[\dA-Z]{18}', // Moldova
'ME' => 'ME\d{2}\d{3}\d{13}\d{2}', // Montenegro
'MF' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // Saint Martin (French part)
'MF' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // France
'MG' => 'MG\d{2}\d{23}', // Madagascar
'MK' => 'MK\d{2}\d{3}[\dA-Z]{10}\d{2}', // Macedonia, Former Yugoslav Republic of
'ML' => 'ML\d{2}[A-Z]{1}\d{23}', // Mali
'MQ' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // Martinique
'MK' => 'MK\d{2}\d{3}[\dA-Z]{10}\d{2}', // Macedonia
'ML' => 'ML\d{2}[\dA-Z]{2}\d{22}', // Mali
'MQ' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // France
'MR' => 'MR\d{2}\d{5}\d{5}\d{11}\d{2}', // Mauritania
'MT' => 'MT\d{2}[A-Z]{4}\d{5}[\dA-Z]{18}', // Malta
'MU' => 'MU\d{2}[A-Z]{4}\d{2}\d{2}\d{12}\d{3}[A-Z]{3}', // Mauritius
'MZ' => 'MZ\d{2}\d{21}', // Mozambique
'NC' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // New Caledonia
'NL' => 'NL\d{2}[A-Z]{4}\d{10}', // The Netherlands
'NC' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // France
'NE' => 'NE\d{2}[A-Z]{2}\d{22}', // Niger
'NI' => 'NI\d{2}[A-Z]{4}\d{24}', // Nicaragua
'NL' => 'NL\d{2}[A-Z]{4}\d{10}', // Netherlands (The)
'NO' => 'NO\d{2}\d{4}\d{6}\d{1}', // Norway
'PF' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // French Polynesia
'PF' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // France
'PK' => 'PK\d{2}[A-Z]{4}[\dA-Z]{16}', // Pakistan
'PL' => 'PL\d{2}\d{8}\d{16}', // Poland
'PM' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // Saint Pierre et Miquelon
'PM' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // France
'PS' => 'PS\d{2}[A-Z]{4}[\dA-Z]{21}', // Palestine, State of
'PT' => 'PT\d{2}\d{4}\d{4}\d{11}\d{2}', // Portugal (plus Azores and Madeira)
'PT' => 'PT\d{2}\d{4}\d{4}\d{11}\d{2}', // Portugal
'QA' => 'QA\d{2}[A-Z]{4}[\dA-Z]{21}', // Qatar
'RE' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // Reunion
'RE' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // France
'RO' => 'RO\d{2}[A-Z]{4}[\dA-Z]{16}', // Romania
'RS' => 'RS\d{2}\d{3}\d{13}\d{2}', // Serbia
'RU' => 'RU\d{2}\d{9}\d{5}[\dA-Z]{15}', // Russia
'SA' => 'SA\d{2}\d{2}[\dA-Z]{18}', // Saudi Arabia
'SC' => 'SC\d{2}[A-Z]{4}\d{2}\d{2}\d{16}[A-Z]{3}', // Seychelles
'SD' => 'SD\d{2}\d{2}\d{12}', // Sudan
'SE' => 'SE\d{2}\d{3}\d{16}\d{1}', // Sweden
'SI' => 'SI\d{2}\d{5}\d{8}\d{2}', // Slovenia
'SK' => 'SK\d{2}\d{4}\d{6}\d{10}', // Slovak Republic
'SK' => 'SK\d{2}\d{4}\d{6}\d{10}', // Slovakia
'SM' => 'SM\d{2}[A-Z]{1}\d{5}\d{5}[\dA-Z]{12}', // San Marino
'SN' => 'SN\d{2}[A-Z]{1}\d{23}', // Senegal
'TF' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // French Southern Territories
'SN' => 'SN\d{2}[A-Z]{2}\d{22}', // Senegal
'SO' => 'SO\d{2}\d{4}\d{3}\d{12}', // Somalia
'ST' => 'ST\d{2}\d{4}\d{4}\d{11}\d{2}', // Sao Tome and Principe
'SV' => 'SV\d{2}[A-Z]{4}\d{20}', // El Salvador
'TD' => 'TD\d{2}\d{23}', // Chad
'TF' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // France
'TG' => 'TG\d{2}[A-Z]{2}\d{22}', // Togo
'TL' => 'TL\d{2}\d{3}\d{14}\d{2}', // Timor-Leste
'TN' => 'TN\d{2}\d{2}\d{3}\d{13}\d{2}', // Tunisia
'TR' => 'TR\d{2}\d{5}[\dA-Z]{1}[\dA-Z]{16}', // Turkey
'TR' => 'TR\d{2}\d{5}\d{1}[\dA-Z]{16}', // Turkey
'UA' => 'UA\d{2}\d{6}[\dA-Z]{19}', // Ukraine
'VA' => 'VA\d{2}\d{3}\d{15}', // Vatican City State
'VG' => 'VG\d{2}[A-Z]{4}\d{16}', // Virgin Islands, British
'WF' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // Wallis and Futuna Islands
'XK' => 'XK\d{2}\d{4}\d{10}\d{2}', // Republic of Kosovo
'YT' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // Mayotte
'VG' => 'VG\d{2}[A-Z]{4}\d{16}', // Virgin Islands
'WF' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // France
'XK' => 'XK\d{2}\d{4}\d{10}\d{2}', // Kosovo
'YT' => 'FR\d{2}\d{5}\d{5}[\dA-Z]{11}\d{2}', // France
];

/**
Expand Down
204 changes: 204 additions & 0 deletions src/Symfony/Component/Validator/Resources/bin/sync-iban-formats.php
@@ -0,0 +1,204 @@
#!/usr/bin/env php
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

if ('cli' !== \PHP_SAPI) {
throw new \Exception('This script must be run from the command line.');
}

/*
* This script syncs IBAN formats from the upstream and updates them into IbanValidator.
*
* Usage:
* php Resources/bin/sync-iban-formats.php
*/

error_reporting(\E_ALL);

set_error_handler(static function (int $type, string $msg, string $file, int $line): void {
throw new \ErrorException($msg, 0, $type, $file, $line);
});

echo "Collecting IBAN formats...\n";

$formats = array_merge(
(new WikipediaIbanProvider())->getIbanFormats(),
(new SwiftRegistryIbanProvider())->getIbanFormats()
);

printf("Collected %d IBAN formats\n", count($formats));

echo "Updating validator...\n";

updateValidatorFormats(__DIR__.'/../../Constraints/IbanValidator.php', $formats);

echo "Done.\n";

exit(0);

function updateValidatorFormats(string $validatorPath, array $formats): void
{
ksort($formats);

$formatsContent = "[\n";
$formatsContent .= " // auto-generated\n";

foreach ($formats as $countryCode => [$format, $country]) {
$formatsContent .= " '{$countryCode}' => '{$format}', // {$country}\n";
}

$formatsContent .= ' ]';

$validatorContent = file_get_contents($validatorPath);

$validatorContent = preg_replace(
'/FORMATS = \[.*?\];/s',
"FORMATS = {$formatsContent};",
$validatorContent
);

file_put_contents($validatorPath, $validatorContent);
}

final class SwiftRegistryIbanProvider
{
/**
* @return array<string, array{string, string}>
*/
public function getIbanFormats(): array
{
$items = $this->readPropertiesFromRegistry([
'Name of country' => 'country',
'IBAN prefix country code (ISO 3166)' => 'country_code',
'IBAN structure' => 'iban_structure',
'Country code includes other countries/territories' => 'included_country_codes',
]);

$formats = [];

foreach ($items as $item) {
$formats[$item['country_code']] = [$this->buildIbanRegexp($item['iban_structure']), $item['country']];

foreach ($this->parseCountryCodesList($item['included_country_codes']) as $includedCountryCode) {
$formats[$includedCountryCode] = $formats[$item['country_code']];
}
}

return $formats;
}

/**
* @return list<string>
*/
private function parseCountryCodesList(string $countryCodesList): array
{
if ('N/A' === $countryCodesList) {
return [];
}

$countryCodes = [];

foreach (explode(',', $countryCodesList) as $countryCode) {
$countryCodes[] = preg_replace('/^([A-Z]{2})(\s+\(.+?\))?$/', '$1', trim($countryCode));
}

return $countryCodes;
}

/**
* @param array<string, string> $properties
*
* @return list<array<string, string>>
*/
private function readPropertiesFromRegistry(array $properties): array
{
$items = [];

$registryContent = file_get_contents('https://www.swift.com/swift-resource/11971/download');
$lines = explode("\n", $registryContent);

// skip header line
array_shift($lines);

foreach ($lines as $line) {
$columns = str_getcsv($line, "\t");
$propertyLabel = array_shift($columns);

if (!isset($properties[$propertyLabel])) {
continue;
}

$propertyField = $properties[$propertyLabel];

foreach ($columns as $index => $value) {
$items[$index][$propertyField] = $value;
}
}

return array_values($items);
}

private function buildIbanRegexp(string $ibanStructure): string
{
$pattern = $ibanStructure;

$pattern = preg_replace('/(\d+)!n/', '\\d{$1}', $pattern);
$pattern = preg_replace('/(\d+)!a/', '[A-Z]{$1}', $pattern);
$pattern = preg_replace('/(\d+)!c/', '[\\dA-Z]{$1}', $pattern);

return $pattern;
}
}

final class WikipediaIbanProvider
{
/**
* @return array<string, array{string, string}>
*/
public function getIbanFormats(): array
{
$formats = [];

foreach ($this->readIbanFormatsTable() as $item) {
if (!preg_match('/^([A-Z]{2})/', $item['Example'], $matches)) {
continue;
}

$countryCode = $matches[1];

$formats[$countryCode] = [$this->buildIbanRegexp($countryCode, $item['BBAN Format']), $item['Country']];
}

return $formats;
}

/**
* @return list<array<string, string|int>>
*/
private function readIbanFormatsTable(): array
{
$tablesResponse = file_get_contents('https://www.wikitable2json.com/api/International_Bank_Account_Number?table=3&keyRows=1&clearRef=true');

return json_decode($tablesResponse, true, 512, JSON_THROW_ON_ERROR)[0];
}

private function buildIbanRegexp(string $countryCode, string $bbanFormat): string
{
$pattern = $bbanFormat;

$pattern = preg_replace('/\s*,\s*/', '', $pattern);
$pattern = preg_replace('/(\d+)n/', '\\d{$1}', $pattern);
$pattern = preg_replace('/(\d+)a/', '[A-Z]{$1}', $pattern);
$pattern = preg_replace('/(\d+)c/', '[\\dA-Z]{$1}', $pattern);

return $countryCode.'\\d{2}'.$pattern;
}
}

0 comments on commit 0955438

Please sign in to comment.