Source code for aeat.domain.fincas._tier_resolver

"""LIRPF art. 23.2 four-tier auto-resolver post Ley 12/2023.

Implements the BOE priority order (90 → 70 → 60 → 50, highest
applicable wins) per disposición final segunda apartado uno of
Ley 12/2023, de 24 de mayo (BOE-A-2023-12203).

Effective-date dispatch:

  - ``period_year < ejercicio_amendment_year`` (default 2024) →
    flat 60 % under the pre-amendment art. 23.2 wording, regardless
    of contract date.
  - ``period_year >= ejercicio_amendment_year`` AND
    ``contract_celebration_date < ley_12_2023_in_force_date``
    (2023-05-26) → flat 60 % under DT 38ª (LIRPF redacción vigente
    a 31/12/2021).
  - Otherwise → four-tier dispatch.

LAU art. 17.6 non-compliance forfeits the reducción entirely
(:attr:`domain.fincas.ReduccionTier.FORFEIT_LAU_17_6`) — checked
before any tier evaluation per the closing paragraph of the rewritten
apartado 2.

Tier 70-b-1 (joven inquilino) carries a per-co-tenant qualifying
share: BOE explicitly states "Cuando existan varios arrendatarios
de una misma vivienda, esta reducción se aplicará sobre la parte
del rendimiento neto que proporcionalmente corresponda a los
arrendatarios que cumplan los requisitos previstos en esta letra".
The resolver returns ``qualifying_share = qualifying_co_tenant_count
/ tenant_count`` for that tier; every other tier returns share=1.
"""

from __future__ import annotations

from datetime import date
from decimal import Decimal

from pydantic import BaseModel, Field

from ...core import STRICT_FROZEN_CONFIG, Modelo
from ...core.logging import get_logger
from ..calculations.registry import LegalRefId
from ._enums import ReduccionTier, UseType
from ._errors import TierResolutionError
from ._models import Arrendamiento, Finca

_logger = get_logger(__name__)

LEY_12_2023_IN_FORCE_DATE: date = date(2023, 5, 26)
"""Entry-into-force date of Ley 12/2023 (BOE-A-2023-12203). Contracts
celebrated on or after this date are subject to the new four-tier
framework once the IRPF amendment also takes legal effect."""

DEFAULT_EJERCICIO_AMENDMENT_YEAR: int = 2024
"""First ejercicio in which the new IRPF apartado 2 art. 23 wording
applies (Ley 12/2023 disposición final novena segundo párrafo)."""

REHAB_LOOKBACK_DAYS: int = 730
"""``2 años anteriores`` interpreted as 730 calendar days (2 * 365).
The BOE wording ("en los dos años anteriores a la fecha de la
celebración del contrato") permits exact-day arithmetic; the
project picks 730-day lookback as the deterministic boundary."""

PRIOR_RENT_REBAJA_THRESHOLD: Decimal = Decimal("0.05")
"""Tier 90-a threshold: ``más de un 5 por ciento``. Strict ``>``
comparison — exactly 5 % does not qualify (BOE wording is "en más
de un 5 por ciento", not "en al menos un 5 por ciento")."""

JOVEN_TENANT_AGE_MIN: int = 18
JOVEN_TENANT_AGE_MAX: int = 35
"""Tier 70-b-1 inclusive age range (``BOE``: "una edad comprendida entre
18 y 35 años")."""

_LIRPF_ART_23_CURRENT_LEGAL_REFS: tuple[LegalRefId, ...] = ("ley-35-2006:art-23",)
_LIRPF_ART_23_2021_LEGAL_REFS: tuple[LegalRefId, ...] = ("ley-35-2006:art-23-2021",)
_LIRPF_DT_38_LEGAL_REFS: tuple[LegalRefId, ...] = (
    "ley-35-2006:dt-38",
    "ley-35-2006:art-23-2021",
)


[docs] class TierResolution(BaseModel): """Outcome of a single :func:`resolve_reduccion` invocation. Attributes: tier: Closed-enum tier identifier. reduccion_pct: Numeric reducción percentage as a Decimal in ``[0, 1]``. Multiplied by ``qualifying_share`` and the per-contract rendimiento neto positivo to compute the rental reduction amount. qualifying_share: Fraction of rendimiento neto eligible for the tier reducción. Always ``1`` except for tier 70-b-1 with mixed-qualification co-tenants. legal_refs: Registry legal-reference ids grounding the resolution in the bundled legal catalogue and corpus. """ model_config = STRICT_FROZEN_CONFIG tier: ReduccionTier reduccion_pct: Decimal = Field(ge=Decimal("0"), le=Decimal("1")) qualifying_share: Decimal = Field(ge=Decimal("0"), le=Decimal("1")) legal_refs: tuple[LegalRefId, ...] = Field(min_length=1)
_FORFEIT_LAU_17_6 = TierResolution( tier=ReduccionTier.FORFEIT_LAU_17_6, reduccion_pct=Decimal("0"), qualifying_share=Decimal("0"), legal_refs=_LIRPF_ART_23_CURRENT_LEGAL_REFS, ) _DT_38 = TierResolution( tier=ReduccionTier.TIER_60_GRANDFATHERED_DT38, reduccion_pct=Decimal("0.60"), qualifying_share=Decimal("1"), legal_refs=_LIRPF_DT_38_LEGAL_REFS, ) _PRE_AMENDMENT = TierResolution( tier=ReduccionTier.TIER_60_GRANDFATHERED_DT38, reduccion_pct=Decimal("0.60"), qualifying_share=Decimal("1"), legal_refs=_LIRPF_ART_23_2021_LEGAL_REFS, ) _TIER_50 = TierResolution( tier=ReduccionTier.TIER_50, reduccion_pct=Decimal("0.50"), qualifying_share=Decimal("1"), legal_refs=_LIRPF_ART_23_CURRENT_LEGAL_REFS, ) _TIER_60_REHAB = TierResolution( tier=ReduccionTier.TIER_60_REHAB, reduccion_pct=Decimal("0.60"), qualifying_share=Decimal("1"), legal_refs=_LIRPF_ART_23_CURRENT_LEGAL_REFS, ) _TIER_70_PUBLIC_ADMIN = TierResolution( tier=ReduccionTier.TIER_70_PUBLIC_ADMIN, reduccion_pct=Decimal("0.70"), qualifying_share=Decimal("1"), legal_refs=_LIRPF_ART_23_CURRENT_LEGAL_REFS, ) _TIER_90 = TierResolution( tier=ReduccionTier.TIER_90, reduccion_pct=Decimal("0.90"), qualifying_share=Decimal("1"), legal_refs=_LIRPF_ART_23_CURRENT_LEGAL_REFS, ) def _with_registry_rate(template: TierResolution, period_year: int, tier_id: str) -> TierResolution: """Return ``template`` with its reducción rate sourced from the registry. Wires a four-tier dispatch result to the law-determined ``renta-<period_year>-rental-reduccion-rate-<tier_id>`` Modelo-100 parameter so the registry is the causal authority for the numeric rate. When the registry rate equals the template's documented rate — every supported year currently matches — the frozen singleton is returned unchanged, preserving identity and behaviour. Only the genuine four-tier results route through here; the grandfathering (pre-amendment / DT-38) and forfeit resolutions are distinct provisions and keep their documented constant. """ rate = _resolve_tier_reduccion_rate(period_year, tier_id) if rate == template.reduccion_pct: return template return template.model_copy(update={"reduccion_pct": rate})
[docs] def resolve_reduccion( contract: Arrendamiento, finca: Finca, period_year: int, *, ejercicio_amendment_year: int | None = None, ) -> TierResolution: """Resolve the LIRPF art. 23.2 reducción tier for ``contract`` in ``period_year``. Args: contract: The per-contract metadata record. finca: The owning finca (provides ``is_stressed_area``). period_year: Ejercicio for which the reducción is being computed. ejercicio_amendment_year: First ejercicio on which the new apartado 2 wording applies (default 2024 per Ley 12/2023 disposición final novena). Returns: :class:`TierResolution` carrying the tier, the numeric reducción percentage, the qualifying share, and the registry legal-reference ids. Raises: TierResolutionError: If ``finca.use_type`` is not eligible for the LIRPF Art. 23.2 reducción — the article applies only to ``arrendamientos de bienes inmuebles destinados a vivienda``, which the second paragraph excludes for touristic / temporary rentals and which the use-type mapping further excludes for commercial premises and non-rented use types. """ if finca.use_type is not UseType.VIVIENDA_ARRENDADA: raise TierResolutionError( f"reducción art. 23.2 LIRPF refused: finca {finca.id} use_type " f"{finca.use_type.value!r} is not VIVIENDA_ARRENDADA. The reducción " f"applies only to arrendamientos destinados a vivienda permanente; " f"the second paragraph of art. 23.2 excludes touristic / temporary " f"rentals (VIVIENDA_TURISTICA), and the article scope excludes " f"commercial premises (LOCAL_COMERCIAL) and non-rented use types.", ) resolved_amendment_year = ( ejercicio_amendment_year if ejercicio_amendment_year is not None else _resolve_ejercicio_amendment_year(period_year) ) if period_year < resolved_amendment_year: _logger.debug( "reduccion tier: pre-amendment flat 60%% for contract_id=%s period=%d", contract.id, period_year, ) return _PRE_AMENDMENT if contract.contract_celebration_date < LEY_12_2023_IN_FORCE_DATE: _logger.debug( "reduccion tier: DT-38 grandfathered 60%% for contract_id=%s (celebration %s)", contract.id, contract.contract_celebration_date.isoformat(), ) return _DT_38 if not contract.lau_17_6_compliant: _logger.debug("reduccion tier: FORFEIT (LAU 17.6 non-compliant) for contract_id=%s", contract.id) return _FORFEIT_LAU_17_6 rebaja_threshold = _resolve_prior_rent_rebaja_threshold(period_year) if _qualifies_for_tier_90(contract, finca, prior_rent_rebaja_threshold=rebaja_threshold): _logger.debug("reduccion tier: TIER_90 for contract_id=%s finca_id=%s", contract.id, finca.id) return _with_registry_rate(_TIER_90, period_year, "tier-90") age_min, age_max = _resolve_joven_tenant_age_range(period_year) tier_70 = _resolve_tier_70(contract, finca, joven_age_min=age_min, joven_age_max=age_max) if tier_70 is not None: _logger.debug( "reduccion tier: %s for contract_id=%s finca_id=%s", tier_70.tier.value, contract.id, finca.id, ) return _with_registry_rate(tier_70, period_year, "tier-70") rehab_lookback_days = _resolve_rehab_lookback_days(period_year) if _qualifies_for_tier_60_rehab(contract, rehab_lookback_days=rehab_lookback_days): _logger.debug("reduccion tier: TIER_60_REHAB for contract_id=%s", contract.id) return _with_registry_rate(_TIER_60_REHAB, period_year, "tier-60") _logger.debug("reduccion tier: TIER_50 (fallback) for contract_id=%s", contract.id) return _with_registry_rate(_TIER_50, period_year, "tier-50")
def _qualifies_for_tier_90( contract: Arrendamiento, finca: Finca, *, prior_rent_rebaja_threshold: Decimal, ) -> bool: """Return True if the contract qualifies for tier a) reduccion. Tier a) applies when the property is in a stressed area, there is a prior contract, and the initial rent is more than ``prior_rent_rebaja_threshold`` below the prior contract's indexed last rent. """ if not finca.is_stressed_area: return False if contract.prior_contract_last_rent is None: return False if contract.prior_contract_last_rent <= Decimal("0"): return False if contract.initial_rent < Decimal("0"): _logger.warning("tier resolver: negative initial_rent for contract_id=%s", contract.id) raise TierResolutionError("initial_rent must be non-negative") rebaja_ratio = (contract.prior_contract_last_rent - contract.initial_rent) / contract.prior_contract_last_rent return rebaja_ratio > prior_rent_rebaja_threshold def _resolve_prior_rent_rebaja_threshold(period_year: int) -> Decimal: """Read the LIRPF art. 23.2 a) rebaja threshold from the registry parameter. Reads ``renta-<period_year>-rental-prior-rent-rebaja-threshold`` from Modelo 100. A missing registry revision or parameter is a grounding defect and raises :class:`RegistryValidationError`. """ from ..calculations.registry import read_parameter return read_parameter( Modelo.M100.value, str(period_year), f"renta-{period_year}-rental-prior-rent-rebaja-threshold", date_context={"filing_period": date(period_year, 12, 31)}, ) def _resolve_ejercicio_amendment_year(period_year: int) -> int: """Read the Ley 12/2023 amendment year from the registry parameter. Reads ``renta-<period_year>-rental-ejercicio-amendment-year`` from Modelo 100. A missing registry revision or parameter is a grounding defect and raises :class:`RegistryValidationError`. """ from ..calculations.registry import read_parameter value = read_parameter( Modelo.M100.value, str(period_year), f"renta-{period_year}-rental-ejercicio-amendment-year", date_context={"filing_period": date(period_year, 12, 31)}, ) return int(value) def _resolve_tier_70( contract: Arrendamiento, finca: Finca, *, joven_age_min: int, joven_age_max: int, ) -> TierResolution | None: """Tier b) — split into two independent ordinals. Returns ``None`` when neither ordinal applies. Returns a :class:`TierResolution` with the appropriate qualifying share when ordinal 1.º (joven inquilino) applies; full-share otherwise. """ public_admin_resolution = _resolve_tier_70_b_2(contract) if public_admin_resolution is not None: return public_admin_resolution return _resolve_tier_70_b_1(contract, finca, joven_age_min=joven_age_min, joven_age_max=joven_age_max) def _resolve_tier_70_b_2(contract: Arrendamiento) -> TierResolution | None: """Return the tier 70 resolution for ordinal 2.º qualifying contracts, or None. Qualifies when the tenant is a Public Admin entity, a Ley 49/2002 entity destining the dwelling to alquiler social, an IMV beneficiary, or when the dwelling is in a public housing program with a rent cap. """ if ( contract.tenant_is_public_admin or contract.tenant_is_ley_49_2002_entity_with_social_use or contract.tenant_is_imv_beneficiary or contract.dwelling_in_public_program ): return _TIER_70_PUBLIC_ADMIN return None def _resolve_tier_70_b_1( contract: Arrendamiento, finca: Finca, *, joven_age_min: int, joven_age_max: int, ) -> TierResolution | None: """Ordinal 1.º — first-time rental + zona tensionada + tenant aged ``joven_age_min``-``joven_age_max``. Multi-tenant case: the reducción applies proportionally to the qualifying-co-tenant share. The age bracket is enforced at the co-tenant level via ``qualifying_co_tenant_count``; the ``tenant_min_age`` / ``tenant_max_age`` fields on the contract are advisory metadata that surface a configuration mistake when inconsistent. """ if not contract.is_first_rental: return None if not finca.is_stressed_area: return None if contract.qualifying_co_tenant_count == 0: return None if ( contract.tenant_min_age is not None and contract.tenant_max_age is not None and (contract.tenant_min_age < joven_age_min or contract.tenant_max_age > joven_age_max) and contract.qualifying_co_tenant_count == contract.tenant_count ): raise TierResolutionError( f"tenant age range falls outside {joven_age_min}-{joven_age_max}" " but qualifying_co_tenant_count claims every co-tenant qualifies", ) qualifying_share = Decimal(contract.qualifying_co_tenant_count) / Decimal(contract.tenant_count) return TierResolution( tier=ReduccionTier.TIER_70_JOVEN, reduccion_pct=Decimal("0.70"), qualifying_share=qualifying_share, legal_refs=_LIRPF_ART_23_CURRENT_LEGAL_REFS, ) def _qualifies_for_tier_60_rehab( contract: Arrendamiento, *, rehab_lookback_days: int, ) -> bool: """Return True if the contract qualifies for tier c) reduccion. Tier c) applies when a rehabilitation (actuación de rehabilitación) was finished within ``rehab_lookback_days`` preceding the contract celebration date. """ if contract.rehabilitation_finished_date is None: return False delta_days = (contract.contract_celebration_date - contract.rehabilitation_finished_date).days return 0 <= delta_days <= rehab_lookback_days def _resolve_tier_reduccion_rate(period_year: int, tier_id: str) -> Decimal: """Read a LIRPF art. 23.2 tier reducción rate from the registry parameter. ``tier_id`` is one of ``"tier-50"``, ``"tier-60"``, ``"tier-70"``, ``"tier-90"``. A missing registry revision or parameter is a grounding defect and raises :class:`RegistryValidationError`. """ from ..calculations.registry import read_parameter return read_parameter( Modelo.M100.value, str(period_year), f"renta-{period_year}-rental-reduccion-rate-{tier_id}", date_context={"filing_period": date(period_year, 12, 31)}, ) def _resolve_joven_tenant_age_range(period_year: int) -> tuple[int, int]: """Read the joven-tenant age range (min, max) from registry parameters. Reads ``renta-<period_year>-rental-joven-tenant-age-min`` and ``-max``. A missing registry revision or parameter is a grounding defect and raises :class:`RegistryValidationError`. """ from ..calculations.registry import read_parameter ctx = {"filing_period": date(period_year, 12, 31)} age_min = int( read_parameter( Modelo.M100.value, str(period_year), f"renta-{period_year}-rental-joven-tenant-age-min", date_context=ctx, ), ) age_max = int( read_parameter( Modelo.M100.value, str(period_year), f"renta-{period_year}-rental-joven-tenant-age-max", date_context=ctx, ), ) return age_min, age_max def _resolve_rehab_lookback_days(period_year: int) -> int: """Read the rehab-lookback window from the registry parameter. Reads ``renta-<period_year>-rental-rehab-lookback-days`` from Modelo 100. A missing registry revision or parameter is a grounding defect and raises :class:`RegistryValidationError`. """ from ..calculations.registry import read_parameter value = read_parameter( Modelo.M100.value, str(period_year), f"renta-{period_year}-rental-rehab-lookback-days", date_context={"filing_period": date(period_year, 12, 31)}, ) return int(value) __all__ = [ "DEFAULT_EJERCICIO_AMENDMENT_YEAR", "LEY_12_2023_IN_FORCE_DATE", "TierResolution", "resolve_reduccion", ]