Source code for cadrumo.domain.invoices.validators

"""Counterparty identity validators for invoice records.

The EU-IVA prefix check and ISO-3166 alpha-2 country-code normaliser
remain in this module because they are invoice-domain concerns.
Each helper raises :class:`ValueError` on failure so pydantic
surfaces the error as a validation error in the enclosing
``Invoice`` model.

The registry-grounded :func:`is_eu_member_state_code` helper anchors the EU
axis to the substrate's registry-projected :class:`cadrumo.domain.iva.schema.EUMemberState`
token. Modelo 369 binding selectors and the OSS / IOSS classifier
boundary checks consume these helpers so the EU membership decision
flows from the substrate, not from a hand-maintained list.
"""

from __future__ import annotations

import re

from ...core.country_code import COUNTRY_CODE_ALPHA2_PATTERN
from ...core.identity.nif_iva import normalise_nif_iva
from ..calculations.registry.eu_member_state_catalogue import resolve_eu_member_state_catalogue
from ..calculations.registry.nif_iva_catalogue import nif_iva_format_for_country
from ..calculations.registry.tax_id_runtime import validate_runtime_spanish_tax_id
from .errors import InvoiceValidationError

__all__ = [
    "is_eu_member_state_code",
    "validate_counterparty_tax_id",
    "validate_country_code",
    "validate_iva_number",
]

_IVA_BODY_RE = re.compile(r"^[a-zA-Z0-9]{4,20}$")
#: The one country whose identifiers take the AEAT checksum regime.
_SPAIN_COUNTRY_CODE = "ES"

_ISO_2_RE = re.compile(rf"^{COUNTRY_CODE_ALPHA2_PATTERN}$")


def _eu_member_state_codes() -> frozenset[str]:
    """Return the 27 strict EU members, excluding the fact-declared ``XI`` prefix."""
    catalogue = resolve_eu_member_state_catalogue()
    northern_ireland = catalogue.require("XI")
    return frozenset(str(member).upper() for member in catalogue.all_states if member != northern_ireland)


[docs] def validate_country_code(value: str) -> str: """Normalise and validate an ISO-3166 alpha-2 country code. Folds case, unlike :func:`~cadrumo.core.parsing.codes.normalise_iso_3166_alpha2_jurisdiction`, which refuses a lowercase token so the regulatory treatment of a ledger row is never guessed. A counterparty's country is a label on an invoice rather than a treatment selector, so folding an operator's ``"es"`` here costs nothing. The two share :data:`~cadrumo.core.country_code.COUNTRY_CODE_ALPHA2_PATTERN` and nothing else. Args: value: Raw country code to validate. Returns: The uppercased two-letter country code. Raises: InvoiceValidationError: If the input is not exactly two alphabetic characters. """ normalized = value.strip().upper() if not _ISO_2_RE.match(normalized): raise InvoiceValidationError("country code must be an ISO-3166 alpha-2 value") return normalized
[docs] def is_eu_member_state_code(value: str) -> bool: """Return ``True`` when ``value`` matches one of the 27 EU Member State codes. The membership check is anchored to :class:`cadrumo.domain.iva.schema.EUMemberState`; if fact 0131 changes (Brexit-style additions or withdrawals) the helper picks up the selected membership automatically. Args: value: Raw country code to check. Returns: ``True`` when ``value`` normalises to one of the 27 EU codes. """ try: normalized = validate_country_code(value) except InvoiceValidationError: return False return normalized in _eu_member_state_codes()
[docs] def validate_iva_number(value: str, country: str) -> str: """Validate a non-ES IVA number shape against its country format. For an EU Member State (and Northern Ireland ``XI``) the number is matched against the country's published NIF-IVA structural pattern, sourced from the central ``cadrumo.core.identity.nif_iva.NIF_IVA_FORMATS`` authority: a malformed intra-community IVA number is bounced by AEAT's Modelo 349 validator, so the refusal names the country and the expected format. Live VIES existence is not checked — only the structure. For a non-EU counterparty (no published pattern) the helper falls back to a generic leading-prefix plus 4-20 character alphanumeric body check, so non-EU counterparties remain acceptable. Args: value: Raw IVA identifier to validate. country: ISO-3166 alpha-2 country code already validated. Returns: The uppercased, whitespace-trimmed IVA identifier. Raises: InvoiceValidationError: If the value is malformed, the prefix does not match ``country``, or the EU NIF-IVA format does not match. """ normalized = normalise_nif_iva(value) if not normalized: raise InvoiceValidationError("IVA number must not be blank") spec = nif_iva_format_for_country(country) if spec is not None: if not spec.pattern.match(normalized): raise InvoiceValidationError( f"IVA number {normalized!r} is not a valid {spec.country_name} NIF-IVA: " f"expected {spec.description} (e.g. {spec.example})", ) return normalized country_upper = country.strip().upper() if not normalized.startswith(country_upper): raise InvoiceValidationError("IVA number must start with the counterparty country ISO-2 prefix") body = normalized[len(country_upper) :] if not _IVA_BODY_RE.match(body): raise InvoiceValidationError("IVA number body must be 4-20 alphanumeric characters") return normalized
[docs] def validate_counterparty_tax_id(tax_id: str, *, country: str | None) -> str: """Validate a counterparty's tax identifier under its country's regime. Which regime applies is a domain rule, not a detail of whichever surface happens to hold the values: a Spanish counterparty is checked against the AEAT checksum, an EU one against its Member State's NIF-IVA structure. That choice was written twice -- once as a mapping with a default in the invoice normaliser, once as an ``if country == "ES"`` in the CLI wire projection -- and a third regime, or a change to which countries take which, would have had to reach both. An ABSENT country returns the identifier unvalidated, and that is the deliberate half. A factura simplificada may legitimately carry no counterparty country (RD 1619/2012 art. 6.1.d), and with no country there is no regime to check against; guessing one from the identifier's own shape is the substitution the identification-state fact exists to prevent. Args: tax_id: The counterparty identifier as declared. country: The counterparty's ISO alpha-2 country, or ``None`` when the document states none. Returns: The identifier in the canonical form its regime's validator returns, unchanged when no country was declared. Raises: InvoiceValidationError: The identifier does not satisfy its regime. """ if country is None: return tax_id if country == _SPAIN_COUNTRY_CODE: return validate_runtime_spanish_tax_id(tax_id) return validate_iva_number(tax_id, country)