"""Task 5: emissão de NF-e (paridade 1b.1) -- ported from the auto's `app/modules/fiscal/emissao.py`, porte table applied throughout. The biggest structural change (design spec decision #3): this service receives a COMPLETE `EmissaoRequest` -- it never collects Sale/Branch/Person/Part rows, never calls `resolve_fiscal`, and validates only STRUCTURAL completeness (a certificate exists for the branch_ref, a series exists for the (document_model, serie)) rather than an entire domain's worth of cadastro fields. The outbox invariant survives verbatim: número allocation (`documents.service.allocate_fiscal_number`, no-commit contract) and the `FiscalDocument` + `FiscalIdempotencyKey` INSERTs happen in the SAME transaction, ONE commit -- any failure (including a sabotaged signature) rolls back all three together, never burning a number with nothing to show 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 (document_model, serie) -- BOTH checked BEFORE touching `allocate_fiscal_number`, so a missing config 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 session -> ONE commit. A UNIQUE-violation on the idempotency key here (the race: two concurrent requests, same key, both passed step 1 before either committed) rolls back and RE-READS the winner's row -- see `documents.models.FiscalIdempotencyKey`'s docstring for the full two-layer pattern this implements.""" import uuid from datetime import datetime, timezone from zoneinfo import ZoneInfo from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey from cryptography.x509 import Certificate from erpbrasil.assinatura.assinatura import Assinatura from lxml import etree from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from xsdata.formats.dataclass.serializers import XmlSerializer from xsdata.formats.dataclass.serializers.config import SerializerConfig from fiscal_svc.certificates import crypto as certificate_lib from fiscal_svc.documents.models import ( FiscalCertificate, FiscalDocument, FiscalDocumentStatus, FiscalIdempotencyKey, FiscalSeries, ) from fiscal_svc.documents.service import FiscalSeriesNotFoundError, allocate_fiscal_number from fiscal_svc.emission.schemas import ( DestinatarioDataPayload, EmissaoRequest, EmitenteDataPayload, FiscalResultPayload, ItemDataPayload, PagamentoDataPayload, TributoLinhaPayload, ) from fiscal_svc.tenancy.models import Product from sowai_fiscal.chave_acesso import gerar_cnf, montar_chave_acesso from sowai_fiscal.resolver import FiscalResult, TributoLinha from sowai_fiscal.xml_builder import ( DadosEmissao, DestinatarioData, EmitenteData, ItemData, PagamentoData, build_nfe, ) _NFE_NAMESPACE = "http://www.portalfiscal.inf.br/nfe" # 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 # observa mais horário de verão (desde 2019), então o offset é sempre # -03:00 para America/Sao_Paulo. _TZ_EMISSAO = ZoneInfo("America/Sao_Paulo") class FiscalConfigMissingError(Exception): """Fail-closed: campos estruturais ausentes para EMITIR (certificado/ série) -- router mapeia para 409 `fiscal_config_missing`, nomeando os campos. Ao contrário do auto (que também validava dezenas de campos de cadastro de Branch/Person/Part), este serviço só valida o que É DELE: o `EmissaoRequest` inteiro já passou pela borda pydantic (campos obrigatórios/tipos), e o `FiscalResult` por item já vem RESOLVIDO (design spec decisão #3) -- não há cadastro para revalidar aqui.""" def __init__(self, missing: list[str]): self.missing = missing super().__init__("Configuração fiscal ausente para emissão: " + ", ".join(missing)) 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 mesmo nNF, ou qualquer outra causa) estouraria `IntegrityError` direto do driver; traduzido aqui em 409 `fiscal_document_conflict`. O `rollback()` que acompanha desfaz a alocação do número junto (mesmo outbox de qualquer outra falha antes do commit) -- a colisão nunca queima um número.""" def __init__(self, chave_acesso: str): self.chave_acesso = chave_acesso super().__init__( f"Colisão de chave de acesso ({chave_acesso}) ao gravar o documento fiscal — tente novamente" ) def _is_chave_acesso_constraint_violation(exc: IntegrityError) -> bool: detail = str(getattr(exc.orig, "args", [""])[0]) if exc.orig else str(exc) return "uq_fiscal_documents_chave_acesso" in detail.lower() def _is_idempotency_key_constraint_violation(exc: IntegrityError) -> bool: detail = str(getattr(exc.orig, "args", [""])[0]) if exc.orig else str(exc) return "uq_fiscal_idempotency_key_product_key" in detail.lower() class _SignerCertificado: """Shim mínimo para `erpbrasil.assinatura.Assinatura`, que espera um objeto `certificado` com atributos `.key`/`._cert`/`._chave`/`._senha` (a forma de `erpbrasil.assinatura.certificado.Certificado`). Ported verbatim from the auto's `fiscal.emissao._SignerCertificado`.""" def __init__(self, private_key: RSAPrivateKey, cert: Certificate): self.key = private_key self.cert = cert self._cert = cert.public_bytes(encoding=serialization.Encoding.PEM) self._chave = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption(), ) self._senha = b"" def _serialize_nfe(nfe) -> str: config = SerializerConfig(xml_declaration=False, indent=None) return XmlSerializer(config=config).render(nfe, ns_map={None: _NFE_NAMESPACE}) def sign_nfe_xml(xml_str: str, chave_acesso: str, private_key: RSAPrivateKey, cert: Certificate) -> str: """Assina o XML (enveloped, `erpbrasil.assinatura`/xmlsec) referenciando `infNFe` pelo seu `Id` (`"NFe" + chave_acesso`). Função de módulo (não inline em `emitir_documento`) DE PROPÓSITO -- é o ponto exato que o teste-prova do outbox (`tests/emission/test_emissao.py`) monkeypatcha para forçar uma falha DEPOIS da alocação do número e ANTES do commit.""" assinatura = Assinatura(_SignerCertificado(private_key, cert)) root = etree.fromstring(xml_str.encode("utf-8")) signed = assinatura.assina_xml2(root, reference="NFe" + chave_acesso) return signed.decode("utf-8") if isinstance(signed, bytes) else signed # --- EmissaoRequest -> dataclasses da lib (sowai_fiscal.xml_builder) -------- def _to_tributo(payload: TributoLinhaPayload) -> TributoLinha: return TributoLinha(**payload.model_dump()) def _to_fiscal_result(payload: FiscalResultPayload) -> FiscalResult: return FiscalResult( cfop=payload.cfop, cst=payload.cst, csosn=payload.csosn, origem=payload.origem, consumidor_final=payload.consumidor_final, indicador_ie=payload.indicador_ie, tributos=[_to_tributo(t) for t in payload.tributos], ) def _to_item(payload: ItemDataPayload) -> ItemData: return ItemData( codigo=payload.codigo, descricao=payload.descricao, ncm=payload.ncm, cfop=payload.cfop, unidade_comercial=payload.unidade_comercial, unidade_tributavel=payload.unidade_tributavel, quantidade=payload.quantidade, valor_unitario=payload.valor_unitario, fiscal_result=_to_fiscal_result(payload.fiscal_result), gtin=payload.gtin, cest=payload.cest, peso_liquido_kg=payload.peso_liquido_kg, peso_bruto_kg=payload.peso_bruto_kg, ) def _to_emitente(payload: EmitenteDataPayload) -> EmitenteData: return EmitenteData(**payload.model_dump()) def _to_destinatario(payload: DestinatarioDataPayload | None) -> DestinatarioData | None: if payload is None: return None return DestinatarioData(**payload.model_dump()) def _to_pagamento(payload: PagamentoDataPayload) -> PagamentoData: return PagamentoData(**payload.model_dump()) # --- lookups internos (duplicados, não importados de certificates.service -- # mesmo racional "não vale o acoplamento por uma SELECT de poucas linhas" # que o auto documenta em `fiscal.emissao._get_live_certificate`) ---------- async def _get_live_certificate( session: AsyncSession, product_id: uuid.UUID, branch_ref: str ) -> FiscalCertificate | None: result = await session.execute( select(FiscalCertificate).where( FiscalCertificate.product_id == product_id, FiscalCertificate.branch_ref == branch_ref, FiscalCertificate.deleted_at.is_(None), ) ) return result.scalar_one_or_none() async def _get_series( session: AsyncSession, product_id: uuid.UUID, tenant_ref: str, branch_ref: str, document_model: str, serie: int, ) -> FiscalSeries | None: result = await session.execute( select(FiscalSeries).where( FiscalSeries.product_id == product_id, FiscalSeries.tenant_ref == tenant_ref, FiscalSeries.branch_ref == branch_ref, FiscalSeries.document_model == document_model, FiscalSeries.serie == serie, FiscalSeries.deleted_at.is_(None), ) ) return result.scalar_one_or_none() async def _get_document_by_idempotency_key( session: AsyncSession, product_id: uuid.UUID, idempotency_key: str ) -> FiscalDocument | None: result = await session.execute( select(FiscalDocument) .join(FiscalIdempotencyKey, FiscalIdempotencyKey.document_id == FiscalDocument.id) .where( FiscalIdempotencyKey.product_id == product_id, FiscalIdempotencyKey.idempotency_key == idempotency_key, ) ) return result.scalar_one_or_none() async def emitir_documento( session: AsyncSession, product: Product, payload: EmissaoRequest, idempotency_key: str, ) -> tuple[FiscalDocument, bool]: """Devolve `(document, created)` -- `created=False` quando `idempotency_ key` já resolvia para um documento existente (pré-checagem OU corrida resolvida via re-leitura), para o router escolher 200 vs 201.""" # 1. idempotência -- pré-checagem (camada 1 do padrão de duas camadas). existing = await _get_document_by_idempotency_key(session, product.id, idempotency_key) if existing is not None: return existing, False # 2. completude estrutural -- ANTES de alocar número. missing: list[str] = [] certificate = await _get_live_certificate(session, product.id, payload.branch_ref) if certificate is None: missing.append("certificado A1 do branch_ref") elif certificate.not_valid_after < datetime.now(timezone.utc): missing.append("certificado A1 vencido") series = await _get_series( session, product.id, payload.tenant_ref, payload.branch_ref, payload.document_model, payload.serie ) if series is None: missing.append(f"série fiscal modelo {payload.document_model} série {payload.serie}") if missing: raise FiscalConfigMissingError(missing) # 3. aloca o número (lock, sem commit) -> cNF -> chave -------------- try: numero = await allocate_fiscal_number( session, product.id, payload.tenant_ref, payload.branch_ref, payload.document_model, payload.serie, ) except FiscalSeriesNotFoundError as exc: # Corrida rara: a série existia no pré-check acima e sumiu (soft- # delete concorrente) antes do SELECT ... FOR UPDATE de allocate. # Mesma família de erro que a ausência original -- 409 fiscal_ # config_missing, não um 500. raise FiscalConfigMissingError( [f"série fiscal modelo {payload.document_model} série {payload.serie}"] ) from exc cnf = gerar_cnf(numero) # UM `now()` só, em horário de Brasília -- o AAMM da chave e o dhEmi # têm que vir do MESMO instante/fuso, senão divergem na virada de mês. agora_brasil = datetime.now(_TZ_EMISSAO) chave_acesso = montar_chave_acesso( uf_ibge=payload.emitente.address_city_ibge_code[:2], aamm=agora_brasil.strftime("%y%m"), cnpj=payload.emitente.cnpj, modelo=payload.document_model, serie=payload.serie, numero=numero, tp_emis=payload.tp_emis, cnf=cnf, ) dados = DadosEmissao( emitente=_to_emitente(payload.emitente), itens=[_to_item(item) for item in payload.itens], pagamento=_to_pagamento(payload.pagamento), ambiente=payload.ambiente, chave_acesso=chave_acesso, numero=numero, serie=payload.serie, cnf=cnf, # `timespec="seconds"` -- o XSD não aceita fração de segundo. dh_emi=agora_brasil.isoformat(timespec="seconds"), uf_destino_tipo=payload.uf_destino_tipo, destinatario=_to_destinatario(payload.destinatario), nat_op=payload.nat_op, tp_emis=payload.tp_emis, ind_final=payload.ind_final, ind_pres=payload.ind_pres, fin_nfe=payload.fin_nfe, ver_proc=payload.ver_proc, ) # 4. monta + assina --------------------------------------------------- nfe = build_nfe(dados) xml_str = _serialize_nfe(nfe) private_key, cert = certificate_lib.load_private_key_and_cert(certificate) xml_assinado = sign_nfe_xml(xml_str, chave_acesso, private_key, cert) # 5. persiste -- MESMO commit da alocação acima + a chave de idempotência document = FiscalDocument( product_id=product.id, tenant_ref=payload.tenant_ref, branch_ref=payload.branch_ref, series_id=series.id, document_model=payload.document_model, serie=payload.serie, numero=numero, chave_acesso=chave_acesso, codigo_numerico=cnf, status=FiscalDocumentStatus.ASSINADO.value, ambiente=payload.ambiente, xml_assinado=xml_assinado, ) session.add(document) await session.flush() # popula document.id para o FK abaixo idem_row = FiscalIdempotencyKey( product_id=product.id, idempotency_key=idempotency_key, document_id=document.id ) session.add(idem_row) try: await session.commit() except IntegrityError as exc: await session.rollback() if _is_idempotency_key_constraint_violation(exc): # Camada 2 do padrão: um concorrente com a MESMA idempotency_key # venceu a corrida entre a pré-checagem (passo 1) e este commit # -- re-lê o vencedor e converge, em vez de expor a corrida como # erro. O rollback acima já desfez a alocação do número E o # INSERT do documento deste caller (outbox intacto). winner = await _get_document_by_idempotency_key(session, product.id, idempotency_key) if winner is not None: return winner, False raise if _is_chave_acesso_constraint_violation(exc): raise FiscalDocumentConflictError(chave_acesso) from exc raise await session.refresh(document) return document, True async def get_fiscal_document( session: AsyncSession, product_id: uuid.UUID, document_id: uuid.UUID ) -> FiscalDocument | None: result = await session.execute( select(FiscalDocument).where( FiscalDocument.id == document_id, FiscalDocument.product_id == product_id, FiscalDocument.deleted_at.is_(None), ) ) return result.scalar_one_or_none() async def list_fiscal_documents( session: AsyncSession, product_id: uuid.UUID, *, tenant_ref: str | None = None, branch_ref: str | None = None, status_: str | None = None, limit: int = 50, offset: int = 0, ) -> list[FiscalDocument]: query = select(FiscalDocument).where( FiscalDocument.product_id == product_id, FiscalDocument.deleted_at.is_(None) ) if tenant_ref is not None: query = query.where(FiscalDocument.tenant_ref == tenant_ref) if branch_ref is not None: query = query.where(FiscalDocument.branch_ref == branch_ref) if status_ is not None: query = query.where(FiscalDocument.status == status_) query = query.order_by(FiscalDocument.created_at.desc()).limit(limit).offset(offset) result = await session.execute(query) return list(result.scalars().all())