"""Deriving an IVA category from the regime mention the issuer printed.
The narrowest axis of the deterministic classifier, and the only one that needs
nothing but the document. Spanish law does not leave the regime of an operation
to be inferred: RD 1619/2012 art. 6.1 obliges the issuer to print a fixed
*mención* whenever certain regimes apply, so for those cases the paper states the
regime in words the regulation itself sets. Copying a printed phrase is something
a reading stage may do; concluding what that phrase declares is something
deterministic code may do. Neither step guesses.
**Why this axis rather than the full rule table.**
:func:`~domain.iva.classification.classify_iva` already exists and is the single classifier for
this domain; nothing here duplicates or competes with it. That table resolves the
categories that depend on WHO the counterparty is -- their tax status and
residency -- and ``customer_tax_status`` is a profile fact rather than a document
fact, which is exactly why it cannot be derived from evidence alone. The legend
axis needs neither input. It reports only what the issuer themselves declared in
writing, so it never enters that table's domain and the two cannot silently
disagree.
**Three outcomes, and "probably" is not one.** A legend is present and consistent
with the tax evidence on the page, or it contradicts that evidence, or there is
nothing to derive from. No score, no ranking, no numeric confidence anywhere:
those exist to let a caller pick a threshold, and there is no honest threshold
between "the issuer declared this" and "the issuer did not".
**The absent outcome is the common one, and it must stay that way.** The selected
registry vocabulary has seven rows and exactly one declares a category, because
that is the measured state of the regulation rather than an unfinished table.
Most invoices legitimately derive nothing here. The pressure to close that gap
with a default -- treating an unstated counterparty country as domestic, or a
legend-less invoice as ordinary domestic supply -- is the restrictive-
provision-as-default trap: it silently captures the whole population the
provision does not govern. **A wrong category is worse than an absent one**,
because an absent category asks the operator and a wrong one does not.
**The exempt case cannot be derived here at all.** Art. 6.1.j fixes no phrase for
an exempt operation; it requires a reference to the provision granting the
exemption, so an exempt invoice prints whichever article applies and no canonical
string exists to match. That absence is load-bearing and is documented on
:func:`~domain.iva.regime_legend.resolve_regime_legends`.
See Also:
:class:`~domain.iva.regime_legend.RegimeLegend`
The statutory mention this module reads, and its declared expectations.
:class:`~domain.iva.schema.IvaCategory`
The closed catalogue a legend resolves into.
:func:`~domain.iva.classification.classify_iva`
The counterparty-dependent rule table this axis deliberately stays out of.
"""
from __future__ import annotations
from collections.abc import Iterable
from enum import StrEnum
from typing import Final, Self
from pydantic import BaseModel, model_validator
from ...core.errors.hierarchy import pydantic_validation_boundary
from ...core.models import STRICT_FROZEN_CONFIG
from ...core.text_fold import fold_printed_phrase
from .regime_legend import RegimeLegend
from .schema import IvaCategory
__all__ = [
"LegendDerivation",
"LegendDerivationOutcome",
"derive_category_from_regime_legend",
"index_regime_legends",
"match_regime_legend",
]
[docs]
class LegendDerivationOutcome(StrEnum):
"""What the legend axis was able to conclude.
Closed, and deliberately without a "probable" member. The axis either has the
issuer's own written declaration or it does not.
"""
DERIVED = "derived"
"""A legend declared a category and the printed tax evidence agrees with it."""
CONTRADICTED = "contradicted"
"""A legend declared a category the printed tax evidence contradicts.
Reported rather than resolved. The document is internally inconsistent, and
picking whichever side looks more likely would erase the one signal that says
so. Turning this into an operator-facing blocking finding belongs to the
review path, not here.
"""
ABSENT = "absent"
"""Nothing to derive from: no legend printed, or one that declares no category.
The expected outcome for most invoices. Six of the seven mandated mentions
declare no category, and an invoice printing none at all is entirely ordinary.
"""
[docs]
class LegendDerivation(BaseModel):
"""The legend axis's conclusion about one document.
Attributes:
outcome: Which of the three states applies.
category: The declared category, set only on ``DERIVED``. Deliberately
``None`` on ``CONTRADICTED``: the legend named a category, but the
page disagrees with itself, and carrying the value anyway would let a
caller use it while ignoring the contradiction.
legend: The matched mention, on ``DERIVED`` and ``CONTRADICTED`` alike.
Carries the ``provision`` so a value derived here can cite the
article that put the phrase on the page.
derived_from: The inputs this conclusion followed from, named so the
derivation can be reproduced by hand. Recorded in place of a printed
anchor, because a derived value has none.
note: Operator-facing explanation, populated on ``CONTRADICTED`` to say
what disagreed with what.
"""
model_config = STRICT_FROZEN_CONFIG
outcome: LegendDerivationOutcome
category: IvaCategory | None = None
legend: RegimeLegend | None = None
derived_from: tuple[str, ...] = ()
note: str = ""
@model_validator(mode="after")
@pydantic_validation_boundary
def _each_outcome_carries_exactly_what_it_claims(self) -> Self:
"""Refuse a record whose payload does not match the state it reports.
Each outcome is a different claim about the evidence, and a payload that
does not fit its state is how a caller ends up reading a category off a
record that did not establish one.
"""
_validate_legend_derivation_payload(
outcome=self.outcome,
category=self.category,
legend=self.legend,
derived_from=self.derived_from,
)
return self
def _validate_legend_derivation_payload(
*,
outcome: LegendDerivationOutcome,
category: IvaCategory | None,
legend: RegimeLegend | None,
derived_from: tuple[str, ...],
) -> None:
if outcome is LegendDerivationOutcome.DERIVED:
if category is None or legend is None:
raise ValueError("a derived outcome must carry both the category and the legend that declared it")
if not derived_from:
raise ValueError("a derived outcome must record the inputs it followed from")
elif outcome is LegendDerivationOutcome.CONTRADICTED:
if legend is None:
raise ValueError("a contradicted outcome must carry the legend that was contradicted")
if category is not None:
raise ValueError(
"a contradicted outcome must not carry a category: the document disagrees with itself, "
"and a caller holding the value would use it without the contradiction",
)
elif category is not None or legend is not None:
raise ValueError("an absent outcome derives nothing, so it carries neither a category nor a legend")
#: Named so the absent outcome reads as a decision rather than a fall-through.
_NOTHING_DERIVED: Final = LegendDerivation(outcome=LegendDerivationOutcome.ABSENT)
[docs]
def index_regime_legends(legends: Iterable[RegimeLegend]) -> dict[str, RegimeLegend]:
"""Index mentions by their normalised form, refusing a table that collides.
Split from the table so the refusal is reachable with a supplied vocabulary
rather than only with the shipped one: the collision refusal is what makes
accent folding sound, and a check exercisable only against the bundled table
is a check nothing proves.
Two mentions that differ only by an accent would become one form under
folding, and a lookup that silently kept whichever came first would make
declaration order decide a taxpayer's regime. Refusing the whole table is
the only safe answer, because there is no basis for preferring either row.
Args:
legends: The mentions to index.
Returns:
Each normalised mention mapped to its record.
Raises:
ValueError: When the vocabulary is empty, a mention normalises to
nothing, or two different provisions claim one normalised form.
"""
indexed: dict[str, RegimeLegend] = {}
for legend in legends:
normalised = fold_printed_phrase(legend.phrase)
if not normalised:
raise ValueError(f"the mention for {legend.provision} normalises to nothing and can match anything")
claimed = indexed.get(normalised)
if claimed is not None and claimed.provision != legend.provision:
raise ValueError(
f"the mention {legend.phrase!r} normalises to {normalised!r}, which both "
f"{claimed.provision} and {legend.provision} claim; a mention that cannot name one "
f"provision cannot declare one regime",
)
indexed[normalised] = legend
if not indexed:
raise ValueError("the regime-legend vocabulary is empty")
return indexed
def _regime_legends_by_normalised_phrase(
legends: tuple[RegimeLegend, ...],
) -> dict[str, RegimeLegend]:
"""Index the caller-selected vocabulary, refusing it if it collides."""
return index_regime_legends(legends)
[docs]
def match_regime_legend(
printed: str | None,
*,
legends: tuple[RegimeLegend, ...],
) -> RegimeLegend | None:
"""Return the mandated mention *printed* contains, or ``None``.
Matched under :func:`core.text_fold.fold_printed_phrase`, which folds case,
collapses whitespace and folds combining accents away. Nothing beyond that
is normalised: a phrase the regulation does not fix is not a mandated
mention, and loosening the match to catch PARAPHRASES would derive a regime
from words the issuer did not print. Folding an accent is not that
loosening -- it recovers wording the issuer printed and our reading
degraded.
Containment rather than equality, because the mention is printed as a line on
an invoice among other text, and the reading stage is instructed to copy the
phrase but may carry surrounding punctuation with it.
Args:
printed: The phrase transcribed from the document, or ``None``.
legends: The dated registry declarations selected by the enclosing
operation.
Returns:
The matching :class:`~domain.iva.regime_legend.RegimeLegend`, or ``None`` when the text
is absent, blank, or contains no mandated mention.
"""
if printed is None:
return None
folded = fold_printed_phrase(printed)
if not folded:
return None
for phrase, legend in _regime_legends_by_normalised_phrase(legends).items():
if phrase in folded:
return legend
return None
[docs]
def derive_category_from_regime_legend(
*,
printed_legend: str | None,
has_repercutido_line: bool,
legends: tuple[RegimeLegend, ...],
) -> LegendDerivation:
"""Derive the IVA category the issuer declared in writing, if any.
The whole axis. A mention is matched against the closed statutory vocabulary;
if it declares a category, the declaration is checked against the tax evidence
the page actually carries, and only an agreeing pair derives a value.
The check is possible because :class:`~domain.iva.regime_legend.RegimeLegend` already states
per mention whether an invoice printing it should carry a repercutido rate and
cuota. A "inversión del sujeto pasivo" invoice charges no Spanish IVA, so a
repercutido line beside that mention means one of the two is wrong -- and
which one is not decidable from the page. Reporting the disagreement keeps
that visible; averaging over it would not.
Args:
printed_legend: The mention transcribed from the document, or ``None``.
has_repercutido_line: Whether the document carries a repercutido rate and
cuota. Passed in as an already-established fact rather than recomputed
here: what counts as a printed tax line is the reading stage's
business, and re-deciding it here would be a second authority on it.
legends: The dated registry declarations selected by the enclosing
operation.
Returns:
:class:`LegendDerivation`: One of the three outcomes, carrying exactly
what that outcome establishes.
"""
legend = match_regime_legend(printed_legend, legends=legends)
if legend is None or legend.declares is None:
# Six of the seven mandated mentions declare no category, so this is the
# ordinary path rather than a failure. It stays ABSENT rather than
# acquiring a domestic default: the mentions that declare nothing are
# obligations about the billing arrangement or a special regime's
# accounting, and none fixes the operation's category on its own.
return _NOTHING_DERIVED
inputs = ("regime_legend", "iva_rate", "iva_amount")
if legend.expects_repercutido_line != has_repercutido_line:
expected = "a repercutido rate and cuota" if legend.expects_repercutido_line else "no repercutido line"
observed = "one is printed" if has_repercutido_line else "none is printed"
return LegendDerivation(
outcome=LegendDerivationOutcome.CONTRADICTED,
legend=legend,
derived_from=inputs,
note=(
f"the document prints the mention {legend.phrase!r} ({legend.provision}), which expects "
f"{expected}, but {observed}; the document disagrees with itself and the category cannot "
"be taken from either side"
),
)
return LegendDerivation(
outcome=LegendDerivationOutcome.DERIVED,
category=legend.declares,
legend=legend,
derived_from=inputs,
)