Files
erp-nutzfahrzeuge/backend/app/utils/ust_validation.py
T

139 lines
4.0 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