"""Legal IVA prorrata substrate (LIVA arts. 101-103).
This module implements the Spanish Value-Added-Tax (IVA) prorrata mechanism
that governs how a taxable person who performs both deductible and
non-deductible operations may deduct input IVA. The substrate is pure
domain logic: it produces immutable result objects and never touches
persistence, the registry, or the CLI.
Legal sources (Ley 37/1992 del IVA, BOE-A-1992-28740):
* **Art. 101** triggers the rule when a taxpayer performs operations that
grant the right to deduction (taxable supplies, deemed-domestic
intracomunitarias, intra-EU services, etc.) alongside operations that do
NOT grant the right (LIVA arts. 20 and 21 exempt supplies and similar).
The default form is *prorrata general* (art. 102); *prorrata especial*
(art. 103) applies on election or as a mandatory fallback when the
general regime overstates the deduction by more than ten percent.
* **Art. 102.Uno** — general prorrata formula:
``deductible_percentage = operaciones_con_derecho / total_operaciones``.
``operaciones_con_derecho`` is the sum of the year's operations that
grant the right to deduct input IVA. ``total_operaciones`` is the sum of
``operaciones_con_derecho`` plus ``operaciones_sin_derecho``
(LIVA-art.-20 exempt supplies and similar). Subvenciones not linked to
operations, autoconsumos, and the disposal of bienes de inversión are
excluded from both numerator and denominator per art. 104 LIVA; the
exclusion is the caller's responsibility; this module only accepts
already-filtered operation totals.
* **Art. 102.Dos** — the resulting percentage is rounded **up** to the
next whole integer (``ROUND_CEILING`` against ``Decimal("1")``).
* **Art. 103.Dos** — prorrata especial is mandatory whenever the
deduction computed under the general regime exceeds the deduction
computed under the especial regime by more than ten percent (i.e.
``deduction_general > deduction_especial * Decimal("1.10")``).
* **Art. 9.1.c** — sectoral separation (``régimen de sectores
diferenciados``) applies when the taxpayer's activities form two or more
distinct sectors and the difference between the highest and lowest
general prorrata across sectors exceeds fifty percentage points. Each
sector then runs its own prorrata (general or especial). This module
computes the predicate; sector identification itself is a profile/
registry concern carried in :class:`ProrrataSector`.
The substrate distinguishes *provisional* and *definitiva* prorrata
percentages explicitly (LIVA arts. 105 and 109). The provisional
percentage applies during quarterly/monthly Modelo 303 filings and is
typically the prior year's definitiva. The definitiva percentage is
computed at year-end with the year's actual operations and produces a
regularisation entry in Q4 303 (casilla 44) and Modelo 390.
Live submission is not the concern of this module. Current filing
surfaces either use registry-defined formula/manual prorrata casillas or
carry validated prorrata references on IVA ledger observations.
"""
from __future__ import annotations
from collections.abc import Iterable, Sequence
from decimal import ROUND_CEILING, Decimal
from enum import StrEnum
from typing import Annotated
from pydantic import (
BaseModel,
Field,
StringConstraints,
ValidationError,
field_validator,
model_validator,
)
from ...core import STRICT_FROZEN_CONFIG
from ...core.external_constants import (
PRORRATA_ESPECIAL_MANDATORY_MULTIPLE,
PRORRATA_SECTORAL_SEPARATION_SPREAD_PP,
)
from ...core.money import round_to_cents as _round_to_cents
from ._errors import ProrrataInputError, ProrrataSectorError
class _ProrrataStrictFrozen(BaseModel):
"""Strict, immutable Pydantic base for the prorrata substrate."""
model_config = STRICT_FROZEN_CONFIG
SectorId = Annotated[
str,
StringConstraints(strip_whitespace=True, min_length=1, max_length=64, pattern=r"^[-._a-zA-Z0-9]+$"),
]
[docs]
class ProrrataRegime(StrEnum):
"""LIVA-defined prorrata regime kinds.
* ``GENERAL`` — single deduction percentage applied to every input IVA
amount (art. 102 LIVA).
* ``ESPECIAL`` — per-input classification: 100% deductible if used
exclusively in deductible activities, 0% if used exclusively in
non-deductible, the general percentage if used in both (common-bien
under art. 103 LIVA).
"""
GENERAL = "general"
ESPECIAL = "especial"
[docs]
class ProrrataKind(StrEnum):
"""Lifecycle stage of the prorrata percentage.
* ``PROVISIONAL`` — applied during the tax year on Modelo 303 quarters
or months, normally derived from the prior year's definitiva.
* ``DEFINITIVA`` — computed at year-end with the year's actual
operations; drives the regularisation entry on Q4 303 and Modelo
390.
"""
PROVISIONAL = "provisional"
DEFINITIVA = "definitiva"
[docs]
class ProrrataSector(_ProrrataStrictFrozen):
"""A single sector under the sectoral-separation regime (art. 9.1.c LIVA).
A taxpayer with two or more economic sectors whose general prorratas
differ by more than fifty percentage points must compute the prorrata
independently per sector. Each sector carries its own filtered totals
and may run under ``GENERAL`` or ``ESPECIAL`` regime.
"""
sector_id: SectorId
name: Annotated[
str,
StringConstraints(strip_whitespace=True, min_length=1, max_length=200),
]
inputs: ProrrataInputs
regime: ProrrataRegime = ProrrataRegime.GENERAL
[docs]
class ProrrataResult(_ProrrataStrictFrozen):
"""Outcome of a single prorrata computation.
The percentage is stored as a ``Decimal`` whole-integer value between
``0`` and ``100`` inclusive, already rounded up per LIVA art. 102.Dos.
Sectoral results carry their ``sector_id``; whole-entity results
carry ``None``.
"""
regime: ProrrataRegime
kind: ProrrataKind
percentage: Decimal = Field(
...,
ge=Decimal("0"),
le=Decimal("100"),
description=(
"Deductible percentage rounded up to the next whole integer per LIVA art. 102.Dos. Range 0..100 inclusive."
),
)
inputs: ProrrataInputs
sector_id: SectorId | None = None
year: Annotated[int, Field(ge=2000, le=2100)]
period: Annotated[
str | None,
StringConstraints(strip_whitespace=True, pattern=r"^(Q[1-4]|M(0[1-9]|1[0-2])|annual)$"),
] = None
@model_validator(mode="after")
def _validate_period_matches_kind(self) -> ProrrataResult:
# PROVISIONAL applies during the year, so a quarterly/monthly
# period token is required. DEFINITIVA is annual.
if self.kind is ProrrataKind.PROVISIONAL and self.period is None:
raise ProrrataInputError("provisional prorrata result must carry a period (Qn or Mnn)")
if self.kind is ProrrataKind.DEFINITIVA and self.period not in (None, "annual"):
raise ProrrataInputError("definitiva prorrata result period must be 'annual' or omitted")
return self
[docs]
class ProrrataReference(_ProrrataStrictFrozen):
"""Stable identifier for a persisted IVA prorrata percentage.
References use the canonical shape
``prorrata:{year}:{kind}:{regime}``, with an optional sector suffix
``:{sector_id}`` for sectoral-separation cases. The reference is a
pointer to a legal IVA prorrata substrate value; it is not a
proportional-use ratio and it is not derived from usage-ratio data.
"""
reference_id: str = Field(min_length=1, max_length=128)
year: Annotated[int, Field(ge=2000, le=2100)]
kind: ProrrataKind
regime: ProrrataRegime
sector_id: SectorId | None = None
@field_validator("reference_id")
@classmethod
def _trim_reference_id(cls, value: str) -> str:
return value.strip()
@model_validator(mode="after")
def _reference_id_matches_fields(self) -> ProrrataReference:
expected = _canonical_prorrata_reference_id(
year=self.year,
kind=self.kind,
regime=self.regime,
sector_id=self.sector_id,
)
if self.reference_id != expected:
raise ProrrataInputError(
f"prorrata reference {self.reference_id!r} does not match canonical value {expected!r}",
)
return self
# ---------------------------------------------------------------------------
# Pure calculators
# ---------------------------------------------------------------------------
def _validate_year(year: int) -> int:
if year < 2000 or year > 2100:
raise ProrrataInputError(f"year out of supported range 2000..2100: {year}")
return year
def _canonical_prorrata_reference_id(
*,
year: int,
kind: ProrrataKind,
regime: ProrrataRegime,
sector_id: SectorId | None = None,
) -> str:
base = f"prorrata:{year}:{kind.value}:{regime.value}"
return f"{base}:{sector_id}" if sector_id is not None else base
[docs]
def validate_prorrata_reference(reference_id: str) -> ProrrataReference:
"""Parse and validate a legal IVA prorrata reference id and return a :class:`ProrrataReference`.
The accepted id shape is ``prorrata:{year}:{kind}:{regime}``, plus
an optional ``:{sector_id}`` suffix. Values from the expense
proportionality/usage-ratio substrate intentionally fail this
parser; callers must keep both concepts separate.
"""
normalized = reference_id.strip()
parts = normalized.split(":")
if len(parts) not in (4, 5) or parts[0] != "prorrata":
raise ProrrataInputError(
"prorrata_reference must use canonical shape "
"'prorrata:{year}:{kind}:{regime}' or "
"'prorrata:{year}:{kind}:{regime}:{sector_id}'",
)
try:
year = int(parts[1])
except ValueError as exc:
raise ProrrataInputError(f"prorrata_reference year must be an integer: {parts[1]!r}") from exc
_validate_year(year)
try:
kind = ProrrataKind(parts[2])
except ValueError as exc:
raise ProrrataInputError(f"unknown prorrata_reference kind: {parts[2]!r}") from exc
try:
regime = ProrrataRegime(parts[3])
except ValueError as exc:
raise ProrrataInputError(f"unknown prorrata_reference regime: {parts[3]!r}") from exc
sector_id = parts[4] if len(parts) == 5 else None
try:
return ProrrataReference(
reference_id=normalized,
year=year,
kind=kind,
regime=regime,
sector_id=sector_id,
)
except ValidationError as exc:
raise ProrrataInputError(f"invalid prorrata_reference: {normalized!r}") from exc
def _compute_percentage_general(inputs: ProrrataInputs) -> Decimal:
"""Apply LIVA art. 102.Uno and art. 102.Dos to produce the general prorrata percentage.
Returns a ``Decimal`` between ``0`` and ``100`` inclusive, rounded up
to the next whole integer. When total operations is zero the
percentage is reported as ``100`` per the long-standing AEAT
administrative criterion that a taxpayer with no operations in the
year may not lose the right to deduct (provisional percentages are
typically carried over from the prior year by the application layer
before this function is reached; this branch is a defence-in-depth).
"""
total = inputs.operaciones_con_derecho_deduccion + inputs.operaciones_sin_derecho_deduccion
if total == 0:
return Decimal("100")
ratio = inputs.operaciones_con_derecho_deduccion / total
# LIVA art. 102.Dos: round up to the next whole integer percentage.
return (ratio * Decimal("100")).quantize(Decimal("1"), rounding=ROUND_CEILING)
[docs]
def compute_prorrata_general(
inputs: ProrrataInputs,
*,
year: int,
kind: ProrrataKind,
period: str | None = None,
sector_id: SectorId | None = None,
) -> ProrrataResult:
"""Compute the general prorrata percentage for one window.
Implements LIVA art. 102.Uno + art. 102.Dos. The caller supplies the
filtered annual totals (or annualised totals if the year is a
fractional first/last year) and the lifecycle kind.
Raises :class:`ProrrataInputError` when the year is out of the
supported range or when ``kind``/``period`` combination is
inconsistent.
Returns:
A :class:`ProrrataResult` with the computed percentage and inputs.
"""
_validate_year(year)
percentage = _compute_percentage_general(inputs)
try:
return ProrrataResult(
regime=ProrrataRegime.GENERAL,
kind=kind,
percentage=percentage,
inputs=inputs,
sector_id=sector_id,
year=year,
period=period,
)
except ValidationError as exc:
raise ProrrataInputError(f"invalid prorrata result window: year={year} period={period!r}") from exc
[docs]
def deductible_percentage_for(
classification: InputClassification,
general_percentage: Decimal,
) -> Decimal:
"""Map an input classification to its deductible percentage under LIVA arts. 103/106.
The single canonical mapping of the art. 106.Uno reglas:
``EXCLUSIVELY_DEDUCTIBLE`` → 100 (regla 1.ª, deducted in full),
``EXCLUSIVELY_NON_DEDUCTIBLE`` → 0 (regla 2.ª, no deduction),
``COMMON`` → ``general_percentage`` (regla 3.ª, deducted at the general
prorrata percentage). Consumed both by :func:`classify_input_deduction`
(per-input deduction) and by the ledger IVA aggregation's regime-aware
especial apportionment, so the reglas live in exactly one place.
"""
if classification is InputClassification.EXCLUSIVELY_DEDUCTIBLE:
return Decimal("100")
if classification is InputClassification.EXCLUSIVELY_NON_DEDUCTIBLE:
return Decimal("0")
# COMMON: apply the general prorrata percentage (art. 103.Uno.3.º).
return general_percentage
[docs]
def is_especial_mandatory(
deduction_under_general: Decimal,
deduction_under_especial: Decimal,
) -> bool:
"""Return True when LIVA art. 103.Dos forces the prorrata especial regime.
The rule: the prorrata especial regime is mandatory whenever the
deduction computed under the general regime would exceed the
deduction computed under the especial regime by more than ten
percent, i.e. ``deduction_general > deduction_especial * 1.10``.
When the especial deduction is zero this function returns ``True``
if the general deduction is positive (the general regime would over-
deduct without bound).
"""
if deduction_under_general < 0 or deduction_under_especial < 0:
raise ProrrataInputError("deduction amounts must be non-negative")
if deduction_under_especial == 0:
return deduction_under_general > 0
threshold = deduction_under_especial * PRORRATA_ESPECIAL_MANDATORY_MULTIPLE
return deduction_under_general > threshold
# ---------------------------------------------------------------------------
# Annual prorrata-general regularisation (LIVA arts. 104-105)
# ---------------------------------------------------------------------------
[docs]
class RegularizacionProrrataDireccion(StrEnum):
"""Direction of the annual prorrata-general regularisation (LIVA art. 105.Cuatro).
* ``DEDUCCION`` — the year's definitive percentage exceeds the provisional
one applied across the quarters, so a *deducción complementaria* is due:
the taxpayer may deduct more input IVA and the Modelo 303 casilla-44 value
is positive (it increases the total deductible cuota).
* ``INGRESO`` — the definitive percentage is lower than the provisional one,
so the provisional deductions were excessive and an *ingreso* is due: the
casilla-44 value is negative (it reduces the total deductible cuota).
* ``NINGUNA`` — the two percentages coincide; no regularisation is practised.
"""
DEDUCCION = "deduccion"
INGRESO = "ingreso"
NINGUNA = "ninguna"
[docs]
class RegularizacionProrrataResult(_ProrrataStrictFrozen):
"""Outcome of the annual prorrata-general regularisation (LIVA art. 105.Cuatro).
Attributes:
cuotas_soportadas_deducibles: The year's total deductible input IVA the
percentages apply to (art. 105.Seis: the sum of the year's cuotas
soportadas, excluding the arts. 95/96 non-deductibles).
prorrata_provisional_pct: The provisional percentage applied during the
year (art. 105.Uno: normally the prior year's definitive percentage).
prorrata_definitiva_pct: The definitive percentage computed at year-end
from the year's actual operations (art. 104).
deduccion_provisional: ``cuotas × provisional% / 100`` — the deduction
already practised across the year's provisional liquidations.
deduccion_definitiva: ``cuotas × definitiva% / 100`` — the deduction that
definitively applies.
importe: ``deduccion_definitiva − deduccion_provisional``, rounded to
cents. The signed value proposed for Modelo 303 casilla 44 / the
Modelo 390 annual regularisation field. Positive = additional
deduction; negative = repayment.
direccion: :class:`RegularizacionProrrataDireccion` describing the sign.
"""
cuotas_soportadas_deducibles: Decimal = Field(..., ge=Decimal("0"))
prorrata_provisional_pct: Decimal = Field(..., ge=Decimal("0"), le=Decimal("100"))
prorrata_definitiva_pct: Decimal = Field(..., ge=Decimal("0"), le=Decimal("100"))
deduccion_provisional: Decimal
deduccion_definitiva: Decimal
importe: Decimal
direccion: RegularizacionProrrataDireccion
[docs]
def compute_prorrata_definitiva_anual(
inputs: ProrrataInputs,
*,
year: int,
sector_id: SectorId | None = None,
) -> ProrrataResult:
"""Compute the year-end DEFINITIVA general prorrata percentage (LIVA arts. 104-105).
Thin, named wrapper over :func:`compute_prorrata_general` fixing
``kind = DEFINITIVA`` and ``period = "annual"``: the caller supplies the
full-year operation volumes (con-derecho / sin-derecho) with the art-104
exclusions already applied, and receives the definitive percentage that
art. 105.Cuatro regularises the provisional deductions against. This is the
definitive-percentage source the capital-goods regularización (LIVA arts.
107-110) and the annual prorrata regularización both consume; deriving it from
a single quarter's volume is a correctness defect (a single period computes
neither the provisional nor the annual-regularised percentage), so the
definitive percentage MUST come from the full-year rollup.
Returns:
The definitive :class:`ProrrataResult` for the year.
"""
return compute_prorrata_general(
inputs,
year=year,
kind=ProrrataKind.DEFINITIVA,
period="annual",
sector_id=sector_id,
)
[docs]
def compute_regularizacion_prorrata_anual(
*,
cuotas_soportadas_deducibles: Decimal,
prorrata_provisional_pct: Decimal,
prorrata_definitiva_pct: Decimal,
) -> RegularizacionProrrataResult:
"""Compute the annual prorrata-general regularisation cuota (LIVA art. 105.Cuatro).
Implements the art-105 procedure. A taxpayer under prorrata general applies a
PROVISIONAL deduction percentage across the year's liquidations (art. 105.Uno:
"el porcentaje de deducción provisionalmente aplicable cada año natural será
el fijado como definitivo para el año precedente"); then, in the last
liquidation of the year, computes the DEFINITIVA percentage from the year's
actual operations (art. 104) and "practicará la consiguiente regularización de
las deducciones provisionales" (art. 105.Cuatro). The regularisation cuota is
the difference between the deduction that definitively applies and the
deduction already practised provisionally::
deduccion_definitiva = cuotas × definitiva% / 100
deduccion_provisional = cuotas × provisional% / 100
importe = deduccion_definitiva − deduccion_provisional
Unlike the capital-goods regularisation (arts. 107-110), the prorrata-general
regularisation carries **no >10-point gate**: art. 105.Cuatro practises it in
every year the two percentages differ. Both percentages are supplied as
inputs; deriving the definitive percentage from the annual volumes is
:func:`compute_prorrata_definitiva_anual`, and carrying the prior-year
definitive as this year's provisional (art. 105.Uno) is the profile-scoped
carry the application layer owns.
Args:
cuotas_soportadas_deducibles: The year's total deductible input IVA
(art. 105.Seis), non-negative.
prorrata_provisional_pct: Provisional deduction percentage applied during
the year (0-100).
prorrata_definitiva_pct: Definitive deduction percentage for the year
(0-100).
Returns:
A :class:`RegularizacionProrrataResult` carrying the signed casilla-44
importe and its :class:`RegularizacionProrrataDireccion`.
Raises:
ProrrataInputError: on a negative cuota or an out-of-range percentage.
"""
if cuotas_soportadas_deducibles < 0:
raise ProrrataInputError(
f"cuotas_soportadas_deducibles must be non-negative, got {cuotas_soportadas_deducibles}",
)
for label, pct in (
("prorrata_provisional_pct", prorrata_provisional_pct),
("prorrata_definitiva_pct", prorrata_definitiva_pct),
):
if pct < Decimal("0") or pct > Decimal("100"):
raise ProrrataInputError(f"{label} out of range 0..100, got {pct}")
deduccion_provisional = _round_to_cents(cuotas_soportadas_deducibles * prorrata_provisional_pct / Decimal("100"))
deduccion_definitiva = _round_to_cents(cuotas_soportadas_deducibles * prorrata_definitiva_pct / Decimal("100"))
importe = deduccion_definitiva - deduccion_provisional
if importe > Decimal("0"):
direccion = RegularizacionProrrataDireccion.DEDUCCION
elif importe < Decimal("0"):
direccion = RegularizacionProrrataDireccion.INGRESO
else:
direccion = RegularizacionProrrataDireccion.NINGUNA
return RegularizacionProrrataResult(
cuotas_soportadas_deducibles=cuotas_soportadas_deducibles,
prorrata_provisional_pct=prorrata_provisional_pct,
prorrata_definitiva_pct=prorrata_definitiva_pct,
deduccion_provisional=deduccion_provisional,
deduccion_definitiva=deduccion_definitiva,
importe=importe,
direccion=direccion,
)
# ---------------------------------------------------------------------------
# Sectoral separation (art. 9.1.c LIVA)
# ---------------------------------------------------------------------------
_SECTORAL_SEPARATION_THRESHOLD_PERCENTAGE_POINTS = PRORRATA_SECTORAL_SEPARATION_SPREAD_PP
def _ensure_unique_sectors(sectors: Sequence[ProrrataSector]) -> None:
seen: set[str] = set()
for sector in sectors:
if sector.sector_id in seen:
raise ProrrataSectorError(f"duplicate sector_id in sectors list: {sector.sector_id!r}")
seen.add(sector.sector_id)
[docs]
def requires_sectoral_separation(sectors: Sequence[ProrrataSector]) -> bool:
"""Return True when LIVA art. 9.1.c mandates sectoral separation.
The rule: a taxpayer with two or more economic sectors must compute
prorrata independently per sector whenever the difference between the
highest and lowest general prorrata across sectors exceeds fifty
percentage points.
Sectors are identified by stable ``sector_id``; the caller is
responsible for assigning activity codes (e.g., CNAE / IAE-epígrafe)
to sectors before invoking this function. A sectors list with fewer
than two members returns ``False`` because the threshold cannot
apply.
"""
if len(sectors) < 2:
return False
_ensure_unique_sectors(sectors)
percentages = [_compute_percentage_general(sector.inputs) for sector in sectors]
spread = max(percentages) - min(percentages)
return spread > _SECTORAL_SEPARATION_THRESHOLD_PERCENTAGE_POINTS
[docs]
def compute_sectoral_prorrata(
sectors: Sequence[ProrrataSector],
*,
year: int,
kind: ProrrataKind,
period: str | None = None,
) -> tuple[ProrrataResult, ...]:
"""Compute the general prorrata for each sector.
Returns one :class:`ProrrataResult` per input sector, in the same
order. This function does NOT enforce whether sectoral separation
applies — that decision lives in
:func:`requires_sectoral_separation`; this calculator runs once the
caller has decided separation is required.
"""
if not sectors:
raise ProrrataSectorError("sectors sequence must not be empty")
_ensure_unique_sectors(sectors)
_validate_year(year)
return tuple(
compute_prorrata_general(
sector.inputs,
year=year,
kind=kind,
period=period,
sector_id=sector.sector_id,
)
for sector in sectors
)
# ---------------------------------------------------------------------------
# Helpers for caller-side rollups
# ---------------------------------------------------------------------------
[docs]
def sum_deductible_amounts(
deductions: Iterable[ProrrataInputDeduction],
) -> Decimal:
"""Sum the ``deductible_amount`` field across a collection of inputs.
Callers use this to roll up per-input deductions after running
:func:`classify_input_deduction` for each purchase invoice evidence
row. Modelo casilla routing remains registry-owned.
"""
return sum((entry.deductible_amount for entry in deductions), Decimal("0"))
__all__ = (
"InputClassification",
"ProrrataInputDeduction",
"ProrrataInputs",
"ProrrataKind",
"ProrrataReference",
"ProrrataRegime",
"ProrrataResult",
"ProrrataSector",
"RegularizacionProrrataDireccion",
"RegularizacionProrrataResult",
"classify_input_deduction",
"compute_prorrata_definitiva_anual",
"compute_prorrata_general",
"compute_regularizacion_prorrata_anual",
"compute_sectoral_prorrata",
"deductible_percentage_for",
"is_especial_mandatory",
"requires_sectoral_separation",
"sum_deductible_amounts",
"validate_prorrata_reference",
)