diff --git a/src/fiscal_svc/emission/router.py b/src/fiscal_svc/emission/router.py index 17fb438..c6b11b0 100644 --- a/src/fiscal_svc/emission/router.py +++ b/src/fiscal_svc/emission/router.py @@ -32,6 +32,8 @@ async def emitir_documento_endpoint( document, created = await service.emitir_documento(session, product, payload, idempotency_key) except service.FiscalConfigMissingError as exc: 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.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 7d6456e..d139ddf 100644 --- a/src/fiscal_svc/emission/service.py +++ b/src/fiscal_svc/emission/service.py @@ -15,9 +15,11 @@ for it. ORDER (mirrors the auto's own emissao.py, same reason: `gerar_cnf(numero)` 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, existing series for + 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 never wastes a número. + `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 @@ -95,6 +97,23 @@ class FiscalConfigMissingError(Exception): super().__init__("Configuração fiscal ausente para emissão: " + ", ".join(missing)) +class EmitenteCertificateCnpjMismatchError(Exception): + """Fail-closed (spec decisão #4: o CNPJ é validado "contra o certificado + no upload e contra o emitente na emissão"). O upload garante `FiscalCertificate. + cnpj_certificado == FiscalCertificate.cnpj` (`certificates.service. + upload_certificate`'s `CertificateCnpjMismatchError`), mas NADA amarrava + o `payload.emitente.cnpj` (free-form, digitado pelo caller a cada + emissão) a ESSE certificado -- um caller podia declarar `branch_ref`=X + (cujo certificado é do CNPJ A) com `emitente.cnpj`=B e receber um + documento ASSINADO com chave/emit=B mas assinatura=A. Checado ANTES de + `allocate_fiscal_number` (mesma completude estrutural de `FiscalConfig + MissingError`), para o mismatch nunca queimar um número. A mensagem + NÃO leak o CNPJ do certificado -- só confirma que divergem.""" + + def __init__(self) -> None: + super().__init__("emitente CNPJ diverge do certificado da filial") + + 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 @@ -212,6 +231,13 @@ def _to_pagamento(payload: PagamentoDataPayload) -> PagamentoData: # que o auto documenta em `fiscal.emissao._get_live_certificate`) ---------- +def _normalize_cnpj(value: str) -> str: + """Só dígitos -- mesma normalização que `EmitenteCertificateCnpjMismatch + Error` promete no docstring, para o CNPJ do payload e o do certificado + nunca divergirem por formatação (pontuação/máscara) em vez de conteúdo.""" + return "".join(ch for ch in value if ch.isdigit()) + + async def _get_live_certificate( session: AsyncSession, product_id: uuid.UUID, branch_ref: str ) -> FiscalCertificate | None: @@ -292,6 +318,15 @@ async def emitir_documento( if missing: raise FiscalConfigMissingError(missing) + # `certificate` está garantidamente não-None e não-vencido aqui (senão + # `missing` teria disparado acima) -- o vínculo forte (spec decisão #4) + # é o CNPJ: o payload declara livremente `emitente.cnpj` a CADA + # emissão, então nada além desta checagem amarra essa declaração ao + # certificado realmente carregado para o branch_ref. ANTES de alocar + # número -- um mismatch nunca queima um nNF. + if _normalize_cnpj(payload.emitente.cnpj) != _normalize_cnpj(certificate.cnpj_certificado): + raise EmitenteCertificateCnpjMismatchError() + # 3. aloca o número (lock, sem commit) -> cNF -> chave -------------- try: numero = await allocate_fiscal_number( diff --git a/tests/emission/test_emissao.py b/tests/emission/test_emissao.py index 11022f0..e15d4d6 100644 --- a/tests/emission/test_emissao.py +++ b/tests/emission/test_emissao.py @@ -355,6 +355,61 @@ async def test_serie_ausente_e_409_fiscal_config_missing(db_session): assert response.json()["detail"]["code"] == "fiscal_config_missing" +# --- FIX 1 (F2 review): emitente.cnpj vs certificado do branch_ref ---------- + + +@pytest.mark.asyncio +async def test_emitente_cnpj_divergente_do_certificado_e_409_e_nao_queima_numero(db_session): + """Certificado do branch_ref carrega o CNPJ A; o payload declara + `emitente.cnpj`=B (outro CNPJ válido, 14 dígitos) -- a auto teria + barrado isso estruturalmente (emitente vem de `Branch.cnpj`, o MESMO + vínculo do certificado); este serviço, com `emitente` free-form no + payload, precisa da checagem explícita ou assina B com a chave de A.""" + product, key = await _product_and_key(db_session) + payload, raw = _payload_from_golden("caso_padrao_intra") + cnpj_divergente = "99887766000155" + assert cnpj_divergente != _CNPJ_EMITENTE + payload["emitente"] = {**payload["emitente"], "cnpj": cnpj_divergente} + + 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"], cnpj=_CNPJ_EMITENTE, + ) + + mismatch_response = await client.post( + "/v1/emissoes", json=payload, + headers={**_headers(key), "Idempotency-Key": f"idem-{uuid.uuid4().hex}"}, + ) + + assert mismatch_response.status_code == 409, mismatch_response.text + assert mismatch_response.json()["detail"]["code"] == "emitente_certificate_cnpj_mismatch" + # A mensagem NÃO deve vazar o CNPJ real do certificado. + assert _CNPJ_EMITENTE not in mismatch_response.text + + 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 emitente.cnpj divergente do certificado" + ) + + # O número que seria queimado acima segue disponível -- uma emissão + # com o CNPJ CORRETO recebe exatamente esse número. + matching_payload, _ = _payload_from_golden("caso_padrao_intra") + success_response = await client.post( + "/v1/emissoes", json=matching_payload, + headers={**_headers(key), "Idempotency-Key": f"idem-{uuid.uuid4().hex}"}, + ) + + assert success_response.status_code == 201, success_response.text + assert success_response.json()["numero"] == raw["numero"] + + # --- prova do outbox --------------------------------------------------------