"""Proportionality and explainability primitives for category profiles.
Defines registry-projected vocabulary tokens and strict pydantic models that
encode how a spending category is deducted on the autónomo filings, plus the
citation chain back to the relevant authority that makes each rule
explainable. Every :class:`ProportionalityRule` carries at least one
:class:`CategoryCitation`; the consistency rules between
``kind``-specific fields (``fixed_pct``, ``default_ratio``,
``statutory_cap_*``) are enforced by the model validator.
"""
from __future__ import annotations
import re
from datetime import date
from decimal import Decimal
from enum import StrEnum
from typing import Self, override
from urllib.parse import urlsplit
from pydantic import AnyHttpUrl, BaseModel, Field, GetCoreSchemaHandler, field_validator, model_validator
from pydantic_core import CoreSchema, core_schema
from ...core.citation_grounding import CitationGrounding
from ...core.errors.hierarchy import pydantic_validation_boundary
from ...core.external_constants import load_external_constants
from ...core.i18n.translatable import Translatable as tr
from ...core.models import STRICT_FROZEN_CONFIG
from ...core.unit_proportion import UnitProportion
from ...core.url_validation import ANY_HTTP_URL_ADAPTER
from ...core.validity_window import ValidityWindow
from .errors import CategoryValidationError
#: The only scheme a citation may cite. Every AEAT and BOE surface the
#: shipped profiles reference publishes over TLS, so a non-``https``
#: citation is a downgrade rather than a legitimate authority.
_CITATION_SCHEME = "https"
class _ProportionalityStrictFrozenModel(BaseModel):
"""Shared strict immutable boundary model."""
model_config = STRICT_FROZEN_CONFIG
def _require_translatable_text(value: tr, field_name: str) -> None:
"""Assert a translatable authority field contains non-blank text."""
if not str(value).strip():
raise CategoryValidationError(f"{field_name} must contain authoritative Spanish text")
def _authoritative_citation_origins() -> frozenset[str]:
"""Return the registered domains a category citation may cite.
Derived from the canonical
:class:`core.external_constants.AeatDomainSection` registry, which owns every
AEAT / BOE hostname, so this module never restates a hostname literal
and a domain rotation reaches citations automatically.
"""
domains = load_external_constants().aeat.domains
origins = {domains.host_suffix.lower()}
for origin in (domains.boe, domains.legacy_www):
host = urlsplit(origin).hostname
if host is not None:
origins.add(host.lower())
return frozenset(origins)
def _host_is_authoritative(host: str, accepted: frozenset[str]) -> bool:
"""Return whether ``host`` is an accepted origin or a subdomain of one.
Suffix matching is anchored on a dot boundary so a look-alike host such
as ``evil-agenciatributaria.gob.es`` cannot pass by ending in the same
characters as the real origin.
"""
return any(host == origin or host.endswith(f".{origin}") for origin in accepted)
[docs]
class CategoryCitationSource(StrEnum):
"""Allowed citation sources for explainable category profiles.
Attributes:
MANUAL_RENTA: AEAT *Manual práctico Renta*.
MANUAL_IVA: AEAT *Manual práctico IVA*.
LEY_IRPF: Ley del Impuesto sobre la Renta de las Personas
Físicas.
REGLAMENTO_IRPF: Reglamento del IRPF.
AEAT_HELP: AEAT online help / portal text.
"""
MANUAL_RENTA = "manual_renta"
MANUAL_IVA = "manual_iva"
LEY_IRPF = "ley_irpf"
REGLAMENTO_IRPF = "reglamento_irpf"
AEAT_HELP = "aeat_help"
#: Citation sources published as a dated ANNUAL EDITION, as opposed to a statute
#: whose reference year is its enactment. The distinction is what makes the
#: anti-mirror invariant precise rather than a year-sniffing heuristic: "Manual
#: práctico Renta 2025" names the edition read, while "Ley 35/2006" names when
#: the law was passed and says nothing about which filing year it covers.
ANNUAL_EDITION_CITATION_SOURCES: frozenset[CategoryCitationSource] = frozenset(
{
CategoryCitationSource.MANUAL_RENTA,
CategoryCitationSource.MANUAL_IVA,
CategoryCitationSource.AEAT_HELP,
},
)
#: Citation sources that name a PROVISION rather than a dated publication. Their
#: windows are bounded by the provision's own effective span in the registry
#: legal catalogue, which makes a multi-year statutory citation a derived fact
#: instead of an author's confidence. Complete by construction against the enum:
#: a new source must land in one partition or the other, and the gate that reads
#: these sets reds if it lands in neither.
STATUTORY_CITATION_SOURCES: frozenset[CategoryCitationSource] = frozenset(
set(CategoryCitationSource) - ANNUAL_EDITION_CITATION_SOURCES,
)
#: Matches the four-digit edition year in an annual publication's reference.
_EDITION_YEAR_PATTERN = re.compile(r"(?<!\d)(?:19|20)\d{2}(?!\d)")
[docs]
class CategoryCitation(_ProportionalityStrictFrozenModel):
"""Traceable citation backing one category or proportionality rule.
Attributes:
source: Originating :class:`CategoryCitationSource`.
reference: Human-readable document reference (title,
edition, BOE number).
locator: Section, article, or page locator within the
referenced document.
url: Canonical URL where the citation can be checked. Constrained
to an official AEAT or BOE origin over ``https`` -- see
:meth:`_validate_authoritative_url`.
quote: Verbatim Spanish from the bundled corpus, stored INLINE and never
as a translation key. Verifying a quotation requires the literal
text at the citation site; indirecting it means the record no longer
carries its own evidence, whatever the key resolves to. Empty when,
and only when, :attr:`grounding` is not ``VERIFIED``.
grounding: Which of the three states this citation's evidence is in --
read against the corpus, examined and refused, or unreachable
because the cited document is not bundled.
grounding_reason: Why no quotation is carried. Required when, and only
when, :attr:`grounding` is not ``VERIFIED``.
legal_ref: The registry legal-catalogue id of the provision cited.
Required on a STATUTORY source and forbidden on an annual-edition
one, so that every citation is bounded on exactly one axis: an
edition-dated citation by the edition it names, a statutory one by
the provision's own effective span. Leaving it optional would let an
author widen a statutory window with nothing able to check it.
valid_from: First date this citation is asserted to support the rule.
valid_to: Last date, inclusive. Both bounds are required: the span is
the claim, and a defaulted one would assert grounding nobody typed.
"""
source: CategoryCitationSource
reference: str = Field(min_length=1, max_length=256)
locator: str = Field(min_length=1, max_length=256)
url: AnyHttpUrl
quote: str = Field(
default="",
description="Verbatim Spanish from the bundled corpus; empty only when grounding is not verified.",
)
grounding: CitationGrounding = Field(
default=CitationGrounding.VERIFIED,
description="Whether the quotation was verified against the corpus, refused, or is unreachable.",
)
grounding_reason: str = Field(
default="",
description="Why no quotation is carried; required when grounding is not verified.",
)
legal_ref: str | None = Field(
default=None,
min_length=1,
description="Registry legal-catalogue id of the cited provision; statutory sources only.",
)
valid_from: date
valid_to: date
@property
def window(self) -> ValidityWindow:
"""Return the closed span this citation is asserted over.
Returns:
The :class:`~core.validity_window.ValidityWindow` the two declared
bounds describe.
"""
return ValidityWindow(valid_from=self.valid_from, valid_to=self.valid_to)
@field_validator("url", mode="after")
@classmethod
@pydantic_validation_boundary
def _validate_authoritative_url(cls, value: AnyHttpUrl) -> AnyHttpUrl:
"""Refuse a citation that does not resolve to an official origin.
A citation is the operator's route back to the authority behind a
deduction rule, so an arbitrary host is not a weaker citation --
it is an unverifiable one. ``AnyHttpUrl`` alone accepts any host on
either scheme, so the origin is constrained here against the
canonical :class:`core.external_constants.AeatDomainSection` registry
rather than against hostname literals restated in this module.
"""
accepted = _authoritative_citation_origins()
host = (value.host or "").lower()
scheme = value.scheme.lower()
if scheme != _CITATION_SCHEME or not _host_is_authoritative(host, accepted):
raise CategoryValidationError(
f"category citation url {str(value)!r} is not an official authority: "
f"expected an {_CITATION_SCHEME} URL whose host is, or is a subdomain of, "
f"one of {', '.join(sorted(accepted))}",
)
return value
@model_validator(mode="after")
@pydantic_validation_boundary
def _validate_quote(self) -> CategoryCitation:
self._validate_grounding_matches_its_evidence()
# Constructing the window is the validation: an inverted span refuses
# here, where the citation was written, rather than silently covering
# no year at all downstream.
_ = self.window
self._validate_edition_year_bounds_the_window()
self._validate_legal_ref_matches_the_source_kind()
return self
def _validate_grounding_matches_its_evidence(self) -> None:
"""Hold each grounding state to the evidence it claims.
Unlike the check this replaced, both branches can fail. The old one
asserted a translatable was non-empty AFTER the loader had already
resolved it through a fallback that never yields an empty string, so it
inspected the literal word "Quote", found it non-empty, and passed for
all eighty-three citations in the corpus.
The second branch matters as much as the first. The corpus-containment
gate skips a non-verified citation by design, so candidate text parked
there would never be read against anything while still reading as
evidence to anyone who printed it.
"""
where = f"CategoryCitation[{self.source.value}/{self.locator}]"
if self.grounding is CitationGrounding.VERIFIED:
if not self.quote.strip():
raise CategoryValidationError(
f"{where}: a verified citation must carry its verbatim quotation",
)
if self.grounding_reason.strip():
raise CategoryValidationError(
f"{where}: a verified citation must not carry a grounding reason",
)
return
if not self.grounding_reason.strip():
raise CategoryValidationError(
f"{where}: a {self.grounding.value!r} citation must record WHY it carries no "
"quotation, so that it reads as examined rather than merely unchecked",
)
if self.quote.strip():
raise CategoryValidationError(
f"{where}: a {self.grounding.value!r} citation must not carry a quotation; "
"the containment gate skips this state, so the text would never be checked",
)
def _validate_legal_ref_matches_the_source_kind(self) -> None:
"""Every citation must be checkable on exactly one axis, never neither.
A statutory citation carries no edition year to bound it, so without a
provision id nothing can judge how far its window may reach and an
author could widen it freely. An annual-edition citation is already
bounded by the edition it names; giving it a provision id too would
offer a second, looser axis to be judged on.
"""
if self.source in STATUTORY_CITATION_SOURCES and self.legal_ref is None:
raise CategoryValidationError(
f"category citation from {self.source.value!r} cites a provision and must name it with "
"legal_ref, so its validity window can be bounded by that provision's effective span "
"rather than asserted",
)
if self.source in ANNUAL_EDITION_CITATION_SOURCES and self.legal_ref is not None:
raise CategoryValidationError(
f"category citation from {self.source.value!r} is bounded by the edition year it names; "
f"it must not also carry legal_ref={self.legal_ref!r}",
)
def _validate_edition_year_bounds_the_window(self) -> None:
"""Refuse an annual-edition citation whose window reaches past its edition.
THIS IS THE ANTI-MIRROR INVARIANT, and it is enforced here, at
construction, rather than only in a gate: a corpus that refuses the
shape cannot acquire it, whereas a gate can be run late or not at all.
A citation naming an annual edition asserts that THAT edition supports
the rule. Stretching its window over a neighbouring year converts a
document nobody read into a grounding claim, which is precisely how the
retired per-year mirror was produced -- its year-dated references were
rewritten by string substitution from a reviewed year, so the copy
asserted 41 times that a manual nobody opened said something.
The edition year is required, not merely checked when present. An annual
publication cited without an edition is unciteable in the first place,
and leaving the year optional would hand an author a one-token escape
from the invariant.
"""
if self.source not in ANNUAL_EDITION_CITATION_SOURCES:
return
edition_years = {int(match.group()) for match in _EDITION_YEAR_PATTERN.finditer(self.reference)}
if not edition_years:
raise CategoryValidationError(
f"category citation from {self.source.value!r} must name the edition year it was read "
f"from in its reference, but reference={self.reference!r} carries none; an annual "
"publication cited without an edition cannot be checked against its window",
)
outside = sorted(year for year in self.window.years() if year not in edition_years)
if outside:
raise CategoryValidationError(
f"category citation reference={self.reference!r} names edition year(s) "
f"{sorted(edition_years)} but its validity window reaches {outside}; read the source "
"for those years and cite it, never widen an edition-dated citation to cover them",
)
[docs]
def parse_http_url(value: str) -> AnyHttpUrl:
"""Parse a string into a statically typed :class:`AnyHttpUrl`.
Args:
value: Raw HTTP / HTTPS URL string.
Returns:
A validated :class:`pydantic.AnyHttpUrl`.
"""
return ANY_HTTP_URL_ADAPTER.validate_python(value)
[docs]
class ProportionalityKind:
"""Opaque proportionality token projected from the facts registry.
The token and its evaluator-role metadata are governed category facts. This
object retains only the projected token plus mechanics-facing role flags;
callers cannot mint a token without a validated registry projection.
"""
_is_full_deductible: bool
_is_non_deductible: bool
_is_statutory_cap: bool
_is_usage_ratio: bool
_requires_exclusive_use: bool
_requires_fixed_pct: bool
_token: str
__slots__ = (
"_is_full_deductible",
"_is_non_deductible",
"_is_statutory_cap",
"_is_usage_ratio",
"_requires_exclusive_use",
"_requires_fixed_pct",
"_token",
)
def __new__(
cls,
token: str,
*,
is_full_deductible: bool,
is_usage_ratio: bool,
is_statutory_cap: bool,
requires_fixed_pct: bool,
is_non_deductible: bool,
requires_exclusive_use: bool,
_registry_validated: bool = False,
) -> Self:
"""Create one registry-projected proportionality token."""
if not _registry_validated:
raise TypeError("ProportionalityKind tokens must be projected from the facts registry")
if not isinstance(token, str) or not token.strip():
raise ValueError("ProportionalityKind token must be a non-empty string")
if not any(
(
is_full_deductible,
is_usage_ratio,
is_statutory_cap,
requires_fixed_pct,
is_non_deductible,
requires_exclusive_use,
)
):
raise ValueError("ProportionalityKind projection must declare an evaluator role")
self = super().__new__(cls)
self._token = token
self._is_full_deductible = is_full_deductible
self._is_usage_ratio = is_usage_ratio
self._is_statutory_cap = is_statutory_cap
self._requires_fixed_pct = requires_fixed_pct
self._is_non_deductible = is_non_deductible
self._requires_exclusive_use = requires_exclusive_use
return self
[docs]
@classmethod
def from_registry(cls, token: str, **roles: bool) -> Self:
"""Construct the typed value from its canonical registry token."""
return cls(token, _registry_validated=True, **roles)
@classmethod
def _require_registry_token(cls, value: object) -> Self:
if isinstance(value, cls):
return value
raise CategoryValidationError("ProportionalityKind must be a registry-projected token")
@classmethod
def __get_pydantic_core_schema__(
cls,
_source_type: object,
_handler: GetCoreSchemaHandler,
) -> CoreSchema:
"""Expose the projected proportionality token to Pydantic."""
return core_schema.no_info_plain_validator_function(
cls._require_registry_token,
json_schema_input_schema=core_schema.str_schema(),
serialization=core_schema.to_string_ser_schema(),
)
@property
def value(self) -> str:
"""Return the canonical proportionality token text."""
return self._token
@property
def name(self) -> str:
"""Return the canonical proportionality token name."""
return self._token
@property
def is_full_deductible(self) -> bool:
"""Return whether this role allows full deduction."""
return self._is_full_deductible
@property
def is_usage_ratio(self) -> bool:
"""Return whether this role uses a declared usage ratio."""
return self._is_usage_ratio
@property
def is_statutory_cap(self) -> bool:
"""Return whether this role applies a statutory cap."""
return self._is_statutory_cap
@property
def requires_fixed_pct(self) -> bool:
"""Return whether this role requires a fixed percentage."""
return self._requires_fixed_pct
@property
def is_non_deductible(self) -> bool:
"""Return whether this role disallows deduction."""
return self._is_non_deductible
@property
def requires_exclusive_use(self) -> bool:
"""Return whether this role requires exclusive use."""
return self._requires_exclusive_use
@override
def __str__(self) -> str:
return self._token
@override
def __repr__(self) -> str:
return f"{type(self).__name__}({self._token!r})"
@override
def __hash__(self) -> int:
return hash(self._token)
@override
def __eq__(self, other: object) -> bool:
return isinstance(other, ProportionalityKind) and self._token == other._token
[docs]
class StatutoryCapPeriod:
"""Opaque statutory-cap period token projected from the facts registry."""
_is_per_person: bool
_token: str
__slots__ = ("_is_per_person", "_token")
def __new__(cls, token: str, *, is_per_person: bool, _registry_validated: bool = False) -> Self:
"""Create one registry-projected statutory-cap period token."""
if not _registry_validated:
raise TypeError("StatutoryCapPeriod tokens must be projected from the facts registry")
if not isinstance(token, str) or not token.strip():
raise ValueError("StatutoryCapPeriod token must be a non-empty string")
self = super().__new__(cls)
self._token = token
self._is_per_person = is_per_person
return self
[docs]
@classmethod
def from_registry(cls, token: str, *, is_per_person: bool) -> Self:
"""Construct the typed value from its canonical registry token."""
return cls(token, is_per_person=is_per_person, _registry_validated=True)
@classmethod
def _require_registry_token(cls, value: object) -> Self:
if isinstance(value, cls):
return value
raise CategoryValidationError("StatutoryCapPeriod must be a registry-projected token")
@classmethod
def __get_pydantic_core_schema__(
cls,
_source_type: object,
_handler: GetCoreSchemaHandler,
) -> CoreSchema:
"""Expose the projected statutory-cap period to Pydantic."""
return core_schema.no_info_plain_validator_function(
cls._require_registry_token,
json_schema_input_schema=core_schema.str_schema(),
serialization=core_schema.to_string_ser_schema(),
)
@property
def value(self) -> str:
"""Return the canonical statutory-cap period token text."""
return self._token
@property
def name(self) -> str:
"""Return the canonical statutory-cap period token name."""
return self._token
@property
def is_per_person(self) -> bool:
"""Return whether the cap is evaluated per person."""
return self._is_per_person
@override
def __str__(self) -> str:
return self._token
@override
def __repr__(self) -> str:
return f"{type(self).__name__}({self._token!r})"
@override
def __hash__(self) -> int:
return hash(self._token)
@override
def __eq__(self, other: object) -> bool:
return isinstance(other, StatutoryCapPeriod) and self._token == other._token
[docs]
class StatutoryCapVariant(_ProportionalityStrictFrozenModel):
"""One legally distinct cap inside a statutory-cap rule, selected by a condition.
The condition is what makes a variant a variant; the UNIT of the amount is
incidental and the law uses both. RIRPF art. 9.A.3.a distinguishes con and sin
pernocta and states DAILY amounts; LIRPF art. 30.2.5.a distinguishes a person with
discapacidad from one without and states ANNUAL per-person amounts. Modelling only
the daily unit is why the second provision could ship with one of its two limbs
missing.
Attributes:
id: Stable identifier the calculation context selects on.
label: Translation key for the human-readable label.
statutory_cap_eur_per_day: A daily amount, for a rule capped per day.
statutory_cap_eur: An annual amount, for a rule capped per period. Exactly
one of the two is set: a variant carrying both would leave the unit to
the call site, and a variant carrying neither caps nothing.
"""
id: str = Field(min_length=1, max_length=64)
label: tr = Field(description="Translation key for the human-readable label.")
statutory_cap_eur_per_day: Decimal | None = Field(default=None, ge=Decimal("0"))
statutory_cap_eur: Decimal | None = Field(default=None, ge=Decimal("0"))
@model_validator(mode="after")
@pydantic_validation_boundary
def _validate_label(self) -> StatutoryCapVariant:
_require_translatable_text(self.label, "statutory cap variant label")
declared = (self.statutory_cap_eur_per_day is not None, self.statutory_cap_eur is not None)
if not any(declared):
raise CategoryValidationError(
f"statutory cap variant {self.id!r} declares no amount; a variant that caps "
"nothing cannot be selected for anything",
)
if all(declared):
raise CategoryValidationError(
f"statutory cap variant {self.id!r} declares both a daily and an annual amount; "
"the unit must be fixed by the variant, not chosen at the call site",
)
return self
@property
def is_per_day(self) -> bool:
"""Return whether this variant's amount is a daily one.
Returns:
``True`` when the variant carries :attr:`statutory_cap_eur_per_day`.
"""
return self.statutory_cap_eur_per_day is not None
[docs]
class StatutoryCapAmount(_ProportionalityStrictFrozenModel):
"""One year-bounded value of a statutory cap whose amount the law varies.
Some statutory caps are fixed by the tax law and hold until it is amended --
the 500 euro seguro de enfermedad limit of LIRPF art. 30.2.5.a is one. Others
are fixed BY REFERENCE to a figure that moves every year: LIRPF art. 30.2.1
caps the deductible mutualidad alternativa premium at the cuota maxima por
contingencias comunes established "en cada ejercicio economico" in the RETA.
A single :attr:`ProportionalityRule.statutory_cap_eur` cannot express the
second kind. Encoding one year's figure -- or worse, a round number close to
none of them -- silently applies it to every filing year, and the error is
invisible because the value looks like a law-fixed constant. The registry
shipped exactly that: a flat 15000 that matched no ejercicio at all.
Attributes:
value: The cap amount in euros for this span.
valid_from: First date the amount applies.
valid_to: Last date, inclusive. Both bounds required and closed, for the
reasons :class:`~core.validity_window.ValidityWindow` documents.
"""
value: Decimal = Field(ge=Decimal("0"))
valid_from: date
valid_to: date
@property
def window(self) -> ValidityWindow:
"""Return the closed span this amount applies over.
Returns:
The :class:`~core.validity_window.ValidityWindow` the bounds describe.
"""
return ValidityWindow(valid_from=self.valid_from, valid_to=self.valid_to)
@model_validator(mode="after")
@pydantic_validation_boundary
def _span_is_coherent(self) -> StatutoryCapAmount:
_ = self.window
return self
def _statutory_cap_mode_flags(rule: ProportionalityRule) -> tuple[bool, bool, bool, bool]:
"""Return the daily, generic, variant, and scheduled cap-mode flags."""
has_daily_cap = rule.statutory_cap_eur_per_day is not None
has_scheduled_cap = bool(rule.statutory_cap_schedule)
has_variant_caps = bool(rule.statutory_cap_variants)
# A bare period is only a cap MODE of its own when nothing else claims it.
# A dated schedule and an annual variant set both legitimately declare the
# period the per-person amount applies over, and counting that as a second
# mode would refuse two shapes the law actually uses.
has_generic_cap = rule.statutory_cap_eur is not None or (
rule.statutory_cap_period is not None and not has_scheduled_cap and not has_variant_caps
)
return has_daily_cap, has_generic_cap, has_variant_caps, has_scheduled_cap
def _require_one_statutory_cap_mode(
*,
has_daily_cap: bool,
has_generic_cap: bool,
has_variant_caps: bool,
has_scheduled_cap: bool,
) -> None:
"""Require a statutory-cap rule to declare at least one cap mode."""
if not any((has_daily_cap, has_generic_cap, has_variant_caps, has_scheduled_cap)):
raise CategoryValidationError("statutory_cap rules require a cap amount")
def _reject_mixed_statutory_cap_modes(
*,
has_daily_cap: bool,
has_generic_cap: bool,
has_variant_caps: bool,
has_scheduled_cap: bool,
) -> None:
"""Reject a statutory-cap rule that declares more than one cap mode."""
mode_count = sum((has_daily_cap, has_generic_cap, has_variant_caps, has_scheduled_cap))
if mode_count > 1:
raise CategoryValidationError("statutory cap rules must use one cap mode")
def _validate_scheduled_statutory_cap(rule: ProportionalityRule) -> None:
"""Require a schedule period and refuse contradictory scheduled amounts."""
if rule.statutory_cap_period is None:
raise CategoryValidationError("statutory_cap_schedule requires statutory_cap_period")
_reject_contradictory_scheduled_caps(rule)
def _reject_contradictory_scheduled_caps(rule: ProportionalityRule) -> None:
"""Two different amounts covering one filing year is a contradiction.
Silently taking the first or the last would make the applied cap depend
on authoring order, which is the class of defect a dated schedule exists
to remove.
"""
seen: dict[int, Decimal] = {}
for amount in rule.statutory_cap_schedule:
for year in amount.window.years():
if year in seen and seen[year] != amount.value:
raise CategoryValidationError(
f"statutory_cap_schedule declares two different amounts for {year}: "
f"{seen[year]} and {amount.value}",
)
seen[year] = amount.value
def _validate_generic_statutory_cap(rule: ProportionalityRule) -> None:
"""Require a generic statutory amount and its accounting period as a pair."""
if rule.statutory_cap_eur is None and rule.statutory_cap_period is not None:
raise CategoryValidationError("statutory_cap_period requires statutory_cap_eur")
if rule.statutory_cap_eur is not None and rule.statutory_cap_period is None:
raise CategoryValidationError("statutory_cap_eur requires statutory_cap_period")
def _validate_statutory_cap_mode_shape(
rule: ProportionalityRule,
*,
has_variant_caps: bool,
has_scheduled_cap: bool,
) -> None:
"""Validate the fields specific to the selected statutory-cap mode."""
if has_scheduled_cap:
_validate_scheduled_statutory_cap(rule)
elif not has_variant_caps:
_validate_generic_statutory_cap(rule)
def _reject_duplicate_statutory_cap_variant_ids(rule: ProportionalityRule) -> None:
"""Reject duplicate identifiers inside a statutory-cap variant set."""
variant_ids = [variant.id for variant in rule.statutory_cap_variants]
if len(set(variant_ids)) != len(variant_ids):
raise CategoryValidationError("statutory cap variant ids must be unique")
def _validate_statutory_cap_variant_units(rule: ProportionalityRule) -> None:
"""Require all variants to use one unit and annual variants to name a period."""
if not rule.statutory_cap_variants:
return
units = {variant.is_per_day for variant in rule.statutory_cap_variants}
if len(units) > 1:
raise CategoryValidationError(
"statutory cap variants must agree on their unit; mixing a daily variant with "
"an annual one leaves the resolver to guess which the rule is capped in",
)
if not next(iter(units)) and rule.statutory_cap_period is None:
raise CategoryValidationError(
"annual statutory cap variants require statutory_cap_period, which is the "
"period the per-person amount applies over",
)
def _validate_statutory_cap_variants(rule: ProportionalityRule) -> None:
"""Validate identifiers and unit coherence for statutory-cap variants."""
_reject_duplicate_statutory_cap_variant_ids(rule)
_validate_statutory_cap_variant_units(rule)
[docs]
class ProportionalityRule(_ProportionalityStrictFrozenModel):
"""Deductibility and proportionality rule for one spending category.
Attributes:
kind: One of :class:`ProportionalityKind`.
fixed_pct: Required when the projected kind declares a fixed-percent
evaluator role; otherwise must be ``None``.
default_ratio: Optional default usage ratio; only valid for
usage-ratio kinds.
statutory_multiplier: Optional statutory factor applied on
top of the operator-chosen usage ratio. Only valid for
usage-ratio kinds. The canonical example is the LIRPF
Art. 30.2 rule 5 (Ley 6/2017, BOE-A-2017-12544) 0.30
multiplier applied to suministros (utility) costs of
the habitual vivienda when the operator deducts under
estimacion directa: ``effective_deductible_pct =
operator_chosen_ratio * statutory_multiplier``. When
``None`` no statutory factor is applied (equivalent to
``Decimal("1")``); the operator's chosen ratio is the
effective deductible percentage.
statutory_cap_eur_per_day: Daily statutory cap; only valid
for a projected statutory-cap kind.
statutory_cap_eur: Generic statutory cap amount; only valid
for a projected statutory-cap kind and must be paired with
:attr:`statutory_cap_period`.
statutory_cap_period: :class:`StatutoryCapPeriod` that the
generic cap applies over; required when
:attr:`statutory_cap_eur` is set.
statutory_cap_variants: Daily statutory caps selected by a
legally relevant condition.
statutory_cap_schedule: Dated amounts for a cap the law re-fixes each
ejercicio. Mutually exclusive with the fixed
:attr:`statutory_cap_eur`: a cap is either law-fixed or
year-referenced, never both.
citations: At least one :class:`CategoryCitation` proving
the rule.
notes: Translation key for the notes describing the rule.
"""
kind: ProportionalityKind
fixed_pct: UnitProportion | None = None
default_ratio: UnitProportion | None = None
statutory_multiplier: UnitProportion | None = None
statutory_cap_eur_per_day: Decimal | None = Field(default=None, ge=Decimal("0"))
statutory_cap_eur: Decimal | None = Field(default=None, ge=Decimal("0"))
statutory_cap_period: StatutoryCapPeriod | None = None
statutory_cap_variants: tuple[StatutoryCapVariant, ...] = Field(default_factory=tuple)
statutory_cap_schedule: tuple[StatutoryCapAmount, ...] = Field(default_factory=tuple)
citations: tuple[CategoryCitation, ...] = Field(default_factory=tuple)
notes: tr = Field(description="Translation key for the notes describing the rule.")
@model_validator(mode="after")
@pydantic_validation_boundary
def _validate_shape(self) -> ProportionalityRule:
if not self.citations:
raise CategoryValidationError("proportionality rules require at least one citation")
_require_translatable_text(self.notes, "proportionality rule notes")
self._validate_fixed_percentage_invariants()
self._validate_usage_ratio_invariants()
if self.kind.is_statutory_cap:
self._validate_statutory_cap_invariants()
else:
self._reject_statutory_cap_fields_outside_cap_kind()
return self
def _validate_fixed_percentage_invariants(self) -> None:
"""``fixed_pct`` follows the projected fixed-percent evaluator role."""
if self.kind.requires_fixed_pct and self.fixed_pct is None:
raise CategoryValidationError("fixed-percentage rules require fixed_pct")
if not self.kind.requires_fixed_pct and self.fixed_pct is not None:
raise CategoryValidationError("fixed_pct is only valid for fixed-percentage rules")
def _validate_usage_ratio_invariants(self) -> None:
"""``default_ratio`` and ``statutory_multiplier`` are only valid on usage-ratio rules."""
is_usage_ratio = self.kind.is_usage_ratio
if not is_usage_ratio and self.default_ratio is not None:
raise CategoryValidationError("default_ratio is only valid for usage_ratio rules")
if not is_usage_ratio and self.statutory_multiplier is not None:
raise CategoryValidationError(
"statutory_multiplier is only valid for usage_ratio rules",
)
def _validate_statutory_cap_invariants(self) -> None:
"""STATUTORY_CAP rules require exactly one cap mode and a coherent (eur, period) pair."""
has_daily_cap, has_generic_cap, has_variant_caps, has_scheduled_cap = _statutory_cap_mode_flags(self)
_require_one_statutory_cap_mode(
has_daily_cap=has_daily_cap,
has_generic_cap=has_generic_cap,
has_variant_caps=has_variant_caps,
has_scheduled_cap=has_scheduled_cap,
)
_reject_mixed_statutory_cap_modes(
has_daily_cap=has_daily_cap,
has_generic_cap=has_generic_cap,
has_variant_caps=has_variant_caps,
has_scheduled_cap=has_scheduled_cap,
)
_validate_statutory_cap_mode_shape(
self,
has_variant_caps=has_variant_caps,
has_scheduled_cap=has_scheduled_cap,
)
_validate_statutory_cap_variants(self)
[docs]
def cap_amount_for_year(self, year: int) -> Decimal | None:
"""Return the statutory cap amount in force for ``year``.
Returns:
The scheduled amount covering ``year`` when the cap is
year-referenced, the flat :attr:`statutory_cap_eur` when it is
law-fixed, and ``None`` when this rule carries neither.
"""
for amount in self.statutory_cap_schedule:
if amount.window.covers_year(year):
return amount.value
return self.statutory_cap_eur if not self.statutory_cap_schedule else None
def _reject_statutory_cap_fields_outside_cap_kind(self) -> None:
"""Every statutory-cap field is forbidden on non-STATUTORY_CAP kinds."""
if self.statutory_cap_eur_per_day is not None:
raise CategoryValidationError("statutory_cap_eur_per_day is only valid for statutory_cap rules")
if self.statutory_cap_eur is not None:
raise CategoryValidationError("statutory_cap_eur is only valid for statutory_cap rules")
if self.statutory_cap_period is not None:
raise CategoryValidationError("statutory_cap_period is only valid for statutory_cap rules")
if self.statutory_cap_variants:
raise CategoryValidationError("statutory_cap_variants are only valid for statutory_cap rules")
if self.statutory_cap_schedule:
raise CategoryValidationError("statutory_cap_schedule is only valid for statutory_cap rules")
[docs]
def effective_usage_ratio(rule: ProportionalityRule, chosen_ratio: Decimal) -> Decimal:
"""Return the legally-effective deductible percentage for ``chosen_ratio``.
Applies the rule's ``statutory_multiplier`` on top of the operator-
chosen usage ratio. Only meaningful for usage-ratio kinds; raises
when called on a non-usage-ratio rule (the caller is responsible
for routing rules to the right evaluator).
Args:
rule: A :class:`ProportionalityRule` of a usage-ratio kind.
chosen_ratio: The operator's stored usage ratio (typically
derived from censo ``office_m2 / total_m2`` for HOME_AREA
kinds, or a personal-use proportion for PERSONAL kinds).
Returns:
``chosen_ratio * (rule.statutory_multiplier or Decimal("1"))``.
Raises:
CategoryValidationError: When ``rule.kind`` is not a usage-ratio kind.
"""
if not rule.kind.is_usage_ratio:
raise CategoryValidationError(
f"effective_usage_ratio is only valid for usage_ratio rules; got {rule.kind}",
)
multiplier = rule.statutory_multiplier if rule.statutory_multiplier is not None else Decimal("1")
return chosen_ratio * multiplier