162 lines
5.1 KiB
PHP
162 lines
5.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace OCA\Mitgliederverwaltung\Validation;
|
|
|
|
/**
|
|
* IBAN validation with MOD-97 checksum and country-specific format checks.
|
|
*
|
|
* Supports all European IBAN formats with special focus on DE (Germany).
|
|
*
|
|
* Part of Issue #26.
|
|
*/
|
|
class IbanValidator {
|
|
|
|
/**
|
|
* Country-specific IBAN lengths.
|
|
* Source: ISO 13616 / SWIFT IBAN Registry.
|
|
*
|
|
* @var array<string, int>
|
|
*/
|
|
private const COUNTRY_LENGTHS = [
|
|
'AL' => 28, 'AD' => 24, 'AT' => 20, 'AZ' => 28, 'BH' => 22,
|
|
'BY' => 28, 'BE' => 16, 'BA' => 20, 'BR' => 29, 'BG' => 22,
|
|
'CR' => 22, 'HR' => 21, 'CY' => 28, 'CZ' => 24, 'DK' => 18,
|
|
'DO' => 28, 'TL' => 23, 'EE' => 20, 'FO' => 18, 'FI' => 18,
|
|
'FR' => 27, 'GE' => 22, 'DE' => 22, 'GI' => 23, 'GR' => 27,
|
|
'GL' => 18, 'GT' => 28, 'HU' => 28, 'IS' => 26, 'IQ' => 23,
|
|
'IE' => 22, 'IL' => 23, 'IT' => 27, 'JO' => 30, 'KZ' => 20,
|
|
'XK' => 20, 'KW' => 30, 'LV' => 21, 'LB' => 28, 'LI' => 21,
|
|
'LT' => 20, 'LU' => 20, 'MK' => 19, 'MT' => 31, 'MR' => 27,
|
|
'MU' => 30, 'MC' => 27, 'MD' => 24, 'ME' => 22, 'NL' => 18,
|
|
'NO' => 15, 'PK' => 24, 'PS' => 29, 'PL' => 28, 'PT' => 25,
|
|
'QA' => 29, 'RO' => 24, 'LC' => 32, 'SM' => 27, 'ST' => 25,
|
|
'SA' => 24, 'RS' => 22, 'SC' => 31, 'SK' => 24, 'SI' => 19,
|
|
'ES' => 24, 'SE' => 24, 'CH' => 21, 'TN' => 24, 'TR' => 26,
|
|
'UA' => 29, 'AE' => 23, 'GB' => 22, 'VG' => 24,
|
|
];
|
|
|
|
/**
|
|
* Validate an IBAN string.
|
|
*
|
|
* @param string $iban The IBAN to validate
|
|
* @return bool True if the IBAN is valid
|
|
*/
|
|
public static function isValid(string $iban): bool {
|
|
$cleaned = self::clean($iban);
|
|
|
|
if (strlen($cleaned) < 15 || strlen($cleaned) > 34) {
|
|
return false;
|
|
}
|
|
|
|
// Must start with 2 letters + 2 digits
|
|
if (!preg_match('/^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/', $cleaned)) {
|
|
return false;
|
|
}
|
|
|
|
// Check country-specific length
|
|
$country = substr($cleaned, 0, 2);
|
|
if (isset(self::COUNTRY_LENGTHS[$country])) {
|
|
if (strlen($cleaned) !== self::COUNTRY_LENGTHS[$country]) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// MOD-97 checksum
|
|
return self::mod97Check($cleaned);
|
|
}
|
|
|
|
/**
|
|
* Get a human-readable validation error for an invalid IBAN.
|
|
*
|
|
* @param string $iban The IBAN to check
|
|
* @return string|null Error message in German, or null if valid
|
|
*/
|
|
public static function getError(string $iban): ?string {
|
|
$cleaned = self::clean($iban);
|
|
|
|
if (strlen($cleaned) === 0) {
|
|
return 'IBAN darf nicht leer sein.';
|
|
}
|
|
|
|
if (strlen($cleaned) < 15) {
|
|
return 'IBAN ist zu kurz (mindestens 15 Zeichen).';
|
|
}
|
|
|
|
if (strlen($cleaned) > 34) {
|
|
return 'IBAN ist zu lang (maximal 34 Zeichen).';
|
|
}
|
|
|
|
if (!preg_match('/^[A-Z]{2}/', $cleaned)) {
|
|
return 'IBAN muss mit einem Laendercode beginnen (z.B. DE).';
|
|
}
|
|
|
|
if (!preg_match('/^[A-Z]{2}[0-9]{2}/', $cleaned)) {
|
|
return 'IBAN muss nach dem Laendercode zwei Pruefziffern enthalten.';
|
|
}
|
|
|
|
if (!preg_match('/^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/', $cleaned)) {
|
|
return 'IBAN darf nur Buchstaben und Ziffern enthalten.';
|
|
}
|
|
|
|
$country = substr($cleaned, 0, 2);
|
|
if (isset(self::COUNTRY_LENGTHS[$country])) {
|
|
$expected = self::COUNTRY_LENGTHS[$country];
|
|
if (strlen($cleaned) !== $expected) {
|
|
return sprintf(
|
|
'IBAN fuer %s muss genau %d Zeichen lang sein (aktuell: %d).',
|
|
$country,
|
|
$expected,
|
|
strlen($cleaned)
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!self::mod97Check($cleaned)) {
|
|
return 'IBAN-Pruefsumme ist ungueltig.';
|
|
}
|
|
|
|
return null; // Valid
|
|
}
|
|
|
|
/**
|
|
* Perform MOD-97 checksum validation per ISO 13616.
|
|
*
|
|
* 1. Move first 4 characters to end
|
|
* 2. Convert letters to numbers (A=10, B=11, ..., Z=35)
|
|
* 3. Compute number modulo 97
|
|
* 4. Result must equal 1
|
|
*/
|
|
private static function mod97Check(string $iban): bool {
|
|
// Move first 4 chars (country + check digits) to end
|
|
$rearranged = substr($iban, 4) . substr($iban, 0, 4);
|
|
|
|
// Convert letters to numbers
|
|
$numericStr = '';
|
|
for ($i = 0; $i < strlen($rearranged); $i++) {
|
|
$char = $rearranged[$i];
|
|
if ($char >= 'A' && $char <= 'Z') {
|
|
$numericStr .= (string)(ord($char) - 55);
|
|
} else {
|
|
$numericStr .= $char;
|
|
}
|
|
}
|
|
|
|
// Process in chunks to avoid integer overflow
|
|
$remainder = 0;
|
|
for ($i = 0; $i < strlen($numericStr); $i++) {
|
|
$remainder = ($remainder * 10 + (int)$numericStr[$i]) % 97;
|
|
}
|
|
|
|
return $remainder === 1;
|
|
}
|
|
|
|
/**
|
|
* Strip all whitespace and convert to uppercase.
|
|
*/
|
|
private static function clean(string $iban): string {
|
|
return strtoupper(preg_replace('/\s+/', '', trim($iban)));
|
|
}
|
|
}
|