Source code for cadrumo.domain.calculations.registry.applicability

"""Registry-grounded modelo-applicability derivation from the taxpayer model.

The overview surfaces (``explain`` / ``calendar`` / ``agenda`` /
``backlog``) used to treat every profile as an *autónomo en estimación
directa*: the :class:`~domain.deadlines.engine.DeadlineEngine` produces an
obligation for every modelo with a registered deadline window, and no
layer asked *which kind of taxpayer this is*. A pure landlord was told
Modelo 130 was overdue.

This module is the derivation layer: each modelo's ``applicable``
verdict is DERIVED from the three-axis
:class:`~domain.deadlines.models.TaxpayerProfile` model (entity type,
IRPF income categories, estimation regime) through a registry-grounded
rule table. The autónomo-by-default assumption is removed.

Four verdicts are possible:

* :attr:`ApplicabilityVerdict.APPLICABLE` — the taxpayer model
  triggers this modelo.
* :attr:`ApplicabilityVerdict.NOT_APPLICABLE` — the taxpayer model
  positively excludes this modelo (a landlord has no Modelo 130;
  an S.L. has no Modelo 100).
* :attr:`ApplicabilityVerdict.ATTRIBUTION_PASS_THROUGH` — the
  profile is an *attribution entity* (comunidad de bienes, sociedad
  civil sin objeto mercantil) and the modelo asked about is a cuota
  self-assessment (the IRPF Modelo 100 / 130 or the IS Modelo
  200 / 202). An attribution entity runs no IS and no IRPF cuota of
  its own — the régimen de atribución de rentas (LIRPF Title X
  Section 2) attributes the income to the members, who file the
  substantive tax. The honest answer to "what is my cuota" is
  "none — taxed in the members' returns". This is structurally
  distinct from a plain ``NOT_APPLICABLE``: a salaried-only natural
  person is positively excluded from Modelo 200 because they file a
  *different* cuota (Modelo 100); an attribution entity files *no*
  cuota at all.
* :attr:`ApplicabilityVerdict.INCOMPLETE` — the taxpayer model is
  undeclared (no ``entity_type`` and, for a natural person, no income
  categories), or the entity form is recognised-but-unsupported. The
  engine refuses to guess: it never reports a confident wrong
  obligation, and it never runs an IRPF cuota for a company or an IS
  cuota for an attribution entity.

The entity-type axis selects the *income-tax* route: a legal entity
routes to the IS path (Modelo 200 / 202), a natural person to the IRPF
path (Modelo 100 / 130), and an attribution entity to member
pass-through for cuota self-assessments. IVA and payer-fact modelos are
then decided by their own declared profile facts (IVA regime,
withholding-payer facts, trade thresholds). The engine routing contract
does not treat pass-through income taxation as an exemption from
non-income-tax obligations.

**Canonical applicability authority — modelo level.**
:data:`MODELO_APPLICABILITY_RULES` is the single canonical source for
modelo-level applicability. Any question of the form "does this
taxpayer ever owe this modelo?" is answered here. Code that derives
applicability verdicts MUST read from this table; it MUST NOT
re-implement the logic in another module or maintain a parallel copy
of the rules dict.

**Relation to ``applicability_conditions`` on ``ModeloDeadlineWindow``.**
``ModeloDeadlineWindow`` carries a
``applicability_conditions`` mapping that governs *window-level*
scheduling — which specific deadline window applies for a profile
within the set of applicable windows (e.g. Modelo 202 uses different
modality windows for the April / October / December instalments, and
some windows filter by ``entity_size``). These conditions are
COMPLEMENTARY to the modelo-level rules, not replacements:
``applicability_conditions`` operates after the modelo-level gate
confirms the modelo applies at all; it never overrides the modelo-level
verdict. Adding a condition to a deadline window does not affect the
``ApplicabilityVerdict`` returned by :func:`derive_modelo_applicability`.

Every rule carries ``legal_refs`` — scoped registry citation keys in
the ``law-slug:art-N`` form (e.g. ``ley-35-2006:art-99``) that resolve
against ``src/cadrumo/_data/registry/aeat/legal/*.toml`` — per
``.claude/rules/aeat-calculation-grounding.md``: applicability is
regulatory data and must be registry-grounded, and every typed-ID
reference must point at an existing registry entity. The seed table
below covers the core modelo set an ordinary taxpayer encounters —
the IRPF Renta and pago-fraccionado modelos (100 / 130 / 131), the
corporate IS modelos (200 / 202), the IVA modelos (303 / 390), the
retención modelos and their annual companions (111 / 190, 115 / 180),
the operaciones modelos (349 / 347), and the attribution-entity
informational Modelo 184. Per-entity / per-regime expansion to the
remaining registered modelos is intentionally deferred; a modelo with no
seed rule is reported INCOMPLETE with a rationale that says so.
"""

from __future__ import annotations

from collections.abc import Iterator, Mapping
from datetime import date
from enum import StrEnum
from typing import TYPE_CHECKING, Annotated, override

from pydantic import BaseModel, Field, StringConstraints

if TYPE_CHECKING:
    from .authority import PinnedAuthorityOperation, ValidatedRegistryAuthority

from ....core.modelo import Modelo
from ....core.models import STRICT_FROZEN_CONFIG as _STRICT_FROZEN
from ....core.time.clock import today_madrid
from ...contribuyente.entity_type import (
    EntityType,
    entity_type_attribution_entity_token,
    entity_type_natural_person_token,
    entity_type_tokens,
    require_entity_type,
)
from ...contribuyente.renta_codes import FiscalResidency
from ...deadlines.models import (
    IrpfEstimationRegime,
    IrpfIncomeCategory,
    IVARegime,
    TaxpayerProfile,
)
from ._applicability_labels import payer_fact_incomplete_label
from .applicability_payer_facts import PayerFactValue, payer_fact_holds, resolve_payer_fact
from .applicability_routes import TaxRoute, tax_route_for_entity_type
from .errors import RegistryFailureClassification, RegistryFailureCondition, RegistryValidationError
from .facts.resolution import MappingFactQuery, ResolvedMappingFact
from .ids import LegalRefId, ModeloId
from .irpf_income_categories import (
    irpf_income_category_actividad_economica_token,
    require_irpf_income_category,
)
from .irpf_regimes import irpf_estimation_regime_directa_normal_token, require_irpf_estimation_regime
from .iva_schema_vocabulary import iva_regime_self_assessment_tokens, require_iva_regime
from .schema_base import DateAxis
from .schema_revision_members import ApplicabilityRuleDefinition

type _OperatorReason = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]


[docs] class ApplicabilityVerdict(StrEnum): """Whether a modelo applies to a taxpayer, derived from its model. Attributes: APPLICABLE: The declared taxpayer model triggers this modelo. NOT_APPLICABLE: The declared taxpayer model positively excludes this modelo (e.g. a landlord has no Modelo 130 obligation; a sociedad limitada files no Modelo 100). ATTRIBUTION_PASS_THROUGH: The profile is an attribution entity and the modelo is a cuota self-assessment (Modelo 100 / 130 / 200 / 202). The entity runs no IS and no IRPF cuota of its own — the régimen de atribución de rentas (LIRPF Title X Section 2) attributes the income to the members, who file the substantive tax. The honest answer to "what is my cuota" is "none — the income is taxed in the members' returns". Distinct from ``NOT_APPLICABLE``, which means the taxpayer files a *different* cuota. INCOMPLETE: The taxpayer model is not declared in enough detail to decide, or the entity form is recognised-but-unsupported. The engine refuses to guess — the operator must declare their taxpayer type first. """ APPLICABLE = "applicable" NOT_APPLICABLE = "not_applicable" ATTRIBUTION_PASS_THROUGH = "attribution_pass_through" INCOMPLETE = "incomplete"
[docs] class ModeloApplicability(BaseModel): """The derived applicability of one modelo for one taxpayer profile. Attributes: modelo: The AEAT modelo identifier. verdict: The :class:`ApplicabilityVerdict` derived from the taxpayer model. reason: Operator-facing prose explaining the verdict. An ``INCOMPLETE`` verdict carries one of two distinct rationales: the "declare your taxpayer type first" guidance when the taxpayer model is undeclared, or a "no rule derived yet" notice when the modelo has no seed rule (the latter is not a statement about the operator's profile). legal_refs: Scoped registry citation keys (``law-slug:art-N``) grounding the rule, each resolvable against the registry ``legal/*.toml`` tables. Always at least one entry — applicability is regulatory data and must be grounded (``.claude/rules/aeat-calculation-grounding.md``). For an ``INCOMPLETE`` verdict the refs ground the *concept* being asked about (the LIRPF / LIS taxpayer definitions) so the operator still sees a citation. """ model_config = _STRICT_FROZEN modelo: ModeloId verdict: ApplicabilityVerdict reason: _OperatorReason legal_refs: tuple[LegalRefId, ...] = Field(min_length=1) failure: RegistryFailureClassification | None = None """Domain facts for a boundary to project when applicability is incomplete.""" @property def applicable(self) -> bool: """Return whether the modelo positively applies. Only :attr:`ApplicabilityVerdict.APPLICABLE` is a confident yes. ``NOT_APPLICABLE``, ``ATTRIBUTION_PASS_THROUGH`` and ``INCOMPLETE`` all yield ``False`` — the operative views must not surface an obligation the engine cannot positively justify. An attribution entity owes no cuota self-assessment, so a pass-through verdict is not an applicable obligation. """ return self.verdict is ApplicabilityVerdict.APPLICABLE
[docs] class ModeloApplicabilityRule(BaseModel): """A single registry-grounded modelo-applicability rule. A rule answers, for one modelo, the question "does the declared taxpayer model trigger this modelo?". The predicate is expressed as closed sets over the three taxpayer axes; evaluation never invents legal behaviour beyond what the seed table grounds. Attributes: modelo: The AEAT modelo identifier the rule decides. applicable_entity_types: The :class:`EntityType` values the modelo applies to. A taxpayer whose ``entity_type`` is outside this set gets :attr:`ApplicabilityVerdict.NOT_APPLICABLE`. required_income_categories: For a natural person, the IRPF income categories of which *at least one* must be declared for the modelo to apply. Empty means the modelo does not gate on income category (it applies to every natural person whose ``entity_type`` matches). Non-empty means a natural person without any of these categories gets ``NOT_APPLICABLE`` — this is the gate that excludes Modelo 130 for a pure landlord. required_estimation_regimes: The IRPF estimation regimes the modelo applies to. Empty means the modelo does not gate on the estimation regime. Non-empty means a natural person whose ``irpf_estimation_regime`` is outside the set gets ``NOT_APPLICABLE``. An undeclared regime resolves to the direct-estimation default: estimación directa is the default IRPF method (LIRPF art. 16; RIRPF art. 32 makes módulos opt-in), so an actividad-económica autónomo who has not explicitly elected módulos owes Modelo 130. This is the axis that splits Modelo 130 (estimación directa) from Modelo 131 (estimación objetiva): the two are mutually exclusive on the regime. applicable_fiscal_residencies: The fiscal residency categories the modelo applies to. Empty means the modelo does not gate on fiscal residency. An undeclared residency is kept on the resident-IRPF default path described by ``TaxpayerProfile``; a declared residency outside this set is a positive exclusion. applicable_iva_regimes: The IVA regimes that positively keep a modelo in scope. Empty means the modelo does not gate on IVA regime. Non-empty means a profile outside those regimes gets ``NOT_APPLICABLE``. This lets Modelo 303 / 390 be driven by the declared IVA obligation instead of borrowing the natural person's IRPF income-category axis for legal and attribution entities. required_payer_fact: The :class:`PayerFactValue` the modelo's applicability depends on, or ``None`` when the modelo does not gate on a payer fact. When set, a profile that positively declares the fact gets ``APPLICABLE``; a profile that does not gets ``INCOMPLETE`` — the underlying boolean has no tri-state, so the engine cannot positively justify a ``NOT_APPLICABLE`` (see :class:`PayerFactValue`). applicable_reason: Operator-facing prose for the ``APPLICABLE`` verdict. not_applicable_reason: Operator-facing prose for the ``NOT_APPLICABLE`` verdict. cuota_bearing: ``True`` when the modelo is a cuota self-assessment (the IRPF Modelo 100 / 130 or the IS Modelo 200 / 202). A cuota-bearing modelo asked of an *attribution entity* yields an :attr:`ApplicabilityVerdict.ATTRIBUTION_PASS_THROUGH` verdict rather than a plain ``NOT_APPLICABLE``: the entity runs no cuota of its own, the income is taxed in the members' returns. An informational modelo (Modelo 184) is *not* cuota-bearing — it stays a plain ``NOT_APPLICABLE`` for the entity types its ``applicable_entity_types`` excludes. legal_refs: Scoped registry citation keys (``law-slug:art-N``) grounding the rule, each resolvable against the registry ``legal/*.toml`` tables. """ model_config = _STRICT_FROZEN modelo: ModeloId applicable_entity_types: frozenset[EntityType] = Field(min_length=1) required_income_categories: frozenset[IrpfIncomeCategory] = frozenset() required_estimation_regimes: frozenset[IrpfEstimationRegime] = frozenset() applicable_fiscal_residencies: frozenset[FiscalResidency] = frozenset() applicable_iva_regimes: frozenset[IVARegime] = frozenset() required_payer_fact: PayerFactValue | None = None applicable_reason: _OperatorReason not_applicable_reason: _OperatorReason cuota_bearing: bool = False legal_refs: tuple[LegalRefId, ...] = Field(min_length=1) def _entity_type_result(self, profile: TaxpayerProfile) -> ModeloApplicability | None: if profile.entity_type in self.applicable_entity_types: return None if self.cuota_bearing and profile.entity_type == entity_type_attribution_entity_token(): return ModeloApplicability( modelo=self.modelo, verdict=ApplicabilityVerdict.ATTRIBUTION_PASS_THROUGH, reason=_registry_applicability_reason("attribution_pass_through.reason"), legal_refs=_ATTRIBUTION_PASS_THROUGH_LEGAL_REFS, ) return self._not_applicable() def _natural_person_axes_result(self, profile: TaxpayerProfile) -> ModeloApplicability | None: if profile.entity_type != entity_type_natural_person_token(): return None if self.required_income_categories: if not profile.irpf_income_categories: return _incomplete_applicability( self.modelo, # Reached only once the caller's own `profile.entity_type is # None` guard has already returned, so entity_type is # always declared by this point. entity_type_declared=True, ) if profile.irpf_income_categories.isdisjoint(self.required_income_categories): return self._not_applicable() if self.required_estimation_regimes: regime = profile.irpf_estimation_regime or irpf_estimation_regime_directa_normal_token() if regime not in self.required_estimation_regimes: return self._not_applicable() return None def _payer_fact_result(self, profile: TaxpayerProfile) -> ModeloApplicability | None: if self.required_payer_fact is None or payer_fact_holds(profile, self.required_payer_fact): return None return _undetermined_applicability( self.modelo, payer_fact=self.required_payer_fact, legal_refs=self.legal_refs, )
[docs] def evaluate(self, profile: TaxpayerProfile) -> ModeloApplicability: """Derive the :class:`ModeloApplicability` for ``profile``. Returns an ``INCOMPLETE`` verdict when the taxpayer model is not declared in enough detail to decide; an ``ATTRIBUTION_PASS_THROUGH`` verdict when the modelo is a cuota self-assessment asked of an attribution entity; otherwise an ``APPLICABLE`` / ``NOT_APPLICABLE`` verdict derived from the entity-type, income-category, estimation-regime, and payer-fact axes. Args: profile: The :class:`TaxpayerProfile` to evaluate against this rule. """ if profile.entity_type is None: return _incomplete_applicability(self.modelo, entity_type_declared=False) if (result := self._entity_type_result(profile)) is not None: return result if ( self.applicable_fiscal_residencies and profile.fiscal_residency is not None and profile.fiscal_residency not in self.applicable_fiscal_residencies ): return self._not_applicable() if self.applicable_iva_regimes and profile.iva_regime not in self.applicable_iva_regimes: return self._not_applicable() # The income-category and estimation-regime axes are # natural-person facts: a legal entity carries neither (income # categories and the IRPF estimation regime only describe a # persona física). A legal or attribution entity that matched # the entity-type and IVA-regime gates of a modelo applicable to # it (e.g. Modelo 303 / 390) is not re-gated on those axes. if (result := self._natural_person_axes_result(profile)) is not None: return result # The payer-fact axis (Modelo 111 / 115 / 349 / 347) can only be # asserted in the positive direction — the underlying boolean has # no tri-state, so an absent fact yields INCOMPLETE rather than a # NOT_APPLICABLE the engine cannot positively justify. if (result := self._payer_fact_result(profile)) is not None: return result return ModeloApplicability( modelo=self.modelo, verdict=ApplicabilityVerdict.APPLICABLE, reason=self.applicable_reason, legal_refs=self.legal_refs, )
def _not_applicable(self) -> ModeloApplicability: """Return the ``NOT_APPLICABLE`` applicability for this rule.""" return ModeloApplicability( modelo=self.modelo, verdict=ApplicabilityVerdict.NOT_APPLICABLE, reason=self.not_applicable_reason, legal_refs=self.legal_refs, )
[docs] def hydrate_applicability_rule(modelo: Modelo, fragment: ApplicabilityRuleDefinition) -> ModeloApplicabilityRule: """Hydrate a registry-authored applicability fragment into the runtime rule. The loader boundary for the ``applicability`` schema family: every free-form TOML string on ``fragment`` is resolved here to its typed registry token (or :class:`PayerFactValue`), never left as a raw string for a downstream branch to compare against. An unknown token raises :class:`RegistryValidationError` naming the offending rule and the underlying enum-coercion error, mirroring the coercion-boundary shape :data:`~._schema_base.RevisionReviewStatusField` and its siblings already use. Args: modelo: The modelo the owning revision belongs to; the fragment carries no self-referential ``modelo`` field to avoid a value that could silently diverge from the revision it is nested in. fragment: The validated :class:`ApplicabilityRuleDefinition` to hydrate. Returns: The equivalent :class:`ModeloApplicabilityRule`. Raises: RegistryValidationError: A field names a token not declared by the selected facts registry. """ try: return ModeloApplicabilityRule( modelo=modelo.value, applicable_entity_types=frozenset(require_entity_type(value) for value in fragment.applicable_entity_types), required_income_categories=frozenset( require_irpf_income_category(value) for value in fragment.required_income_categories ), required_estimation_regimes=frozenset( require_irpf_estimation_regime(value) for value in fragment.required_estimation_regimes ), applicable_fiscal_residencies=frozenset( FiscalResidency.from_registry(value) for value in fragment.applicable_fiscal_residencies ), applicable_iva_regimes=frozenset(require_iva_regime(value) for value in fragment.applicable_iva_regimes), required_payer_fact=resolve_payer_fact(fragment.required_payer_fact) if fragment.required_payer_fact is not None else None, applicable_reason=fragment.applicable_reason, not_applicable_reason=fragment.not_applicable_reason, cuota_bearing=fragment.cuota_bearing, legal_refs=fragment.legal_refs, ) except (ValueError, RegistryValidationError) as exc: raise RegistryValidationError( f"applicability rule {fragment.id!r} for modelo {modelo.value!r} does not hydrate: {exc}", ) from exc
# Scoped registry citation keys grounding the "declare your taxpayer # type first" answer. An undeclared profile cannot be decided, but the # verdict still carries the LIRPF / LIS articles that frame the question # the operator must answer. Both keys resolve in the registry legal # tables (irpf.toml / is.toml). _INCOMPLETE_LEGAL_REFS: tuple[LegalRefId, ...] = ( "ley-35-2006:art-99", # LIRPF art. 99 — IRPF contribuyente / pagos a cuenta. "ley-27-2014:art-124", # LIS art. 124 — obligación de declarar del IS. ) # Scoped registry citation keys grounding the attribution pass-through # verdict — the régimen de atribución de rentas. LIRPF art. 86 fixes # the general régimen (income attributed to socios / herederos / # comuneros / partícipes); LIRPF art. 87 defines which entities fall # under it (sociedades civiles sin objeto mercantil, comunidades de # bienes, herencias yacentes). Both keys resolve in the registry legal # table ``legal/irpf.toml``. _ATTRIBUTION_PASS_THROUGH_LEGAL_REFS: tuple[LegalRefId, ...] = ( "ley-35-2006:art-86", # LIRPF art. 86 — régimen general de atribución de rentas. "ley-35-2006:art-87", # LIRPF art. 87 — entidades en régimen de atribución. ) _APPLICABILITY_VERDICT_REASON_FACT_ID = "modelo-applicability-verdict-reason-catalogue" def _registry_applicability_reason(key: str, *, operation: PinnedAuthorityOperation | None = None) -> str: """Resolve an operator-facing verdict reason from the authored catalogue.""" if operation is None: from .authority import bundled_indexed_authority with bundled_indexed_authority().operation() as indexed_operation: return _registry_applicability_reason(key, operation=indexed_operation) resolved = operation.resolve_governed_fact( MappingFactQuery( fact_id=_APPLICABILITY_VERDICT_REASON_FACT_ID, date_axis=DateAxis.FILING_PERIOD, effective_date=today_madrid(), ), ) if not isinstance(resolved, ResolvedMappingFact): raise RegistryValidationError("modelo applicability reasons must resolve as a mapping fact") entries = {str(entry.key): str(entry.value) for entry in resolved.payload.entries} try: return entries[key] except KeyError as exc: raise RegistryValidationError(f"modelo applicability reason is missing {key!r}") from exc _INCOMPLETE_UNDECLARED_REASON = ( "No se puede determinar la aplicabilidad: el tipo de contribuyente no " "está declarado. Faltan el tipo de entidad y, en su caso, las " "categorías de renta del IRPF." ) """``INCOMPLETE`` rationale for an *undeclared taxpayer model*. Used only when the engine cannot decide because the profile itself is incomplete: no ``entity_type``, or a natural person with no declared IRPF income category against a category-gated rule. The guidance to declare the taxpayer type first is correct here. """ _INCOMPLETE_UNRULED_REASON = ( "No se puede determinar la aplicabilidad de este modelo: todavía no se " "ha derivado una regla de aplicabilidad para él. La cobertura de reglas " "es deliberadamente reducida (el conjunto inicial de personas) y la " "expansión por entidad y régimen está pendiente. No es una afirmación " "sobre su perfil: su tipo de contribuyente puede estar correctamente " "declarado." ) """``INCOMPLETE`` rationale for a *modelo with no seed rule*. Used when :data:`MODELO_APPLICABILITY_RULES` carries no rule for the requested modelo. The profile may be fully declared; this verdict is a statement about the seed coverage, not about the operator. It must never tell a declared operator to declare their taxpayer type. """ _INCOMPLETE_UNDETERMINED_REASON = ( "No se puede determinar la aplicabilidad de este modelo desde el modelo " "de contribuyente declarado: depende de un hecho que el perfil no " "expresa con certeza. El modelo solo se afirma aplicable cuando ese " "hecho se declara positivamente; en otro caso no se conjetura una " "obligación." ) _IMPATRIADO_M720_LEGAL_REFS: tuple[LegalRefId, ...] = ( "ley-35-2006:art-93", # LIRPF Art. 93 — régimen especial impatriados. "ley-7-2012:da-1", # Ley 7/2012 DA 1ª — obligación Modelo 720. "orden-hap-72-2013:art-1", # Orden HAP/72/2013 — aprobación Modelo 720. ) """Legal refs grounding the IRPF Art. 93 impatriado Modelo 720 exemption. An impatriado under LIRPF Art. 93 is taxed as a non-resident (IRNR) for the duration of the special regime. Modelo 720 (bienes en el extranjero) is an obligation reserved for IRPF residents; it does not extend to non-residents or to IRPF taxpayers who have opted into the IRNR-rate regime. The general Art. 93 key resolves in the registry table ``legal/irpf-impatriados.toml``; the two Modelo 720 keys resolve in the table ``legal/modelo-720.toml``. """ _IMPATRIADO_M151_ROUTE_LEGAL_REFS: tuple[LegalRefId, ...] = ( "ley-35-2006:art-93", # LIRPF Art. 93 — impatriados opt into IRNR taxation. "rd-439-2007:art-115", # RIRPF Art. 115 — duration of the special regime. "rd-439-2007:art-116", # RIRPF Art. 116 — option exercise / start-date selector. # Form orders for the Modelo 151 declaration, both eras. This was a single # ref to `orden-eha-2887-2008:modelo-151`, which the registry retired as a # stub whose document_id never resolved to real text; it grounded nothing and # was absent from the legal catalogue, so this tuple carried a dangling id. # The real instruments are bundled: Orden HAP/2783/2015 governs 2015-2022 and # Orden HFP/1338/2023 governs ejercicio 2023 onward, per its own Disposicion # Final Segunda(a). Both are cited because the route's grounding spans the # whole regime rather than one filing year. "orden-hap-2783-2015:art-1", "orden-hfp-1338-2023:art-1", ) """Legal refs grounding the Art. 93 Modelo 151 route and M100 suppression.""" _IMPATRIADO_M100_SUPPRESSED_REASON = ( "Modelo 100 no aplica: el contribuyente tiene activo el régimen especial " "de trabajadores, profesionales, emprendedores e inversores desplazados " "a territorio español (LIRPF Art. 93) dentro de la ventana de seis " "ejercicios. Durante esa ventana tributa por las reglas del IRNR " "manteniendo la condición de contribuyente IRPF, y la declaración anual " "correspondiente es el Modelo 151, no el Modelo 100." ) """``NOT_APPLICABLE`` rationale for suppressing M100 during Art. 93.""" _IMPATRIADO_M151_APPLICABLE_REASON = ( "Modelo 151 aplica: el contribuyente tiene activo el régimen especial de " "impatriados del Art. 93 LIRPF dentro de la ventana de seis ejercicios " "del año de opción y los cinco siguientes; la declaración anual del " "régimen se presenta por Modelo 151." ) """``APPLICABLE`` rationale for the active Art. 93 Modelo 151 route.""" _IMPATRIADO_M151_NOT_APPLICABLE_REASON = ( "Modelo 151 no aplica: el perfil no tiene activo el régimen especial de " "impatriados del Art. 93 LIRPF dentro de su ventana de seis ejercicios. " "Fuera de esa ventana, o sin opción por el régimen, la persona física " "residente vuelve a la ruta ordinaria del IRPF y al Modelo 100 cuando " "proceda." ) """``NOT_APPLICABLE`` rationale for M151 outside the active Art. 93 window.""" def _incomplete_applicability( modelo: str, *, unruled: bool = False, entity_type_declared: bool = False, ) -> ModeloApplicability: """Return the explicit ``INCOMPLETE`` applicability for ``modelo``. The safe default: the engine never assumes autónomo and never reports a confident wrong obligation. The two ``INCOMPLETE`` causes are structurally distinct and carry distinct rationale: Args: modelo: The AEAT modelo identifier the verdict decides. unruled: ``True`` when the cause is a *missing seed rule* for the modelo — the profile may be fully declared. ``False`` (the default) when the cause is an *undeclared taxpayer model*. entity_type_declared: Whether the profile supplied its entity-type fact. Retained as a fact for the application boundary; it is not a domain recovery instruction. Returns: A :class:`ModeloApplicability` with ``INCOMPLETE`` verdict and the appropriate rationale for the given cause. """ reason = _INCOMPLETE_UNRULED_REASON if unruled else _INCOMPLETE_UNDECLARED_REASON return ModeloApplicability( modelo=modelo, verdict=ApplicabilityVerdict.INCOMPLETE, reason=reason, legal_refs=_INCOMPLETE_LEGAL_REFS, failure=( None if unruled else RegistryFailureClassification( condition=RegistryFailureCondition.TAXPAYER_MODEL_DECLARED, facts={ "modelo": modelo, "taxpayer_model_declared": False, "entity_type_declared": entity_type_declared, }, ) ), ) def _undetermined_applicability( modelo: str, *, payer_fact: PayerFactValue, legal_refs: tuple[LegalRefId, ...], ) -> ModeloApplicability: """Return the ``INCOMPLETE`` applicability for a fact only the taxpayer can supply. Used when a modelo gates on a :class:`PayerFactValue` (Modelo 111 / 115 / 349 / 347 / 720 / 721) and the profile does not positively declare the fact. The taxpayer model itself may be fully declared — the entity type and regime are known — but the payer fact has no tri-state, so the engine refuses to guess a ``NOT_APPLICABLE`` it cannot positively justify. The rationale is distinct from the *undeclared taxpayer model* one: it never tells a declared operator to declare their taxpayer type. Args: modelo: The AEAT modelo identifier the verdict decides. payer_fact: The specific profile fact required to positively establish applicability. legal_refs: The concrete rule legal refs that ground the payer-fact requirement, pending the taxpayer's own answer. Returns: A :class:`ModeloApplicability` with ``INCOMPLETE`` verdict and the undetermined-payer-fact rationale. """ return ModeloApplicability( modelo=modelo, verdict=ApplicabilityVerdict.INCOMPLETE, reason=( f"{_INCOMPLETE_UNDETERMINED_REASON} " f"Hecho requerido para este modelo: {payer_fact_incomplete_label(payer_fact)}." ), legal_refs=legal_refs, ) # --------------------------------------------------------------------- # Seed rule table — core persona coverage, deliberately narrow # --------------------------------------------------------------------- # # Every rule below is grounded against the registry legal tables for the # taxpayer-type applicability model. Citation keys are scoped registry # keys (``law-slug:art-N``) that resolve against # ``src/cadrumo/_data/registry/aeat/legal/*.toml`` — never URLs, never # invented slugs. Full per-entity / per-regime coverage of every # registered modelo is a deferred expansion. def _iva_seed_applicability_rule(modelo: str) -> ModeloApplicabilityRule: """Build an IVA seed rule from the governed facts in the active scope. These rules remain Python-authored until their export fragments migrate to the registry, but none of their governed vocabulary may be captured at module import. Development validation can compile several candidates in one process; resolving here makes each lookup observe that candidate's fact scope instead of the first candidate that happened to import this module. """ if modelo == "390": return ModeloApplicabilityRule( modelo=modelo, applicable_entity_types=frozenset(entity_type_tokens()), required_income_categories=frozenset({irpf_income_category_actividad_economica_token()}), applicable_iva_regimes=iva_regime_self_assessment_tokens(), applicable_reason=( "Modelo 390 (resumen anual del IVA): el contribuyente realiza " "una actividad económica sujeta al IVA y presenta la " "declaración-resumen anual del impuesto." ), not_applicable_reason=( "Modelo 390 no aplica: sin una actividad económica sujeta al " "IVA no hay declaración-resumen anual del impuesto." ), legal_refs=("rd-1624-1992:art-71", "orden-eha-3111-2009:art-1"), ) if modelo == "303": return ModeloApplicabilityRule( modelo=modelo, applicable_entity_types=frozenset(entity_type_tokens()), required_income_categories=frozenset({irpf_income_category_actividad_economica_token()}), applicable_iva_regimes=iva_regime_self_assessment_tokens(), applicable_reason=( "Modelo 303 (autoliquidación del IVA): el contribuyente " "realiza una actividad económica sujeta al IVA y presenta la " "autoliquidación periódica." ), not_applicable_reason=( "Modelo 303 no aplica: sin una actividad económica sujeta al " "IVA no hay autoliquidación periódica del impuesto." ), legal_refs=("ley-37-1992:art-99",), ) raise KeyError(modelo) class _SeedApplicabilityRules(Mapping[str, ModeloApplicabilityRule]): """Read-only seed-rule mapping whose values follow the active fact scope.""" _MODELOS = ("303", "390") @override def __getitem__(self, modelo: str) -> ModeloApplicabilityRule: if modelo not in self._MODELOS: raise KeyError(modelo) return _iva_seed_applicability_rule(modelo) @override def __iter__(self) -> Iterator[str]: return iter(self._MODELOS) @override def __len__(self) -> int: return len(self._MODELOS) MODELO_APPLICABILITY_RULES: Mapping[str, ModeloApplicabilityRule] = _SeedApplicabilityRules() """Seed modelo-applicability rules — core persona coverage. A modelo absent from this table has no derived rule yet: its applicability is reported :attr:`ApplicabilityVerdict.INCOMPLETE` with a rationale naming the deferred expansion, never a confident guess. """ #: Modelo ids whose applicability rule is authored in the registry rather than #: declared as a Python literal in :data:`MODELO_APPLICABILITY_RULES` below. #: 303 and 390 stay literal -- their authoring trees are owned by the #: export-fragment-generator-authority campaign, not unplaced by omission. #: #: Membership is reached two ways, and the difference matters when reading a #: rule's provenance. Most entries arrived by MIGRATION: every revision of each #: was hydration-verified equal to the literal it replaces, through the real #: loader, before it was added here and the literal deleted in the same commit. #: Modelo 840 arrived by AUTHORING instead -- it never had a literal to be #: verified against, and its rule is grounded directly on TRLRHL arts. 78, 82 #: and 90 with real-profile verdicts asserted in #: ``test_modelo_840_applicability``. A migrated entry's guarantee is #: equivalence; an authored entry's is its citations and its tests, and no #: count is stated here because a tally of either goes stale on the next entry. #: #: This is the single declaration of the mixed-surface state #: ``MODELO_APPLICABILITY_RULES`` is now in: these modelos resolve from the #: registry, 303 and 390 still resolve from the literal table. The literal #: table retires outright once the export-fragment campaign closes those two #: trees and they are migrated the same way; this module then stops authoring #: applicability data at all -- it only reads it. REGISTRY_RESOLVED_APPLICABILITY_MODELOS: frozenset[Modelo] = frozenset( { Modelo("100"), Modelo("111"), Modelo("115"), Modelo("117"), Modelo("123"), Modelo("126"), Modelo("128"), Modelo("130"), Modelo("131"), Modelo("136"), Modelo("151"), Modelo("180"), Modelo("184"), Modelo("187"), Modelo("188"), Modelo("190"), Modelo("193"), Modelo("194"), Modelo("200"), Modelo("202"), Modelo("210"), Modelo("216"), Modelo("232"), Modelo("296"), Modelo("322"), Modelo("347"), Modelo("349"), Modelo("353"), Modelo("360"), Modelo("369"), Modelo("714"), Modelo("720"), Modelo("721"), Modelo("840"), }, )
[docs] def resolve_applicability_rule_from_authority( authority: ValidatedRegistryAuthority, modelo: Modelo, ) -> ModeloApplicabilityRule: """Resolve one modelo's applicability rule from an already-loaded authority. Reads the UNVALIDATED :class:`~._schema.ModeloDefinition` (``authority.modelo(...)``), not ``validate_modelo``/``snapshot``: applicability derivation is a pervasive, taxpayer-facing read on every profile view, and coupling its availability to full business-rule validation or the review-status filing gate would make an unrelated validation defect elsewhere in the tree break every taxpayer's applicability answer. The fragment was already validated once, when the registry authority was built and published. This is a deliberate asymmetry with :class:`~._schema.RegistrySnapshot`, which carries a same-shaped projection for every OTHER schema family: applicability answers "is this modelo due, and to whom" -- the floor rung of the authority-grade ladder, scheduling reach, not filing authority. ``RegistrySnapshot`` is a filing-context projection one rung up. Resolving applicability without filing-grade review is correct per that ladder, not a gate dodged; coupling it to snapshot construction would wrongly tie a floor-rung fact to filing authority it does not need. Applicability content is uniform across a modelo's declared revisions today (the migrator authors the identical rule into every one), so any revision carrying the family answers the question -- the first one found is used. Split out from :func:`_resolve_registry_applicability_rule` so the real logic takes its authority as a parameter and is testable against a scratch :class:`ValidatedRegistryAuthority` without touching the bundled tree or monkeypatching anything; the production wrapper leases the indexed generation only when a caller has not already supplied an operation. Raises: RegistryValidationError: No declared revision carries an ``applicability`` rule, or the rule fails to hydrate. """ from .queries import RegistryQueryService for _modelo_id, revision in RegistryQueryService(authority).iter_modelo_revisions( modelo_codes=(modelo.value,), ): if revision.applicability: return hydrate_applicability_rule(modelo, revision.applicability[0]) raise RegistryValidationError( f"modelo {modelo.value!r} is declared in REGISTRY_RESOLVED_APPLICABILITY_MODELOS but no " "declared revision carries an applicability rule", )
[docs] def resolve_applicability_rule_from_operation( operation: PinnedAuthorityOperation, modelo: Modelo, ) -> ModeloApplicabilityRule: """Resolve one modelo's applicability rule from a generation-pinned directory.""" directory = operation.modelo_directory(modelo.value) for metadata in directory.revisions: revision = operation.revision(modelo.value, str(metadata.id)) if revision.applicability: return hydrate_applicability_rule(modelo, revision.applicability[0]) raise RegistryValidationError( f"modelo {modelo.value!r} is declared in REGISTRY_RESOLVED_APPLICABILITY_MODELOS but no " "declared revision carries an applicability rule", )
def _resolve_registry_applicability_rule( modelo: Modelo, *, authority: ValidatedRegistryAuthority | None = None, operation: PinnedAuthorityOperation | None = None, ) -> ModeloApplicabilityRule: """Resolve one modelo's applicability rule from the pinned registry generation. The import is function-local by necessity, not preference: ``_authority`` transitively imports THIS module already, through the build-validation dispatch chain (``_authority`` -> ``_snapshot`` -> ``_validate`` -> ``_validate_revision_sections`` -> ``validate_applicability_section`` -> here), so a module-level authority import would close a real cycle. Resolving on first call, long after both modules have finished importing, is the same discipline used by the shared-catalogue compiler -- module-body evaluation is the hazard, first-call resolution is not. No local cache sits in front of this call: the indexed operation already owns generation pinning and storage validation. Caching here would bypass the caller's generation boundary and re-introduce a path-only registry cache, exactly the defect that consolidation removed. """ if authority is not None: return resolve_applicability_rule_from_authority(authority, modelo) if operation is not None: return resolve_applicability_rule_from_operation(operation, modelo) from .authority import bundled_indexed_authority with bundled_indexed_authority().operation() as indexed_operation: return resolve_applicability_rule_from_operation(indexed_operation, modelo) def _modelo_applicability_rule( modelo: str, *, authority: ValidatedRegistryAuthority | None = None, operation: PinnedAuthorityOperation | None = None, ) -> ModeloApplicabilityRule | None: """Return ``modelo``'s applicability rule, resolved from the registry or the literal table. The single seam every consumer (:func:`derive_modelo_applicability` and :func:`iter_modelo_applicability_rules`) reads through, so the mixed-surface split (registry-resolved vs. still-literal) is decided in exactly one place. An unrecognised ``modelo`` string -- not a member of either surface -- returns ``None``, matching the pre-cutover dict-lookup behaviour exactly; it is never an error to ask about an unruled modelo. """ if modelo in REGISTRY_RESOLVED_APPLICABILITY_MODELOS: return _resolve_registry_applicability_rule(Modelo(modelo), authority=authority, operation=operation) return MODELO_APPLICABILITY_RULES.get(modelo)
[docs] def iter_modelo_applicability_rules() -> tuple[ModeloApplicabilityRule, ...]: """Return every registry-resolved or seed-literal :class:`ModeloApplicabilityRule`. The returned tuple is ordered by modelo id for deterministic audits and tests. Callers receive rule objects, not the mutable module-level dictionary, so the registry rule table remains read-only from the public API. """ known_modelos = sorted( {str(modelo) for modelo in MODELO_APPLICABILITY_RULES} | REGISTRY_RESOLVED_APPLICABILITY_MODELOS, ) return tuple(rule for modelo in known_modelos if (rule := _modelo_applicability_rule(modelo)) is not None)
[docs] def modelo_requires_iva_regime(modelo: str) -> bool: """Return whether the modelo's applicability rule depends on IVA regime. Calendar completeness is a consumer of the same applicability predicate that determines whether a taxpayer can have the modelo obligation. Keep that classification here, where registry-resolved and seed rules already meet, rather than maintaining a second calendar or core-constants set. """ rule = _modelo_applicability_rule(modelo) return rule is not None and bool(rule.applicable_iva_regimes)
[docs] def taxpayer_model_is_declared(profile: TaxpayerProfile) -> bool: """Return whether the profile carries a usable taxpayer model. The taxpayer model is "declared" when the operator has set an ``entity_type`` and — for a natural person — at least one IRPF income category. Without these, modelo applicability cannot be derived: the engine must report ``INCOMPLETE`` rather than assume autónomo. A legal / attribution entity needs no income category; the ``entity_type`` alone selects its tax. Args: profile: The :class:`TaxpayerProfile` to inspect. """ if profile.entity_type is None: return False if profile.entity_type == entity_type_natural_person_token(): return bool(profile.irpf_income_categories) return True
[docs] def derive_tax_route(profile: TaxpayerProfile) -> TaxRoute: """Return the tax branch ``profile`` routes to. The routing contract: the ``entity_type`` axis selects the tax. A legal-entity profile routes to the Impuesto sobre Sociedades (Modelo 200 / 202); a natural person to the IRPF (Modelo 100 / 130 / 303); an attribution entity to the member pass-through. An undeclared ``entity_type`` yields :attr:`TaxRoute.INCOMPLETE` — the engine never runs an IRPF cuota for a company or an IS cuota for an attribution entity, and never defaults a tax for a profile that declared none. Args: profile: The :class:`TaxpayerProfile` whose ``entity_type`` axis selects the tax branch. Returns: The :class:`TaxRoute` branch the profile's ``entity_type`` selects, or :attr:`TaxRoute.INCOMPLETE` when ``entity_type`` is undeclared. """ if profile.entity_type is None: return TaxRoute.INCOMPLETE return tax_route_for_entity_type(profile.entity_type)
[docs] def derive_modelo_applicability( profile: TaxpayerProfile, modelo: str, *, today: date | None = None, authority: ValidatedRegistryAuthority | None = None, operation: PinnedAuthorityOperation | None = None, ) -> ModeloApplicability: """Derive a modelo's applicability from the taxpayer model. The verdict is DERIVED from the three-axis :class:`~domain.deadlines.models.TaxpayerProfile` model — never assumed. An undeclared taxpayer model yields an explicit :attr:`ApplicabilityVerdict.INCOMPLETE` answer; the engine never reports a confident wrong obligation. A modelo without a seed rule (the seed covers the core persona set only) is also reported ``INCOMPLETE`` so the operator is never told a confident yes/no the registry rules cannot yet justify; the rationale points at the deferred expansion. Args: profile: The operator's three-axis taxpayer model. modelo: The AEAT modelo identifier to decide. today: Reference date for the Beckham window check. Defaults to the Europe/Madrid civil date (``today_madrid()``) when ``None`` — the six-year window is a Spanish-calendar boundary. Pass an explicit date in tests so results are deterministic. authority: Already-resolved validated authority to reuse. Omitting it preserves the standalone fingerprint-bounded bundled-tree lookup. operation: Already-pinned indexed operation to reuse for point-loaded applicability facts. Omitting it leases the bundled indexed generation for this call. Returns: The :class:`ModeloApplicability` for ``modelo`` and ``profile``. Core types: :class:`~cadrumo.domain.calculations.registry.authority.ValidatedRegistryAuthority`. """ _today = today if today is not None else today_madrid() beckham_window_active = profile.beckham_window_active(_today) # The Art. 93 impatriado route is a modelo-level switch while the # six-year Beckham window is active: the annual declaration is Modelo # 151, and the ordinary Renta self-assessment (Modelo 100) is not the # filing route. Once the window expires, both modelos fall back to their # ordinary applicability rules: M100 through the seed table below, M151 # to a positive NOT_APPLICABLE. if beckham_window_active and modelo == Modelo("100"): return ModeloApplicability( modelo=Modelo("100"), verdict=ApplicabilityVerdict.NOT_APPLICABLE, reason=_IMPATRIADO_M100_SUPPRESSED_REASON, legal_refs=_IMPATRIADO_M151_ROUTE_LEGAL_REFS, ) if modelo == Modelo("151"): return ModeloApplicability( modelo=Modelo("151"), verdict=(ApplicabilityVerdict.APPLICABLE if beckham_window_active else ApplicabilityVerdict.NOT_APPLICABLE), reason=( _IMPATRIADO_M151_APPLICABLE_REASON if beckham_window_active else _IMPATRIADO_M151_NOT_APPLICABLE_REASON ), legal_refs=_IMPATRIADO_M151_ROUTE_LEGAL_REFS, ) # An impatriado (LIRPF Art. 93 special regime) is taxed as a non-resident # for the duration of the six-year Beckham window (RIRPF Art. 116.1) and # is therefore exempt from IRPF-resident obligations. Modelo 720 (bienes # en el extranjero) is one of those obligations: it applies to IRPF # residents, not to non-resident taxpayers under Art. 93. Enforce the # exemption before the rule table so the payer-fact gate is never reached. # Year-7+ filers whose window has expired revert to the general IRPF # regime and owe M720 again — the window-expiry check is wired here. if modelo == Modelo("720") and beckham_window_active: return ModeloApplicability( modelo=Modelo("720"), verdict=ApplicabilityVerdict.NOT_APPLICABLE, reason=_registry_applicability_reason("impatriado_m720_exempt.reason", operation=operation), legal_refs=_IMPATRIADO_M720_LEGAL_REFS, ) rule = _modelo_applicability_rule(modelo, authority=authority, operation=operation) if rule is None: return _incomplete_applicability(modelo, unruled=True) return rule.evaluate(profile)
[docs] def derive_taxpayer_files_economic_activity(profile: TaxpayerProfile) -> bool | None: """Whether the taxpayer files actividad-económica pagos fraccionados (130/131). Reads the :class:`TaxpayerProfile` income-category declarations. ``True`` when the profile declares actividad-económica income; ``False`` when it declares income categories that exclude it (a salaried/rental-only filer never files 130/131); ``None`` when income categories are undeclared (fail-closed: the 130/131 dependency stays enforced). LIRPF art. 99 / RIRPF art. 109. """ if not profile.irpf_income_categories: return None return irpf_income_category_actividad_economica_token() in profile.irpf_income_categories
__all__ = [ "MODELO_APPLICABILITY_RULES", "ApplicabilityVerdict", "ModeloApplicability", "ModeloApplicabilityRule", "derive_modelo_applicability", "derive_tax_route", "derive_taxpayer_files_economic_activity", "iter_modelo_applicability_rules", "modelo_requires_iva_regime", "taxpayer_model_is_declared", ]