# Copyright (C) 2023-2026 Pain001. All rights reserved.
# SPDX-License-Identifier: Apache-2.0 OR MIT
#
# Licensed under either of the Apache License, Version 2.0 or the MIT
# License, at your option. You may not use this file except in
# compliance with one of those licences. Copies are provided in
# LICENSE-APACHE and LICENSE-MIT.
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the Licences is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied. See the applicable Licence for the specific language
# governing permissions and limitations.
"""IBAN (International Bank Account Number) validator.
This module implements ISO 7064 mod-97-10 checksum validation for IBANs.
It validates both format and checksum integrity before XML generation,
significantly reducing bank rejection rates.
Example:
>>> from pain001.validation.iban_validator import validate_iban
>>>
>>> # Valid IBAN
>>> is_valid, error = validate_iban("DE89370400440532013000")
>>> assert is_valid
>>>
>>> # Invalid checksum
>>> is_valid, error = validate_iban("DE00370400440532013000")
>>> assert not is_valid
>>> assert "checksum" in error.lower()
Standards:
- ISO 13616:2020 - International Bank Account Number (IBAN)
- ISO 7064 - Check digit mod-97-10 algorithm
"""
import functools
try: # pragma: no cover - presence depends on the install
import pain001_fast as _fast # type: ignore[import-not-found]
_FAST_AVAILABLE = True
except ImportError: # pragma: no cover - presence depends on the install
_fast = None
_FAST_AVAILABLE = False
from pain001.exceptions import InvalidIBANError
# SEPA IBAN lengths by country code (ISO 13616)
IBAN_LENGTHS = {
"AD": 24, # Andorra
"AE": 23, # United Arab Emirates
"AL": 28, # Albania
"AT": 20, # Austria
"AZ": 28, # Azerbaijan
"BA": 20, # Bosnia and Herzegovina
"BE": 16, # Belgium
"BG": 22, # Bulgaria
"BH": 22, # Bahrain
"BR": 29, # Brazil
"BY": 28, # Belarus
"CH": 21, # Switzerland
"CR": 22, # Costa Rica
"CY": 28, # Cyprus
"CZ": 24, # Czech Republic
"DE": 22, # Germany
"DK": 18, # Denmark
"DO": 28, # Dominican Republic
"EE": 20, # Estonia
"EG": 29, # Egypt
"ES": 24, # Spain
"FI": 18, # Finland
"FO": 18, # Faroe Islands
"FR": 27, # France
"GB": 22, # United Kingdom
"GE": 22, # Georgia
"GI": 23, # Gibraltar
"GL": 18, # Greenland
"GR": 27, # Greece
"GT": 28, # Guatemala
"HR": 21, # Croatia
"HU": 28, # Hungary
"IE": 22, # Ireland
"IL": 23, # Israel
"IQ": 23, # Iraq
"IS": 26, # Iceland
"IT": 27, # Italy
"JO": 30, # Jordan
"KW": 30, # Kuwait
"KZ": 20, # Kazakhstan
"LB": 28, # Lebanon
"LC": 32, # Saint Lucia
"LI": 21, # Liechtenstein
"LT": 20, # Lithuania
"LU": 20, # Luxembourg
"LV": 21, # Latvia
"MC": 27, # Monaco
"MD": 24, # Moldova
"ME": 22, # Montenegro
"MK": 19, # North Macedonia
"MR": 27, # Mauritania
"MT": 31, # Malta
"MU": 30, # Mauritius
"NL": 18, # Netherlands
"NO": 15, # Norway
"PK": 24, # Pakistan
"PL": 28, # Poland
"PS": 29, # Palestine
"PT": 25, # Portugal
"QA": 29, # Qatar
"RO": 24, # Romania
"RS": 22, # Serbia
"SA": 24, # Saudi Arabia
"SE": 24, # Sweden
"SI": 19, # Slovenia
"SK": 24, # Slovakia
"SM": 27, # San Marino
"TN": 24, # Tunisia
"TR": 26, # Turkey
"UA": 29, # Ukraine
"VA": 22, # Vatican City
"VG": 24, # British Virgin Islands
"XK": 20, # Kosovo
}
[docs]
@functools.lru_cache(maxsize=16384)
def validate_iban_checksum(iban: str) -> tuple[bool, str]:
"""Validate IBAN checksum using ISO 7064 mod-97-10 algorithm.
Algorithm:
1. Move first 4 characters to end: "DE89370400440532013000" → "370400440532013000DE89"
2. Replace letters with numbers (A=10, B=11, ..., Z=35)
3. Calculate mod 97 of resulting number
4. Valid IBAN has mod 97 = 1
Args:
iban: IBAN string to validate (with or without spaces).
Returns:
Tuple of (is_valid, error_message).
Example:
>>> is_valid, error = validate_iban_checksum("DE89370400440532013000")
>>> assert is_valid
"""
# Remove spaces
iban_clean = iban.replace(" ", "").replace("-", "").upper()
# Move first 4 characters to end
rearranged = iban_clean[4:] + iban_clean[:4]
# Replace letters with numbers (A=10, B=11, ..., Z=35)
numeric_iban = ""
for char in rearranged:
if char.isdigit():
numeric_iban += char
elif char.isalpha():
# A=10, B=11, ..., Z=35
numeric_iban += str(ord(char) - ord("A") + 10)
else:
return False, f"Invalid character in IBAN: {char}"
# Calculate mod 97
try:
remainder = int(numeric_iban) % 97
except (
ValueError
) as e: # pragma: no cover - only digits remain after the letter mapping
return (
False,
f"Invalid numeric IBAN representation: {e}",
)
if remainder != 1:
return (
False,
f"IBAN checksum validation failed (mod 97 = {remainder}, expected 1)",
)
return True, ""
[docs]
def validate_iban(
iban: str, field: str | None = None, strict: bool = True
) -> tuple[bool, str]:
"""Validate IBAN format and checksum.
This is the main entry point for IBAN validation. It performs both
format validation and ISO 7064 mod-97-10 checksum verification.
Args:
iban: IBAN string to validate.
field: Optional field name for error reporting.
strict: If True, raise InvalidIBANError on failure. If False, return tuple.
Returns:
Tuple of (is_valid, error_message). If strict=True and invalid, raises exception.
Raises:
InvalidIBANError: If strict=True and IBAN is invalid.
Example:
>>> # Non-strict mode (returns tuple)
>>> is_valid, error = validate_iban("DE89370400440532013000", strict=False)
>>> assert is_valid
>>>
>>> # Strict mode (raises exception on error)
>>> try:
... validate_iban("DE00370400440532013000", field="debtor_account")
... except InvalidIBANError as e:
... print(f"Invalid: {e}")
"""
# Format validation
is_valid, error = validate_iban_format(iban)
if not is_valid:
if strict:
raise InvalidIBANError(
message=error,
iban=iban,
field=field,
reason="Invalid IBAN format",
)
return False, error
# Checksum validation
is_valid, error = validate_iban_checksum(iban)
if not is_valid:
if strict:
raise InvalidIBANError(
message=error,
iban=iban,
field=field,
reason="Invalid IBAN checksum (ISO 7064 mod-97-10)",
)
return False, error
return True, ""
[docs]
def validate_iban_safe(iban: str, field: str | None = None) -> bool:
"""Validate IBAN and return True/False (never raises exceptions).
This is a convenience wrapper for validate_iban with strict=False.
Useful when you only need a boolean result without error details.
When ``pain001-fast`` is available, delegates to zero-allocation native mod-97.
Args:
iban: IBAN string to validate.
field: Optional field name (unused, for API compatibility).
Returns:
True if IBAN is valid, False otherwise.
Example:
>>> if validate_iban_safe("DE89370400440532013000"):
... print("Valid IBAN")
"""
if _FAST_AVAILABLE and _fast is not None:
return bool(_fast.validate_iban(iban))
is_valid, _ = validate_iban(iban, field=field, strict=False)
return is_valid