fix(emission): validate Σ vPag against the document total (vNF)

IMPORTANT (F2 review): the service promises to validate structural
coherence (sums, required leiaute fields) per design spec decision #3, but
the one sum that can genuinely diverge -- pagamento.valor vs the document
total -- was unchecked. The lib documents SEFAZ rejects this with 'Valor do
Pagamento difere do total' (sowai_fiscal.xml_builder).

Adds PagamentoTotalMismatchError, computed in the completeness block
(before allocate_fiscal_number) by reusing sowai_fiscal.xml_builder.
soma_itens_quantizados -- the SAME function the lib's own _build_total uses
for vNF/vProd, avoiding a second source of truth for the same total.
Decimal-exact comparison (never float), both sides quantized to cents the
same way _build_pag does. Router maps it to 409 pagamento_total_diverge.

Test: tests/emission/test_emissao.py::
test_pagamento_divergente_do_total_dos_itens_e_409_e_nao_queima_numero --
pagamento.valor != Σ itens -> 409 pagamento_total_diverge, no fiscal number
consumed. The existing golden-backed happy-path tests (already balanced)
continue to prove the matching-totals case still succeeds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jonatanritter
2026-08-08 15:34:24 -03:00
co-authored by Claude Opus 4.8
parent 72bb089222
commit 0b64602716
3 changed files with 87 additions and 4 deletions
+2
View File
@@ -34,6 +34,8 @@ async def emitir_documento_endpoint(
raise conflict("fiscal_config_missing", str(exc)) from exc
except service.EmitenteCertificateCnpjMismatchError as exc:
raise conflict("emitente_certificate_cnpj_mismatch", str(exc)) from exc
except service.PagamentoTotalMismatchError as exc:
raise conflict("pagamento_total_diverge", str(exc)) from exc
except service.FiscalDocumentConflictError as exc:
raise conflict("fiscal_document_conflict", str(exc)) from exc
response.status_code = status.HTTP_201_CREATED if created else status.HTTP_200_OK
+45 -4
View File
@@ -17,9 +17,9 @@ needs the number ALREADY allocated -- cNF != nNF is NT2019.001):
1. idempotency pre-check (`Idempotency-Key` -> existing document, if any).
2. completeness: live certificate for branch_ref (+ its CNPJ against
`payload.emitente.cnpj`, F2 review FIX 1), existing series for
(document_model, serie) -- BOTH checked BEFORE touching
`allocate_fiscal_number`, so a missing config/mismatch never wastes a
número.
(document_model, serie), `pagamento.valor` against the document total
(F2 review FIX 3) -- ALL checked BEFORE touching `allocate_fiscal_
number`, so a missing config/mismatch never wastes a número.
3. `allocate_fiscal_number` (lock, no commit) -> `gerar_cnf` -> chave.
4. `build_nfe` (pure, `sowai_fiscal`) -> serialize -> assina.
5. `FiscalDocument(ASSINADO)` + `FiscalIdempotencyKey` added to the SAME
@@ -30,6 +30,7 @@ needs the number ALREADY allocated -- cNF != nNF is NT2019.001):
two-layer pattern this implements."""
import uuid
from datetime import datetime, timezone
from decimal import ROUND_HALF_UP, Decimal
from zoneinfo import ZoneInfo
from cryptography.hazmat.primitives import serialization
@@ -71,10 +72,17 @@ from sowai_fiscal.xml_builder import (
ItemData,
PagamentoData,
build_nfe,
soma_itens_quantizados,
)
_NFE_NAMESPACE = "http://www.portalfiscal.inf.br/nfe"
# Mesma quantização de `sowai_fiscal.xml_builder._build_pag`'s `vPag` --
# comparar `pagamento.valor` cru (Decimal com mais casas do que o leiaute
# aceita) contra `vNF` (2 casas, ROUND_HALF_UP) sem quantizar os DOIS lados
# do mesmo jeito produziria falsos-positivos de divergência.
_CENT = Decimal("0.01")
# M4 do auto (review Opus, 2026-07-16), preservado: dhEmi e o AAMM da chave
# em horário de Brasília, NÃO UTC -- na virada de mês o AAMM em UTC cairia
# no período de apuração errado e a chave divergiria do dhEmi. O Brasil não
@@ -114,6 +122,26 @@ class EmitenteCertificateCnpjMismatchError(Exception):
super().__init__("emitente CNPJ diverge do certificado da filial")
class PagamentoTotalMismatchError(Exception):
"""Fail-closed: a coerência estrutural que o serviço promete validar
(design spec decisão #3: "valida coerência estrutural -- somas, campos
obrigatórios do leiaute -- nunca recalcula imposto") cobria as somas de
`ICMSTot` mas deixava passar a ÚNICA outra que pode genuinamente
divergir -- `vPag` (`pagamento.valor`) contra `vNF` (o total do
documento, `sowai_fiscal.xml_builder.soma_itens_quantizados`). A SEFAZ
rejeita com "Valor do Pagamento difere do total" (mesma mensagem que o
módulo `xml_builder` já documenta); melhor 409 ANTES de alocar um
número (e queimá-lo) do que uma rejeição do lado de lá depois de já ter
persistido um `FiscalDocument` ASSINADO."""
def __init__(self, valor_pagamento: Decimal, valor_nf: Decimal):
self.valor_pagamento = valor_pagamento
self.valor_nf = valor_nf
super().__init__(
f"Valor do pagamento ({valor_pagamento}) diverge do total do documento ({valor_nf})"
)
class FiscalDocumentConflictError(Exception):
"""`chave_acesso` é UNIQUE GLOBAL (`uq_fiscal_documents_chave_acesso`,
Task 3) -- uma colisão no INSERT final (cNF repetido por acaso para o
@@ -339,6 +367,19 @@ async def emitir_documento(
if _normalize_cnpj(payload.emitente.cnpj) != _normalize_cnpj(certificate.cnpj_certificado):
raise EmitenteCertificateCnpjMismatchError()
# Coerência estrutural (spec decisão #3, "somas") -- a ÚNICA soma que
# pode genuinamente divergir do que os itens fecham é `vPag` vs `vNF`.
# `soma_itens_quantizados` é a MESMA função que `xml_builder._build_
# total` usa para `vNF`/`vProd` (fonte única -- ver o docstring dessa
# função na lib) -- reusada aqui em vez de re-somar, para nunca haver
# dois jeitos de calcular o mesmo total divergindo entre si. Decimal
# exato (nunca float); ANTES de alocar número.
itens_lib = [_to_item(item) for item in payload.itens]
valor_nf = soma_itens_quantizados(itens_lib)
valor_pagamento = payload.pagamento.valor.quantize(_CENT, rounding=ROUND_HALF_UP)
if valor_pagamento != valor_nf:
raise PagamentoTotalMismatchError(valor_pagamento, valor_nf)
# 3. aloca o número (lock, sem commit) -> cNF -> chave --------------
try:
numero = await allocate_fiscal_number(
@@ -371,7 +412,7 @@ async def emitir_documento(
dados = DadosEmissao(
emitente=_to_emitente(payload.emitente),
itens=[_to_item(item) for item in payload.itens],
itens=itens_lib,
pagamento=_to_pagamento(payload.pagamento),
ambiente=payload.ambiente,
chave_acesso=chave_acesso,
+40
View File
@@ -15,6 +15,7 @@ import importlib.resources
import json
import uuid
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from pathlib import Path
import pytest
@@ -475,6 +476,45 @@ async def test_dois_tenants_do_mesmo_produto_reusando_branch_ref_tem_certificado
assert emit_b.status_code == 201, emit_b.text
# --- FIX 3 (F2 review): Σ vPag vs vNF ---------------------------------------
@pytest.mark.asyncio
async def test_pagamento_divergente_do_total_dos_itens_e_409_e_nao_queima_numero(db_session):
"""A SEFAZ rejeita `pagamento.valor != vNF` com "Valor do Pagamento
difere do total" (`sowai_fiscal.xml_builder` module docstring) -- este
serviço promete validar "coerência estrutural (somas)" (spec decisão
#3) antes de queimar um número, não só deixar a rejeição acontecer do
lado de lá depois de já ter um `FiscalDocument` ASSINADO persistido."""
product, key = await _product_and_key(db_session)
payload, raw = _payload_from_golden("caso_padrao_intra")
valor_correto = Decimal(str(payload["pagamento"]["valor"]))
payload["pagamento"]["valor"] = str(valor_correto + Decimal("10.00"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
await _setup_certificate_and_series(
db_session, client, key, tenant_ref="t1", branch_ref="b1",
serie=raw["serie"], next_number=raw["numero"],
)
response = await client.post(
"/v1/emissoes", json=payload,
headers={**_headers(key), "Idempotency-Key": f"idem-{uuid.uuid4().hex}"},
)
assert response.status_code == 409, response.text
assert response.json()["detail"]["code"] == "pagamento_total_diverge"
result = await db_session.execute(
select(FiscalSeries).where(FiscalSeries.product_id == product.id, FiscalSeries.tenant_ref == "t1")
)
series = result.scalar_one()
assert series.next_number == raw["numero"], (
"o número NÃO pode ter sido queimado por um pagamento divergente do total dos itens"
)
# --- prova do outbox --------------------------------------------------------