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,