Source code for aeat.domain.iva._saturation

"""Grounded saturation primitives for the :mod:`aeat.domain.iva` subpackage.

Two reusable primitives that let a caller turn a selected
:class:`IvaCategory` and a transaction gross into the regulated euro
substrate (rate, taxable base, IVA amount) **without ever guessing a
number**:

* :func:`resolve_category_rate` maps an :class:`IvaCategory` to its
  :class:`IvaRateKind` and looks the applicable rate up via
  :func:`aeat.domain.iva.lookup_rate`, returning it as a decimal
  *fraction* (``Decimal("0.21")``) wrapped in a typed
  :class:`IvaRateResolution`. Domestic general / reduced / super-reduced
  derive a positive rate; domestic zero and exempt derive ``0``; every
  category with no simple derivable positive domestic rate
  (intra-community, export, reverse-charge, recargo, import,
  régimen simplificado, no-sujeta, erroneous, unknown) returns a
  ``derivable=False`` resolution carrying an explicit operator-facing
  reason — never a fabricated rate.

* :func:`split_gross_at_rate` performs the inverse split of a gross at a
  rate fraction into ``(taxable_base, iva_amount)`` quantised with the
  AEAT-mandated :func:`aeat.core.money.round_to_cents` (ROUND_HALF_UP).

The split formula is the canonical inverse of an IVA-inclusive gross:
``base = round_to_cents(gross / (1 + rate))`` and
``iva = round_to_cents(gross - base)``. Quantising the base first and
deriving the IVA as the remainder guarantees ``base + iva == gross`` to
the cent regardless of the rounding residual, which is exactly the
invariant the :class:`aeat.domain.transactions.Transaction` model
enforces.

Authority: ``2026-06-04-llm-ledger-classification-adr``. The rate values
are grounded in ``registry/aeat/iva/rates.toml`` (Spain general 21 /
reduced 10 / super-reduced 4 / zero 0; LIVA art. 90/91, year-scoped).
"""

from __future__ import annotations

from datetime import date
from decimal import Decimal

from pydantic import BaseModel, Field

from ...core import STRICT_FROZEN_CONFIG
from ...core.money import round_to_cents
from ._lookup import lookup_rate
from ._schema import EUMemberState, IvaCategory, IvaRateKind

_ONE_HUNDRED = Decimal("100")
_ONE = Decimal("1")
_ZERO = Decimal("0")

# Spanish domestic categories whose rate is a single registry rate tier.
# Maps the operator-/LLM-selected IvaCategory onto the IvaRateKind whose
# registry record carries the authoritative percentage. Zero and exempt
# both resolve to a zero fraction (no positive IVA), so they are handled
# explicitly below rather than via a positive lookup.
_CATEGORY_TO_RATE_KIND: dict[IvaCategory, IvaRateKind] = {
    IvaCategory.DOMESTIC_GENERAL_21: IvaRateKind.GENERAL,
    IvaCategory.DOMESTIC_REDUCED_10: IvaRateKind.REDUCED,
    IvaCategory.DOMESTIC_SUPER_REDUCED_4: IvaRateKind.SUPER_REDUCED,
    IvaCategory.DOMESTIC_ZERO: IvaRateKind.ZERO,
    IvaCategory.DOMESTIC_EXEMPT: IvaRateKind.EXEMPT,
}

# Per-category explanation for every IvaCategory whose IVA rate cannot be
# derived from a single Spanish domestic rate tier. The reason is
# operator-facing: it states why the system declines to guess and what the
# operator must supply.
_NON_DERIVABLE_REASONS: dict[IvaCategory, str] = {
    IvaCategory.DOMESTIC_NOT_SUBJECT: (
        "not subject to Spanish IVA (no devengo); no rate is derivable here, "
        "but this reason does not confirm the filing treatment"
    ),
    IvaCategory.OPERACION_NO_SUJETA: (
        "not subject to Spanish IVA (no devengo); no rate is derivable here, "
        "but this reason does not confirm the filing treatment"
    ),
    IvaCategory.DOMESTIC_REVERSE_CHARGE: (
        "potential domestic reverse charge (inversión del sujeto pasivo): "
        "no rate is derivable here; verify the operation evidence and supply "
        "the self-assessed base and cuota explicitly"
    ),
    IvaCategory.INTRA_COMMUNITY_SUPPLY: (
        "potential intra-community supply: no Spanish rate is derivable here; "
        "verify the customer VAT ID, cross-border transport, and reporting "
        "evidence before treating it as exempt"
    ),
    IvaCategory.INTRA_COMMUNITY_ACQUISITION_REVERSE_CHARGE: (
        "potential intra-community acquisition under reverse charge: no rate "
        "is derivable here; verify the acquisition evidence and supply the "
        "self-assessed base and cuota explicitly"
    ),
    IvaCategory.INTRA_COMMUNITY_TRIANGULATION: (
        "potential intra-community triangulation: no Spanish rate is derivable "
        "here; verify the triangulation conditions and reporting evidence"
    ),
    IvaCategory.EXPORT_THIRD_COUNTRY_ZERO_RATED: (
        "potential export to a third country: no domestic rate is derivable "
        "here; verify the export evidence before treating it as zero-rated"
    ),
    IvaCategory.EXPORT_ASSIMILATED_ZERO_RATED: (
        "potential operation assimilated to an export: no domestic rate is "
        "derivable here; verify the qualifying ship, aircraft, provisioning, "
        "or related service facts before treating it as exempt"
    ),
    IvaCategory.IMPORT_THIRD_COUNTRY: (
        "import from a third country: IVA is assessed at customs against the "
        "import base — derivation is left to the operator"
    ),
    IvaCategory.RECARGO_EQUIVALENCIA: (
        "recargo de equivalencia: a surcharge on top of the IVA rate that "
        "varies by product tier — derivation is left to the operator"
    ),
    IvaCategory.REGIMEN_SIMPLIFICADO: (
        "régimen simplificado: IVA is determined by activity modules, not by "
        "an inverse split of the gross — derivation is left to the operator"
    ),
    IvaCategory.ERRONEOUS_INVOICE: (
        "erroneous invoice: the line is flagged for correction; no rate is derivable until the operator resolves it"
    ),
    IvaCategory.UNKNOWN: (
        "unknown IVA situation: the category has not been determined — no rate "
        "can be derived until the operator selects a concrete category"
    ),
}


[docs] class IvaRateResolution(BaseModel): """Typed outcome of resolving an :class:`IvaCategory` to a rate fraction. A resolution is *self-documenting*: it never silently substitutes a fabricated rate for a category the system cannot ground. When :attr:`derivable` is ``True`` the :attr:`rate` carries the applicable rate as a decimal fraction in ``[0, 1]`` (e.g. ``Decimal("0.21")``; ``Decimal("0")`` for zero-rated and exempt categories). When :attr:`derivable` is ``False`` the :attr:`rate` is ``None`` and :attr:`reason` states why the operator must complete the field. Attributes: category: The :class:`IvaCategory` that was resolved. derivable: ``True`` when a Spanish domestic rate fraction was derived, ``False`` when the category has no simple derivable domestic rate and the operator must complete it. rate: The applicable IVA rate as a decimal fraction in ``[0, 1]`` when :attr:`derivable`, else ``None``. rate_kind: The :class:`IvaRateKind` the category maps to when :attr:`derivable`, else ``None``. reason: An operator-facing explanation present only when the rate is not derivable; the empty string otherwise. """ model_config = STRICT_FROZEN_CONFIG category: IvaCategory = Field(description="The IvaCategory that was resolved.") derivable: bool = Field(description="Whether a domestic rate fraction was derived.") rate: Decimal | None = Field( default=None, ge=_ZERO, le=_ONE, description="Applicable IVA rate as a decimal fraction in [0, 1], or None.", ) rate_kind: IvaRateKind | None = Field( default=None, description="The IvaRateKind the category maps to, or None.", ) reason: str = Field( default="", description="Operator-facing reason when the rate is not derivable.", )
[docs] def resolve_category_rate(category: IvaCategory, *, on_date: date) -> IvaRateResolution: """Resolve an :class:`IvaCategory` to its Spanish IVA rate fraction. Maps ``category`` to its :class:`IvaRateKind` and looks the applicable rate up via :func:`aeat.domain.iva.lookup_rate` for :attr:`EUMemberState.ES` on ``on_date``, returning the percentage as a decimal *fraction* (``IvaRateRecord.pct / 100``). Domestic general / reduced / super-reduced derive a positive fraction; domestic zero and exempt derive ``Decimal("0")``. Every category with no simple derivable positive domestic rate returns a ``derivable=False`` resolution carrying an operator-facing reason — the system never guesses a rate for those. Args: category: The selected IVA category to resolve. on_date: The effective date used to resolve the registry rate. Returns: A typed :class:`IvaRateResolution`. ``derivable`` is ``True`` with a ``rate`` fraction for domestic categories; ``False`` with a ``reason`` for every non-derivable category. Raises: IvaRateNotFoundError: If a domestic category maps to a rate kind that the registry has no record for on ``on_date`` (a registry gap, not a category-shape problem). """ rate_kind = _CATEGORY_TO_RATE_KIND.get(category) if rate_kind is None: return IvaRateResolution( category=category, derivable=False, rate=None, rate_kind=None, reason=_NON_DERIVABLE_REASONS[category], ) if rate_kind in (IvaRateKind.ZERO, IvaRateKind.EXEMPT): return IvaRateResolution( category=category, derivable=True, rate=_ZERO, rate_kind=rate_kind, reason="", ) record = lookup_rate(EUMemberState.ES, rate_kind, on_date) return IvaRateResolution( category=category, derivable=True, rate=record.pct / _ONE_HUNDRED, rate_kind=rate_kind, reason="", )
[docs] def split_gross_at_rate(gross: Decimal, rate: Decimal) -> tuple[Decimal, Decimal]: """Inverse-split an IVA-inclusive gross into ``(taxable_base, iva_amount)``. Computes the IVA-exclusive base and the IVA charged from an IVA-inclusive gross at ``rate`` (a decimal *fraction*, e.g. ``Decimal("0.21")``), quantising with the AEAT-mandated :func:`aeat.core.money.round_to_cents` (ROUND_HALF_UP). The base is quantised first and the IVA is taken as the quantised remainder (``gross - base``), so ``taxable_base + iva_amount == gross`` holds to the cent regardless of the rounding residual. A ``rate`` of ``0`` (zero-rated or exempt) yields the whole gross as the base and a zero IVA amount. Args: gross: The IVA-inclusive gross amount. Expected non-negative (the caller passes ``abs(amount)`` for a signed transaction); a signed value is split as given. rate: The IVA rate as a decimal fraction in ``[0, 1]``. Returns: A ``(taxable_base, iva_amount)`` tuple, each quantised to euro cents, whose sum equals ``round_to_cents(gross)`` to the cent. """ if rate == _ZERO: base = round_to_cents(gross) return base, round_to_cents(gross - base) base = round_to_cents(gross / (_ONE + rate)) return base, round_to_cents(gross - base)
__all__ = [ "IvaRateResolution", "resolve_category_rate", "split_gross_at_rate", ]