fix(emission): validate emitente CNPJ against branch certificate at emission
CRITICAL (F2 review, Opus+Fable): the service decoupled emitente (free-form payload.emitente.cnpj) from the certificate (looked up by branch_ref) with nothing re-checking the equality the auto had structurally via Branch.cnpj. A caller passing emitente.cnpj=B with a branch whose certificate CNPJ=A got a signed, persisted ASSINADO document with chave/emit=B but signature=A, having burned a nNF. Adds EmitenteCertificateCnpjMismatchError, checked in the completeness block (before allocate_fiscal_number, so a mismatch never consumes a número), normalizing both sides (digits only) before comparing. Router maps it to 409 emitente_certificate_cnpj_mismatch; message never leaks the certificate's CNPJ. Test: tests/emission/test_emissao.py:: test_emitente_cnpj_divergente_do_certificado_e_409_e_nao_queima_numero -- uploads a cert for CNPJ A, POSTs emissão with the same branch_ref but emitente.cnpj=B, asserts 409 + no fiscal number consumed (a follow-up emission with the matching CNPJ gets the number that would have been burned). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d9c1c444fb
commit
3e3a1abc6f
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user