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 raise conflict("fiscal_config_missing", str(exc)) from exc
except service.EmitenteCertificateCnpjMismatchError as exc: except service.EmitenteCertificateCnpjMismatchError as exc:
raise conflict("emitente_certificate_cnpj_mismatch", str(exc)) from 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: except service.FiscalDocumentConflictError as exc:
raise conflict("fiscal_document_conflict", str(exc)) from exc raise conflict("fiscal_document_conflict", str(exc)) from exc
response.status_code = status.HTTP_201_CREATED if created else status.HTTP_200_OK 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). 1. idempotency pre-check (`Idempotency-Key` -> existing document, if any).
2. completeness: live certificate for branch_ref (+ its CNPJ against 2. completeness: live certificate for branch_ref (+ its CNPJ against
`payload.emitente.cnpj`, F2 review FIX 1), existing series for `payload.emitente.cnpj`, F2 review FIX 1), existing series for
(document_model, serie) -- BOTH checked BEFORE touching (document_model, serie), `pagamento.valor` against the document total
`allocate_fiscal_number`, so a missing config/mismatch never wastes a (F2 review FIX 3) -- ALL checked BEFORE touching `allocate_fiscal_
número. number`, so a missing config/mismatch never wastes a número.
3. `allocate_fiscal_number` (lock, no commit) -> `gerar_cnf` -> chave. 3. `allocate_fiscal_number` (lock, no commit) -> `gerar_cnf` -> chave.
4. `build_nfe` (pure, `sowai_fiscal`) -> serialize -> assina. 4. `build_nfe` (pure, `sowai_fiscal`) -> serialize -> assina.
5. `FiscalDocument(ASSINADO)` + `FiscalIdempotencyKey` added to the SAME 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.""" two-layer pattern this implements."""
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from decimal import ROUND_HALF_UP, Decimal
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
@@ -71,10 +72,17 @@ from sowai_fiscal.xml_builder import (
ItemData, ItemData,
PagamentoData, PagamentoData,
build_nfe, build_nfe,
soma_itens_quantizados,
) )
_NFE_NAMESPACE = "http://www.portalfiscal.inf.br/nfe" _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 # 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 # 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 # 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") 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): class FiscalDocumentConflictError(Exception):
"""`chave_acesso` é UNIQUE GLOBAL (`uq_fiscal_documents_chave_acesso`, """`chave_acesso` é UNIQUE GLOBAL (`uq_fiscal_documents_chave_acesso`,
Task 3) -- uma colisão no INSERT final (cNF repetido por acaso para o 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): if _normalize_cnpj(payload.emitente.cnpj) != _normalize_cnpj(certificate.cnpj_certificado):
raise EmitenteCertificateCnpjMismatchError() 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 -------------- # 3. aloca o número (lock, sem commit) -> cNF -> chave --------------
try: try:
numero = await allocate_fiscal_number( numero = await allocate_fiscal_number(
@@ -371,7 +412,7 @@ async def emitir_documento(
dados = DadosEmissao( dados = DadosEmissao(
emitente=_to_emitente(payload.emitente), emitente=_to_emitente(payload.emitente),
itens=[_to_item(item) for item in payload.itens], itens=itens_lib,
pagamento=_to_pagamento(payload.pagamento), pagamento=_to_pagamento(payload.pagamento),
ambiente=payload.ambiente, ambiente=payload.ambiente,
chave_acesso=chave_acesso, chave_acesso=chave_acesso,
+40
View File
@@ -15,6 +15,7 @@ import importlib.resources
import json import json
import uuid import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from decimal import Decimal
from pathlib import Path from pathlib import Path
import pytest 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 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 -------------------------------------------------------- # --- prova do outbox --------------------------------------------------------