"""Helpers for persisting and reloading DescendantInfo as profile facts.
DescendantInfo records are stored as individual profile facts under the
``renta_family.descendiente.{n}.{field}`` key hierarchy. One aggregate — the
row count — is stored alongside them so the registry binding resolver can look
it up by a simple ``profile_key`` selector. Aggregates the ENGINE derives are
not stored here; see the note below the fact table.
Stored fact paths per descendant (n = 0-based index)::
renta_family.descendiente.{n}.birth_date ISO-8601 date string
renta_family.descendiente.{n}.relacion DescendantRelacion token (absent means ordinary)
renta_family.descendiente.{n}.inscripcion_registro_civil
ISO-8601 date string or absent
renta_family.descendiente.{n}.acogimiento_resolucion ISO-8601 date string or absent
renta_family.descendiente.{n}.fallecimiento ISO-8601 date string or absent (absent means the
descendant did not die)
renta_family.descendiente.{n}.discapacidad registry-declared grade token or absent
renta_family.descendiente.{n}.convivencia "true" / "false"
renta_family.descendiente.{n}.dependencia_economica "true" / "false" or absent (absent means unset)
renta_family.descendiente.{n}.custodia_compartida "true" / "false" (absent means False)
renta_family.descendiente.{n}.rentas_anuales decimal euros or absent (absent means undeclared)
renta_family.descendiente.{n}.declaracion_propia "true" / "false" (absent means False)
renta_family.descendiente.{n}.prorrata_minimo "true" / "false" or absent (absent means unanswered)
renta_family.descendiente.{n}.meses_madre_trabajo "MM;MM..." ascending, zero-padded
(absent means no qualifying months)
renta_family.descendiente.{n}.alta_posterior_nacimiento_mes
"1".."12" or absent (absent means no post-birth
alta increment declared)
renta_family.descendiente.{n}.gastos_guarderia non-negative integer euros (absent means 0)
renta_family.descendiente.{n}.gastos_guarderia_mensuales
canonical MM:AMOUNT[;MM:AMOUNT...] map or absent
renta_family.descendiente.{n}.nif NIF string or absent
Aggregate facts stored::
renta_family.descendientes_count int count
The Art. 81.2 guardería sum (``renta_family.gastos_guarderia_reales_{year}``)
is deliberately NOT stored here, and must not be re-added. It is a DERIVED path:
the calculate-time injector recomputes it from the per-child spend above —
through the canonical record, so the annual figure and the monthly map are
weighed by the same Art. 81.2 month rules — and overwrites whatever the index
holds, precisely so an operator's number can never be substituted for the law's. The
profile write door refuses that path outright, so projecting it from here would
refuse the whole batch rather than persist a second, divergent copy.
"""
from __future__ import annotations
import re
from collections.abc import Mapping, Sequence
from datetime import date
from decimal import Decimal
from typing import Final, Literal, TypedDict, cast
from ...core.decimal.grammar import try_parse_canonical_decimal
from ...core.descendant_relacion import DescendantRelacion
from ...core.errors.hierarchy import ProfileAnswerTypeError
from ...core.identity.tax_id import tax_id_identity_token
from ...core.parsing.dates import parse_iso8601_date
from ...core.parsing.utils import parse_bool
from ...core.text_bounds import is_calendar_month
from ...core.time.clock import today_madrid
from ..calculations.registry.descendant_relacion_catalogue import (
descendant_relacion_default_token,
descendant_relacion_tokens,
require_descendant_relacion,
)
from ..calculations.registry.errors import RegistryValidationError
from ..calculations.registry.facts.resolution import MappingFactQuery, ResolvedMappingFact
from ..calculations.registry.governed_fact_scope import GovernedFactSource, governed_facts_in_scope
from ..calculations.registry.schema_base import DateAxis
from .descendant import DescendantInfo
from .family_types import GuarderiaMonthSpend
from .guarderia_mensual import (
is_plain_whole_number,
parse_guarderia_mensual,
serialise_guarderia_mensual,
)
from .meses_trabajo import parse_meses_trabajo, serialise_meses_trabajo
#: Localised refusal for a rentas figure outside the canonical euro grammar.
#:
#: Reuses the key the wizard already raises for the SAME refusal on the same
#: field, rather than minting a second one: it is present and translated in all
#: four catalogues and its copy is exactly the accepted form ("enter euros with
#: a dot for decimals"). Two keys for one refusal would drift, and the operator
#: would meet different words depending on which door they came through.
_RENTAS_GRAMMAR_LOCALE_KEY = "application.wizard.errors.descendant_rentas_not_a_valid_amount"
_DESCENDANT_FACT_PREFIX = "renta_family.descendiente"
_COUNT_PATH = "renta_family.descendientes_count"
_DISABILITY_BAND_FACT_ID = "lirpf-descendant-disability-grade-catalogue"
_DESCENDIENTE_FLAG_KEYS = frozenset(
{
"NACIMIENTO",
"RELACION",
"INSCRIPCION",
"ACOGIMIENTO",
"FALLECIMIENTO",
"DISCAPACIDAD",
"CONVIVENCIA",
"DEPENDENCIA",
"CUSTODIA",
"RENTAS",
"DECLARACION_PROPIA",
"PRORRATA",
"MESES_TRABAJO",
"ALTA_POSTERIOR_MES",
"SEGUNDO_CICLO_INFANTIL_INICIO_MES",
"GASTOS_GUARDERIA",
"GASTOS_GUARDERIA_MENSUAL",
"NIF",
},
)
"""Every key ``--descendiente`` accepts, so an unrecognised one is refused rather than ignored.
The parser looks each known key UP; without this set a token it does not
recognise would sit unread in the parsed mapping while the field it was meant to
set kept its default. That is a silent drop, and it shipped once: a catalogue
translated the key tokens, the accented forms stopped matching, and an operator
declaring a NON-cohabiting descendant got one recorded as cohabiting -- granting
a minimo they were not entitled to. Help copy alone was under-declaring tax.
The catalogues are fixed and a behavioural gate now drives every advertised token
through this parser. This set closes the other half: a token that was never
advertised at all -- a typo, a stale flag from a script, a future locale edit the
gate's extraction misses -- now fails loudly instead of vanishing. Every other
structured-token surface in this tree already refuses unknown keys, through a
typed enum, a strict model, or an explicit check. This one was the exception.
"""
_FALSE_PROFILE_TOKENS: Final[frozenset[str]] = frozenset({"false", "0"})
"""The lower-cased stored tokens a profile row uses to mean NO.
Two descendant flags tested this pair independently, each with a different default, so
the two agreed on what counts as false only by hand. A token added to one and not the
other would have made one flag read a stored NO as a YES -- which for
``convive_con_contribuyente`` decides whether a descendant counts toward the minimo
familiar at all.
"""
def _disability_band_declarations(*, authority: GovernedFactSource | None = None) -> Mapping[str, str]:
"""Resolve descendant disability-grade vocabulary from the dated registry fact."""
authority = authority or governed_facts_in_scope()
if authority is None:
raise ProfileAnswerTypeError("descendant facts require an explicit authority operation or scope")
resolved = authority.resolve_governed_fact(
MappingFactQuery(
fact_id=_DISABILITY_BAND_FACT_ID,
date_axis=DateAxis.FILING_PERIOD,
effective_date=today_madrid(),
),
)
if not isinstance(resolved, ResolvedMappingFact):
raise ProfileAnswerTypeError("descendant disability catalogue must resolve as a mapping fact")
return {str(entry.key): str(entry.value) for entry in resolved.payload.entries}
type _DisabilityGrade = Literal[0, 33, 65]
def _accepted_disability_grades(*, authority: GovernedFactSource | None = None) -> frozenset[_DisabilityGrade]:
declarations = _disability_band_declarations(authority=authority)
try:
accepted = declarations["accepted_grades"]
except KeyError as exc:
raise ProfileAnswerTypeError("descendant disability catalogue is missing accepted_grades") from exc
try:
parsed = frozenset(int(token.strip()) for token in accepted.split(",") if token.strip())
except ValueError as exc:
raise ProfileAnswerTypeError("descendant disability catalogue has invalid accepted_grades") from exc
if not parsed.issubset({0, 33, 65}):
raise ProfileAnswerTypeError("descendant disability catalogue declares unsupported accepted_grades")
return frozenset(cast(_DisabilityGrade, grade) for grade in parsed)
def _discapacidad_grade(
value: int | None,
*,
authority: GovernedFactSource | None = None,
) -> _DisabilityGrade | None:
if value is None:
return None
for accepted_grade in _accepted_disability_grades(authority=authority):
if value == accepted_grade:
return accepted_grade
return None
[docs]
def descendant_facts_from_list(
descendientes: Sequence[DescendantInfo],
*,
authority: GovernedFactSource | None = None,
) -> list[tuple[str, str]]:
"""Return a list of (path, canonical-value-string) tuples for all DescendantInfo entries.
The caller converts these to :class:`domain.user_profile.values.UserProfileFact`
records; this function only computes the canonical key-value pairs.
"""
facts: list[tuple[str, str]] = []
for idx, descendant in enumerate(descendientes):
prefix = f"{_DESCENDANT_FACT_PREFIX}.{idx}"
facts.append((f"{prefix}.birth_date", descendant.birth_date.isoformat()))
_append_present_facts(facts, prefix, _identity_fact_values(descendant, authority=authority))
_append_present_facts(facts, prefix, _family_fact_values(descendant))
_append_present_facts(facts, prefix, _maternity_fact_values(descendant))
facts.append((_COUNT_PATH, str(len(descendientes))))
return facts
def _append_present_facts(
facts: list[tuple[str, str]],
prefix: str,
values: tuple[tuple[str, str | None], ...],
) -> None:
facts.extend((f"{prefix}.{field}", value) for field, value in values if value is not None)
def _identity_fact_values(
descendant: DescendantInfo,
*,
authority: GovernedFactSource | None = None,
) -> tuple[tuple[str, str | None], ...]:
"""Serialise relationship evidence, omitting the ordinary default relation."""
return (
(
"relacion",
None
if descendant.relacion == descendant_relacion_default_token(authority=authority)
else descendant.relacion.value,
),
(
"inscripcion_registro_civil",
None
if descendant.inscripcion_registro_civil_date is None
else descendant.inscripcion_registro_civil_date.isoformat(),
),
(
"acogimiento_resolucion",
None
if descendant.acogimiento_resolucion_date is None
else descendant.acogimiento_resolucion_date.isoformat(),
),
("fallecimiento", None if descendant.death_date is None else descendant.death_date.isoformat()),
("discapacidad", None if descendant.discapacidad_grado is None else str(descendant.discapacidad_grado)),
)
def _family_fact_values(descendant: DescendantInfo) -> tuple[tuple[str, str | None], ...]:
"""Serialise eligibility facts while retaining the unset-versus-false axes."""
return (
("convivencia", "true" if descendant.convive_con_contribuyente else "false"),
(
"dependencia_economica",
None if descendant.dependencia_economica is None else str(descendant.dependencia_economica).lower(),
),
("custodia_compartida", "true" if descendant.custodia_compartida else None),
("rentas_anuales", None if descendant.rentas_anuales_euros is None else str(descendant.rentas_anuales_euros)),
("declaracion_propia", "true" if descendant.presenta_declaracion_propia else None),
(
"prorrata_minimo",
None if descendant.prorrata_minimo is None else str(descendant.prorrata_minimo).lower(),
),
)
def _maternity_fact_values(descendant: DescendantInfo) -> tuple[tuple[str, str | None], ...]:
"""Serialise Art. 81 facts, preserving their canonical month representations."""
return (
(
"meses_madre_trabajo",
None if not descendant.meses_madre_trabajo else serialise_meses_trabajo(descendant.meses_madre_trabajo),
),
(
"alta_posterior_nacimiento_mes",
None if descendant.alta_posterior_nacimiento_mes is None else str(descendant.alta_posterior_nacimiento_mes),
),
(
"segundo_ciclo_infantil_inicio_mes",
None
if descendant.segundo_ciclo_infantil_inicio_mes is None
else str(descendant.segundo_ciclo_infantil_inicio_mes),
),
(
"gastos_guarderia",
None if descendant.gastos_guarderia_euros <= 0 else str(descendant.gastos_guarderia_euros),
),
(
"gastos_guarderia_mensuales",
None
if not descendant.gastos_guarderia_mensuales
else serialise_guarderia_mensual(descendant.gastos_guarderia_mensuales),
),
("nif", descendant.nif),
)
_N_RE = re.compile(
r"^renta_family\.descendiente\.(\d+)\."
r"(birth_date|relacion|inscripcion_registro_civil|acogimiento_resolucion|fallecimiento|"
r"discapacidad|convivencia|dependencia_economica|custodia_compartida|"
r"rentas_anuales|declaracion_propia|prorrata_minimo|"
# The monthly map precedes the annual figure in the alternation because a
# regex alternation is ordered: with `gastos_guarderia` first, every
# `gastos_guarderia_mensuales` path would match the shorter branch, fail the
# `$` anchor, and be DROPPED from the row -- a declared map silently absent
# on every reload.
r"meses_madre_trabajo|alta_posterior_nacimiento_mes|segundo_ciclo_infantil_inicio_mes|"
r"gastos_guarderia_mensuales|gastos_guarderia|nif)$",
)
[docs]
class RelacionKwarg(TypedDict, total=False):
"""The single optional constructor keyword :func:`relacion_kwarg` may supply.
A plain mapping loses which keyword is present, so splatting it into a
constructor reported every unrelated field as mistyped. Declaring the one
key it can carry keeps the splat precise.
"""
relacion: DescendantRelacion
class _CivilFields(TypedDict):
inscripcion_registro_civil_date: date | None
acogimiento_resolucion_date: date | None
death_date: date | None
discapacidad_grado: _DisabilityGrade | None
class _FamilyFields(TypedDict):
convive_con_contribuyente: bool
dependencia_economica: bool | None
custodia_compartida: bool
rentas_anuales_euros: Decimal | None
presenta_declaracion_propia: bool
prorrata_minimo: bool | None
class _MaternityFields(TypedDict):
meses_madre_trabajo: tuple[int, ...]
alta_posterior_nacimiento_mes: int | None
segundo_ciclo_infantil_inicio_mes: int | None
gastos_guarderia_euros: int
gastos_guarderia_mensuales: tuple[GuarderiaMonthSpend, ...]
[docs]
def relacion_kwarg(relacion: DescendantRelacion | None) -> RelacionKwarg:
"""Render an optional relación as the constructor keyword, OMITTING it when unstated.
"Unstated" and "ordinary descendant" are different inputs and the record
treats them differently: an unstated relación carrying a Registro Civil
inscription is read as an adoption, while an explicitly-ordinary one
carrying the same date is a contradiction the coherence rule refuses. The
only way to express "unstated" to a pydantic constructor is to leave the
keyword out, so every door routes through this rather than inventing a
sentinel — and the type checker sees a plain
:class:`~cadrumo.core.descendant_relacion.DescendantRelacion` at each call site.
"""
return {} if relacion is None else {"relacion": relacion}
def _stored_relacion(
raw: str | None,
*,
index: int,
authority: GovernedFactSource | None = None,
) -> DescendantRelacion | None:
"""Read one descendant's stored relación, refusing a token outside the registry catalogue.
Returns ``None`` for an absent token — UNSTATED, not "ordinary". The two
differ: :class:`~domain.contribuyente.descendant.DescendantInfo` reads an unstated
relación carrying an inscription date as an adoption, and defaults it to the
ordinary descendant otherwise. Resolving absence to the ordinary member here
would pre-empt that reading and turn the adoption record into a
contradiction the coherence validator then refuses.
A PRESENT but unreadable token refuses rather than falling back, for the
reason every other guard in this module refuses: the fallback points in the
claiming direction on one side and the excluded direction on the other, and
neither is a reading of what the operator wrote. A corrupted
``acogimiento_temporal`` resolving to the default would additionally strip
the record of the one distinction keeping the Art. 58.2 increase away from
it.
"""
if raw is None:
return None
authority = authority or governed_facts_in_scope()
if authority is None:
raise ProfileAnswerTypeError("descendant facts require an explicit authority operation or scope")
try:
return require_descendant_relacion(raw.strip().lower(), authority=authority)
except (RegistryValidationError, ValueError):
accepted = ", ".join(member.value for member in descendant_relacion_tokens(authority=authority))
raise ProfileAnswerTypeError(
f"renta_family.descendiente.{index}.relacion must be one of {accepted}; got {raw!r}.",
) from None
[docs]
def descendant_list_from_facts(
facts: dict[str, str],
*,
authority: GovernedFactSource | None = None,
) -> tuple[DescendantInfo, ...]:
"""Reconstruct a tuple of DescendantInfo from a flat profile-fact dict.
``facts`` is a ``{path: canonical-value-string}`` mapping. Only
``renta_family.descendiente.*`` paths are consumed; all others are ignored.
Entries are sorted by index so the reconstructed tuple preserves the
original insertion order.
Returns:
Tuple of :class:`DescendantInfo` reconstructed from the profile facts.
"""
rows: dict[int, dict[str, str]] = {}
for path, value in facts.items():
m = _N_RE.match(path)
if m is None:
continue
idx = int(m.group(1))
field = m.group(2)
rows.setdefault(idx, {})[field] = value
return tuple(
_descendant_from_stored_row(idx, row, authority=authority)
for idx, row in sorted(rows.items())
if row.get("birth_date")
)
def _descendant_from_stored_row(
index: int,
row: dict[str, str],
*,
authority: GovernedFactSource | None = None,
) -> DescendantInfo:
"""Hydrate one complete descendant record from its canonical fact row."""
birth_raw = row["birth_date"]
birth_date = parse_iso8601_date(birth_raw)
# The row filter rejects absent/empty values; the parser raises on a
# malformed non-empty value, so a surviving row always has a date.
if birth_date is None:
raise ProfileAnswerTypeError(
f"renta_family.descendiente.{index}.birth_date carries no readable date; got {birth_raw!r}.",
)
relacion = _stored_relacion(row.get("relacion"), index=index, authority=authority)
return DescendantInfo(
birth_date=birth_date,
**relacion_kwarg(relacion),
**_stored_civil_fields(row, authority=authority),
**_stored_family_fields(row, index=index),
**_stored_maternity_fields(row, index=index),
nif=row.get("nif"),
)
def _stored_civil_fields(
row: dict[str, str],
*,
authority: GovernedFactSource | None = None,
) -> _CivilFields:
inscripcion_raw = row.get("inscripcion_registro_civil")
acogimiento_raw = row.get("acogimiento_resolucion")
fallecimiento_raw = row.get("fallecimiento")
discapacidad_raw = row.get("discapacidad")
disc_val = int(discapacidad_raw) if discapacidad_raw is not None else None
if disc_val is not None and disc_val not in _accepted_disability_grades(authority=authority):
raise ProfileAnswerTypeError(f"DISCAPACIDAD carries an unsupported governed grade: {disc_val!r}")
return {
"inscripcion_registro_civil_date": parse_iso8601_date(inscripcion_raw) if inscripcion_raw else None,
"acogimiento_resolucion_date": parse_iso8601_date(acogimiento_raw) if acogimiento_raw else None,
"death_date": parse_iso8601_date(fallecimiento_raw) if fallecimiento_raw else None,
"discapacidad_grado": _discapacidad_grade(disc_val, authority=authority),
}
def _stored_family_fields(row: dict[str, str], *, index: int) -> _FamilyFields:
dependencia_raw = row.get("dependencia_economica")
declaracion_raw = row.get("declaracion_propia")
prorrata_raw = row.get("prorrata_minimo")
return {
"convive_con_contribuyente": row.get("convivencia", "true").lower() not in _FALSE_PROFILE_TOKENS,
"dependencia_economica": (
_flag_bool(dependencia_raw, key=f"renta_family.descendiente.{index}.dependencia_economica")
if dependencia_raw is not None
else None
),
"custodia_compartida": row.get("custodia_compartida", "false").lower() not in _FALSE_PROFILE_TOKENS,
"rentas_anuales_euros": _stored_rentas_anuales(row.get("rentas_anuales"), index=index),
"presenta_declaracion_propia": (
_flag_bool(declaracion_raw, key=f"renta_family.descendiente.{index}.declaracion_propia")
if declaracion_raw is not None
else False
),
"prorrata_minimo": (
_flag_bool(prorrata_raw, key=f"renta_family.descendiente.{index}.prorrata_minimo")
if prorrata_raw is not None
else None
),
}
def _stored_maternity_fields(row: dict[str, str], *, index: int) -> _MaternityFields:
meses_raw = row.get("meses_madre_trabajo")
return {
"meses_madre_trabajo": (
parse_meses_trabajo(meses_raw, field=f"renta_family.descendiente.{index}.meses_madre_trabajo")
if meses_raw is not None
else ()
),
"alta_posterior_nacimiento_mes": _stored_alta_posterior_mes(
row.get("alta_posterior_nacimiento_mes"),
index=index,
),
"segundo_ciclo_infantil_inicio_mes": _stored_alta_posterior_mes(
row.get("segundo_ciclo_infantil_inicio_mes"),
index=index,
),
"gastos_guarderia_euros": _stored_gastos_guarderia(row.get("gastos_guarderia"), index=index),
"gastos_guarderia_mensuales": parse_guarderia_mensual(
row.get("gastos_guarderia_mensuales") or "",
field=f"renta_family.descendiente.{index}.gastos_guarderia_mensuales",
),
}
def _stored_alta_posterior_mes(raw: str | None, *, index: int) -> int | None:
"""Read one descendant's stored Art. 81.1 alta-posterior completion month.
Absent means ``None`` — no post-birth alta increment declared for this
child, the ordinary case.
A PRESENT but unreadable value REFUSES rather than resolving to ``None``,
for the reason every other guard in this module refuses: ``None`` here
means the increment does not apply, and silently reading a corrupted month
as "not declared" would withhold an entitlement the operator believes they
recorded, with nothing said. A month outside 1-12 refuses for the same
reason a whole-number test is used elsewhere rather than a bare ``int()``:
a bare parse raises an untranslated ``ValueError`` naming neither the row
nor the accepted range.
"""
if raw is None:
return None
text = raw.strip()
if not is_plain_whole_number(text):
raise ProfileAnswerTypeError(
f"renta_family.descendiente.{index}.alta_posterior_nacimiento_mes must be a month 1-12; got {raw!r}.",
)
month = int(text)
if not is_calendar_month(month):
raise ProfileAnswerTypeError(
f"renta_family.descendiente.{index}.alta_posterior_nacimiento_mes must be a month 1-12; got {raw!r}.",
)
return month
def _stored_gastos_guarderia(raw: str | None, *, index: int) -> int:
"""Read one descendant's stored ANNUAL guardería figure, refusing a bad value.
Absent means zero — no annual spend declared for this child.
A present but unreadable value refuses INSTRUCTIVELY and by index, where a
bare ``int()`` raised an untranslated ``ValueError`` naming neither the row
nor the accepted form. That mattered once this became the canonical reader
for the calculate path: the derived-facts injector used to carry its own
tolerant coercion and its own named refusal, and folding it onto this reader
would otherwise have traded a diagnostic naming the row for a raw traceback.
A negative figure refuses rather than clamping to zero, for this module's
standing reason: a clamp is a value the operator never wrote, and silently
substituting one is how a figure stops meaning what was typed.
The sign is read rather than stripped. An earlier form of this guard used
``lstrip("-")``, which removes EVERY leading dash, so ``"--5"`` satisfied the
digit test and fell through to ``int``, which rejects a double sign with the
exact bare ``ValueError`` the paragraph above says this function exists to
replace. That is the shape where an almost-right guard is worse than none,
because the test proving it works passes. ``removeprefix`` takes one dash
and no more, so a second one is left in the text and fails the shape test.
"""
if raw is None:
return 0
text = raw.strip()
negative = text.startswith("-")
digits = text.removeprefix("-")
if not is_plain_whole_number(digits):
raise ProfileAnswerTypeError(
f"renta_family.descendiente.{index}.gastos_guarderia must be a whole number of euros; got {raw!r}.",
)
if negative:
# Reached for a signed zero too, deliberately. ``"-0"`` is numerically
# the default and harmless, but accepting it while refusing ``"-5"``
# would make the sign sometimes readable and sometimes not, and a reader
# cannot tell which from the code. One rule: a sign here is refused.
raise ProfileAnswerTypeError(
f"renta_family.descendiente.{index}.gastos_guarderia must be zero or more; got {raw!r}.",
)
return int(digits)
def _stored_rentas_anuales(raw: str | None, *, index: int) -> Decimal | None:
"""Read one descendant's stored Art. 58.1 rentas figure, refusing a bad value.
Returns ``None`` for an absent fact, which the eligibility predicate reads
as "no figure declared" and therefore as non-excluding.
A PRESENT but unparseable value refuses instead of falling back to that
same ``None``, for the reason :func:`_flag_bool` documents: the silent
fallback points in the CLAIMING direction. ``None`` means the Art. 58.1
ceiling and the Art. 61 norma 2ª exclusion are both skipped, so a typo in a
figure that would have disqualified the descendant would instead restore
the full mínimo — the exact silent over-claim
:meth:`~domain.contribuyente.descendant_record.DescendantRecordBase.exceeds_rentas_cap` exists to
prevent. A negative figure refuses for the same reason rather than being
clamped to zero.
"""
if raw is None:
return None
# The canonical grammar, not a bare ``Decimal()``. A bare constructor reads
# the Spanish thousands shape ``12.500`` as twelve euros fifty -- three
# orders of magnitude below what the taxpayer typed, silently, and in the
# CLAIMING direction: the real figure would breach the Art. 58.1 ceiling
# and disqualify the descendant, while the misread figure sits far below it
# and restores the full mínimo.
#
# ``max_fraction_digits=2`` is what makes the thousands shape refuse, since
# a Spanish grouping is always exactly three digits. That protection is
# therefore incidental rather than separator recognition, and it declines a
# literal ``8.000`` meaning eight euros exactly. That cost is accepted
# knowingly: a parser that GUESSES which convention the
# operator meant is the silent-corruption surface, and a loud refusal
# naming the accepted form is the safe direction.
value = try_parse_canonical_decimal(raw, max_fraction_digits=2)
if value is None:
raise ProfileAnswerTypeError(
f"renta_family.descendiente.{index}.rentas_anuales must be euros with a dot decimal "
f"separator and no thousands grouping (for example 12500 or 12500.75); got {raw!r}. "
"The Spanish thousands shape is refused rather than read, because reading '12.500' as "
"twelve euros fifty would restore a mínimo por descendientes that the real figure "
"disqualifies.",
translated_message=_RENTAS_GRAMMAR_LOCALE_KEY,
)
if value < 0:
raise ProfileAnswerTypeError(
f"renta_family.descendiente.{index}.rentas_anuales must be a non-negative amount; got {raw!r}.",
translated_message=_RENTAS_GRAMMAR_LOCALE_KEY,
)
return value
def _flag_bool(raw: str, *, key: str) -> bool:
"""Read one yes/no flag value, refusing a word the vocabulary cannot read.
Both fields this serves gate the mínimo por descendientes, and both used
to resolve an unreadable word to a bool silently — in the direction that
CLAIMS. ``CONVIVENCIA`` took ``is not False``, so a typo became ``True``,
and Art. 58 cohabitation is what qualifies the descendant at all.
``CUSTODIA`` took ``is True``, so a typo became ``False``, and
:meth:`~domain.contribuyente.family_profile.RentaFamilyProfile.custodia_compartida_prorrata_factor`
returns the full ``1`` for ``False`` against ``0.5`` for shared custody
(Art. 61 LIRPF). Opposite booleans, one direction: more deduction than the
operator asked for.
That is why this refuses rather than picking a safer default. The safe
default points opposite ways on the two fields, so any single default is
wrong on one of them, and both silent readings are wrong anyway — one
over-claims, the other quietly under-claims. An unreadable answer is not
an answer, and the operator is at a command line where they can correct
it, which is the same call
``cadrumo.core.parsing.parse_bool`` is asked to make everywhere else.
Absence still means the documented default (``CONVIVENCIA`` true,
``CUSTODIA`` false); this governs only a value the operator did supply.
"""
parsed = parse_bool(raw)
if parsed is None:
raise ProfileAnswerTypeError(
f"{key} must say yes or no; got {raw!r}. Write yes as si, sí, s, true, verdadero, y or 1, "
"and no as no, n, false, falso or 0. It is refused rather than assumed because both "
"fields decide part of the mínimo por descendientes, and guessing would claim a "
"deduction nobody asked for.",
context={"key": key},
)
return parsed
def _parse_flag_parts(raw: str) -> dict[str, str]:
"""Parse and validate the closed key vocabulary of a descendant flag."""
parts = {k.strip().upper(): v.strip() for k, _, v in (p.partition("=") for p in raw.split(","))}
unknown = sorted(key for key in parts if key not in _DESCENDIENTE_FLAG_KEYS)
if unknown:
raise ProfileAnswerTypeError(
f"--descendiente does not accept {', '.join(unknown)}; "
f"accepted keys are {', '.join(sorted(_DESCENDIENTE_FLAG_KEYS))}",
)
return parts
def _flag_date(value: str, *, key: str) -> date:
"""Read one ``YYYY-MM-DD`` flag value, refusing it as a typed answer error.
The date parser raises a bare ``ValueError`` on a malformed value, which the
CLI boundary cannot tell apart from a defect and reports as an internal
failure. The operator typed a bad date; they are told which key.
"""
try:
parsed = parse_iso8601_date(value)
except ValueError as exc:
raise ProfileAnswerTypeError(
f"--descendiente {key} must be a date YYYY-MM-DD; got {value!r}",
context={"key": key},
) from exc
if parsed is None:
raise ProfileAnswerTypeError(
f"--descendiente {key} carries no readable date; got {value!r}",
context={"key": key},
)
return parsed
def _flag_integer(value: str, *, key: str) -> int:
"""Read one whole-number flag value, keeping at most one leading sign for range checks."""
text = value.strip()
if not is_plain_whole_number(text.removeprefix("-")):
raise ProfileAnswerTypeError(
f"--descendiente {key} must be a whole number; got {value!r}",
context={"key": key},
)
return int(text)
def _flag_birth_date(parts: dict[str, str], *, raw: str) -> date:
"""Read the required birth date from parsed descendant flag parts."""
nacimiento_raw = parts.get("NACIMIENTO")
if not nacimiento_raw:
raise ProfileAnswerTypeError(f"--descendiente flag requires NACIMIENTO=YYYY-MM-DD; got: {raw!r}")
return _flag_date(nacimiento_raw, key="NACIMIENTO")
[docs]
def parse_descendiente_flag(
raw: str,
*,
authority: GovernedFactSource | None = None,
) -> DescendantInfo:
"""Parse a ``--descendiente NACIMIENTO=YYYY-MM-DD,...`` flag value.
Accepted keys (case-insensitive)::
NACIMIENTO=YYYY-MM-DD (required) birth date
RELACION=descendiente|adoptado|acogimiento_preadoptivo_o_permanente|
acogimiento_temporal|tutela|guarda_y_custodia_judicial
(optional, default descendiente) Art. 58.1 / 58.2
relationship. A temporal acogimiento takes the
tranches and NOT the under-three increase. A
judicial guarda y custodia takes the tranches and
neither the increase nor the Art. 81.1 deducción
por maternidad, which excludes it by name.
INSCRIPCION=YYYY-MM-DD (optional) Registro Civil inscription of the
adoption, or the resolución judicial o
administrativa where inscription is not required
— the Art. 58.2 anchor for RELACION=adoptado.
Supplying it without RELACION reads as adoptado.
ACOGIMIENTO=YYYY-MM-DD (optional) first ENTITLING acogimiento resolución
— the Art. 58.2 anchor for a preadoptivo or
permanente placement, and retained on an adoptado
record so a fostered-then-adopted child's window
is capped at three periods rather than restarted.
DISCAPACIDAD=<registry-declared-grade> (optional) discapacidad grade
CONVIVENCIA=true|false (optional, default true) cohabitation flag
DEPENDENCIA=true|false (optional) the taxpayer contributes to this
descendant's upkeep without cohabiting. Omit to
leave UNSET, which never assimilates; only an
explicit true does, and only when no anualidades
are declared.
CUSTODIA=true|false (optional, default false) custodia compartida (Art. 61 LIRPF)
RENTAS=N (optional) annual rentas excluding exempt income — Art. 58.1 ceiling
DECLARACION_PROPIA=true|false
(optional, default false) descendant files their own IRPF return
— combines with RENTAS for the Art. 61 norma 2ª exclusion
PRORRATA=true|false (optional) explicit Art. 61 norma 1ª answer: is another
contribuyente also entitled to this descendant's mínimo?
Omit to let the engine derive it from profile signals.
MESES_TRABAJO=0..12 (optional, default 0) months mother worked — Art. 81 deducción maternidad
ALTA_POSTERIOR_MES=1..12
(optional) calendar month in which a mother not registered with the
Seguridad Social or a mutualidad at the birth completed the 30-day
minimum contribution period Art. 81.1 requires for the post-birth alta
route. Adds the 150 euro completion-month increment (raising this
child's cap to 1.350 euros) for filing years from 2023 only. Omit for
the ordinary case.
SEGUNDO_CICLO_INFANTIL_INICIO_MES=1..12
(optional) the month in which the child may begin
the second cycle of educación infantil. It bounds
Art. 81.2 guardería spend in the year the child
turns three.
GASTOS_GUARDERIA=N (optional, default 0) actual guardería euros — Art. 81.2 incremento 0613,
as an ANNUAL total. Sufficient only while the
child is under three for the whole period.
GASTOS_GUARDERIA_MENSUAL=MM:N[;MM-MM:N...]
(optional) the same spend month by month, sparse.
Entries are separated by ';' rather than ',' because
',' already separates this flag's own keys, and a
month specification may be a range (9-12:210) for
the ordinary case of a constant fee across an
enrolment span. Required for the period in which the
child turns three, whose post-birthday months an
annual total cannot be apportioned across. Mutually
exclusive with GASTOS_GUARDERIA for one child.
NIF=XXXXXXXXX (optional) NIF/NIE
Returns a validated :class:`DescendantInfo`. Raises ``ValueError``
on missing required keys or invalid values.
"""
parts = _parse_flag_parts(raw)
birth_date = _flag_birth_date(parts, raw=raw)
# RELACION is read through the same stored-token authority the fact-index
# path uses, so the flag door and the profile-read door refuse an unknown
# value identically rather than each inventing a tolerance.
relacion_raw = parts.get("RELACION")
relacion = _stored_relacion(
relacion_raw.strip() or None if relacion_raw else None,
index=0,
authority=authority,
)
civil_fields = _flag_civil_fields(parts, authority=authority)
family_fields = _flag_family_fields(parts)
maternity_fields = _flag_maternity_fields(parts)
nif_raw = parts.get("NIF")
return DescendantInfo(
birth_date=birth_date,
**relacion_kwarg(relacion),
**civil_fields,
**family_fields,
**maternity_fields,
nif=tax_id_identity_token(nif_raw) if nif_raw else None,
)
def _flag_civil_fields(
parts: dict[str, str],
*,
authority: GovernedFactSource | None = None,
) -> _CivilFields:
inscripcion_raw = parts.get("INSCRIPCION")
acogimiento_raw = parts.get("ACOGIMIENTO")
fallecimiento_raw = parts.get("FALLECIMIENTO")
disc_raw = parts.get("DISCAPACIDAD")
discapacidad_grado = _flag_integer(disc_raw, key="DISCAPACIDAD") if disc_raw is not None else None
if discapacidad_grado is not None and discapacidad_grado not in _accepted_disability_grades(authority=authority):
raise ProfileAnswerTypeError(
f"DISCAPACIDAD carries an unsupported governed grade: {discapacidad_grado!r}",
)
return {
"inscripcion_registro_civil_date": (
_flag_date(inscripcion_raw, key="INSCRIPCION") if inscripcion_raw else None
),
"acogimiento_resolucion_date": _flag_date(acogimiento_raw, key="ACOGIMIENTO") if acogimiento_raw else None,
"death_date": _flag_date(fallecimiento_raw, key="FALLECIMIENTO") if fallecimiento_raw else None,
"discapacidad_grado": _discapacidad_grade(discapacidad_grado, authority=authority),
}
def _flag_family_fields(parts: dict[str, str]) -> _FamilyFields:
conv_raw = parts.get("CONVIVENCIA")
dependencia_raw = parts.get("DEPENDENCIA")
custodia_raw = parts.get("CUSTODIA")
declaracion_raw = parts.get("DECLARACION_PROPIA")
prorrata_raw = parts.get("PRORRATA")
return {
"convive_con_contribuyente": _flag_bool(conv_raw, key="CONVIVENCIA") if conv_raw is not None else True,
"dependencia_economica": (
_flag_bool(dependencia_raw, key="DEPENDENCIA") if dependencia_raw is not None else None
),
"custodia_compartida": _flag_bool(custodia_raw, key="CUSTODIA") if custodia_raw is not None else False,
"rentas_anuales_euros": _stored_rentas_anuales(parts.get("RENTAS") or None, index=0),
"presenta_declaracion_propia": (
_flag_bool(declaracion_raw, key="DECLARACION_PROPIA") if declaracion_raw is not None else False
),
"prorrata_minimo": _flag_bool(prorrata_raw, key="PRORRATA") if prorrata_raw is not None else None,
}
def _flag_maternity_fields(parts: dict[str, str]) -> _MaternityFields:
meses_raw = parts.get("MESES_TRABAJO")
alta_posterior_raw = parts.get("ALTA_POSTERIOR_MES")
segundo_ciclo_raw = parts.get("SEGUNDO_CICLO_INFANTIL_INICIO_MES")
alta_posterior_nacimiento_mes = (
_flag_integer(alta_posterior_raw, key="ALTA_POSTERIOR_MES") if alta_posterior_raw is not None else None
)
if alta_posterior_nacimiento_mes is not None and not is_calendar_month(alta_posterior_nacimiento_mes):
raise ProfileAnswerTypeError(f"ALTA_POSTERIOR_MES must be 1-12; got {alta_posterior_nacimiento_mes!r}")
segundo_ciclo_infantil_inicio_mes = (
_flag_integer(segundo_ciclo_raw, key="SEGUNDO_CICLO_INFANTIL_INICIO_MES")
if segundo_ciclo_raw is not None
else None
)
if segundo_ciclo_infantil_inicio_mes is not None and not is_calendar_month(segundo_ciclo_infantil_inicio_mes):
raise ProfileAnswerTypeError(
f"SEGUNDO_CICLO_INFANTIL_INICIO_MES must be 1-12; got {segundo_ciclo_infantil_inicio_mes!r}",
)
gastos_raw = parts.get("GASTOS_GUARDERIA")
gastos_guarderia_euros = _flag_integer(gastos_raw, key="GASTOS_GUARDERIA") if gastos_raw is not None else 0
if gastos_guarderia_euros < 0:
raise ProfileAnswerTypeError(f"GASTOS_GUARDERIA must be ≥ 0; got {gastos_guarderia_euros!r}")
gastos_guarderia_mensuales = parse_guarderia_mensual(
parts.get("GASTOS_GUARDERIA_MENSUAL") or "",
field="GASTOS_GUARDERIA_MENSUAL",
)
if gastos_guarderia_mensuales and gastos_guarderia_euros > 0:
raise ProfileAnswerTypeError(
"--descendiente accepts GASTOS_GUARDERIA or GASTOS_GUARDERIA_MENSUAL for one "
"descendant, not both. The monthly breakdown is the authority where it exists, "
"so drop GASTOS_GUARDERIA rather than stating the same spend twice.",
context={"refusal": "guarderia_spend_shapes", "key": "GASTOS_GUARDERIA_MENSUAL"},
)
return {
"meses_madre_trabajo": parse_meses_trabajo(meses_raw, field="MESES_TRABAJO") if meses_raw is not None else (),
"alta_posterior_nacimiento_mes": alta_posterior_nacimiento_mes,
"segundo_ciclo_infantil_inicio_mes": segundo_ciclo_infantil_inicio_mes,
"gastos_guarderia_euros": gastos_guarderia_euros,
"gastos_guarderia_mensuales": gastos_guarderia_mensuales,
}
__all__ = [
"descendant_facts_from_list",
"descendant_list_from_facts",
"parse_descendiente_flag",
"relacion_kwarg",
]