From 4541f55c870820122c5fa04333eb6ac63b1e3a18 Mon Sep 17 00:00:00 2001 From: Alex Schickedanz Date: Mon, 20 Jan 2025 19:33:54 +0100 Subject: [PATCH] Add support for pain.001.001.09 and pain.008.01.08. --- CHANGE_LOG.md | 16 +++++- README.md | 22 +++++-- src/SepaUtilities.php | 112 ++++++++++++++++++++++++++++-------- tests/SepaUtilitiesTest.php | 6 ++ 4 files changed, 127 insertions(+), 29 deletions(-) diff --git a/CHANGE_LOG.md b/CHANGE_LOG.md index 864fd5e..f02979b 100644 --- a/CHANGE_LOG.md +++ b/CHANGE_LOG.md @@ -1,6 +1,18 @@ -Sephpa - Change Log +SepaUtilities - Change Log =============== +## 2.0.0 - Jan 20, 24 + - Minimal required PHP version is now 8.1 as it is the currently oldest supported version. + - **Add support for pain.001.001.09 and pain.008.001.08.** + - `check()` supports new fields: `reqdexctndttm`, subfields of `pstladr` (`bldgnm`,`bldgnb`, `twnnm`, `twnlctnnm`, `dstrctnm`, + `ctrysubdvsn`, `pstbx`, `pstcd`, `dept`, `subdept`, `strtnm`, `flr`, `room`). All fields are case-insensitive. + - Add missing parameter and return types. + - `getDate()`, `getDateWithOffset()` do not throw anymore to simplify checking for errors. + - `checkAndSanitizeAll()` now returns an array instead of a string in case of an error. + +# 1.3.4 - Sep 8, 21 +- Add key `orgid_sm` to the check and sanitize functions. + ## 1.3.3 - Mar 4, 21 - Fixed typo in `ultmtCdtr` (was `ultmtCdrt` before). The old version still works for backward compatibility. @@ -84,7 +96,7 @@ and was never officially supported anyway. ## 1.0.7 - Dec 18, '14 - added `checkInput()`,`sanitizeInput()` and `checkAndSanitizeInput()` to validate user inputs. -With this functions it is not required to check first, if an array index like `$_POST['key1']['key2']` +With these functions it is not required to check first, if an array index like `$_POST['key1']['key2']` exists, before validating the values. ## 1.0.6 - Oct 24, '14 diff --git a/README.md b/README.md index ef21677..af3cf3e 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ SepaUtilities is a PHP class to check and sanitize inputs used in SEPA files such as IBAN numbers, creditor identifiers, names and other text. ## PHP Versions -SepaUtilities supports PHP >= 7.2 including PHP 8. +SepaUtilities supports PHP >= 8.1. ## Installation @@ -22,7 +22,7 @@ You can get SepaUtilities via Composer. Just add ```json { "require": { - "abcaeffchen/sepa-utilities": "^1.3" + "abcaeffchen/sepa-utilities": "^2.0" } } ``` @@ -43,7 +43,7 @@ and changes all letters to upper case. - `isNationalTransaction($iban1,$iban2)`: Checks if both IBANs are belong to the same country. - `checkCharset($str)`: Checks if the string contains only allowed characters. - `check($field, $input, $options, $version)`: Checks if the input fits the field. This function also does little -format changes, e.g. correcting letter case. Possible field values are: +format changes, e.g. correcting letter case. Possible field values are (case-insensitive): - `initgpty`: Initiating Party - `initgptyid`: Initiating Party ID - `msgid`: Message ID @@ -64,7 +64,21 @@ format changes, e.g. correcting letter case. Possible field values are: - `seqtp`: Sequence Type - `lclinstrm`: Local Instrument - `ctry`: Two-letter country code, used in postal address - - `adrline`: An address line, or an array of at most two address lines + - Subfield of `pstlAdr` + - `adrline`: An address line, or an array of at most two address lines + - `bldgnm`: Building name + - `bldgnb`: Building number + - `twnnm`: Town name + - `twnlctnnm`: Town location name (within a town) + - `dstrctnm`: District name + - `ctrysubdvsn`: country subdivision, e.g. state, region, county + - `pstbx`: Postbox + - `pstcd`: Postal code + - `dept`: Department + - `subdept`: Subdepartment + - `strtnm`: Street name + - `flr`: Floor + - `room`: Room The `$options` take an array ### Sanitizing diff --git a/src/SepaUtilities.php b/src/SepaUtilities.php index d8faf6c..a854c64 100644 --- a/src/SepaUtilities.php +++ b/src/SepaUtilities.php @@ -27,13 +27,13 @@ function easterDate(int $year): DateTime { $G = $year % 19; - $C = (int)($year / 100); - $H = (int)($C - (int)($C / 4) - (int)((8 * $C + 13) / 25) + 19 * $G + 15) % 30; - $I = $H - (int)($H / 28) * (1 - (int)($H / 28) * (int)(29 / ($H + 1)) * (int)((21 - $G) / 11)); - $J = ($year + (int)($year / 4) + $I + 2 - $C + (int)($C / 4)) % 7; + $C = (int) ($year / 100); + $H = (int) ($C - (int) ($C / 4) - (int) ((8 * $C + 13) / 25) + 19 * $G + 15) % 30; + $I = $H - (int) ($H / 28) * (1 - (int) ($H / 28) * (int) (29 / ($H + 1)) * (int) ((21 - $G) / 11)); + $J = ($year + (int) ($year / 4) + $I + 2 - $C + (int) ($C / 4)) % 7; $L = $I - $J; - $m = 3 + (int)(($L + 40) / 44); - $d = $L + 28 - 31 * ((int)($m / 4)); + $m = 3 + (int) (($L + 40) / 44); + $d = $L + 28 - 31 * ((int) ($m / 4)); return DateTime::createFromFormat('Y-n-j', $year . '-' . $m . '-' . $d); } @@ -492,6 +492,16 @@ public static function sanitizeDateFormat(string $input, array $preferredFormats * @return string|false Returns $input if it is valid and false else. */ public static function checkCreateDateTime(string $input): string|bool + { + return self::checkDateTimeFormat($input); + } + + /** + * Checks if the input has the format 'Y-m-d\TH:i:s' + * @param string $input + * @return string|false Returns $input if it is valid and false else. + */ + private static function checkDateTimeFormat(string $input): string|bool { $dateObj = DateTime::createFromFormat('Y-m-d\TH:i:s', $input); if($dateObj !== false && $input === $dateObj->format('Y-m-d\TH:i:s')) @@ -620,10 +630,10 @@ private static function dateIsTargetDay(DateTime $date): bool return false; $year = $date->format('Y'); - $easter = easterDate((int)$year); // contains Easter Sunday + $easter = easterDate((int) $year); // contains Easter Sunday // date_modify does not throw in contrast to Date->modify. The date string is static so exception handling is not needed - $goodFriday = date_modify($easter, '-2 days')->format('m-d'); // $easter contains now Good Friday - $easterMonday = date_modify($easter, '+3 days')->format('m-d'); // $easter contains now Easter Monday + $goodFriday = date_modify($easter, '-2 days')->format('m-d'); // $easter contains now Good Friday + $easterMonday = date_modify($easter, '+3 days')->format('m-d'); // $easter contains now Easter Monday if($day === $goodFriday || $day === $easterMonday) return false; @@ -657,8 +667,10 @@ private static function getValFromMultiDimInput(array &$input, int|string|array * 'orgnlmndtid','mndtid','initgpty','cdtr','dbtr','orgnlcdtrschmeid_nm', * 'ultmtcdtr','ultmtdbtr','rmtinf','orgnldbtracct_iban','iban','bic', * 'ccy','amendment', 'btchbookg','instdamt','seqtp','lclinstrm', - * 'elctrncsgntr','reqdexctndt','purp','ctgypurp','orgnldbtragt', 'adrline' - * 'ctry', 'dbtrpstladr', 'cdtrpstladr', 'pstladr', 'orgid_id', 'orgid_sm' + * 'elctrncsgntr','reqdexctndt','reqdexctndttm','purp','ctgypurp','orgnldbtragt', 'adrline' + * 'ctry', 'dbtrpstladr', 'cdtrpstladr', 'pstladr', 'orgid_id', 'orgid_sm', `bldgnm`,`bldgnb`, + * `twnnm`, `twnlctnnm`, `dstrctnm`, `ctrysubdvsn`, `pstbx`, `pstcd`, `dept`, `subdept`, + * `strtnm`, `flr`, `room` * @param mixed $input * @param ?array $options See `checkBIC()`, `checkIBAN()` and `checkLocalInstrument()` for * details. In addition one can use the key `version`, which is relevant @@ -688,6 +700,10 @@ public static function check(string $field, mixed $input, ?array $options = null || $version === self::SEPA_PAIN_008_001_02_GBIC ? self::checkRestrictedIdentificationSEPA1($input) : self::checkRestrictedIdentificationSEPA2($input); + case 'bldgnb': + case 'pstbx': + case 'pstcd': + return self::checkText($input, 16); case 'initgptyid': if($version === self::SEPA_PAIN_008_001_02_AUSTRIAN_003) return false; // not supported on this version @@ -701,9 +717,12 @@ public static function check(string $field, mixed $input, ?array $options = null case 'ultmtdbtrid': case 'orgid_sm': case 'orgid_id': - return (self::checkLength($input, self::TEXT_LENGTH_VERY_SHORT) - && self::checkCharset($input)) - ? $input : false; + case 'bldgnm': + case 'twnnm': + case 'twnlctnnm': + case 'dstrctnm': + case 'ctrysubdvsn': + return self::checkText($input, self::TEXT_LENGTH_VERY_SHORT); case 'adrline': if(is_array($input)) { @@ -721,13 +740,14 @@ public static function check(string $field, mixed $input, ?array $options = null case 'ultmtcdrt': // deprecated, just here for backwards compatibility case 'ultmtcdtr': case 'ultmtdbtr': - return (self::checkLength($input, self::TEXT_LENGTH_SHORT) - && self::checkCharset($input)) - ? $input : false; + case 'dept': + case 'subdept': + case 'strtnm': + case 'flr': + case 'room': + return self::checkText($input, self::TEXT_LENGTH_SHORT); case 'rmtinf': - return (self::checkLength($input, self::TEXT_LENGTH_LONG) - && self::checkCharset($input)) - ? $input : false; + return self::checkText($input, self::TEXT_LENGTH_LONG); case 'orgnldbtracct_iban': case 'iban': return self::checkIBAN($input, $options); @@ -747,11 +767,13 @@ public static function check(string $field, mixed $input, ?array $options = null case 'lclinstrm': return self::checkLocalInstrument($input, $options); case 'elctrncsgntr': - return (self::checkLength($input, 1025) && self::checkCharset($input)) ? $input : false; + return self::checkText($input, 1025); case 'dtofsgntr': case 'reqdcolltndt': case 'reqdexctndt': return self::checkDateFormat($input); + case 'reqdexctndttm': + return self::checkDateTimeFormat($input); case 'purp': return self::checkPurpose($input); case 'ctgypurp': @@ -762,6 +784,39 @@ public static function check(string $field, mixed $input, ?array $options = null case 'ctry': return self::checkCountryCode($input); case 'pstladr': + if(in_array($version, [self::SEPA_PAIN_001_001_09, self::SEPA_PAIN_008_001_08], true)) + { + if($input === null) return true; + if(!is_array($input)) return false; + + $adrKeys = array_map(fn($k) => strtolower($k), array_keys($input)); + foreach(['ctry', 'twnnm'] as $reqKey) + { + if(!in_array($reqKey, $adrKeys, true)) return false; + } + + foreach($input as $key => &$value) + { + if(!in_array(strtolower($key), ['ctry', + 'bldgnm', + 'twnnm', + 'twnlctnnm', + 'dstrctnm', + 'ctrysubdvsn', + 'bldgnb', + 'pstbx', + 'pstcd', + 'dept', + 'subdept', + 'strtnm', + 'flr', + 'room'], true)) + return false; + + $value = self::check($key, $value, $options); + } + return in_array(false, $input, true) ? false : $input; + } // if not => fall through to cdtrpstladr case 'cdtrpstladr': case 'dbtrpstladr': if(is_array($input) && count($input) > 0 && count($input) <= 2) @@ -962,6 +1017,7 @@ public static function checkRequiredCollectionKeys(array $inputs, int $version): case self::SEPA_PAIN_001_002_03: $requiredKeys = ['pmtInfId', 'dbtr', 'iban', 'bic']; break; + case self::SEPA_PAIN_001_001_09: case self::SEPA_PAIN_001_001_03: case self::SEPA_PAIN_001_001_03_GBIC: case self::SEPA_PAIN_001_001_03_CH_02: @@ -971,6 +1027,7 @@ public static function checkRequiredCollectionKeys(array $inputs, int $version): case self::SEPA_PAIN_008_002_02: $requiredKeys = ['pmtInfId', 'lclInstrm', 'seqTp', 'cdtr', 'iban', 'bic', 'ci']; break; + case self::SEPA_PAIN_008_001_08: case self::SEPA_PAIN_008_001_02: case self::SEPA_PAIN_008_001_02_GBIC: case self::SEPA_PAIN_008_003_02: @@ -998,6 +1055,7 @@ public static function checkRequiredPaymentKeys(array $inputs, int $version): bo case self::SEPA_PAIN_001_002_03: $requiredKeys = ['pmtId', 'instdAmt', 'iban', 'bic', 'cdtr']; break; + case self::SEPA_PAIN_001_001_09: case self::SEPA_PAIN_001_001_03: case self::SEPA_PAIN_001_001_03_GBIC: case self::SEPA_PAIN_001_001_03_CH_02: @@ -1007,6 +1065,7 @@ public static function checkRequiredPaymentKeys(array $inputs, int $version): bo case self::SEPA_PAIN_008_002_02: $requiredKeys = ['pmtId', 'instdAmt', 'mndtId', 'dtOfSgntr', 'dbtr', 'iban', 'bic']; break; + case self::SEPA_PAIN_008_001_08: case self::SEPA_PAIN_008_001_02: case self::SEPA_PAIN_008_001_02_GBIC: case self::SEPA_PAIN_008_003_02: @@ -1204,7 +1263,13 @@ public static function replaceSpecialChars(string $str, int $flags = 0): string private static function checkCharset(string $str): bool { - return (boolean)preg_match('#^[a-zA-Z0-9/\-?:().,\'+ ]*$#', $str); + return (boolean) preg_match('#^[a-zA-Z0-9/\-?:().,\'+ ]*$#', $str); + } + + private + static function checkText(string $str, int $length): string|bool + { + return (self::checkLength($str, $length) && self::checkCharset($str)) ? $str : false; } /** @@ -1262,6 +1327,7 @@ private static function checkLocalInstrument(string $input, ?array $options = nu switch($version) // fall-through's are on purpose { + case self::SEPA_PAIN_008_001_08: case self::SEPA_PAIN_008_001_02: case self::SEPA_PAIN_008_001_02_GBIC: case self::SEPA_PAIN_008_002_02: @@ -1374,7 +1440,7 @@ public static function version2string(int $version): string|bool */ public static function version2transactionType(int $version): int|bool { - $type = (int)((string)$version)[0]; + $type = (int) ((string) $version)[0]; if($type === self::SEPA_TRANSACTION_TYPE_CT) return self::SEPA_TRANSACTION_TYPE_CT; diff --git a/tests/SepaUtilitiesTest.php b/tests/SepaUtilitiesTest.php index 73109b9..a72c111 100644 --- a/tests/SepaUtilitiesTest.php +++ b/tests/SepaUtilitiesTest.php @@ -375,6 +375,12 @@ public function testCheckDateFormat() static::assertFalse(SepaUtilities::checkCreateDateTime('19.10.2014')); } + public function testCheckDateTimeFormat() + { + static::assertSame('2014-10-19', SepaUtilities::check('dtofsgntr', '2014-10-19')); + static::assertFalse(SepaUtilities::checkCreateDateTime('19.10.2014')); + } + public function testCrossIbanBicCheck() { // IBAN and BIC is not validated. It is only checked if the country codes belong together.