Source code for aeat.core.identity._documents

"""NIF / NIE / CIF parser and check-letter validator.

The three document shapes:

* **NIF** (Número de Identificación Fiscal): 8 digits, or a leading
  K/L/M plus 7 digits for natural persons without DNI/NIE, followed by
  1 check letter. Check letter computed from the numeric portion via
  ``"TRWAGMYFPDXBNJZSQVHLCKE"[number % 23]``.
* **NIE** (Número de Identidad de Extranjero): leading X / Y / Z + 7
  digits + 1 check letter. Used by foreigners resident in Spain. Check
  letter computed by replacing the leading letter with 0 / 1 / 2
  respectively, then applying the same table as NIF.
* **CIF** (Código de Identificación Fiscal): leading letter (A-H, J, N,
  P-S, U, V, W) + 7 digits + 1 check character. Used by legal entities.
  The check character is either a digit or a letter depending on the
  leading kind code; the algorithm is the Luhn-style sum-of-doubled-
  odd-digits method described by AEAT.

The public :func:`validate_identity` parser returns an :class:`IdentityDocument`
member and raises :class:`IdentityError` on malformed input. It accepts mixed
case, trims surrounding whitespace, and rejects values whose check letter does
not match the algorithm, not just shape mismatches. Callers that only need the
canonical string form use
:func:`~aeat.core.identity.validate_spanish_tax_id` from the sibling tax-id
module.
"""

from __future__ import annotations

import re
from enum import StrEnum

from ..errors import AeatError

_NIF_LETTERS = "TRWAGMYFPDXBNJZSQVHLCKE"
_NIE_PREFIX_MAP = {"X": "0", "Y": "1", "Z": "2"}
_CIF_KIND_LETTERS = "ABCDEFGHJNPQRSUVW"
"""Closed catalogue of CIF leading kind characters per AEAT current spec (17 letters).

K, L, and M are excluded from CIF because they are current natural-person
NIF prefixes, not legal-entity kind letters. This set is the authoritative
shape gate for legal-entity CIF classification via ``_CIF_PATTERN`` and
:func:`validate_identity`; the string-returning ``validate_spanish_tax_id``
helper uses the same CIF catalogue.
"""

# AEAT publishes a small lookup mapping the CIF kind letter to the
# expected check-character format. Letters that always carry a digit
# check, letters that always carry a letter check, and letters that
# accept either are partitioned here.
_CIF_KIND_DIGIT_ONLY = "ABEH"
"""Kinds whose check character MUST be a digit."""

_CIF_KIND_LETTER_ONLY = "PQRSNW"
"""Kinds whose check character MUST be a letter."""

_CIF_LETTER_TABLE = "JABCDEFGHI"
"""When the check character is a letter, it is the index-th entry of this table."""

_NIF_PATTERN = re.compile(r"^(\d{8})([A-Z])$")
_PREFIXED_NIF_PATTERN = re.compile(r"^([KLM])(\d{7})([A-Z])$")
_NIE_PATTERN = re.compile(r"^([XYZ])(\d{7})([A-Z])$")
_CIF_PATTERN = re.compile(rf"^([{_CIF_KIND_LETTERS}])(\d{{7}})([0-9A-J])$")


[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] class IdentityError(AeatError, ValueError): """Raised when a candidate string is not a valid Spanish identity document. Bound to the registered error code ``INTEGRITY_IDENTITY_DOCUMENT`` in :data:`aeat.core.errors.ERROR_REGISTRY`. Carries a human-readable diagnostic that names the failing shape (``NIF``, ``NIE``, ``CIF``) and, where relevant, the expected vs observed check character. Inherits from :class:`ValueError` so that pydantic's :class:`~pydantic.AfterValidator` can wrap it directly into a :class:`~pydantic.ValidationError` without a re-raise shim. """
[docs] def nif_check_letter(number: int) -> str: """Return the AEAT NIF / NIE check letter for a numeric body. Implements the AEAT control-letter table :data:`_NIF_LETTERS` (``TRWAGMYFPDXBNJZSQVHLCKE``) indexed by ``number % 23``. This is the single source of the check-letter computation for the whole :mod:`aeat.core.identity` package; the sibling :mod:`aeat.core.identity._tax_id` re-exports it rather than re-declaring the ``% 23`` expression, and every enum-returning validator in this module computes its expected letter through it. """ return _NIF_LETTERS[number % 23]
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 and leaves the digit-vs-letter rendering and the per-kind acceptance policy to the caller, because the two identity surfaces (:func:`_compute_cif_check` here and :func:`aeat.core.identity._tax_id.validate_spanish_tax_id`) apply deliberately divergent CIF leader-set policies over the same arithmetic. 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 _compute_cif_check(kind: str, digits: str) -> str: """Compute the CIF check character per AEAT's Luhn-style algorithm. Delegates the arithmetic to :func:`_cif_check_value` and renders the result as a digit or as :data:`_CIF_LETTER_TABLE` indexed by the check value depending on the leading ``kind`` letter. Args: kind: The CIF leading kind letter. digits: The 7-digit body of the CIF. Returns: The expected check character as a one-character string. """ check_int = _cif_check_value(digits) if kind in _CIF_KIND_DIGIT_ONLY: return str(check_int) if kind in _CIF_KIND_LETTER_ONLY: return _CIF_LETTER_TABLE[check_int] # Mixed kinds — either digit or letter is acceptable. return str(check_int) def _validate_nif(candidate: str) -> IdentityDocument: """Validate a NIF candidate, raising :class:`IdentityError` on mismatch.""" match = _NIF_PATTERN.match(candidate) if match is None: raise IdentityError( translated_message="errors.identity.nif_invalid_shape", context={"candidate": candidate}, ) digits, letter = match.group(1), match.group(2) expected = nif_check_letter(int(digits)) if letter != expected: raise IdentityError( translated_message="errors.identity.nif_check_letter_mismatch", context={"digits": digits, "expected": expected, "got": letter}, ) return IdentityDocument.NIF def _validate_prefixed_nif(candidate: str) -> IdentityDocument: """Validate a K/L/M-prefixed NIF candidate.""" match = _PREFIXED_NIF_PATTERN.match(candidate) if match is None: raise IdentityError( 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)) if letter != expected: raise IdentityError( translated_message="errors.identity.nif_check_letter_mismatch", context={"digits": prefix + digits, "expected": expected, "got": letter}, ) return IdentityDocument.NIF def _validate_nie(candidate: str) -> IdentityDocument: """Validate a NIE candidate, raising :class:`IdentityError` on mismatch.""" match = _NIE_PATTERN.match(candidate) if match is None: raise IdentityError( translated_message="errors.identity.nie_invalid_shape", context={"candidate": candidate}, ) prefix, digits, letter = match.group(1), match.group(2), match.group(3) numeric_str = _NIE_PREFIX_MAP[prefix] + digits expected = nif_check_letter(int(numeric_str)) if letter != expected: raise IdentityError( translated_message="errors.identity.nie_check_letter_mismatch", context={"body": prefix + digits, "expected": expected, "got": letter}, ) return IdentityDocument.NIE def _validate_cif(candidate: str) -> IdentityDocument: """Validate a CIF candidate, raising :class:`IdentityError` on mismatch.""" match = _CIF_PATTERN.match(candidate) if match is None: raise IdentityError( translated_message="errors.identity.cif_invalid_shape", context={"candidate": candidate}, ) kind, digits, check = match.group(1), match.group(2), match.group(3) expected_digit = _compute_cif_check(kind, digits) if kind in _CIF_KIND_DIGIT_ONLY: if check != expected_digit: raise IdentityError( translated_message="errors.identity.cif_check_digit_mismatch", context={"kind": kind, "expected": expected_digit, "got": check}, ) elif kind in _CIF_KIND_LETTER_ONLY: if check != expected_digit: raise IdentityError( translated_message="errors.identity.cif_check_letter_mismatch_kind", context={"kind": kind, "expected": expected_digit, "got": check}, ) else: # Mixed kind: accept either the digit form or the corresponding letter form. check_int = int(expected_digit) if check != expected_digit and check != _CIF_LETTER_TABLE[check_int]: raise IdentityError( translated_message="errors.identity.cif_check_char_mismatch_mixed", context={ "kind": kind, "expected": expected_digit, "alt": _CIF_LETTER_TABLE[check_int], "got": check, }, ) return IdentityDocument.CIF
[docs] def validate_identity(candidate: object) -> IdentityDocument: """Parse and check-letter-validate a Spanish identity document. Disambiguates by leading character: ``K``/``L``/``M`` route to prefixed NIF, ``X``/``Y``/``Z`` route to NIE, leading letters in :data:`_CIF_KIND_LETTERS` route to CIF, and everything else is attempted as NIF. The check-letter / check-digit algorithm is then applied for the chosen shape and the parsed :class:`IdentityDocument` is returned. 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`. 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(translated_message="errors.identity.document_empty") # 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 "KLM": return _validate_prefixed_nif(normalised) if normalised[0] in "XYZ": return _validate_nie(normalised) if normalised[0] in _CIF_KIND_LETTERS: return _validate_cif(normalised) return _validate_nif(normalised)