SclDirectory::getError()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 1
dl 0
loc 3
rs 10
c 1
b 0
f 0
cc 1
nc 1
nop 0
1
<?php
2
declare(strict_types=1);
3
4
namespace SKien\Sepa;
5
6
/**
7
 * This class represents the SCL directory provided by the 'Deutsche Bundesbank'.
8
 *
9
 * The SCL directory contains all business identifier codes (BICs) that can be reached
10
 * within the SEPA payment area.
11
 * (https://www.bundesbank.de/en/tasks/payment-systems/rps/sepa-clearer/scl-directory/scl-directory-626672)
12
 *
13
 * With this class, the data made available as a CSV file can be loaded from the
14
 * Internet and saved in a XML file on the own server. The XML file contains
15
 * a timestamp of the latest download that can be retrieved - so its on the user to
16
 * decide, in which timespan he will update the informations. Optionally, this update
17
 * process can be automated by specifying a time period.
18
 *
19
 * The clas contains functionality to:
20
 * - check, if a given BIC is valid
21
 * - get the service provider name (bankname) to a given BIC
22
 * - get a list of the payment service providers (all or filtered by country)
23
 *
24
 * @package Sepa
25
 * @author Stefanius <[email protected]>
26
 * @copyright MIT License - see the LICENSE file for details
27
 */
28
class SclDirectory
29
{
30
    /** the url where the scl directory can be accessed */
31
    protected const DATA_PROVIDER_URL = 'https://www.bundesbank.de/scl-directory';
32
    /** the data file */
33
    protected const DATAFILE = 'scl-directory.xml';
34
35
    /** @var string path to the data file     */
36
    protected string $strDataPath = '';
37
    /** @var \DOMDocument      */
38
    protected ?\DOMDocument $oDoc = null;
39
    /** @var string last error                   */
40
    protected string $strLastError;
41
    /** @var integer unix timestamp of last data update     */
42
    protected int $uxtsLastUpdated = 0;
43
44
    /**
45
     * Create SCL directory.
46
     * @param string $strDataPath path to the XML data file
47
     */
48
    public function __construct(string $strDataPath = '')
49
    {
50
        $this->strDataPath = rtrim($strDataPath, DIRECTORY_SEPARATOR);
51
    }
52
53
    /**
54
     * Init the object.
55
     * If the XML data file already exist, it is checked if writable.
56
     * If not, the datapath is checked if writable
57
     * @return bool false, if any error occur
58
     */
59
    public function init() : bool
60
    {
61
        $this->strLastError = '';
62
        $strXMLName = self::DATAFILE;
63
        if (strlen($this->strDataPath) > 0) {
64
            $strXMLName = $this->strDataPath . DIRECTORY_SEPARATOR . self::DATAFILE;
65
        }
66
67
        if (file_exists($strXMLName)) {
68
            if (!is_writable($strXMLName)) {
69
                $this->strLastError .= 'readonly data file ' . $strXMLName . '!';
70
            } else {
71
                $this->oDoc = new \DOMDocument();
72
                $this->oDoc->load($strXMLName);
73
            }
74
        } else {
75
            $strPath = realpath($this->strDataPath);
76
            if ($strPath === false || !is_writable($strPath)) {
77
                $this->strLastError .= ' (no rights to write on directory ' . $strPath . ')';
78
            }
79
        }
80
        return (strlen($this->strLastError) == 0);
81
    }
82
83
    /**
84
     * Get the date the data has been updated last.
85
     * @return int date as unix timestamp
86
     */
87
    public function lastUpdated() : int
88
    {
89
        $uxtsLastUpdated = 0;
90
        if ($this->oDoc !== null && $this->oDoc->documentElement !== null) {
91
            $strDate = $this->oDoc->documentElement->getAttribute('created');
92
            if (strlen($strDate) > 0) {
93
                $uxtsLastUpdated = strtotime($strDate);
94
            }
95
        }
96
        return intval($uxtsLastUpdated);
97
    }
98
99
    /**
100
     * Check, if the requested BIC exists.
101
     * @param string $strBIC
102
     * @return bool
103
     */
104
    public function isValidBIC(string $strBIC) : bool
105
    {
106
        return ($this->getProviderNode($strBIC) !== null);
107
    }
108
109
    /**
110
     * Get the name of the provider to the given BIC.
111
     * Since the provider names in the supported directory all  in uppercase, this
112
     * can be converted to upper case words by setting the `$bToUCWords`parameter to true.
113
     * @param string $strBIC BIC to get the name for
114
     * @param bool $bToUCWords convert the provider names to Uppercase Words
115
     * @return string   name or empty string, if not exist
116
     */
117
    public function getNameFromBIC(string $strBIC, bool $bToUCWords = false) : string
118
    {
119
        $strName = '';
120
        $oNode = $this->getProviderNode($strBIC);
121
        if ($oNode !== null) {
122
            $strName = ($bToUCWords ? $this->convToUCWords($oNode->nodeValue ?? '') : $oNode->nodeValue ?? '');
123
        }
124
        return $strName;
125
    }
126
127
    /**
128
     * Get the list of provider names.
129
     * Since the provider names in the supported directory all  in uppercase, this
130
     * can be converted to upper case words by setting the `$bToUCWords`parameter to true.
131
     * @param string $strCC country code the list should be generated for (leave empty for full list)
132
     * @param bool $bToUCWords convert the provider names to Uppercase Words
133
     * @return array<string,string>
134
     */
135
    public function getProviderList(string $strCC = '', bool $bToUCWords = false) : array
136
    {
137
        $aList = [];
138
        if ($this->oDoc !== null) {
139
            $oXPath = new \DOMXPath($this->oDoc);
140
            if (strlen($strCC) > 0) {
141
                $oNodelist = $oXPath->query("//Provider[@CC='" . $strCC . "']");
142
            } else {
143
                $oNodelist = $oXPath->query("//Provider");
144
            }
145
            if ($oNodelist !== false) {
146
                foreach ($oNodelist as $oNode) {
147
                    if ($oNode instanceof \DOMElement && $oNode->hasAttribute('BIC')) {
148
                        $aList[$oNode->getAttribute('BIC')] = ($bToUCWords ? $this->convToUCWords($oNode->nodeValue ?? '') : $oNode->nodeValue ?? '');
149
                    }
150
                }
151
            }
152
        }
153
        return $aList;
154
    }
155
156
    /**
157
     * Load the actual list from the internet.
158
     * Since downloading and saving the data takes a certain amount of time and this
159
     * list does not change constantly, an interval can be specified so that the data
160
     * only is downloaded again after it has expired.
161
     * The intervall can be specified as integer in seconds (according to a unix timestamp)
162
     * or as string representing any `dateinterval´.
163
     * > recommended values are 1..4 weeks (e.g. `'P2W'` for 2 weeks).
164
     * @link https://www.php.net/manual/dateinterval.construct.php
165
     * @param int|string|null $interval
166
     * @return bool
167
     */
168
    public function loadFromInternet($interval = null) : bool
169
    {
170
        try {
171
            if (!$this->hasIntervalExpired($interval)) {
172
                return true;
173
            }
174
        } catch (\Exception $e) {
175
            $this->strLastError = $e->__toString();
176
            return false;
177
        }
178
        $this->oDoc = new \DOMDocument('1.0', 'UTF-8');
179
180
        $this->oDoc->formatOutput = true;
181
        $this->oDoc->preserveWhiteSpace = false;
182
183
        $xmlRoot = $this->oDoc->createElement("SCL-Directory");
184
        $xmlRoot->setAttribute('created', date('Y-m-d H:i:s'));
185
        $this->oDoc->appendChild($xmlRoot);
186
187
        $xmlBase = $this->oDoc->createElement('Providers');
188
        $xmlRoot->appendChild($xmlBase);
189
190
        // cURL
191
        $curl = curl_init();
192
        curl_setopt_array($curl, [
193
            CURLOPT_URL            => self::DATA_PROVIDER_URL,
194
            CURLOPT_HTTPHEADER     => ['User-Agent: cURL'],
195
            CURLOPT_FOLLOWLOCATION => true,
196
            CURLOPT_RETURNTRANSFER => true,
197
        ]);
198
199
        $strResponse = curl_exec($curl);
200
        if (is_bool($strResponse)) {
201
            $this->strLastError = 'cURL-Error: ' . curl_error($curl);
202
            curl_close($curl);
203
            return false;
204
        }
205
        curl_close($curl);
206
        $fp = fopen('data://text/plain,' . $strResponse, 'r');
207
        $aCountry = [];
208
        if ($fp !== false) {
209
            $iRow = 0;
210
            while (($row = fgetcsv($fp, 1000, ';')) !== false) {
211
                // first line contains 'valid from xx.xx.xxxx'; second the colheader
212
                if ($iRow > 1 && is_array($row) && count($row) > 1) {
213
                    $strBIC = trim($row[0]);
214
                    $strCC = substr($strBIC, 4, 2);
215
                    // $strName = ucwords(strtolower(trim($row[1])));
216
                    $strName = trim($row[1]);
217
                    $strName = utf8_encode($strName);
218
                    $strName = htmlspecialchars($strName);
219
220
                    $xmlRow = $this->oDoc->createElement('Provider', $strName);
221
                    $xmlRow->setAttribute('CC', $strCC);
222
                    $xmlRow->setAttribute('BIC', $strBIC);
223
                    $xmlBase->appendChild($xmlRow);
224
225
                    isset($aCountry[$strCC]) ? $aCountry[$strCC] = $aCountry[$strCC] + 1 : $aCountry[$strCC] = 1;
226
                }
227
                $iRow++;
228
            }
229
        }
230
        ksort($aCountry);
231
        $xmlBase = $this->oDoc->createElement('Countries');
232
        $xmlRoot->appendChild($xmlBase);
233
        foreach ($aCountry as $strCC => $iCount) {
234
            $xmlRow = $this->oDoc->createElement('Country', (string)$iCount);
235
            $xmlRow->setAttribute('CC', $strCC);
236
            $xmlBase->appendChild($xmlRow);
237
        }
238
        $strXMLName = self::DATAFILE;
239
        if (strlen($this->strDataPath) > 0) {
240
            $strXMLName = $this->strDataPath . DIRECTORY_SEPARATOR . self::DATAFILE;
241
        }
242
        $this->oDoc->save($strXMLName);
243
        return true;
244
    }
245
246
    /**
247
     * Return last error occured.
248
     * @return string
249
     */
250
    public function getError() : string
251
    {
252
        return $this->strLastError;
253
    }
254
255
    /**
256
     * Search for the node to the given BIC.
257
     * If the requested BIC ends with 'XXX' and does'n exist, we're lookin
258
     * for an entry without trailing 'XXX'.
259
     * @param string $strBIC
260
     * @return \DOMNode
261
     */
262
    protected function getProviderNode(string $strBIC) : ?\DOMNode
263
    {
264
        $oNode = null;
265
        if ($this->oDoc !== null) {
266
            $oXPath = new \DOMXPath($this->oDoc);
267
            $oNodelist = $oXPath->query("//Provider[@BIC='" . $strBIC . "']");
268
            if (($oNodelist === false || $oNodelist->length === 0) && strlen($strBIC) == 11 && substr($strBIC, 8, 3) == 'XXX') {
269
                $strBIC = substr($strBIC, 0, 8);
270
                $oNodelist = $oXPath->query("//Provider[@BIC='" . $strBIC . "']");
271
            }
272
            if ($oNodelist !== false && $oNodelist->length > 0) {
273
                $oNode = $oNodelist[0];
274
            }
275
        }
276
        return $oNode;
277
    }
278
279
    /**
280
     * Check, if the intervall has expired since last download.
281
     * The intervall can be specified as integer in seconds (according to a unix timestamp)
282
     * or as string representing any `dateinterval´.
283
     * > recommended values are 1..4 weeks (e.g. `'P2W'` for 2 weeks).
284
     * @param int|string|null $interval
285
     * @return bool
286
     * @throws \Exception if $interval cannot be parsed in the \DateInterval constructor
287
     */
288
    protected function hasIntervalExpired($interval = null) : bool
289
    {
290
        if ($interval === null) {
291
            return true;
292
        }
293
        $uxtsLastUpdate = $this->lastUpdated();
294
        if ($uxtsLastUpdate == 0) {
295
            return true;
296
        }
297
        if (is_numeric($interval)) {
298
            // inteval is a timespan in seconds...
299
            return $uxtsLastUpdate + $interval < time();
300
        } else {
301
            $di = new \DateInterval($interval);
302
            $dtLastUpdate = new \DateTime();
303
            $dtLastUpdate->setTimestamp($uxtsLastUpdate);
304
            $dtLastUpdate->add($di);
305
306
            return $dtLastUpdate->getTimestamp() < time();
307
        }
308
    }
309
310
    /**
311
     * Convert to uppercase words.
312
     * Some exceptions are converted to lowercase, some to uppercase and some
313
     * will be replaced by special case...
314
     * @param string $strText
315
     * @return string
316
     */
317
    protected function convToUCWords(string $strText)
318
    {
319
        $aDelimiters = [" ", "-", ".", "'"];
320
        // we use associative array because the 'isset' call works faster than the 'in_array' call
321
        // (And we expect a lot of calls or loops when using the getProviderList function...)
322
        $aToLower = ["a" => 1, "ab" => 1, "de" => 1, "der" => 1, "di" => 1, "do" => 1, "du" => 1, "et" => 1, "for" => 1, "im" => 1, "of" => 1, "on" => 1, "plc" => 1, "s" => 1, "und" => 1, "van" => 1, "von" => 1];
323
        $aToUpper = ["ABC" => 1, "AG" => 1, "BCP" => 1, "BGL" => 1, "BHF" => 1, "BKS" => 1, "BLG" => 1, "BNP" => 1, "CIB" => 1, "GB" => 1, "HSBC" => 1, "KG" => 1, "LGT" => 1, "NV" => 1, "SA" => 1, "UK" => 1, "VR" => 1];
324
        $aReplacement = ['eg' => 'eG', 'gmbh' => 'GmbH'];
325
        /*
326
         * Exceptions in lower case are words you don't want converted
327
         * Exceptions all in upper case are any words you don't want converted to title case
328
         *   but should be converted to upper case, e.g.:
329
         *   king henry viii or king henry Viii should be King Henry VIII
330
         */
331
        $strText = strtolower($strText);
332
        foreach ($aDelimiters as $delimiter) {
333
            $aWords = explode($delimiter, $strText);
334
            $aNewWords = [];
335
            foreach ($aWords as $strWord) {
336
                if (isset($aReplacement[$strWord])) {
337
                    // check replacements
338
                    $strWord = $aReplacement[$strWord];
339
                } elseif (isset($aToUpper[strtoupper($strWord)])) { // (in_array(strtoupper($strWord), $aToUpper)) {
340
                    // check list for any words that should be in upper case
341
                    $strWord = strtoupper($strWord);
342
                } elseif (!isset($aToLower[$strWord])) { // (!in_array($strWord, $aToLower)) {
343
                    // convert to UCFirst
344
                    $strWord = ucfirst($strWord);
345
                }
346
                $aNewWords[] = $strWord;
347
            }
348
            $strText = join($delimiter, $aNewWords);
349
        }//foreach
350
        return $strText;
351
    }
352
}