"""External constants registry loaded from ``external_constants.toml``.
Centralises third-party hostnames, AEAT service paths, OAuth scopes, and
remote API endpoints. The TOML file sits beside this module and is parsed
once per process via :func:`load_external_constants`. Every section is
modelled as a frozen, strict pydantic v2 model so callers see typed,
immutable values and any drift between the TOML and the schema fails fast
at import time.
This is a read-only remote-mirror registry for public, externally defined
constants. Runtime-tunable values such as timeouts, storage roots, and operator
choices belong in :class:`core.config.Settings`; profile data, tokens,
passphrases, bucket ids, and SQL routes do not belong here. Loading the registry
only reads packaged TOML (or an explicit audit/test path) and never opens
storage, writes files, or contacts remote providers.
The typed root is :class:`ExternalConstants`, with AEAT-specific subsections
grouped under :class:`AeatSection`; callers normally reach it through
:meth:`core.config.Settings.external_constants`. The volatile Pre303 and
IVA-wallet browser surface remains lazily validated as :class:`AeatPre303Surface`
so selector churn does not poison unrelated configuration reads.
"""
from __future__ import annotations
import re
import tomllib
from collections.abc import Mapping
from decimal import Decimal
from enum import StrEnum
from functools import cached_property, lru_cache
from importlib.resources import files # nosemgrep
from pathlib import Path
from types import MappingProxyType
from typing import Any, Final, Literal
from pydantic import BaseModel, Field, ValidationError, field_validator
from ._modelo import Modelo
from ._models import STRICT_FROZEN_CONFIG
from .errors import CoreValidationError
#: ISO 4217 currency code for the Euro, used as the functional currency throughout AEAT.
DEFAULT_CURRENCY: Final[str] = "EUR"
#: Standard binary MIME type for opaque byte-stream payloads (Drive uploads, blob store, fichero).
BINARY_MIME_TYPE: Final[str] = "application/octet-stream"
#: IANA-registered MIME type for JSON document payloads.
JSON_MIME_TYPE: Final[str] = "application/json"
#: IANA-registered MIME type for comma-separated value exports.
CSV_MIME_TYPE: Final[str] = "text/csv"
#: MIME type for newline-delimited JSON export streams.
JSONL_MIME_TYPE: Final[str] = "application/x-ndjson"
#: MIME type for Office Open XML spreadsheet workbooks.
XLSX_MIME_TYPE: Final[str] = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
#: Sentinel written to ``classified_by`` when the operator provides a classification directly
#: (no rule engine involved). The field also accepts ``"rule:<id>"`` payloads; this named
#: constant prevents the literal from drifting across the application and domain layers.
CLASSIFIED_BY_MANUAL: Final[str] = "manual"
#: Sentinel written to ``classified_by`` when the classification was produced automatically
#: by the rule engine with no operator override.
CLASSIFIED_BY_AUTO: Final[str] = "auto"
class _Frozen(BaseModel):
"""Strict, frozen base for external-constant submodels.
Unknown TOML keys are rejected and parsed instances are immutable, so a
newly added external value must be represented in the schema before
production code can consume it.
"""
model_config = STRICT_FROZEN_CONFIG
[docs]
class AeatDomains(_Frozen):
"""AEAT and related government hostnames.
Hostnames are registry data, not executable literals in live drivers.
Callers combine these origins with path sections below instead of
re-declaring Sede, Cl@ve, BOE, or numbered AEAT subdomain strings.
"""
host_suffix: str = Field(min_length=1)
sede: str = Field(min_length=1)
www1: str = Field(min_length=1)
www2: str = Field(min_length=1)
www3: str = Field(min_length=1)
www6: str = Field(min_length=1)
www12: str = Field(min_length=1)
aeat_gob: str = Field(min_length=1)
legacy_www: str = Field(min_length=1)
clave: str = Field(min_length=1)
boe: str = Field(min_length=1)
[docs]
class AeatSedePaths(_Frozen):
"""Relative path templates against configured AEAT origins.
These values are route fragments and templates only; consumers choose the
correct origin from :class:`AeatDomains` or an overrideable
:class:`core.config.Settings` field before building a full URL.
"""
auth_gate_4033: str
expedientes_resumen: str
declarations_listing: str
cotejo_query: str
cotejo_document: str
notifications_summary: str
notifications_query: str
certificate_selector: str
censo_g313_launcher: str
r210_simulator_open_ajax: str
borrador_100_detail_template: str
declaracion_consult: str
clave_movil_login: str
expediente_detail_template: str
irpf_expediente_detail_year_prefix: str
irpf_expediente_detail_year_suffix: str
notificaciones: str
iva_compensation_wallet: str
[docs]
class AeatClaveMovilSurface(_Frozen):
"""Externally-defined Cl@ve Móvil page identifiers and shape markers."""
selector_access_url_template: str = Field(min_length=1)
selector_access_path_marker: str = Field(min_length=1)
dialogo_representacion_path_marker: str = Field(min_length=1)
dialogo_representacion_path: str = Field(min_length=1)
obtener_clave_movil_path_marker: str = Field(min_length=1)
obtener_clave_movil_qr_path_marker: str = Field(min_length=1)
cancelar_clave_movil_path_marker: str = Field(min_length=1)
obtener_clave_movil_qr_path: str = Field(min_length=1)
obtener_clave_movil_non_qr_path: str = Field(min_length=1)
autentica_dni_nie_contraste_path: str = Field(min_length=1)
cancelar_clave_movil_path: str = Field(min_length=1)
obtener_clave_movil_browser_global: str = Field(min_length=1)
authorize_button_selector: str = Field(min_length=1)
non_qr_link_selector: str = Field(min_length=1)
nif_input_selector: str = Field(min_length=1)
dni_fecha_input_selector: str = Field(min_length=1)
dni_fecha_visible_selector: str = Field(min_length=1)
nie_soporte_input_selector: str = Field(min_length=1)
nie_soporte_visible_selector: str = Field(min_length=1)
continue_button_selector: str = Field(min_length=1)
continue_button_visible_selector: str = Field(min_length=1)
verification_code_selector: str = Field(min_length=1)
wait_text_markers: tuple[str, ...] = Field(min_length=1)
pending_petition_text_markers: tuple[str, ...] = Field(min_length=1)
@field_validator(
"wait_text_markers",
"pending_petition_text_markers",
mode="before",
)
@classmethod
def _markers_from_toml_arrays(cls, value: object) -> object:
if isinstance(value, list):
return tuple(value)
return value
[docs]
class AeatClavePermanenteSurface(_Frozen):
"""Externally-defined Cl@ve Permanente selector-page and IdP form markers.
Cl@ve Permanente reuses the same AEAT auth-method selector page as Cl@ve
Movil (:attr:`selector_access_url_template`); the Cl@ve IdP itself then
renders a DNI/NIE + password form rather than the QR/push screen. These
IdP form selectors and error markers are the least stable part of this
surface — they track the Cl@ve frontend, not an AEAT-published contract —
and are the ``needs-design`` surface tracked by issue #283.
"""
selector_access_url_template: str = Field(min_length=1)
selector_access_path_marker: str = Field(min_length=1)
idp_host_marker: str = Field(min_length=1)
username_input_selector: str = Field(min_length=1)
password_input_selector: str = Field(min_length=1)
submit_button_selector: str = Field(min_length=1)
elevation_sms_marker: str = Field(min_length=1)
invalid_credentials_marker: str = Field(min_length=1)
account_locked_marker: str = Field(min_length=1)
password_expired_marker: str = Field(min_length=1)
[docs]
class AeatPre303Surface(_Frozen):
"""Externally-defined Pre303 and IVA compensation wallet surface markers."""
presentation_service_path: str = Field(min_length=1)
access_help_path: str = Field(min_length=1)
faq_general_path: str = Field(min_length=1)
faq_specific_path: str = Field(min_length=1)
functionalities_path: str = Field(min_length=1)
procedures_path: str = Field(min_length=1)
iva_wallet_header_tokens: tuple[str, ...] = Field(min_length=1)
iva_wallet_total_label_tokens: tuple[str, ...] = Field(min_length=1)
iva_wallet_empty_page_tokens: tuple[str, ...] = Field(min_length=1)
representation_own_name_selector: str = Field(min_length=1)
representation_own_name_label_selector: str = Field(min_length=1)
representation_representative_selector: str = Field(min_length=1)
representation_submit_selector: str = Field(min_length=1)
representation_own_name_action_label: str = Field(min_length=1)
wallet_discovered_entrypoint_action_label: str = Field(min_length=1)
wallet_execute_read_action_label: str = Field(min_length=1)
alert_modal_selector: str = Field(min_length=1)
alert_continue_button_text: str = Field(min_length=1)
wallet_form_selector: str = Field(min_length=1)
wallet_execute_submit_selector: str = Field(min_length=1)
tipo_actuacion_own_name_link_selector: str = Field(min_length=1)
wallet_ejercicio_input_selector: str = Field(min_length=1)
wallet_periodo_input_selector: str = Field(min_length=1)
official_access_auth_methods: tuple[str, ...] = Field(min_length=1)
@field_validator(
"iva_wallet_header_tokens",
"iva_wallet_total_label_tokens",
"iva_wallet_empty_page_tokens",
"official_access_auth_methods",
mode="before",
)
@classmethod
def _tuples_from_toml_arrays(cls, value: object) -> object:
if isinstance(value, list):
return tuple(value)
return value
[docs]
class AeatHelpPages(_Frozen):
"""Static help/landing pages rooted under the sede origin."""
csv_verification: str
renta_web_open_landing: str
nif_iva_landing: str
manual_practicos_root: str
[docs]
class AeatOracles(_Frozen):
"""Absolute URLs of AEAT parity oracles."""
nif_iva_verification: str
groi_check: str
renta_web_open_app_template: str
groi_auth_unlock_descriptor: str
nif_iva_auth_locked_descriptor: str
[docs]
class AeatLiveSafety(_Frozen):
"""Centralized allow-list labels for audited live AEAT browser actions.
The patterns identify reviewed action categories for live-surface guards.
They do not authorize a write by themselves; command policy, capability
checks, and live-write gates remain responsible for deciding whether an
operation may run.
"""
auth_browser_action_patterns: tuple[str, ...] = Field(default_factory=tuple)
wallet_browser_action_patterns: tuple[str, ...] = Field(default_factory=tuple)
declarations_browser_action_patterns: tuple[str, ...] = Field(default_factory=tuple)
csv_verify_browser_action_patterns: tuple[str, ...] = Field(default_factory=tuple)
consult_oracle_browser_action_patterns: tuple[str, ...] = Field(default_factory=tuple)
renta_web_open_browser_action_patterns: tuple[str, ...] = Field(default_factory=tuple)
@field_validator(
"auth_browser_action_patterns",
"wallet_browser_action_patterns",
"declarations_browser_action_patterns",
"csv_verify_browser_action_patterns",
"consult_oracle_browser_action_patterns",
"renta_web_open_browser_action_patterns",
mode="before",
)
@classmethod
def _tuples_from_toml_arrays(cls, value: object) -> object:
if isinstance(value, list):
return tuple(value)
return value
[docs]
class AeatPortalPaths(_Frozen):
"""Centralized AEAT portal catalogue paths keyed by :class:`Portal` id.
Portal entries resolve their route fragments from this registry so the
catalogue can describe AEAT surfaces without carrying host or path source
literals in each entry module.
"""
filing_censo_path_regex: str = Field(min_length=1)
filing_censo_path_description: str = Field(min_length=1)
paths: dict[str, str] = Field(min_length=1)
@field_validator("filing_censo_path_regex")
@classmethod
def _filing_censo_path_regex_is_valid(cls, value: str) -> str:
re.compile(value)
return value
@field_validator("paths")
@classmethod
def _paths_are_relative_urls(cls, value: dict[str, str]) -> dict[str, str]:
for key, path in value.items():
if not key.strip():
raise ValueError("portal path keys must not be blank")
if not path.startswith("/"):
raise ValueError(f"portal path for {key!r} must start with '/'")
return value
[docs]
class AeatSection(_Frozen):
"""Aggregates every AEAT-flavoured constant subsection.
The ``pre303`` web-scraping surface (IVA-compensation-wallet routes,
representation-gate selectors, parser markers) is the most volatile
section of the registry: every value tracks the AEAT portal's HTML
and may break on a portal redesign. To keep that volatility from
poisoning the whole registry — and therefore every ``Settings()``
construction, since :class:`core.config.Settings` resolves
AEAT-URL defaults through :func:`load_external_constants` — the raw
``[aeat.pre303]`` mapping is kept untyped and validated lazily into a
strict :class:`AeatPre303Surface` only on first access via the
:attr:`pre303` property. A missing or malformed pre303 block thus
never raises while parsing the registry; it surfaces as a clean
:class:`core.errors.CoreValidationError` to the wallet /
representation flows that actually consume it, and leaves
selector-free commands (``config profile status``, ``modelo list``,
…) entirely unaffected.
"""
domains: AeatDomains
sede_paths: AeatSedePaths
clave_movil: AeatClaveMovilSurface
clave_permanente: AeatClavePermanenteSurface
# ANY-RETURN-RATIONALE-PRE303-RAW-STAGING:
# Raw TOML parse staging slot; cached_property converts to typed
# AeatPre303Surface boundary model.
pre303_raw: dict[str, Any] = Field(default_factory=dict, alias="pre303")
help_pages: AeatHelpPages
oracles: AeatOracles
live_safety: AeatLiveSafety
portal_paths: AeatPortalPaths
[docs]
@cached_property
def pre303(self) -> AeatPre303Surface:
"""Return the strict-validated :class:`AeatPre303Surface` (Pre303 / IVA-wallet surface).
Validation is deferred to first access so a malformed or absent
``[aeat.pre303]`` block cannot break registry parsing for the
many CLI paths that never scrape the AEAT portal. When the block
is broken the leaked :exc:`pydantic.ValidationError` is wrapped
in a :class:`core.errors.CoreValidationError` carrying an
operator-facing recovery hint.
"""
try:
return AeatPre303Surface.model_validate(self.pre303_raw)
except ValidationError as exc:
raise CoreValidationError(
"The AEAT Pre303 / IVA-wallet surface section of "
"external_constants.toml is missing or malformed. This "
"section configures AEAT web-scraping selectors; only "
"commands that read the IVA compensation wallet or the "
"Pre303 portal need it.",
context={
"section": "aeat.pre303",
"validation_error": str(exc),
},
suggestion="aeat config repair --help",
) from exc
[docs]
class GoogleOAuthScopes(_Frozen):
"""OAuth scope strings the Google integration requests."""
openid: str
email: str
drive_file: str
spreadsheets: str
[docs]
class GoogleServices(_Frozen):
"""Google-hosted service surfaces."""
oauth_scopes: GoogleOAuthScopes
[docs]
class OnlineServicesSection(_Frozen):
"""Aggregates non-AEAT online service constants."""
google: GoogleServices
[docs]
class ExternalConstants(_Frozen):
"""Top-level registry model mirroring the TOML root.
The root intentionally separates AEAT-owned surfaces from other online
services so call sites can depend on the narrow subsection they need while
still sharing one typed registry load.
"""
aeat: AeatSection
online_services: OnlineServicesSection
#: IANA-registered MIME type for PDF document payloads.
PDF_MIME_TYPE: Final[str] = "application/pdf"
#: PDF file-extension string (lower-case, dot-prefixed).
PDF_EXTENSION: Final[str] = ".pdf"
#: Legacy binary Excel workbook file-extension string (lower-case, dot-prefixed).
XLS_EXTENSION: Final[Literal[".xls"]] = ".xls"
#: Excel / Open-XML workbook file-extension string (lower-case, dot-prefixed).
XLSX_EXTENSION: Final[Literal[".xlsx"]] = ".xlsx"
#: Excel macro-enabled workbook file-extension string (lower-case, dot-prefixed).
XLSM_EXTENSION: Final[Literal[".xlsm"]] = ".xlsm"
#: Legacy ISO-8859-1 / Latin-1 encoding used by AEAT sede fixed-width response bodies.
LATIN_1_ENCODING: Final[str] = "latin-1"
#: ISO-8859-1 encoding string as accepted by the fichero-BOE wire layer.
#:
#: Identical in coverage to :data:`LATIN_1_ENCODING` at runtime; declared as a
#: typed ``Literal["iso-8859-1"]`` so callers that pass it to a
#: :data:`adapters.outbound.aeat.export._formats._record_spec.FicheroBoeEncoding`
#: parameter satisfy the static type checker without a cast.
ISO_8859_1_ENCODING: Final[Literal["iso-8859-1"]] = "iso-8859-1"
#: UTF-8 character encoding used for all text file I/O in the application layer.
UTF_8_ENCODING: Final[str] = "utf-8"
#: Allowed wire encodings for fichero-BOE payloads.
#:
#: Windows-1252 is a superset of ISO-8859-1 that adds characters in the
#: 0x80-0x9F range; AEAT treats them as equivalent for fichero-BOE
#: purposes. ISO-8859-15 adds the Euro symbol at 0xA4 plus minor deltas.
BOE_ENCODING_CHOICES: Final[tuple[str, ...]] = ("cp1252", "iso-8859-1", "iso-8859-15")
CSV_ENCODING_FALLBACK_CHAIN: tuple[str, ...] = ("utf-8-sig", "utf-8", "cp1252", "iso-8859-1")
#: Provenance source identifier for facts entered interactively via the CLI.
PROVENANCE_SOURCE_MANUAL_CLI: Final[str] = "manual_cli"
#: Reserved operator-visible bucket-label prefix identifying a sandbox profile.
#: A profile whose plaintext manifest label starts with this token is a sandbox:
#: an isolated, discardable bucket. Declared in the light core layer so the
#: state-free CLI surface (``aeat`` / ``--help`` / ``--version``) can check the
#: active bucket's sandbox status without importing the heavy
#: ``bucket_maintenance`` / ``workflow`` facades. ``bucket_maintenance._sandbox``
#: re-exports it as the canonical application-facing name.
SANDBOX_LABEL_PREFIX: Final[str] = "sandbox:"
#: Environment variable name used to override the CLI output language at runtime.
OUTPUT_LANGUAGE_ENV_VAR: Final[str] = "AEAT_OUTPUT_LANGUAGE"
#: POSIX / Windows environment variable that Rich uses to determine console column width.
COLUMNS_ENV_VAR: Final[str] = "COLUMNS"
[docs]
class OutputLanguage(StrEnum):
"""Closed enumeration of CLI / API output language BCP-47 tags.
The four members match the locale catalogues committed under
``src/aeat/locales/``. Adding a new operator-facing language
requires landing the catalogue first and then extending this
enum so the loader-side gates remain in sync.
"""
ES = "es"
EN = "en"
CA = "ca"
HU = "hu"
#: BCP-47 language tag for the default CLI and API output language (Spanish).
DEFAULT_OUTPUT_LANGUAGE: Final[OutputLanguage] = OutputLanguage.ES
#: Ordered tuple of BCP-47 language tags supported by the CLI and API output layer.
#: Kept as ``tuple[str, ...]`` (not ``tuple[OutputLanguage, ...]``) so
#: ``click.Choice(SUPPORTED_OUTPUT_LANGUAGES)`` renders the operator-facing
#: lowercase tags (``[es|en|ca|hu]``) on parse failure rather than the enum
#: NAMES (``[ES|EN|CA|HU]``) that Click derives from StrEnum members. The
#: ``OutputLanguage`` enum stays the canonical closed-set authority above;
#: this constant is the str-typed projection used at the click.Choice
#: boundary.
SUPPORTED_OUTPUT_LANGUAGES: Final[tuple[str, ...]] = tuple(lang.value for lang in OutputLanguage)
#: Modelo 347 declaration floor per counterparty. Binding provision: RD 1065/2007
#: art. 33.1 ("operaciones que en su conjunto … hayan superado la cifra de 3.005,06
#: euros"), which fixes the figure; art. 31.1 only defines the general obligation.
#: Counterparties whose annual operations total at most this amount are NOT declarable.
M347_THRESHOLD_EUR: Final[Decimal] = Decimal("3005.06")
#: IVA regularización de deducciones por bienes de inversión — regulatory constants
#: (LIVA arts. 107-109, Ley 37/1992, BOE-A-1992-28740), re-read verbatim from the
#: bundled consolidated corpus ``corpus/normatives/html/ley-37-1992-art-107.html``
#: and ``-art-109.html`` per ``legal-grounding-verifies-bundled-authoritative-corpus``.
#:
#: Art. 107.Uno: movable capital goods regularise over the "cuatro años naturales
#: siguientes" to acquisition; art. 107.Tres: "terrenos o edificaciones" over the
#: "nueve años naturales siguientes". These are the count of FOLLOWING years in the
#: regularisation window (the acquisition year itself is the year the deduction was
#: made).
IVA_BIEN_INVERSION_MUEBLE_VENTANA_ANOS: Final[int] = 4
IVA_BIEN_INVERSION_INMUEBLE_VENTANA_ANOS: Final[int] = 9
#: Art. 107.Uno: the regularisation is practised only "cuando … exista una diferencia
#: superior a diez puntos" between the definitive deduction percentage of the year and
#: the one that prevailed in the acquisition year. The gate is STRICT (> 10 points);
#: a difference of exactly 10 points does not trigger a regularisation. Binding
#: provision: Art. 107.Uno LIVA (Ley 37/1992).
IVA_BIEN_INVERSION_REGULARIZACION_UMBRAL_PUNTOS: Final[Decimal] = Decimal("10")
#: Art. 109.3.º: "La diferencia positiva o negativa se dividirá por cinco o, tratándose
#: de terrenos o edificaciones, por diez". The per-year regularisation quotient divides
#: the deduction difference by 5 for movable goods and 10 for land/buildings. Binding
#: provision: Art. 109.3.º LIVA (Ley 37/1992).
IVA_BIEN_INVERSION_MUEBLE_DIVISOR: Final[Decimal] = Decimal("5")
IVA_BIEN_INVERSION_INMUEBLE_DIVISOR: Final[Decimal] = Decimal("10")
#: Art. 108.Dos.5.º bienes-de-escaso-valor exclusion: a good "cuyo valor de adquisición
#: sea inferior a quinientas mil pesetas" is NOT a bien de inversión. The consolidated
#: corpus still states the figure in pesetas; 500.000 ptas is the historic amount whose
#: euro equivalent is 3.005,06 € (the same figure the Modelo 347 floor carries). A good
#: whose acquisition value is at or above this threshold may qualify; below it is
#: excluded. Binding provision: Art. 108.Dos.5.º LIVA (Ley 37/1992).
IVA_BIEN_ESCASO_VALOR_UMBRAL_EUR: Final[Decimal] = Decimal("3005.06")
#: Modelo 720 declaration floor per regulatory obligation ``bloque``. Binding provision: RD 1065/2007
#: arts. 42 bis/ter/quater (added by RD 1558/2012) under LGT DA 18ª — each block
#: (cuentas / valores-seguros / inmuebles) carries an independent 50.000 € umbral.
#: An obligation block is declarable iff its aggregate valuation strictly exceeds this amount.
MODELO_720_REPORTING_THRESHOLD_EUR: Final[Decimal] = Decimal("50000.00")
#: IAE art. 82.1.c net-turnover exemption ceiling for Modelo 840 threshold
#: continuity. Binding provision: TRLRHL RDL 2/2004 art. 82.1.c ("importe
#: neto de la cifra de negocios inferior a 1.000.000 de euros"). The gate is
#: STRICTLY BELOW this amount; an INCN equal to 1,000,000.00 EUR is not within
#: the turnover-based exemption.
MODELO_840_IAE_CIFRA_NEGOCIOS_EXEMPTION_THRESHOLD_EUR: Final[Decimal] = Decimal("1000000.00")
#: Art. 7.p) LIRPF (Ley 35/2006, BOE-A-2006-20764) annual exemption cap for
#: foreign-work income of maritime and other qualifying workers.
#: The exempt amount is the lesser of the proportional daily salary for
#: qualifying days and this ceiling. Binding provision: Art. 7.p) LIRPF.
ART_7P_EXEMPTION_CAP_EUR: Final[Decimal] = Decimal("60100")
#: Art. 96.3 LIRPF (Ley 35/2006) secondary-pagador trigger amount. When work
#: income comes from more than one pagador, the reduced filing-exemption limit
#: (see ``WORK_INCOME_MULTIPLE_PAGADORES_REDUCED_LIMIT_EUR_BY_YEAR``) applies only
#: when the aggregate income from the 2nd and subsequent pagadores STRICTLY
#: exceeds this amount; at or below it the general 22.000 € limit is retained
#: (Art. 96.3.a.1.º). Binding provision: Art. 96.3 LIRPF (Ley 35/2006). This
#: trigger is year-stable and has not been revalued.
MULTIPLE_PAGADORES_SECONDARY_THRESHOLD_EUR: Final[Decimal] = Decimal("1500")
#: Art. 96.2.a) LIRPF (Ley 35/2006) GENERAL filing-exemption ceiling for
#: rendimientos íntegros del trabajo. A natural person whose work income does not
#: exceed this amount (single pagador, or multiple pagadores with the 2nd-and-
#: subsequent aggregate at or below 1.500 €) is NOT obliged to file Modelo 100 on
#: account of work income. Binding provision: Art. 96.2.a) LIRPF (Ley 35/2006).
#: This ceiling is year-stable.
WORK_INCOME_GENERAL_DECLARATION_LIMIT_EUR: Final[Decimal] = Decimal("22000")
#: Art. 96.3 LIRPF (Ley 35/2006) REDUCED filing-exemption ceiling for
#: rendimientos íntegros del trabajo, keyed by filing year (the year the income
#: was obtained). The general 22.000 € ceiling drops to this reduced amount when
#: work income comes from more than one pagador AND the 2nd-and-subsequent
#: aggregate exceeds ``MULTIPLE_PAGADORES_SECONDARY_THRESHOLD_EUR`` (1.500 €).
#: The amount is DATED: 2019-2022 use 14.000 € (Art. 96.3 LIRPF base value,
#: post-Ley 26/2014); 2023 uses 15.000 € (Ley 31/2022 PGE-2023,
#: BOE-A-2022-22128, art. 96.3 modification); 2024-2026 use 15.876 €
#: (RD-Ley 4/2024, BOE-A-2024-13066, art. 96.3 modification, confirmed by
#: the bundled consolidated LIRPF art-96 corpus).
#: Binding provision: Art. 96.3 LIRPF (Ley 35/2006), as modified per year above.
#: A filing year beyond the latest tabulated entry resolves to the latest known
#: amount (forward-compatible) until a new law revalues it.
WORK_INCOME_MULTIPLE_PAGADORES_REDUCED_LIMIT_EUR_BY_YEAR: Final[Mapping[int, Decimal]] = MappingProxyType(
{
2019: Decimal("14000"),
2020: Decimal("14000"),
2021: Decimal("14000"),
2022: Decimal("14000"),
2023: Decimal("15000"),
2024: Decimal("15876"),
2025: Decimal("15876"),
2026: Decimal("15876"),
},
)
#: Art. 40.3 LIS (Ley 27/2014, BOE-A-2014-12328) INCN threshold that makes the
#: base-imponible pago-fraccionado modality MANDATORY for Modelo 202. A taxpayer
#: whose importe neto de la cifra de negocios in the 12 months prior to the start of
#: the relevant período impositivo exceeded this amount must use the art. 40.3
#: modality; below it, the art. 40.2 (cuota) modality is optional. Binding
#: provision: Ley 27/2014 art. 40.3 (modalidad obligatoria por cifra de negocios).
MODELO_202_ART_40_3_INCN_THRESHOLD_EUR: Final[Decimal] = Decimal("6000000")
#: Art. 20 LIRPF (Ley 35/2006) rendimiento-neto-del-trabajo ceiling above which the
#: reducción por obtención de rendimientos del trabajo is zero. The reduction is a
#: piecewise-linear function of the rendimiento neto del trabajo (RNT) that decays to
#: zero at this ceiling: for RNT strictly below it the general reduction is positive;
#: at or above it the reduction is nil. Used by the Modelo 100 art. 20 advisory to flag
#: a possibly-unapplied reduction (RNT inside the band but the general-reduction casilla
#: zero) — a ``no-silent-under-declaration`` safeguard. The DATED per-ejercicio schedule
#: is authoritative in the registry; this is the current (2024-2025) ceiling raised by
#: RDL 4/2024. Binding provision: Ley 35/2006 art. 20, schedule per RDL 4/2024 art. 3.1
#: (BOE-A-2024-12944).
MODELO_100_ART_20_TRABAJO_REDUCCION_RNT_CEILING_EUR: Final[Decimal] = Decimal("19747.50")
#: Art. 52.1 LIRPF (Ley 35/2006) individual-contribution sub-limit for the
#: reducción por aportaciones y contribuciones a sistemas de previsión social.
#: The joint reducción (casilla 0468) is capped at the lesser of 30% of net
#: yields and EUR 10.000, but a taxpayer whose aportaciones are PURELY
#: individual — no plan-de-empleo worker contribution (casilla 0426) and no
#: contribución empresarial (casilla 0427) backing the EUR 8.500 increment —
#: is bound by the lower EUR 1.500 general limit; the EUR 8.500 increment
#: "siempre que tal incremento provenga de contribuciones empresariales, o de
#: aportaciones del trabajador al mismo instrumento de previsión social".
#: Used by the Modelo 100 art. 52 advisory to flag a possible over-reduction
#: (a granted reducción above this sub-limit with no employer-linked backing)
#: — a ``no-silent-under-declaration`` safeguard pending the full individual/
#: employer contribution-split compute (Phase 2b,
#: ``2026-07-01-modelo-100-trabajo-casilla-compute-adr``). Binding provision:
#: Ley 35/2006 art. 52.1.
MODELO_100_ART_52_INDIVIDUAL_SUBLIMIT_EUR: Final[Decimal] = Decimal("1500")
#: Default IVA general-rate percentage for input/pre-fill purposes.
#: This is the LIVA art. 90 Uno general rate (Ley 37/1992, BOE-A-1992-28740)
#: currently in force for Spain (ES).
#: The DATED authoritative percentage lives in ``registry/aeat/iva/rates.toml``
#: and is resolved via :func:`domain.iva.lookup_rate`; this constant is
#: bound to that registry authority by a gate test so it cannot silently drift.
DEFAULT_IVA_GENERAL_RATE_PCT: Final[Decimal] = Decimal("21.00")
#: Secure-object namespace slug for Cl@ve Móvil auth diagnostics.
#: Used by the auth diagnostics service and the persistence namespace registry.
CLAVE_MOVIL_DIAGNOSTIC_NAMESPACE: Final[str] = "aeat.outbound.aeat.auth.clave_movil.diagnostics"
#: Modelos belonging to the *retenciones* aggregation family (withholding/retention filings).
#: Covers: M111 (labour income), M115 (leases), M123 (capital yields), M180 (lease annual),
#: M190 (labour annual summary), M193 (capital yields annual summary).
RETENCIONES_MODELOS: Final[tuple[Modelo, ...]] = (
Modelo.M111,
Modelo.M115,
Modelo.M123,
Modelo.M180,
Modelo.M190,
Modelo.M193,
)
#: Modelos belonging to the *counterpart* aggregation family (third-party declaration filings).
#: Covers: M347 (annual operations with third parties), M349 (intra-EU operations summary).
COUNTERPART_MODELOS: Final[tuple[Modelo, ...]] = (Modelo.M347, Modelo.M349)
#: Modelos belonging to the *foreign assets* aggregation family (overseas-asset declaration).
#: Covers: M720 (assets and rights abroad declaration per Ley 7/2012).
FOREIGN_ASSET_MODELOS: Final[tuple[Modelo, ...]] = (Modelo.M720,)
#: Modelos belonging to the *IVA regime* gating group (value-added tax periodic filings).
#: Covers: M303 (quarterly/monthly IVA self-assessment), M390 (IVA annual summary).
IVA_REGIME_MODELOS: Final[tuple[Modelo, ...]] = (Modelo.M303, Modelo.M390)
#: REBECA 50% exemption of qualifying maritime navigation income.
#: Applies to crew of REBECA-registered vessels and scheduled Canary Islands routes.
#: Binding provision: Ley 19/1994 art. 75.1 (BOE-A-1994-15794) fixes the 50 por 100
#: renta exenta; art. 73 establishes REBECA eligibility. Catalogue: ley-19-1994:art-75.
REBECA_MARITIME_EXEMPTION_FRACTION: Final[Decimal] = Decimal("0.50")
#: 3% amortización de inmuebles arrendados; rate fixed by RD 439/2007 (RIRPF) art. 14.2.a
#: ("3 por 100 sobre el mayor de coste de adquisición o valor catastral, excluido el suelo").
#: Deductibility base: Ley 35/2006 art. 23 (capital inmobiliario gastos deducibles).
AMORTIZACION_INMUEBLE_RATE: Final[Decimal] = Decimal("0.03")
#: Art. 81 LIRPF (Ley 35/2006, BOE-A-2006-20764) monthly accrual per hijo menor de tres años.
#: Proration of the €1,200 annual cap; casilla 0611 carries integer euros only.
DEDUCCION_MATERNIDAD_MENSUAL_EUR: Final[int] = 100
#: Art. 81 LIRPF (Ley 35/2006, BOE-A-2006-20764) annual cap per hijo menor de tres años.
#: The deducción accrues at €100/month and is capped at this amount per hijo; casilla 0611.
DEDUCCION_MATERNIDAD_ANUAL_CAP_EUR: Final[int] = 1200
#: Art. 81 LIRPF (Ley 35/2006, BOE-A-2006-20764) incremento por gastos de custodia en
#: guardería o centro de educación infantil autorizado, per hijo menor de tres años cap.
#: Capped at the lesser of real gastos, this amount × hijos_menores_3, and SS cotizaciones;
#: casilla 0613. Note: this is Art. 81 LIRPF (deducción maternidad supplemento), NOT Art. 81
#: bis (familia numerosa / discapacidad).
INCREMENTO_GUARDERIA_POR_HIJO_CAP_EUR: Final[int] = 1000
#: Art. 58.1 LIRPF (Ley 35/2006, BOE-A-2006-20764) ordinary mínimo-por-descendientes
#: age ceiling: a descendant qualifies for the ordinary mínimo while younger than 25
#: (exclusive) at year end, unless disabled (which removes the age limit).
MINIMO_DESCENDIENTE_MAX_AGE: Final[int] = 25
#: Art. 58.2 LIRPF (Ley 35/2006, BOE-A-2006-20764) bajo-3-años supplement age ceiling:
#: "Cuando el descendiente sea menor de tres años, el mínimo … se aumentará". The
#: additional mínimo applies to a descendant younger than 3 (exclusive) at year end.
MINIMO_MENOR_TRES_MAX_AGE: Final[int] = 3
#: Art. 61.4ª LIRPF (Ley 35/2006, BOE-A-2006-20764) custodia compartida prorrata
#: factor: under the normas comunes, when two contribuyentes have the right to the
#: same mínimo "su importe se prorrateará entre ellos por partes iguales" — a 50 %
#: split between the two custodial parents.
CUSTODIA_COMPARTIDA_PRORRATA_FACTOR: Final[Decimal] = Decimal("0.5")
#: LIRPF Disposición Transitoria 12ª (Ley 35/2006, BOE-A-2006-20764) reducción rate:
#: 40 % reducción on the part of a plan-de-pensiones capital rescate attributable to
#: contributions made on or before 31-12-2006.
DT12_RESCATE_REDUCCION_RATE: Final[Decimal] = Decimal("0.40")
#: Ley 44/2015 art. 14.1 (BOE-A-2015-11071) SAL/SLL reserva especial dotación rate:
#: 10 % of net profit endowed each year ("se dotará con el diez por ciento del
#: beneficio líquido de cada ejercicio").
SAL_RESERVA_DOTACION_RATE: Final[Decimal] = Decimal("0.10")
#: Ley 44/2015 art. 14.1 (BOE-A-2015-11071) SAL/SLL reserva especial accumulation cap
#: multiple: the reserve accrues until it exceeds twice the share capital ("hasta que
#: alcance al menos una cifra superior al doble del capital social").
SAL_RESERVA_CAPITAL_MULTIPLE: Final[Decimal] = Decimal("2")
#: LIVA art. 103.Dos (Ley 37/1992, BOE-A-1992-28740) prorrata especial mandatory
#: multiple: the especial regime is mandatory when the general-regime deduction
#: exceeds the especial-regime deduction by more than ten percent — i.e. when
#: ``deduction_general > deduction_especial * 1.10``.
PRORRATA_ESPECIAL_MANDATORY_MULTIPLE: Final[Decimal] = Decimal("1.10")
#: LIVA art. 9.1.c (Ley 37/1992, BOE-A-1992-28740) sectoral-separation threshold:
#: régimen de sectores diferenciados is mandatory when the spread between the highest
#: and lowest general prorrata across sectors exceeds fifty percentage points.
PRORRATA_SECTORAL_SEPARATION_SPREAD_PP: Final[Decimal] = Decimal("50")
[docs]
@lru_cache(maxsize=1)
def load_external_constants(path: Path | None = None) -> ExternalConstants:
"""Return the parsed external-constants registry.
Cached per-process; the first call reads and validates
``external_constants.toml`` from the package directory via
``importlib.resources`` so the resolution path is identical
under editable installs and built wheels. Passing ``path`` is reserved for
audits and tests that need to validate an alternate TOML payload against the
same schema.
Args:
path: Optional TOML file to parse instead of the packaged registry.
Returns:
The process-wide cached :class:`ExternalConstants` instance.
"""
if path is not None:
with path.open("rb") as handle:
payload = tomllib.load(handle)
else:
payload = tomllib.loads(files(__package__).joinpath("external_constants.toml").read_text(encoding="utf-8"))
return ExternalConstants.model_validate(payload)