"""Typed personal/family profile records for Modelo 100 inputs.
The records in this module describe factual people and family-unit flags.
They do not decide Modelo 100 legal treatment, minimum amounts, deduction
eligibility, or casilla formulas; those remain registry-owned.
:class:`DescendantInfo`, :class:`RentaDescendantProfile`, and
:class:`RentaAscendantProfile` feed :class:`RentaFamilyProfile`, whose helper
methods derive Art. 58 minimum counts and Art. 81 maternity/guardería amounts
from the factual profile records.
"""
from __future__ import annotations
from collections.abc import Sequence
from datetime import date
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, Field, field_validator, model_validator
from ...core import STRICT_FROZEN_CONFIG as _STRICT_FROZEN
from ...core.external_constants import (
CUSTODIA_COMPARTIDA_PRORRATA_FACTOR,
DEDUCCION_MATERNIDAD_ANUAL_CAP_EUR,
DEDUCCION_MATERNIDAD_MENSUAL_EUR,
INCREMENTO_GUARDERIA_POR_HIJO_CAP_EUR,
MINIMO_DESCENDIENTE_MAX_AGE,
MINIMO_MENOR_TRES_MAX_AGE,
)
from ...core.parsing import parse_iso8601_date
from ._errors import ProfileValidationError
# Comunidad de Madrid "Por nacimiento o adopción de hijos" deducción autonómica
# (DL 1/2010, de 21 octubre, arts. 4 y 18.1). Ámbito temporal: the deducción
# applies in the period of nacimiento/adopción AND in each of the two following
# periods — a three-period window keyed on the entry (nacimiento/adopción) year.
# Grounded in the bundled AEAT Renta 2025 manual, parte 2 (deducciones
# autonómicas), "Ámbito temporal de aplicación de la deducción".
_NACIMIENTO_ADOPCION_APPLICABILITY_FOLLOWING_PERIODS = 2
[docs]
def within_multi_year_applicability_window(
entry_year: int,
filing_year: int,
*,
following_periods: int,
) -> bool:
"""Return whether *filing_year* is inside a multi-year applicability window.
A reusable primitive for autonomic deducciones that apply in the period of
a triggering event (a nacimiento, an adopción, a rehabilitación, ...) plus a
fixed number of following periods. The window is the closed interval
``[entry_year, entry_year + following_periods]``; ``following_periods = 0``
yields a single-year window.
Args:
entry_year: Calendar year of the triggering event.
filing_year: The filing year whose eligibility is being tested.
following_periods: Count of periods after ``entry_year`` in which the
deducción remains applicable. Must be non-negative.
"""
if following_periods < 0:
raise ProfileValidationError("following_periods must be non-negative")
return entry_year <= filing_year <= entry_year + following_periods
# Art. 58 thresholds sourced from the central authority: age < 25 (exclusive)
# for ordinary mínimo eligibility, age < 3 (exclusive) for the bajo-3-años
# supplement. The module-private aliases keep the internal call sites stable.
_MAX_AGE_ORDINARY = MINIMO_DESCENDIENTE_MAX_AGE
_MAX_AGE_MENOR_TRES = MINIMO_MENOR_TRES_MAX_AGE
def _coerce_iso_date_field(value: object) -> object:
"""Delegate for @field_validator date fields: parse ISO strings, pass through everything else."""
if isinstance(value, str):
return parse_iso8601_date(value)
return value
[docs]
class DescendantInfo(BaseModel):
"""Structured per-descendant data for Art. 58 mínimo-por-descendientes.
This record drives the mínimo-por-descendientes calculation (casilla 0513)
and the bajo-3-años supplement (Art. 58.2). It is intentionally richer
than :class:`RentaDescendantProfile`, which models official-form rows.
Fields
------
birth_date
Required date of birth.
adoption_date
Finalisation date of the adoption, or ``None`` for a biological child.
When present it must be ≥ ``birth_date`` and ≤ today.
discapacidad_grado
0 = sin discapacidad, 33 = grado ≥ 33 % < 65 %, 65 = grado ≥ 65 %.
A disabled descendant remains mínimo-eligible regardless of age.
convive_con_contribuyente
Whether the descendant cohabits with the taxpayer (Art. 58.1 condition).
custodia_compartida
Art. 61 LIRPF: when ``True``, both progenitors share custody under a
judicial or administrative arrangement. The mínimo-por-descendientes
and the bajo-3-años supplement for this child are split 50 % between
them (Art. 61 prorrata). Default ``False`` (sole custody / not
applicable). Setting this flag on a non-cohabiting descendant has no
additional effect because eligibility already fails.
meses_madre_trabajo_2024
Months the mother worked while this child was under 3 years old during
the 2024 filing year. Used by Art. 81 LIRPF deducción maternidad:
``min(meses × 100, 1_200)`` per eligible child. Valid range: 0–12.
Default ``0`` (no deducción contribution from this child).
gastos_guarderia_euros
Actual guardería / centro educación infantil autorizado expenses paid
for this child (Art. 81 bis LIRPF). Integer euros, ≥ 0.
Default ``0`` (no guardería expenses declared for this child).
nif
Optional NIF/NIE; validated for shape when present.
"""
model_config = _STRICT_FROZEN
birth_date: date
adoption_date: date | None = None
discapacidad_grado: Literal[0, 33, 65] | None = None
convive_con_contribuyente: bool = True
custodia_compartida: bool = False
meses_madre_trabajo_2024: int = Field(default=0, ge=0, le=12)
gastos_guarderia_euros: int = Field(default=0, ge=0)
nif: str | None = None
@field_validator("birth_date", "adoption_date", mode="before")
@classmethod
def _parse_date(cls, value: object) -> object:
return _coerce_iso_date_field(value)
@field_validator("nif")
@classmethod
def _validate_nif(cls, value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip().upper()
if not stripped:
raise ProfileValidationError("nif must not be blank when provided")
if len(stripped) not in (9,):
raise ProfileValidationError(f"nif must be 9 characters, got {len(stripped)!r} for {value!r}")
return stripped
@model_validator(mode="after")
def _validate_adoption_date(self) -> DescendantInfo:
if self.adoption_date is None:
return self
if self.adoption_date < self.birth_date:
raise ProfileValidationError(f"adoption_date {self.adoption_date} must be ≥ birth_date {self.birth_date}")
today = date.today()
if self.adoption_date > today:
raise ProfileValidationError(
f"adoption_date {self.adoption_date} must not be in the future (today={today})",
)
return self
[docs]
def age_at_year_end(self, filing_year: int) -> int:
"""Return the descendant's age on 31 December of *filing_year*."""
year_end = date(filing_year, 12, 31)
age = year_end.year - self.birth_date.year
# Subtract one if the birthday has not yet occurred by year-end.
if (self.birth_date.month, self.birth_date.day) > (year_end.month, year_end.day):
age -= 1
return age
def _entry_date(self) -> date:
"""The effective entry date used for prorrata: adoption or birth."""
return self.adoption_date if self.adoption_date is not None else self.birth_date
[docs]
def is_eligible_ordinary(self, filing_year: int) -> bool:
"""True when the descendant qualifies for the Art. 58.1 ordinary mínimo.
Eligibility: age < 25 at year-end OR any degree of discapacidad, AND
cohabiting with the taxpayer.
"""
if not self.convive_con_contribuyente:
return False
if self.discapacidad_grado and self.discapacidad_grado > 0:
return True
return self.age_at_year_end(filing_year) < _MAX_AGE_ORDINARY
[docs]
def is_eligible_menor_tres(self, filing_year: int) -> bool:
"""True when the descendant qualifies for the Art. 58.2 bajo-3-años supplement."""
if not self.convive_con_contribuyente:
return False
return self.age_at_year_end(filing_year) < _MAX_AGE_MENOR_TRES
[docs]
def entry_year(self) -> int:
"""Calendar year of the nacimiento/adopción event (deducción-window anchor)."""
return self._entry_date().year
[docs]
def is_nacimiento_adopcion_eligible(
self,
filing_year: int,
*,
following_periods: int = _NACIMIENTO_ADOPCION_APPLICABILITY_FOLLOWING_PERIODS,
) -> bool:
"""True when this descendant is inside the nacimiento/adopción window and cohabits.
The Madrid nacimiento/adopción deducción (DL 1/2010 art. 4) requires both
that the parent cohabits with the child ("Solo tendrán derecho a practicar
la deducción los padres que convivan con los hijos nacidos o adoptados")
and that the filing year falls inside the applicability window measured
from the entry (nacimiento/adopción) year.
"""
if not self.convive_con_contribuyente:
return False
return within_multi_year_applicability_window(
self.entry_year(),
filing_year,
following_periods=following_periods,
)
[docs]
def nacimiento_adopcion_prorrateo_share(self) -> Decimal:
"""Return this descendant's share of the deducción after prorrateo.
When the child cohabits with both parents and they file individually the
Madrid manual splits the amount equally between the two declarations
(":data:`CUSTODIA_COMPARTIDA_PRORRATA_FACTOR`" / ``Decimal("0.5")``);
otherwise the full amount accrues to this filer (``Decimal("1")``).
``custodia_compartida`` is the profile signal for the shared-cohabitation
case that triggers the ÷2 prorrateo.
"""
return CUSTODIA_COMPARTIDA_PRORRATA_FACTOR if self.custodia_compartida else Decimal("1")
class _RentaPersonProfileBase(BaseModel):
"""Private base for person-profile rows in the official Modelo 100 family section.
Carries the shared :class:`~datetime.date` fields and their validators for
:class:`RentaDescendantProfile` and :class:`RentaAscendantProfile`.
Both subclasses declare the same ``tax_id``/``display_name``/
``disability_grade`` optional-text guard and the ``birth_date``/
``death_date`` ISO-8601 parser; extracting them here removes the
duplicate without altering any field constraint.
"""
model_config = _STRICT_FROZEN
tax_id: str | None = None
display_name: str | None = None
birth_date: date
disability_grade: str | None = None
death_date: date | None = None
@field_validator("tax_id", "display_name", "disability_grade")
@classmethod
def _optional_text_not_blank(cls, value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
if not stripped:
raise ProfileValidationError("optional text fields must not be blank")
return stripped
@field_validator("birth_date", "death_date", mode="before")
@classmethod
def _parse_date(cls, value: object) -> object:
return _coerce_iso_date_field(value)
[docs]
class RentaDescendantProfile(_RentaPersonProfileBase):
"""One descendant row from the official Modelo 100 family section."""
[docs]
class RentaAscendantProfile(_RentaPersonProfileBase):
"""One ascendant row from the official Modelo 100 family section."""
cohabiting_descendant_count: int | None = Field(default=None, ge=0, le=10)
[docs]
class RentaFamilyProfile(BaseModel):
"""Typed repeated family-member facts consumed by Modelo 100 bindings."""
model_config = _STRICT_FROZEN
schema_version: str = Field(default="1")
descendants: tuple[RentaDescendantProfile, ...] = ()
ascendants: tuple[RentaAscendantProfile, ...] = ()
descendientes: tuple[DescendantInfo, ...] = ()
cotizaciones_ss_madre_2024: int = Field(default=0, ge=0)
"""SS cotizaciones paid by the mother during 2024 (mirrors casilla 0013).
Used as the statutory cap for the Art. 81 bis guardería incremento:
0613 = min(gastos_guarderia_reales, hijos_menores_3 × 1000, cotizaciones_ss_madre_2024).
Default ``0`` (cap not declared; guardería incremento will be zero).
"""
"""Structured per-descendant data for Art. 58 mínimo calculation.
Each entry carries birth / adoption date, discapacidad grade, and
cohabitation flag. The ``descendants`` tuple above models the
official Modelo 100 form rows; this tuple drives the mínimo engine.
"""
@field_validator("schema_version")
@classmethod
def _schema_version_is_supported(cls, value: str) -> str:
if value != "1":
raise ProfileValidationError("schema_version must be '1'")
return value
@field_validator("descendants", "ascendants", mode="before")
@classmethod
def _tuple_from_list(cls, value: object) -> object:
if isinstance(value, list):
return tuple(value)
return value
@field_validator("descendientes", mode="before")
@classmethod
def _descendientes_from_list(cls, value: object) -> object:
if isinstance(value, list):
return tuple(value)
return value
# ------------------------------------------------------------------
# Derived properties for Art. 58 mínimo-por-descendientes
# ------------------------------------------------------------------
@property
def descendientes_count(self) -> int:
"""Total number of DescendantInfo entries."""
return len(self.descendientes)
[docs]
def descendientes_menores_3_year_end(self, filing_year: int) -> int:
"""Count of eligible descendientes whose age at year-end < 3 (Art. 58.2)."""
return sum(1 for d in self.descendientes if d.is_eligible_menor_tres(filing_year))
@property
def descendientes_menores_3_2024(self) -> int:
"""Count of descendants eligible for the bajo-3-años supplement in 2024.
Binding-compatible property (no argument) for the 2024 registry binding
``renta-2024-profile-descendientes-menores-3``.
"""
return self.descendientes_menores_3_year_end(2024)
@property
def gastos_guarderia_reales_2024(self) -> int:
"""Sum of Art. 81 bis guardería expenses across eligible children under 3 in 2024.
Only children eligible for the bajo-3-años supplement (age < 3 at
year-end 2024 AND cohabiting) contribute their ``gastos_guarderia_euros``.
Used as the ``gastos_reales`` term in the 0613 formula:
min(gastos_guarderia_reales_2024, descendientes_menores_3_2024 × 1000,
cotizaciones_ss_madre_2024).
"""
return sum(d.gastos_guarderia_euros for d in self.descendientes if d.is_eligible_menor_tres(2024))
[docs]
def descendientes_eligible_minimum(self, filing_year: int) -> int:
"""Count of descendientes eligible for the ordinary Art. 58.1 mínimo.
A descendant is eligible when age < 25 at year-end OR discapacidad > 0,
and cohabiting with the taxpayer.
"""
return sum(1 for d in self.descendientes if d.is_eligible_ordinary(filing_year))
[docs]
def custodia_compartida_count(self, filing_year: int) -> int:
"""Count of eligible descendientes with custodia_compartida=True.
Only eligible (Art. 58.1) and cohabiting descendants are counted;
non-eligible ones carry no mínimo, so the prorrata has no effect.
"""
return sum(1 for d in self.descendientes if d.custodia_compartida and d.is_eligible_ordinary(filing_year))
[docs]
def custodia_compartida_prorrata_factor(self, descendant: DescendantInfo, filing_year: int) -> Decimal:
"""Return the Art. 61 LIRPF prorrata factor for one descendant.
Returns :data:`CUSTODIA_COMPARTIDA_PRORRATA_FACTOR` (``0.5``, Art. 61
LIRPF) when ``descendant.custodia_compartida`` is ``True`` and the
descendant is eligible for the mínimo, otherwise ``Decimal("1")``.
"""
if descendant.custodia_compartida and descendant.is_eligible_ordinary(filing_year):
return CUSTODIA_COMPARTIDA_PRORRATA_FACTOR
return Decimal("1")
[docs]
def minimo_descendientes_estatal(
self,
filing_year: int,
*,
birth_order_amounts: Sequence[Decimal],
menor_tres_supplement: Decimal,
) -> Decimal:
"""Compute the Art. 58 mínimo por descendientes aggregate (casillas 0513/0514).
Ranks every Art. 58.1-eligible descendant (age < 25 at year-end, or any
discapacidad grade, and cohabiting) by ``birth_date`` ascending — the
eldest eligible descendant is "el primero", matching the AEAT Renta
manual's birth-order reading of Art. 58.1's "primero/segundo/tercero/
cuarto y siguientes" tranches. Each eligible descendant contributes:
* the birth-order tranche amount from *birth_order_amounts* (index 0
for the 1st, index 1 for the 2nd, ... the last entry repeats for the
4th and every subsequent descendant, per Art. 58.1's "cuarto y
siguientes" wording);
* plus *menor_tres_supplement* when the descendant is also
Art. 58.2-eligible (age < 3 at year-end);
* the sum is then multiplied by the descendant's Art. 61.1ª LIRPF
custodia-compartida prorrata factor (0.5 when
``custodia_compartida=True`` and eligible, else 1 —
:meth:`custodia_compartida_prorrata_factor`).
No within-year temporal prorrateo is applied: Art. 58 (in force since
01/01/2015, BOE-A-2014-12327) declares only the two numbered
subsections above and no birth/adoption-date cutoff for descendientes;
Art. 61's temporal rules are scoped to a mid-year death (norma 4ª) and
to ascendientes' half-period residency (norma 5ª), neither of which
applies here (see ADR ``modelo-100-minimo-descendientes-engine``).
*birth_order_amounts* and *menor_tres_supplement* are registry
``money`` parameters the caller resolves per filing year; this domain
method performs no euro-figure lookup of its own
(`aeat-schema-central-config`).
Returns ``Decimal("0")`` when no descendant is Art. 58.1-eligible
(including an empty ``descendientes`` tuple) — the legally correct
zero for a childless filer, not an under-declaration.
"""
eligible = sorted(
(d for d in self.descendientes if d.is_eligible_ordinary(filing_year)),
key=lambda d: d.birth_date,
)
if not eligible:
return Decimal("0")
if not birth_order_amounts:
raise ProfileValidationError("birth_order_amounts must not be empty")
total = Decimal("0")
for ordinal, descendant in enumerate(eligible):
tranche_index = min(ordinal, len(birth_order_amounts) - 1)
amount = birth_order_amounts[tranche_index]
if descendant.is_eligible_menor_tres(filing_year):
amount += menor_tres_supplement
total += amount * self.custodia_compartida_prorrata_factor(descendant, filing_year)
return total
[docs]
def custodia_compartida_advisory(self, filing_year: int) -> str | None:
"""Return the translated Art. 61 prorrata advisory string, or ``None``.
When at least one eligible descendant has ``custodia_compartida=True``
the returned string reads "Se ha aplicado prorrata 50 % (Art. 61 LIRPF)
por custodia compartida en X descendientes." Returns ``None`` when no
prorrata is in effect.
"""
from ...core.i18n import tr
count = self.custodia_compartida_count(filing_year)
if count > 0:
return tr(
"profile.descendiente.custodia_compartida_prorrata_applied",
count=count,
)
return None
# ------------------------------------------------------------------
# Comunidad de Madrid "Por nacimiento o adopción de hijos" deducción
# autonómica (DL 1/2010 arts. 4 y 18.1) — casilla 1039 framework primitives
# ------------------------------------------------------------------
[docs]
def madrid_nacimiento_adopcion_eligible_count(self, filing_year: int) -> int:
"""Count of descendants inside the Madrid nacimiento/adopción window who cohabit.
The raw (unweighted) eligible count; ``madrid_nacimiento_adopcion_weighted_count``
applies the per-descendant prorrateo the registry cuantía is multiplied by.
"""
return sum(1 for d in self.descendientes if d.is_nacimiento_adopcion_eligible(filing_year))
[docs]
def madrid_nacimiento_adopcion_weighted_count(self, filing_year: int) -> Decimal:
"""Prorrateo-weighted eligible-descendant count for the Madrid deducción.
Each eligible descendant contributes its prorrateo share (``1``, or
``0.5`` under custodia compartida). The registry formula multiplies this
weighted count by the per-child cuantía (721,70 € for 2023+ entries), so
the per-descendant prorrateo the registry schema cannot express is
embedded here — the Python/registry split the ADR mandates.
"""
total = Decimal("0")
for descendant in self.descendientes:
if descendant.is_nacimiento_adopcion_eligible(filing_year):
total += descendant.nacimiento_adopcion_prorrateo_share()
return total
[docs]
def unidad_familiar_otros_miembros_base(self) -> Decimal:
"""Base imponible of unidad-familiar members OTHER than the filer.
Framework primitive for the autonomic double income-limit gate (the
unidad-familiar 61.860 € límite). For a monoparental/single filer the
other members are the filer's cohabiting children, whose own base
imponible the profile does not hold and is treated as zero; the filer's
own base (casillas 0435 + 0460) is added by the registry formula. A
conyugal unit's spouse base is not persisted, so the derived-fact
injector supplies this term only for the determinable single-filer case
and the trigger stays advisory-only otherwise (fail-closed, no
over-claim).
"""
return Decimal("0")
# ------------------------------------------------------------------
# Art. 81 LIRPF deducción maternidad (casilla 0611)
# ------------------------------------------------------------------
[docs]
def deduccion_maternidad_0611(self, filing_year: int) -> int:
"""Compute the Art. 81 LIRPF deducción maternidad for casilla 0611.
Formula: ``sum(min(meses_madre_trabajo_2024 × 100, 1_200))`` for each
descendant that is eligible for the bajo-3-años supplement (age < 3 at
year-end AND cohabiting with the taxpayer).
Returns an integer euros amount (casilla 0611 carries no decimal places
on the official form). Returns ``0`` when no eligible child has a
non-zero ``meses_madre_trabajo_2024``.
"""
total = 0
for d in self.descendientes:
if d.is_eligible_menor_tres(filing_year) and d.meses_madre_trabajo_2024 > 0:
total += min(
d.meses_madre_trabajo_2024 * DEDUCCION_MATERNIDAD_MENSUAL_EUR,
DEDUCCION_MATERNIDAD_ANUAL_CAP_EUR,
)
return total
# ------------------------------------------------------------------
# Art. 81 LIRPF guardería incremento (casilla 0613)
# ------------------------------------------------------------------
[docs]
def incremento_guarderia_0613(self, filing_year: int) -> int:
"""Compute the Art. 81 LIRPF guardería incremento for casilla 0613.
Formula (Art. 81 LIRPF — incremento por gastos de custodia en guardería,
NOT Art. 81 bis which covers familia numerosa / discapacidad)::
min(gastos_guarderia_reales,
hijos_menores_3 × INCREMENTO_GUARDERIA_POR_HIJO_CAP_EUR,
cotizaciones_ss_madre_2024)
Only the 2024 filing year is supported by the profile fields
(``gastos_guarderia_euros`` and ``cotizaciones_ss_madre_2024``); for
other years, returns 0.
Returns an integer euros amount. Returns 0 when no eligible child has
``gastos_guarderia_euros > 0`` or ``cotizaciones_ss_madre_2024 == 0``.
"""
if filing_year != 2024:
return 0
gastos_reales = self.gastos_guarderia_reales_2024
hijos_menores_3 = self.descendientes_menores_3_2024
cotizaciones = self.cotizaciones_ss_madre_2024
if gastos_reales == 0 or hijos_menores_3 == 0 or cotizaciones == 0:
return 0
return min(gastos_reales, hijos_menores_3 * INCREMENTO_GUARDERIA_POR_HIJO_CAP_EUR, cotizaciones)
[docs]
def incremento_guarderia_advisory(self, filing_year: int) -> str | None:
"""Return a translated advisory string when 0613 can be auto-populated.
Returns ``None`` when the incremento is zero.
"""
from ...core.i18n import tr
amount = self.incremento_guarderia_0613(filing_year)
if amount > 0:
return tr(
"profile.descendiente.incremento_guarderia_applied",
amount=amount,
)
return None
[docs]
def deduccion_maternidad_advisory(self, filing_year: int) -> str | None:
"""Return a translated advisory string when 0611 can be auto-populated.
Returns ``None`` when no descendant under 3 carries
``meses_madre_trabajo_2024 > 0``, i.e. the computation produces zero.
"""
from ...core.i18n import tr
amount = self.deduccion_maternidad_0611(filing_year)
if amount > 0:
return tr(
"profile.descendiente.deduccion_maternidad_applied",
amount=amount,
)
return None
__all__ = [
"DescendantInfo",
"RentaAscendantProfile",
"RentaDescendantProfile",
"RentaFamilyProfile",
"within_multi_year_applicability_window",
]