"""Spanish tax-identifier parser and check-character validator.
This module owns the immutable format shape and pure validation arithmetic,
while a caller supplies every operative declaration from its authority. Core
validation performs no registry lookup, file access, or provider registration.
The public :func:`validate_identity` parser returns an
:class:`IdentityDocument` member and raises :class:`IdentityError` on
malformed input. Callers that need the canonical string form use
:func:`~cadrumo.core.identity.tax_id.validate_spanish_tax_id`.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import StrEnum
from ..errors.hierarchy import CadrumoError
TAX_ID_FORMAT_CONTEXT = "spanish_tax_id_format"
def _nif_pattern(tax_id_format: SpanishTaxIdFormat) -> re.Pattern[str]:
"""Build the NIF shape gate from the authored width declaration."""
return re.compile(rf"^(\d{{{tax_id_format.width - 1}}})([A-Z])$")
def _prefixed_nif_pattern(tax_id_format: SpanishTaxIdFormat) -> re.Pattern[str]:
"""Build the prefixed-NIF shape gate from authored width and leaders."""
leaders = re.escape(tax_id_format.prefixed_nif_leaders)
return re.compile(rf"^([{leaders}])(\d{{{tax_id_format.width - 2}}})([A-Z])$")
def _nie_pattern(tax_id_format: SpanishTaxIdFormat) -> re.Pattern[str]:
"""Build the NIE shape gate from authored width and leaders."""
leaders = re.escape(tax_id_format.nie_leaders)
return re.compile(rf"^([{leaders}])(\d{{{tax_id_format.width - 2}}})([A-Z])$")
def _cif_pattern(tax_id_format: SpanishTaxIdFormat) -> re.Pattern[str]:
"""Build the CIF shape gate from authored width and leaders."""
leaders = re.escape(tax_id_format.cif_leaders)
return re.compile(rf"^([{leaders}])(\d{{{tax_id_format.width - 2}}})(.)$")
[docs]
class IdentityDocument(StrEnum):
"""Closed catalogue of recognised Spanish identity-document kinds.
Attributes:
NIF: Número de Identificación Fiscal — Spanish nationals.
NIE: Número de Identidad de Extranjero — foreign residents.
CIF: Código de Identificación Fiscal — legal entities.
"""
NIF = "NIF"
NIE = "NIE"
CIF = "CIF"
[docs]
def is_identity_structurally_shaped(candidate: object) -> bool:
"""Return whether *candidate* has a tax-identity lexical shape.
This is intentionally a syntax-only admission predicate for low-level
consumers such as redaction. It does not claim that the candidate is a
currently recognised Spanish NIF, NIE, or CIF: leader membership,
checksum tables, and control-character partitions remain supplied by an
explicit authority format to :func:`validate_identity`.
"""
if not isinstance(candidate, str):
return False
normalised = candidate.strip().upper().replace("-", "").replace(" ", "").replace(".", "")
if not normalised.isascii() or not normalised.isalnum():
return False
return bool(re.fullmatch(r"[0-9]+[A-Z]", normalised) or re.fullmatch(r"[A-Z][0-9]+[A-Z0-9]", normalised))
[docs]
class IdentityError(CadrumoError):
"""Raised when a candidate string is not a valid Spanish identity document.
Bound to the registered error code ``INTEGRITY_IDENTITY_DOCUMENT``
in :data:`~core.errors.registry.declared_codes.ALL_DECLARED_ERROR_CODES`. Carries a human-readable
diagnostic that names the failing shape (``NIF``, ``NIE``, ``CIF``)
and, where relevant, the expected vs observed check character.
Its canonical registered ancestry is :class:`CadrumoError`. Pydantic
validators translate this registered failure to ``ValueError`` at their
narrow validator boundary.
"""
[docs]
def nif_check_letter(number: int, tax_id_format: SpanishTaxIdFormat) -> str:
"""Return the AEAT NIF / NIE check letter for a numeric body.
Resolves the governed check-letter table without retaining a local
catalogue. This is the single source of the check-letter computation for
the whole :mod:`cadrumo.core.identity` package; the sibling
:mod:`cadrumo.core.identity.tax_id` consumes it rather than re-declaring
the modulo expression, and every enum-returning validator in this module
computes its expected letter through it.
"""
return _nif_check_letter(number, tax_id_format)
def _nif_check_letter(number: int, tax_id_format: SpanishTaxIdFormat) -> str:
"""Return one registry-declared NIF/NIE check letter, failing closed."""
return tax_id_format.nif_letters[number % len(tax_id_format.nif_letters)]
def _cif_check_value(digits: str) -> int:
"""Return the AEAT CIF Luhn-style check value (0-9) for a 7-digit body.
For each digit position ``i`` (1-indexed) the running sum is built as:
* odd positions: ``2 * digit``; if the doubled value is >= 10, sum
its decimal digits (i.e. ``divmod(2*digit, 10)``).
* even positions: ``digit`` directly.
The check value is ``(10 - (sum mod 10)) mod 10``. This kernel returns
the raw integer; :func:`_validate_cif` renders it as a digit or a letter
and applies the per-kind acceptance policy. That policy exists once, here,
and both identity surfaces reach it -- the enum-returning
:func:`validate_identity` and the string-returning
:func:`cadrumo.core.identity.tax_id.validate_spanish_tax_id`.
Args:
digits: The 7-digit body of the CIF.
Returns:
The check value as an integer in ``range(10)``.
"""
total = 0
for index, raw in enumerate(digits, start=1):
digit = int(raw)
if index % 2 == 1:
doubled = digit * 2
total += doubled // 10 + doubled % 10
else:
total += digit
return (10 - (total % 10)) % 10
def _validate_nif(candidate: str, tax_id_format: SpanishTaxIdFormat) -> IdentityDocument:
"""Validate a NIF candidate, raising :class:`IdentityError` on mismatch."""
match = _nif_pattern(tax_id_format).match(candidate)
if match is None:
raise IdentityError(
f"tax identifier {candidate!r} is not shaped like a NIF",
translated_message="errors.identity.nif_invalid_shape",
context={"candidate": candidate},
)
digits, letter = match.group(1), match.group(2)
expected = _nif_check_letter(int(digits), tax_id_format)
if letter != expected:
raise IdentityError(
f"NIF checksum mismatch for {digits}: expected check letter {expected!r}, got {letter!r}",
translated_message="errors.identity.nif_check_letter_mismatch",
context={"digits": digits, "expected": expected, "got": letter},
)
return IdentityDocument.NIF
def _validate_prefixed_nif(candidate: str, tax_id_format: SpanishTaxIdFormat) -> IdentityDocument:
"""Validate a registry-declared prefixed-NIF candidate."""
match = _prefixed_nif_pattern(tax_id_format).match(candidate)
if match is None:
raise IdentityError(
f"tax identifier {candidate!r} is not shaped like a NIF",
translated_message="errors.identity.nif_invalid_shape",
context={"candidate": candidate},
)
prefix, digits, letter = match.group(1), match.group(2), match.group(3)
expected = _nif_check_letter(int(digits), tax_id_format)
if letter != expected:
raise IdentityError(
f"NIF checksum mismatch for {prefix + digits}: expected check letter {expected!r}, got {letter!r}",
translated_message="errors.identity.nif_check_letter_mismatch",
context={"digits": prefix + digits, "expected": expected, "got": letter},
)
return IdentityDocument.NIF
def _validate_nie(candidate: str, tax_id_format: SpanishTaxIdFormat) -> IdentityDocument:
"""Validate a NIE candidate, raising :class:`IdentityError` on mismatch."""
match = _nie_pattern(tax_id_format).match(candidate)
if match is None:
raise IdentityError(
f"tax identifier {candidate!r} is not shaped like a NIE",
translated_message="errors.identity.nie_invalid_shape",
context={"candidate": candidate},
)
prefix, digits, letter = match.group(1), match.group(2), match.group(3)
numeric_str = dict(tax_id_format.nie_prefix_substitutions)[prefix] + digits
expected = _nif_check_letter(int(numeric_str), tax_id_format)
if letter != expected:
raise IdentityError(
f"NIE checksum mismatch for {prefix + digits}: expected check letter {expected!r}, got {letter!r}",
translated_message="errors.identity.nie_check_letter_mismatch",
context={"body": prefix + digits, "expected": expected, "got": letter},
)
return IdentityDocument.NIE
def _validate_cif(candidate: str, tax_id_format: SpanishTaxIdFormat) -> IdentityDocument:
"""Validate a CIF candidate, raising :class:`IdentityError` on mismatch.
The one home of the CIF leader policy. AEAT partitions the kind letters
three ways and the registry-declared partition decides which control
characters are legal. Digit-only kinds accept only the digit form,
letter-only kinds only the letter form, and every remaining kind accepts
either, both being historically in circulation.
The middle class is the one worth naming. A digit-only kind that also
accepts the letter form is not a laxer reading of the same rule -- it
accepts an identifier AEAT rejects, so a counterparty passes the boundary
here and bounces at the sede with the declaration already built.
"""
match = _cif_pattern(tax_id_format).match(candidate)
if match is None:
raise IdentityError(
f"tax identifier {candidate!r} is not shaped like a CIF",
translated_message="errors.identity.cif_invalid_shape",
context={"candidate": candidate},
)
kind, digits, check = match.group(1), match.group(2), match.group(3)
if not check.isalnum():
raise IdentityError(
f"CIF checksum control character {check!r} in tax identifier {candidate!r} must be a digit or letter",
translated_message="errors.identity.cif_control_char_invalid",
context={"candidate": candidate, "got": check},
)
check_int = _cif_check_value(digits)
expected_digit = str(check_int)
expected_letter = tax_id_format.cif_letter_table[check_int]
cif_digit_only_kinds = tax_id_format.cif_digit_only_kinds
cif_letter_only_kinds = tax_id_format.cif_letter_only_kinds
if kind in cif_digit_only_kinds:
if check != expected_digit:
raise IdentityError(
f"CIF checksum mismatch (kind {kind}): expected check digit {expected_digit!r}, got {check!r}",
translated_message="errors.identity.cif_check_digit_mismatch",
context={"kind": kind, "expected": expected_digit, "got": check},
)
elif kind in cif_letter_only_kinds:
if check != expected_letter:
raise IdentityError(
f"CIF checksum mismatch (kind {kind}): expected check letter {expected_letter!r}, got {check!r}",
translated_message="errors.identity.cif_check_letter_mismatch_kind",
context={"kind": kind, "expected": expected_letter, "got": check},
)
elif check not in (expected_digit, expected_letter):
raise IdentityError(
f"CIF checksum mismatch (kind {kind}): expected {expected_digit} or "
f"check letter {expected_letter!r}, got {check!r}",
translated_message="errors.identity.cif_check_char_mismatch_mixed",
context={
"kind": kind,
"expected": expected_digit,
"alt": expected_letter,
"got": check,
},
)
return IdentityDocument.CIF
[docs]
def validate_identity(candidate: object, tax_id_format: SpanishTaxIdFormat) -> IdentityDocument:
"""Parse and check-letter-validate a Spanish identity document.
Disambiguates by the leader sets in the registry-owned mapping, then
applies the checksum/control algorithm for the selected shape and returns
the parsed :class:`IdentityDocument`.
Args:
candidate: A free-form candidate value. Strings tolerate surrounding
whitespace, dashes, spaces, and casing; non-string values are
rejected with a typed :class:`IdentityError`.
tax_id_format: Complete authority-supplied format declarations.
Returns:
The matching :class:`IdentityDocument` enum member.
Raises:
IdentityError: When ``candidate`` is not a string, is empty, or
does not match any valid shape, or when the check letter or
digit fails the AEAT algorithm.
"""
if not isinstance(candidate, str):
raise IdentityError(
translated_message="errors.identity.validate_expects_str",
context={"got_type": type(candidate).__name__},
)
normalised = candidate.strip().upper().replace("-", "").replace(" ", "")
if not normalised:
raise IdentityError(
"tax identifier is empty",
translated_message="errors.identity.document_empty",
)
prefixed_nif_leaders = tax_id_format.prefixed_nif_leaders
nie_leaders = tax_id_format.nie_leaders
cif_leaders = tax_id_format.cif_leaders
# Try prefixed NIF and NIE first (they have unambiguous prefixes);
# then CIF (also unambiguous on its leading letter set); then NIF.
if normalised[0] in prefixed_nif_leaders:
return _validate_prefixed_nif(normalised, tax_id_format)
if normalised[0] in nie_leaders:
return _validate_nie(normalised, tax_id_format)
if normalised[0] in cif_leaders:
return _validate_cif(normalised, tax_id_format)
return _validate_nif(normalised, tax_id_format)