diff --git a/pydantic_extra_types/iban.py b/pydantic_extra_types/iban.py new file mode 100644 index 0000000..1dd4c1c --- /dev/null +++ b/pydantic_extra_types/iban.py @@ -0,0 +1,207 @@ +"""The `pydantic_extra_types.iban` module provides functionality to receive and validate IBAN. + +IBAN (International Bank Account Number) is an internationally agreed system of identifying +bank accounts across national borders to facilitate the communication and processing of +cross border transactions. For more information, see the +`Wikipedia page `_. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import GetCoreSchemaHandler +from pydantic_core import PydanticCustomError, core_schema + +# IBAN lengths per country code (ISO 3166-1 alpha-2) +# Source: https://www.swift.com/standards/data-standards/iban +IBAN_COUNTRY_CODE_LENGTH: dict[str, int] = { + '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, + 'EG': 29, + 'SV': 28, + '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, + 'SD': 18, + 'SE': 24, + 'CH': 21, + 'TN': 24, + 'TR': 26, + 'UA': 29, + 'AE': 23, + 'GB': 22, + 'VA': 22, + 'VG': 24, +} + + +def _validate_iban_check_digits(iban: str) -> bool: + """Validate IBAN check digits using the MOD-97 algorithm (ISO 7064). + + The algorithm: + 1. Move the first four characters to the end + 2. Convert letters to numbers (A=10, B=11, ..., Z=35) + 3. Compute remainder of the resulting number divided by 97 + 4. If remainder is 1, the IBAN is valid + """ + rearranged = iban[4:] + iban[:4] + numeric = '' + for char in rearranged: + if char.isdigit(): + numeric += char + else: + numeric += str(ord(char) - ord('A') + 10) + return int(numeric) % 97 == 1 + + +class IBAN(str): + """Represents an International Bank Account Number (IBAN). + + ```python + from pydantic import BaseModel + + from pydantic_extra_types.iban import IBAN + + + class BankAccount(BaseModel): + iban: IBAN + + + account = BankAccount(iban='GB29NWBK60161331926819') + print(account) + # > iban='GB29NWBK60161331926819' + ``` + """ + + @classmethod + def __get_pydantic_core_schema__( + cls, + source: type[Any], + handler: GetCoreSchemaHandler, + ) -> core_schema.CoreSchema: + return core_schema.with_info_before_validator_function( + cls._validate, + core_schema.str_schema(), + ) + + @classmethod + def _validate(cls, __input_value: str, _: Any) -> IBAN: + # Remove spaces and convert to uppercase + iban = __input_value.replace(' ', '').upper() + + # Check minimum length + if len(iban) < 5: + raise PydanticCustomError('iban_invalid_length', 'Invalid IBAN: too short') + + # Check that first two characters are letters (country code) + country_code = iban[:2] + if not country_code.isalpha(): + raise PydanticCustomError( + 'iban_invalid_country_code', + 'Invalid IBAN: country code must be two letters', + ) + + # Validate country code and length + expected_length = IBAN_COUNTRY_CODE_LENGTH.get(country_code) + if expected_length is None: + raise PydanticCustomError( + 'iban_invalid_country_code', + 'Invalid IBAN: unknown country code {country_code}', + {'country_code': country_code}, + ) + + if len(iban) != expected_length: + raise PydanticCustomError( + 'iban_invalid_length', + 'Invalid IBAN: expected {expected_length} characters for {country_code}, got {actual_length}', + { + 'expected_length': expected_length, + 'country_code': country_code, + 'actual_length': len(iban), + }, + ) + + # Check that remaining characters are alphanumeric + if not iban[2:].isalnum(): + raise PydanticCustomError( + 'iban_invalid_characters', + 'Invalid IBAN: must contain only alphanumeric characters', + ) + + # Validate check digits (positions 3-4 must be digits) + if not iban[2:4].isdigit(): + raise PydanticCustomError( + 'iban_invalid_check_digits', + 'Invalid IBAN: check digits must be numeric', + ) + + # Validate using MOD-97 algorithm + if not _validate_iban_check_digits(iban): + raise PydanticCustomError( + 'iban_invalid_checksum', + 'Invalid IBAN: checksum validation failed', + ) + + return cls(iban) diff --git a/tests/test_iban.py b/tests/test_iban.py new file mode 100644 index 0000000..e556760 --- /dev/null +++ b/tests/test_iban.py @@ -0,0 +1,57 @@ +import pytest +from pydantic import BaseModel, ValidationError + +from pydantic_extra_types.iban import IBAN + + +class BankAccount(BaseModel): + iban: IBAN + + +# Valid IBANs from various countries +@pytest.mark.parametrize( + 'iban', + [ + 'GB29NWBK60161331926819', + 'DE89370400440532013000', + 'FR7630006000011234567890189', + 'ES9121000418450200051332', + 'IT60X0542811101000000123456', + 'NL91ABNA0417164300', + 'BE68539007547034', + 'CH9300762011623852957', + 'AT611904300234573201', + 'SE4550000000058398257466', + 'NO9386011117947', + 'DK5000400440116243', + 'PL61109010140000071219812874', + # With spaces (should be normalized) + 'GB29 NWBK 6016 1331 9268 19', + # Lowercase (should be normalized) + 'gb29nwbk60161331926819', + ], +) +def test_valid_iban(iban: str) -> None: + account = BankAccount(iban=iban) + assert isinstance(account.iban, str) + # Should be stored uppercase without spaces + assert ' ' not in account.iban + assert account.iban == account.iban.upper() + + +@pytest.mark.parametrize( + 'iban', + [ + '', # empty + 'GB', # too short + 'XX29NWBK60161331926819', # unknown country code + 'GB00NWBK60161331926819', # invalid check digits (wrong checksum) + 'GB29NWBK6016133192681', # wrong length for GB + 'GB29NWBK601613319268199', # wrong length for GB + '1234567890123456', # no country code + 'GB29NWBK6016133192681!', # invalid character + ], +) +def test_invalid_iban(iban: str) -> None: + with pytest.raises(ValidationError): + BankAccount(iban=iban)