Source code for aeat.core._result_disposition

"""Per-modelo result-disposition codes for the fichero "Tipo de declaración".

The AEAT fichero-BOE "Tipo de declaración" field encodes the *result disposition*
of an autoliquidación (a ingresar / a compensar / a devolver / negativa), NOT the
amendment type (ordinary vs complementaria, which is a separate "Rectificativa"
field). Hardcoding it to a constant ``"I"`` (ingreso) silently mis-files every
credit, refund, and nil return as a payment owed.

Each modelo declares its own closed code set in its AEAT Diseño de Registros
"Tipo de declaración" note. This module codifies those code sets and the
result→code derivation per modelo, so feature code (the export header composer)
emits a grounded, codified member instead of a hardcoded literal.

This module stops at the base sign-to-code mapping. The application resolver
:func:`application.modelo._result_disposition_resolution.resolve_modelo_result_disposition`
validates the full revision in its work-unit and registry context, then layers
:class:`~core.RefundElection` and
:func:`~domain.iva.refund_disposition_available` for Modelo 303. Export
and cross-period carry read that single resolved fact through
:func:`result_disposition_is_refund`.

Grounded verbatim from the bundled official diseños
(``_data/corpus/aeat_official/disenos_registro/modelo_*``):

- M303: "C (solicitud de compensación) D (devolución) G (cuenta corriente-ingreso)
  I (ingreso) N (sin actividad/resultado cero) V (cuenta corriente-devolución)
  U (domiciliación) X (devolución por transferencia al extranjero)".
- M130 / M131: "I (ingreso), U (domiciliación), G (ingreso a anotar en CCT),
  N (negativa) y B (resultado al deducir)".
- M111 / M115 / M123: "I (ingreso), U (domiciliación), G (ingreso a anotar en CCT)
  y N (negativa)".
- M200: "I (Ingreso), U (Domiciliación), N (Negativa/Resultado cero),
  D (Solicitud de devolución), R (Renuncia a la devolución), G/V/X (CCT / extranjero)".
- M202: "I (ingreso), U (domiciliación), G (Ingreso en C.C.T.) y
  N (Negativa/Sin actividad/Resultado cero)".
"""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass
from decimal import Decimal
from enum import StrEnum
from typing import Final

from ._casilla_id import CasillaId, validated_casilla_id
from ._modelo import Modelo
from .errors import CoreValidationError


[docs] class ResultDisposition(StrEnum): """AEAT fichero "Tipo de declaración" result-disposition codes (all modelos). The member *value* is the single-character code the fichero expects. Not every modelo admits every code — the per-modelo derivation only selects codes that modelo's diseño declares. See Also: :class:`~core.RefundElection` Operator input that can request a Modelo 303 ``C`` credit be filed as ``D`` after the application resolver applies the refund gate. :func:`result_disposition_is_refund` Classifier for the refund dispositions that suppress compensación carry-forward. """ COMPENSACION = "C" """Solicitud de compensación — an IVA credit carried forward, ``a compensar``.""" DEVOLUCION = "D" """Solicitud de devolución — refund requested.""" CUENTA_CORRIENTE_INGRESO = "G" """Cuenta corriente tributaria — ingreso (operator payment-method election).""" INGRESO = "I" """Ingreso — a positive result the taxpayer pays (a ingresar).""" NEGATIVA = "N" """Negativa / sin actividad / resultado cero.""" CUENTA_CORRIENTE_DEVOLUCION = "V" """Cuenta corriente tributaria — devolución.""" DOMICILIACION = "U" """Domiciliación del ingreso en cuenta de cargo (payment-method election).""" DEVOLUCION_TRANSFERENCIA_EXTRANJERO = "X" """Devolución por transferencia al extranjero.""" RESULTADO_A_DEDUCIR = "B" """Resultado a deducir (pago fraccionado IRPF M130/M131 negative carry).""" RENUNCIA_DEVOLUCION = "R" """Renuncia a la devolución (IS election)."""
@dataclass(frozen=True) class _DispositionSpec: """The result casilla(s) and credit/zero disposition codes for one modelo. A positive result is always :attr:`ResultDisposition.INGRESO`. ``negative`` is the code for a sub-zero result (modelo-specific: C for IVA, B for IRPF pagos fraccionados, D for IS), and ``zero`` is the code for a zero result (N for all modelos here). ``U`` (domiciliación), ``G``/``V`` (cuenta corriente), ``X`` / ``R`` are operator elections layered on a base disposition, not derivable from the result amount alone; the caller records those explicitly. ``result_casilla_ids`` is an ordered tuple of canonical ``casilla.id`` values summed to the final result. It carries more than one key when a modelo has revision-specific result ids (M123 2019-2023 vs 2024+) or mutually-exclusive result casillas (M202's 40.2 vs 40.3 — exactly one is non-zero). """ result_casilla_ids: tuple[CasillaId, ...] negative: ResultDisposition zero: ResultDisposition _M303_RESULT_CASILLA: Final[CasillaId] = validated_casilla_id("71", surface="_M303_RESULT_CASILLA") _M130_RESULT_CASILLA: Final[CasillaId] = validated_casilla_id("19", surface="_M130_RESULT_CASILLA") _M131_RESULT_CASILLA: Final[CasillaId] = validated_casilla_id("15", surface="_M131_RESULT_CASILLA") _M111_RESULT_CASILLA: Final[CasillaId] = validated_casilla_id("30", surface="_M111_RESULT_CASILLA") _M115_RESULT_CASILLA: Final[CasillaId] = validated_casilla_id("05", surface="_M115_RESULT_CASILLA") _M123_RESULT_CASILLA: Final[CasillaId] = validated_casilla_id("14", surface="_M123_RESULT_CASILLA") _M123_2019_2023_RESULT_CASILLA: Final[CasillaId] = validated_casilla_id( "08", surface="_M123_2019_2023_RESULT_CASILLA", ) _M200_RESULT_CASILLA: Final[CasillaId] = validated_casilla_id( "DP200014B:00599", surface="_M200_RESULT_CASILLA", ) _M202_402_RESULT_CASILLA: Final[CasillaId] = validated_casilla_id("03", surface="_M202_402_RESULT_CASILLA") _M202_403_RESULT_CASILLA: Final[CasillaId] = validated_casilla_id("34", surface="_M202_403_RESULT_CASILLA") #: Per-modelo disposition spec, grounded in each bundled diseño's "Tipo de #: declaración" note and the registry's result-casilla ``semantic_role``. Modelos #: absent from this table return ``None`` and the caller applies a documented #: fallback rather than a guessed mapping. _DISPOSITION_SPEC: dict[str, _DispositionSpec] = { # IVA: credit is a compensar (C). Result casilla 71 "Resultado final". Modelo.M303: _DispositionSpec( result_casilla_ids=(_M303_RESULT_CASILLA,), negative=ResultDisposition.COMPENSACION, zero=ResultDisposition.NEGATIVA, ), # IRPF pago fraccionado: a negative result is "resultado a deducir" (B), not C. Modelo.M130: _DispositionSpec( result_casilla_ids=(_M130_RESULT_CASILLA,), negative=ResultDisposition.RESULTADO_A_DEDUCIR, zero=ResultDisposition.NEGATIVA, ), Modelo.M131: _DispositionSpec( result_casilla_ids=(_M131_RESULT_CASILLA,), negative=ResultDisposition.RESULTADO_A_DEDUCIR, zero=ResultDisposition.NEGATIVA, ), # Retenciones: only I/N (no credit code). "Resultado a ingresar" casilla. Modelo.M111: _DispositionSpec( result_casilla_ids=(_M111_RESULT_CASILLA,), negative=ResultDisposition.NEGATIVA, zero=ResultDisposition.NEGATIVA, ), Modelo.M115: _DispositionSpec( result_casilla_ids=(_M115_RESULT_CASILLA,), negative=ResultDisposition.NEGATIVA, zero=ResultDisposition.NEGATIVA, ), Modelo.M123: _DispositionSpec( result_casilla_ids=(_M123_RESULT_CASILLA, _M123_2019_2023_RESULT_CASILLA), negative=ResultDisposition.NEGATIVA, zero=ResultDisposition.NEGATIVA, ), # IS annual: credit is a devolución (D), not C. Result casilla # DP200014B:00599 (semantic_role is_resultado_ingresar_o_devolver, Estado), # signed. Renuncia R is an explicit election, not derived; default credit # disposition is D. Modelo.M200: _DispositionSpec( result_casilla_ids=(_M200_RESULT_CASILLA,), negative=ResultDisposition.DEVOLUCION, zero=ResultDisposition.NEGATIVA, ), # IS pago fraccionado: only I/N. Result is the active modality's "a ingresar" # casilla — 40.2 -> 03, 40.3 -> 34; both are >= 0 and exactly one is non-zero. Modelo.M202: _DispositionSpec( result_casilla_ids=(_M202_402_RESULT_CASILLA, _M202_403_RESULT_CASILLA), negative=ResultDisposition.NEGATIVA, zero=ResultDisposition.NEGATIVA, ), } #: The fichero "Tipo de declaración" codes that mean the negative result is #: requested as devolución and therefore excluded from compensación carry. A #: refunded period generates ZERO compensación carry-forward (RD #: 1624/1992 art. 30 / Ley 37/1992 art. 116). ``D`` is the ordinary refund #: request; ``V`` (cuenta corriente devolución) and ``X`` (devolución por #: transferencia al extranjero) are the same disposition through a different #: settlement channel. ``C`` (compensación, carried) is deliberately absent. _REFUND_DISPOSITIONS: frozenset[ResultDisposition] = frozenset( { ResultDisposition.DEVOLUCION, ResultDisposition.CUENTA_CORRIENTE_DEVOLUCION, ResultDisposition.DEVOLUCION_TRANSFERENCIA_EXTRANJERO, }, )
[docs] def result_disposition_is_refund(disposition: ResultDisposition) -> bool: """Return whether ``disposition`` files the result as a refund (devolución). A refund disposition (``D`` / ``V`` / ``X``) requests devolución rather than carry-forward, so a refunded Modelo 303 period must generate zero compensación carry-forward. The carried disposition (``C``, compensación) and every ingreso/negativa code return ``False``. This is the single determined fact the export "Tipo de declaración" and the carry-forward derivation both read, so the fichero ``D`` and the cross-period carry cannot disagree. """ return disposition in _REFUND_DISPOSITIONS
[docs] def modelo_has_codified_disposition(modelo: str) -> bool: """Return whether ``modelo`` has a codified, diseño-grounded disposition spec. This is the capability probe for callers that need to decide whether :func:`derive_result_disposition` can produce a :class:`ResultDisposition` without falling back to their own documented export default. """ return modelo in _DISPOSITION_SPEC
[docs] def result_disposition_casilla_ids(modelo: str) -> tuple[CasillaId, ...] | None: """Return the canonical result ``casilla.id`` values for ``modelo``. These are the only keys :func:`derive_result_disposition` accepts in its ``casilla_values`` mapping. A caller holding a full calculation revision must validate and filter the revision to this tuple first; passing unrelated result metadata raises :class:`~core.errors.CoreValidationError`. """ spec = _DISPOSITION_SPEC.get(modelo) if spec is None: return None return spec.result_casilla_ids
[docs] def derive_result_disposition(modelo: str, casilla_values: Mapping[CasillaId, Decimal]) -> ResultDisposition | None: """Derive the fichero result disposition for ``modelo`` from its computed result. Sums the modelo's final-result casilla(s) from ``casilla_values`` and maps the sign to the modelo's diseño-grounded code. ``casilla_values`` must contain only the result casilla ids returned by :func:`result_disposition_casilla_ids`; callers with a full revision value map must first validate it against the selected registry revision, then filter to those ids. - ``> 0`` → :attr:`ResultDisposition.INGRESO` (``I``) for every modelo. - ``< 0`` → the modelo's credit code (C for M303 IVA, B for M130/M131 IRPF pagos fraccionados; N for retenciones, which cannot go sub-zero in practice). - ``== 0`` (or the casilla absent) → the modelo's zero code (``N``). Returns the derived :class:`ResultDisposition`, or ``None`` for a modelo without a codified spec, so the caller applies a documented fallback rather than a guessed disposition. """ spec = _DISPOSITION_SPEC.get(modelo) if spec is None: return None _reject_non_result_casilla_values(modelo, spec, casilla_values) result = sum((casilla_values.get(casilla_id, Decimal("0")) for casilla_id in spec.result_casilla_ids), Decimal("0")) if result > 0: return ResultDisposition.INGRESO if result < 0: return spec.negative return spec.zero
def _reject_non_result_casilla_values( modelo: str, spec: _DispositionSpec, casilla_values: Mapping[CasillaId, Decimal], ) -> None: result_ids = frozenset(spec.result_casilla_ids) non_result_ids = tuple(sorted(casilla_id for casilla_id in casilla_values if casilla_id not in result_ids)) if not non_result_ids: return accepted = ", ".join(repr(casilla_id) for casilla_id in spec.result_casilla_ids) received = ", ".join(repr(casilla_id) for casilla_id in non_result_ids) raise CoreValidationError( f"result disposition for modelo {modelo!r} received non-result casilla.id values " f"{received}; pass only result casilla.id values {accepted}", context={ "modelo": modelo, "casilla_ids": received, "result_casilla_ids": accepted, }, ) __all__ = [ "ResultDisposition", "derive_result_disposition", "modelo_has_codified_disposition", "result_disposition_casilla_ids", "result_disposition_is_refund", ]