← Files VeraARCHIVED FILE

modules/invoice-xml/scripts/invoice_checks.py

9.74 KB · Oct 2, 2026 · 00:29 UTC

↓ Download file

"""Local arithmetic and field consistency checks, distinct from SdI acceptance."""

from __future__ import annotations

from collections import defaultdict
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation, localcontext
from typing import Any

__all__ = ["check_invoice"]

CENT = Decimal("0.01")
ZERO = Decimal("0")


def _rows(value: Any) -> list[dict[str, Any]]:
    return value if isinstance(value, list) else [value]


def _amount(row: dict[str, Any], field: str, default: str | None = None) -> Decimal:
    value = row.get(field, default)
    if not isinstance(value, str):
        raise ValueError(f"Missing decimal {field}")
    try:
        amount = Decimal(value)
    except InvalidOperation as exc:
        raise ValueError(f"Invalid decimal {field}") from exc
    if not amount.is_finite():
        raise ValueError(f"Nonfinite decimal {field}")
    return amount


def _key(row: dict[str, Any]) -> tuple[Decimal, str]:
    return _amount(row, "AliquotaIVA"), row.get("Natura", "")


def _vat_consistency(
    row: dict[str, Any], label: str, issues: list[str], invoice_date: str
) -> None:
    rate, nature = _key(row)
    if (rate == ZERO) != bool(nature):
        issues.append(
            f"{label}: Natura is required at zero VAT and excluded at nonzero VAT"
        )
    if nature.startswith("N6") and row.get("EsigibilitaIVA") == "S":
        issues.append(f"{label}: reverse charge and split payment conflict")
    if invoice_date >= "2021-01-01" and nature in {"N2", "N3", "N6"}:
        issues.append(
            f"{label}: generic Natura {nature} is retired from 2021; review the specific code"
        )


def check_invoice(invoice: dict[str, Any], route: str) -> list[str]:
    """Check schema-valid fields without choosing tax codes or changing amounts.

    Decimal arithmetic is required for reproducible money. The cent tolerance is
    this workflow's reconciliation policy, not a claim to reproduce all SdI rules.
    Complex header adjustments are withheld until separately qualified; no fields
    are silently removed in order to make a document exportable.
    """
    issues: list[str] = []
    header = invoice["FatturaElettronicaHeader"]
    transmission = header["DatiTrasmissione"]
    if transmission["FormatoTrasmissione"] != "FPR12":
        issues.append("Only ordinary private-recipient FPR12 export is qualified")
    if len(transmission["CodiceDestinatario"]) != 7:
        issues.append("FPR12 requires a seven-character recipient code")
    if (
        transmission.get("PECDestinatario")
        and transmission["CodiceDestinatario"] != "0000000"
    ):
        issues.append("PEC routing requires recipient code 0000000")
    if route not in {"domestic", "foreign_integration"}:
        issues.append("Select and review domestic or foreign_integration preparation")
    bodies = _rows(invoice["FatturaElettronicaBody"])
    identities: set[tuple[str, str, str]] = set()
    with localcontext() as context:
        context.prec = 50
        for index, body in enumerate(bodies):
            label = f"Invoice body {index + 1}"
            general = body["DatiGenerali"]["DatiGeneraliDocumento"]
            document_type = general["TipoDocumento"]
            allowed = (
                {"TD17", "TD18", "TD19"}
                if route == "foreign_integration"
                else {
                    "TD01",
                    "TD02",
                    "TD03",
                    "TD04",
                    "TD05",
                    "TD06",
                    "TD24",
                    "TD25",
                    "TD26",
                }
            )
            if document_type not in allowed:
                issues.append(f"{label}: document type is outside the selected route")
            identity = (document_type, general["Data"], general["Numero"])
            if identity in identities:
                issues.append(f"{label}: duplicate invoice identity within the export")
            identities.add(identity)
            if route == "foreign_integration":
                if (
                    header["CedentePrestatore"]["DatiAnagrafici"]["IdFiscaleIVA"][
                        "IdPaese"
                    ]
                    == "IT"
                ):
                    issues.append(
                        f"{label}: foreign integration needs the supplier's foreign identifier"
                    )
                customer = header["CessionarioCommittente"]["DatiAnagrafici"]
                if customer.get("IdFiscaleIVA", {}).get("IdPaese") != "IT":
                    issues.append(
                        f"{label}: foreign integration requires the Italian customer's VAT identity"
                    )
                links = body["DatiGenerali"].get("DatiFattureCollegate", [])
                if not links or any(not row.get("Data") for row in _rows(links)):
                    issues.append(
                        f"{label}: record original invoice number and date separately"
                    )
            if general["Divisa"] != "EUR":
                issues.append(
                    f"{label}: this export profile requires reviewed EUR amounts and conversion evidence"
                )
            for special in ("DatiBollo", "ScontoMaggiorazione", "Art73"):
                if special in general:
                    issues.append(
                        f"{label}: {special} total reconciliation is not yet qualified; retained for review"
                    )
            lines = _rows(body["DatiBeniServizi"]["DettaglioLinee"])
            summaries = _rows(body["DatiBeniServizi"]["DatiRiepilogo"])
            line_numbers: set[int] = set()
            bases: dict[tuple[Decimal, str], Decimal] = defaultdict(lambda: ZERO)
            for line in lines:
                number = int(line["NumeroLinea"])
                if number in line_numbers:
                    issues.append(f"{label}: duplicate line number {number}")
                line_numbers.add(number)
                _vat_consistency(
                    line, f"{label}, line {number}", issues, general["Data"]
                )
                unit = _amount(line, "PrezzoUnitario")
                for adjustment in _rows(line.get("ScontoMaggiorazione", [])):
                    if "Importo" not in adjustment and "Percentuale" not in adjustment:
                        issues.append(
                            f"{label}, line {number}: discount/surcharge has no value"
                        )
                        continue
                    adjustment_amount = (
                        _amount(adjustment, "Importo")
                        if "Importo" in adjustment
                        else unit * _amount(adjustment, "Percentuale") / 100
                    )
                    unit += (
                        adjustment_amount
                        if adjustment["Tipo"] == "MG"
                        else -adjustment_amount
                    )
                expected = unit * _amount(line, "Quantita", "1")
                if abs(expected - _amount(line, "PrezzoTotale")) > CENT:
                    issues.append(
                        f"{label}, line {number}: quantity × adjusted unit price does not reconcile to line total"
                    )
                bases[_key(line)] += _amount(line, "PrezzoTotale")
                if line.get("Ritenuta") == "SI" and not general.get("DatiRitenuta"):
                    issues.append(
                        f"{label}, line {number}: withholding flagged without DatiRitenuta"
                    )
            for contribution in _rows(general.get("DatiCassaPrevidenziale", [])):
                _vat_consistency(
                    contribution,
                    f"{label}, pension contribution",
                    issues,
                    general["Data"],
                )
                bases[_key(contribution)] += _amount(
                    contribution, "ImportoContributoCassa"
                )
            summarized: dict[tuple[Decimal, str], Decimal] = defaultdict(lambda: ZERO)
            gross = ZERO
            for summary in summaries:
                _vat_consistency(
                    summary, f"{label}, VAT summary", issues, general["Data"]
                )
                taxable = _amount(summary, "ImponibileImporto")
                tax = _amount(summary, "Imposta")
                expected_tax = (
                    taxable * _amount(summary, "AliquotaIVA") / 100
                ).quantize(CENT, rounding=ROUND_HALF_UP)
                if abs(expected_tax - tax) > CENT:
                    issues.append(
                        f"{label}: VAT amount does not reconcile to taxable base × rate"
                    )
                summarized[_key(summary)] += taxable - _amount(
                    summary, "Arrotondamento", "0"
                )
                gross += taxable + tax
            if set(bases) != set(summarized):
                issues.append(
                    f"{label}: line/contribution VAT groups differ from summary groups"
                )
            for group in set(bases) | set(summarized):
                if abs(bases[group] - summarized[group]) > CENT:
                    issues.append(
                        f"{label}: taxable summary does not reconcile to lines and contributions for VAT group {group}"
                    )
            if "ImportoTotaleDocumento" not in general:
                issues.append(f"{label}: review and supply the document total")
            elif (
                abs(
                    gross
                    + _amount(general, "Arrotondamento", "0")
                    - _amount(general, "ImportoTotaleDocumento")
                )
                > CENT
            ):
                issues.append(
                    f"{label}: document total does not reconcile to taxable bases, VAT and rounding"
                )
    return issues

SHA-256: d36b1e851a8791762762ccf7d6d38494093b91947dad2577aab2016b9a73e3eb