feat: add IBAN MOD-97 validation with country format checks (Closes #26) (#86)

This commit was merged in pull request #86.
This commit is contained in:
2026-04-07 13:20:59 +02:00
parent 9f1dd057db
commit 3d956ddb98
2 changed files with 179 additions and 0 deletions
+18
View File
@@ -8,6 +8,7 @@ use DateTime;
use OCA\Mitgliederverwaltung\Db\Family;
use OCA\Mitgliederverwaltung\Db\FamilyMapper;
use OCA\Mitgliederverwaltung\Db\MemberMapper;
use OCA\Mitgliederverwaltung\Validation\IbanValidator;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\DB\Exception;
@@ -42,6 +43,7 @@ class FamilyService {
*/
public function create(array $data): array {
$this->validateRequiredFields($data);
$this->validateIban($data);
$now = (new DateTime())->format('Y-m-d H:i:s');
@@ -112,6 +114,7 @@ class FamilyService {
* @throws Exception
*/
public function update(int $id, array $data): array {
$this->validateIban($data);
$family = $this->familyMapper->findById($id);
if (isset($data['name'])) {
@@ -225,6 +228,21 @@ class FamilyService {
}
}
/**
* Validate IBAN if provided and non-empty.
*
* @throws ValidationException
*/
private function validateIban(array $data): void {
$iban = $data['ibanEncrypted'] ?? null;
if ($iban !== null && trim($iban) !== '') {
$error = IbanValidator::getError($iban);
if ($error !== null) {
throw new ValidationException($error);
}
}
}
/**
* Build response array with linked members.
*
+161
View File
@@ -0,0 +1,161 @@
<?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)));
}
}