2cf433a01c
- Contact model: company, role (kaeufer/verkaeufer/beide), soft-delete - ContactPerson model: 1:N relation with CASCADE delete - USt-IdNr. validation: DE + 10 EU countries - Contact CRUD: 7 API endpoints with RBAC, search/filter/paginate - Frontend: ContactList, ContactForm, ContactDetail with EU/Inland toggle - 73 backend tests (91% coverage), 20 frontend tests
115 lines
3.9 KiB
Python
115 lines
3.9 KiB
Python
"""USt-IdNr. (VAT ID) format validation for DE and common EU countries.
|
|
|
|
Provides basic regex-based format validation. Does NOT perform live API verification.
|
|
"""
|
|
|
|
import re
|
|
|
|
# Country-specific VAT ID regex patterns.
|
|
# Each pattern validates the format after the 2-letter country code prefix.
|
|
_VAT_PATTERNS: dict[str, re.Pattern[str]] = {
|
|
# DE: DE + 9 digits (e.g. DE123456789)
|
|
"DE": re.compile(r"^DE\d{9}$"),
|
|
# AT: AT + U + 8 digits (e.g. ATU12345678)
|
|
"AT": re.compile(r"^ATU\d{8}$"),
|
|
# FR: FR + 2 alphanumeric + 9 digits (e.g. FRAB123456789)
|
|
"FR": re.compile(r"^FR[A-Za-z0-9]{2}\d{9}$"),
|
|
# NL: NL + 9 digits + B + 2 digits (e.g. NL123456789B01)
|
|
"NL": re.compile(r"^NL\d{9}B\d{2}$"),
|
|
# PL: PL + 10 digits (e.g. PL1234567890)
|
|
"PL": re.compile(r"^PL\d{10}$"),
|
|
# CZ: CZ + 8-10 digits (e.g. CZ1234567890)
|
|
"CZ": re.compile(r"^CZ\d{8,10}$"),
|
|
# IT: IT + 11 digits (e.g. IT12345678901)
|
|
"IT": re.compile(r"^IT\d{11}$"),
|
|
# ES: ES + 1 alphanumeric + 7 digits + 1 alphanumeric (e.g. ESA1234567B)
|
|
"ES": re.compile(r"^ES[A-Za-z0-9]\d{7}[A-Za-z0-9]$"),
|
|
# BE: BE + 10 digits (e.g. BE1234567890)
|
|
"BE": re.compile(r"^BE\d{10}$"),
|
|
# DK: DK + 8 digits (e.g. DK12345678)
|
|
"DK": re.compile(r"^DK\d{8}$"),
|
|
# SE: SE + 10 digits (e.g. SE1234567890)
|
|
"SE": re.compile(r"^SE\d{10}$"),
|
|
}
|
|
|
|
# Fallback pattern for EU countries not explicitly listed above.
|
|
# Accepts <2-letter country code> + 5-15 alphanumeric characters.
|
|
_EU_FALLBACK_PATTERN = re.compile(r"^[A-Z]{2}[A-Za-z0-9]{5,15}$")
|
|
|
|
# Set of supported EU country codes (ISO 3166-1 alpha-2).
|
|
_EU_COUNTRY_CODES: set[str] = {
|
|
"AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GR",
|
|
"HR", "HU", "IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL", "PT", "RO",
|
|
"SE", "SI", "SK",
|
|
}
|
|
|
|
|
|
def get_country_code_from_vat_id(vat_id: str) -> str | None:
|
|
"""Extract the 2-letter country code from a VAT ID string.
|
|
|
|
Returns None if the string is too short or does not start with letters.
|
|
"""
|
|
if not vat_id or len(vat_id) < 3:
|
|
return None
|
|
prefix = vat_id[:2].upper()
|
|
if not prefix.isalpha():
|
|
return None
|
|
return prefix
|
|
|
|
|
|
def validate_vat_id(vat_id: str) -> bool:
|
|
"""Validate the format of a VAT ID (USt-IdNr.).
|
|
|
|
Supports DE and common EU countries with specific regex patterns.
|
|
For other EU countries, a fallback pattern is used.
|
|
Non-EU or unrecognised formats return False.
|
|
|
|
Args:
|
|
vat_id: The VAT ID string to validate (case-insensitive, will be uppercased).
|
|
|
|
Returns:
|
|
True if the format is valid, False otherwise.
|
|
"""
|
|
if not vat_id:
|
|
return True # Empty VAT ID is valid (nullable field)
|
|
|
|
normalized = vat_id.strip().upper().replace(" ", "")
|
|
country_code = get_country_code_from_vat_id(normalized)
|
|
if country_code is None:
|
|
return False
|
|
|
|
# Check against country-specific pattern if available
|
|
pattern = _VAT_PATTERNS.get(country_code)
|
|
if pattern is not None:
|
|
return bool(pattern.match(normalized))
|
|
|
|
# Fallback for EU countries without a specific pattern
|
|
if country_code in _EU_COUNTRY_CODES:
|
|
return bool(_EU_FALLBACK_PATTERN.match(normalized))
|
|
|
|
# Non-EU country code: reject (this module validates EU VAT IDs only)
|
|
return False
|
|
|
|
|
|
def validate_vat_id_or_raise(vat_id: str | None) -> str | None:
|
|
"""Validate VAT ID format and return the normalised value.
|
|
|
|
Raises ValueError if the format is invalid.
|
|
|
|
Args:
|
|
vat_id: The VAT ID string to validate, or None.
|
|
|
|
Returns:
|
|
The normalised (uppercased, whitespace-stripped) VAT ID, or None.
|
|
|
|
Raises:
|
|
ValueError: If the VAT ID format is invalid.
|
|
"""
|
|
if vat_id is None or vat_id == "":
|
|
return None
|
|
|
|
normalized = vat_id.strip().upper().replace(" ", "")
|
|
if not validate_vat_id(normalized):
|
|
raise ValueError(f"Invalid VAT ID format: '{vat_id}'")
|
|
return normalized
|