Source code for aeat.domain.renta._maritime_exemption

"""Maritime worker IRPF exemption calculation engine.

Implements the three legally distinct exemption pathways for trabajadores
del mar and the associated profile completeness gate. Each calculation
function returns a :class:`CasillaObservation` carrying the exempt amount
and its full legal provenance.

Active exemption axes (2024/2025):

  Art. 7.p) LIRPF (Ley 35/2006, BOE-A-2006-20764)
    Conditions: foreign-flagged vessel OR international waters; foreign
    entity receiving services; territory with CDI or equivalent income
    tax. Annual cap: 60,100 EUR.
    Formula: min(annual_salary / 365 * qualifying_days, 60_100)

  REBECA 50% exemption (Ley 19/1994, Arts. 73.2 73.3 75.1 75.3, BOE-A-1994-15794)
    Crew of REBECA-registered vessels or scheduled Canary Islands routes.
    Exempt 50% is excluded from the Modelo 111 withholding base by employer.

Inactive axis (future variant):

  DA 41 LIRPF (Ley 35/2006 DA 41, added by Ley 26/2014 BOE-A-2014-12327)
    50% exemption for tuna fleet crew. Requires EU state-aid clearance
    not granted as of 2024/2025. Engine raises MaritimeExemptionInactiveError
    when the selector resolves True so it is never silently applied.

Profile completeness gate (not a calculation axis):

  RETM mandatory filing (Ley 35/2006 Art. 96, BOE-A-2006-20764)
    Since January 2023 all RETMAR-registered workers must file IRPF
    regardless of income level. Raises ProfileCompletenessError; it
    does not alter casilla values.

Provisions outside scope:
  The transitional withholding rule from January 2015 (a different
  disposicion adicional with no maritime content) must not be cited as a
  maritime exemption anchor in any registry or code artefact.
  Art. 17.1.d) / Art. 9 RIRPF daily allowance caps apply generically;
  TEAC narrowed their scope for crew whose ordinary workplace is the vessel
  (STS 954/2020, STS 3185/2021). No special per-diem exists in any currently
  applicable LIRPF provision for these workers.
"""

from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal
from typing import Literal

from ...core.external_constants import ART_7P_EXEMPTION_CAP_EUR, REBECA_MARITIME_EXEMPTION_FRACTION
from ..calculations.registry import CasillaId, CasillaObservation, LegalRefId, SourceRefId, validated_casilla_id
from ._errors import RentaError, RentaValidationError

# Casilla in Modelo 100 that receives exempt income (renta exenta section).
# Art. 7.p) and REBECA both flow through the existing renta exenta casilla
# for base liquidable general (0525). No new casilla identifiers exist for
# maritime workers; the existing renta exenta casilla is the only target.
# Source: aeat-dr-100-2024-dictionary (semantic_role irpf_rentas_exentas_base_general).
RENTA_EXENTA_CASILLA: CasillaId = validated_casilla_id("0525", surface="RENTA_EXENTA_CASILLA")

# Legal references carried through every observation — sourced from the
# trabajador_del_mar.toml binding entries.
_ART_7P_LEGAL_REFS: tuple[LegalRefId, ...] = ("ley-35-2006:art-7",)
_REBECA_LEGAL_REFS: tuple[LegalRefId, ...] = ("ley-19-1994:art-75",)
_DA41_LEGAL_REFS: tuple[LegalRefId, ...] = ("ley-35-2006:da-41",)
_RETMAR_LEGAL_REFS: tuple[LegalRefId, ...] = ("ley-35-2006:art-96",)

_ART_7P_SOURCE_REFS: tuple[SourceRefId, ...] = ("boe-lirpf-art-7-authority",)
_REBECA_SOURCE_REFS: tuple[SourceRefId, ...] = ("boe-ley-19-1994-art-75-authority",)


[docs] class MaritimeExemptionInactiveError(RentaError): """Raised when the DA 41 selector resolves True for a trabajador del mar. DA 41 LIRPF requires prior EU state-aid clearance under TFEU rules. That clearance has not been granted as of 2024/2025. The engine raises this error instead of silently producing an exempt-income observation, which would be legally incorrect output. Once EU clearance is granted a follow-up task must flip the binding status in trabajador_del_mar.toml and add oracle-backed tests; no code change in this module is required. Legal authority: Ley 35/2006 DA 41 BOE-A-2006-20764 (as amended by Ley 26/2014 BOE-A-2014-12327). """
[docs] class ProfileCompletenessError(RentaError): """Raised when a RETMAR-registered worker's profile is presented for filing. Since 2023, all workers registered in the maritime special Social Security regime must file an IRPF declaration regardless of income level (Ley 35/2006 art. 96, BOE-A-2006-20764). This is a profile completeness gate — it does not alter casilla values or formula execution paths. Callers should surface the mandatory-filing status to the operator in CLI JSON output; the warning must not suppress further processing. """
# --------------------------------------------------------------------------- # Profile fact types # ---------------------------------------------------------------------------
[docs] @dataclass(frozen=True, slots=True) class MaritimeWorkerFacts: """Resolved profile facts that gate maritime exemption pathway selection. All fields are optional and default to the non-triggering value so that callers building partial profiles do not require every fact to be present. A profile without worker_class = "trabajador_del_mar" is unaffected by any predicate in this module. Attributes: worker_class: Must be ``"trabajador_del_mar"`` to activate any maritime exemption pathway. vessel_flag: ``"ES"`` (Spanish-flagged) or ``"foreign"``. waters_type: ``"national"`` or ``"international"``. vessel_registry: One of ``"REBECA"``, ``"rebeca_eu_eea"``, ``"scheduled_canary_route"``, or ``None``. tuna_fleet: Whether the vessel is a qualifying tuna fleet vessel (DA 41 selector; currently always inactive). pending_eu_clearance: Mirrors the DA 41 TOML selector field. Must be True alongside tuna_fleet to trigger the inactive gate. retmar_registered: Whether the taxpayer is in the RETMAR register. Drives the mandatory-filing completeness check only. """ worker_class: str | None = None vessel_flag: Literal["ES", "foreign"] | None = None waters_type: Literal["national", "international"] | None = None vessel_registry: Literal["REBECA", "rebeca_eu_eea", "scheduled_canary_route"] | None = None tuna_fleet: bool = False pending_eu_clearance: bool = False retmar_registered: bool = False
# --------------------------------------------------------------------------- # Binding selector predicates (S08 / S09 / S10) # ---------------------------------------------------------------------------
[docs] def art_7p_eligible(facts: MaritimeWorkerFacts) -> bool: """Return True when Art. 7.p) LIRPF applies to the worker profile. Conditions (conjunctive): - worker_class == "trabajador_del_mar" - vessel_flag == "foreign" OR waters_type == "international" The ADR notes that international waters qualify for foreign-flagged vessels per AEAT accepted practice, confirmed by TEAR Galicia December 2024 for Galician fishing crew and by Supreme Court doctrine extended April 2025 to military Navy in NATO/UN sea operations. Legal authority: Ley 35/2006 Art. 7.p) BOE-A-2006-20764. """ if facts.worker_class != "trabajador_del_mar": return False return facts.vessel_flag == "foreign" or facts.waters_type == "international"
[docs] def rebeca_eligible(facts: MaritimeWorkerFacts) -> bool: """Return True when the REBECA 50% exemption applies to the worker profile. Conditions: - worker_class == "trabajador_del_mar" - vessel_registry in {"REBECA", "rebeca_eu_eea", "scheduled_canary_route"} The "rebeca_eu_eea" value covers the 2021 extension to crews of REBECA-registered company vessels enrolled in other EU/EEA member state registries (Ley 19/1994 Art. 75.1). Legal authority: Ley 19/1994 Arts. 73.2 73.3 75.1 75.3 BOE-A-1994-15794. """ if facts.worker_class != "trabajador_del_mar": return False return facts.vessel_registry in {"REBECA", "rebeca_eu_eea", "scheduled_canary_route"}
[docs] def da41_eligible(facts: MaritimeWorkerFacts) -> bool: """Return True when the DA 41 tuna-fleet selector resolves. DA 41 requires: - worker_class == "trabajador_del_mar" - tuna_fleet == True - pending_eu_clearance == True The binding is currently inactive_pending_eu_clearance. Callers must check the return value and raise MaritimeExemptionInactiveError when True; this predicate is deliberately separated from the error-raising path so it can be tested independently. Legal authority: Ley 35/2006 DA 41 BOE-A-2006-20764 (as amended by Ley 26/2014 BOE-A-2014-12327). """ if facts.worker_class != "trabajador_del_mar": return False return facts.tuna_fleet and facts.pending_eu_clearance
# --------------------------------------------------------------------------- # Exemption calculations (S12 / S13 / S14 / S15) # ---------------------------------------------------------------------------
[docs] def calculate_art_7p_exemption( *, annual_salary: Decimal, qualifying_days: int, facts: MaritimeWorkerFacts, ) -> CasillaObservation: """Calculate the Art. 7.p) exempt amount and return a :class:`CasillaObservation`. Formula (Ley 35/2006 Art. 7.p) BOE-A-2006-20764): exempt_amount = min(annual_salary / 365 * qualifying_days, 60_100) The observation carries legal_refs and source_refs from the registry binding entry so the provenance is traceable from calculation to CLI emit. Args: annual_salary: Gross annual employment salary in EUR (Decimal). qualifying_days: Calendar days of work effectively performed outside Spanish territory within the tax year. Must be in [1, 365]. facts: Resolved MaritimeWorkerFacts. art_7p_eligible must be True; raises RentaValidationError otherwise. Returns: :class:`CasillaObservation` for the renta exenta casilla with the exempt amount and full legal provenance. Raises: RentaValidationError: When eligibility predicate is not satisfied or when input values are out of range. """ if not art_7p_eligible(facts): raise RentaValidationError("art_7p_eligible predicate is False; cannot calculate Art. 7.p) exemption") if not isinstance(annual_salary, Decimal) or isinstance(annual_salary, bool): raise RentaValidationError("annual_salary must be a Decimal") if not annual_salary.is_finite() or annual_salary <= Decimal("0"): raise RentaValidationError("annual_salary must be a positive finite Decimal") if not isinstance(qualifying_days, int) or isinstance(qualifying_days, bool): raise RentaValidationError("qualifying_days must be an int") if not (1 <= qualifying_days <= 365): raise RentaValidationError("qualifying_days must be in [1, 365]") raw_exempt = annual_salary / Decimal("365") * Decimal(qualifying_days) exempt_amount = min(raw_exempt, ART_7P_EXEMPTION_CAP_EUR) return CasillaObservation( casilla_id=RENTA_EXENTA_CASILLA, value=exempt_amount, formula_id=None, op=None, operand_refs=(), operand_casilla_refs=(), operand_values=(), legal_refs=_ART_7P_LEGAL_REFS, source_refs=_ART_7P_SOURCE_REFS, absent_by_design=False, )
[docs] def calculate_rebeca_exemption( *, gross_navigation_income: Decimal, facts: MaritimeWorkerFacts, ) -> CasillaObservation: """Calculate the REBECA 50% exempt amount and return a typed CasillaObservation. Formula (Ley 19/1994 Arts. 73-75 BOE-A-1994-15794): exempt_amount = gross_navigation_income * 0.50 The 50% fraction is statutory and not variable by election. Args: gross_navigation_income: Total gross employment income from navigation in EUR (Decimal). Must be positive. facts: Resolved MaritimeWorkerFacts. rebeca_eligible must be True; raises RentaValidationError otherwise. Returns: :class:`CasillaObservation` for the renta exenta casilla with the exempt amount and full legal provenance. Raises: RentaValidationError: When eligibility predicate is not satisfied or when input values are out of range. """ if not rebeca_eligible(facts): raise RentaValidationError("rebeca_eligible predicate is False; cannot calculate REBECA exemption") if not isinstance(gross_navigation_income, Decimal) or isinstance(gross_navigation_income, bool): raise RentaValidationError("gross_navigation_income must be a Decimal") if not gross_navigation_income.is_finite() or gross_navigation_income <= Decimal("0"): raise RentaValidationError("gross_navigation_income must be a positive finite Decimal") exempt_amount = gross_navigation_income * REBECA_MARITIME_EXEMPTION_FRACTION return CasillaObservation( casilla_id=RENTA_EXENTA_CASILLA, value=exempt_amount, formula_id=None, op=None, operand_refs=(), operand_casilla_refs=(), operand_values=(), legal_refs=_REBECA_LEGAL_REFS, source_refs=_REBECA_SOURCE_REFS, absent_by_design=False, )
[docs] def guard_da41_inactive(facts: MaritimeWorkerFacts) -> None: """Raise MaritimeExemptionInactiveError if DA 41 selector resolves True. This function must be called before any code path that would produce DA 41 exempt-income output. DA 41 requires EU state-aid clearance not granted as of 2024/2025; silently producing an exempt amount would be legally incorrect. Args: facts: Resolved MaritimeWorkerFacts. Raises: MaritimeExemptionInactiveError: When da41_eligible returns True. """ if da41_eligible(facts): raise MaritimeExemptionInactiveError( "DA 41 LIRPF exemption is inactive: EU state-aid clearance has not been granted " "as of 2024/2025. AEAT confirms non-applicability in official 2024 guidance. " "Activate only after EU clearance is granted (Ley 35/2006 DA 41 BOE-A-2006-20764, " "added by Ley 26/2014 BOE-A-2014-12327).", context={ "binding_id": "da41-tuna-fleet-inactive", "legal_ref": _DA41_LEGAL_REFS[0], }, )
[docs] def check_retmar_mandatory_filing(facts: MaritimeWorkerFacts) -> None: """Raise ProfileCompletenessError when retmar_registered is True. Since 2023, all workers registered in the maritime special Social Security regime must file an IRPF declaration regardless of income level (Ley 35/2006 art. 96, BOE-A-2006-20764). This is a completeness gate only — it must not suppress further processing or alter casilla values. Callers should catch ProfileCompletenessError, surface the message to the operator, and continue processing. Args: facts: Resolved MaritimeWorkerFacts. Raises: ProfileCompletenessError: When retmar_registered is True. """ if facts.retmar_registered: raise ProfileCompletenessError( "RETMAR mandatory filing: this worker is registered in RETMAR. " "Since 2023, workers registered in the maritime special Social Security " "regime must file an IRPF " "declaration regardless of income level " "(Ley 35/2006 Art. 96 BOE-A-2006-20764).", context={ "legal_ref": _RETMAR_LEGAL_REFS[0], }, )
__all__ = [ "ART_7P_EXEMPTION_CAP_EUR", "RENTA_EXENTA_CASILLA", "MaritimeExemptionInactiveError", "MaritimeWorkerFacts", "ProfileCompletenessError", "art_7p_eligible", "calculate_art_7p_exemption", "calculate_rebeca_exemption", "check_retmar_mandatory_filing", "da41_eligible", "guard_da41_inactive", "rebeca_eligible", ]