Source code for cadrumo.core.identity.tax_id

"""Spanish tax-identifier validation (NIF / NIE / CIF) returning canonical strings.

The Agencia Tributaria's identifier algorithm is shared infrastructure
across multiple subpackages — invoice counterparty checks, encrypted
master-key NIF canaries, sanitiser fixture validation, and CLI preflight
gates. Co-locating the algorithm in :mod:`core.identity` gives
every caller a public, layer-respecting import path.

This module differs from :mod:`core.identity.documents` only in its return
shape: :func:`validate_spanish_tax_id` yields the normalised identifier string,
while :func:`~core.identity.documents.validate_identity` returns the matching
:class:`~core.identity.documents.IdentityDocument` enum member. The shape is the ONLY
difference -- there is one implementation of the AEAT algorithm and one CIF
leader policy, both in :mod:`core.identity.documents`, and this function
delegates to them rather than restating them. It once did restate them, and the
two copies drifted into disagreeing about whether an ``ABEH`` CIF may carry a
letter control; two validators answering one question differently is not a
laxer reading, it is a defect with a second opinion.

Both surfaces raise :class:`~core.identity.documents.IdentityError`; the shared NIF/NIE
checksum table is defined by :mod:`core.identity.documents`.
"""

from __future__ import annotations

from typing import Annotated

from pydantic import BeforeValidator

from ...core.errors.hierarchy import pydantic_validation_boundary
from .documents import IdentityError, SpanishTaxIdFormat, validate_identity
from .nif_iva import normalise_nif_iva


[docs] @pydantic_validation_boundary def tax_id_identity_token(value: str) -> str: """Return the canonical identity form of a tax identifier, without a checksum claim. The one comparison form for tax identifiers whose bearer is not guaranteed to be Spanish -- a non-resident counterparty may carry a foreign identifier, so :func:`validate_spanish_tax_id`'s checksum gate would refuse a legitimately declared row. This function answers only "are these two identifiers the same identifier", which is what grouping keys, distinct counts, and storage object keys need. Normalisation is trim-and-uppercase and nothing more: it is idempotent, so a value already in canonical form is returned unchanged, and it never silently merges two identifiers that differ in their characters. Applying it at the model boundary AND at the storage key is the point: when an aggregator groups by the raw value while the repository keys by a normalised one, two canonically-equal identifiers become two rollups but one stored row, so the declared distinct count and the persisted evidence disagree. Args: value: Raw tax identifier as declared. Returns: The trimmed, uppercased identifier. """ return value.strip().upper()
[docs] def same_tax_identifier(left: str | None, right: str | None) -> bool: """Return whether two declared identifiers name the same bearer. The one "is this the same identifier" predicate. It asserts **no checksum**, which is the whole point: the question is identity, not validity, so a foreign identifier, or a Spanish one whose control character does not check out, must still be comparable. A comparison routed through :func:`validate_spanish_tax_id` answers ``False`` for both of those by returning nothing to compare, which reads as "different bearer" when the truth is "unverifiable identifier". Comparison is on the separator-stripped form (:func:`~core.identity.nif_iva.normalise_nif_iva`), so a printed ``B-1234567-4`` matches a stored ``B12345674``. That is deliberately looser than :func:`tax_id_identity_token`, which stays trim-and-uppercase because it keys stored objects and must never merge two characters-differ identifiers into one row; this predicate keys nothing. Args: left: One identifier as declared, or ``None``. right: The other identifier as declared, or ``None``. Returns: ``True`` only when both sides carry a non-blank value and those values are the same identifier. An absent or blank side answers ``False``: absence is not a match, and this predicate must never turn "nothing to compare" into "the same". """ if left is None or right is None: return False left_token = normalise_nif_iva(left) right_token = normalise_nif_iva(right) if not left_token or not right_token: return False return left_token == right_token
[docs] def validate_spanish_tax_id(value: str, tax_id_format: SpanishTaxIdFormat) -> str: """Validate a Spanish NIF, NIE, or CIF and return its canonical form. Args: value: Raw tax identifier to validate. tax_id_format: Complete authority-supplied format declarations. Returns: The identifier in the package's separator-stripped normal form -- :func:`~core.identity.nif_iva.normalise_nif_iva`, the same form :func:`same_tax_identifier` compares on, so a printed ``B-1234567-4`` and a stored ``B12345674`` validate to one string rather than two. Raises: IdentityError: If the identifier is malformed or the checksum fails. """ normalized = normalise_nif_iva(value) if not normalized: raise IdentityError( "tax identifier is empty", translated_message="errors.identity.document_empty", ) width = tax_id_format.width country_prefix = tax_id_format.country_prefix prefixed_width = tax_id_format.country_prefixed_width strip_width = tax_id_format.country_prefix_strip_width if len(normalized) == prefixed_width and normalized.startswith(country_prefix): normalized = normalized[strip_width:] if len(normalized) != width: raise IdentityError( f"tax identifier {normalized!r} must be exactly {width} characters, got {len(normalized)}", translated_message="errors.identity.tax_id_invalid_length", context={"candidate": normalized, "length": len(normalized)}, ) try: validate_identity(normalized, tax_id_format) except IdentityError as exc: leader = normalized[0] recognised_leader = leader.isdigit() or any( leader in leaders for leaders in (tax_id_format.prefixed_nif_leaders, tax_id_format.nie_leaders, tax_id_format.cif_leaders) ) if exc.translated_message == "errors.identity.nif_invalid_shape" and not recognised_leader: raise IdentityError( f"tax identifier {normalized!r} does not start with a recognised leader {leader!r}", translated_message="errors.identity.tax_id_unrecognised_leader", context={"candidate": normalized, "leader": leader}, ) from exc raise return normalized
type TaxIdIdentityToken = Annotated[str, BeforeValidator(tax_id_identity_token)] """Canonical identity form of a tax identifier, normalised at the boundary. Runs :func:`tax_id_identity_token` BEFORE the field's own length constraints, so a field annotated with it stores the canonical token and any ``min_length`` bound is applied to that token rather than to the raw declaration. Unlike :data:`~domain.calculations.registry.tax_id_format.SubjectTaxId` it asserts no checksum, so it fits identifiers whose bearer may be non-resident; use :data:`~domain.calculations.registry.tax_id_format.SubjectTaxId` where the value must be a valid Spanish NIF / NIE / CIF. """ __all__ = [ "TaxIdIdentityToken", "same_tax_identifier", "tax_id_identity_token", "validate_spanish_tax_id", ]