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:
co-authored by
Claude Opus 4.8
parent
72bb089222
commit
0b64602716
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 --------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user