Files

117 lines
4.0 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support;
/**
* Zustandslose Helfer für IBANs.
*
* Geprüft wird nach ISO 13616 bzw. ISO 7064 (Mod 97-10): Struktur, Länge des Landes und die Prüfziffer.
* Die Prüfziffer ist der wichtige Teil -- sie fängt Zahlendreher und Tippfehler ab, die sonst erst beim
* Rückläufer der Bank auffallen würden. Auf ein Konto, dessen IBAN nur strukturell stimmt, überweist man
* im Zweifel Geld an eine fremde Person.
*/
final class Iban
{
/**
* Länge der IBAN je Ländercode. Vollständig für den SEPA-Raum; alles darüber hinaus fällt auf die
* generische Längenprüfung zurück (siehe isValid()).
*
* @var array<string, int>
*/
private const array LENGTHS = [
'AD' => 24, 'AT' => 20, 'BE' => 16, 'BG' => 22, 'CH' => 21, 'CY' => 28, 'CZ' => 24,
'DE' => 22, 'DK' => 18, 'EE' => 20, 'ES' => 24, 'FI' => 18, 'FR' => 27, 'GB' => 22,
'GI' => 23, 'GR' => 27, 'HR' => 21, 'HU' => 28, 'IE' => 22, 'IS' => 26, 'IT' => 27,
'LI' => 21, 'LT' => 20, 'LU' => 20, 'LV' => 21, 'MC' => 27, 'MT' => 31, 'NL' => 18,
'NO' => 15, 'PL' => 28, 'PT' => 25, 'RO' => 24, 'SE' => 24, 'SI' => 19, 'SK' => 24,
'SM' => 27, 'VA' => 22,
];
/** Leerzeichen raus, Großbuchstaben -- die kanonische Form, die gespeichert wird. */
public static function normalize(string $iban): string
{
return strtoupper(preg_replace('/\s+/', '', $iban) ?? '');
}
public static function isValid(string $iban): bool
{
$iban = self::normalize($iban);
if (preg_match('/^[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}$/', $iban) !== 1) {
return false;
}
$expected = self::LENGTHS[substr($iban, 0, 2)] ?? null;
if ($expected !== null && strlen($iban) !== $expected) {
return false;
}
return self::checksum($iban) === 1;
}
/** In Vierergruppen -- die Schreibweise auf Belegen und Formularen. */
public static function format(string $iban): string
{
return trim(chunk_split(self::normalize($iban), 4, ' '));
}
/**
* Nur Land, Prüfziffer und die letzten vier Stellen -- der Rest wird zu Punkten.
*
* Für Seiten, die über einen Token aus einer Mail erreichbar sind: Die eigene Bankverbindung
* erkennt man daran wieder, wer den Token aus einem fremden Postfach fischt, liest sie aber
* nicht mit.
*/
public static function mask(string $iban): string
{
$iban = self::normalize($iban);
// Zu kurz zum Maskieren wäre auch zu kurz zum Erkennen -- dann lieber alles verdecken.
$masked = strlen($iban) <= 8
? str_repeat('•', max(strlen($iban), 1))
: substr($iban, 0, 4) . str_repeat('•', strlen($iban) - 8) . substr($iban, -4);
return self::group($masked);
}
/**
* In Vierergruppen, zeichen- statt byteweise.
*
* `chunk_split()` zählt Bytes und würde das drei Byte lange „•" mitten durchschneiden -- heraus
* käme Zeichensalat.
*
* @param string $value
*/
private static function group(string $value): string
{
return implode(' ', array_map(
static fn (array $chunk): string => implode('', $chunk),
array_chunk(mb_str_split($value), 4),
));
}
/**
* Mod 97-10: die ersten vier Zeichen ans Ende, Buchstaben durch ihre Position + 9 ersetzen
* (A = 10 … Z = 35), das Ergebnis modulo 97. Eine gültige IBAN ergibt 1.
*
* Der Rest wird stellenweise fortgeschrieben, weil die Zahl sonst jeden Integer sprengt.
*/
private static function checksum(string $iban): int
{
$rearranged = substr($iban, 4) . substr($iban, 0, 4);
$remainder = 0;
foreach (str_split($rearranged) as $char) {
$value = ctype_digit($char) ? $char : (string) (ord($char) - 55);
foreach (str_split($value) as $digit) {
$remainder = ($remainder * 10 + (int) $digit) % 97;
}
}
return $remainder;
}
}