1<?php
2
3/**
4 * @see       https://github.com/laminas/laminas-validator for the canonical source repository
5 * @copyright https://github.com/laminas/laminas-validator/blob/master/COPYRIGHT.md
6 * @license   https://github.com/laminas/laminas-validator/blob/master/LICENSE.md New BSD License
7 */
8
9namespace Laminas\Validator;
10
11use Laminas\Stdlib\ArrayUtils;
12use Traversable;
13
14/**
15 * Validates IBAN Numbers (International Bank Account Numbers)
16 */
17class Iban extends AbstractValidator
18{
19    const NOTSUPPORTED     = 'ibanNotSupported';
20    const SEPANOTSUPPORTED = 'ibanSepaNotSupported';
21    const FALSEFORMAT      = 'ibanFalseFormat';
22    const CHECKFAILED      = 'ibanCheckFailed';
23
24    /**
25     * Validation failure message template definitions
26     *
27     * @var array
28     */
29    protected $messageTemplates = [
30        self::NOTSUPPORTED     => 'Unknown country within the IBAN',
31        self::SEPANOTSUPPORTED => 'Countries outside the Single Euro Payments Area (SEPA) are not supported',
32        self::FALSEFORMAT      => 'The input has a false IBAN format',
33        self::CHECKFAILED      => 'The input has failed the IBAN check',
34    ];
35
36    /**
37     * Optional country code by ISO 3166-1
38     *
39     * @var string|null
40     */
41    protected $countryCode;
42
43    /**
44     * Optionally allow IBAN codes from non-SEPA countries. Defaults to true
45     *
46     * @var bool
47     */
48    protected $allowNonSepa = true;
49
50    /**
51     * The SEPA country codes
52     *
53     * @var array<ISO 3166-1>
54     */
55    protected static $sepaCountries = [
56        'AT', 'BE', 'BG', 'CY', 'CZ', 'DK', 'FO', 'GL', 'EE', 'FI', 'FR', 'DE',
57        'GI', 'GR', 'HU', 'IS', 'IE', 'IT', 'LV', 'LI', 'LT', 'LU', 'MT', 'MC',
58        'NL', 'NO', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'CH', 'GB', 'SM',
59        'HR',
60    ];
61
62    /**
63     * IBAN regexes by country code
64     *
65     * @var array
66     */
67    protected static $ibanRegex = [
68        'AD' => 'AD[0-9]{2}[0-9]{4}[0-9]{4}[A-Z0-9]{12}',
69        'AE' => 'AE[0-9]{2}[0-9]{3}[0-9]{16}',
70        'AL' => 'AL[0-9]{2}[0-9]{8}[A-Z0-9]{16}',
71        'AT' => 'AT[0-9]{2}[0-9]{5}[0-9]{11}',
72        'AZ' => 'AZ[0-9]{2}[A-Z]{4}[A-Z0-9]{20}',
73        'BA' => 'BA[0-9]{2}[0-9]{3}[0-9]{3}[0-9]{8}[0-9]{2}',
74        'BE' => 'BE[0-9]{2}[0-9]{3}[0-9]{7}[0-9]{2}',
75        'BG' => 'BG[0-9]{2}[A-Z]{4}[0-9]{4}[0-9]{2}[A-Z0-9]{8}',
76        'BH' => 'BH[0-9]{2}[A-Z]{4}[A-Z0-9]{14}',
77        'BR' => 'BR[0-9]{2}[0-9]{8}[0-9]{5}[0-9]{10}[A-Z][A-Z0-9]',
78        'BY' => 'BY[0-9]{2}[A-Z0-9]{4}[0-9]{4}[A-Z0-9]{16}',
79        'CH' => 'CH[0-9]{2}[0-9]{5}[A-Z0-9]{12}',
80        'CR' => 'CR[0-9]{2}[0-9]{3}[0-9]{14}',
81        'CY' => 'CY[0-9]{2}[0-9]{3}[0-9]{5}[A-Z0-9]{16}',
82        'CZ' => 'CZ[0-9]{2}[0-9]{20}',
83        'DE' => 'DE[0-9]{2}[0-9]{8}[0-9]{10}',
84        'DO' => 'DO[0-9]{2}[A-Z0-9]{4}[0-9]{20}',
85        'DK' => 'DK[0-9]{2}[0-9]{14}',
86        'EE' => 'EE[0-9]{2}[0-9]{2}[0-9]{2}[0-9]{11}[0-9]{1}',
87        'ES' => 'ES[0-9]{2}[0-9]{4}[0-9]{4}[0-9]{1}[0-9]{1}[0-9]{10}',
88        'FI' => 'FI[0-9]{2}[0-9]{6}[0-9]{7}[0-9]{1}',
89        'FO' => 'FO[0-9]{2}[0-9]{4}[0-9]{9}[0-9]{1}',
90        'FR' => 'FR[0-9]{2}[0-9]{5}[0-9]{5}[A-Z0-9]{11}[0-9]{2}',
91        'GB' => 'GB[0-9]{2}[A-Z]{4}[0-9]{6}[0-9]{8}',
92        'GE' => 'GE[0-9]{2}[A-Z]{2}[0-9]{16}',
93        'GI' => 'GI[0-9]{2}[A-Z]{4}[A-Z0-9]{15}',
94        'GL' => 'GL[0-9]{2}[0-9]{4}[0-9]{9}[0-9]{1}',
95        'GR' => 'GR[0-9]{2}[0-9]{3}[0-9]{4}[A-Z0-9]{16}',
96        'GT' => 'GT[0-9]{2}[A-Z0-9]{4}[A-Z0-9]{20}',
97        'HR' => 'HR[0-9]{2}[0-9]{7}[0-9]{10}',
98        'HU' => 'HU[0-9]{2}[0-9]{3}[0-9]{4}[0-9]{1}[0-9]{15}[0-9]{1}',
99        'IE' => 'IE[0-9]{2}[A-Z]{4}[0-9]{6}[0-9]{8}',
100        'IL' => 'IL[0-9]{2}[0-9]{3}[0-9]{3}[0-9]{13}',
101        'IS' => 'IS[0-9]{2}[0-9]{4}[0-9]{2}[0-9]{6}[0-9]{10}',
102        'IT' => 'IT[0-9]{2}[A-Z]{1}[0-9]{5}[0-9]{5}[A-Z0-9]{12}',
103        'KW' => 'KW[0-9]{2}[A-Z]{4}[0-9]{22}',
104        'KZ' => 'KZ[0-9]{2}[0-9]{3}[A-Z0-9]{13}',
105        'LB' => 'LB[0-9]{2}[0-9]{4}[A-Z0-9]{20}',
106        'LI' => 'LI[0-9]{2}[0-9]{5}[A-Z0-9]{12}',
107        'LT' => 'LT[0-9]{2}[0-9]{5}[0-9]{11}',
108        'LU' => 'LU[0-9]{2}[0-9]{3}[A-Z0-9]{13}',
109        'LV' => 'LV[0-9]{2}[A-Z]{4}[A-Z0-9]{13}',
110        'MC' => 'MC[0-9]{2}[0-9]{5}[0-9]{5}[A-Z0-9]{11}[0-9]{2}',
111        'MD' => 'MD[0-9]{2}[A-Z0-9]{20}',
112        'ME' => 'ME[0-9]{2}[0-9]{3}[0-9]{13}[0-9]{2}',
113        'MK' => 'MK[0-9]{2}[0-9]{3}[A-Z0-9]{10}[0-9]{2}',
114        'MR' => 'MR13[0-9]{5}[0-9]{5}[0-9]{11}[0-9]{2}',
115        'MT' => 'MT[0-9]{2}[A-Z]{4}[0-9]{5}[A-Z0-9]{18}',
116        'MU' => 'MU[0-9]{2}[A-Z]{4}[0-9]{2}[0-9]{2}[0-9]{12}[0-9]{3}[A-Z]{3}',
117        'NL' => 'NL[0-9]{2}[A-Z]{4}[0-9]{10}',
118        'NO' => 'NO[0-9]{2}[0-9]{4}[0-9]{6}[0-9]{1}',
119        'PK' => 'PK[0-9]{2}[A-Z]{4}[A-Z0-9]{16}',
120        'PL' => 'PL[0-9]{2}[0-9]{8}[0-9]{16}',
121        'PS' => 'PS[0-9]{2}[A-Z]{4}[A-Z0-9]{21}',
122        'PT' => 'PT[0-9]{2}[0-9]{4}[0-9]{4}[0-9]{11}[0-9]{2}',
123        'RO' => 'RO[0-9]{2}[A-Z]{4}[A-Z0-9]{16}',
124        'RS' => 'RS[0-9]{2}[0-9]{3}[0-9]{13}[0-9]{2}',
125        'SA' => 'SA[0-9]{2}[0-9]{2}[A-Z0-9]{18}',
126        'SE' => 'SE[0-9]{2}[0-9]{3}[0-9]{16}[0-9]{1}',
127        'SI' => 'SI[0-9]{2}[0-9]{5}[0-9]{8}[0-9]{2}',
128        'SK' => 'SK[0-9]{2}[0-9]{4}[0-9]{6}[0-9]{10}',
129        'SM' => 'SM[0-9]{2}[A-Z]{1}[0-9]{5}[0-9]{5}[A-Z0-9]{12}',
130        'TN' => 'TN59[0-9]{2}[0-9]{3}[0-9]{13}[0-9]{2}',
131        'TR' => 'TR[0-9]{2}[0-9]{5}[A-Z0-9]{1}[A-Z0-9]{16}',
132        'VG' => 'VG[0-9]{2}[A-Z]{4}[0-9]{16}',
133    ];
134
135    /**
136     * Sets validator options
137     *
138     * @param  array|Traversable $options OPTIONAL
139     */
140    public function __construct($options = [])
141    {
142        if ($options instanceof Traversable) {
143            $options = ArrayUtils::iteratorToArray($options);
144        }
145
146        if (array_key_exists('country_code', $options)) {
147            $this->setCountryCode($options['country_code']);
148        }
149
150        if (array_key_exists('allow_non_sepa', $options)) {
151            $this->setAllowNonSepa($options['allow_non_sepa']);
152        }
153
154        parent::__construct($options);
155    }
156
157    /**
158     * Returns the optional country code by ISO 3166-1
159     *
160     * @return string|null
161     */
162    public function getCountryCode()
163    {
164        return $this->countryCode;
165    }
166
167    /**
168     * Sets an optional country code by ISO 3166-1
169     *
170     * @param  string|null $countryCode
171     * @return $this provides a fluent interface
172     * @throws Exception\InvalidArgumentException
173     */
174    public function setCountryCode($countryCode = null)
175    {
176        if ($countryCode !== null) {
177            $countryCode = (string) $countryCode;
178
179            if (! isset(static::$ibanRegex[$countryCode])) {
180                throw new Exception\InvalidArgumentException(
181                    "Country code '{$countryCode}' invalid by ISO 3166-1 or not supported"
182                );
183            }
184        }
185
186        $this->countryCode = $countryCode;
187        return $this;
188    }
189
190    /**
191     * Returns the optional allow non-sepa countries setting
192     *
193     * @return bool
194     */
195    public function allowNonSepa()
196    {
197        return $this->allowNonSepa;
198    }
199
200    /**
201     * Sets the optional allow non-sepa countries setting
202     *
203     * @param  bool $allowNonSepa
204     * @return $this provides a fluent interface
205     */
206    public function setAllowNonSepa($allowNonSepa)
207    {
208        $this->allowNonSepa = (bool) $allowNonSepa;
209        return $this;
210    }
211
212    /**
213     * Returns true if $value is a valid IBAN
214     *
215     * @param  string $value
216     * @return bool
217     */
218    public function isValid($value)
219    {
220        if (! is_string($value)) {
221            $this->error(self::FALSEFORMAT);
222            return false;
223        }
224
225        $value = str_replace(' ', '', strtoupper($value));
226        $this->setValue($value);
227
228        $countryCode = $this->getCountryCode();
229        if ($countryCode === null) {
230            $countryCode = substr($value, 0, 2);
231        }
232
233        if (! array_key_exists($countryCode, static::$ibanRegex)) {
234            $this->setValue($countryCode);
235            $this->error(self::NOTSUPPORTED);
236            return false;
237        }
238
239        if (! $this->allowNonSepa && ! in_array($countryCode, static::$sepaCountries)) {
240            $this->setValue($countryCode);
241            $this->error(self::SEPANOTSUPPORTED);
242            return false;
243        }
244
245        if (! preg_match('/^' . static::$ibanRegex[$countryCode] . '$/', $value)) {
246            $this->error(self::FALSEFORMAT);
247            return false;
248        }
249
250        $format = substr($value, 4) . substr($value, 0, 4);
251        $format = str_replace(
252            ['A',  'B',  'C',  'D',  'E',  'F',  'G',  'H',  'I',  'J',  'K',  'L',  'M',
253                  'N',  'O',  'P',  'Q',  'R',  'S',  'T',  'U',  'V',  'W',  'X',  'Y',  'Z'],
254            ['10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22',
255                  '23', '24', '25', '26', '27', '28', '29', '30', '31', '32', '33', '34', '35'],
256            $format
257        );
258
259        $temp = intval(substr($format, 0, 1));
260        $len  = strlen($format);
261        for ($x = 1; $x < $len; ++$x) {
262            $temp *= 10;
263            $temp += intval(substr($format, $x, 1));
264            $temp %= 97;
265        }
266
267        if ($temp != 1) {
268            $this->error(self::CHECKFAILED);
269            return false;
270        }
271
272        return true;
273    }
274}
275