Source code for aeat.domain.deadlines._festivos

"""Spanish business-day calendar and AEAT deadline shift.

This module is the holiday-adjustment service. It loads BOE-
published national plus autonomous-community ("CCAA") holiday
calendars from the project's
``registry/aeat/calendars/`` directory and exposes pure functions that
answer two questions:

* Is a given date a *día hábil* (business day) for AEAT filings in the
  taxpayer's CCAA of tax residence?
* Given an AEAT-registered close date, what is the legally-due filing
  deadline once weekends, national holidays, and CCAA holidays are
  considered, and what was the reason for the shift?

AEAT's published deadline-shift rule (Calendario del Contribuyente,
*"Vencimientos en días inhábiles, sábados o festivos"*) is:

    "Si el último día del plazo coincide con un sábado, domingo o
    festivo, el plazo se entiende ampliado hasta el primer día hábil
    siguiente."

The rule covers national holidays plus the autonomous-community holiday
of the taxpayer's domicilio fiscal. Local (municipal) holidays do NOT
affect AEAT filing deadlines and are not part of this calendar.

One well-known exception: **Modelo 369** (OSS / IOSS one-stop-shop)
deadlines do NOT shift, even when the close date falls on a non-
business day, because the OSS / IOSS regime is governed by the EU
Council Directive's harmonised cutoffs and the AEAT cannot lengthen
the EU-wide window unilaterally. The exception list is encoded in
:data:`MODELOS_WITHOUT_SHIFT` so future modelo additions land as data,
not as a fork in :func:`shift_deadline`.

The substrate is pure domain logic: it never touches the CLI, never
mutates input, and never reaches outside the project for live calendar
data (calendars are git-tracked, BOE-cited TOML).
"""

from __future__ import annotations

from collections.abc import Iterable
from datetime import date, timedelta
from enum import StrEnum
from functools import lru_cache
from pathlib import Path
from typing import Annotated

from pydantic import BaseModel, ConfigDict, Field, StringConstraints, ValidationError

from ...core import STRICT_FROZEN_CONFIG, Modelo, read_toml
from ...core.resources import bundled_path
from ._errors import DeadlineValidationError

# ---------------------------------------------------------------------------
# CCAA enumeration (ISO 3166-2:ES codes).
# ---------------------------------------------------------------------------


[docs] class CalendarCCAA(StrEnum): """Spanish autonomous communities and the two autonomous cities, keyed by ISO 3166-2:ES code. AEAT filing deadlines may shift when the close date coincides with a holiday in the taxpayer's CCAA of tax residence (domicilio fiscal). The two autonomous cities of Ceuta and Melilla each publish their own holiday calendar and behave like a CCAA for this purpose. The codes match the ISO 3166-2:ES standard and the BOE-published holiday resolutions. """ ANDALUCIA = "ES-AN" ARAGON = "ES-AR" ASTURIAS = "ES-AS" ILLES_BALEARS = "ES-IB" CANARIAS = "ES-CN" CANTABRIA = "ES-CB" CASTILLA_LA_MANCHA = "ES-CM" CASTILLA_Y_LEON = "ES-CL" CATALUNA = "ES-CT" EXTREMADURA = "ES-EX" GALICIA = "ES-GA" LA_RIOJA = "ES-RI" MADRID = "ES-MD" MURCIA = "ES-MC" NAVARRA = "ES-NC" PAIS_VASCO = "ES-PV" VALENCIA = "ES-VC" CEUTA = "ES-CE" MELILLA = "ES-ML"
[docs] class HolidayJurisdiction(StrEnum): """Layer of government that declared the holiday. * ``NATIONAL`` — declared by the State; observed everywhere. * ``CCAA`` — declared by an autonomous community; observed only in that CCAA's territory. AEAT does not consider municipal-level holidays for filing-deadline shifts, so a corresponding ``LOCAL`` value would be out of scope. """ NATIONAL = "national" CCAA = "ccaa"
# --------------------------------------------------------------------------- # Holiday + Calendar value types. # --------------------------------------------------------------------------- _NonEmptyShortString = Annotated[ str, StringConstraints(strip_whitespace=True, min_length=1, max_length=200), ]
[docs] class Holiday(BaseModel): """A single declared holiday.""" model_config = STRICT_FROZEN_CONFIG holiday_date: date jurisdiction: HolidayJurisdiction ccaa_code: CalendarCCAA | None = None name: _NonEmptyShortString
[docs] class HolidayCalendar(BaseModel): """A year's BOE-published holiday calendar. ``boe_ref`` is the citation stem of the BOE Resolución that published the annual relación de fiestas laborales. ``boe_url`` is an optional convenience anchor for the same resolution. """ model_config = STRICT_FROZEN_CONFIG year: Annotated[int, Field(ge=2000, le=2100)] boe_ref: _NonEmptyShortString boe_url: str | None = None national: tuple[Holiday, ...] = Field(default_factory=tuple) ccaa: tuple[Holiday, ...] = Field(default_factory=tuple)
class _NationalHolidayRow(BaseModel): """One ``[[national]]`` row as authored in a festivos TOML file.""" model_config = ConfigDict(extra="forbid") date: date name: _NonEmptyShortString class _CcaaHolidayRow(BaseModel): """One ``[[ccaa]]`` row as authored in a festivos TOML file.""" model_config = ConfigDict(extra="forbid") date: date ccaa_code: CalendarCCAA name: _NonEmptyShortString class _HolidayCalendarToml(BaseModel): """Typed shape of a parsed ``festivos-{year}.toml`` document. Validating the loosely-typed :func:`read_toml` result through this boundary model lifts the TOML rows into typed records (coercing the native TOML scalars into the ``date`` and :class:`CalendarCCAA` field types) before they are projected onto the immutable :class:`Holiday` / :class:`HolidayCalendar` domain records, which stay strict. """ model_config = ConfigDict(extra="forbid") year: Annotated[int, Field(ge=2000, le=2100)] boe_ref: _NonEmptyShortString boe_url: str | None = None national: list[_NationalHolidayRow] = Field(default_factory=list) ccaa: list[_CcaaHolidayRow] = Field(default_factory=list)
[docs] class DeadlineShift(BaseModel): """Outcome of applying the AEAT deadline-shift rule to one close date. Carries the original AEAT-registered close date, the legally-adjusted close date after weekend / holiday shifts, a structured reason, and references to the holiday source(s) that drove the shift. ``shifted`` is the boolean predicate "did the close date move?"; ``shift_days`` is the (always non-negative) day count between original and adjusted; ``shift_reason`` is a stable identifier consumers may use for output-formatting and rule-explanation; the optional ``holiday_refs`` and ``jurisdictions`` tuples carry the specific holidays whose presence on the calendar caused the shift. """ model_config = STRICT_FROZEN_CONFIG original_close_date: date adjusted_close_date: date shifted: bool shift_days: Annotated[int, Field(ge=0)] shift_reason: _NonEmptyShortString jurisdictions: tuple[HolidayJurisdiction, ...] = Field(default_factory=tuple) holiday_refs: tuple[str, ...] = Field(default_factory=tuple)
# --------------------------------------------------------------------------- # Modelo-specific exception list. # --------------------------------------------------------------------------- #: Modelos whose deadlines are NOT shifted by the AEAT día-inhábil rule. #: #: Per the AEAT Calendario del Contribuyente, the OSS / IOSS one-stop-shop #: regime (Modelo 369) is governed by the EU-harmonised cutoff date and #: AEAT cannot extend the window unilaterally even when the close date #: falls on a Spanish holiday or weekend. Adding new modelos to this #: tuple keeps the exception list data-driven; :func:`shift_deadline` #: never grows a switch statement. MODELOS_WITHOUT_SHIFT: tuple[str, ...] = (Modelo.M369,) # --------------------------------------------------------------------------- # Calendar loader. # --------------------------------------------------------------------------- _CALENDARS_DIR = bundled_path("registry", "aeat", "calendars") def _calendar_path(year: int) -> Path: return _CALENDARS_DIR / f"festivos-{year}.toml"
[docs] @lru_cache(maxsize=64) def load_holiday_calendar(year: int) -> HolidayCalendar: """Load the BOE-published holiday calendar for ``year``. Reads ``registry/aeat/calendars/festivos-{year}.toml``, parses it into immutable :class:`Holiday` records under the :class:`HolidayCalendar` aggregate, and caches the result. The cache is unbounded by use but bounded by call site (lru_cache with ``maxsize=64``). Raises :class:`DeadlineValidationError` when the TOML file is missing or malformed (caller-recoverable; the surrounding deadline engine can degrade to weekend-only shifts). """ path = _calendar_path(year) if not path.exists(): raise DeadlineValidationError(f"holiday calendar for year {year} not registered (expected file: {path.name})") raw = read_toml(path, error_factory=DeadlineValidationError) declared_year = raw.get("year") if declared_year != year: raise DeadlineValidationError( f"holiday calendar year mismatch: filename declares {year} but TOML declares {declared_year!r}", ) try: parsed = _HolidayCalendarToml.model_validate(raw) national_entries = tuple( Holiday( holiday_date=entry.date, jurisdiction=HolidayJurisdiction.NATIONAL, ccaa_code=None, name=entry.name, ) for entry in parsed.national ) ccaa_entries = tuple( Holiday( holiday_date=entry.date, jurisdiction=HolidayJurisdiction.CCAA, ccaa_code=entry.ccaa_code, name=entry.name, ) for entry in parsed.ccaa ) return HolidayCalendar( year=year, boe_ref=parsed.boe_ref, boe_url=parsed.boe_url, national=national_entries, ccaa=ccaa_entries, ) except (KeyError, TypeError, ValueError, ValidationError) as exc: raise DeadlineValidationError(f"{path}: invalid holiday calendar row: {exc}") from exc
# --------------------------------------------------------------------------- # Pure business-day arithmetic. # --------------------------------------------------------------------------- _WEEKEND = {5, 6} # Saturday, Sunday — Python's date.weekday() def _holidays_on( candidate: date, *, calendar: HolidayCalendar, ccaa_code: CalendarCCAA | None, ) -> tuple[Holiday, ...]: """Return every holiday that matches ``candidate`` for the supplied CCAA. National holidays are always included regardless of CCAA. """ matches: list[Holiday] = [] for holiday in calendar.national: if holiday.holiday_date == candidate: matches.append(holiday) if ccaa_code is not None: for holiday in calendar.ccaa: if holiday.holiday_date == candidate and holiday.ccaa_code is ccaa_code: matches.append(holiday) return tuple(matches)
[docs] def is_business_day( candidate: date, *, calendar: HolidayCalendar, ccaa_code: CalendarCCAA | None, ) -> bool: """Return True when ``candidate`` is a business day for AEAT filings. A date is a business day when it is not a Saturday, not a Sunday, not a national holiday for that year, and (when ``ccaa_code`` is supplied) not a CCAA holiday for that ccaa-year pair. When ``ccaa_code`` is ``None`` the predicate degrades to national-only: callers with no tax-residence information get weekend + national detection but miss CCAA shifts. """ if candidate.weekday() in _WEEKEND: return False return not _holidays_on(candidate, calendar=calendar, ccaa_code=ccaa_code)
[docs] def next_business_day( start: date, *, calendar: HolidayCalendar, ccaa_code: CalendarCCAA | None, ) -> date: """Return the first date on or after ``start`` that is a business day. The walk is bounded by 14 days (a fortnight) to keep the function safe against pathological inputs; in practice the AEAT calendar never strings more than four non-business days together (e.g. Semana Santa weekend + Jueves Santo + Viernes Santo + Lunes de Pascua = 4 days). """ candidate = start for _ in range(14): if is_business_day(candidate, calendar=calendar, ccaa_code=ccaa_code): return candidate candidate = candidate + timedelta(days=1) raise DeadlineValidationError( f"could not find a business day within 14 days of {start.isoformat()} " f"(ccaa={ccaa_code.value if ccaa_code else 'none'}); the calendar may " f"have invalid contiguous holidays", )
# --------------------------------------------------------------------------- # Shift computation. # --------------------------------------------------------------------------- def _reason_for( candidate: date, *, holidays: Iterable[Holiday], ) -> tuple[str, tuple[HolidayJurisdiction, ...], tuple[str, ...]]: """Build the structured shift reason for one non-business day.""" if candidate.weekday() == 5: weekend_token = "sabado" elif candidate.weekday() == 6: weekend_token = "domingo" else: weekend_token = "" holiday_tuple = tuple(holidays) holiday_names = tuple(h.name for h in holiday_tuple) jurisdictions = tuple(h.jurisdiction for h in holiday_tuple) if weekend_token and holiday_tuple: # e.g., a Saturday that is also a national holiday. reason = f"{weekend_token} + " + " + ".join(holiday_names) elif weekend_token: reason = weekend_token elif holiday_tuple: reason = " + ".join(holiday_names) else: reason = "business_day" return reason, jurisdictions, holiday_names
[docs] def shift_deadline( original_close_date: date, *, modelo: str, ccaa_code: CalendarCCAA | None, calendar: HolidayCalendar | None = None, ) -> DeadlineShift: """Apply the AEAT deadline-shift rule and return a :class:`DeadlineShift` result. The rule moves the deadline to the next business day when the original close date is a Saturday, Sunday, national holiday, or CCAA holiday of the taxpayer's tax residence. Modelo-specific exceptions (e.g., Modelo 369 OSS / IOSS) bypass the shift and return an unshifted :class:`DeadlineShift` with reason ``modelo_exception``. When ``calendar`` is omitted the function loads it via :func:`load_holiday_calendar` keyed by the original close date's year. Callers that already hold the calendar may pass it directly to avoid a second lookup. """ if not modelo: raise DeadlineValidationError("modelo must be a non-empty string") if modelo in MODELOS_WITHOUT_SHIFT: return DeadlineShift( original_close_date=original_close_date, adjusted_close_date=original_close_date, shifted=False, shift_days=0, shift_reason="modelo_exception", jurisdictions=(), holiday_refs=(), ) target_calendar = calendar if calendar is not None else load_holiday_calendar(original_close_date.year) # Determine whether the original date is a business day. holidays_on_close = _holidays_on( original_close_date, calendar=target_calendar, ccaa_code=ccaa_code, ) is_weekend = original_close_date.weekday() in _WEEKEND if not is_weekend and not holidays_on_close: return DeadlineShift( original_close_date=original_close_date, adjusted_close_date=original_close_date, shifted=False, shift_days=0, shift_reason="business_day", jurisdictions=(), holiday_refs=(), ) # Build the structured reason for the original date being inhábil. reason, jurisdictions, holiday_names = _reason_for( original_close_date, holidays=holidays_on_close, ) # Walk forward to the next business day. adjusted = next_business_day( original_close_date + timedelta(days=1), calendar=target_calendar, ccaa_code=ccaa_code, ) return DeadlineShift( original_close_date=original_close_date, adjusted_close_date=adjusted, shifted=True, shift_days=(adjusted - original_close_date).days, shift_reason=reason, jurisdictions=jurisdictions, holiday_refs=holiday_names, )
__all__ = ( "MODELOS_WITHOUT_SHIFT", "CalendarCCAA", "DeadlineShift", "Holiday", "HolidayCalendar", "HolidayJurisdiction", "is_business_day", "load_holiday_calendar", "next_business_day", "shift_deadline", )