"""Canonical period code enumeration for filing periods.
Period codes represent the filing frequency and scheme for tax returns:
- Quarterly: 1T, 2T, 3T, 4T (standard filing periods)
- Instalment: 1P, 2P, 3P, 4P (corporate-tax/IS instalment periods)
- Annual: 0A
- Monthly: 01-12 (calendar months)
- OSS/IOSS: EXT-1T, EXT-2T, EXT-3T, EXT-4T (extra-Union scheme)
- Ad-hoc/Event: AD-HOC, EVENT-N (event-driven filings)
:class:`StandardPeriodCode` is the canonical :class:`~enum.StrEnum` for
the closed standard vocabulary (1T-4T, 1P-4P, 0A, 01-12). It includes both
calendar spans and instalment claves, so callers that need date boundaries
must still check :meth:`Period.has_date_span`. :data:`RegistryPeriodCode`
is the pydantic field annotation for the full registry union: standard
members plus extended OSS/IOSS literals, ``AD-HOC``, the open-ended event
shape, the administrative censo tokens, and the symbolic ``EVENT-N`` selector.
:data:`FilingPeriodCode` is the narrower annotation for a period a taxpayer
files in — the same union minus the administrative tokens and the symbolic
selector, neither of which names one concrete filing period. Two boundaries
with different value spaces need two annotations:
widening one to admit a registry coordinate must not widen the other.
:class:`Period` combines one accepted FILING code with a filing year, and
:class:`PeriodKind` classifies the resulting cadence.
This module is the runtime counterpart to the registry
:data:`domain.calculations.registry.schema_scalars.PeriodCode` alias and
:class:`domain.calculations.registry.schema_references.PeriodSelector` schema. Registry
objects carry bare period tokens; application services that need a concrete
filing window compose those tokens with a year into :class:`Period`.
Operator commands follow the same separated shape (``--year YYYY --period
<AEAT-token>``); any year-qualified filter mini-grammar is converted at the
CLI boundary before it reaches this model.
"""
from __future__ import annotations
import calendar
import re
from datetime import date
from enum import StrEnum
from typing import Annotated, override
from pydantic import BaseModel, BeforeValidator, Field
from ..core.models import STRICT_FROZEN_CONFIG
from .errors.hierarchy import CadrumoError
[docs]
class StandardPeriodCode(StrEnum):
"""Canonical enumeration of closed standard filing-period codes.
Quarterly, monthly, and annual members have calendar spans. Instalment
members (``1P``-``4P``) identify filing/payment events and intentionally do
not imply a contiguous ledger date span.
"""
Q1 = "1T"
Q2 = "2T"
Q3 = "3T"
Q4 = "4T"
P1 = "1P"
P2 = "2P"
P3 = "3P"
P4 = "4P"
ANNUAL = "0A"
JAN = "01"
FEB = "02"
MAR = "03"
APR = "04"
MAY = "05"
JUN = "06"
JUL = "07"
AUG = "08"
SEP = "09"
OCT = "10"
NOV = "11"
DEC = "12"
_STANDARD_PERIOD_SET: frozenset[str] = frozenset(member.value for member in StandardPeriodCode)
_EXTENDED_PERIOD_SET: frozenset[str] = frozenset(("EXT-1T", "EXT-2T", "EXT-3T", "EXT-4T"))
_AD_HOC_PERIOD = "AD-HOC"
_ADMINISTRATIVE_PERIOD_SET: frozenset[str] = frozenset(
("ALTA", "MODIFICACION", "BAJA", "COMUNICACION", "VARIACION"),
)
#: The symbolic token a registry ``period_selector`` declares to COVER the
#: concrete event periods (``EVENT-1``, ``EVENT-2``, ...) an operator scope may
#: carry — see the shared-matcher rationale in the registry's ``select_revision``.
#: It stands for a SET of periods, so it is not itself one.
_SYMBOLIC_EVENT_SELECTOR = "EVENT-N"
_EVENT_NUMBER_PERIOD_RE = re.compile(r"^EVENT-\d+$")
_DISPLAY_PERIOD_RE = re.compile(r"^(?P<year>\d{4})\s+(?P<code>[A-Z0-9]+(?:-[A-Z0-9]+)*)$", re.I)
def _normalised_period_token(value: str) -> str:
"""Return the trimmed, upper-cased token, refusing a non-string input."""
if not isinstance(value, str):
raise ValueError(f"period code must be a string, got {type(value).__name__}")
return value.strip().upper()
def _validate_filing_period(value: str) -> str:
"""Validate and normalize a period code that a taxpayer can actually file.
Accepts StandardPeriodCode members, extended OSS/IOSS forms (EXT-1T..EXT-4T),
the ad-hoc literal (AD-HOC), and event-driven forms carrying a concrete event
NUMBER (EVENT-3). It admits neither the administrative censo/comunicación
tokens nor the symbolic ``EVENT-N`` selector: an administrative token names a
registration event rather than a period, and ``EVENT-N`` is declared to COVER
the concrete ``EVENT-1``/``EVENT-2`` scopes rather than to be one of them. A
symbolic stand-in for a set is a type error at a boundary whose whole contract
is "exactly one filing period", not merely an odd value.
Raises ValueError with the filing-scoped accepted set on rejection; pydantic
wraps it into a ValidationError at the BeforeValidator boundary.
"""
normalized = _normalised_period_token(value)
if normalized in _STANDARD_PERIOD_SET:
return normalized
if normalized in _EXTENDED_PERIOD_SET:
return normalized
if normalized == _AD_HOC_PERIOD:
return normalized
if _EVENT_NUMBER_PERIOD_RE.match(normalized):
return normalized
accepted = _format_accepted_filing_period_set()
raise ValueError(f"invalid period code '{value}'; accepted forms: {accepted}")
def _validate_period_against_registry(value: str) -> str:
"""Validate and normalize a period code against the union of accepted forms.
This is the REGISTRY coordinate's vocabulary: every filing period plus the
two sub-vocabularies that address a revision without naming a filing period —
the administrative censo/comunicación tokens (Modelo 036, Modelo 145) and the
symbolic ``EVENT-N`` selector (Modelo 210). Use :func:`_validate_filing_period`
for anything that represents a period a taxpayer files in.
Raises ValueError with the full accepted-set list on rejection; pydantic
wraps it into a ValidationError at the BeforeValidator boundary.
"""
normalized = _normalised_period_token(value)
if normalized in _ADMINISTRATIVE_PERIOD_SET:
return normalized
if normalized == _SYMBOLIC_EVENT_SELECTOR:
return normalized
try:
return _validate_filing_period(normalized)
except ValueError:
accepted = _format_accepted_period_set()
raise ValueError(f"invalid period code '{value}'; accepted forms: {accepted}") from None
[docs]
def is_administrative_period_token(token: str) -> bool:
"""Return whether ``token`` is one of the administrative censo period codes.
The administrative sub-vocabulary (Modelo 036's ``ALTA`` / ``MODIFICACION`` /
``BAJA``, Modelo 145's ``COMUNICACION`` / ``VARIACION``) addresses a registry
revision rather than naming a period, so a caller holding a raw registry token
generally needs to route it somewhere other than :class:`Period`. This is the
one public way to ask, so the membership test cannot be hand-copied into a
second, drifting set.
Matching is on the registry's own unaccented spelling. A caller reading a
token off an AEAT-rendered surface — where Spanish prints ``MODIFICACIÓN``,
``COMUNICACIÓN``, ``VARIACIÓN`` — folds it with
:func:`~cadrumo.core.text_fold.fold_diacritics` first; admitting accented spellings here
would put this predicate and :data:`RegistryPeriodCode` into disagreement
about the same token.
Raises:
ValueError: When ``token`` is not a string.
"""
return _normalised_period_token(token) in _ADMINISTRATIVE_PERIOD_SET
[docs]
def is_symbolic_event_selector(token: str) -> bool:
"""Return whether ``token`` is the symbolic ``EVENT-N`` registry selector.
The selector covers the concrete ``EVENT-1``/``EVENT-2`` periods without
being one, so a registry coordinate carrying it has no filing period.
Raises:
ValueError: When ``token`` is not a string.
"""
return _normalised_period_token(token) == _SYMBOLIC_EVENT_SELECTOR
[docs]
def is_filing_period_token(token: str) -> bool:
"""Return whether ``token`` names exactly one period a taxpayer can file.
Registry selectors also carry administrative censo tokens and the symbolic
``EVENT-N`` selector, which cover filings without being one; neither builds
a :class:`Period`.
Raises:
ValueError: When ``token`` is not a string.
"""
normalized = _normalised_period_token(token)
try:
_validate_filing_period(normalized)
except ValueError:
return False
return True
[docs]
def accepted_filing_period_codes() -> tuple[str, ...]:
"""Return the fully enumerable codes a :class:`Period` accepts.
The administrative tokens are excluded — they address a registry revision,
not a filing period — as is the infinite ``EVENT-<number>`` family.
"""
return tuple(sorted(_STANDARD_PERIOD_SET | _EXTENDED_PERIOD_SET | {_AD_HOC_PERIOD}))
[docs]
def accepted_filing_period_patterns() -> tuple[str, ...]:
"""Return human-readable patterns for the periods a :class:`Period` accepts."""
return (
"StandardPeriodCode (1T-4T, 1P-4P, 0A, 01-12)",
"Extended OSS/IOSS (EXT-1T, EXT-2T, EXT-3T, EXT-4T)",
"Ad-hoc (AD-HOC)",
"Event-driven (EVENT-N where N is an event-number integer, e.g. EVENT-3)",
)
def _format_accepted_filing_period_set() -> str:
"""Format the filing-scoped accepted period set for error messages."""
standard = sorted(_STANDARD_PERIOD_SET)
extended = sorted(_EXTENDED_PERIOD_SET)
lines = [
f"StandardPeriodCode: {', '.join(standard)}",
f"Extended: {', '.join(extended)}",
f"Ad-hoc: {_AD_HOC_PERIOD}",
"Event-driven: EVENT-N (where N is an event-number integer, e.g. EVENT-3)",
]
return "; ".join(lines)
def _format_accepted_period_set() -> str:
"""Format the registry-scoped accepted period set for error messages."""
lines = [
_format_accepted_filing_period_set(),
f"Administrative: {', '.join(sorted(_ADMINISTRATIVE_PERIOD_SET))}",
f"Symbolic registry selector: the literal {_SYMBOLIC_EVENT_SELECTOR}",
]
return "; ".join(lines)
#: Pydantic field annotation for bare registry period tokens. Use
#: :data:`FilingPeriodCode` (or :class:`Period`) instead when the value names a
#: period a taxpayer files in rather than a registry coordinate.
RegistryPeriodCode = Annotated[str, BeforeValidator(_validate_period_against_registry)]
#: Pydantic field annotation for a bare period token a taxpayer can file in.
#: Narrower than :data:`RegistryPeriodCode`: the administrative censo tokens and
#: the symbolic ``EVENT-N`` selector are refused.
FilingPeriodCode = Annotated[str, BeforeValidator(_validate_filing_period)]
def _validate_registry_selector_period(value: str) -> str:
"""Validate a registry token while preserving declaration casing for event selectors."""
normalized = _validate_period_against_registry(value)
if normalized in _ADMINISTRATIVE_PERIOD_SET:
return normalized.lower()
return normalized
RegistrySelectorPeriodCode = Annotated[str, BeforeValidator(_validate_registry_selector_period)]
[docs]
class PeriodError(CadrumoError):
"""Raised when a :class:`Period` is constructed from an invalid year/code.
Pydantic boundaries translate this registered failure to ``ValueError``
when they need Pydantic's structured validation envelope.
"""
[docs]
class PeriodKind(StrEnum):
"""Cadence class of a filing period, derived from its registry code."""
QUARTERLY = "quarterly"
MONTHLY = "monthly"
ANNUAL = "annual"
INSTALMENT = "instalment"
EXTENDED = "extended"
# Inclusive (start_month, end_month) for each quarterly token.
_QUARTER_SPAN_MONTHS: dict[str, tuple[int, int]] = {
"1T": (1, 3),
"2T": (4, 6),
"3T": (7, 9),
"4T": (10, 12),
}
_QUARTER_ORDINALS: dict[StandardPeriodCode, int] = {
StandardPeriodCode.Q1: 1,
StandardPeriodCode.Q2: 2,
StandardPeriodCode.Q3: 3,
StandardPeriodCode.Q4: 4,
}
def _period_kind_for_code(code: str) -> PeriodKind:
"""Classify a normalised period code's cadence.
The cadence is a property of the token alone — no filing year participates —
so this is shared by :attr:`Period.kind` and :func:`registry_period_kind`
rather than requiring a registry-facing caller to mint a throwaway
:class:`Period` (which the administrative tokens can no longer become).
"""
if code in _QUARTER_SPAN_MONTHS:
return PeriodKind.QUARTERLY
if code == "0A":
return PeriodKind.ANNUAL
if len(code) == 2 and code.isdigit():
return PeriodKind.MONTHLY
if len(code) == 2 and code.endswith("P") and code[0] in "1234":
return PeriodKind.INSTALMENT
return PeriodKind.EXTENDED
[docs]
def registry_period_kind(token: str) -> PeriodKind:
"""Return the cadence class of any REGISTRY period token.
Accepts the full :data:`RegistryPeriodCode` vocabulary, so a registry-facing
consistency check (a ``filing_schedules`` declaration reconciling its
``period_kind`` against its ``periods``) can classify an administrative censo
token or the symbolic ``EVENT-N`` selector. Both classify as
:attr:`PeriodKind.EXTENDED`, matching what a :class:`Period` reported for them
while one validator still served both boundaries.
This lives here, rather than in the one registry validator that calls it,
because :func:`_period_kind_for_code` is the single cadence authority
:attr:`Period.kind` also reads. Registry ownership would force either a
cross-package import of that private helper or a second token-to-cadence map —
a duplicate authority for "what cadence is this token". One narrow public
function for one narrow caller is the cheaper of the two, and it names the
vocabulary it speaks now that two vocabularies exist.
Raises:
ValueError: When ``token`` is not an accepted registry period code.
"""
normalized = _validate_period_against_registry(token)
if normalized in _ADMINISTRATIVE_PERIOD_SET or normalized == _SYMBOLIC_EVENT_SELECTOR:
return PeriodKind.EXTENDED
return _period_kind_for_code(normalized)
[docs]
class Period(BaseModel):
"""A filing period as one typed value: a year paired with a registry code.
``Period`` is the canonical, fundamental representation of "which filing
period" across the whole application. It composes exactly two authoritative
fields — the :attr:`filing_year` and the registry period :attr:`code`
(a :class:`StandardPeriodCode` member such as ``1T`` / ``0A`` / ``03``, or an
extended union member such as ``EXT-1T`` / ``AD-HOC`` / ``EVENT-3``) — and
never a combined calendar string (``2026Q1`` / ``2026-03`` / ``2026``).
Those calendar shapes are neither accepted inputs nor canonical outputs.
The only display projection is the space-separated :meth:`__str__` form
(``"2026 1T"``), and normal inbound construction is from a ``(year, code)``
pair via :meth:`from_year_and_code`.
The model is frozen and hashes by ``(filing_year, code)``, so a ``Period`` is
a drop-in dict key, set member, and equality target wherever a typed period
is required. The ledger and aggregation boundary routes through
:func:`application.aggregation.modelo_bindings.aggregation_period_for_modelo`, then uses
:meth:`contains` as the single date-boundary authority.
Attributes:
filing_year: Filing year carried separately from the registry token.
code: Bare :data:`FilingPeriodCode` token — the registry union minus the
coordinates that address a revision rather than a filing period.
"""
model_config = STRICT_FROZEN_CONFIG
filing_year: int = Field(ge=1980, le=2200)
"""Deliberately wider than the registry's authored filing-year window.
A period is a calendar span, not a claim that a revision exists for it. It
must be able to EXPRESS a year the registry cannot serve -- a pre-1995
IVA regime under Ley 41/1994, or a 1999 coordinate whose refusal is the
behaviour under test -- so narrowing it to the filing-year axis would make
those cases unrepresentable rather than refusable.
"""
code: FilingPeriodCode
[docs]
@classmethod
def from_year_and_code(cls, year: int, code: str) -> Period:
"""Build a :class:`Period` from a filing year and a bare registry code.
Args:
year: The filing year (e.g. ``2026``).
code: A bare filing period code — a :class:`StandardPeriodCode`
value (``1T``-``4T`` / ``1P``-``4P`` / ``0A`` / ``01``-``12``) or
an extended union member (``EXT-1T``-``EXT-4T`` / ``AD-HOC`` /
``EVENT-3``). The code is validated against
:data:`FilingPeriodCode`; a combined calendar string, an
administrative censo token, and the symbolic ``EVENT-N``
selector are all refused.
Raises:
PeriodError: When ``code`` is not an accepted filing period code or
``year`` is outside the supported range.
"""
try:
return cls(filing_year=year, code=code)
except ValueError as exc:
raise PeriodError(f"cannot build a period from year={year!r} code={code!r}: {exc}") from exc
[docs]
@classmethod
def from_string(cls, value: str) -> Period:
"""Parse the canonical display string emitted by :meth:`__str__`.
Only the separated ``"YYYY <registry-code>"`` display form is accepted.
Combined calendar strings such as ``"2026Q1"`` or ``"2026-1T"`` are
refused; this method is for display round-trips, not an alternate CLI or
registry grammar.
Args:
value: Space-separated display string containing a four-digit filing
year and a bare registry period code.
Returns:
A :class:`Period` parsed from the canonical display string.
Raises:
PeriodError: When ``value`` is not the display form or contains an
invalid registry period code.
"""
if not isinstance(value, str):
raise PeriodError(f"period string must be str, got {type(value).__name__}")
match = _DISPLAY_PERIOD_RE.fullmatch(value.strip())
if match is None:
raise PeriodError(f"invalid period display string {value!r}: expected 'YYYY <period-code>'")
return cls.from_year_and_code(int(match.group("year")), match.group("code"))
@property
def registry_token(self) -> str:
"""Return the bare registry period code as a string (e.g. ``"1T"``).
Use this when calling registry APIs that expect the bare
:data:`domain.calculations.registry.schema_scalars.PeriodCode` token
rather than a
structured :class:`Period`.
"""
return str(self.code)
@property
def standard_code(self) -> StandardPeriodCode | None:
"""Return the :class:`StandardPeriodCode` member, or ``None`` for extended forms."""
try:
return StandardPeriodCode(str(self.code))
except ValueError:
return None
@property
def quarter_ordinal(self) -> int | None:
"""Return the standard-quarter ordinal, or ``None`` for every non-quarter period.
Only the ordinary ``1T`` through ``4T`` registry tokens are quarterly.
Extended OSS/IOSS tokens such as ``EXT-1T`` retain their extended
cadence and therefore do not acquire a quarter ordinal.
"""
standard_code = self.standard_code
if standard_code is None:
return None
return _QUARTER_ORDINALS.get(standard_code)
@property
def is_quarterly(self) -> bool:
"""Return whether this is one of the four ordinary quarterly periods."""
return self.quarter_ordinal is not None
@property
def kind(self) -> PeriodKind:
"""Return the cadence class derived from the period code."""
return _period_kind_for_code(str(self.code))
[docs]
def has_date_span(self) -> bool:
"""Return whether the period maps to an inclusive calendar date span.
Quarterly, monthly, and annual periods cover a contiguous span of
calendar dates. Instalment claves (``1P``-``4P``) and the extended union
members are filing/payment events, not calendar spans, so they return
``False`` and :attr:`start_date` / :attr:`end_date` refuse for them.
"""
return self.kind in (PeriodKind.QUARTERLY, PeriodKind.MONTHLY, PeriodKind.ANNUAL)
@property
def start_date(self) -> date:
"""Return the inclusive first calendar date of the period.
Raises:
PeriodError: When the period has no calendar span
(:meth:`has_date_span` is ``False``).
"""
code = str(self.code)
if code in _QUARTER_SPAN_MONTHS:
return date(self.filing_year, _QUARTER_SPAN_MONTHS[code][0], 1)
if code == "0A":
return date(self.filing_year, 1, 1)
if self.kind is PeriodKind.MONTHLY:
return date(self.filing_year, int(code), 1)
raise PeriodError(f"period {self!s} has no calendar date span; guard with has_date_span()")
@property
def end_date(self) -> date:
"""Return the inclusive last calendar date of the period.
Raises:
PeriodError: When the period has no calendar span
(:meth:`has_date_span` is ``False``).
"""
code = str(self.code)
if code in _QUARTER_SPAN_MONTHS:
end_month = _QUARTER_SPAN_MONTHS[code][1]
return date(self.filing_year, end_month, calendar.monthrange(self.filing_year, end_month)[1])
if code == "0A":
return date(self.filing_year, 12, 31)
if self.kind is PeriodKind.MONTHLY:
month = int(code)
return date(self.filing_year, month, calendar.monthrange(self.filing_year, month)[1])
raise PeriodError(f"period {self!s} has no calendar date span; guard with has_date_span()")
[docs]
def contains(self, value: date) -> bool:
"""Return whether ``value`` falls within this period's inclusive span.
Raises:
PeriodError: When the period has no calendar span.
"""
return self.start_date <= value <= self.end_date
@override
def __str__(self) -> str:
"""Return the canonical display form: the year and code, space-separated.
This is the operator ``--year YYYY --period <token>`` shape rendered as
``"2026 1T"`` — deliberately NOT the combined ``2026Q1`` form the
standardisation removed, so the display can never be mistaken for, or
round-tripped as, a parseable combined token.
"""
return f"{self.filing_year} {self.code}"
@override
def __repr__(self) -> str:
return f"Period(filing_year={self.filing_year}, code={str(self.code)!r})"
@override
def __hash__(self) -> int:
return hash((self.filing_year, str(self.code)))
__all__ = [
"FilingPeriodCode",
"Period",
"PeriodError",
"PeriodKind",
"RegistryPeriodCode",
"StandardPeriodCode",
"accepted_filing_period_codes",
"accepted_filing_period_patterns",
"is_administrative_period_token",
"is_filing_period_token",
"is_symbolic_event_selector",
"registry_period_kind",
]