Files
sowai-fiscal-svc/src/fiscal_svc/emission/service.py
T
2026-07-22 18:25:08 -03:00

431 lines
17 KiB
Python

"""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())