"""Renta deductible-expense observations derived from ledger facts.
:class:`RentaDeductibleExpenseFact` is evaluated through
:func:`evaluate_renta_deductibility` using :class:`CategoryProfile` and
:class:`RentaDeductibilityContext`; eligible
:class:`RentaDeductibilityResult` values become
:class:`RentaDeductibleExpenseObservation` records routed through the dated
facts-registry mapping to :data:`CasillaId` bindings for
:class:`~cadrumo.core.Modelo('100')`.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from enum import StrEnum
from types import MappingProxyType
from typing import Literal
from pydantic import BaseModel, Field, field_validator, model_validator
from ...core.aggregation import BindingSourceKind
from ...core.casilla_id import CasillaId, validated_casilla_id
from ...core.errors.hierarchy import pydantic_validation_boundary
from ...core.filing_year import FilingYear
from ...core.identity.transaction_ids import TransactionId
from ...core.modelo import Modelo
from ...core.models import STRICT_FROZEN_CONFIG
from ...core.period import Period
from ...core.unit_proportion import UnitProportion, is_unit_proportion
from ..calculations.registry.authority_artifact import AuthorityComponentCodecError
from ..calculations.registry.renta_expense_policy import renta_expense_policy_declarations
from ..categories.profile import CategoryProfile
from ..categories.proportionality import (
CategoryCitation,
ProportionalityKind,
ProportionalityRule,
StatutoryCapVariant,
)
from ..categories.spending_category import SpendingCategory, SpendingCategoryFamily, family_for
from ..categories.spending_category_catalogue import require_spending_category, spending_category_tokens
from ..contribuyente.ccaa import CCAA
from ._first_slice_routing import resolve_first_slice_expense_routing
from .errors import RentaValidationError
EUR_CURRENCY: Literal["EUR"] = "EUR"
[docs]
class RentaExpenseDirection(StrEnum):
"""Closed direction values for first-slice Renta expense facts."""
OUTGOING_EXPENSE = "outgoing_expense"
REFUND = "refund"
REVERSAL = "reversal"
[docs]
class RentaDeductibilityStatus(StrEnum):
"""Whether a ledger fact can produce a Renta calculation observation."""
ELIGIBLE = "eligible"
INELIGIBLE = "ineligible"
[docs]
class RentaInvoiceEvidenceStatus(StrEnum):
"""Invoice-evidence state carried by the Renta observation."""
NONE = "none"
LINKED = "linked"
[docs]
class RentaReconciliationStatus(StrEnum):
"""Transaction/invoice reconciliation state for duplicate prevention."""
TRANSACTION_ONLY = "transaction_only"
LINKED_INVOICE = "linked_invoice"
class _RentaStrictFrozenModel(BaseModel):
"""Shared strict immutable boundary model."""
model_config = STRICT_FROZEN_CONFIG
[docs]
class RentaDeductibilityContext(_RentaStrictFrozenModel):
"""Inputs that affect proportionality but are not ledger facts."""
profile_year: FilingYear
usage_ratios: dict[SpendingCategory, Decimal] = Field(default_factory=dict)
statutory_cap_days: Decimal | None = Field(default=None, gt=Decimal("0"))
statutory_cap_variant_id: str | None = Field(default=None, min_length=1, max_length=64)
statutory_cap_person_count: int = Field(default=1, ge=1)
statutory_cap_variant_person_counts: Mapping[str, int] = Field(default_factory=dict)
"""How many insured persons fall under each annual cap variant, keyed by variant id.
LIRPF art. 30.2.5.a caps the seguro de enfermedad premium at 500 euros per person
or 1.500 for a person with discapacidad, so the lawful cap is a SUM over two
populations rather than one amount times one count. A single
:attr:`statutory_cap_person_count` cannot express that, and using it alone applies
the lower limb to everybody -- which understates the allowance for exactly the
people the higher limb exists for.
"""
exclusive_use_confirmed: bool = False
iva_deduction_ratio: UnitProportion | None = None
"""Fraction of the fact's input IVA the activity affords a right to deduct (LIVA arts. 94/104).
``None`` (the default) means the axis has not been evaluated for this fact and
preserves the historic base-only behaviour: when an invoice-evidenced
``taxable_base`` is known, only that net-of-IVA base becomes the IRPF-deductible
cost. That is correct for the common case (an activity with full deduction
rights recovers its input IVA through Modelo 303, so the IVA is not also an
IRPF expense), but it is silently wrong whenever the activity's right to deduct
is less than total: PGC NRV 12.ª treats non-recoverable input IVA as part of
the acquisition cost, so the portion the taxpayer cannot deduct for IVA
purposes must join the IRPF gasto. Populating this field with the resolved
IVA-deduction percentage (``1`` for full deduction, ``0`` for a wholly exempt
activity with no right to deduct, a fractional value under prorrata general or
especial) makes ``1 - iva_deduction_ratio`` of ``iva_amount`` join the
deductible base. See :func:`evaluate_renta_deductibility`.
Populated by :func:`~application.aggregation.renta_ledger.aggregate_renta_ledger_expenses_from_repositories`
for the M100 annual first slice, via
``application.aggregation.renta_ledger.resolve_iva_deduction_ratio``: a
wholly ``EXENTO`` ``iva.regime`` profile fact resolves to ``0`` outright;
otherwise the bucket's ``domain.prorrata_register.ProrrataRegister``
whole-entity entry for the ejercicio contributes its in-force provisional
percentage under ``GENERAL`` or ``ESPECIAL``. The M130 quarterly pagos
fraccionados gasto path (``application.aggregation.renta_gasto_ledger``)
does not construct a :class:`RentaDeductibilityContext` at all and is not
wired to this axis -- a named follow-up, not silently covered here.
"""
residence_ccaa: CCAA | None = None
"""Optional residence axis consumed by the governed territorial policy."""
@field_validator("usage_ratios", mode="after")
@classmethod
@pydantic_validation_boundary
def _usage_ratios_in_bounds(cls, value: dict[SpendingCategory, Decimal]) -> dict[SpendingCategory, Decimal]:
for category, ratio in value.items():
_require_decimal(ratio, f"usage ratio for {category.value!r}")
if not is_unit_proportion(ratio):
raise RentaValidationError(f"usage ratio for {category.value!r} must be in [0, 1]")
return {category: value[category] for category in sorted(value, key=lambda item: item.value)}
@field_validator("statutory_cap_days")
@classmethod
@pydantic_validation_boundary
def _cap_days_decimal(cls, value: Decimal | None) -> Decimal | None:
if value is not None:
_require_decimal(value, "statutory_cap_days")
return value
[docs]
class RentaDeductibleExpenseFact(_RentaStrictFrozenModel):
"""Canonical ledger fact eligible for Renta deductibility evaluation."""
transaction_id: TransactionId
invoice_id: str | None = Field(default=None, min_length=1, max_length=128)
catalogue_id: str = Field(min_length=1, max_length=128)
operation_date: date
invoice_issue_date: date | None = None
posting_date: date | None = None
payment_date: date | None = None
# Strictly positive, where the INCOME observations that mirror this shape
# allow zero. Not a divergence: a zero-value income observation is a
# modelled case -- an unrouted one still has to surface, and a test asserts
# exactly that -- while a zero-amount deductible expense is not an expense.
# The import parse boundary agrees with this side and says why: a
# zero-amount row "carries no flow".
gross_amount: Decimal = Field(gt=Decimal("0"))
taxable_base: Decimal | None = None
iva_amount: Decimal | None = None
currency: Literal["EUR"] = EUR_CURRENCY
direction: RentaExpenseDirection = RentaExpenseDirection.OUTGOING_EXPENSE
category: SpendingCategory
activity_key: str = Field(default="default", min_length=1, max_length=128)
@field_validator("gross_amount", "taxable_base", "iva_amount")
@classmethod
@pydantic_validation_boundary
def _amounts_are_finite_decimals(cls, value: Decimal | None) -> Decimal | None:
if value is not None:
_require_decimal(value, "monetary amount")
return value
@model_validator(mode="after")
@pydantic_validation_boundary
def _validate_invoice_and_direction(self) -> RentaDeductibleExpenseFact:
if self.invoice_id is None and self.invoice_issue_date is not None:
raise RentaValidationError("invoice_issue_date requires invoice_id")
if self.invoice_id is not None and self.invoice_issue_date is None:
raise RentaValidationError("linked invoice facts require invoice_issue_date")
if self.direction in {RentaExpenseDirection.REFUND, RentaExpenseDirection.REVERSAL} and self.invoice_id is None:
raise RentaValidationError("refund and reversal facts must be linked to an invoice")
return self
@property
def filing_date(self) -> date:
"""Return the filing date used for first-slice selection.
Prefers ``invoice_issue_date`` when present, falling back to
``operation_date`` so observations without invoices still get a
deterministic anchor.
"""
return self.invoice_issue_date if self.invoice_issue_date is not None else self.operation_date
@property
def sign(self) -> Literal[-1, 1]:
"""Return +1 for expenses and -1 for linked corrections."""
if self.direction is RentaExpenseDirection.OUTGOING_EXPENSE:
return 1
return -1
[docs]
class RentaDeductibilityResult(_RentaStrictFrozenModel):
"""Deductibility evaluation result before registry binding resolution."""
transaction_id: TransactionId
invoice_id: str | None = Field(default=None, min_length=1, max_length=128)
category: SpendingCategory
category_family: SpendingCategoryFamily
profile_year: FilingYear
proportionality_kind: ProportionalityKind
status: RentaDeductibilityStatus
reason: str = Field(min_length=1, max_length=256)
gross_amount: Decimal
deductible_amount: Decimal
non_deductible_amount: Decimal
applied_ratio: UnitProportion | None = None
statutory_cap_applied: Decimal | None = Field(default=None, ge=Decimal("0"))
legal_references: tuple[CategoryCitation, ...] = Field(min_length=1)
@field_validator("gross_amount", "deductible_amount", "non_deductible_amount", "statutory_cap_applied")
@classmethod
@pydantic_validation_boundary
def _result_amounts_are_decimals(cls, value: Decimal | None) -> Decimal | None:
if value is not None:
_require_decimal(value, "deductibility result amount")
return value
@model_validator(mode="after")
@pydantic_validation_boundary
def _validate_category_family(self) -> RentaDeductibilityResult:
if self.category_family is not family_for(self.category):
raise RentaValidationError("category_family must match category")
return self
@dataclass(frozen=True, slots=True)
class _RentaDeductibilityDecision:
"""Unsigned amounts and status produced by one proportionality rule."""
status: RentaDeductibilityStatus
reason: str
deductible_amount: Decimal
applied_ratio: Decimal | None
statutory_cap_applied: Decimal | None
def _validated_first_slice_casilla(value: object) -> CasillaId:
"""Validate a target token received from the first-slice facts mapping."""
return validated_casilla_id(value, surface="first-slice expense routing target")
def _resolve_first_slice_expense_routing(
*,
effective_date: date,
) -> Mapping[SpendingCategory, CasillaId]:
"""Resolve the selected dated first-slice route for production consumers."""
try:
routing = resolve_first_slice_expense_routing(
category_type=SpendingCategory,
category_tokens=lambda selected_authority, coordinate: spending_category_tokens(
effective_date=coordinate,
authority=selected_authority,
),
casilla_factory=_validated_first_slice_casilla,
model_code=Modelo("100").value,
fact_id="modelo-100-first-slice-expense-routing-mapping",
effective_date=effective_date,
)
except (AuthorityComponentCodecError, TypeError, ValueError) as exc:
raise RentaValidationError(
"first-slice expense routing declaration is absent or malformed",
) from exc
return routing
[docs]
class RentaDeductibleExpenseObservation(_RentaStrictFrozenModel):
"""Binding-ready Renta expense observation for the first Modelo 100 slice."""
observation_id: str = Field(min_length=1, max_length=160)
source_kind: Literal[BindingSourceKind.LEDGER_RENTA_GASTOS_ESTIMACION_DIRECTA_AGGREGATION] = (
BindingSourceKind.LEDGER_RENTA_GASTOS_ESTIMACION_DIRECTA_AGGREGATION
)
modelo: Literal["100"] = "100"
period: Literal["0A"] = "0A"
tax_year: FilingYear
activity_key: str = Field(min_length=1, max_length=128)
target_casilla_id: CasillaId
transaction_id: TransactionId
invoice_id: str | None = Field(default=None, min_length=1, max_length=128)
catalogue_id: str = Field(min_length=1, max_length=128)
operation_date: date
invoice_issue_date: date | None = None
posting_date: date | None = None
payment_date: date | None = None
filing_date: date
gross_amount: Decimal
taxable_base: Decimal | None = None
iva_amount: Decimal | None = None
deductible_amount: Decimal
non_deductible_amount: Decimal
currency: Literal["EUR"] = EUR_CURRENCY
direction: RentaExpenseDirection
sign: Literal[-1, 1]
category: SpendingCategory
category_family: SpendingCategoryFamily
profile_year: FilingYear
proportionality_kind: ProportionalityKind
applied_ratio: UnitProportion | None = None
invoice_evidence_status: RentaInvoiceEvidenceStatus
reconciliation_status: RentaReconciliationStatus
legal_references: tuple[CategoryCitation, ...] = Field(min_length=1)
@field_validator("gross_amount", "taxable_base", "iva_amount", "deductible_amount", "non_deductible_amount")
@classmethod
@pydantic_validation_boundary
def _observation_amounts_are_decimals(cls, value: Decimal | None) -> Decimal | None:
if value is not None:
_require_decimal(value, "observation amount")
return value
@model_validator(mode="after")
@pydantic_validation_boundary
def _validate_period_and_invoice_state(self) -> RentaDeductibleExpenseObservation:
if self.category_family is not family_for(self.category):
raise RentaValidationError("category_family must match category")
if not Period.from_year_and_code(self.tax_year, "0A").contains(self.filing_date):
raise RentaValidationError("filing_date must fall inside the observation tax year")
target_casilla_id = _resolve_first_slice_expense_routing(effective_date=self.filing_date).get(self.category)
if target_casilla_id is None or self.target_casilla_id != target_casilla_id:
raise RentaValidationError("target_casilla_id must match the first-slice category mapping")
if self.invoice_id is None and self.invoice_issue_date is not None:
raise RentaValidationError("invoice_issue_date requires invoice_id")
if self.invoice_id is None and self.invoice_evidence_status is not RentaInvoiceEvidenceStatus.NONE:
raise RentaValidationError("transaction-only observations must not declare linked invoice evidence")
if self.invoice_id is not None and self.invoice_evidence_status is not RentaInvoiceEvidenceStatus.LINKED:
raise RentaValidationError("linked invoice observations must declare linked invoice evidence")
return self
[docs]
def normalize_spending_category(value: SpendingCategory | str) -> SpendingCategory:
"""Normalize persisted category identifiers to closed enum members.
Returns:
The :class:`SpendingCategory` member for the given value.
"""
if isinstance(value, SpendingCategory):
return value
return require_spending_category(value)
[docs]
def resolve_region_category_profiles(
profile_year: int,
) -> Mapping[CCAA, Mapping[SpendingCategory, CategoryProfile]]:
"""Return the territorial-regime category-profile overrides for a filing year.
The override layer is provisioned but deliberately empty: the only genuinely
region-varying expense-side regimes reach the base through their own dedicated
bindings (the Reserva para Inversiones en Canarias, Ley 19/1994 art. 27, is
modelled as its own binding, not a :class:`SpendingCategory` profile), and the
Ceuta/Melilla benefit is an art. 68.4 cuota deduction rather than a
base-imponible expense rule. No :class:`SpendingCategory` therefore warrants a
per-comunidad deductibility variant today, so this resolver returns an empty
mapping and every fact falls through to the state year profile. A future
territorial-regime enrollment populates this mapping (grounded to its regime
law) with no further architectural change.
Returns:
Mapping from :class:`CCAA` to a per-:class:`SpendingCategory` override
profile mapping; empty until a territorial regime is enrolled.
"""
declarations = renta_expense_policy_declarations(profile_year)
if declarations.get("territorial_variant_state") != "empty":
raise RentaValidationError("Renta expense territorial policy is not an empty catalogue")
empty: dict[CCAA, Mapping[SpendingCategory, CategoryProfile]] = {}
return MappingProxyType(empty)
[docs]
def select_deductibility_profile(
*,
state_profile: CategoryProfile,
region_override_profiles: Mapping[CCAA, CategoryProfile],
context: RentaDeductibilityContext,
) -> CategoryProfile | None:
"""Select the applicable category profile, honouring territorial-regime overrides.
``region_override_profiles`` holds the per-:class:`CCAA` overrides declared for
the fact's category (empty for the general state-law case). Selection:
- no override for the category: return ``state_profile`` unchanged (general
expense deductibility is state base-imponible law, invariant across comunidades);
- the category carries an override but ``context.residence_ccaa is None``: return
``None`` (D4 fail-closed) so the caller refuses rather than silently choosing a
base for an undeclared region;
- the residence comunidad has an override: return that override profile;
- the residence comunidad has no override (a different comunidad owns the regime):
return ``state_profile`` (state law applies to this taxpayer's region).
Returns:
The selected :class:`CategoryProfile`, or ``None`` when a region override
exists for the category but the residence comunidad is undeclared.
"""
if not region_override_profiles:
return state_profile
if context.residence_ccaa is None:
return None
return region_override_profiles.get(context.residence_ccaa, state_profile)
def _evaluate_full_deductible_rule(
_fact: RentaDeductibleExpenseFact,
_rule: ProportionalityRule,
_context: RentaDeductibilityContext,
deductible_basis: Decimal,
) -> _RentaDeductibilityDecision:
return _RentaDeductibilityDecision(
status=RentaDeductibilityStatus.ELIGIBLE,
reason="deductible",
deductible_amount=deductible_basis,
applied_ratio=Decimal("1"),
statutory_cap_applied=None,
)
def _evaluate_fixed_percentage_rule(
fact: RentaDeductibleExpenseFact,
_rule: ProportionalityRule,
_context: RentaDeductibilityContext,
deductible_basis: Decimal,
) -> _RentaDeductibilityDecision:
fixed_pct = _rule.fixed_pct
if fixed_pct is None:
raise RentaValidationError(
f"proportionality rule for {fact.category.value!r} declares a fixed percentage kind without a rate",
)
return _RentaDeductibilityDecision(
status=RentaDeductibilityStatus.ELIGIBLE,
reason="deductible",
deductible_amount=deductible_basis * fixed_pct,
applied_ratio=fixed_pct,
statutory_cap_applied=None,
)
def _evaluate_usage_ratio_rule(
fact: RentaDeductibleExpenseFact,
rule: ProportionalityRule,
context: RentaDeductibilityContext,
deductible_basis: Decimal,
) -> _RentaDeductibilityDecision:
ratio = context.usage_ratios.get(fact.category, rule.default_ratio)
if ratio is None:
return _RentaDeductibilityDecision(
status=RentaDeductibilityStatus.INELIGIBLE,
reason="missing usage ratio",
deductible_amount=Decimal("0"),
applied_ratio=None,
statutory_cap_applied=None,
)
return _RentaDeductibilityDecision(
status=RentaDeductibilityStatus.ELIGIBLE,
reason="deductible",
deductible_amount=deductible_basis * ratio,
applied_ratio=ratio,
statutory_cap_applied=None,
)
def _evaluate_statutory_cap_rule(
_fact: RentaDeductibleExpenseFact,
rule: ProportionalityRule,
context: RentaDeductibilityContext,
deductible_basis: Decimal,
) -> _RentaDeductibilityDecision:
cap_applied = _resolve_statutory_cap(rule=rule, context=context)
if cap_applied is None:
return _RentaDeductibilityDecision(
status=RentaDeductibilityStatus.INELIGIBLE,
reason="missing statutory cap context",
deductible_amount=Decimal("0"),
applied_ratio=None,
statutory_cap_applied=None,
)
deductible_amount = min(deductible_basis, cap_applied)
applied_ratio = Decimal("0") if deductible_basis == Decimal("0") else deductible_amount / deductible_basis
return _RentaDeductibilityDecision(
status=RentaDeductibilityStatus.ELIGIBLE,
reason="deductible",
deductible_amount=deductible_amount,
applied_ratio=applied_ratio,
statutory_cap_applied=cap_applied,
)
def _evaluate_non_deductible_rule(
_fact: RentaDeductibleExpenseFact,
_rule: ProportionalityRule,
_context: RentaDeductibilityContext,
_deductible_basis: Decimal,
) -> _RentaDeductibilityDecision:
return _RentaDeductibilityDecision(
status=RentaDeductibilityStatus.INELIGIBLE,
reason="non deductible category",
deductible_amount=Decimal("0"),
applied_ratio=Decimal("0"),
statutory_cap_applied=None,
)
def _evaluate_exclusive_use_rule(
_fact: RentaDeductibleExpenseFact,
_rule: ProportionalityRule,
context: RentaDeductibilityContext,
deductible_basis: Decimal,
) -> _RentaDeductibilityDecision:
if context.exclusive_use_confirmed:
# Confirmation gates whether the cost is deductible, never what the
# deductible cost is. The basis excludes recoverable input IVA and
# includes only the non-recoverable share (PGC NRV 12.ª).
return _RentaDeductibilityDecision(
status=RentaDeductibilityStatus.ELIGIBLE,
reason="deductible",
deductible_amount=deductible_basis,
applied_ratio=Decimal("1"),
statutory_cap_applied=None,
)
return _RentaDeductibilityDecision(
status=RentaDeductibilityStatus.INELIGIBLE,
reason="exclusive use not confirmed",
deductible_amount=Decimal("0"),
applied_ratio=None,
statutory_cap_applied=None,
)
_RentaDeductibilityEvaluator = Callable[
[RentaDeductibleExpenseFact, ProportionalityRule, RentaDeductibilityContext, Decimal],
_RentaDeductibilityDecision,
]
def _evaluator_for_proportionality_kind(
kind: ProportionalityKind,
) -> _RentaDeductibilityEvaluator | None:
"""Route a projected kind through its registry-declared evaluator role."""
if kind.is_full_deductible:
return _evaluate_full_deductible_rule
if kind.requires_fixed_pct:
return _evaluate_fixed_percentage_rule
if kind.is_usage_ratio:
return _evaluate_usage_ratio_rule
if kind.is_statutory_cap:
return _evaluate_statutory_cap_rule
if kind.is_non_deductible:
return _evaluate_non_deductible_rule
if kind.requires_exclusive_use:
return _evaluate_exclusive_use_rule
return None
[docs]
def evaluate_renta_deductibility(
fact: RentaDeductibleExpenseFact,
profile: CategoryProfile,
context: RentaDeductibilityContext,
) -> RentaDeductibilityResult:
"""Evaluate one classified ledger fact and return a :class:`RentaDeductibilityResult`.
``deductible_basis`` (see :func:`_deductible_basis_amount`) folds in the
non-deductible-for-IVA-purposes share of the fact's input IVA when
``context.iva_deduction_ratio`` states it, so an exempt or prorrata-rationed
activity does not lose that real cost.
"""
if profile.category != fact.category:
raise RentaValidationError(
f"profile category {profile.category.value!r} does not match fact category {fact.category.value!r}",
)
rule = profile.proportionality
deductible_basis = _deductible_basis_amount(fact, context)
evaluator = _evaluator_for_proportionality_kind(rule.kind)
if evaluator is None: # pragma: no cover - closed enum exhaustiveness guard
raise RentaValidationError(f"unsupported proportionality kind: {rule.kind.value}")
decision = evaluator(fact, rule, context, deductible_basis)
signed_deductible = decision.deductible_amount * fact.sign
signed_non_deductible = (fact.gross_amount - decision.deductible_amount) * fact.sign
return RentaDeductibilityResult(
transaction_id=fact.transaction_id,
invoice_id=fact.invoice_id,
category=fact.category,
category_family=family_for(fact.category),
profile_year=context.profile_year,
proportionality_kind=rule.kind,
status=decision.status,
reason=decision.reason,
gross_amount=fact.gross_amount * fact.sign,
deductible_amount=signed_deductible,
non_deductible_amount=signed_non_deductible,
applied_ratio=decision.applied_ratio,
statutory_cap_applied=decision.statutory_cap_applied,
legal_references=rule.citations,
)
def _deductible_basis_amount(fact: RentaDeductibleExpenseFact, context: RentaDeductibilityContext) -> Decimal:
"""Return the IRPF-deductible cost basis, joining non-deductible input IVA to it.
Uses the net-of-IVA ``taxable_base`` when known, falling back to
``gross_amount`` (already IVA-inclusive) when no invoice evidence split it.
When the fact carries both a ``taxable_base`` and an ``iva_amount``, and
``context.iva_deduction_ratio`` states the activity's IVA-deduction
percentage, the portion of ``iva_amount`` the activity has no right to deduct
(PGC NRV 12.ª) joins the base as real acquisition cost. A ``None`` ratio (not
yet evaluated) or a missing ``iva_amount`` leaves the base untouched, matching
the historic behaviour.
"""
base = fact.taxable_base if fact.taxable_base is not None else fact.gross_amount
if fact.taxable_base is None or fact.iva_amount is None or context.iva_deduction_ratio is None:
return base
non_deductible_iva = fact.iva_amount * (Decimal("1") - context.iva_deduction_ratio)
return base + non_deductible_iva
[docs]
def build_renta_deductible_expense_observation(
fact: RentaDeductibleExpenseFact,
result: RentaDeductibilityResult,
*,
tax_year: int,
) -> RentaDeductibleExpenseObservation:
"""Build a first-slice Modelo 100 observation from an eligible result.
Returns:
A :class:`RentaDeductibleExpenseObservation` ready for filing assembly.
"""
if result.status is not RentaDeductibilityStatus.ELIGIBLE:
raise RentaValidationError(f"ineligible deductibility result cannot become an observation: {result.reason}")
if fact.category != result.category:
raise RentaValidationError("fact and result categories must match")
if not Period.from_year_and_code(tax_year, "0A").contains(fact.filing_date):
raise RentaValidationError("fact filing date falls outside the requested tax year")
target_casilla_id = _resolve_first_slice_expense_routing(effective_date=fact.filing_date).get(fact.category)
if target_casilla_id is None:
raise RentaValidationError(f"category {fact.category.value!r} is outside the first Renta expense slice")
invoice_status = (
RentaInvoiceEvidenceStatus.LINKED if fact.invoice_id is not None else RentaInvoiceEvidenceStatus.NONE
)
reconciliation_status = (
RentaReconciliationStatus.LINKED_INVOICE
if fact.invoice_id is not None
else RentaReconciliationStatus.TRANSACTION_ONLY
)
return RentaDeductibleExpenseObservation(
observation_id=_observation_id(fact),
tax_year=tax_year,
activity_key=fact.activity_key,
target_casilla_id=target_casilla_id,
transaction_id=fact.transaction_id,
invoice_id=fact.invoice_id,
catalogue_id=fact.catalogue_id,
operation_date=fact.operation_date,
invoice_issue_date=fact.invoice_issue_date,
posting_date=fact.posting_date,
payment_date=fact.payment_date,
filing_date=fact.filing_date,
gross_amount=result.gross_amount,
taxable_base=fact.taxable_base * fact.sign if fact.taxable_base is not None else None,
iva_amount=fact.iva_amount * fact.sign if fact.iva_amount is not None else None,
deductible_amount=result.deductible_amount,
non_deductible_amount=result.non_deductible_amount,
direction=fact.direction,
sign=fact.sign,
category=fact.category,
category_family=result.category_family,
profile_year=result.profile_year,
proportionality_kind=result.proportionality_kind,
applied_ratio=result.applied_ratio,
invoice_evidence_status=invoice_status,
reconciliation_status=reconciliation_status,
legal_references=result.legal_references,
)
def _resolve_statutory_cap(
*,
rule: ProportionalityRule,
context: RentaDeductibilityContext,
) -> Decimal | None:
if rule.statutory_cap_eur is not None:
return _resolve_fixed_statutory_cap(rule, context=context)
if rule.statutory_cap_eur_per_day is not None:
return _resolve_daily_statutory_cap(rule, context=context)
if rule.statutory_cap_variants:
return _resolve_variant_statutory_cap(rule, context=context)
return None
def _resolve_fixed_statutory_cap(
rule: ProportionalityRule,
*,
context: RentaDeductibilityContext,
) -> Decimal | None:
amount = rule.statutory_cap_eur
if amount is None:
return None
if rule.statutory_cap_period is not None and rule.statutory_cap_period.is_per_person:
return amount * Decimal(context.statutory_cap_person_count)
return amount
def _resolve_daily_statutory_cap(
rule: ProportionalityRule,
*,
context: RentaDeductibilityContext,
) -> Decimal | None:
amount = rule.statutory_cap_eur_per_day
days = context.statutory_cap_days
if amount is None or days is None:
return None
return amount * days
def _resolve_variant_statutory_cap(
rule: ProportionalityRule,
*,
context: RentaDeductibilityContext,
) -> Decimal | None:
annual = [variant for variant in rule.statutory_cap_variants if not variant.is_per_day]
if annual:
return _resolve_annual_variant_cap(annual, context=context)
if context.statutory_cap_variant_id is None or context.statutory_cap_days is None:
return None
for variant in rule.statutory_cap_variants:
if variant.id == context.statutory_cap_variant_id:
per_day = variant.statutory_cap_eur_per_day
if per_day is None:
return None
return per_day * context.statutory_cap_days
return None
def _resolve_annual_variant_cap(
variants: Sequence[StatutoryCapVariant],
*,
context: RentaDeductibilityContext,
) -> Decimal | None:
"""Sum an annual per-person cap across the populations each variant governs.
Unlike a daily variant set, where one condition selects one amount, an annual
per-person set applies EVERY variant at once to its own share of the insured
persons: LIRPF art. 30.2.5.a allows 500 for each ordinary person AND 1.500 for
each person with discapacidad in the same return.
Returns:
The summed cap, or ``None`` when no population has been counted -- absence of
counts is unknown, not zero, and returning zero would cap the deduction at
nothing.
"""
counts = context.statutory_cap_variant_person_counts
if not counts:
# Nothing is known about which persons meet the condition the higher limb
# requires. The ordinary limit is what the article grants absent that
# condition, so the conservative limb applies to the persons we do know
# about. This reproduces the behaviour that shipped before the second limb
# existed, so an uninformed caller is never worse off than it was, while a
# caller that supplies the counts gets the lawful sum.
lowest = min(
(variant.statutory_cap_eur for variant in variants if variant.statutory_cap_eur is not None),
default=None,
)
if lowest is None:
return None
return lowest * Decimal(context.statutory_cap_person_count)
declared = {variant.id for variant in variants}
unknown = sorted(set(counts) - declared)
if unknown:
raise RentaValidationError(
f"statutory cap variant person counts name variants the rule does not declare: {unknown}",
)
total = Decimal("0")
for variant in variants:
amount = variant.statutory_cap_eur
if amount is None:
return None
total += amount * Decimal(counts.get(variant.id, 0))
return total
def _observation_id(fact: RentaDeductibleExpenseFact) -> str:
if fact.invoice_id is None:
return f"renta-expense:{fact.catalogue_id}:{fact.transaction_id}"
return f"renta-expense:{fact.catalogue_id}:{fact.transaction_id}:{fact.invoice_id}"
def _require_decimal(value: object, field_name: str) -> None:
if isinstance(value, bool) or not isinstance(value, Decimal) or not value.is_finite():
raise RentaValidationError(f"{field_name} must be a finite Decimal")
__all__ = [
"RentaDeductibilityContext",
"RentaDeductibilityResult",
"RentaDeductibilityStatus",
"RentaDeductibleExpenseFact",
"RentaDeductibleExpenseObservation",
"RentaExpenseDirection",
"RentaInvoiceEvidenceStatus",
"RentaReconciliationStatus",
"build_renta_deductible_expense_observation",
"evaluate_renta_deductibility",
"normalize_spending_category",
"resolve_region_category_profiles",
"select_deductibility_profile",
]