feat: certificates + series API v1 (Task 4)
Port certificate lifecycle (parse/encrypt/upload/deactivate) and FiscalSeries CRUD from the auto, adapted to (product_id, tenant_ref, branch_ref) tenancy. Closes the "guard retroativo" PATCH /v1/series next_number regression check that Task 3 deferred to this task. Routes: POST/GET/DELETE /v1/certificados, POST/GET/PATCH /v1/series.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
"""A1 certificate (.pfx) lifecycle: parse + validate metadata, encrypt/
|
||||
decrypt the binary for storage (Fernet), and decrypt-in-memory for signing
|
||||
(consumed by `emission.service`, Task 5). Ported VERBATIM (mechanism +
|
||||
docstrings) from the auto's `app/modules/fiscal/certificate.py` -- no
|
||||
tenancy-shaped adaptation needed, this module never touches
|
||||
product_id/tenant_ref/branch_ref, only the PFX bytes themselves.
|
||||
|
||||
`FISCAL_CERT_ENCRYPTION_KEY` is this SERVICE's OWN env var (Global
|
||||
Constraints/porte table: "secrets never live in Settings", same convention
|
||||
as the auto) -- deliberately a DIFFERENT key/secret than the auto's own
|
||||
`FISCAL_CERT_ENCRYPTION_KEY` (they are two different Kubernetes Secrets in
|
||||
two different namespaces/deployments; the NAME collides on purpose, mirroring
|
||||
the auto's env var name 1:1, but the VALUE never does -- ties the blast
|
||||
radius of a compromised key to exactly one service's certificates)."""
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
from cryptography.x509 import Certificate
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
CURRENT_ENCRYPTION_KEY_ID = "fiscal-svc-cert-fernet-v1-env"
|
||||
|
||||
|
||||
class FiscalCertEncryptionKeyNotConfiguredError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidPfxError(Exception):
|
||||
"""O arquivo não é um PKCS#12 (.pfx/.p12) válido -- corrompido ou de
|
||||
outro formato inteiro. Distinto de `WrongPasswordError` (ver a
|
||||
docstring de `parse_pfx` para como -- e o quão bem -- essa distinção é
|
||||
feita)."""
|
||||
|
||||
|
||||
class WrongPasswordError(Exception):
|
||||
"""A senha informada não abre o PFX. `cryptography` não distingue com
|
||||
perfeição "senha errada" de "PFX corrompido de um jeito que só se
|
||||
manifesta na fase de decriptação" -- ver a docstring de `parse_pfx`."""
|
||||
|
||||
|
||||
class CertificateExpiredError(Exception):
|
||||
"""O certificado já passou do `not_valid_after` -- fail-closed: emitir
|
||||
com um A1 vencido é rejeitado pela própria SEFAZ, então isto é
|
||||
detectado no UPLOAD, não só na emissão."""
|
||||
|
||||
def __init__(self, not_valid_after: datetime):
|
||||
self.not_valid_after = not_valid_after
|
||||
super().__init__(f"Certificado vencido em {not_valid_after.isoformat()}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CertInfo:
|
||||
subject_cn: str
|
||||
cnpj: str
|
||||
not_valid_before: datetime
|
||||
not_valid_after: datetime
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_fernet() -> Fernet:
|
||||
"""A ausência de `FISCAL_CERT_ENCRYPTION_KEY` é um erro de DEPLOY (o
|
||||
Secret do k8s não foi provisionado/montado), detectado aqui no PRIMEIRO
|
||||
uso -- o primeiro upload ou emissão que precisar cifrar/decifrar um
|
||||
certificado, não antes (mesma convenção do módulo que este porta)."""
|
||||
key = os.environ.get("FISCAL_CERT_ENCRYPTION_KEY")
|
||||
if not key:
|
||||
raise FiscalCertEncryptionKeyNotConfiguredError(
|
||||
"FISCAL_CERT_ENCRYPTION_KEY não configurada. Em produção vem de um "
|
||||
"secrets manager (Vault) injetada como variável de ambiente -- nunca "
|
||||
"de um arquivo .env versionado."
|
||||
)
|
||||
return Fernet(key.encode("utf-8"))
|
||||
|
||||
|
||||
def encrypt_bytes(raw: bytes) -> bytes:
|
||||
return _get_fernet().encrypt(raw)
|
||||
|
||||
|
||||
def decrypt_bytes(token: bytes) -> bytes:
|
||||
return _get_fernet().decrypt(token)
|
||||
|
||||
|
||||
def _extract_cnpj(certificate: Certificate) -> str:
|
||||
"""CNPJ do certificado A1 e-CNPJ (ICP-Brasil, DOC-ICP-04): o padrão de
|
||||
mercado é `CN=RAZAO SOCIAL:CNPJ` e um atributo `SERIALNUMBER` (OID
|
||||
2.5.4.5) carregando o CNPJ puro. Tenta o `SERIALNUMBER` primeiro; cai
|
||||
para o sufixo do `CN` depois de `:` quando o `SERIALNUMBER` não vem ou
|
||||
não tem 14 dígitos (algumas ACs variam)."""
|
||||
serial_attrs = certificate.subject.get_attributes_for_oid(NameOID.SERIAL_NUMBER)
|
||||
if serial_attrs:
|
||||
digits = "".join(c for c in serial_attrs[0].value if c.isdigit())
|
||||
if len(digits) == 14:
|
||||
return digits
|
||||
|
||||
cn_attrs = certificate.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
|
||||
if cn_attrs and ":" in cn_attrs[0].value:
|
||||
tail = cn_attrs[0].value.rsplit(":", 1)[-1]
|
||||
digits = "".join(c for c in tail if c.isdigit())
|
||||
if len(digits) == 14:
|
||||
return digits
|
||||
|
||||
raise InvalidPfxError(
|
||||
"Não foi possível extrair um CNPJ (14 dígitos) do subject do certificado"
|
||||
)
|
||||
|
||||
|
||||
def parse_pfx(pfx_bytes: bytes, password: str) -> CertInfo:
|
||||
"""Abre o .pfx com a senha informada e extrai os metadados.
|
||||
|
||||
`cryptography.hazmat.primitives.serialization.pkcs12.
|
||||
load_key_and_certificates` levanta `ValueError` tanto para "não é um
|
||||
PKCS#12 válido" quanto para "senha errada" -- mensagens diferentes,
|
||||
confirmadas empiricamente (heurística, não contrato): dado malformado
|
||||
-> "Could not deserialize PKCS12 data"; senha errada sobre um PKCS#12
|
||||
estruturalmente válido -> "Invalid password or PKCS12 data". Ambas
|
||||
viram 422 no router de qualquer forma -- a distinção importa só para a
|
||||
MENSAGEM ao usuário."""
|
||||
try:
|
||||
private_key, certificate, _ca_certs = pkcs12.load_key_and_certificates(
|
||||
pfx_bytes, password.encode("utf-8")
|
||||
)
|
||||
except ValueError as exc:
|
||||
if "deserialize" in str(exc).lower():
|
||||
raise InvalidPfxError(f"Arquivo não é um PKCS#12 (.pfx) válido: {exc}") from exc
|
||||
raise WrongPasswordError("Senha do certificado incorreta") from exc
|
||||
|
||||
if certificate is None or private_key is None:
|
||||
raise InvalidPfxError("PFX não contém certificado e/ou chave privada")
|
||||
|
||||
cn_attrs = certificate.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
|
||||
subject_cn = cn_attrs[0].value if cn_attrs else certificate.subject.rfc4514_string()
|
||||
cnpj = _extract_cnpj(certificate)
|
||||
|
||||
return CertInfo(
|
||||
subject_cn=subject_cn,
|
||||
cnpj=cnpj,
|
||||
not_valid_before=certificate.not_valid_before_utc,
|
||||
not_valid_after=certificate.not_valid_after_utc,
|
||||
)
|
||||
|
||||
|
||||
def load_private_key_and_cert(certificate) -> tuple[RSAPrivateKey, Certificate]:
|
||||
"""Decifra o PFX/senha de um `FiscalCertificate` row EM MEMÓRIA e
|
||||
devolve `(private_key, certificate)` prontos para assinatura
|
||||
(`erpbrasil.assinatura`, consumido por `emission.service`, Task 5).
|
||||
Nunca grava nada em disco/tmp; o retorno vive só na pilha do
|
||||
chamador."""
|
||||
pfx_bytes = decrypt_bytes(certificate.pfx_encrypted)
|
||||
password = decrypt_bytes(certificate.password_encrypted).decode("utf-8")
|
||||
private_key, cert, _ca_certs = pkcs12.load_key_and_certificates(
|
||||
pfx_bytes, password.encode("utf-8")
|
||||
)
|
||||
return private_key, cert
|
||||
@@ -0,0 +1,111 @@
|
||||
"""`POST/GET/DELETE /v1/certificados` -- Task 4. `tenant_ref`/`branch_ref`
|
||||
travel as QUERY params on all three verbs (consistent shape across POST/GET/
|
||||
DELETE, since POST's body is multipart -- file + form fields -- and cannot
|
||||
also carry a JSON body); `cnpj`/`password` are `Form(...)` fields alongside
|
||||
the file, same multipart shape as the auto's own certificate upload."""
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fiscal_svc.certificates import crypto as certificate_lib
|
||||
from fiscal_svc.certificates import service
|
||||
from fiscal_svc.certificates.schemas import FiscalCertificateRead
|
||||
from fiscal_svc.core.db import get_session
|
||||
from fiscal_svc.shared.errors import conflict
|
||||
from fiscal_svc.tenancy.deps import require_product
|
||||
from fiscal_svc.tenancy.models import Product
|
||||
|
||||
router = APIRouter(prefix="/v1/certificados", tags=["certificados"])
|
||||
|
||||
# Teto de tamanho do upload do .pfx -- mesmo valor/racional do auto
|
||||
# (`fiscal.router._MAX_PFX_UPLOAD_BYTES`): um A1 típico fica na casa de
|
||||
# poucos KB; 256 KiB é generoso o bastante sem deixar o endpoint aceitar um
|
||||
# upload arbitrariamente grande.
|
||||
_MAX_PFX_UPLOAD_BYTES = 256 * 1024
|
||||
|
||||
|
||||
async def _read_upload_capped(file: UploadFile, max_bytes: int) -> bytes:
|
||||
chunk_size = 64 * 1024
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = await file.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
||||
detail=f"Certificado excede o tamanho máximo permitido ({max_bytes} bytes)",
|
||||
)
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
@router.post("", response_model=FiscalCertificateRead, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_certificate_endpoint(
|
||||
tenant_ref: str = Query(..., max_length=64),
|
||||
branch_ref: str = Query(..., max_length=64),
|
||||
cnpj: str = Form(..., min_length=14, max_length=14),
|
||||
file: UploadFile = File(...),
|
||||
password: str = Form(...),
|
||||
product: Product = Depends(require_product),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> FiscalCertificateRead:
|
||||
pfx_bytes = await _read_upload_capped(file, _MAX_PFX_UPLOAD_BYTES)
|
||||
try:
|
||||
certificate = await service.upload_certificate(
|
||||
session, product.id, tenant_ref, branch_ref, cnpj, pfx_bytes, password
|
||||
)
|
||||
except (certificate_lib.InvalidPfxError, certificate_lib.WrongPasswordError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except service.CertificateCnpjMismatchError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except certificate_lib.CertificateExpiredError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except service.CertificateNotYetValidError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except service.CertificateUploadConflictError as exc:
|
||||
raise conflict("certificate_upload_conflict", str(exc)) from exc
|
||||
return FiscalCertificateRead.model_validate(certificate)
|
||||
|
||||
|
||||
@router.get("", response_model=FiscalCertificateRead)
|
||||
async def read_certificate_endpoint(
|
||||
tenant_ref: str = Query(..., max_length=64),
|
||||
branch_ref: str = Query(..., max_length=64),
|
||||
product: Product = Depends(require_product),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> FiscalCertificateRead:
|
||||
"""Metadados do certificado VIVO -- NUNCA o binário (o .pfx/senha
|
||||
cifrados nunca saem do banco por esta rota; só `certificates.crypto.
|
||||
load_private_key_and_cert`, uso interno da emissão, Task 5, os
|
||||
descriptografa, e só em memória)."""
|
||||
certificate = await service.get_certificate(session, product.id, tenant_ref, branch_ref)
|
||||
if certificate is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Certificado não encontrado"
|
||||
)
|
||||
return FiscalCertificateRead.model_validate(certificate)
|
||||
|
||||
|
||||
@router.delete("", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def deactivate_certificate_endpoint(
|
||||
tenant_ref: str = Query(..., max_length=64),
|
||||
branch_ref: str = Query(..., max_length=64),
|
||||
product: Product = Depends(require_product),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
try:
|
||||
await service.deactivate_certificate(session, product.id, tenant_ref, branch_ref)
|
||||
except service.CertificateNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Certificado não encontrado"
|
||||
) from exc
|
||||
@@ -0,0 +1,23 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class FiscalCertificateRead(BaseModel):
|
||||
"""Metadados do A1 -- NUNCA o binário (`pfx_encrypted`/
|
||||
`password_encrypted` ficam de fora de propósito; ver
|
||||
`certificates.router.read_certificate_endpoint`'s docstring)."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
product_id: uuid.UUID
|
||||
tenant_ref: str
|
||||
branch_ref: str
|
||||
cnpj: str
|
||||
subject_cn: str
|
||||
cnpj_certificado: str
|
||||
not_valid_before: datetime
|
||||
not_valid_after: datetime
|
||||
created_at: datetime
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Task 4: `FiscalCertificate` lifecycle -- ported from the auto's
|
||||
`app/modules/fiscal/service.py` (the certificate half only; TaxRule/preset/
|
||||
simulate stay in the product per design spec decision #3, "o motor de
|
||||
regras FICA no produto"). Porte table applied: `organization_id`/
|
||||
`branch_id` (a real FK, validated via `tenants_service.get_branch`) become
|
||||
`product_id`/`branch_ref` (an OPAQUE string this service never resolves
|
||||
against a Branch row -- there is no Branch row here). Consequently every
|
||||
`CertificateBranchNotFoundError` check from the auto is GONE: there is no
|
||||
branch existence to 404 on, only a `product_id`/`tenant_ref`/`branch_ref`
|
||||
scope the caller declares.
|
||||
|
||||
The "vínculo forte" (design spec decision #4) that used to be `Branch.cnpj`
|
||||
is now the `cnpj` FIELD ON THE UPLOAD REQUEST ITSELF (the router's
|
||||
`FiscalCertificateUpload.cnpj`, Task 4) -- the caller (the product) declares
|
||||
which CNPJ this branch_ref is FOR, and this service validates the
|
||||
certificate's own CNPJ against THAT declaration, never against a row it
|
||||
owns."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fiscal_svc.certificates import crypto as certificate_lib
|
||||
from fiscal_svc.documents.models import FiscalCertificate
|
||||
|
||||
|
||||
class CertificateNotFoundError(Exception):
|
||||
"""No `(product_id, tenant_ref, branch_ref)` scope has a live
|
||||
`FiscalCertificate` -- raised both when none was ever uploaded and when
|
||||
a previous one was soft-deleted, same "revoked = gone" semantics as the
|
||||
rest of this service."""
|
||||
|
||||
def __init__(self, product_id: uuid.UUID, branch_ref: str):
|
||||
self.product_id = product_id
|
||||
self.branch_ref = branch_ref
|
||||
super().__init__(
|
||||
f"Nenhum certificado vivo para product_id={product_id}, branch_ref={branch_ref!r}"
|
||||
)
|
||||
|
||||
|
||||
class CertificateCnpjMismatchError(Exception):
|
||||
"""O CNPJ extraído do certificado não bate com o CNPJ declarado no
|
||||
payload do upload -- um A1 é emitido para UM CNPJ específico (design
|
||||
spec decisão #4: o CNPJ é o vínculo forte); um certificado de outro
|
||||
CNPJ nunca pode ser aceito, mesmo que a senha esteja correta e o
|
||||
arquivo seja um PFX íntegro."""
|
||||
|
||||
def __init__(self, payload_cnpj: str, cert_cnpj: str):
|
||||
self.payload_cnpj = payload_cnpj
|
||||
self.cert_cnpj = cert_cnpj
|
||||
super().__init__(
|
||||
f"CNPJ do certificado ({cert_cnpj}) diverge do CNPJ informado ({payload_cnpj})"
|
||||
)
|
||||
|
||||
|
||||
class CertificateNotYetValidError(Exception):
|
||||
"""O `not_valid_before` do certificado ainda não chegou -- um A1 emitido
|
||||
com validade FUTURA (ou lido sob um relógio de cliente adiantado) não
|
||||
está "ainda válido" agora, e a SEFAZ rejeita a assinatura de um
|
||||
certificado fora da janela de validade em QUALQUER direção, não só
|
||||
vencido. Mesma camada fail-closed de `CertificateExpiredError` (checado
|
||||
no UPLOAD, não só na emissão)."""
|
||||
|
||||
def __init__(self, not_valid_before: datetime):
|
||||
self.not_valid_before = not_valid_before
|
||||
super().__init__(
|
||||
f"Certificado ainda não é válido -- válido a partir de "
|
||||
f"{not_valid_before.isoformat()}"
|
||||
)
|
||||
|
||||
|
||||
class CertificateUploadConflictError(Exception):
|
||||
"""Traduz o `IntegrityError` da violação do índice parcial único
|
||||
`ix_fiscal_certificates_product_branch_live` (`(product_id, branch_ref)
|
||||
WHERE deleted_at IS NULL`, Task 3's `FiscalCertificate.__table_args__`)
|
||||
-- disparado quando DUAS chamadas genuinamente concorrentes de
|
||||
`upload_certificate` para o MESMO `(product_id, branch_ref)` ambas leem
|
||||
`_get_live_certificate_by_branch() -> None` antes de qualquer uma
|
||||
commitar. A PRIMEIRA a commitar vence; a segunda recebe este erro (409
|
||||
`certificate_upload_conflict` no router) em vez de silenciosamente
|
||||
criar um segundo certificado vivo."""
|
||||
|
||||
def __init__(self, product_id: uuid.UUID, branch_ref: str):
|
||||
self.product_id = product_id
|
||||
self.branch_ref = branch_ref
|
||||
super().__init__(
|
||||
f"Upload de certificado concorrente para product_id={product_id}, "
|
||||
f"branch_ref={branch_ref!r} -- outro upload venceu a corrida; tente novamente"
|
||||
)
|
||||
|
||||
|
||||
def _is_fiscal_certificate_branch_live_violation(exc: IntegrityError) -> bool:
|
||||
"""True iff `exc` violates `ix_fiscal_certificates_product_branch_live`
|
||||
-- substring match on the index name, which Task 3's migration names
|
||||
explicitly (unlike the auto's un-named-in-create_all equivalent, this
|
||||
one is identical across `Base.metadata.create_all` and Alembic, so a
|
||||
plain substring check suffices, no "unique constraint" reinforcement
|
||||
needed)."""
|
||||
detail = str(getattr(exc.orig, "args", [""])[0]) if exc.orig else str(exc)
|
||||
return "ix_fiscal_certificates_product_branch_live" in detail
|
||||
|
||||
|
||||
async def _get_live_certificate_by_branch(
|
||||
session: AsyncSession, product_id: uuid.UUID, branch_ref: str
|
||||
) -> FiscalCertificate | None:
|
||||
"""Scoped to `(product_id, branch_ref)` ONLY -- matches exactly the
|
||||
scope of the partial unique index this function's callers (`upload_
|
||||
certificate`) need to pre-check against. NOT the public lookup for GET/
|
||||
DELETE (see `get_certificate`/`deactivate_certificate` below, which
|
||||
additionally scope by `tenant_ref` for the anti-oracle boundary, design
|
||||
spec decision #4: "anti-oracle 404 por (product_id, tenant_ref)")."""
|
||||
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_live_certificate(
|
||||
session: AsyncSession, product_id: uuid.UUID, tenant_ref: str, branch_ref: str
|
||||
) -> FiscalCertificate | None:
|
||||
"""Public lookup, scoped to `(product_id, tenant_ref, branch_ref)` --
|
||||
the anti-oracle boundary (design spec decision #4). Used by GET/DELETE
|
||||
AND by `emission.service` (Task 5, which duplicates this as a small
|
||||
private SELECT rather than importing it -- same "not worth the cross-
|
||||
module coupling for a few-line query" reasoning the auto's own
|
||||
`fiscal.emissao._get_live_certificate` docstring gives for not reusing
|
||||
`fiscal.service._get_live_certificate` directly)."""
|
||||
result = await session.execute(
|
||||
select(FiscalCertificate).where(
|
||||
FiscalCertificate.product_id == product_id,
|
||||
FiscalCertificate.tenant_ref == tenant_ref,
|
||||
FiscalCertificate.branch_ref == branch_ref,
|
||||
FiscalCertificate.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def upload_certificate(
|
||||
session: AsyncSession,
|
||||
product_id: uuid.UUID,
|
||||
tenant_ref: str,
|
||||
branch_ref: str,
|
||||
cnpj: str,
|
||||
pfx_bytes: bytes,
|
||||
password: str,
|
||||
) -> FiscalCertificate:
|
||||
"""Valida e armazena um A1 para `(product_id, tenant_ref, branch_ref)`.
|
||||
Fail-closed, na ordem: PFX abre com a senha (`certificate_lib.parse_pfx`
|
||||
-- `InvalidPfxError`/`WrongPasswordError`) -> CNPJ do certificado ==
|
||||
`cnpj` declarado no payload (`CertificateCnpjMismatchError`) -> dentro
|
||||
da janela de validade, nas DUAS direções: não vencido
|
||||
(`CertificateExpiredError`) e já vigente (`CertificateNotYetValidError`).
|
||||
Só depois de TODAS as checagens passarem é que qualquer escrita
|
||||
acontece: o certificado anterior (se houver, para o MESMO
|
||||
`(product_id, branch_ref)`) é soft-deletado e o novo é inserido -- um
|
||||
único VIVO por `(product_id, branch_ref)`, nunca dois, nunca um
|
||||
update-in-place.
|
||||
|
||||
O INSERT final é protegido pelo índice parcial único (Task 3) contra a
|
||||
corrida de dois uploads genuinamente concorrentes -- o `commit()` do
|
||||
PERDEDOR levanta `IntegrityError`, capturado aqui e traduzido em
|
||||
`CertificateUploadConflictError` (409 no router)."""
|
||||
info = certificate_lib.parse_pfx(pfx_bytes, password)
|
||||
|
||||
if cnpj != info.cnpj:
|
||||
raise CertificateCnpjMismatchError(cnpj, info.cnpj)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
if info.not_valid_after < now:
|
||||
raise certificate_lib.CertificateExpiredError(info.not_valid_after)
|
||||
if info.not_valid_before > now:
|
||||
raise CertificateNotYetValidError(info.not_valid_before)
|
||||
|
||||
previous = await _get_live_certificate_by_branch(session, product_id, branch_ref)
|
||||
if previous is not None:
|
||||
previous.deleted_at = now
|
||||
|
||||
certificate = FiscalCertificate(
|
||||
product_id=product_id,
|
||||
tenant_ref=tenant_ref,
|
||||
branch_ref=branch_ref,
|
||||
cnpj=cnpj,
|
||||
pfx_encrypted=certificate_lib.encrypt_bytes(pfx_bytes),
|
||||
password_encrypted=certificate_lib.encrypt_bytes(password.encode("utf-8")),
|
||||
subject_cn=info.subject_cn,
|
||||
cnpj_certificado=info.cnpj,
|
||||
not_valid_before=info.not_valid_before,
|
||||
not_valid_after=info.not_valid_after,
|
||||
)
|
||||
session.add(certificate)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError as exc:
|
||||
await session.rollback()
|
||||
if _is_fiscal_certificate_branch_live_violation(exc):
|
||||
raise CertificateUploadConflictError(product_id, branch_ref) from exc
|
||||
raise
|
||||
await session.refresh(certificate)
|
||||
return certificate
|
||||
|
||||
|
||||
async def get_certificate(
|
||||
session: AsyncSession, product_id: uuid.UUID, tenant_ref: str, branch_ref: str
|
||||
) -> FiscalCertificate | None:
|
||||
"""Metadados do certificado VIVO -- NUNCA descriptografa o binário aqui
|
||||
(ver `certificates.crypto.load_private_key_and_cert`, uso exclusivo da
|
||||
emissão, Task 5)."""
|
||||
return await _get_live_certificate(session, product_id, tenant_ref, branch_ref)
|
||||
|
||||
|
||||
async def deactivate_certificate(
|
||||
session: AsyncSession, product_id: uuid.UUID, tenant_ref: str, branch_ref: str
|
||||
) -> None:
|
||||
certificate = await _get_live_certificate(session, product_id, tenant_ref, branch_ref)
|
||||
if certificate is None:
|
||||
raise CertificateNotFoundError(product_id, branch_ref)
|
||||
certificate.deleted_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
@@ -1,9 +1,14 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from fiscal_svc.certificates.router import router as certificates_router
|
||||
from fiscal_svc.core.config import settings
|
||||
from fiscal_svc.series.router import router as series_router
|
||||
|
||||
app = FastAPI(title=settings.app_name)
|
||||
|
||||
app.include_router(certificates_router)
|
||||
app.include_router(series_router)
|
||||
|
||||
|
||||
@app.get("/v1/health")
|
||||
async def health_check() -> dict[str, str]:
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fiscal_svc.core.db import get_session
|
||||
from fiscal_svc.series import service
|
||||
from fiscal_svc.series.schemas import FiscalSeriesCreate, FiscalSeriesPatch, FiscalSeriesRead
|
||||
from fiscal_svc.shared.errors import conflict
|
||||
from fiscal_svc.tenancy.deps import require_product
|
||||
from fiscal_svc.tenancy.models import Product
|
||||
|
||||
router = APIRouter(prefix="/v1/series", tags=["series"])
|
||||
|
||||
|
||||
@router.post("", response_model=FiscalSeriesRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_fiscal_series_endpoint(
|
||||
payload: FiscalSeriesCreate,
|
||||
product: Product = Depends(require_product),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> FiscalSeriesRead:
|
||||
try:
|
||||
series = await service.create_fiscal_series(session, product.id, payload)
|
||||
except service.DuplicateFiscalSeriesError as exc:
|
||||
raise conflict("duplicate_fiscal_series", str(exc)) from exc
|
||||
return FiscalSeriesRead.model_validate(series)
|
||||
|
||||
|
||||
@router.get("", response_model=list[FiscalSeriesRead])
|
||||
async def list_fiscal_series_endpoint(
|
||||
tenant_ref: str = Query(..., max_length=64),
|
||||
branch_ref: str = Query(..., max_length=64),
|
||||
product: Product = Depends(require_product),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[FiscalSeriesRead]:
|
||||
series_list = await service.list_fiscal_series(session, product.id, tenant_ref, branch_ref)
|
||||
return [FiscalSeriesRead.model_validate(series) for series in series_list]
|
||||
|
||||
|
||||
@router.patch("/{series_id}", response_model=FiscalSeriesRead)
|
||||
async def update_fiscal_series_endpoint(
|
||||
series_id: uuid.UUID,
|
||||
payload: FiscalSeriesPatch,
|
||||
product: Product = Depends(require_product),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> FiscalSeriesRead:
|
||||
try:
|
||||
series = await service.update_fiscal_series(session, product.id, series_id, payload)
|
||||
except service.FiscalSeriesNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Série fiscal não encontrada"
|
||||
) from exc
|
||||
except service.FiscalSeriesNumberRegressionError as exc:
|
||||
raise conflict("fiscal_series_number_regression", str(exc)) from exc
|
||||
return FiscalSeriesRead.model_validate(series)
|
||||
@@ -0,0 +1,55 @@
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class FiscalSeriesCreate(BaseModel):
|
||||
"""POST /v1/series body. `serie` allows `0` (SEFAZ convention for
|
||||
"série única"/sem série formal) -- `ge=0`, unlike `next_number` which
|
||||
must start at 1 (`ge=1`, a document numbering never starts at 0). Only
|
||||
`"55"` is accepted for now (Global Constraints/spec: F2 covers NF-e 55
|
||||
emission only -- `xml_builder.build_nfe` itself hardcodes `mod="55"`;
|
||||
accepting `"65"` here would create a series nothing can ever allocate
|
||||
against without silently wrong output)."""
|
||||
|
||||
tenant_ref: str = Field(max_length=64)
|
||||
branch_ref: str = Field(max_length=64)
|
||||
document_model: Literal["55"] = "55"
|
||||
serie: int = Field(ge=0)
|
||||
next_number: int = Field(ge=1)
|
||||
|
||||
|
||||
class FiscalSeriesRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
product_id: uuid.UUID
|
||||
tenant_ref: str
|
||||
branch_ref: str
|
||||
document_model: str
|
||||
serie: int
|
||||
next_number: int
|
||||
|
||||
|
||||
class FiscalSeriesPatch(BaseModel):
|
||||
"""PATCH /v1/series/{series_id} body -- ONLY `next_number` is editable
|
||||
(a manual correction, e.g. realigning the counter after a migration
|
||||
from another emissor). `tenant_ref`/`branch_ref`/`document_model`/
|
||||
`serie` are immutable once created -- delete and recreate the series
|
||||
instead if it was set up wrong (no DELETE endpoint exists yet for this
|
||||
resource per the F2 API contract; ported convention documented here for
|
||||
when it does). `next_number` maps to a NOT NULL column, so explicit
|
||||
`null` is rejected with 422, same convention as the auto's
|
||||
`FiscalDocumentSeriesPatch`."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
next_number: int | None = Field(default=None, ge=1)
|
||||
|
||||
@field_validator("next_number", mode="before")
|
||||
@classmethod
|
||||
def _reject_explicit_null(cls, value: object) -> object:
|
||||
if value is None:
|
||||
raise ValueError("não pode ser nulo (coluna obrigatória)")
|
||||
return value
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Task 4: `FiscalSeries` CRUD (create/list/update) -- ported from the
|
||||
auto's `app/modules/tenants/service.py` FiscalDocumentSeries CRUD, porte
|
||||
table applied (`organization_id`/`branch_id` -> `product_id`/`tenant_ref`/
|
||||
`branch_ref`). `allocate_fiscal_number` itself (the atomic number-granting
|
||||
function) already lives in `documents.service` (Task 3, ported first since
|
||||
`FiscalSeries` is its home table) -- this module owns everything ELSE about
|
||||
a series: creating one, listing them, and the ADMIN edit path (`PATCH`)
|
||||
with the "guard retroativo" (`next_number` can never regress below what
|
||||
this série has already emitted) that Task 3's `FiscalSeries` docstring
|
||||
explicitly deferred to this task.
|
||||
|
||||
Deliberately its OWN `FiscalSeriesNotFoundError` (distinct class from
|
||||
`documents.service.FiscalSeriesNotFoundError`, same name, different
|
||||
module): that one is raised by a `(product_id, tenant_ref, branch_ref,
|
||||
document_model, serie)` TUPLE lookup (`allocate_fiscal_number`'s exact
|
||||
lookup shape); this one is raised by a bare `series_id` lookup (`PATCH
|
||||
/v1/series/{series_id}`'s shape) -- the auto's own `tenants.service.
|
||||
FiscalSeriesNotFoundError` supported BOTH shapes via optional constructor
|
||||
args in one class; this port keeps the shapes SEPARATE instead of carrying
|
||||
that same either/or constructor across two different modules-by-porte."""
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fiscal_svc.documents.models import FiscalDocument, FiscalSeries
|
||||
from fiscal_svc.series.schemas import FiscalSeriesCreate, FiscalSeriesPatch
|
||||
|
||||
|
||||
class FiscalSeriesNotFoundError(Exception):
|
||||
"""`series_id` does not resolve to a live row for this `product_id` --
|
||||
same anti-oracle 404 either way (does not exist vs. belongs to another
|
||||
product) as the rest of this service."""
|
||||
|
||||
def __init__(self, series_id: uuid.UUID):
|
||||
self.series_id = series_id
|
||||
super().__init__(f"Série fiscal {series_id} não encontrada")
|
||||
|
||||
|
||||
class DuplicateFiscalSeriesError(Exception):
|
||||
"""Raised when a `(product_id, tenant_ref, branch_ref, document_model,
|
||||
serie)` tuple collides with an existing `FiscalSeries` row -- live OR
|
||||
soft-deleted (the DB constraint, Task 3's
|
||||
`uq_fiscal_series_product_tenant_branch_model_serie`, has no `WHERE
|
||||
deleted_at IS NULL`, so a soft-deleted series still blocks recreation
|
||||
with the same tuple)."""
|
||||
|
||||
def __init__(self, document_model: str, serie: int):
|
||||
self.document_model = document_model
|
||||
self.serie = serie
|
||||
super().__init__(
|
||||
f"Já existe uma série fiscal para o modelo {document_model} e série {serie} "
|
||||
"neste branch_ref"
|
||||
)
|
||||
|
||||
|
||||
class FiscalSeriesNumberRegressionError(Exception):
|
||||
"""The "requisito herdado" guard from Task 3's `FiscalSeries` docstring,
|
||||
finally closed now that `FiscalDocument` exists to check against:
|
||||
`PATCH /v1/series/{id}` setting `next_number` to a value that is NOT
|
||||
strictly greater than the highest `numero` this series has already
|
||||
emitted (a LIVE, i.e. non-soft-deleted, `FiscalDocument`) would let the
|
||||
NEXT allocation hand out a number that was already used -- either an
|
||||
outright repeat (SEFAZ duplicate-key rejection) or, worse, a silent
|
||||
re-use if the earlier document was never transmitted. Blocked
|
||||
unconditionally whenever the series has emitted at least one document,
|
||||
regardless of whether the new `next_number` is higher or lower than the
|
||||
CURRENT `next_number` -- "regression" here means "against what SEFAZ has
|
||||
already seen for this série", not "against the previous column value"."""
|
||||
|
||||
def __init__(self, series_id: uuid.UUID, next_number: int, max_numero: int):
|
||||
self.series_id = series_id
|
||||
self.next_number = next_number
|
||||
self.max_numero = max_numero
|
||||
super().__init__(
|
||||
f"série {series_id} já emitiu até o número {max_numero}; "
|
||||
f"next_number ({next_number}) deve ser maior que {max_numero}"
|
||||
)
|
||||
|
||||
|
||||
def _is_fiscal_series_constraint_violation(exc: IntegrityError) -> bool:
|
||||
"""True iff `exc` violates `uq_fiscal_series_product_tenant_branch_
|
||||
model_serie` -- Task 3's migration names this constraint EXPLICITLY
|
||||
(unlike the auto's un-named equivalent), so a plain substring match on
|
||||
the name suffices; no need for the auto's "serie" + "unique constraint"
|
||||
double-marker workaround (that existed there only because the
|
||||
auto-generated constraint name there collided with the table's own
|
||||
name)."""
|
||||
detail = str(getattr(exc.orig, "args", [""])[0]) if exc.orig else str(exc)
|
||||
return "uq_fiscal_series_product_tenant_branch_model_serie" in detail
|
||||
|
||||
|
||||
async def _check_duplicate_fiscal_series(
|
||||
session: AsyncSession,
|
||||
product_id: uuid.UUID,
|
||||
tenant_ref: str,
|
||||
branch_ref: str,
|
||||
document_model: str,
|
||||
serie: int,
|
||||
) -> None:
|
||||
"""Pre-flight check against LIVE series only -- fast, friendlier-error
|
||||
happy path. Does NOT see soft-deleted rows; the DB constraint plus
|
||||
`create_fiscal_series`'s `except IntegrityError` still block those,
|
||||
same two-layer pattern as the rest of this codebase's uniqueness
|
||||
guards."""
|
||||
result = await session.execute(
|
||||
select(FiscalSeries.id).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),
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none() is not None:
|
||||
raise DuplicateFiscalSeriesError(document_model, serie)
|
||||
|
||||
|
||||
async def create_fiscal_series(
|
||||
session: AsyncSession, product_id: uuid.UUID, data: FiscalSeriesCreate
|
||||
) -> FiscalSeries:
|
||||
await _check_duplicate_fiscal_series(
|
||||
session, product_id, data.tenant_ref, data.branch_ref, data.document_model, data.serie
|
||||
)
|
||||
|
||||
series = FiscalSeries(
|
||||
product_id=product_id,
|
||||
tenant_ref=data.tenant_ref,
|
||||
branch_ref=data.branch_ref,
|
||||
document_model=data.document_model,
|
||||
serie=data.serie,
|
||||
next_number=data.next_number,
|
||||
)
|
||||
session.add(series)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError as exc:
|
||||
await session.rollback()
|
||||
if _is_fiscal_series_constraint_violation(exc):
|
||||
raise DuplicateFiscalSeriesError(data.document_model, data.serie) from exc
|
||||
raise
|
||||
await session.refresh(series)
|
||||
return series
|
||||
|
||||
|
||||
async def list_fiscal_series(
|
||||
session: AsyncSession, product_id: uuid.UUID, tenant_ref: str, branch_ref: str
|
||||
) -> list[FiscalSeries]:
|
||||
"""`(product_id, tenant_ref, branch_ref)`-scoped, non-soft-deleted
|
||||
list, ordered by `(document_model, serie)` -- small per-branch catalog,
|
||||
unpaginated (mirrors the auto's `list_fiscal_series`)."""
|
||||
result = await session.execute(
|
||||
select(FiscalSeries)
|
||||
.where(
|
||||
FiscalSeries.product_id == product_id,
|
||||
FiscalSeries.tenant_ref == tenant_ref,
|
||||
FiscalSeries.branch_ref == branch_ref,
|
||||
FiscalSeries.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(FiscalSeries.document_model, FiscalSeries.serie)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def update_fiscal_series(
|
||||
session: AsyncSession, product_id: uuid.UUID, series_id: uuid.UUID, data: FiscalSeriesPatch
|
||||
) -> FiscalSeries:
|
||||
"""Applies the allowlisted partial edit -- ONLY `next_number`. Uses
|
||||
`.with_for_update()` + `.execution_options(populate_existing=True)` --
|
||||
the SAME two-part fix as `documents.service.allocate_fiscal_number` (see
|
||||
its docstring): this PATCH can race a concurrent `allocate_fiscal_
|
||||
number` call against the SAME row, and the regression guard below needs
|
||||
a freshly-locked read to be meaningful."""
|
||||
result = await session.execute(
|
||||
select(FiscalSeries)
|
||||
.where(
|
||||
FiscalSeries.id == series_id,
|
||||
FiscalSeries.product_id == product_id,
|
||||
FiscalSeries.deleted_at.is_(None),
|
||||
)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
series = result.scalar_one_or_none()
|
||||
if series is None:
|
||||
raise FiscalSeriesNotFoundError(series_id=series_id)
|
||||
|
||||
changes = data.model_dump(exclude_unset=True)
|
||||
|
||||
# Guard retroativo (Task 3's `FiscalSeries` docstring, closed here):
|
||||
# roda DEPOIS do `.with_for_update()` acima (a mesma linha travada
|
||||
# serializa esta checagem contra `allocate_fiscal_number`) e ANTES de
|
||||
# aplicar qualquer mudança -- um `next_number` que regride é rejeitado
|
||||
# inteiro, nenhum campo do patch é aplicado.
|
||||
if "next_number" in changes:
|
||||
max_result = await session.execute(
|
||||
select(func.max(FiscalDocument.numero)).where(
|
||||
FiscalDocument.series_id == series.id,
|
||||
FiscalDocument.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
max_numero = max_result.scalar_one_or_none()
|
||||
if max_numero is not None and changes["next_number"] <= max_numero:
|
||||
raise FiscalSeriesNumberRegressionError(
|
||||
series_id=series.id, next_number=changes["next_number"], max_numero=max_numero
|
||||
)
|
||||
|
||||
for field, value in changes.items():
|
||||
setattr(series, field, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(series)
|
||||
return series
|
||||
Reference in New Issue
Block a user