SepaCntryValidationBase   A
last analyzed

Complexity

Total Complexity 26

Size/Duplication

Total Lines 255
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 26
eloc 63
dl 0
loc 255
rs 10
c 0
b 0
f 0

10 Methods

Rating   Name   Duplication   Size   Complexity  
A validateIBAN() 0 25 6
A __construct() 0 4 2
A convCharToNum() 0 4 1
A adjustFP() 0 16 2
A getLastCheckSum() 0 8 1
A getCheckSum() 0 15 2
A replaceAlpha() 0 7 2
A getAlpha2CntryCode() 0 4 1
A validateCI() 0 24 6
A validateBIC() 0 10 3
1
<?php
2
namespace SKien\Sepa\CntryValidation;
3
4
use SKien\Sepa\Sepa;
5
6
/**
7
 * Base class for country specific validation for IBAN, BIC and CI.
8
 *
9
 * Needed methods to calculate checksum and check the format of
10
 * the values.
11
 * Format check is made with regular expressions that can be set to
12
 * the country specific rules.
13
 *
14
 * Create for each country to support a class extending this class.
15
 * For most of the participating countries, it is sufficient to
16
 * specify in the constructor the respective length, the formatting
17
 * rule (RegEx) and the information whether alphanumeric characters
18
 * are allowed.
19
 *
20
 * If more complicated rules apply in a country, the respective
21
 * method for validation can be redefined in the extended class in
22
 * order to map this rule.
23
 * @see SepaCntryValidationBE class
24
 *
25
 * @package Sepa
26
 * @author Stefanius <[email protected]>
27
 * @copyright MIT License - see the LICENSE file for details
28
 */
29
class SepaCntryValidationBase implements SepaCntryValidation
30
{
31
    /** @var string 2 digit ISO country code (ISO 3166-1)   */
32
    protected string $strCntry = '';
33
    /** @var int    length of the IBAN     */
34
    protected int $iLenIBAN = -1;
35
    /** @var string regular expression to validate the format of the IBAN    */
36
    protected string $strRegExIBAN = '';
37
    /** @var bool   if true, alphanum values in the IBAN allowed    */
38
    protected bool $bAlphaNumIBAN = false;
39
    /** @var int    length of the IC     */
40
    protected int $iLenCI = -1;
41
    /** @var string regular expression to validate the format of the CI    */
42
    protected string $strRegExCI = '';
43
    /** @var bool   if true, alphanum values in the CI allowed    */
44
    protected bool $bAlphaNumCI = false;
45
    /** @var string last calculated checksum    */
46
    protected string $strLastCheckSum = '';
47
48
    /**
49
     * Create instance of validation.
50
     * This constructor checks, if the country code from the parameter equals to the
51
     * code defined in the extension class. This prevents the wrong class from being used
52
     * for validation for a given country.
53
     * @param string $strCntry  2 sign country code
54
     */
55
    public function __construct(string $strCntry)
56
    {
57
        if (strtoupper($strCntry) != $this->strCntry) {
58
            trigger_error('instanciation with invalid country ' . $strCntry . ' (expected ' . $this->strCntry . ')', E_USER_ERROR);
59
        }
60
    }
61
62
    /**
63
     * Validates given IBAN.
64
     *
65
     * @link https://www.ecbs.org/iban.htm
66
     * @link http://www.pruefziffernberechnung.de/I/IBAN.shtml
67
     *
68
     * @param string $strIBAN   IBAN to validate
69
     * @return int Sepa::OK or errorcode <ul>
70
     * <li><b> Sepa::ERR_IBAN_INVALID_CNTRY   </b>  invalid country code </li>
71
     * <li><b> Sepa::ERR_IBAN_INVALID_LENGTH  </b>  invalid length </li>
72
     * <li><b> Sepa::ERR_IBAN_INVALID_SIGN    </b>  IBAN contains invalid sign(s) </li>
73
     * <li><b> Sepa::ERR_IBAN_CHECKSUM        </b>  wrong checksum </li></ul>
74
     */
75
    public function validateIBAN(string $strIBAN) : int
76
    {
77
        // toupper, trim and remove containing blanks
78
        $strIBAN = str_replace(' ', '', trim(strtoupper($strIBAN)));
79
        if (strlen($strIBAN) != $this->iLenIBAN) {
80
            return Sepa::ERR_IBAN_INVALID_LENGTH;
81
        }
82
        if (substr($strIBAN, 0, 2) != $this->strCntry) {
83
            return Sepa::ERR_IBAN_INVALID_CNTRY;
84
        }
85
        if (!preg_match($this->strRegExIBAN, $strIBAN)) {
86
            return Sepa::ERR_IBAN_INVALID_SIGN;
87
        }
88
89
        $strCS = substr($strIBAN, 2, 2);
90
        $strBBAN = substr($strIBAN, 4);
91
92
        // alphanumeric account number allowed (except the country code...)?
93
        if ($this->bAlphaNumIBAN) {
94
            $strBBAN = $this->replaceAlpha($strBBAN);
95
        }
96
        if ($this->getCheckSum($strBBAN) != $strCS) {
97
            return Sepa::ERR_IBAN_CHECKSUM;
98
        }
99
        return Sepa::OK;
100
    }
101
102
    /**
103
     * Validates given BIC.
104
     * Follows <b> ISO 9362 </b><br>
105
     * as far as I have determined, the format of the BIC is uniform within
106
     * the participating countries for SEPA.
107
     * @param string $strBIC    BIC to validate
108
     * @return int Sepa::OK or errorcode<ul>
109
     * <li><b> Sepa::ERR_BIC_INVALID        </b>  invalid BIC </li>
110
     * <li><b> Sepa::ERR_BIC_INVALID_CNTRY  </b>  invalid country code </li></ul>
111
     */
112
    public function validateBIC(string $strBIC) : int
113
    {
114
        if (substr($strBIC, 4, 2) != $this->strCntry) {
115
            return Sepa::ERR_BIC_INVALID_CNTRY;
116
        }
117
        $iErr = Sepa::ERR_BIC_INVALID;
118
        if (preg_match('/^([A-Z]){4}([A-Z]){2}([0-9A-Z]){2}([0-9A-Z]{3})?$/', $strBIC)) {
119
            $iErr = Sepa::OK;
120
        }
121
        return $iErr;
122
    }
123
124
    /**
125
     * Validates given CI (Creditor Scheme Identification).
126
     *
127
     * The general structure for the CI is the following:
128
     * - Position 1-2 filled with the ISO country code
129
     * - Position 3-4 filled with the check digit according to ISO 7064 Mod 97-10
130
     * - Position 5-7 filled with the Creditor Business Code, if not used then filled with ZZZ
131
     * - Position 8 onwards filled with the country specific part of the identifier being
132
     *   a national identifier of the Creditor as defined by the concerned national community.
133
     *
134
     * NOTE: the CBC is not taken into account when calculating the checksum!
135
     *
136
     * @link https://www.sepaforcorporates.com/sepa-direct-debits/sepa-creditor-identifier/
137
     * @link https://www.europeanpaymentscouncil.eu/sites/default/files/kb/file/2019-09/EPC262-08%20v7.0%20Creditor%20Identifier%20Overview_0.pdf
138
     * @link https://www.europeanpaymentscouncil.eu/sites/default/files/KB/files/EPC114-06%20SDD%20Core%20Interbank%20IG%20V9.0%20Approved.pdf#page=10
139
     *
140
     * online CI Validator
141
     * @link http://www.maric.info/fin/SEPA/ddchkden.htm
142
     *
143
     * @param string $strCI     CI to validate
144
     * @return int Sepa::OK or errorcode<ul>
145
     * <li><b> Sepa::ERR_CI_INVALID_CNTRY   </b>  invalid country code </li>
146
     * <li><b> Sepa::ERR_CI_INVALID_LENGTH  </b>  invalid length </li>
147
     * <li><b> Sepa::ERR_CI_INVALID_SIGN    </b>  CI contains invalid sign(s) </li>
148
     * <li><b> Sepa::ERR_CI_CHECKSUM        </b>  wrong checksum </li></ul>
149
     */
150
    public function validateCI(string $strCI) : int
151
    {
152
        // toupper, trim and remove containing blanks
153
        $strCheck = str_replace(' ', '', trim(strtoupper($strCI)));
154
        if (strlen($strCheck) != $this->iLenCI) {
155
            return Sepa::ERR_CI_INVALID_LENGTH;
156
        }
157
        if (substr($strCheck, 0, 2) != $this->strCntry) {
158
            return Sepa::ERR_CI_INVALID_CNTRY;
159
        }
160
        if (!preg_match($this->strRegExCI, $strCheck)) {
161
            return Sepa::ERR_CI_INVALID_SIGN;
162
        }
163
164
        $strCS = substr($strCheck, 2, 2);
165
        // NOTE: the CBC is not taken into account when calculating the checksum!
166
        $strCheck = substr($strCheck, 7);
167
        if ($this->bAlphaNumCI) {
168
            $strCheck = $this->replaceAlpha($strCheck);
169
        }
170
        if ($this->getCheckSum($strCheck) != $strCS) {
171
            return Sepa::ERR_CI_CHECKSUM;
172
        }
173
        return Sepa::OK;
174
    }
175
176
    /**
177
     * Return last calculated checksum.
178
     * This method is only for test purposes.
179
     * @return string
180
     * @internal
181
     */
182
    public function getLastCheckSum() : string
183
    {
184
        // @codeCoverageIgnoreStart
185
        /*
186
         * Outside of coverge, since this method only can be used to retrieve
187
         * any calculated checksum for testpurposes and shouldn't be used in production code!
188
         */
189
        return $this->strLastCheckSum;
190
        // @codeCoverageIgnoreEnd
191
    }
192
193
    /**
194
     * Calculate modulo 97 checksum for bankcode and accountnumber
195
     * MOD 97-10 (see ISO 7064)
196
     * @param string $strCheck
197
     * @return string
198
     */
199
    protected function getCheckSum(string $strCheck) : string
200
    {
201
        // calculate checksum
202
        // 1. move 6 digit feft and add numerical countrycode
203
        $strCS1 = $this->adjustFP(bcadd(bcmul($strCheck, '1000000'), $this->getAlpha2CntryCode() . '00'));
204
        // 2. modulo 97 value
205
        $strCS2 = $this->adjustFP(bcmod($strCS1, '97'));
206
        // 3. subtract value from 98
207
        $strCS = $this->adjustFP(bcsub('98', $strCS2));
208
        // 4. always 2 digits...
209
        if (strlen($strCS) < 2) {
210
            $strCS = '0' . $strCS;
211
        }
212
        $this->strLastCheckSum = $strCS;
213
        return $strCS;
214
    }
215
216
    /**
217
     * In some cases there appears unwanted decimals (floatingpoint drift from bc - operations)
218
     * ... just cut them off
219
     *
220
     * @param string $str
221
     * @return string
222
     */
223
    protected function adjustFP(string $str) : string
224
    {
225
        // @codeCoverageIgnoreStart
226
        /*
227
         * Note:
228
         * This problem actually occurred in practice, unfortunately at the time I wrote
229
         * the tests, I no longer have any concrete sample data that cause this error.
230
         * This means that I am currently not in a position to intentionally induce the
231
         * error in order to test its correct handling.
232
         * That's why I take the function out of the coverage so that I don't have to look
233
         * over and over again with every run to see what is not covered ...
234
         */
235
        if (strpos('.', $str) !== false) {
236
            $str = substr($str, 0, strpos('.', $str));
237
        }
238
        return $str;
239
        // @codeCoverageIgnoreEnd
240
    }
241
242
    /**
243
     * Get the ALPHA-2 country code.
244
     * To calc the checksum, the first four digits of the IBAN (country code and check digit)
245
     * have to be placed at the end of the IBAN. Check digit is represented by 00 for calculation.
246
     * @return string
247
     */
248
    protected function getAlpha2CntryCode() : string
249
    {
250
        $strNumCode = $this->convCharToNum($this->strCntry[0]) . $this->convCharToNum($this->strCntry[1]);
251
        return $strNumCode;
252
    }
253
254
    /**
255
     * Replace all alpha chars with it's numeric substitution.
256
     * @param string $strCheck
257
     * @return string
258
     */
259
    protected function replaceAlpha(string $strCheck) : string
260
    {
261
        // account number may contains characters
262
        foreach (range('A', 'Z') as $ch) {
263
            $strCheck = str_replace((string)$ch, $this->convCharToNum((string)$ch), $strCheck);
264
        }
265
        return $strCheck;
266
    }
267
268
    /**
269
     * Existing non-numeric characters must be converted to a numeric value for the calculation.
270
     *
271
     *  A = 10  F = 15  K = 20  P = 25  U = 30  Z = 35
272
     *  B = 11  G = 16  L = 21  Q = 26  V = 31
273
     *  C = 12  H = 17  M = 22  R = 27  W = 32
274
     *  D = 13  I = 18  N = 23  S = 28  X = 33
275
     *  E = 14  J = 19  O = 24  T = 29  Y = 34
276
     *
277
     * @param string $ch
278
     * @return string
279
     */
280
    protected function convCharToNum(string $ch) : string
281
    {
282
        $iValue = ord($ch) - ord('A') + 10;
283
        return strval($iValue);
284
    }
285
}