diff --git a/src/fiscal_svc/emission/router.py b/src/fiscal_svc/emission/router.py index c6b11b0..3617a3c 100644 --- a/src/fiscal_svc/emission/router.py +++ b/src/fiscal_svc/emission/router.py @@ -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 diff --git a/src/fiscal_svc/emission/service.py b/src/fiscal_svc/emission/service.py index c4d7e0a..d625ab2 100644 --- a/src/fiscal_svc/emission/service.py +++ b/src/fiscal_svc/emission/service.py @@ -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, diff --git a/tests/emission/test_emissao.py b/tests/emission/test_emissao.py index b76ec64..78df007 100644 --- a/tests/emission/test_emissao.py +++ b/tests/emission/test_emissao.py @@ -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 --------------------------------------------------------