"""Family-level Modelo 100 aggregation and proration behaviors."""
from __future__ import annotations
from collections.abc import Sequence
from decimal import Decimal
from typing import cast
from pydantic import BaseModel, Field, field_validator
from ...core.errors.hierarchy import pydantic_validation_boundary
from ...core.models import STRICT_FROZEN_CONFIG as _STRICT_FROZEN
from ...core.text_bounds import CALENDAR_MONTH_MAX
from .constants import SUPPORTED_PROFILE_SCHEMA_VERSION, ProfileSchemaVersion
from .descendant import DescendantInfo
from .errors import ProfileValidationError
from .family_fact_context import FamilyFactResolutionContext
from .family_types import (
MinimoDescendientesThresholds,
RentaAscendantProfile,
RentaDescendantProfile,
)
def _survivor_rank_by_position(eligible: Sequence[DescendantInfo], filing_year: int) -> dict[int, int]:
"""Rank the surviving descendants, vacating the rank of each who died before devengo.
Art. 61 norma 4ª limb two: the ordering that assigns "primero, segundo,
tercero…" is computed over the survivors ALONE. A descendant who died before
the devengo still contributes their flat cuantía (limb one) but vacates their
rank, which is what moves each younger sibling down a tranche. Omitting this
limb over-grants the survivors as well as the deceased.
Keyed by POSITION rather than by the record itself: DescendantInfo is a frozen
pydantic model, so two identically-declared siblings — twins with the same
birth date and no distinguishing fact — compare equal, and a value-keyed
lookup would give them both the elder's rank.
"""
return {
position: rank
for rank, position in enumerate(
position for position, d in enumerate(eligible) if not d.died_before_devengo(filing_year)
)
}
[docs]
class RentaFamilyProfile(BaseModel):
"""Typed repeated family-member facts consumed by Modelo 100 bindings."""
model_config = _STRICT_FROZEN
schema_version: ProfileSchemaVersion = Field(default=SUPPORTED_PROFILE_SCHEMA_VERSION)
descendants: tuple[RentaDescendantProfile, ...] = ()
ascendants: tuple[RentaAscendantProfile, ...] = ()
descendientes: tuple[DescendantInfo, ...] = ()
anualidades_alimentos_euros: Decimal | None = Field(default=None, ge=Decimal("0"))
"""Judicial anualidades por alimentos the filer PAYS, or ``None`` if undeclared.
Filer-level rather than per-descendant, and that is the staged boundary
rather than the intended end state. Art. 58 carves the dependency
assimilation out where anualidades are satisfied, and the carve-out is
per-child in the law; this profile cannot attribute a payment to one
descendant, so a declared amount suppresses the assimilation for EVERY
descendant until that attribution lands.
Suppressing them all is the under-granting direction, which is the safe one
to default to, but it is a real narrowing: a filer paying anualidades for one
child and supporting another outside any court order loses the second child's
assimilation too. The calculate path discloses that rather than leaving it
silent.
A declared ``0`` is an answer meaning none are paid and does NOT suppress;
only a positive amount does. ``None`` means the question was never put.
"""
cotizaciones_ss_madre_2024: int = Field(default=0, ge=0)
"""SS cotizaciones paid by the mother during 2024 (mirrors casilla 0013).
The statutory ceiling on the Art. 81.2 guardería incremento, applied to the
household total after the per-child proration
(:meth:`RentaFamilyProfile.incremento_guarderia_0613` computes each child's
own ``min(cap_anual / 12 × meses, su gasto)`` and sums them; this figure caps
that sum). The annual cap is a registry ``money`` parameter resolved by the
caller, never a literal here (`aeat-registry-authority-flow`).
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("descendants", "ascendants", mode="before")
@classmethod
@pydantic_validation_boundary
def _tuple_from_list(cls, value: object) -> object:
if isinstance(value, list):
# CAST-RATIONALE-DESCENDANTS-ASCENDANTS-COERCION: isinstance narrows
# to list but not its element type; pydantic re-validates each
# element against the field's declared item type after this
# coercion.
# nosemgrep: no-cast-in-domain-application
return tuple(cast("list[object]", value))
return value
@field_validator("descendientes", mode="before")
@classmethod
@pydantic_validation_boundary
def _descendientes_from_list(cls, value: object) -> object:
if isinstance(value, list):
# CAST-RATIONALE-DESCENDIENTES-COERCION: isinstance narrows to list
# but not its element type; pydantic re-validates each element
# against the field's declared item type after this coercion.
# nosemgrep: no-cast-in-domain-application
return tuple(cast("list[object]", 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)
@property
def dependencia_assimilation_available(self) -> bool:
"""Whether the dependency assimilation may apply to ANY descendant this year.
False as soon as the filer declares a positive anualidades figure. Art.
58 carves the assimilation out where anualidades are satisfied, and
because this profile cannot yet attribute a payment to a particular
descendant the carve-out is applied to all of them.
The narrowing is deliberate and one-directional: it withholds an
allowance some filers are owed rather than granting one they are not.
Reversing it needs per-child attribution, not a looser reading here.
"""
return not (self.anualidades_alimentos_euros is not None and self.anualidades_alimentos_euros > 0)
[docs]
def descendientes_menores_3_year_end(self, filing_year: int, *, context: FamilyFactResolutionContext) -> 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, context=context))
[docs]
def descendientes_guarderia_count(self, filing_year: int, *, context: FamilyFactResolutionContext) -> int:
"""Count of descendants who may carry an Art. 81.2 guardería increase.
Wider than the Art. 58.2 menor-de-tres count by exactly the turning-three
period. Kept separate rather than widening that count, which has its own
registry binding and its own statutory meaning for the supplement.
"""
return sum(1 for d in self.descendientes if d.is_eligible_guarderia(filing_year, context=context))
[docs]
def gastos_guarderia_reales(self, filing_year: int, *, context: FamilyFactResolutionContext) -> int:
"""Sum of the Art. 81.2 guardería spend every descendant contributes in *filing_year*.
Sums :meth:`DescendantInfo.guarderia_contributing_spend`, which applies
the Art. 81.2 month rules per child: every declared month while the child
is under three, and only the post-birthday months in the period the child
turns three. The turning-three period is INCLUDED here and was not
before, which is the campaign's largest measured under-grant — a full
birth cohort rather than a minority case, reducing cuota directly.
Year-parameterised rather than pinned to 2024 because the calculate path
derives ``renta_family.gastos_guarderia_reales_{filing_year}`` for
whatever year the registry declares a consumer for. A 2024-only accessor
would have forced that path to keep its own parallel sum, which is how
the monthly map could be declared and contribute nothing.
"""
return sum(d.guarderia_contributing_spend(filing_year, context=context) for d in self.descendientes)
[docs]
def incremento_guarderia_0613(
self,
filing_year: int,
*,
thresholds: MinimoDescendientesThresholds,
cap_anual: Decimal,
context: FamilyFactResolutionContext,
) -> Decimal:
"""Art. 81.2 guardería increment (casilla 0613), prorated and capped PER CHILD.
The manual states the increment "puede alcanzar hasta 1.000 euros
anuales y se calculará proporcionalmente al número de meses en que se
cumplan de forma simultánea los requisitos exigidos en el artículo 81.1
y 2", and works it as ``1.000 / 12 x meses``. It then states the result
as that child's own limit — "Límite del incremento: 166,67 euros" — so
the cap is per child rather than a household total.
Each child contributes ``min(cap_anual / 12 x meses, su gasto)`` and the
contributions are SUMMED. The per-child bound is the load-bearing part:
an aggregate ``min`` over the household lets one child's unused cap
absorb another child's excess spend, and it is also what let the missing
proration hide, because filtering one term of a household-wide ``min``
reads as completing the month rules.
*meses* is the SIMULTANEITY intersection the manual describes, taken as
the smaller of the two sides. The Art. 81.1 side is
:meth:`DescendantInfo.guarderia_art_81_1_meses` rather than the
deducción's own :meth:`DescendantInfo.maternidad_contributing_meses`,
and the distinction is load-bearing: the deducción stops at the child's
third birthday while the increment does not, so reusing it forced this
total to zero for a child turning three in January and for a mother who
began work after the birthday — two cases Capítulo 18 names explicitly
as still qualifying.
BOTH sides are real month sets, so *meses* is a true intersection rather
than a bound. That is what the article asks for — "el número de meses en
que se cumplan de forma simultánea los requisitos" is a question about
WHICH months, and only months can answer it.
The manual's caso a is the case that forced it: a mother entitled May to
August against nursery paid January to June shares exactly two months,
and AEAT prints ``1.000 ÷ 12 × 2 = 166,67``. Taking ``min`` over the two
COUNTS yields four and 333,33 — an over-grant of the deducción, which
under-declares tax. Counts cannot distinguish that case from a
containment or a disjoint one, because the sizes are the same in all
three; the geometry only survives if the months do.
Preferred to a flat per-child cap, which over-grants every mid-year
birth and every partial-year enrolment outright.
*cap_anual* is a registry ``money`` parameter the caller resolves per
filing year; this method performs no euro-figure lookup of its own
(`aeat-registry-authority-flow`).
Returns ``Decimal("0")`` when no descendant qualifies, which is the
legally correct zero rather than an under-declaration.
"""
from ...core.money.rounding import round_to_cents
available = self.dependencia_assimilation_available
total = Decimal("0")
for descendant in self.descendientes:
meses = descendant.guarderia_simultaneous_meses(
filing_year,
thresholds=thresholds,
context=context,
dependencia_assimilation_available=available,
)
if meses <= 0:
continue
prorated_cap = round_to_cents(cap_anual / Decimal(CALENDAR_MONTH_MAX) * Decimal(meses))
spend = Decimal(descendant.guarderia_contributing_spend(filing_year, context=context))
total += min(prorated_cap, spend)
return total
[docs]
def meses_maternidad_por_descendiente(
self,
filing_year: int,
*,
thresholds: MinimoDescendientesThresholds,
context: FamilyFactResolutionContext,
) -> tuple[tuple[str, int], ...]:
"""The Art. 81.1 ``(hijo_id, meses)`` pairs this profile contributes in *filing_year*.
Pairs :meth:`DescendantInfo.maternidad_contributing_meses` with the
descendant's index, which is the identifier every other descendiente
surface addresses a child by — the ``list`` renderer, the ``remove``
verb, and the fact paths themselves. The deducción's own per-hijo cap
then applies over these pairs rather than over a collapsed total, so a
second child can never absorb a first child's unused months.
Aggregates through the canonical record rather than summing the stored
facts directly. The guardería path was broken for exactly one release by
a second loop that read the raw fact under its own inline age test: the
two diverged the moment the record learned a month rule, and a taxpayer
could declare spend, see it stored, and receive nothing. One aggregation
path per value is the lesson, and this is that path for Art. 81.1.
Reads :attr:`dependencia_assimilation_available` off this profile rather
than taking it as an argument, exactly as
:meth:`descendientes_eligible_minimum` does, so the anualidades
carve-out cannot be applied to the mínimo and skipped for the deducción
that keys on it.
Omits descendants contributing zero months, so an ineligible child and
one whose mother declared no employment months are both simply absent
rather than carrying a zero pair into the deducción.
"""
available = self.dependencia_assimilation_available
return tuple(
(str(index), meses)
for index, descendant in enumerate(self.descendientes)
if (
meses := descendant.maternidad_contributing_meses(
filing_year,
thresholds=thresholds,
context=context,
dependencia_assimilation_available=available,
)
)
> 0
)
[docs]
def descendientes_eligible_minimum(
self,
filing_year: int,
*,
thresholds: MinimoDescendientesThresholds,
context: FamilyFactResolutionContext,
) -> int:
"""Count of descendientes eligible for the ordinary Art. 58.1 mínimo.
A descendant is eligible when cohabiting, under 25 at year-end or
carrying any discapacidad, within the Art. 58.1 rentas ceiling, and not
excluded by Art. 61 norma 2ª — see
:meth:`DescendantInfo.is_eligible_ordinary`.
"""
available = self.dependencia_assimilation_available
return sum(
1
for d in self.descendientes
if d.is_eligible_ordinary(
filing_year,
thresholds=thresholds,
context=context,
dependencia_assimilation_available=available,
)
)
[docs]
def minimo_prorrata_factor(
self,
descendant: DescendantInfo,
filing_year: int,
*,
thresholds: MinimoDescendientesThresholds,
context: FamilyFactResolutionContext,
second_filer_indicated: bool = False,
) -> Decimal:
"""Return the Art. 61 norma 1ª prorrata factor for one descendant.
Norma 1ª keys proration to ENTITLEMENT, not to custody: "Cuando dos o
más contribuyentes tengan derecho a la aplicación del mínimo por
descendientes ... respecto de los mismos ... descendientes, su importe
se prorrateará entre ellos por partes iguales." Shared custody is one
way two contribuyentes come to be entitled, not the rule itself, so it
remains a trigger rather than the test.
Precedence, highest first:
1. ``descendant.prorrata_minimo`` — an explicit operator answer always
wins, in both directions.
2. ``descendant.custodia_compartida`` — the judicially shared-custody
case, preserved as a trigger.
3. *second_filer_indicated* — the caller's derivation from profile
signals, used only when nothing above answered the question.
Returns the ``lirpf-art-61-shared-custody-proration-factor`` governed
fact (``0.5`` today) when the
descendant is mínimo-eligible and any of the above indicates a second
entitled contribuyente, otherwise ``Decimal("1")``. A non-eligible
descendant always returns ``Decimal("1")`` because there is no mínimo
to prorate.
"""
if not descendant.is_eligible_ordinary(
filing_year,
thresholds=thresholds,
context=context,
dependencia_assimilation_available=self.dependencia_assimilation_available,
):
return Decimal("1")
if descendant.prorrata_minimo is not None:
if descendant.prorrata_minimo:
return context.decimal("lirpf-art-61-shared-custody-proration-factor")
return Decimal("1")
if descendant.custodia_compartida or second_filer_indicated:
return context.decimal("lirpf-art-61-shared-custody-proration-factor")
return Decimal("1")
[docs]
def minimo_descendientes_estatal(
self,
filing_year: int,
*,
birth_order_amounts: Sequence[Decimal],
menor_tres_supplement: Decimal,
fallecimiento_amount: Decimal,
thresholds: MinimoDescendientesThresholds,
context: FamilyFactResolutionContext,
second_filer_indicated: bool = False,
) -> 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 norma 1ª
prorrata factor (:meth:`minimo_prorrata_factor`), which is 0.5
whenever a second contribuyente is also entitled to this
descendant's mínimo — by explicit answer, by shared custody, or by
the caller's *second_filer_indicated* derivation.
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 norma 4ª governs this aggregate rather than sitting outside it,
and BOTH its limbs are applied here. The rule is easy to half-implement
because its flat figure coincides with the first-child tranche, so a
partial fix looks complete while leaving most of the over-grant standing.
LIMB ONE, the flat cuantía. A descendant who dies in the period takes a
FLAT amount rather than their birth-order tranche — "en caso de
fallecimiento de un descendiente que genere derecho al mínimo por este
concepto, la cuantía aplicable es de 2.400 euros". That figure equals
the first-child tranche in every revision served today, so the
substitution is worth nothing for a first child and 300 € to 2.100 € for
a later one. It is supplied as *fallecimiento_amount*, its own registry
parameter, and is deliberately not read off ``birth_order_amounts[0]``:
the two are legally distinct figures that merely coincide.
LIMB TWO, the ordering exclusion, is the one that is invisible on
inspection. The deceased is dropped from the age ordering that ranks the
survivors — "sin computar a estos efectos aquellos descendientes que …
hubieran fallecido en el ejercicio con anterioridad a la fecha de devengo
del impuesto". Because the tranches ascend with rank, dropping the
deceased moves every younger sibling to a CHEAPER rank, so omitting this
limb over-grants the survivors as well as the deceased. A fix carrying
only limb one would pass any test that checks the deceased's own amount.
The two limbs read ``death_date`` through different predicates and the
difference is deliberate: :meth:`~DescendantInfo.died_in_period` for the
cuantía, :meth:`~DescendantInfo.died_before_devengo` for the ordering.
See the ``death_date`` field documentation for why they are not one
condition.
The Art. 58.2 menor-3 supplement is added on top of the flat cuantía,
not replaced by it: the AEAT manual states the increase "resulta
aplicable en los casos en que el descendiente haya fallecido durante el
período impositivo". Replacing it would under-grant a bereaved filer.
A descendant who died in a PRIOR year contributes nothing at all — they
fail :meth:`~DescendantInfo.meets_non_income_conditions` and never reach
the ranking.
*birth_order_amounts*, *menor_tres_supplement*, *fallecimiento_amount*
and *thresholds* are registry ``money`` parameters the caller resolves
per filing year; this domain method performs no euro-figure lookup of
its own (`aeat-registry-authority-flow`).
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. A descendant
excluded by the rentas ceiling or the norma 2ª own-return rule
contributes exactly zero, including its menor-3 supplement, because it
never enters the birth-order ranking at all.
"""
eligible = sorted(
(
d
for d in self.descendientes
if d.is_eligible_ordinary(
filing_year,
thresholds=thresholds,
context=context,
dependencia_assimilation_available=self.dependencia_assimilation_available,
)
),
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")
rank_by_position = _survivor_rank_by_position(eligible, filing_year)
total = Decimal("0")
for position, descendant in enumerate(eligible):
if descendant.died_in_period(filing_year):
# Limb one: the flat cuantía displaces the tranche at any rank.
amount = fallecimiento_amount
else:
ordinal = rank_by_position[position]
tranche_index = min(ordinal, len(birth_order_amounts) - 1)
amount = birth_order_amounts[tranche_index]
if descendant.is_eligible_minimo_incremento_menor_tres(filing_year, context=context):
amount += menor_tres_supplement
total += amount * self.minimo_prorrata_factor(
descendant,
filing_year,
thresholds=thresholds,
context=context,
second_filer_indicated=second_filer_indicated,
)
return total
# ------------------------------------------------------------------
# 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, *, context: FamilyFactResolutionContext
) -> 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, context=context))
[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()