feat(T04): contact management + USt-IdNr. validation + contact UI
- 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
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user