Compare commits
14
Commits
836c267e09
...
cd0aeaa31f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd0aeaa31f | ||
|
|
d27d8c242b | ||
|
|
1625e8a257 | ||
|
|
4b8532c604 | ||
|
|
c2d2d30b70 | ||
|
|
260644c961 | ||
|
|
0b64602716 | ||
|
|
72bb089222 | ||
|
|
3e3a1abc6f | ||
|
|
d9c1c444fb | ||
|
|
39ba81efad | ||
|
|
fb2b8ce372 | ||
|
|
3aae8b67ef | ||
|
|
c903a9ce0e |
@@ -1,2 +1,8 @@
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/fiscal_svc_dev
|
||||
TEST_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/fiscal_svc_test
|
||||
|
||||
# Fernet key for A1 certificate ciphertext at rest (certificates.crypto,
|
||||
# Task 4) -- read straight from os.environ, never through Settings (same
|
||||
# "secrets don't live in Settings" convention as the auto). Generate one
|
||||
# with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
FISCAL_CERT_ENCRYPTION_KEY=
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""fiscal_idempotency_keys (Task 5): backs the `Idempotency-Key` contract of
|
||||
`POST /v1/emissoes` (design spec decision #6) -- a separate table so the
|
||||
Task 3 `fiscal_documents` migration stays untouched. See
|
||||
`documents.models.FiscalIdempotencyKey`'s docstring for the two-layer
|
||||
idempotency pattern this table's UNIQUE constraint exists for.
|
||||
|
||||
Revision ID: 30a80fe36910
|
||||
Revises: 523235d06bd5
|
||||
Create Date: 2026-07-22 00:00:00.000000
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "30a80fe36910"
|
||||
down_revision: Union[str, Sequence[str], None] = "523235d06bd5"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"fiscal_idempotency_keys",
|
||||
sa.Column("id", sa.UUID(), nullable=False),
|
||||
sa.Column("product_id", sa.UUID(), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("document_id", sa.UUID(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False
|
||||
),
|
||||
sa.ForeignKeyConstraint(["product_id"], ["products.id"]),
|
||||
sa.ForeignKeyConstraint(["document_id"], ["fiscal_documents.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"product_id", "idempotency_key", name="uq_fiscal_idempotency_key_product_key"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("fiscal_idempotency_keys")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""fiscal_certificates: partial-unique live index scoped by tenant_ref too
|
||||
(FIX 2, F2 review, 2026-07-17-sowai-fiscal-svc-design.md decisão #4) --
|
||||
`ix_fiscal_certificates_product_branch_live` (`(product_id, branch_ref)
|
||||
WHERE deleted_at IS NULL`) let two DIFFERENT tenants of the SAME product
|
||||
reusing an identical opaque `branch_ref` (e.g. both `"matriz"`) collapse
|
||||
onto the SAME certificate slot: the second tenant's upload soft-deleted the
|
||||
first tenant's still-live certificate as a legitimate "replace" instead of
|
||||
a 409 conflict, and emission for the first tenant would go on to sign with
|
||||
the second tenant's certificate. Replaces the index with one scoped
|
||||
`(product_id, tenant_ref, branch_ref) WHERE deleted_at IS NULL` -- matching
|
||||
`fiscal_series`'s own tenant-scoped uniqueness and the GET/DELETE
|
||||
certificate lookups, which already filtered by `tenant_ref`.
|
||||
|
||||
Revision ID: 8f1a2c9d4b6e
|
||||
Revises: 30a80fe36910
|
||||
Create Date: 2026-07-24 00:00:00.000000
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "8f1a2c9d4b6e"
|
||||
down_revision: Union[str, Sequence[str], None] = "30a80fe36910"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_index("ix_fiscal_certificates_product_branch_live", table_name="fiscal_certificates")
|
||||
op.create_index(
|
||||
"ix_fiscal_certificates_product_tenant_branch_live",
|
||||
"fiscal_certificates",
|
||||
["product_id", "tenant_ref", "branch_ref"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("deleted_at IS NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_fiscal_certificates_product_tenant_branch_live", table_name="fiscal_certificates"
|
||||
)
|
||||
op.create_index(
|
||||
"ix_fiscal_certificates_product_branch_live",
|
||||
"fiscal_certificates",
|
||||
["product_id", "branch_ref"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("deleted_at IS NULL"),
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
# sowai-fiscal-svc — dev deploy (cluster sow-dev, namespace autopecas-dev).
|
||||
# Internal ClusterIP only: the consumer (auto's HttpEmitter, F3) reaches it via
|
||||
# cluster DNS `fiscal-svc.autopecas-dev.svc.cluster.local:8140`. No Ingress —
|
||||
# the service is never exposed to the internet (fiscal data + A1 certs).
|
||||
#
|
||||
# DB: dedicated database `fiscal_svc` on the SHARED auto-postgres instance
|
||||
# (spec decision #2, amended: dedicated DATABASE, not a schema). Create it once:
|
||||
# kubectl -n autopecas-dev exec deploy/auto-postgres -- \
|
||||
# createdb -U postgres fiscal_svc
|
||||
#
|
||||
# Secret `fiscal-svc-secrets` carries the Fernet key for A1 cert ciphertext at
|
||||
# rest (FISCAL_CERT_ENCRYPTION_KEY, read from os.environ by certificates.crypto).
|
||||
# It is NOT in this manifest — created imperatively at deploy so the key never
|
||||
# lands in git:
|
||||
# python3 -c "from cryptography.fernet import Fernet; \
|
||||
# print('FISCAL_CERT_ENCRYPTION_KEY='+Fernet.generate_key().decode())" \
|
||||
# | kubectl -n autopecas-dev create secret generic fiscal-svc-secrets \
|
||||
# --from-env-file=/dev/stdin
|
||||
# (Losing/rotating it makes existing cert ciphertext undecryptable — in dev,
|
||||
# re-upload the cert. Same "secrets don't live in Settings/git" convention as
|
||||
# the auto's auto-fiscal-cert Secret.)
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: fiscal-svc
|
||||
namespace: autopecas-dev
|
||||
labels:
|
||||
app: fiscal-svc
|
||||
spec:
|
||||
replicas: 1
|
||||
# Recreate (not RollingUpdate): the node runs near 100% CPU, and a surge pod
|
||||
# would sit Pending and stall the rollout (same reason the auto uses
|
||||
# maxSurge:0 / Recreate). Kill old before new.
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: fiscal-svc
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: fiscal-svc
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: registry-credentials
|
||||
initContainers:
|
||||
# Migrations against the dedicated fiscal_svc DB before serving.
|
||||
- name: migrate
|
||||
image: registry.sowai.com.br/autopecas/fiscal-svc:dev
|
||||
# :dev is a mutable tag — always re-pull so a rebuild is picked up
|
||||
# (default IfNotPresent would run stale migrations).
|
||||
imagePullPolicy: Always
|
||||
command: ["uv", "run", "alembic", "upgrade", "head"]
|
||||
env:
|
||||
- name: DATABASE_URL
|
||||
value: postgresql+asyncpg://postgres:postgres@auto-postgres:5432/fiscal_svc
|
||||
containers:
|
||||
- name: fiscal-svc
|
||||
image: registry.sowai.com.br/autopecas/fiscal-svc:dev
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8140
|
||||
env:
|
||||
- name: DATABASE_URL
|
||||
value: postgresql+asyncpg://postgres:postgres@auto-postgres:5432/fiscal_svc
|
||||
envFrom:
|
||||
# Fernet key for A1 certificate ciphertext (see header). Without it
|
||||
# the first certificate upload fails loudly at deploy — not silent.
|
||||
- secretRef:
|
||||
name: fiscal-svc-secrets
|
||||
resources:
|
||||
# cpu request minúsculo DE PROPÓSITO: o nó de dev vive saturado
|
||||
# (~3000m alocáveis, quase todos reservados por system + outros
|
||||
# ns). 10m é o suficiente pra caber no schedule; o limit de 1 CPU
|
||||
# deixa o serviço fazer burst quando precisa (é request, não teto).
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 192Mi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 768Mi
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /v1/health
|
||||
port: 8140
|
||||
initialDelaySeconds: 8
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /v1/health
|
||||
port: 8140
|
||||
initialDelaySeconds: 25
|
||||
periodSeconds: 20
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: fiscal-svc
|
||||
namespace: autopecas-dev
|
||||
labels:
|
||||
app: fiscal-svc
|
||||
spec:
|
||||
selector:
|
||||
app: fiscal-svc
|
||||
ports:
|
||||
- port: 8140
|
||||
targetPort: 8140
|
||||
@@ -13,6 +13,7 @@ dependencies = [
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"pydantic-settings>=2.0",
|
||||
"pydantic>=2.13.4",
|
||||
"python-multipart>=0.0.20",
|
||||
"sowai-fiscal",
|
||||
"sqlalchemy[asyncio]>=2.0",
|
||||
"uvicorn[standard]>=0.49.0",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Task 6: one-shot generator for `tests/goldens_signed/*.expected.xml` --
|
||||
NOT run automatically by the test suite. Run manually whenever a golden case
|
||||
in the `sowai-fiscal` lib is added/changed (or `tests/fixtures/test_cert.pfx`
|
||||
is rotated), then commit the regenerated files.
|
||||
|
||||
Signs each `sowai_fiscal.goldens/*.input.json` case's `DadosEmissao` (loaded
|
||||
via `sowai_fiscal.golden_helpers.dados_from_json` -- SAME helper the lib's
|
||||
own pre-signature goldens use, so `chave_acesso`/`numero`/`cnf`/`dh_emi` are
|
||||
whatever that JSON already pins, unchanged) with the FIXED test certificate.
|
||||
Deterministic because RSA PKCS#1v1.5 signing has no random padding: the SAME
|
||||
(input XML, private key) pair always produces the SAME signature bytes --
|
||||
this is the byte-exact assertion `tests/test_signed_goldens.py` checks.
|
||||
|
||||
uv run python scripts/generate_signed_goldens.py
|
||||
"""
|
||||
import importlib.resources
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
from sowai_fiscal.golden_helpers import dados_from_json
|
||||
from sowai_fiscal.xml_builder import build_nfe
|
||||
|
||||
from fiscal_svc.emission.service import _serialize_nfe, sign_nfe_xml
|
||||
|
||||
REPO_DIR = Path(__file__).resolve().parents[1]
|
||||
CERT_PATH = REPO_DIR / "tests" / "fixtures" / "test_cert.pfx"
|
||||
CERT_PASSWORD = "test-cert-password" # must match generate_test_cert.py
|
||||
OUT_DIR = REPO_DIR / "tests" / "goldens_signed"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not CERT_PATH.exists():
|
||||
raise SystemExit(
|
||||
f"{CERT_PATH} não existe -- rode scripts/generate_test_cert.py primeiro"
|
||||
)
|
||||
pfx_bytes = CERT_PATH.read_bytes()
|
||||
private_key, cert, _ca_certs = pkcs12.load_key_and_certificates(
|
||||
pfx_bytes, CERT_PASSWORD.encode("utf-8")
|
||||
)
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
goldens_dir = Path(str(importlib.resources.files("sowai_fiscal") / "goldens"))
|
||||
for input_path in sorted(goldens_dir.glob("*.input.json")):
|
||||
case = input_path.name.removesuffix(".input.json")
|
||||
dados = dados_from_json(input_path)
|
||||
nfe = build_nfe(dados)
|
||||
xml_str = _serialize_nfe(nfe)
|
||||
xml_assinado = sign_nfe_xml(xml_str, dados.chave_acesso, private_key, cert)
|
||||
out_path = OUT_DIR / f"{case}.expected.xml"
|
||||
out_path.write_text(xml_assinado, encoding="utf-8")
|
||||
print(f"wrote {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Task 6: one-shot generator for `tests/fixtures/test_cert.pfx` -- a
|
||||
self-signed TEST-ONLY A1 certificate, NEVER a real one (design spec's "os 3
|
||||
seguros de portabilidade", item (b)/(c) extended by the F2 emenda: goldens
|
||||
ASSINADOS need a FIXED test certificate, committed as a binary fixture).
|
||||
|
||||
Re-running this script produces a DIFFERENT RSA key pair (key generation is
|
||||
not seeded) -- that is fine and expected: this is meant to be run ONCE and
|
||||
the output COMMITTED. The determinism `tests/test_signed_goldens.py` relies
|
||||
on comes from every test run reusing the SAME committed `.pfx` file (so the
|
||||
same private key signs the same canonicalized XML the same way every time,
|
||||
RSA PKCS#1v1.5 having no random padding) -- NOT from this generator being
|
||||
reproducible bit-for-bit across runs. Re-run only if the fixture needs to be
|
||||
rotated, and regenerate `tests/goldens_signed/*.expected.xml`
|
||||
(`scripts/generate_signed_goldens.py`) together with it, in the same commit.
|
||||
|
||||
uv run python scripts/generate_test_cert.py
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
# Mesmo CNPJ do emitente usado em TODOS os casos golden da lib
|
||||
# (`sowai_fiscal.goldens/*.input.json`) -- ver esse pacote se algum caso
|
||||
# novo usar um CNPJ diferente; este script/fixture precisaria acompanhar.
|
||||
CNPJ = "12345678000190"
|
||||
PASSWORD = "test-cert-password"
|
||||
OUT_PATH = Path(__file__).resolve().parents[1] / "tests" / "fixtures" / "test_cert.pfx"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
subject = issuer = x509.Name(
|
||||
[
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, f"AUTOPECAS THIAGO LTDA:{CNPJ}"),
|
||||
x509.NameAttribute(NameOID.SERIAL_NUMBER, CNPJ),
|
||||
]
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(issuer)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - timedelta(days=1))
|
||||
.not_valid_after(now + timedelta(days=3650))
|
||||
.sign(key, hashes.SHA256())
|
||||
)
|
||||
pfx_bytes = pkcs12.serialize_key_and_certificates(
|
||||
name=b"sowai-fiscal-svc-test",
|
||||
key=key,
|
||||
cert=cert,
|
||||
cas=None,
|
||||
encryption_algorithm=serialization.BestAvailableEncryption(PASSWORD.encode("utf-8")),
|
||||
)
|
||||
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT_PATH.write_bytes(pfx_bytes)
|
||||
print(f"wrote {OUT_PATH} (cnpj={CNPJ}, password={PASSWORD!r})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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,140 @@
|
||||
"""`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,
|
||||
responses={
|
||||
401: {"description": "API key ausente ou inválida"},
|
||||
409: {"description": "`detail.code`=`certificate_upload_conflict` -- upload concorrente venceu a corrida"},
|
||||
413: {"description": f"Certificado excede o tamanho máximo permitido ({_MAX_PFX_UPLOAD_BYTES} bytes)"},
|
||||
422: {
|
||||
"description": (
|
||||
"PFX inválido/senha incorreta, CNPJ do certificado diverge do declarado, "
|
||||
"certificado vencido ou ainda não vigente"
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
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,
|
||||
responses={
|
||||
401: {"description": "API key ausente ou inválida"},
|
||||
404: {"description": "Nenhum certificado vivo para este (tenant_ref, branch_ref) -- anti-oracle"},
|
||||
},
|
||||
)
|
||||
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,
|
||||
responses={
|
||||
401: {"description": "API key ausente ou inválida"},
|
||||
404: {"description": "Nenhum certificado vivo para este (tenant_ref, branch_ref) -- anti-oracle"},
|
||||
},
|
||||
)
|
||||
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,237 @@
|
||||
"""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_tenant_branch_live` (`(product_id,
|
||||
tenant_ref, branch_ref) WHERE deleted_at IS NULL`, Task 3's
|
||||
`FiscalCertificate.__table_args__`, escopo estendido a `tenant_ref` na
|
||||
correção do FIX 2/F2 review -- ver o docstring de `_get_live_
|
||||
certificate_by_tenant_branch`) -- disparado quando DUAS chamadas
|
||||
genuinamente concorrentes de `upload_certificate` para o MESMO
|
||||
`(product_id, tenant_ref, branch_ref)` ambas leem `_get_live_
|
||||
certificate_by_tenant_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_tenant_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_tenant_branch_live" in detail
|
||||
|
||||
|
||||
async def _get_live_certificate_by_tenant_branch(
|
||||
session: AsyncSession, product_id: uuid.UUID, tenant_ref: str, branch_ref: str
|
||||
) -> FiscalCertificate | None:
|
||||
"""Scoped to `(product_id, tenant_ref, branch_ref)` -- matches exactly
|
||||
the scope of the partial unique index this function's callers (`upload_
|
||||
certificate`) need to pre-check against. FIX 2 (F2 review): renamed
|
||||
from `_get_live_certificate_by_branch` (which omitted `tenant_ref`) --
|
||||
without it, two DIFFERENT tenants of the SAME product reusing an
|
||||
identical opaque `branch_ref` (e.g. both "matriz") collapsed onto the
|
||||
SAME slot: the second tenant's upload soft-deleted the first tenant's
|
||||
still-live certificate as a "replace", not a conflict. Same public
|
||||
lookup shape as `_get_live_certificate` below (GET/DELETE, anti-oracle
|
||||
boundary, design spec decision #4) -- kept as a SEPARATE function
|
||||
because this one's caller (`upload_certificate`'s pre-check) needs
|
||||
`None` on "no live cert for this slot", not a 404-worthy distinction."""
|
||||
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 _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, tenant_ref, branch_ref)`) é soft-deletado e o novo é
|
||||
inserido -- um único VIVO por `(product_id, tenant_ref, branch_ref)`,
|
||||
nunca dois, nunca um update-in-place (FIX 2/F2 review: `tenant_ref`
|
||||
entrou no escopo -- ver `_get_live_certificate_by_tenant_branch`'s
|
||||
docstring para o porquê).
|
||||
|
||||
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_tenant_branch(session, product_id, tenant_ref, 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()
|
||||
@@ -109,27 +109,33 @@ class FiscalSeries(Base, UUIDPKMixin, TimestampMixin, SoftDeleteMixin):
|
||||
class FiscalCertificate(Base, UUIDPKMixin, TimestampMixin, SoftDeleteMixin):
|
||||
"""Ported from the auto's `FiscalCertificate` (`app/modules/fiscal/
|
||||
models.py`) -- A1 certificate (.pfx) for a `branch_ref`. Per the porte
|
||||
table, the emitter is `(product_id, branch_ref)`, with `cnpj` carried
|
||||
on the row itself (the "vínculo forte", design spec decision #4: the
|
||||
CNPJ is what's validated against the certificate at upload and against
|
||||
the emitente at emission -- `branch_ref` alone is an opaque string this
|
||||
service never interprets). No fallback to a tenant-level or
|
||||
table, the emitter is `(product_id, tenant_ref, branch_ref)`, with
|
||||
`cnpj` carried on the row itself (the "vínculo forte", design spec
|
||||
decision #4: the CNPJ is what's validated against the certificate at
|
||||
upload and against the emitente at emission -- `branch_ref` alone is an
|
||||
opaque string this service never interprets). No fallback to a
|
||||
product-level certificate -- fail-closed by construction, same as the
|
||||
auto: a branch_ref without its own live certificate cannot emit.
|
||||
auto: a `(tenant_ref, branch_ref)` without its own live certificate
|
||||
cannot emit.
|
||||
|
||||
One VIVO (`deleted_at IS NULL`) row per `(product_id, branch_ref)`: a
|
||||
second upload soft-deletes the previous live row (Task 4's
|
||||
`certificates.service`). `ix_fiscal_certificates_product_branch_live`
|
||||
is a PARTIAL UNIQUE index enforcing that at the database level (same
|
||||
"two concurrent uploads must not both land a live row" reasoning as the
|
||||
auto's own migration `a1b2c3d4e5f6`'s fix) -- scoped by `product_id` in
|
||||
ADDITION to `branch_ref` (the auto's version only needed `branch_id`,
|
||||
already product-scoped by being a real FK; here `branch_ref` is an
|
||||
OPAQUE string owned by the calling product, so two DIFFERENT products
|
||||
could coincidentally pick the identical string for two DIFFERENT real
|
||||
branches -- scoping the uniqueness by `product_id` too is what keeps
|
||||
that from cross-contaminating one product's certificate slot with
|
||||
another's).
|
||||
One VIVO (`deleted_at IS NULL`) row per `(product_id, tenant_ref,
|
||||
branch_ref)`: a second upload soft-deletes the previous live row
|
||||
(Task 4's `certificates.service`). `ix_fiscal_certificates_product_
|
||||
tenant_branch_live` is a PARTIAL UNIQUE index enforcing that at the
|
||||
database level (same "two concurrent uploads must not both land a live
|
||||
row" reasoning as the auto's own migration `a1b2c3d4e5f6`'s fix) --
|
||||
scoped by `product_id` AND `tenant_ref` in ADDITION to `branch_ref`
|
||||
(the auto's version only needed `branch_id`, already product-scoped by
|
||||
being a real FK; here `branch_ref` is an OPAQUE string owned by the
|
||||
calling product, so two DIFFERENT products -- or two DIFFERENT tenants
|
||||
of the SAME product -- could coincidentally pick the identical string
|
||||
for two DIFFERENT real branches -- scoping the uniqueness by
|
||||
`product_id`+`tenant_ref` too is what keeps that from
|
||||
cross-contaminating one tenant's certificate slot with another's; FIX 2,
|
||||
F2 review, 2026-07-17-sowai-fiscal-svc-design.md decisão #4 turned this
|
||||
into a shipped bug otherwise: two tenants both using `branch_ref=
|
||||
"matriz"` would collapse onto one slot, the second tenant's upload
|
||||
silently soft-deleting the first's still-live certificate).
|
||||
|
||||
`pfx_encrypted`/`password_encrypted` are Fernet ciphertext (Task 4,
|
||||
`FISCAL_CERT_ENCRYPTION_KEY` env var, own key -- never
|
||||
@@ -139,8 +145,9 @@ class FiscalCertificate(Base, UUIDPKMixin, TimestampMixin, SoftDeleteMixin):
|
||||
__tablename__ = "fiscal_certificates"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_fiscal_certificates_product_branch_live",
|
||||
"ix_fiscal_certificates_product_tenant_branch_live",
|
||||
"product_id",
|
||||
"tenant_ref",
|
||||
"branch_ref",
|
||||
unique=True,
|
||||
postgresql_where=text("deleted_at IS NULL"),
|
||||
@@ -215,3 +222,42 @@ class FiscalDocument(Base, UUIDPKMixin, TimestampMixin, SoftDeleteMixin):
|
||||
rejeicao_motivo: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
protocolo: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
autorizada_em: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class FiscalIdempotencyKey(Base, UUIDPKMixin, TimestampMixin):
|
||||
"""Task 5: backs the `Idempotency-Key` contract of `POST /v1/emissoes`
|
||||
(design spec decision #6) -- a SEPARATE table rather than a column on
|
||||
`FiscalDocument` (Task 3's table, already shipped/migrated) so this
|
||||
Task never touches that migration. `UNIQUE (product_id,
|
||||
idempotency_key)` is the SAME-transaction outbox partner of `emission.
|
||||
service.emitir_documento`'s number-allocation + document-INSERT commit
|
||||
(`documents.service.allocate_fiscal_number`'s docstring): this row is
|
||||
added to the SAME session, in the SAME commit, as the `FiscalDocument`
|
||||
it points to via `document_id` -- so a rollback (e.g. the idempotency
|
||||
race below, or a signature failure) undoes the allocated number AND the
|
||||
document row AND this key together, never just some of the three.
|
||||
|
||||
The two-layer idempotency pattern this table exists for: (1) a cheap
|
||||
pre-check SELECT before doing any real work (the fast path for a
|
||||
genuine retry); (2) this UNIQUE constraint as the source of truth for
|
||||
the RACE -- two concurrent requests carrying the SAME `Idempotency-Key`
|
||||
can both pass the pre-check (`None`) before either commits; the FIRST
|
||||
to commit wins, the SECOND's commit raises `IntegrityError` here, which
|
||||
`emission.service` catches and translates into a RE-READ of the
|
||||
winner's row (converging both callers on the SAME `FiscalDocument`,
|
||||
never emitting a duplicate NF-e for one logical request).
|
||||
|
||||
No soft-delete mixin: an idempotency key's history is permanent by
|
||||
design -- there is no "un-claim this key" operation."""
|
||||
|
||||
__tablename__ = "fiscal_idempotency_keys"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"product_id", "idempotency_key",
|
||||
name="uq_fiscal_idempotency_key_product_key",
|
||||
),
|
||||
)
|
||||
|
||||
product_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("products.id"), nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
document_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("fiscal_documents.id"), nullable=False)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fiscal_svc.core.db import get_session
|
||||
from fiscal_svc.emission import service
|
||||
from fiscal_svc.emission.schemas import EmissaoRequest, FiscalDocumentRead
|
||||
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", tags=["emissao"])
|
||||
|
||||
_ERRO_409 = {
|
||||
"description": (
|
||||
"Conflito estruturado -- `detail.code` identifica a causa: "
|
||||
"`fiscal_config_missing` (certificado/série ausentes ou certificado vencido), "
|
||||
"`emitente_certificate_cnpj_mismatch` (CNPJ do emitente diverge do certificado da filial), "
|
||||
"`pagamento_total_diverge` (Σ vPag ≠ vNF), "
|
||||
"`fiscal_document_conflict` (colisão de chave de acesso)."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/emissoes",
|
||||
response_model=FiscalDocumentRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Idempotency-Key repetida -- documento já emitido devolvido (replay, created=False)",
|
||||
"model": FiscalDocumentRead,
|
||||
},
|
||||
401: {"description": "API key ausente ou inválida"},
|
||||
409: _ERRO_409,
|
||||
},
|
||||
)
|
||||
async def emitir_documento_endpoint(
|
||||
payload: EmissaoRequest,
|
||||
response: Response,
|
||||
idempotency_key: str = Header(..., alias="Idempotency-Key", max_length=255, min_length=1),
|
||||
product: Product = Depends(require_product),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> FiscalDocumentRead:
|
||||
"""`Idempotency-Key` OBRIGATÓRIO (design spec decisão #6) -- ausente
|
||||
vira 422 na borda do FastAPI (`Header(...)`, sem default), antes de
|
||||
qualquer lógica rodar. Repetida com a MESMA `product_id` -> 200 com o
|
||||
documento já existente (`created=False`); nova -> 201 (`created=True`).
|
||||
Ver `emission.service.emitir_documento`'s docstring para o padrão de
|
||||
duas camadas que garante isto mesmo sob duas requisições concorrentes
|
||||
com a mesma chave."""
|
||||
try:
|
||||
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.PagamentoTotalMismatchError as exc:
|
||||
raise conflict("pagamento_total_diverge", 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
|
||||
return FiscalDocumentRead.model_validate(document)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/documentos/{document_id}",
|
||||
response_model=FiscalDocumentRead,
|
||||
responses={
|
||||
401: {"description": "API key ausente ou inválida"},
|
||||
404: {"description": "Documento fiscal não encontrado (ou de outro product/tenant -- anti-oracle)"},
|
||||
},
|
||||
)
|
||||
async def get_fiscal_document_endpoint(
|
||||
document_id: uuid.UUID,
|
||||
product: Product = Depends(require_product),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> FiscalDocumentRead:
|
||||
document = await service.get_fiscal_document(session, product.id, document_id)
|
||||
if document is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Documento fiscal não encontrado"
|
||||
)
|
||||
return FiscalDocumentRead.model_validate(document)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/documentos/{document_id}/xml",
|
||||
responses={
|
||||
200: {"content": {"application/xml": {}}},
|
||||
401: {"description": "API key ausente ou inválida"},
|
||||
404: {"description": "Documento fiscal não encontrado (ou de outro product/tenant -- anti-oracle)"},
|
||||
},
|
||||
)
|
||||
async def get_fiscal_document_xml_endpoint(
|
||||
document_id: uuid.UUID,
|
||||
product: Product = Depends(require_product),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Response:
|
||||
document = await service.get_fiscal_document(session, product.id, document_id)
|
||||
if document is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Documento fiscal não encontrado"
|
||||
)
|
||||
return Response(content=document.xml_assinado, media_type="application/xml")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/documentos",
|
||||
response_model=list[FiscalDocumentRead],
|
||||
responses={401: {"description": "API key ausente ou inválida"}},
|
||||
)
|
||||
async def list_fiscal_documents_endpoint(
|
||||
tenant_ref: str | None = Query(default=None, max_length=64),
|
||||
branch_ref: str | None = Query(default=None, max_length=64),
|
||||
status_filter: str | None = Query(default=None, alias="status", max_length=20),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
product: Product = Depends(require_product),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[FiscalDocumentRead]:
|
||||
documents = await service.list_fiscal_documents(
|
||||
session, product.id,
|
||||
tenant_ref=tenant_ref, branch_ref=branch_ref, status_=status_filter,
|
||||
limit=limit, offset=offset,
|
||||
)
|
||||
return [FiscalDocumentRead.model_validate(d) for d in documents]
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Task 5: `EmissaoRequest` -- the pydantic mirror of `sowai_fiscal.
|
||||
xml_builder.DadosEmissao` (+ `sowai_fiscal.resolver.FiscalResult`/
|
||||
`TributoLinha`, which the lib itself already defines as pydantic
|
||||
`BaseModel`s -- FIX 6, F2 review: these two ARE re-mirrored here, as
|
||||
`FiscalResultPayload`/`TributoLinhaPayload` below, kept SEPARATE from the
|
||||
lib's own classes on purpose -- this module is the wire contract this
|
||||
SERVICE owns (a field the lib adds/renames should never silently change
|
||||
what a caller across the network is allowed to send), while the lib's
|
||||
`FiscalResult`/`TributoLinha` are what `sowai_fiscal.xml_builder.build_nfe`
|
||||
actually consumes internally. `emission.service._to_fiscal_result`/
|
||||
`_to_tributo` convert Payload -> lib class (`FiscalResult(**payload.
|
||||
model_dump())`, not the payload objects themselves), adapted per the porte
|
||||
table and design spec:
|
||||
|
||||
* `tenant_ref`/`branch_ref` (opaque strings, decision #4) replace what in
|
||||
the auto was implicit (`sale.branch_id`).
|
||||
* `document_model`/`serie` select WHICH `FiscalSeries` to allocate from
|
||||
(Task 4) -- `chave_acesso`/`numero`/`cnf`/`dh_emi` are DROPPED from this
|
||||
payload entirely (unlike the lib's own `DadosEmissao`, which expects
|
||||
them pre-computed): this SERVICE computes all four itself (`cNF do
|
||||
serviço, persistido`, plan Task 5) -- a caller can never inject its own
|
||||
chave/número, closing the exact class of bug the auto's own emission
|
||||
code had to defend against for `branch_id` (C1, "auditoria Fable
|
||||
2026-07-16", `auto/backend/app/modules/fiscal/emissao.py`'s module
|
||||
docstring).
|
||||
* `ver_proc` is REQUIRED (no default) -- design spec's "EMENDA F1": the
|
||||
lib's own `DadosEmissao.ver_proc` defaults to `"sowai-auto/1b.1"`, a
|
||||
default that made sense when this code lived IN the auto and is
|
||||
actively WRONG for every other product calling this shared service. A
|
||||
plain (no-default) pydantic field is already "required, 422 if absent"
|
||||
-- no extra validator needed.
|
||||
* `fiscal_result` per item is the motor de regras' OUTPUT (design spec
|
||||
decision #3: "O serviço NÃO resolve imposto (FiscalResult vem no
|
||||
payload)") -- this service treats it as opaque data to embed in the
|
||||
XML, never recomputes it."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class TributoLinhaPayload(BaseModel):
|
||||
tax_domain: str
|
||||
cst: str | None = None
|
||||
csosn: str | None = None
|
||||
base_calc: Decimal
|
||||
base_calc_percent: Decimal
|
||||
aliquota: Decimal | None = None
|
||||
valor: Decimal
|
||||
mva: Decimal | None = None
|
||||
aliquota_st: Decimal | None = None
|
||||
fcp_percent: Decimal | None = None
|
||||
codigo_beneficio: str | None = None
|
||||
rule_id: uuid.UUID
|
||||
|
||||
|
||||
class FiscalResultPayload(BaseModel):
|
||||
cfop: str
|
||||
cst: str | None = None
|
||||
csosn: str | None = None
|
||||
origem: str | None = None
|
||||
consumidor_final: bool
|
||||
indicador_ie: str
|
||||
tributos: list[TributoLinhaPayload] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EmitenteDataPayload(BaseModel):
|
||||
cnpj: str = Field(min_length=14, max_length=14)
|
||||
razao_social: str
|
||||
nome_fantasia: str | None = None
|
||||
ie: str
|
||||
crt: str = Field(min_length=1, max_length=1)
|
||||
address_street: str
|
||||
address_number: str
|
||||
address_complement: str | None = None
|
||||
address_district: str
|
||||
address_city: str
|
||||
address_state: str = Field(min_length=2, max_length=2)
|
||||
address_zip: str
|
||||
address_city_ibge_code: str
|
||||
fone: str | None = None
|
||||
|
||||
|
||||
class DestinatarioDataPayload(BaseModel):
|
||||
"""`None` no `EmissaoRequest.destinatario` == consumidor final não
|
||||
identificado -- espelha `sowai_fiscal.xml_builder.DestinatarioData`."""
|
||||
|
||||
nome: str
|
||||
cnpj: str | None = None
|
||||
cpf: str | None = None
|
||||
indicador_ie: str
|
||||
ie: str | None = None
|
||||
address_street: str | None = None
|
||||
address_number: str | None = None
|
||||
address_complement: str | None = None
|
||||
address_district: str | None = None
|
||||
address_city: str | None = None
|
||||
address_state: str | None = None
|
||||
address_zip: str | None = None
|
||||
address_city_ibge_code: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class ItemDataPayload(BaseModel):
|
||||
codigo: str
|
||||
descricao: str
|
||||
ncm: str
|
||||
cfop: str
|
||||
unidade_comercial: str
|
||||
unidade_tributavel: str
|
||||
quantidade: Decimal = Field(gt=0)
|
||||
valor_unitario: Decimal = Field(gt=0)
|
||||
fiscal_result: FiscalResultPayload
|
||||
gtin: str | None = None
|
||||
cest: str | None = None
|
||||
peso_liquido_kg: Decimal | None = None
|
||||
peso_bruto_kg: Decimal | None = None
|
||||
|
||||
|
||||
class PagamentoDataPayload(BaseModel):
|
||||
tpag: str
|
||||
valor: Decimal = Field(gt=0)
|
||||
indpag: str = "0"
|
||||
|
||||
|
||||
class EmissaoRequest(BaseModel):
|
||||
tenant_ref: str = Field(max_length=64)
|
||||
branch_ref: str = Field(max_length=64)
|
||||
# Only "55" for now -- see `series.schemas.FiscalSeriesCreate`'s
|
||||
# docstring for why (`xml_builder.build_nfe` hardcodes `mod="55"`).
|
||||
document_model: Literal["55"] = "55"
|
||||
serie: int = Field(ge=0)
|
||||
emitente: EmitenteDataPayload
|
||||
itens: list[ItemDataPayload] = Field(min_length=1)
|
||||
pagamento: PagamentoDataPayload
|
||||
ambiente: Literal["homologacao", "producao"]
|
||||
uf_destino_tipo: Literal["interna", "interestadual"]
|
||||
destinatario: DestinatarioDataPayload | None = None
|
||||
nat_op: str = "Venda"
|
||||
tp_emis: str = Field(default="1", min_length=1, max_length=1)
|
||||
ind_final: str = Field(default="1", min_length=1, max_length=1)
|
||||
ind_pres: str = Field(default="1", min_length=1, max_length=1)
|
||||
fin_nfe: str = Field(default="1", min_length=1, max_length=1)
|
||||
# OBRIGATÓRIO -- ver o docstring do módulo (EMENDA F1). Nenhum default:
|
||||
# ausente no payload -> 422 na borda do FastAPI, antes de qualquer
|
||||
# lógica de negócio rodar.
|
||||
ver_proc: str = Field(min_length=1, max_length=20)
|
||||
|
||||
|
||||
class FiscalDocumentRead(BaseModel):
|
||||
"""NUNCA inclui `xml_assinado` -- o XML sai só por `GET /v1/documentos/
|
||||
{id}/xml` (`Response(media_type="application/xml")`), mesmo racional de
|
||||
`FiscalCertificateRead` nunca incluir o binário do certificado. Sem
|
||||
`sale_id`/`service_order_id` (porte table: este serviço não conhece o
|
||||
domínio do produto chamador)."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
product_id: uuid.UUID
|
||||
tenant_ref: str
|
||||
branch_ref: str
|
||||
series_id: uuid.UUID
|
||||
document_model: str
|
||||
serie: int
|
||||
numero: int
|
||||
chave_acesso: str
|
||||
codigo_numerico: str
|
||||
status: str
|
||||
ambiente: str
|
||||
rejeicao_codigo: str | None
|
||||
rejeicao_motivo: str | None
|
||||
protocolo: str | None
|
||||
autorizada_em: datetime | None
|
||||
created_at: datetime
|
||||
@@ -0,0 +1,528 @@
|
||||
"""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 (+ its CNPJ against
|
||||
`payload.emitente.cnpj`, F2 review FIX 1), existing series for
|
||||
(document_model, serie), `pagamento.valor` against the document total
|
||||
(F2 review FIX 3) -- ALL checked BEFORE touching `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
|
||||
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 decimal import ROUND_HALF_UP, Decimal
|
||||
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,
|
||||
soma_itens_quantizados,
|
||||
)
|
||||
|
||||
_NFE_NAMESPACE = "http://www.portalfiscal.inf.br/nfe"
|
||||
|
||||
# Mesma quantização de `sowai_fiscal.xml_builder._build_pag`'s `vPag` --
|
||||
# comparar `pagamento.valor` cru (Decimal com mais casas do que o leiaute
|
||||
# aceita) contra `vNF` (2 casas, ROUND_HALF_UP) sem quantizar os DOIS lados
|
||||
# do mesmo jeito produziria falsos-positivos de divergência.
|
||||
_CENT = Decimal("0.01")
|
||||
|
||||
# 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 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 PagamentoTotalMismatchError(Exception):
|
||||
"""Fail-closed: a coerência estrutural que o serviço promete validar
|
||||
(design spec decisão #3: "valida coerência estrutural -- somas, campos
|
||||
obrigatórios do leiaute -- nunca recalcula imposto") cobria as somas de
|
||||
`ICMSTot` mas deixava passar a ÚNICA outra que pode genuinamente
|
||||
divergir -- `vPag` (`pagamento.valor`) contra `vNF` (o total do
|
||||
documento, `sowai_fiscal.xml_builder.soma_itens_quantizados`). A SEFAZ
|
||||
rejeita com "Valor do Pagamento difere do total" (mesma mensagem que o
|
||||
módulo `xml_builder` já documenta); melhor 409 ANTES de alocar um
|
||||
número (e queimá-lo) do que uma rejeição do lado de lá depois de já ter
|
||||
persistido um `FiscalDocument` ASSINADO."""
|
||||
|
||||
def __init__(self, valor_pagamento: Decimal, valor_nf: Decimal):
|
||||
self.valor_pagamento = valor_pagamento
|
||||
self.valor_nf = valor_nf
|
||||
super().__init__(
|
||||
f"Valor do pagamento ({valor_pagamento}) diverge do total do documento ({valor_nf})"
|
||||
)
|
||||
|
||||
|
||||
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`) ----------
|
||||
|
||||
|
||||
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, tenant_ref: str, branch_ref: str
|
||||
) -> FiscalCertificate | None:
|
||||
"""Escopado por `(product_id, tenant_ref, branch_ref)` -- FIX 2 (F2
|
||||
review): o certificado é POR TENANT (mesma escala que `_get_series`, já
|
||||
tenant-scoped, e o GET/DELETE de `certificates.service._get_live_
|
||||
certificate`). Antes desta correção este lookup omitia `tenant_ref`, e
|
||||
dois tenants do MESMO produto reusando um `branch_ref` idêntico (ex.:
|
||||
ambos "matriz", nomes opacos que o serviço nunca interpreta) resolviam
|
||||
para o MESMO certificado -- o segundo upload de um tenant B para
|
||||
"matriz" soft-deletava o certificado vivo do tenant A (ver o índice
|
||||
parcial único em `documents.models.FiscalCertificate`, também corrigido
|
||||
nesta mesma revisão) e a emissão de A passava a assinar com o
|
||||
certificado de B."""
|
||||
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 _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:
|
||||
"""FIX 4 (F2 review, MINOR): `deleted_at IS NULL` -- matches `get_fiscal_
|
||||
document` below. Nenhum caminho deste F2 soft-deleta um `FiscalDocument`
|
||||
hoje (a imutabilidade pós-ASSINADO é preservada via evento futuro, não
|
||||
soft-delete direto), mas a `FiscalIdempotencyKey` que aponta pra ele
|
||||
sobrevive PARA SEMPRE (sem soft-delete mixin, por design -- ver o
|
||||
docstring da própria tabela) -- um futuro "cancelar = soft-delete"
|
||||
faria esta pré-checagem devolver um documento MORTO como se ainda
|
||||
fosse a resposta idempotente válida, em vez de tratar a chave como
|
||||
livre para uma NOVA emissão."""
|
||||
result = await session.execute(
|
||||
select(FiscalDocument)
|
||||
.join(FiscalIdempotencyKey, FiscalIdempotencyKey.document_id == FiscalDocument.id)
|
||||
.where(
|
||||
FiscalIdempotencyKey.product_id == product_id,
|
||||
FiscalIdempotencyKey.idempotency_key == idempotency_key,
|
||||
FiscalDocument.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
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.tenant_ref, 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)
|
||||
|
||||
# `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()
|
||||
|
||||
# Coerência estrutural (spec decisão #3, "somas") -- a ÚNICA soma que
|
||||
# pode genuinamente divergir do que os itens fecham é `vPag` vs `vNF`.
|
||||
# `soma_itens_quantizados` é a MESMA função que `xml_builder._build_
|
||||
# total` usa para `vNF`/`vProd` (fonte única -- ver o docstring dessa
|
||||
# função na lib) -- reusada aqui em vez de re-somar, para nunca haver
|
||||
# dois jeitos de calcular o mesmo total divergindo entre si. Decimal
|
||||
# exato (nunca float); ANTES de alocar número.
|
||||
itens_lib = [_to_item(item) for item in payload.itens]
|
||||
valor_nf = soma_itens_quantizados(itens_lib)
|
||||
valor_pagamento = payload.pagamento.valor.quantize(_CENT, rounding=ROUND_HALF_UP)
|
||||
if valor_pagamento != valor_nf:
|
||||
raise PagamentoTotalMismatchError(valor_pagamento, valor_nf)
|
||||
|
||||
# 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=itens_lib,
|
||||
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())
|
||||
@@ -1,9 +1,16 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from fiscal_svc.certificates.router import router as certificates_router
|
||||
from fiscal_svc.core.config import settings
|
||||
from fiscal_svc.emission.router import router as emission_router
|
||||
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.include_router(emission_router)
|
||||
|
||||
|
||||
@app.get("/v1/health")
|
||||
async def health_check() -> dict[str, str]:
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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,
|
||||
responses={
|
||||
401: {"description": "API key ausente ou inválida"},
|
||||
409: {
|
||||
"description": (
|
||||
"`detail.code`=`duplicate_fiscal_series` -- já existe uma série fiscal "
|
||||
"(live ou soft-deletada) para este (tenant_ref, branch_ref, document_model, serie)"
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
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],
|
||||
responses={401: {"description": "API key ausente ou inválida"}},
|
||||
)
|
||||
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,
|
||||
responses={
|
||||
401: {"description": "API key ausente ou inválida"},
|
||||
404: {"description": "Série fiscal não encontrada (ou de outro product -- anti-oracle)"},
|
||||
409: {"description": "`detail.code`=`fiscal_series_number_regression`"},
|
||||
},
|
||||
)
|
||||
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
|
||||
@@ -0,0 +1,436 @@
|
||||
"""Task 4: `POST/GET/DELETE /v1/certificados` -- ported from the auto's
|
||||
`tests/modules/fiscal/test_certificate.py`, porte table applied (JWT bearer
|
||||
+ `branch_id` path segment -> `X-Api-Key` + `tenant_ref`/`branch_ref` query
|
||||
params; anti-oracle now by `(product_id, tenant_ref)` instead of
|
||||
`organization_id`).
|
||||
|
||||
Fixture de PFX: gerado em memória via `cryptography` (chave RSA 2048 +
|
||||
certificado self-signed com o CNPJ no subject, formato ICP-Brasil e-CNPJ
|
||||
real -- `CN=RAZAO SOCIAL:CNPJ` + atributo `SERIALNUMBER`) -- NUNCA um
|
||||
certificado real no repo, mesma convenção do auto."""
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from cryptography import x509
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
from cryptography.x509.oid import NameOID
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from fiscal_svc.certificates import crypto as certificate_lib
|
||||
from fiscal_svc.certificates import service as certificate_service
|
||||
from fiscal_svc.core.db import get_session
|
||||
from fiscal_svc.documents.models import FiscalCertificate
|
||||
from fiscal_svc.main import app
|
||||
from fiscal_svc.tenancy.service import create_product
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _override_db(db_session):
|
||||
async def _get_session_override():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_session] = _get_session_override
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fiscal_cert_encryption_key(monkeypatch):
|
||||
monkeypatch.setenv("FISCAL_CERT_ENCRYPTION_KEY", Fernet.generate_key().decode("ascii"))
|
||||
certificate_lib._get_fernet.cache_clear()
|
||||
yield
|
||||
certificate_lib._get_fernet.cache_clear()
|
||||
|
||||
|
||||
def _build_test_pfx(
|
||||
*,
|
||||
cnpj: str = "14200166000187",
|
||||
password: str = "correct-horse-battery",
|
||||
not_valid_before: datetime | None = None,
|
||||
not_valid_after: datetime | None = None,
|
||||
cn: str | None = None,
|
||||
) -> bytes:
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
subject = issuer = x509.Name(
|
||||
[
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, cn or f"EMPRESA TESTE LTDA:{cnpj}"),
|
||||
x509.NameAttribute(NameOID.SERIAL_NUMBER, cnpj),
|
||||
]
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
nvb = not_valid_before if not_valid_before is not None else now - timedelta(days=1)
|
||||
nva = not_valid_after if not_valid_after is not None else now + timedelta(days=365)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(issuer)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(nvb)
|
||||
.not_valid_after(nva)
|
||||
.sign(key, hashes.SHA256())
|
||||
)
|
||||
return pkcs12.serialize_key_and_certificates(
|
||||
name=b"test",
|
||||
key=key,
|
||||
cert=cert,
|
||||
cas=None,
|
||||
encryption_algorithm=serialization.BestAvailableEncryption(password.encode("utf-8")),
|
||||
)
|
||||
|
||||
|
||||
def _headers(api_key: str) -> dict[str, str]:
|
||||
return {"X-Api-Key": api_key}
|
||||
|
||||
|
||||
async def _upload(client, api_key, tenant_ref, branch_ref, cnpj, pfx_bytes, password):
|
||||
return await client.post(
|
||||
"/v1/certificados",
|
||||
params={"tenant_ref": tenant_ref, "branch_ref": branch_ref},
|
||||
files={"file": ("cert.pfx", pfx_bytes, "application/x-pkcs12")},
|
||||
data={"password": password, "cnpj": cnpj},
|
||||
headers=_headers(api_key),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_valid_pfx_returns_201_with_correct_metadata_and_encrypted_binary(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
pfx_bytes = _build_test_pfx(cnpj="14200166000187", password="senha123")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await _upload(client, key, "tenant-1", "branch-1", "14200166000187", pfx_bytes, "senha123")
|
||||
|
||||
assert response.status_code == 201, response.text
|
||||
body = response.json()
|
||||
assert body["branch_ref"] == "branch-1"
|
||||
assert body["tenant_ref"] == "tenant-1"
|
||||
assert body["cnpj_certificado"] == "14200166000187"
|
||||
assert "EMPRESA TESTE LTDA" in body["subject_cn"]
|
||||
assert "pfx_encrypted" not in body
|
||||
assert "password_encrypted" not in body
|
||||
|
||||
result = await db_session.execute(
|
||||
select(FiscalCertificate).where(FiscalCertificate.id == uuid.UUID(body["id"]))
|
||||
)
|
||||
row = result.scalar_one()
|
||||
assert row.pfx_encrypted != pfx_bytes
|
||||
assert pfx_bytes not in row.pfx_encrypted
|
||||
assert certificate_lib.decrypt_bytes(row.pfx_encrypted) == pfx_bytes
|
||||
assert certificate_lib.decrypt_bytes(row.password_encrypted) == b"senha123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_wrong_password_is_422(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
pfx_bytes = _build_test_pfx(cnpj="14200166000187", password="correct-pw")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await _upload(client, key, "t1", "b1", "14200166000187", pfx_bytes, "wrong-pw")
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_non_pfx_file_is_422(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await _upload(client, key, "t1", "b1", "14200166000187", b"isso nao e um pfx", "qualquer")
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_expired_certificate_is_422(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
now = datetime.now(timezone.utc)
|
||||
pfx_bytes = _build_test_pfx(
|
||||
cnpj="14200166000187", password="senha123",
|
||||
not_valid_before=now - timedelta(days=400), not_valid_after=now - timedelta(days=10),
|
||||
)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await _upload(client, key, "t1", "b1", "14200166000187", pfx_bytes, "senha123")
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_not_valid_before_in_future_is_422(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
now = datetime.now(timezone.utc)
|
||||
pfx_bytes = _build_test_pfx(
|
||||
cnpj="14200166000187", password="senha123",
|
||||
not_valid_before=now + timedelta(days=5), not_valid_after=now + timedelta(days=400),
|
||||
)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await _upload(client, key, "t1", "b1", "14200166000187", pfx_bytes, "senha123")
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_cnpj_mismatch_between_payload_and_certificate_is_422_naming_both(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
pfx_bytes = _build_test_pfx(cnpj="99887766000155", password="senha123")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await _upload(client, key, "t1", "b1", "14200166000187", pfx_bytes, "senha123")
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
assert "14200166000187" in response.text
|
||||
assert "99887766000155" in response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_certificate_returns_metadata_without_binary(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
pfx_bytes = _build_test_pfx(cnpj="14200166000187", password="senha123")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await _upload(client, key, "t1", "b1", "14200166000187", pfx_bytes, "senha123")
|
||||
response = await client.get(
|
||||
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(key)
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["cnpj_certificado"] == "14200166000187"
|
||||
assert "pfx_encrypted" not in body
|
||||
assert "password_encrypted" not in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_product_branch_is_404_not_leaked(db_session):
|
||||
key_a = f"k-{uuid.uuid4().hex}"
|
||||
key_b = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key_a)
|
||||
await create_product(db_session, name="crm", api_key=key_b)
|
||||
pfx_bytes = _build_test_pfx(cnpj="14200166000187", password="senha123")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await _upload(client, key_a, "t1", "b1", "14200166000187", pfx_bytes, "senha123")
|
||||
get_response = await client.get(
|
||||
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(key_b)
|
||||
)
|
||||
|
||||
assert get_response.status_code == 404, get_response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_tenant_ref_same_product_is_404(db_session):
|
||||
"""Anti-oracle boundary is `(product_id, tenant_ref)` (design spec
|
||||
decision #4) -- even under the SAME product, a wrong `tenant_ref` for a
|
||||
real `branch_ref` must 404, not leak the certificate."""
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
pfx_bytes = _build_test_pfx(cnpj="14200166000187", password="senha123")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await _upload(client, key, "tenant-a", "b1", "14200166000187", pfx_bytes, "senha123")
|
||||
get_response = await client.get(
|
||||
"/v1/certificados", params={"tenant_ref": "tenant-b", "branch_ref": "b1"}, headers=_headers(key)
|
||||
)
|
||||
|
||||
assert get_response.status_code == 404, get_response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_second_upload_replaces_the_first_soft_deleting_it(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
product = await create_product(db_session, name="auto", api_key=key)
|
||||
first_pfx = _build_test_pfx(cnpj="14200166000187", password="senha123", cn="PRIMEIRO:14200166000187")
|
||||
second_pfx = _build_test_pfx(cnpj="14200166000187", password="senha456", cn="SEGUNDO:14200166000187")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
first_response = await _upload(client, key, "t1", "b1", "14200166000187", first_pfx, "senha123")
|
||||
second_response = await _upload(client, key, "t1", "b1", "14200166000187", second_pfx, "senha456")
|
||||
get_response = await client.get(
|
||||
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(key)
|
||||
)
|
||||
|
||||
assert first_response.status_code == 201, first_response.text
|
||||
assert second_response.status_code == 201, second_response.text
|
||||
assert get_response.json()["id"] == second_response.json()["id"]
|
||||
assert "SEGUNDO" in get_response.json()["subject_cn"]
|
||||
|
||||
# `product_id` scoped -- `tenant_ref="t1"`/`branch_ref="b1"` are literals
|
||||
# reused by MANY tests in this file/suite (the `db_session` fixture only
|
||||
# rolls back at teardown, it does not truncate what earlier tests already
|
||||
# committed), so an unscoped query here would count every OTHER test's
|
||||
# certificates for the same branch_ref too.
|
||||
result = await db_session.execute(
|
||||
select(FiscalCertificate).where(
|
||||
FiscalCertificate.product_id == product.id, FiscalCertificate.branch_ref == "b1"
|
||||
)
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
assert len(rows) == 2
|
||||
live = [r for r in rows if r.deleted_at is None]
|
||||
dead = [r for r in rows if r.deleted_at is not None]
|
||||
assert len(live) == 1
|
||||
assert len(dead) == 1
|
||||
assert live[0].id == uuid.UUID(second_response.json()["id"])
|
||||
assert dead[0].id == uuid.UUID(first_response.json()["id"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_soft_deletes_and_get_then_404s(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
pfx_bytes = _build_test_pfx(cnpj="14200166000187", password="senha123")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await _upload(client, key, "t1", "b1", "14200166000187", pfx_bytes, "senha123")
|
||||
delete_response = await client.delete(
|
||||
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(key)
|
||||
)
|
||||
get_response = await client.get(
|
||||
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(key)
|
||||
)
|
||||
|
||||
assert delete_response.status_code == 204, delete_response.text
|
||||
assert get_response.status_code == 404, get_response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_without_certificate_is_404(db_session):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name="auto", api_key=key)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.delete(
|
||||
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(key)
|
||||
)
|
||||
|
||||
assert response.status_code == 404, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_api_key_is_401(db_session):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"})
|
||||
|
||||
assert response.status_code == 401, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_uploads_for_same_product_branch_only_one_wins_the_other_gets_409(
|
||||
test_engine, db_session, monkeypatch
|
||||
):
|
||||
"""Mesma prova do auto (`test_concurrent_uploads_for_same_branch_only_
|
||||
one_wins_the_other_gets_409`): duas `AsyncSession` distintas contra o
|
||||
MESMO `test_engine`, disparadas via `asyncio.gather` -- concorrência
|
||||
REAL, não simulada.
|
||||
|
||||
Sem sincronização explícita, as duas corrotinas rodam no MESMO event
|
||||
loop e podem interleavear de um jeito que NÃO exercita a corrida real:
|
||||
se a primeira `upload_certificate` COMMITA inteiro antes de a segunda
|
||||
fazer o pre-check `_get_live_certificate_by_tenant_branch`, a segunda
|
||||
enxerga a linha viva da primeira e faz um REPLACE LEGÍTIMO (soft-delete
|
||||
+ insert) -- 2 sucessos, 1 linha viva, comportamento CORRETO do
|
||||
serviço, mas que quebraria a asserção abaixo (que exige exatamente 1
|
||||
sucesso + 1 conflito). Mesma técnica de sincronização determinística de
|
||||
`auto/backend/tests/modules/financeiro/test_pay_account_payable.py::
|
||||
test_pay_concurrent_with_cancel_via_http_lock_serializes_the_race`:
|
||||
monkeypatch no ponto de await entre o pre-check e o commit, com um
|
||||
`asyncio.Event`, para FORÇAR a janela vulnerável -- as duas chamadas
|
||||
fazem o pre-check (ambas leem `None`) ANTES de qualquer uma commitar,
|
||||
e só depois disso o resultado passa a depender só do índice parcial
|
||||
único do banco (determinístico: 1 vencedor, 1 `IntegrityError`
|
||||
traduzido em `CertificateUploadConflictError`)."""
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
product = await create_product(db_session, name="auto", api_key=key)
|
||||
pfx_a = _build_test_pfx(cnpj="14200166000187", password="senha123", cn="A:14200166000187")
|
||||
pfx_b = _build_test_pfx(cnpj="14200166000187", password="senha456", cn="B:14200166000187")
|
||||
|
||||
original_precheck = certificate_service._get_live_certificate_by_tenant_branch
|
||||
precheck_done = asyncio.Event()
|
||||
first_precheck_claimed = False
|
||||
|
||||
async def _precheck_forcing_both_before_any_commit(session, product_id, tenant_ref, branch_ref):
|
||||
nonlocal first_precheck_claimed
|
||||
if not first_precheck_claimed:
|
||||
first_precheck_claimed = True
|
||||
result = await original_precheck(session, product_id, tenant_ref, branch_ref)
|
||||
precheck_done.set()
|
||||
# Segura ESTA chamada (ainda antes do commit em upload_certificate)
|
||||
# até depois que a outra também tenha feito seu pre-check --
|
||||
# garante que as DUAS leem "nenhum certificado vivo" antes de
|
||||
# qualquer uma escrever.
|
||||
await asyncio.sleep(0.3)
|
||||
return result
|
||||
await precheck_done.wait()
|
||||
return await original_precheck(session, product_id, tenant_ref, branch_ref)
|
||||
|
||||
monkeypatch.setattr(
|
||||
certificate_service,
|
||||
"_get_live_certificate_by_tenant_branch",
|
||||
_precheck_forcing_both_before_any_commit,
|
||||
)
|
||||
|
||||
session_maker = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||
session_a = session_maker()
|
||||
session_b = session_maker()
|
||||
try:
|
||||
results = await asyncio.gather(
|
||||
certificate_service.upload_certificate(
|
||||
session_a, product.id, "t1", "b1", "14200166000187", pfx_a, "senha123"
|
||||
),
|
||||
certificate_service.upload_certificate(
|
||||
session_b, product.id, "t1", "b1", "14200166000187", pfx_b, "senha456"
|
||||
),
|
||||
return_exceptions=True,
|
||||
)
|
||||
finally:
|
||||
await session_a.close()
|
||||
await session_b.close()
|
||||
|
||||
successes = [r for r in results if not isinstance(r, BaseException)]
|
||||
errors = [r for r in results if isinstance(r, BaseException)]
|
||||
|
||||
assert len(successes) == 1, f"expected exactly 1 winner, got {len(successes)}: {results!r}"
|
||||
assert len(errors) == 1, f"expected exactly 1 conflict error, got {len(errors)}: {results!r}"
|
||||
assert isinstance(errors[0], certificate_service.CertificateUploadConflictError), errors[0]
|
||||
|
||||
live_result = await db_session.execute(
|
||||
select(FiscalCertificate).where(
|
||||
FiscalCertificate.product_id == product.id,
|
||||
FiscalCertificate.branch_ref == "b1",
|
||||
FiscalCertificate.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
live_rows = live_result.scalars().all()
|
||||
assert len(live_rows) == 1, (
|
||||
f"expected exactly 1 live certificate after the race, found {len(live_rows)} -- "
|
||||
"the DB-level partial unique index should have blocked the second insert"
|
||||
)
|
||||
@@ -0,0 +1,644 @@
|
||||
"""Task 5: `POST /v1/emissoes` + `GET /v1/documentos/{id}[/xml]` -- ported
|
||||
from the auto's `tests/modules/fiscal/test_emissao.py`, with the porte
|
||||
table's biggest structural change applied: this service receives a
|
||||
COMPLETE `EmissaoRequest` (no Sale/Branch/Person/Part collection), so the
|
||||
"caminho feliz" setup here is FAR shorter -- upload a certificate + create a
|
||||
series + POST a golden's `DadosEmissao` payload, no cadastro at all.
|
||||
|
||||
Golden cases (`sowai_fiscal.goldens/*.input.json`) are the payload SOURCE
|
||||
for the happy path -- proves the service round-trips a real, lib-shaped
|
||||
`DadosEmissao` end to end. Byte-exact signature determinism against
|
||||
pre-generated `.expected.xml` fixtures is Task 6's `test_signed_goldens.py`;
|
||||
this file only proves the document is well-formed/valid and the invariants
|
||||
(outbox, idempotency, `ver_proc` obrigatório) hold."""
|
||||
import importlib.resources
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography import x509
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
from cryptography.x509.oid import NameOID
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from lxml import etree
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from fiscal_svc.certificates import crypto as certificate_lib
|
||||
from fiscal_svc.core.db import get_session
|
||||
from fiscal_svc.documents.models import FiscalDocument, FiscalIdempotencyKey, FiscalSeries
|
||||
from fiscal_svc.emission import service as emission_service
|
||||
from fiscal_svc.emission.schemas import EmissaoRequest
|
||||
from fiscal_svc.main import app
|
||||
from fiscal_svc.tenancy.service import create_product
|
||||
|
||||
_GOLDENS_DIR = Path(str(importlib.resources.files("sowai_fiscal") / "goldens"))
|
||||
_CNPJ_EMITENTE = "12345678000190"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _override_db(db_session):
|
||||
async def _get_session_override():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_session] = _get_session_override
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fiscal_cert_encryption_key(monkeypatch):
|
||||
monkeypatch.setenv("FISCAL_CERT_ENCRYPTION_KEY", Fernet.generate_key().decode("ascii"))
|
||||
certificate_lib._get_fernet.cache_clear()
|
||||
yield
|
||||
certificate_lib._get_fernet.cache_clear()
|
||||
|
||||
|
||||
def _build_test_pfx(cnpj: str = _CNPJ_EMITENTE, password: str = "senha123") -> bytes:
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
subject = issuer = x509.Name(
|
||||
[
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, f"AUTOPECAS THIAGO LTDA:{cnpj}"),
|
||||
x509.NameAttribute(NameOID.SERIAL_NUMBER, cnpj),
|
||||
]
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(issuer)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - timedelta(days=1))
|
||||
.not_valid_after(now + timedelta(days=365))
|
||||
.sign(key, hashes.SHA256())
|
||||
)
|
||||
return pkcs12.serialize_key_and_certificates(
|
||||
name=b"test", key=key, cert=cert, cas=None,
|
||||
encryption_algorithm=serialization.BestAvailableEncryption(password.encode("utf-8")),
|
||||
)
|
||||
|
||||
|
||||
def _headers(api_key: str) -> dict[str, str]:
|
||||
return {"X-Api-Key": api_key}
|
||||
|
||||
|
||||
async def _product_and_key(db_session, name="auto"):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
product = await create_product(db_session, name=name, api_key=key)
|
||||
return product, key
|
||||
|
||||
|
||||
def _payload_from_golden(
|
||||
case: str, *, tenant_ref: str = "t1", branch_ref: str = "b1", ver_proc: str = "sowai-auto/1b.1"
|
||||
) -> tuple[dict, dict]:
|
||||
raw = json.loads((_GOLDENS_DIR / f"{case}.input.json").read_text(encoding="utf-8"))
|
||||
payload = {
|
||||
"tenant_ref": tenant_ref,
|
||||
"branch_ref": branch_ref,
|
||||
"document_model": "55",
|
||||
"serie": raw["serie"],
|
||||
"emitente": raw["emitente"],
|
||||
"itens": raw["itens"],
|
||||
"pagamento": raw["pagamento"],
|
||||
"ambiente": raw["ambiente"],
|
||||
"uf_destino_tipo": raw["uf_destino_tipo"],
|
||||
"destinatario": raw.get("destinatario"),
|
||||
"ver_proc": ver_proc,
|
||||
}
|
||||
return payload, raw
|
||||
|
||||
|
||||
async def _setup_certificate_and_series(
|
||||
db_session, client, key, *, tenant_ref, branch_ref, serie, next_number, cnpj=_CNPJ_EMITENTE,
|
||||
):
|
||||
pfx_bytes = _build_test_pfx(cnpj=cnpj)
|
||||
upload_response = await client.post(
|
||||
"/v1/certificados",
|
||||
params={"tenant_ref": tenant_ref, "branch_ref": branch_ref},
|
||||
files={"file": ("cert.pfx", pfx_bytes, "application/x-pkcs12")},
|
||||
data={"password": "senha123", "cnpj": cnpj},
|
||||
headers=_headers(key),
|
||||
)
|
||||
assert upload_response.status_code == 201, upload_response.text
|
||||
|
||||
series_response = await client.post(
|
||||
"/v1/series",
|
||||
json={
|
||||
"tenant_ref": tenant_ref, "branch_ref": branch_ref,
|
||||
"document_model": "55", "serie": serie, "next_number": next_number,
|
||||
},
|
||||
headers=_headers(key),
|
||||
)
|
||||
assert series_response.status_code == 201, series_response.text
|
||||
return series_response.json()
|
||||
|
||||
|
||||
def _xsd_schema():
|
||||
"""Resolvido do pacote `nfelib` INSTALADO -- ausência é FALHA, não
|
||||
skip, mesma convenção do auto (`tests/modules/fiscal/test_emissao.py::
|
||||
_xsd_schema`)."""
|
||||
import nfelib
|
||||
|
||||
path = Path(nfelib.__file__).parent / "nfe" / "schemas" / "v4_0" / "nfe_v4.00.xsd"
|
||||
if not path.exists():
|
||||
pytest.fail(f"XSD não encontrado em {path} -- validação XSD é prova OBRIGATÓRIA")
|
||||
return etree.XMLSchema(etree.parse(str(path)))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emitir_documento_caminho_feliz_gera_documento_assinado_xsd_valido(db_session):
|
||||
product, key = await _product_and_key(db_session)
|
||||
payload, raw = _payload_from_golden("caso_padrao_intra")
|
||||
|
||||
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"],
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
"/v1/emissoes", json=payload,
|
||||
headers={**_headers(key), "Idempotency-Key": f"idem-{uuid.uuid4().hex}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201, response.text
|
||||
body = response.json()
|
||||
assert body["status"] == "ASSINADO"
|
||||
assert body["numero"] == raw["numero"]
|
||||
assert len(body["chave_acesso"]) == 44 and body["chave_acesso"].isdigit()
|
||||
assert body["tenant_ref"] == "t1"
|
||||
assert body["branch_ref"] == "b1"
|
||||
assert "xml_assinado" not in body
|
||||
|
||||
result = await db_session.execute(
|
||||
select(FiscalDocument).where(FiscalDocument.id == uuid.UUID(body["id"]))
|
||||
)
|
||||
document = result.scalar_one()
|
||||
assert "<Signature" in document.xml_assinado
|
||||
|
||||
schema = _xsd_schema()
|
||||
doc = etree.fromstring(document.xml_assinado.encode("utf-8"))
|
||||
valid = schema.validate(doc)
|
||||
assert valid, schema.error_log
|
||||
|
||||
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"] + 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emitir_documento_endpoint_get_and_xml(db_session):
|
||||
product, key = await _product_and_key(db_session)
|
||||
payload, raw = _payload_from_golden("caso_padrao_inter", tenant_ref="t2", branch_ref="b2")
|
||||
|
||||
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="t2", branch_ref="b2",
|
||||
serie=raw["serie"], next_number=raw["numero"],
|
||||
)
|
||||
post_response = await client.post(
|
||||
"/v1/emissoes", json=payload,
|
||||
headers={**_headers(key), "Idempotency-Key": f"idem-{uuid.uuid4().hex}"},
|
||||
)
|
||||
document_id = post_response.json()["id"]
|
||||
|
||||
get_response = await client.get(f"/v1/documentos/{document_id}", headers=_headers(key))
|
||||
xml_response = await client.get(f"/v1/documentos/{document_id}/xml", headers=_headers(key))
|
||||
|
||||
assert post_response.status_code == 201, post_response.text
|
||||
assert get_response.status_code == 200, get_response.text
|
||||
assert "xml_assinado" not in get_response.json()
|
||||
assert xml_response.status_code == 200
|
||||
assert xml_response.headers["content-type"].startswith("application/xml")
|
||||
assert "<NFe" in xml_response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ver_proc_ausente_e_422(db_session):
|
||||
product, key = await _product_and_key(db_session)
|
||||
payload, raw = _payload_from_golden("caso_padrao_intra")
|
||||
del payload["ver_proc"]
|
||||
|
||||
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"],
|
||||
)
|
||||
response = await client.post(
|
||||
"/v1/emissoes", json=payload,
|
||||
headers={**_headers(key), "Idempotency-Key": f"idem-{uuid.uuid4().hex}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idempotency_key_repetida_devolve_o_mesmo_documento_com_200(db_session):
|
||||
product, key = await _product_and_key(db_session)
|
||||
payload, raw = _payload_from_golden("caso_padrao_intra")
|
||||
idem_key = f"idem-{uuid.uuid4().hex}"
|
||||
|
||||
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"],
|
||||
)
|
||||
|
||||
first = await client.post(
|
||||
"/v1/emissoes", json=payload, headers={**_headers(key), "Idempotency-Key": idem_key}
|
||||
)
|
||||
second = await client.post(
|
||||
"/v1/emissoes", json=payload, headers={**_headers(key), "Idempotency-Key": idem_key}
|
||||
)
|
||||
|
||||
assert first.status_code == 201, first.text
|
||||
assert second.status_code == 200, second.text
|
||||
assert first.json()["id"] == second.json()["id"]
|
||||
|
||||
result = await db_session.execute(
|
||||
select(FiscalDocument).where(
|
||||
FiscalDocument.product_id == product.id,
|
||||
FiscalDocument.tenant_ref == "t1", FiscalDocument.branch_ref == "b1",
|
||||
)
|
||||
)
|
||||
assert len(result.scalars().all()) == 1, "idempotency-key repetida não deveria emitir um segundo documento"
|
||||
|
||||
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"] + 1, (
|
||||
"a segunda chamada (mesma idempotency-key) não deveria ter alocado um SEGUNDO número"
|
||||
)
|
||||
|
||||
|
||||
# --- FIX 4 (F2 review, MINOR): replay de idempotency-key ignora soft-delete
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_document_by_idempotency_key_ignora_documento_soft_deletado(
|
||||
db_session, test_engine
|
||||
):
|
||||
"""FIX 4 (F2 review, MINOR): `_get_document_by_idempotency_key` filtra
|
||||
`deleted_at IS NULL`. O CONTRATO deste fix é estreito -- um REPLAY nunca
|
||||
DEVOLVE um documento morto -- e é exatamente isso que se prova aqui, na
|
||||
query, não num POST.
|
||||
|
||||
Escopo (deliberado): nenhum caminho da F2 soft-deleta um `FiscalDocument`
|
||||
(a `FiscalIdempotencyKey` que aponta pra ele, aliás, sobrevive PARA
|
||||
SEMPRE -- sem soft-delete mixin, por design), então o cenário é semeado à
|
||||
mão. O fix NÃO promete "re-emitir sob a mesma chave": a chave de
|
||||
idempotência sobrevivente ainda colide no UNIQUE `(product_id,
|
||||
idempotency_key)`, e o que fazer nessa colisão é decisão da futura
|
||||
feature de CANCELAMENTO (que ainda não existe) -- fora do escopo desta
|
||||
MINOR. Testar a query é o que casa com o que o fix entrega; um teste
|
||||
end-to-end de nova emissão exigiria semântica ainda não construída."""
|
||||
product, key = await _product_and_key(db_session)
|
||||
_payload, raw = _payload_from_golden("caso_padrao_intra")
|
||||
idem_key = f"idem-dead-{uuid.uuid4().hex}"
|
||||
|
||||
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"],
|
||||
)
|
||||
|
||||
# dead-doc + sua idempotency-key: sessão PRÓPRIA (não polui o db_session
|
||||
# que o app reusa via override), commit -> visível por READ COMMITTED.
|
||||
# Chave ÚNICA por invocação (o banco do pod persiste entre execuções e a
|
||||
# chave é global-unique -- hardcode colidiria no 2º run).
|
||||
chave_morta = f"{uuid.uuid4().int:044d}"[:44]
|
||||
seed_maker = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||
async with seed_maker() as seed:
|
||||
series = (
|
||||
await seed.execute(
|
||||
select(FiscalSeries).where(
|
||||
FiscalSeries.product_id == product.id,
|
||||
FiscalSeries.tenant_ref == "t1",
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
dead_document = FiscalDocument(
|
||||
product_id=product.id, tenant_ref="t1", branch_ref="b1",
|
||||
series_id=series.id, document_model="55", serie=raw["serie"],
|
||||
numero=raw["numero"], chave_acesso=chave_morta,
|
||||
codigo_numerico="87654321", status="ASSINADO",
|
||||
ambiente="homologacao", xml_assinado="<NFe/>",
|
||||
deleted_at=datetime.now(timezone.utc),
|
||||
)
|
||||
seed.add(dead_document)
|
||||
await seed.flush()
|
||||
seed.add(
|
||||
FiscalIdempotencyKey(
|
||||
product_id=product.id, idempotency_key=idem_key, document_id=dead_document.id
|
||||
)
|
||||
)
|
||||
await seed.commit()
|
||||
|
||||
# SEM o `deleted_at IS NULL` do fix esta query devolveria o dead-doc.
|
||||
replay = await emission_service._get_document_by_idempotency_key(
|
||||
db_session, product.id, idem_key
|
||||
)
|
||||
assert replay is None, (
|
||||
"sem o filtro `deleted_at IS NULL` a query devolveria o documento morto"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idempotency_key_ausente_e_422(db_session):
|
||||
product, key = await _product_and_key(db_session)
|
||||
payload, raw = _payload_from_golden("caso_padrao_intra")
|
||||
|
||||
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"],
|
||||
)
|
||||
response = await client.post("/v1/emissoes", json=payload, headers=_headers(key))
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_certificado_ausente_e_409_fiscal_config_missing(db_session):
|
||||
product, key = await _product_and_key(db_session)
|
||||
payload, raw = _payload_from_golden("caso_padrao_intra")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
# Only the series, no certificate uploaded.
|
||||
await client.post(
|
||||
"/v1/series",
|
||||
json={
|
||||
"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55",
|
||||
"serie": raw["serie"], "next_number": raw["numero"],
|
||||
},
|
||||
headers=_headers(key),
|
||||
)
|
||||
response = await client.post(
|
||||
"/v1/emissoes", json=payload,
|
||||
headers={**_headers(key), "Idempotency-Key": f"idem-{uuid.uuid4().hex}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409, response.text
|
||||
assert response.json()["detail"]["code"] == "fiscal_config_missing"
|
||||
|
||||
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"], "número não pode ter sido queimado sem certificado"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_serie_ausente_e_409_fiscal_config_missing(db_session):
|
||||
product, key = await _product_and_key(db_session)
|
||||
payload, raw = _payload_from_golden("caso_padrao_intra")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
pfx_bytes = _build_test_pfx()
|
||||
await client.post(
|
||||
"/v1/certificados",
|
||||
params={"tenant_ref": "t1", "branch_ref": "b1"},
|
||||
files={"file": ("cert.pfx", pfx_bytes, "application/x-pkcs12")},
|
||||
data={"password": "senha123", "cnpj": _CNPJ_EMITENTE},
|
||||
headers=_headers(key),
|
||||
)
|
||||
# No series created.
|
||||
response = await client.post(
|
||||
"/v1/emissoes", json=payload,
|
||||
headers={**_headers(key), "Idempotency-Key": f"idem-{uuid.uuid4().hex}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409, response.text
|
||||
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"]
|
||||
|
||||
|
||||
# --- FIX 2 (F2 review): certificado é POR TENANT, não só por branch_ref -----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dois_tenants_do_mesmo_produto_reusando_branch_ref_tem_certificados_isolados(db_session):
|
||||
"""Antes do FIX 2, `_get_live_certificate` (emissão) e o pre-check de
|
||||
upload omitiam `tenant_ref` -- dois tenants do MESMO produto reusando o
|
||||
MESMO `branch_ref` opaco ("matriz", plausível: refs são strings livres
|
||||
do produto chamador) colapsavam no MESMO slot. O upload do tenant B
|
||||
soft-deletava o certificado ainda vivo do tenant A (replace
|
||||
"legítimo"), e a emissão do tenant A passava a resolver o certificado
|
||||
de B.
|
||||
|
||||
A prova combina FIX 1 (CNPJ do emitente vs certificado) para tornar o
|
||||
vínculo OBSERVÁVEL: sem o FIX 2, o certificado "vivo" para `branch_ref
|
||||
="matriz"` seria o de B (CNPJ_B) para AMBOS os tenants -- a emissão do
|
||||
tenant A com `emitente.cnpj`=CNPJ_A bateria no FIX 1 e devolveria 409
|
||||
`emitente_certificate_cnpj_mismatch` em vez de 201."""
|
||||
cnpj_a = "11222333000181"
|
||||
cnpj_b = "44555666000107"
|
||||
product, key = await _product_and_key(db_session)
|
||||
payload_a, raw_a = _payload_from_golden(
|
||||
"caso_padrao_intra", tenant_ref="tenant-a", branch_ref="matriz"
|
||||
)
|
||||
payload_a["emitente"] = {**payload_a["emitente"], "cnpj": cnpj_a}
|
||||
payload_b, raw_b = _payload_from_golden(
|
||||
"caso_padrao_inter", tenant_ref="tenant-b", branch_ref="matriz"
|
||||
)
|
||||
payload_b["emitente"] = {**payload_b["emitente"], "cnpj": cnpj_b}
|
||||
|
||||
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="tenant-a", branch_ref="matriz",
|
||||
serie=raw_a["serie"], next_number=raw_a["numero"], cnpj=cnpj_a,
|
||||
)
|
||||
# Segundo upload, MESMO produto, MESMO branch_ref, tenant DIFERENTE.
|
||||
await _setup_certificate_and_series(
|
||||
db_session, client, key, tenant_ref="tenant-b", branch_ref="matriz",
|
||||
serie=raw_b["serie"], next_number=raw_b["numero"], cnpj=cnpj_b,
|
||||
)
|
||||
|
||||
# O certificado de A segue vivo (GET de A não foi soft-deletado
|
||||
# pelo upload de B) -- prova direta, sem depender do FIX 1.
|
||||
get_a = await client.get(
|
||||
"/v1/certificados",
|
||||
params={"tenant_ref": "tenant-a", "branch_ref": "matriz"},
|
||||
headers=_headers(key),
|
||||
)
|
||||
assert get_a.status_code == 200, get_a.text
|
||||
assert get_a.json()["cnpj_certificado"] == cnpj_a
|
||||
|
||||
emit_a = await client.post(
|
||||
"/v1/emissoes", json=payload_a,
|
||||
headers={**_headers(key), "Idempotency-Key": f"idem-a-{uuid.uuid4().hex}"},
|
||||
)
|
||||
emit_b = await client.post(
|
||||
"/v1/emissoes", json=payload_b,
|
||||
headers={**_headers(key), "Idempotency-Key": f"idem-b-{uuid.uuid4().hex}"},
|
||||
)
|
||||
|
||||
assert emit_a.status_code == 201, emit_a.text
|
||||
assert emit_b.status_code == 201, emit_b.text
|
||||
|
||||
|
||||
# --- FIX 3 (F2 review): Σ vPag vs vNF ---------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pagamento_divergente_do_total_dos_itens_e_409_e_nao_queima_numero(db_session):
|
||||
"""A SEFAZ rejeita `pagamento.valor != vNF` com "Valor do Pagamento
|
||||
difere do total" (`sowai_fiscal.xml_builder` module docstring) -- este
|
||||
serviço promete validar "coerência estrutural (somas)" (spec decisão
|
||||
#3) antes de queimar um número, não só deixar a rejeição acontecer do
|
||||
lado de lá depois de já ter um `FiscalDocument` ASSINADO persistido."""
|
||||
product, key = await _product_and_key(db_session)
|
||||
payload, raw = _payload_from_golden("caso_padrao_intra")
|
||||
valor_correto = Decimal(str(payload["pagamento"]["valor"]))
|
||||
payload["pagamento"]["valor"] = str(valor_correto + Decimal("10.00"))
|
||||
|
||||
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"],
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
"/v1/emissoes", json=payload,
|
||||
headers={**_headers(key), "Idempotency-Key": f"idem-{uuid.uuid4().hex}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409, response.text
|
||||
assert response.json()["detail"]["code"] == "pagamento_total_diverge"
|
||||
|
||||
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 pagamento divergente do total dos itens"
|
||||
)
|
||||
|
||||
|
||||
# --- prova do outbox --------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outbox_falha_na_assinatura_nao_queima_o_numero_nem_cria_documento(db_session, monkeypatch):
|
||||
"""Chama `emission.service.emitir_documento` DIRETO (não via HTTP,
|
||||
mesma escolha do auto's `test_outbox_falha_na_assinatura_...`) -- uma
|
||||
exceção não mapeada propagando pela pilha ASGI real não é o que este
|
||||
teste prova; o que importa é o estado do banco DEPOIS do rollback."""
|
||||
product, key = await _product_and_key(db_session)
|
||||
payload, raw = _payload_from_golden("caso_padrao_intra")
|
||||
|
||||
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"],
|
||||
)
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise RuntimeError("falha simulada na assinatura")
|
||||
|
||||
monkeypatch.setattr(emission_service, "sign_nfe_xml", _boom)
|
||||
|
||||
# Capturado ANTES da chamada -- `db_session.rollback()` (abaixo) EXPIRA
|
||||
# todo objeto ORM já carregado nesta sessão (independente de `expire_on_
|
||||
# commit`, que só rege o comportamento pós-COMMIT); tocar `product.id`
|
||||
# DEPOIS do rollback, fora do contexto greenlet do SQLAlchemy, estoura
|
||||
# `MissingGreenlet` -- mesma pegadinha que o auto's próprio teste
|
||||
# documenta para `series.id`/`sale.id`.
|
||||
product_id = product.id
|
||||
|
||||
request = EmissaoRequest.model_validate(payload)
|
||||
with pytest.raises(RuntimeError, match="falha simulada"):
|
||||
await emission_service.emitir_documento(db_session, product, request, f"idem-{uuid.uuid4().hex}")
|
||||
await db_session.rollback()
|
||||
|
||||
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 -- a falha aconteceu depois da alocação e "
|
||||
"antes do commit, o rollback deve desfazer as duas coisas"
|
||||
)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(FiscalDocument).where(
|
||||
FiscalDocument.product_id == product_id,
|
||||
FiscalDocument.tenant_ref == "t1", FiscalDocument.branch_ref == "b1",
|
||||
)
|
||||
)
|
||||
assert result.scalars().first() is None, "nenhum FiscalDocument deveria ter sido criado"
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><infNFe versao="4.00" Id="NFe41260712345678000190550010000010051100000050"><ide><cUF>41</cUF><cNF>10000005</cNF><natOp>Venda</natOp><mod>55</mod><serie>1</serie><nNF>1005</nNF><dhEmi>2026-07-16T10:20:00-03:00</dhEmi><tpNF>1</tpNF><idDest>1</idDest><cMunFG>4106902</cMunFG><tpImp>1</tpImp><tpEmis>1</tpEmis><cDV>0</cDV><tpAmb>2</tpAmb><finNFe>1</finNFe><indFinal>1</indFinal><indPres>1</indPres><procEmi>0</procEmi><verProc>sowai-auto/1b.1</verProc></ide><emit><CNPJ>12345678000190</CNPJ><xNome>AUTOPECAS THIAGO LTDA</xNome><xFant>Thiago Auto Center</xFant><enderEmit><xLgr>Rua das Autopecas</xLgr><nro>1500</nro><xBairro>Centro</xBairro><cMun>4106902</cMun><xMun>Curitiba</xMun><UF>PR</UF><CEP>80000000</CEP><cPais>1058</cPais><xPais>Brasil</xPais><fone>4133334444</fone></enderEmit><IE>1234567890</IE><CRT>1</CRT></emit><dest><CNPJ>99887766000155</CNPJ><xNome>NF-E EMITIDA EM AMBIENTE DE HOMOLOGACAO - SEM VALOR FISCAL</xNome><enderDest><xLgr>Av Cliente</xLgr><nro>50</nro><xBairro>Bairro</xBairro><cMun>4106902</cMun><xMun>Curitiba</xMun><UF>PR</UF><CEP>80000100</CEP><cPais>1058</cPais><xPais>Brasil</xPais></enderDest><indIEDest>9</indIEDest></dest><det nItem="1"><prod><cProd>PC001</cProd><cEAN>SEM GTIN</cEAN><xProd>Filtro de oleo</xProd><NCM>84212300</NCM><CFOP>5102</CFOP><uCom>UN</uCom><qCom>1</qCom><vUnCom>100.00</vUnCom><vProd>100.00</vProd><cEANTrib>SEM GTIN</cEANTrib><uTrib>UN</uTrib><qTrib>1</qTrib><vUnTrib>100.00</vUnTrib><indTot>1</indTot></prod><imposto><ICMS><ICMSSN102><orig>0</orig><CSOSN>102</CSOSN></ICMSSN102></ICMS><PIS><PISNT><CST>08</CST></PISNT></PIS><COFINS><COFINSNT><CST>08</CST></COFINSNT></COFINS><IBSCBS><CST>000</CST><cClassTrib>000001</cClassTrib><gIBSCBS><vBC>100.00</vBC><gIBSUF><pIBSUF>0.1000</pIBSUF><vIBSUF>10.00</vIBSUF></gIBSUF><gCBS><pCBS>0.9000</pCBS><vCBS>90.00</vCBS></gCBS></gIBSCBS></IBSCBS></imposto></det><total><ICMSTot><vBC>0.00</vBC><vICMS>0.00</vICMS><vICMSDeson>0.00</vICMSDeson><vFCP>0.00</vFCP><vBCST>0.00</vBCST><vST>0.00</vST><vFCPST>0.00</vFCPST><vFCPSTRet>0.00</vFCPSTRet><vProd>100.00</vProd><vFrete>0.00</vFrete><vSeg>0.00</vSeg><vDesc>0.00</vDesc><vII>0.00</vII><vIPI>0.00</vIPI><vIPIDevol>0.00</vIPIDevol><vPIS>0.00</vPIS><vCOFINS>0.00</vCOFINS><vOutro>0.00</vOutro><vNF>100.00</vNF></ICMSTot></total><transp><modFrete>9</modFrete></transp><pag><detPag><indPag>0</indPag><tPag>01</tPag><vPag>100.00</vPag></detPag></pag></infNFe><Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><Reference URI="#NFe41260712345678000190550010000010051100000050"><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><DigestValue>EIBfqHSPgzGamG7niiWJY8QjvaY=</DigestValue></Reference></SignedInfo><SignatureValue>lMywqdUdSFsbf5SR1kOmyByaFI1aSoUJwwgbz+P7Qri6iW+pJy+JJcapJnQcEdYbbFTqMJYJeRM87xHlQngg0SnPqd1ZjcLqZgXf6tm7GZriS3SNaMiXUC5EG9BnawvP4p7m5GviX3JJNKs2lvG0unBUtptq4aH/cZe5jd62SxeqyaeX4PxW/pbvkSBu3WndTLvM56vajMgimjbjQ8zLid85XJkVEAk9rV7KObZsHl0vuvFUqnZsAVaIJxB8nmmmcn/clT4AsPzhlkozR5YV0OjpP4sm08bYcvPcGcxxidxHE1o40xr6S6ozpiQENPEUBgTpNqMYaJTI0Rft3ERWaA==</SignatureValue><KeyInfo><X509Data><X509Certificate>MIIDHDCCAgSgAwIBAgIUOfWhuJYF+atF0Sv3tUJMbQzcRqwwDQYJKoZIhvcNAQEL
|
||||
BQAwSDEtMCsGA1UEAwwkQVVUT1BFQ0FTIFRISUFHTyBMVERBOjEyMzQ1Njc4MDAw
|
||||
MTkwMRcwFQYDVQQFEw4xMjM0NTY3ODAwMDE5MDAeFw0yNjA3MjExOTQ3NDNaFw0z
|
||||
NjA3MTkxOTQ3NDNaMEgxLTArBgNVBAMMJEFVVE9QRUNBUyBUSElBR08gTFREQTox
|
||||
MjM0NTY3ODAwMDE5MDEXMBUGA1UEBRMOMTIzNDU2NzgwMDAxOTAwggEiMA0GCSqG
|
||||
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDZUIX5xprSUIivdhvDZSwLu8pug0QXNHGk
|
||||
oIyyRjG9OIGd3nkn8X+9CP9yukYEyPvr1Yz/TM3pn/4d/kSixIi422kadKMka2QR
|
||||
uax/dRwj0UZJwm7MnC/Osa3FgEln3Yz4S6HWEPwPa/PNeETiXTLtnIQiY1M0GYld
|
||||
ccVsNUjDhvmjhIhuLzcZ3goWlKyLoeFoL8inFb+awFt6u2pao8JGyqAV3/7es5Ul
|
||||
gpLg10KfKcJyvGPvDhFo723FKACdOse7clLtMOrRt0Z/ST9i+17txFo/OjwXWEhC
|
||||
vGOTxNu667uA+1SzUk8ivouHOKtPX1ZdicEmTYY90Re/0B208nV7AgMBAAEwDQYJ
|
||||
KoZIhvcNAQELBQADggEBAGAPlWqX6VSVviqyxQu4AstW0xfgd461wy0lTgfh4KQ4
|
||||
7J6ikP5I+o8O9neSQ2bGaYbfTUja3Oq7VEoLtbumpZcmkrvlmXKmzUnkALoHlTab
|
||||
ih+9p3In9HgUhgQ4wwrAFurQzrtmfsSKC9hqUZMSn1BYGEs3oa2ocE3JvkBZ7YF+
|
||||
BLWs5cd/eqb4fPgDO6yZMnMOzTc9tjOhufW5eQKtGfTuR6YXqc7qRouFk+mqNbGA
|
||||
wAW+WjFafx9FENC3HdEt7UhI2cOjVuC0TUIAKl3dH3HpebaI3O0Gbd/AA+sGYezz
|
||||
lXy6GZUYxpBNNdP9avLSwabIMcoEN37ARvaY2VDULBQ=
|
||||
</X509Certificate></X509Data></KeyInfo></Signature></NFe>
|
||||
@@ -0,0 +1,18 @@
|
||||
<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><infNFe versao="4.00" Id="NFe41260712345678000190550010000010041100000045"><ide><cUF>41</cUF><cNF>10000004</cNF><natOp>Devolucao de venda</natOp><mod>55</mod><serie>1</serie><nNF>1004</nNF><dhEmi>2026-07-16T10:15:00-03:00</dhEmi><tpNF>1</tpNF><idDest>1</idDest><cMunFG>4106902</cMunFG><tpImp>1</tpImp><tpEmis>1</tpEmis><cDV>5</cDV><tpAmb>2</tpAmb><finNFe>1</finNFe><indFinal>1</indFinal><indPres>1</indPres><procEmi>0</procEmi><verProc>sowai-auto/1b.1</verProc></ide><emit><CNPJ>12345678000190</CNPJ><xNome>AUTOPECAS THIAGO LTDA</xNome><xFant>Thiago Auto Center</xFant><enderEmit><xLgr>Rua das Autopecas</xLgr><nro>1500</nro><xBairro>Centro</xBairro><cMun>4106902</cMun><xMun>Curitiba</xMun><UF>PR</UF><CEP>80000000</CEP><cPais>1058</cPais><xPais>Brasil</xPais><fone>4133334444</fone></enderEmit><IE>1234567890</IE><CRT>1</CRT></emit><dest><CNPJ>99887766000155</CNPJ><xNome>NF-E EMITIDA EM AMBIENTE DE HOMOLOGACAO - SEM VALOR FISCAL</xNome><enderDest><xLgr>Av Cliente</xLgr><nro>50</nro><xBairro>Bairro</xBairro><cMun>4106902</cMun><xMun>Curitiba</xMun><UF>PR</UF><CEP>80000100</CEP><cPais>1058</cPais><xPais>Brasil</xPais></enderDest><indIEDest>9</indIEDest></dest><det nItem="1"><prod><cProd>PC001</cProd><cEAN>SEM GTIN</cEAN><xProd>Filtro de oleo</xProd><NCM>84212300</NCM><CFOP>1202</CFOP><uCom>UN</uCom><qCom>1</qCom><vUnCom>75.00</vUnCom><vProd>75.00</vProd><cEANTrib>SEM GTIN</cEANTrib><uTrib>UN</uTrib><qTrib>1</qTrib><vUnTrib>75.00</vUnTrib><indTot>1</indTot></prod><imposto><ICMS><ICMSSN102><orig>0</orig><CSOSN>102</CSOSN></ICMSSN102></ICMS><PIS><PISNT><CST>08</CST></PISNT></PIS><COFINS><COFINSNT><CST>08</CST></COFINSNT></COFINS></imposto></det><total><ICMSTot><vBC>75.00</vBC><vICMS>0.00</vICMS><vICMSDeson>0.00</vICMSDeson><vFCP>0.00</vFCP><vBCST>0.00</vBCST><vST>0.00</vST><vFCPST>0.00</vFCPST><vFCPSTRet>0.00</vFCPSTRet><vProd>75.00</vProd><vFrete>0.00</vFrete><vSeg>0.00</vSeg><vDesc>0.00</vDesc><vII>0.00</vII><vIPI>0.00</vIPI><vIPIDevol>0.00</vIPIDevol><vPIS>0.00</vPIS><vCOFINS>0.00</vCOFINS><vOutro>0.00</vOutro><vNF>75.00</vNF></ICMSTot></total><transp><modFrete>9</modFrete></transp><pag><detPag><indPag>0</indPag><tPag>90</tPag><vPag>75.00</vPag></detPag></pag></infNFe><Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><Reference URI="#NFe41260712345678000190550010000010041100000045"><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><DigestValue>dWH2Qpht8rN1pncCUL31+ymheWE=</DigestValue></Reference></SignedInfo><SignatureValue>iyBjOX1COZz2vFk1SNaQvmLqvsXGo63DINjVvAsoAA2g6XsnoLdrNjXOtxATh4Iez1SEcVoOex5V6x4/Vdd7EkX8TSu5QKqIAbDUjystojZAVW+wXj7enUgb24qbrsFU40EhEPUdZp51grKgm0d2R4qNeodXZiFGbuheRZQ4EhY5b+2EPX6kSdxGuYi4F0Xq0JgkG90CTJIUFNEDkY2PKCXOipNBpx6nOYiWnEBGfMw2oTmJeXQ2yKyXjrr01S9Lj48UZybGMIStQJoMTHn+p9+3ccTwbBAJ66Et38SEsP8gmWaLkGq5+zMCtriiwEJzCJMzTUMnDPee+/nada0KWg==</SignatureValue><KeyInfo><X509Data><X509Certificate>MIIDHDCCAgSgAwIBAgIUOfWhuJYF+atF0Sv3tUJMbQzcRqwwDQYJKoZIhvcNAQEL
|
||||
BQAwSDEtMCsGA1UEAwwkQVVUT1BFQ0FTIFRISUFHTyBMVERBOjEyMzQ1Njc4MDAw
|
||||
MTkwMRcwFQYDVQQFEw4xMjM0NTY3ODAwMDE5MDAeFw0yNjA3MjExOTQ3NDNaFw0z
|
||||
NjA3MTkxOTQ3NDNaMEgxLTArBgNVBAMMJEFVVE9QRUNBUyBUSElBR08gTFREQTox
|
||||
MjM0NTY3ODAwMDE5MDEXMBUGA1UEBRMOMTIzNDU2NzgwMDAxOTAwggEiMA0GCSqG
|
||||
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDZUIX5xprSUIivdhvDZSwLu8pug0QXNHGk
|
||||
oIyyRjG9OIGd3nkn8X+9CP9yukYEyPvr1Yz/TM3pn/4d/kSixIi422kadKMka2QR
|
||||
uax/dRwj0UZJwm7MnC/Osa3FgEln3Yz4S6HWEPwPa/PNeETiXTLtnIQiY1M0GYld
|
||||
ccVsNUjDhvmjhIhuLzcZ3goWlKyLoeFoL8inFb+awFt6u2pao8JGyqAV3/7es5Ul
|
||||
gpLg10KfKcJyvGPvDhFo723FKACdOse7clLtMOrRt0Z/ST9i+17txFo/OjwXWEhC
|
||||
vGOTxNu667uA+1SzUk8ivouHOKtPX1ZdicEmTYY90Re/0B208nV7AgMBAAEwDQYJ
|
||||
KoZIhvcNAQELBQADggEBAGAPlWqX6VSVviqyxQu4AstW0xfgd461wy0lTgfh4KQ4
|
||||
7J6ikP5I+o8O9neSQ2bGaYbfTUja3Oq7VEoLtbumpZcmkrvlmXKmzUnkALoHlTab
|
||||
ih+9p3In9HgUhgQ4wwrAFurQzrtmfsSKC9hqUZMSn1BYGEs3oa2ocE3JvkBZ7YF+
|
||||
BLWs5cd/eqb4fPgDO6yZMnMOzTc9tjOhufW5eQKtGfTuR6YXqc7qRouFk+mqNbGA
|
||||
wAW+WjFafx9FENC3HdEt7UhI2cOjVuC0TUIAKl3dH3HpebaI3O0Gbd/AA+sGYezz
|
||||
lXy6GZUYxpBNNdP9avLSwabIMcoEN37ARvaY2VDULBQ=
|
||||
</X509Certificate></X509Data></KeyInfo></Signature></NFe>
|
||||
@@ -0,0 +1,18 @@
|
||||
<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><infNFe versao="4.00" Id="NFe41260712345678000190550010000010061100000066"><ide><cUF>41</cUF><cNF>10000006</cNF><natOp>Venda</natOp><mod>55</mod><serie>1</serie><nNF>1006</nNF><dhEmi>2026-07-16T10:25:00-03:00</dhEmi><tpNF>1</tpNF><idDest>1</idDest><cMunFG>4106902</cMunFG><tpImp>1</tpImp><tpEmis>1</tpEmis><cDV>6</cDV><tpAmb>2</tpAmb><finNFe>1</finNFe><indFinal>1</indFinal><indPres>1</indPres><procEmi>0</procEmi><verProc>sowai-auto/1b.1</verProc></ide><emit><CNPJ>12345678000190</CNPJ><xNome>AUTOPECAS THIAGO LTDA</xNome><xFant>Thiago Auto Center</xFant><enderEmit><xLgr>Rua das Autopecas</xLgr><nro>1500</nro><xBairro>Centro</xBairro><cMun>4106902</cMun><xMun>Curitiba</xMun><UF>PR</UF><CEP>80000000</CEP><cPais>1058</cPais><xPais>Brasil</xPais><fone>4133334444</fone></enderEmit><IE>1234567890</IE><CRT>1</CRT></emit><dest><CNPJ>99887766000155</CNPJ><xNome>NF-E EMITIDA EM AMBIENTE DE HOMOLOGACAO - SEM VALOR FISCAL</xNome><enderDest><xLgr>Av Cliente</xLgr><nro>50</nro><xBairro>Bairro</xBairro><cMun>4106902</cMun><xMun>Curitiba</xMun><UF>PR</UF><CEP>80000100</CEP><cPais>1058</cPais><xPais>Brasil</xPais></enderDest><indIEDest>9</indIEDest></dest><det nItem="1"><prod><cProd>PC900</cProd><cEAN>SEM GTIN</cEAN><xProd>Arruela avulsa</xProd><NCM>73181900</NCM><CFOP>5102</CFOP><uCom>UN</uCom><qCom>1.5</qCom><vUnCom>0.01</vUnCom><vProd>0.02</vProd><cEANTrib>SEM GTIN</cEANTrib><uTrib>UN</uTrib><qTrib>1.5</qTrib><vUnTrib>0.01</vUnTrib><indTot>1</indTot></prod><imposto><ICMS><ICMSSN102><orig>0</orig><CSOSN>102</CSOSN></ICMSSN102></ICMS><PIS><PISNT><CST>08</CST></PISNT></PIS><COFINS><COFINSNT><CST>08</CST></COFINSNT></COFINS></imposto></det><det nItem="2"><prod><cProd>PC901</cProd><cEAN>SEM GTIN</cEAN><xProd>Arruela avulsa 2</xProd><NCM>73181900</NCM><CFOP>5102</CFOP><uCom>UN</uCom><qCom>1.5</qCom><vUnCom>0.01</vUnCom><vProd>0.02</vProd><cEANTrib>SEM GTIN</cEANTrib><uTrib>UN</uTrib><qTrib>1.5</qTrib><vUnTrib>0.01</vUnTrib><indTot>1</indTot></prod><imposto><ICMS><ICMSSN102><orig>0</orig><CSOSN>102</CSOSN></ICMSSN102></ICMS><PIS><PISNT><CST>08</CST></PISNT></PIS><COFINS><COFINSNT><CST>08</CST></COFINSNT></COFINS></imposto></det><total><ICMSTot><vBC>0.04</vBC><vICMS>0.00</vICMS><vICMSDeson>0.00</vICMSDeson><vFCP>0.00</vFCP><vBCST>0.00</vBCST><vST>0.00</vST><vFCPST>0.00</vFCPST><vFCPSTRet>0.00</vFCPSTRet><vProd>0.04</vProd><vFrete>0.00</vFrete><vSeg>0.00</vSeg><vDesc>0.00</vDesc><vII>0.00</vII><vIPI>0.00</vIPI><vIPIDevol>0.00</vIPIDevol><vPIS>0.00</vPIS><vCOFINS>0.00</vCOFINS><vOutro>0.00</vOutro><vNF>0.04</vNF></ICMSTot></total><transp><modFrete>9</modFrete></transp><pag><detPag><indPag>0</indPag><tPag>01</tPag><vPag>0.04</vPag></detPag></pag></infNFe><Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><Reference URI="#NFe41260712345678000190550010000010061100000066"><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><DigestValue>UrxPctSfvF8tf9cYULFIdWbEUMA=</DigestValue></Reference></SignedInfo><SignatureValue>Bdi9HkxrMIPD7dtfd8wq1Mg2hCt+ZFffU3n79WqAxcDDLgtjCOb/vX4hvXj+J+LZ+SEcNzKFLF5kRyriHb7+e9YkS7BFgljNOGZunqkTz6/ZdmrPNlZB89z6SOB5aI+Z+J2KHtOO4l7kyIji5EdJl+NrDfRoCpBFFctBVMKCKtwBh31KoNDWq0XzqmfI+FDfZskdgR+W0AGFg6pGbNfv4dhpOAPT5/UY8HQDU4Zv6hVleUv/ifsqKTKzEa7z9/IL+5894NoPk9JyhNAFgKQDs4y2r1irykOsu1LHH+BvnPPCGKeKCLsVxqTgQxseKkfhiis2hTt97e365a7UmZ1JaA==</SignatureValue><KeyInfo><X509Data><X509Certificate>MIIDHDCCAgSgAwIBAgIUOfWhuJYF+atF0Sv3tUJMbQzcRqwwDQYJKoZIhvcNAQEL
|
||||
BQAwSDEtMCsGA1UEAwwkQVVUT1BFQ0FTIFRISUFHTyBMVERBOjEyMzQ1Njc4MDAw
|
||||
MTkwMRcwFQYDVQQFEw4xMjM0NTY3ODAwMDE5MDAeFw0yNjA3MjExOTQ3NDNaFw0z
|
||||
NjA3MTkxOTQ3NDNaMEgxLTArBgNVBAMMJEFVVE9QRUNBUyBUSElBR08gTFREQTox
|
||||
MjM0NTY3ODAwMDE5MDEXMBUGA1UEBRMOMTIzNDU2NzgwMDAxOTAwggEiMA0GCSqG
|
||||
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDZUIX5xprSUIivdhvDZSwLu8pug0QXNHGk
|
||||
oIyyRjG9OIGd3nkn8X+9CP9yukYEyPvr1Yz/TM3pn/4d/kSixIi422kadKMka2QR
|
||||
uax/dRwj0UZJwm7MnC/Osa3FgEln3Yz4S6HWEPwPa/PNeETiXTLtnIQiY1M0GYld
|
||||
ccVsNUjDhvmjhIhuLzcZ3goWlKyLoeFoL8inFb+awFt6u2pao8JGyqAV3/7es5Ul
|
||||
gpLg10KfKcJyvGPvDhFo723FKACdOse7clLtMOrRt0Z/ST9i+17txFo/OjwXWEhC
|
||||
vGOTxNu667uA+1SzUk8ivouHOKtPX1ZdicEmTYY90Re/0B208nV7AgMBAAEwDQYJ
|
||||
KoZIhvcNAQELBQADggEBAGAPlWqX6VSVviqyxQu4AstW0xfgd461wy0lTgfh4KQ4
|
||||
7J6ikP5I+o8O9neSQ2bGaYbfTUja3Oq7VEoLtbumpZcmkrvlmXKmzUnkALoHlTab
|
||||
ih+9p3In9HgUhgQ4wwrAFurQzrtmfsSKC9hqUZMSn1BYGEs3oa2ocE3JvkBZ7YF+
|
||||
BLWs5cd/eqb4fPgDO6yZMnMOzTc9tjOhufW5eQKtGfTuR6YXqc7qRouFk+mqNbGA
|
||||
wAW+WjFafx9FENC3HdEt7UhI2cOjVuC0TUIAKl3dH3HpebaI3O0Gbd/AA+sGYezz
|
||||
lXy6GZUYxpBNNdP9avLSwabIMcoEN37ARvaY2VDULBQ=
|
||||
</X509Certificate></X509Data></KeyInfo></Signature></NFe>
|
||||
@@ -0,0 +1,18 @@
|
||||
<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><infNFe versao="4.00" Id="NFe41260712345678000190550010000010021100000024"><ide><cUF>41</cUF><cNF>10000002</cNF><natOp>Venda</natOp><mod>55</mod><serie>1</serie><nNF>1002</nNF><dhEmi>2026-07-16T10:05:00-03:00</dhEmi><tpNF>1</tpNF><idDest>2</idDest><cMunFG>4106902</cMunFG><tpImp>1</tpImp><tpEmis>1</tpEmis><cDV>4</cDV><tpAmb>2</tpAmb><finNFe>1</finNFe><indFinal>1</indFinal><indPres>1</indPres><procEmi>0</procEmi><verProc>sowai-auto/1b.1</verProc></ide><emit><CNPJ>12345678000190</CNPJ><xNome>AUTOPECAS THIAGO LTDA</xNome><xFant>Thiago Auto Center</xFant><enderEmit><xLgr>Rua das Autopecas</xLgr><nro>1500</nro><xBairro>Centro</xBairro><cMun>4106902</cMun><xMun>Curitiba</xMun><UF>PR</UF><CEP>80000000</CEP><cPais>1058</cPais><xPais>Brasil</xPais><fone>4133334444</fone></enderEmit><IE>1234567890</IE><CRT>1</CRT></emit><dest><CNPJ>11222333000144</CNPJ><xNome>NF-E EMITIDA EM AMBIENTE DE HOMOLOGACAO - SEM VALOR FISCAL</xNome><enderDest><xLgr>Av Paulista</xLgr><nro>1000</nro><xCpl>Sala 10</xCpl><xBairro>Bela Vista</xBairro><cMun>3550308</cMun><xMun>Sao Paulo</xMun><UF>SP</UF><CEP>01310000</CEP><cPais>1058</cPais><xPais>Brasil</xPais></enderDest><indIEDest>1</indIEDest><IE>1122334455</IE><email>compras@clienteinter.com.br</email></dest><det nItem="1"><prod><cProd>PC001</cProd><cEAN>SEM GTIN</cEAN><xProd>Filtro de oleo</xProd><NCM>84212300</NCM><CFOP>6102</CFOP><uCom>UN</uCom><qCom>10</qCom><vUnCom>75.00</vUnCom><vProd>750.00</vProd><cEANTrib>SEM GTIN</cEANTrib><uTrib>UN</uTrib><qTrib>10</qTrib><vUnTrib>75.00</vUnTrib><indTot>1</indTot></prod><imposto><ICMS><ICMSSN102><orig>0</orig><CSOSN>102</CSOSN></ICMSSN102></ICMS><PIS><PISNT><CST>08</CST></PISNT></PIS><COFINS><COFINSNT><CST>08</CST></COFINSNT></COFINS></imposto></det><total><ICMSTot><vBC>750.00</vBC><vICMS>0.00</vICMS><vICMSDeson>0.00</vICMSDeson><vFCP>0.00</vFCP><vBCST>0.00</vBCST><vST>0.00</vST><vFCPST>0.00</vFCPST><vFCPSTRet>0.00</vFCPSTRet><vProd>750.00</vProd><vFrete>0.00</vFrete><vSeg>0.00</vSeg><vDesc>0.00</vDesc><vII>0.00</vII><vIPI>0.00</vIPI><vIPIDevol>0.00</vIPIDevol><vPIS>0.00</vPIS><vCOFINS>0.00</vCOFINS><vOutro>0.00</vOutro><vNF>750.00</vNF></ICMSTot></total><transp><modFrete>9</modFrete></transp><pag><detPag><indPag>0</indPag><tPag>17</tPag><vPag>750.00</vPag></detPag></pag></infNFe><Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><Reference URI="#NFe41260712345678000190550010000010021100000024"><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><DigestValue>wMN3wf/wyPYVP/p8oSMeLIwQ0sI=</DigestValue></Reference></SignedInfo><SignatureValue>YTzAGw2xRWQSgMsJuF1XB2elWaTAHRRnLvOwM3Bo6YHNGVM3AqViOlhf0jRmbuQe4VCj8ugSEAe24p50Mn0YGLqH91emub4ADsewNAjeIcb7C9n/cYk8Sh2e/wPc3DvS5cOSICUOroPak0A3bWFb8DjRAcUdYvX/uI0IrzMlN0Qxzjd+lZwo/1cmzIWnW0G+nDvd2rykGOZTwCxd+yZf+qHw+A1iLFgeI9YHyWcWoUtKH746GvlJQ59Oz4dTXgtJL3Nnhx2O7dsgbU0/ee8Q6bYGELZz6Jtdup3EOVI7T5K/BBgVeNpchQfj7hHdL1gzmVAnGdVnBR01EDQnscVtZw==</SignatureValue><KeyInfo><X509Data><X509Certificate>MIIDHDCCAgSgAwIBAgIUOfWhuJYF+atF0Sv3tUJMbQzcRqwwDQYJKoZIhvcNAQEL
|
||||
BQAwSDEtMCsGA1UEAwwkQVVUT1BFQ0FTIFRISUFHTyBMVERBOjEyMzQ1Njc4MDAw
|
||||
MTkwMRcwFQYDVQQFEw4xMjM0NTY3ODAwMDE5MDAeFw0yNjA3MjExOTQ3NDNaFw0z
|
||||
NjA3MTkxOTQ3NDNaMEgxLTArBgNVBAMMJEFVVE9QRUNBUyBUSElBR08gTFREQTox
|
||||
MjM0NTY3ODAwMDE5MDEXMBUGA1UEBRMOMTIzNDU2NzgwMDAxOTAwggEiMA0GCSqG
|
||||
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDZUIX5xprSUIivdhvDZSwLu8pug0QXNHGk
|
||||
oIyyRjG9OIGd3nkn8X+9CP9yukYEyPvr1Yz/TM3pn/4d/kSixIi422kadKMka2QR
|
||||
uax/dRwj0UZJwm7MnC/Osa3FgEln3Yz4S6HWEPwPa/PNeETiXTLtnIQiY1M0GYld
|
||||
ccVsNUjDhvmjhIhuLzcZ3goWlKyLoeFoL8inFb+awFt6u2pao8JGyqAV3/7es5Ul
|
||||
gpLg10KfKcJyvGPvDhFo723FKACdOse7clLtMOrRt0Z/ST9i+17txFo/OjwXWEhC
|
||||
vGOTxNu667uA+1SzUk8ivouHOKtPX1ZdicEmTYY90Re/0B208nV7AgMBAAEwDQYJ
|
||||
KoZIhvcNAQELBQADggEBAGAPlWqX6VSVviqyxQu4AstW0xfgd461wy0lTgfh4KQ4
|
||||
7J6ikP5I+o8O9neSQ2bGaYbfTUja3Oq7VEoLtbumpZcmkrvlmXKmzUnkALoHlTab
|
||||
ih+9p3In9HgUhgQ4wwrAFurQzrtmfsSKC9hqUZMSn1BYGEs3oa2ocE3JvkBZ7YF+
|
||||
BLWs5cd/eqb4fPgDO6yZMnMOzTc9tjOhufW5eQKtGfTuR6YXqc7qRouFk+mqNbGA
|
||||
wAW+WjFafx9FENC3HdEt7UhI2cOjVuC0TUIAKl3dH3HpebaI3O0Gbd/AA+sGYezz
|
||||
lXy6GZUYxpBNNdP9avLSwabIMcoEN37ARvaY2VDULBQ=
|
||||
</X509Certificate></X509Data></KeyInfo></Signature></NFe>
|
||||
@@ -0,0 +1,18 @@
|
||||
<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><infNFe versao="4.00" Id="NFe41260712345678000190550010000010011100000019"><ide><cUF>41</cUF><cNF>10000001</cNF><natOp>Venda</natOp><mod>55</mod><serie>1</serie><nNF>1001</nNF><dhEmi>2026-07-16T10:00:00-03:00</dhEmi><tpNF>1</tpNF><idDest>1</idDest><cMunFG>4106902</cMunFG><tpImp>1</tpImp><tpEmis>1</tpEmis><cDV>9</cDV><tpAmb>2</tpAmb><finNFe>1</finNFe><indFinal>1</indFinal><indPres>1</indPres><procEmi>0</procEmi><verProc>sowai-auto/1b.1</verProc></ide><emit><CNPJ>12345678000190</CNPJ><xNome>AUTOPECAS THIAGO LTDA</xNome><xFant>Thiago Auto Center</xFant><enderEmit><xLgr>Rua das Autopecas</xLgr><nro>1500</nro><xBairro>Centro</xBairro><cMun>4106902</cMun><xMun>Curitiba</xMun><UF>PR</UF><CEP>80000000</CEP><cPais>1058</cPais><xPais>Brasil</xPais><fone>4133334444</fone></enderEmit><IE>1234567890</IE><CRT>1</CRT></emit><dest><CNPJ>99887766000155</CNPJ><xNome>NF-E EMITIDA EM AMBIENTE DE HOMOLOGACAO - SEM VALOR FISCAL</xNome><enderDest><xLgr>Av Cliente</xLgr><nro>50</nro><xBairro>Bairro</xBairro><cMun>4106902</cMun><xMun>Curitiba</xMun><UF>PR</UF><CEP>80000100</CEP><cPais>1058</cPais><xPais>Brasil</xPais></enderDest><indIEDest>9</indIEDest></dest><det nItem="1"><prod><cProd>PC001</cProd><cEAN>SEM GTIN</cEAN><xProd>Filtro de oleo</xProd><NCM>84212300</NCM><CFOP>5102</CFOP><uCom>UN</uCom><qCom>2</qCom><vUnCom>75.00</vUnCom><vProd>150.00</vProd><cEANTrib>SEM GTIN</cEANTrib><uTrib>UN</uTrib><qTrib>2</qTrib><vUnTrib>75.00</vUnTrib><indTot>1</indTot></prod><imposto><ICMS><ICMSSN102><orig>0</orig><CSOSN>102</CSOSN></ICMSSN102></ICMS><PIS><PISNT><CST>08</CST></PISNT></PIS><COFINS><COFINSNT><CST>08</CST></COFINSNT></COFINS></imposto></det><det nItem="2"><prod><cProd>PC002</cProd><cEAN>SEM GTIN</cEAN><xProd>Pastilha de freio</xProd><NCM>87083090</NCM><CFOP>5102</CFOP><uCom>PC</uCom><qCom>4</qCom><vUnCom>45.50</vUnCom><vProd>182.00</vProd><cEANTrib>SEM GTIN</cEANTrib><uTrib>PC</uTrib><qTrib>4</qTrib><vUnTrib>45.50</vUnTrib><indTot>1</indTot></prod><imposto><ICMS><ICMSSN102><orig>0</orig><CSOSN>102</CSOSN></ICMSSN102></ICMS><PIS><PISNT><CST>08</CST></PISNT></PIS><COFINS><COFINSNT><CST>08</CST></COFINSNT></COFINS></imposto></det><total><ICMSTot><vBC>332.00</vBC><vICMS>0.00</vICMS><vICMSDeson>0.00</vICMSDeson><vFCP>0.00</vFCP><vBCST>0.00</vBCST><vST>0.00</vST><vFCPST>0.00</vFCPST><vFCPSTRet>0.00</vFCPSTRet><vProd>332.00</vProd><vFrete>0.00</vFrete><vSeg>0.00</vSeg><vDesc>0.00</vDesc><vII>0.00</vII><vIPI>0.00</vIPI><vIPIDevol>0.00</vIPIDevol><vPIS>0.00</vPIS><vCOFINS>0.00</vCOFINS><vOutro>0.00</vOutro><vNF>332.00</vNF></ICMSTot></total><transp><modFrete>9</modFrete></transp><pag><detPag><indPag>0</indPag><tPag>01</tPag><vPag>332.00</vPag></detPag></pag></infNFe><Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><Reference URI="#NFe41260712345678000190550010000010011100000019"><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><DigestValue>1inHNlmRb0maeftKaq/JExhNc8A=</DigestValue></Reference></SignedInfo><SignatureValue>ked+Fn1wxCy5EdNT289eLof7cqwuWvj1bh/AV0CQ3zbrLD5kL5/fXj2pLMZWS9wyl3NG8ZFATJ5B1xmeDcc0Niigs9O+e9wwWgX+3STDHZOogLuOU/CKhgFq/6e4wkZlMhV7Yffo+swo5qf82itGxUkkQkD00TDuFP7OnKOZBW8Ti+CPPPm+HLv613+BolP14If/2BXJW9HZiSu4Cua66/E32RSqPSp319DPvvpGwefJWDddOYfK9TkChirgdygAQUrkeJutcQdzzo9LnSVKE3FAu6q5w3rkNIrqA0OTEctGpojwfXc9E+IzRehof6fPvDCUjwSe89fP4JpK6tYiYA==</SignatureValue><KeyInfo><X509Data><X509Certificate>MIIDHDCCAgSgAwIBAgIUOfWhuJYF+atF0Sv3tUJMbQzcRqwwDQYJKoZIhvcNAQEL
|
||||
BQAwSDEtMCsGA1UEAwwkQVVUT1BFQ0FTIFRISUFHTyBMVERBOjEyMzQ1Njc4MDAw
|
||||
MTkwMRcwFQYDVQQFEw4xMjM0NTY3ODAwMDE5MDAeFw0yNjA3MjExOTQ3NDNaFw0z
|
||||
NjA3MTkxOTQ3NDNaMEgxLTArBgNVBAMMJEFVVE9QRUNBUyBUSElBR08gTFREQTox
|
||||
MjM0NTY3ODAwMDE5MDEXMBUGA1UEBRMOMTIzNDU2NzgwMDAxOTAwggEiMA0GCSqG
|
||||
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDZUIX5xprSUIivdhvDZSwLu8pug0QXNHGk
|
||||
oIyyRjG9OIGd3nkn8X+9CP9yukYEyPvr1Yz/TM3pn/4d/kSixIi422kadKMka2QR
|
||||
uax/dRwj0UZJwm7MnC/Osa3FgEln3Yz4S6HWEPwPa/PNeETiXTLtnIQiY1M0GYld
|
||||
ccVsNUjDhvmjhIhuLzcZ3goWlKyLoeFoL8inFb+awFt6u2pao8JGyqAV3/7es5Ul
|
||||
gpLg10KfKcJyvGPvDhFo723FKACdOse7clLtMOrRt0Z/ST9i+17txFo/OjwXWEhC
|
||||
vGOTxNu667uA+1SzUk8ivouHOKtPX1ZdicEmTYY90Re/0B208nV7AgMBAAEwDQYJ
|
||||
KoZIhvcNAQELBQADggEBAGAPlWqX6VSVviqyxQu4AstW0xfgd461wy0lTgfh4KQ4
|
||||
7J6ikP5I+o8O9neSQ2bGaYbfTUja3Oq7VEoLtbumpZcmkrvlmXKmzUnkALoHlTab
|
||||
ih+9p3In9HgUhgQ4wwrAFurQzrtmfsSKC9hqUZMSn1BYGEs3oa2ocE3JvkBZ7YF+
|
||||
BLWs5cd/eqb4fPgDO6yZMnMOzTc9tjOhufW5eQKtGfTuR6YXqc7qRouFk+mqNbGA
|
||||
wAW+WjFafx9FENC3HdEt7UhI2cOjVuC0TUIAKl3dH3HpebaI3O0Gbd/AA+sGYezz
|
||||
lXy6GZUYxpBNNdP9avLSwabIMcoEN37ARvaY2VDULBQ=
|
||||
</X509Certificate></X509Data></KeyInfo></Signature></NFe>
|
||||
@@ -0,0 +1,18 @@
|
||||
<NFe xmlns="http://www.portalfiscal.inf.br/nfe"><infNFe versao="4.00" Id="NFe41260712345678000190550010000010031100000030"><ide><cUF>41</cUF><cNF>10000003</cNF><natOp>Venda</natOp><mod>55</mod><serie>1</serie><nNF>1003</nNF><dhEmi>2026-07-16T10:10:00-03:00</dhEmi><tpNF>1</tpNF><idDest>1</idDest><cMunFG>4106902</cMunFG><tpImp>1</tpImp><tpEmis>1</tpEmis><cDV>0</cDV><tpAmb>2</tpAmb><finNFe>1</finNFe><indFinal>1</indFinal><indPres>1</indPres><procEmi>0</procEmi><verProc>sowai-auto/1b.1</verProc></ide><emit><CNPJ>12345678000190</CNPJ><xNome>AUTOPECAS THIAGO LTDA</xNome><xFant>Thiago Auto Center</xFant><enderEmit><xLgr>Rua das Autopecas</xLgr><nro>1500</nro><xBairro>Centro</xBairro><cMun>4106902</cMun><xMun>Curitiba</xMun><UF>PR</UF><CEP>80000000</CEP><cPais>1058</cPais><xPais>Brasil</xPais><fone>4133334444</fone></enderEmit><IE>1234567890</IE><CRT>1</CRT></emit><dest><CPF>12345678909</CPF><xNome>NF-E EMITIDA EM AMBIENTE DE HOMOLOGACAO - SEM VALOR FISCAL</xNome><enderDest><xLgr>Rua Oficina</xLgr><nro>22</nro><xBairro>Industrial</xBairro><cMun>4106902</cMun><xMun>Curitiba</xMun><UF>PR</UF><CEP>80000200</CEP><cPais>1058</cPais><xPais>Brasil</xPais></enderDest><indIEDest>9</indIEDest></dest><det nItem="1"><prod><cProd>OL500</cProd><cEAN>SEM GTIN</cEAN><xProd>Oleo lubrificante 15W40 1L</xProd><NCM>27101259</NCM><CEST>0600100</CEST><CFOP>5405</CFOP><uCom>UN</uCom><qCom>1</qCom><vUnCom>100.00</vUnCom><vProd>100.00</vProd><cEANTrib>SEM GTIN</cEANTrib><uTrib>UN</uTrib><qTrib>1</qTrib><vUnTrib>100.00</vUnTrib><indTot>1</indTot></prod><imposto><ICMS><ICMSSN500><orig>0</orig><CSOSN>500</CSOSN><vBCSTRet>140.00</vBCSTRet><pST>18.0000</pST><vICMSSTRet>13.20</vICMSSTRet></ICMSSN500></ICMS><PIS><PISNT><CST>04</CST></PISNT></PIS><COFINS><COFINSNT><CST>04</CST></COFINSNT></COFINS></imposto></det><total><ICMSTot><vBC>0.00</vBC><vICMS>0.00</vICMS><vICMSDeson>0.00</vICMSDeson><vFCP>0.00</vFCP><vBCST>140.00</vBCST><vST>13.20</vST><vFCPST>0.00</vFCPST><vFCPSTRet>0.00</vFCPSTRet><vProd>100.00</vProd><vFrete>0.00</vFrete><vSeg>0.00</vSeg><vDesc>0.00</vDesc><vII>0.00</vII><vIPI>0.00</vIPI><vIPIDevol>0.00</vIPIDevol><vPIS>0.00</vPIS><vCOFINS>0.00</vCOFINS><vOutro>0.00</vOutro><vNF>100.00</vNF></ICMSTot></total><transp><modFrete>9</modFrete></transp><pag><detPag><indPag>0</indPag><tPag>01</tPag><vPag>100.00</vPag></detPag></pag></infNFe><Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo><CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><Reference URI="#NFe41260712345678000190550010000010031100000030"><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/></Transforms><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><DigestValue>GynTwfOg2ofaLE8WyWWwdpLBjWg=</DigestValue></Reference></SignedInfo><SignatureValue>1KMXWaK4qME2/kgazsMvEIB0B2Oxr6bLCNtfa6IrWd/i+muv9CDRhOm/yKumdRdmC6wys0o0krkwOW0Zh91iCikvHVyO/sh4ze4ycFzE2cODFpEciS7+59pMZM4vARhoZga855QKTzCB6CvFx99VAeq7X0wxXc7FWEmz2ENH6Vn0pkFkiMlpIgT1yFtGZbOQ5lcuE0hGTQJlEool/dbZRWWNp2CPbpx4Hys/CwLUM/MIA7lgfOqvEE0VxrfsU4Cfk2azBylFmprGnySot6HMPl0nkmGoVWEnaX3hxIlJJjfHXPZMnRp/HeryZpNb5GRzWjW9oJlXZUkzPcc02gkI8g==</SignatureValue><KeyInfo><X509Data><X509Certificate>MIIDHDCCAgSgAwIBAgIUOfWhuJYF+atF0Sv3tUJMbQzcRqwwDQYJKoZIhvcNAQEL
|
||||
BQAwSDEtMCsGA1UEAwwkQVVUT1BFQ0FTIFRISUFHTyBMVERBOjEyMzQ1Njc4MDAw
|
||||
MTkwMRcwFQYDVQQFEw4xMjM0NTY3ODAwMDE5MDAeFw0yNjA3MjExOTQ3NDNaFw0z
|
||||
NjA3MTkxOTQ3NDNaMEgxLTArBgNVBAMMJEFVVE9QRUNBUyBUSElBR08gTFREQTox
|
||||
MjM0NTY3ODAwMDE5MDEXMBUGA1UEBRMOMTIzNDU2NzgwMDAxOTAwggEiMA0GCSqG
|
||||
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDZUIX5xprSUIivdhvDZSwLu8pug0QXNHGk
|
||||
oIyyRjG9OIGd3nkn8X+9CP9yukYEyPvr1Yz/TM3pn/4d/kSixIi422kadKMka2QR
|
||||
uax/dRwj0UZJwm7MnC/Osa3FgEln3Yz4S6HWEPwPa/PNeETiXTLtnIQiY1M0GYld
|
||||
ccVsNUjDhvmjhIhuLzcZ3goWlKyLoeFoL8inFb+awFt6u2pao8JGyqAV3/7es5Ul
|
||||
gpLg10KfKcJyvGPvDhFo723FKACdOse7clLtMOrRt0Z/ST9i+17txFo/OjwXWEhC
|
||||
vGOTxNu667uA+1SzUk8ivouHOKtPX1ZdicEmTYY90Re/0B208nV7AgMBAAEwDQYJ
|
||||
KoZIhvcNAQELBQADggEBAGAPlWqX6VSVviqyxQu4AstW0xfgd461wy0lTgfh4KQ4
|
||||
7J6ikP5I+o8O9neSQ2bGaYbfTUja3Oq7VEoLtbumpZcmkrvlmXKmzUnkALoHlTab
|
||||
ih+9p3In9HgUhgQ4wwrAFurQzrtmfsSKC9hqUZMSn1BYGEs3oa2ocE3JvkBZ7YF+
|
||||
BLWs5cd/eqb4fPgDO6yZMnMOzTc9tjOhufW5eQKtGfTuR6YXqc7qRouFk+mqNbGA
|
||||
wAW+WjFafx9FENC3HdEt7UhI2cOjVuC0TUIAKl3dH3HpebaI3O0Gbd/AA+sGYezz
|
||||
lXy6GZUYxpBNNdP9avLSwabIMcoEN37ARvaY2VDULBQ=
|
||||
</X509Certificate></X509Data></KeyInfo></Signature></NFe>
|
||||
@@ -231,13 +231,16 @@ async def test_chave_acesso_unique_constraint_holds_on_real_migration(migration_
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_live_certificates_for_same_product_branch_violate_unique_index_on_real_migration(
|
||||
async def test_two_live_certificates_for_same_product_tenant_branch_violate_unique_index_on_real_migration(
|
||||
migration_database,
|
||||
):
|
||||
"""Same fix/reasoning as the auto's `a1b2c3d4e5f6` migration: a PARTIAL
|
||||
UNIQUE index (here on `(product_id, branch_ref) WHERE deleted_at IS
|
||||
NULL`) makes two concurrently-uploaded LIVE certificates for the same
|
||||
slot structurally impossible, not just avoided by the service layer."""
|
||||
UNIQUE index (here on `(product_id, tenant_ref, branch_ref) WHERE
|
||||
deleted_at IS NULL` -- FIX 2/F2 review, migration `8f1a2c9d4b6e`) makes
|
||||
two concurrently-uploaded LIVE certificates for the SAME
|
||||
`(product_id, tenant_ref, branch_ref)` slot structurally impossible, not
|
||||
just avoided by the service layer. Renamed from `..._product_branch_...`
|
||||
(pre-FIX-2 name) -- `tenant_ref` is now PART of the scope this proves."""
|
||||
result = run_alembic(_MIGRATION_DB_URL, "upgrade", "head")
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
@@ -274,6 +277,64 @@ async def test_two_live_certificates_for_same_product_branch_violate_unique_inde
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_tenants_can_both_hold_a_live_certificate_for_the_same_branch_ref_on_real_migration(
|
||||
migration_database,
|
||||
):
|
||||
"""FIX 2 (F2 review): the pre-fix index was `(product_id, branch_ref)
|
||||
WHERE deleted_at IS NULL` -- ONE live cert per `branch_ref` PER PRODUCT,
|
||||
regardless of tenant. Two tenants of the SAME product reusing the
|
||||
identical opaque `branch_ref="matriz"` collided on that slot: this
|
||||
proves, on a database built PURELY by `alembic upgrade head`, that BOTH
|
||||
now insert and stay live simultaneously -- the index scope is
|
||||
`(product_id, tenant_ref, branch_ref)`."""
|
||||
result = run_alembic(_MIGRATION_DB_URL, "upgrade", "head")
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
from fiscal_svc.documents.models import FiscalCertificate
|
||||
|
||||
engine = create_async_engine(_MIGRATION_DB_URL, echo=False)
|
||||
session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
try:
|
||||
async with session_maker() as session:
|
||||
product = await _make_product(session, name="auto-cert-multi-tenant")
|
||||
|
||||
def _cert(tenant_ref, cnpj):
|
||||
return FiscalCertificate(
|
||||
product_id=product.id,
|
||||
tenant_ref=tenant_ref,
|
||||
branch_ref="matriz",
|
||||
cnpj=cnpj,
|
||||
pfx_encrypted=b"\x00pfx",
|
||||
password_encrypted=b"\x00pw",
|
||||
subject_cn=f"EMPRESA TESTE LTDA:{cnpj}",
|
||||
cnpj_certificado=cnpj,
|
||||
not_valid_before=datetime.now(timezone.utc) - timedelta(days=1),
|
||||
not_valid_after=datetime.now(timezone.utc) + timedelta(days=365),
|
||||
)
|
||||
|
||||
cert_a = _cert("tenant-a", "14200166000280")
|
||||
cert_b = _cert("tenant-b", "99887766000155")
|
||||
session.add(cert_a)
|
||||
session.add(cert_b)
|
||||
# Would raise IntegrityError on the old (product_id, branch_ref)
|
||||
# index before FIX 2 -- the second INSERT collided with the
|
||||
# first tenant's still-live row.
|
||||
await session.commit()
|
||||
|
||||
cert_a_id, cert_b_id = cert_a.id, cert_b.id
|
||||
|
||||
async with session_maker() as session:
|
||||
reloaded_a = await session.get(FiscalCertificate, cert_a_id)
|
||||
reloaded_b = await session.get(FiscalCertificate, cert_b_id)
|
||||
assert reloaded_a.deleted_at is None
|
||||
assert reloaded_b.deleted_at is None
|
||||
assert reloaded_a.cnpj_certificado == "14200166000280"
|
||||
assert reloaded_b.cnpj_certificado == "99887766000155"
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_fiscal_series_tuple_violates_unique_constraint_on_real_migration(
|
||||
migration_database,
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Task 4: `POST/GET /v1/series`, `PATCH /v1/series/{id}` -- ported from the
|
||||
auto's `tests/modules/tenants/test_fiscal_series.py` CRUD half (allocation
|
||||
itself, and its concurrency proofs, are Task 3's
|
||||
`tests/documents/test_allocate_fiscal_number.py`). Covers: CRUD happy path,
|
||||
duplicate tuple -> 409, anti-oracle 404 (cross-product), missing API key ->
|
||||
401, and the "guard retroativo" this Task closes: `PATCH .../next_number`
|
||||
regressing below the highest `numero` a series has already emitted -> 409."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from fiscal_svc.core.db import get_session
|
||||
from fiscal_svc.documents.models import FiscalDocument, FiscalSeries
|
||||
from fiscal_svc.main import app
|
||||
from fiscal_svc.tenancy.service import create_product
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _override_db(db_session):
|
||||
async def _get_session_override():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_session] = _get_session_override
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _headers(api_key: str) -> dict[str, str]:
|
||||
return {"X-Api-Key": api_key}
|
||||
|
||||
|
||||
async def _product_and_key(db_session, name="auto"):
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
product = await create_product(db_session, name=name, api_key=key)
|
||||
return product, key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_fiscal_series_returns_201(db_session):
|
||||
_, key = await _product_and_key(db_session)
|
||||
payload = {"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 1014}
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post("/v1/series", json=payload, headers=_headers(key))
|
||||
|
||||
assert response.status_code == 201, response.text
|
||||
body = response.json()
|
||||
assert body["tenant_ref"] == "t1"
|
||||
assert body["branch_ref"] == "b1"
|
||||
assert body["document_model"] == "55"
|
||||
assert body["serie"] == 1
|
||||
assert body["next_number"] == 1014
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_fiscal_series_returns_created_series(db_session):
|
||||
_, key = await _product_and_key(db_session)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await client.post(
|
||||
"/v1/series",
|
||||
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 1},
|
||||
headers=_headers(key),
|
||||
)
|
||||
await client.post(
|
||||
"/v1/series",
|
||||
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 2, "next_number": 1},
|
||||
headers=_headers(key),
|
||||
)
|
||||
response = await client.get(
|
||||
"/v1/series", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(key)
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
series_list = response.json()
|
||||
assert len(series_list) == 2
|
||||
assert {s["serie"] for s in series_list} == {1, 2}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_fiscal_series_updates_next_number(db_session):
|
||||
_, key = await _product_and_key(db_session)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
create_response = await client.post(
|
||||
"/v1/series",
|
||||
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 1},
|
||||
headers=_headers(key),
|
||||
)
|
||||
series_id = create_response.json()["id"]
|
||||
|
||||
response = await client.patch(
|
||||
f"/v1/series/{series_id}", json={"next_number": 500}, headers=_headers(key)
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["next_number"] == 500
|
||||
|
||||
|
||||
async def _series_with_documents(db_session, product, *, max_numero: int, next_number: int) -> FiscalSeries:
|
||||
"""Fabrica uma série + um `FiscalDocument` VIVO com `numero=max_numero`
|
||||
-- exatamente como o guard vai ler (`numero`/`series_id`/`deleted_at IS
|
||||
NULL`), sem passar pelo fluxo real de emissão (Task 5)."""
|
||||
series = FiscalSeries(
|
||||
product_id=product.id, tenant_ref="t1", branch_ref="b1",
|
||||
document_model="55", serie=1, next_number=next_number,
|
||||
)
|
||||
db_session.add(series)
|
||||
await db_session.flush()
|
||||
|
||||
document = FiscalDocument(
|
||||
product_id=product.id, tenant_ref="t1", branch_ref="b1", series_id=series.id,
|
||||
document_model="55", serie=1, numero=max_numero,
|
||||
chave_acesso=str(uuid.uuid4().int)[:44].zfill(44), codigo_numerico="12345678",
|
||||
status="ASSINADO", ambiente="homologacao", xml_assinado="<NFe/>",
|
||||
)
|
||||
db_session.add(document)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(series)
|
||||
return series
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_fiscal_series_next_number_regression_is_409(db_session):
|
||||
product, key = await _product_and_key(db_session)
|
||||
series = await _series_with_documents(db_session, product, max_numero=1014, next_number=1015)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
regressed = await client.patch(
|
||||
f"/v1/series/{series.id}", json={"next_number": 1000}, headers=_headers(key)
|
||||
)
|
||||
equal_to_max = await client.patch(
|
||||
f"/v1/series/{series.id}", json={"next_number": 1014}, headers=_headers(key)
|
||||
)
|
||||
allowed = await client.patch(
|
||||
f"/v1/series/{series.id}", json={"next_number": 1015}, headers=_headers(key)
|
||||
)
|
||||
|
||||
assert regressed.status_code == 409, regressed.text
|
||||
assert regressed.json()["detail"]["code"] == "fiscal_series_number_regression"
|
||||
assert equal_to_max.status_code == 409, equal_to_max.text
|
||||
assert allowed.status_code == 200, allowed.text
|
||||
assert allowed.json()["next_number"] == 1015
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_fiscal_series_next_number_ignores_soft_deleted_documents(db_session):
|
||||
"""O guard só olha documentos VIVOS -- um `FiscalDocument`
|
||||
soft-deletado não deve travar o `next_number` para sempre."""
|
||||
product, key = await _product_and_key(db_session)
|
||||
series = await _series_with_documents(db_session, product, max_numero=1014, next_number=1015)
|
||||
|
||||
result = await db_session.execute(select(FiscalDocument).where(FiscalDocument.series_id == series.id))
|
||||
document = result.scalar_one()
|
||||
document.deleted_at = datetime.now(timezone.utc)
|
||||
await db_session.commit()
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
f"/v1/series/{series.id}", json={"next_number": 1}, headers=_headers(key)
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["next_number"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_fiscal_series_rejects_explicit_null(db_session):
|
||||
_, key = await _product_and_key(db_session)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
create_response = await client.post(
|
||||
"/v1/series",
|
||||
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 1},
|
||||
headers=_headers(key),
|
||||
)
|
||||
series_id = create_response.json()["id"]
|
||||
|
||||
response = await client.patch(
|
||||
f"/v1/series/{series_id}", json={"next_number": None}, headers=_headers(key)
|
||||
)
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_duplicate_fiscal_series_is_409(db_session):
|
||||
_, key = await _product_and_key(db_session)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await client.post(
|
||||
"/v1/series",
|
||||
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 1},
|
||||
headers=_headers(key),
|
||||
)
|
||||
response = await client.post(
|
||||
"/v1/series",
|
||||
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 999},
|
||||
headers=_headers(key),
|
||||
)
|
||||
|
||||
assert response.status_code == 409, response.text
|
||||
assert response.json()["detail"]["code"] == "duplicate_fiscal_series"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_serie_same_model_is_ok(db_session):
|
||||
_, key = await _product_and_key(db_session)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
await client.post(
|
||||
"/v1/series",
|
||||
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 1},
|
||||
headers=_headers(key),
|
||||
)
|
||||
response = await client.post(
|
||||
"/v1/series",
|
||||
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 2, "next_number": 1},
|
||||
headers=_headers(key),
|
||||
)
|
||||
|
||||
assert response.status_code == 201, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_fiscal_series_other_product_series_is_404(db_session):
|
||||
product_a, key_a = await _product_and_key(db_session, name="auto")
|
||||
_, key_b = await _product_and_key(db_session, name="crm")
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
create_response = await client.post(
|
||||
"/v1/series",
|
||||
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 1},
|
||||
headers=_headers(key_a),
|
||||
)
|
||||
series_id = create_response.json()["id"]
|
||||
|
||||
response = await client.patch(
|
||||
f"/v1/series/{series_id}", json={"next_number": 42}, headers=_headers(key_b)
|
||||
)
|
||||
|
||||
assert response.status_code == 404, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_fiscal_series_missing_api_key_is_401(db_session):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/v1/series", params={"tenant_ref": "t1", "branch_ref": "b1"})
|
||||
|
||||
assert response.status_code == 401, response.text
|
||||
@@ -0,0 +1,400 @@
|
||||
"""Task 6: suíte de CONTRATO HTTP -- design spec's "os 3 seguros de
|
||||
portabilidade", item (a): "suíte de contrato HTTP language-agnostic (bate na
|
||||
API, não no interior)". A REGRA DESTE ARQUIVO: zero import de `fiscal_svc.*`
|
||||
além de `fiscal_svc.main.app` (para montar o `ASGITransport` -- inevitável
|
||||
em Python; um cliente HTTP real bateria numa URL, não precisaria nem disso).
|
||||
Nenhum import de model/service/schema interno -- é a suíte que uma
|
||||
implementação GO deste MESMO contrato (`docs/openapi-v1.json`) teria que
|
||||
passar batendo na porta 8140 de fora, sem NUNCA ver este código-fonte.
|
||||
|
||||
Provisionamento de fixtures (criar um Product/API key) é feito via
|
||||
`scripts/create_product.py`'s MESMA função (`tenancy.service.create_
|
||||
product`) em qualquer OUTRO arquivo de teste deste repo -- mas aqui, para
|
||||
manter a regra "zero import do interior", cada teste usa o endpoint HTTP
|
||||
disponível (upload de certificado, criação de série) e trata a criação do
|
||||
Product como um pré-requisito de infraestrutura resolvido pela fixture
|
||||
`product_api_key` (que É a única concessão: sem UM jeito de provisionar um
|
||||
Product por HTTP -- design spec decisão #5, "provisionar um Product é uma
|
||||
ação de operador, não self-service" -- não há como popular um cenário sem
|
||||
tocar a camada de app UMA vez por teste; a asserção em si nunca o faz)."""
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from cryptography import x509
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
from cryptography.x509.oid import NameOID
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from fiscal_svc.core.db import get_session
|
||||
from fiscal_svc.main import app
|
||||
|
||||
_CNPJ = "14200166000187"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _override_db(db_session):
|
||||
async def _get_session_override():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_session] = _get_session_override
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fiscal_cert_encryption_key(monkeypatch):
|
||||
monkeypatch.setenv("FISCAL_CERT_ENCRYPTION_KEY", Fernet.generate_key().decode("ascii"))
|
||||
yield
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def product_api_key(db_session):
|
||||
"""A ÚNICA concessão à regra "zero import do interior" -- provisionar
|
||||
um Product não tem endpoint HTTP (decisão de design deliberada, ver o
|
||||
docstring do módulo), então esta fixture chama a função de serviço
|
||||
diretamente. Nenhum teste abaixo importa nada além disto."""
|
||||
from fiscal_svc.tenancy.service import create_product
|
||||
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
await create_product(db_session, name=f"contract-{uuid.uuid4().hex[:8]}", api_key=key)
|
||||
return key
|
||||
|
||||
|
||||
def _headers(api_key: str) -> dict[str, str]:
|
||||
return {"X-Api-Key": api_key}
|
||||
|
||||
|
||||
def _build_test_pfx(cnpj: str = _CNPJ, password: str = "senha123") -> bytes:
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
subject = issuer = x509.Name(
|
||||
[
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, f"EMPRESA TESTE LTDA:{cnpj}"),
|
||||
x509.NameAttribute(NameOID.SERIAL_NUMBER, cnpj),
|
||||
]
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(issuer)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - timedelta(days=1))
|
||||
.not_valid_after(now + timedelta(days=365))
|
||||
.sign(key, hashes.SHA256())
|
||||
)
|
||||
return pkcs12.serialize_key_and_certificates(
|
||||
name=b"test", key=key, cert=cert, cas=None,
|
||||
encryption_algorithm=serialization.BestAvailableEncryption(password.encode("utf-8")),
|
||||
)
|
||||
|
||||
|
||||
# --- health ------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_returns_200_ok_shape():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/v1/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
|
||||
# --- auth (require_product) --------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"method,path,kwargs",
|
||||
[
|
||||
("get", "/v1/certificados", {"params": {"tenant_ref": "t", "branch_ref": "b"}}),
|
||||
("get", "/v1/series", {"params": {"tenant_ref": "t", "branch_ref": "b"}}),
|
||||
("get", "/v1/documentos", {}),
|
||||
("post", "/v1/series", {"json": {"tenant_ref": "t", "branch_ref": "b", "document_model": "55", "serie": 1, "next_number": 1}}),
|
||||
],
|
||||
)
|
||||
async def test_every_v1_route_requires_api_key(method, path, kwargs):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await getattr(client, method)(path, **kwargs)
|
||||
|
||||
assert response.status_code == 401, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrong_api_key_is_401():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/v1/series",
|
||||
params={"tenant_ref": "t", "branch_ref": "b"},
|
||||
headers=_headers("not-a-real-key"),
|
||||
)
|
||||
|
||||
assert response.status_code == 401, response.text
|
||||
|
||||
|
||||
# --- series --------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_series_create_list_patch_shapes(product_api_key):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
create_response = await client.post(
|
||||
"/v1/series",
|
||||
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 1},
|
||||
headers=_headers(product_api_key),
|
||||
)
|
||||
assert create_response.status_code == 201, create_response.text
|
||||
body = create_response.json()
|
||||
for field in ("id", "product_id", "tenant_ref", "branch_ref", "document_model", "serie", "next_number"):
|
||||
assert field in body, f"campo {field!r} ausente no shape de FiscalSeriesRead"
|
||||
series_id = body["id"]
|
||||
|
||||
duplicate_response = await client.post(
|
||||
"/v1/series",
|
||||
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 2},
|
||||
headers=_headers(product_api_key),
|
||||
)
|
||||
assert duplicate_response.status_code == 409, duplicate_response.text
|
||||
assert duplicate_response.json()["detail"]["code"] == "duplicate_fiscal_series"
|
||||
|
||||
list_response = await client.get(
|
||||
"/v1/series", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(product_api_key)
|
||||
)
|
||||
assert list_response.status_code == 200, list_response.text
|
||||
assert isinstance(list_response.json(), list)
|
||||
assert len(list_response.json()) == 1
|
||||
|
||||
patch_response = await client.patch(
|
||||
f"/v1/series/{series_id}", json={"next_number": 5}, headers=_headers(product_api_key)
|
||||
)
|
||||
assert patch_response.status_code == 200, patch_response.text
|
||||
assert patch_response.json()["next_number"] == 5
|
||||
|
||||
not_found_response = await client.patch(
|
||||
f"/v1/series/{uuid.uuid4()}", json={"next_number": 5}, headers=_headers(product_api_key)
|
||||
)
|
||||
assert not_found_response.status_code == 404, not_found_response.text
|
||||
|
||||
|
||||
# --- certificados ----------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_certificados_upload_get_delete_shapes(product_api_key):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
upload_response = await client.post(
|
||||
"/v1/certificados",
|
||||
params={"tenant_ref": "t1", "branch_ref": "b1"},
|
||||
files={"file": ("cert.pfx", _build_test_pfx(), "application/x-pkcs12")},
|
||||
data={"password": "senha123", "cnpj": _CNPJ},
|
||||
headers=_headers(product_api_key),
|
||||
)
|
||||
assert upload_response.status_code == 201, upload_response.text
|
||||
body = upload_response.json()
|
||||
for field in (
|
||||
"id", "product_id", "tenant_ref", "branch_ref", "cnpj",
|
||||
"subject_cn", "cnpj_certificado", "not_valid_before", "not_valid_after", "created_at",
|
||||
):
|
||||
assert field in body, f"campo {field!r} ausente no shape de FiscalCertificateRead"
|
||||
assert "pfx_encrypted" not in body
|
||||
assert "password_encrypted" not in body
|
||||
|
||||
get_response = await client.get(
|
||||
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(product_api_key)
|
||||
)
|
||||
assert get_response.status_code == 200, get_response.text
|
||||
|
||||
delete_response = await client.delete(
|
||||
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(product_api_key)
|
||||
)
|
||||
assert delete_response.status_code == 204, delete_response.text
|
||||
|
||||
get_after_delete_response = await client.get(
|
||||
"/v1/certificados", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(product_api_key)
|
||||
)
|
||||
assert get_after_delete_response.status_code == 404, get_after_delete_response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_certificado_invalido_e_422(product_api_key):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/v1/certificados",
|
||||
params={"tenant_ref": "t1", "branch_ref": "b1"},
|
||||
files={"file": ("cert.pfx", b"nao e um pfx", "application/x-pkcs12")},
|
||||
data={"password": "qualquer", "cnpj": _CNPJ},
|
||||
headers=_headers(product_api_key),
|
||||
)
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
# --- emissão ---------------------------------------------------------------
|
||||
|
||||
|
||||
def _minimal_emissao_payload(*, tenant_ref="t1", branch_ref="b1", serie=1) -> dict:
|
||||
return {
|
||||
"tenant_ref": tenant_ref,
|
||||
"branch_ref": branch_ref,
|
||||
"document_model": "55",
|
||||
"serie": serie,
|
||||
"emitente": {
|
||||
"cnpj": _CNPJ,
|
||||
"razao_social": "EMPRESA TESTE LTDA",
|
||||
"nome_fantasia": None,
|
||||
"ie": "1234567890",
|
||||
"crt": "1",
|
||||
"address_street": "Rua Teste",
|
||||
"address_number": "100",
|
||||
"address_complement": None,
|
||||
"address_district": "Centro",
|
||||
"address_city": "Curitiba",
|
||||
"address_state": "PR",
|
||||
"address_zip": "80000000",
|
||||
"address_city_ibge_code": "4106902",
|
||||
},
|
||||
"itens": [
|
||||
{
|
||||
"codigo": "PC001",
|
||||
"descricao": "Peca teste",
|
||||
"ncm": "84212300",
|
||||
"cfop": "5102",
|
||||
"unidade_comercial": "UN",
|
||||
"unidade_tributavel": "UN",
|
||||
"quantidade": "1",
|
||||
"valor_unitario": "10.00",
|
||||
"fiscal_result": {
|
||||
"cfop": "5102",
|
||||
"cst": None,
|
||||
"csosn": "102",
|
||||
"origem": "0",
|
||||
"consumidor_final": True,
|
||||
"indicador_ie": "9",
|
||||
"tributos": [],
|
||||
},
|
||||
}
|
||||
],
|
||||
"pagamento": {"tpag": "01", "valor": "10.00", "indpag": "0"},
|
||||
"ambiente": "homologacao",
|
||||
"uf_destino_tipo": "interna",
|
||||
"destinatario": None,
|
||||
"ver_proc": "contract-test/1.0",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emissao_requires_idempotency_key_header(product_api_key):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/v1/emissoes", json=_minimal_emissao_payload(), headers=_headers(product_api_key)
|
||||
)
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emissao_requires_ver_proc(product_api_key):
|
||||
payload = _minimal_emissao_payload()
|
||||
del payload["ver_proc"]
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/v1/emissoes", json=payload,
|
||||
headers={**_headers(product_api_key), "Idempotency-Key": f"k-{uuid.uuid4().hex}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emissao_without_certificate_or_series_is_409_fiscal_config_missing(product_api_key):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/v1/emissoes", json=_minimal_emissao_payload(),
|
||||
headers={**_headers(product_api_key), "Idempotency-Key": f"k-{uuid.uuid4().hex}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409, response.text
|
||||
body = response.json()
|
||||
assert body["detail"]["code"] == "fiscal_config_missing"
|
||||
assert "message" in body["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_emissao_happy_path_shapes_and_idempotency(product_api_key):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
upload_response = await client.post(
|
||||
"/v1/certificados",
|
||||
params={"tenant_ref": "t1", "branch_ref": "b1"},
|
||||
files={"file": ("cert.pfx", _build_test_pfx(), "application/x-pkcs12")},
|
||||
data={"password": "senha123", "cnpj": _CNPJ},
|
||||
headers=_headers(product_api_key),
|
||||
)
|
||||
assert upload_response.status_code == 201, upload_response.text
|
||||
|
||||
series_response = await client.post(
|
||||
"/v1/series",
|
||||
json={"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55", "serie": 1, "next_number": 1},
|
||||
headers=_headers(product_api_key),
|
||||
)
|
||||
assert series_response.status_code == 201, series_response.text
|
||||
|
||||
idem_key = f"k-{uuid.uuid4().hex}"
|
||||
first_response = await client.post(
|
||||
"/v1/emissoes", json=_minimal_emissao_payload(),
|
||||
headers={**_headers(product_api_key), "Idempotency-Key": idem_key},
|
||||
)
|
||||
assert first_response.status_code == 201, first_response.text
|
||||
body = first_response.json()
|
||||
for field in (
|
||||
"id", "product_id", "tenant_ref", "branch_ref", "series_id", "document_model",
|
||||
"serie", "numero", "chave_acesso", "codigo_numerico", "status", "ambiente",
|
||||
"rejeicao_codigo", "rejeicao_motivo", "protocolo", "autorizada_em", "created_at",
|
||||
):
|
||||
assert field in body, f"campo {field!r} ausente no shape de FiscalDocumentRead"
|
||||
assert "xml_assinado" not in body
|
||||
assert body["status"] == "ASSINADO"
|
||||
document_id = body["id"]
|
||||
|
||||
second_response = await client.post(
|
||||
"/v1/emissoes", json=_minimal_emissao_payload(),
|
||||
headers={**_headers(product_api_key), "Idempotency-Key": idem_key},
|
||||
)
|
||||
assert second_response.status_code == 200, second_response.text
|
||||
assert second_response.json()["id"] == document_id
|
||||
|
||||
get_response = await client.get(f"/v1/documentos/{document_id}", headers=_headers(product_api_key))
|
||||
assert get_response.status_code == 200, get_response.text
|
||||
assert "xml_assinado" not in get_response.json()
|
||||
|
||||
xml_response = await client.get(f"/v1/documentos/{document_id}/xml", headers=_headers(product_api_key))
|
||||
assert xml_response.status_code == 200
|
||||
assert xml_response.headers["content-type"].startswith("application/xml")
|
||||
assert "<NFe" in xml_response.text
|
||||
|
||||
list_response = await client.get(
|
||||
"/v1/documentos", params={"tenant_ref": "t1", "branch_ref": "b1"}, headers=_headers(product_api_key)
|
||||
)
|
||||
assert list_response.status_code == 200, list_response.text
|
||||
assert any(d["id"] == document_id for d in list_response.json())
|
||||
|
||||
not_found_response = await client.get(
|
||||
f"/v1/documentos/{uuid.uuid4()}", headers=_headers(product_api_key)
|
||||
)
|
||||
assert not_found_response.status_code == 404, not_found_response.text
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Task 6: design spec's "os 3 seguros de portabilidade", item (c) --
|
||||
"OpenAPI versionado como fonte de verdade do contrato" -- and the F2 plan's
|
||||
own requirement: `docs/openapi-v1.json` COMMITTED + a test that fails if it
|
||||
diverges from `app.openapi()`. Regenerate with:
|
||||
|
||||
uv run python -c "
|
||||
import json
|
||||
from fiscal_svc.main import app
|
||||
with open('docs/openapi-v1.json', 'w') as f:
|
||||
json.dump(app.openapi(), f, indent=2, sort_keys=True)
|
||||
f.write('\n')
|
||||
"
|
||||
|
||||
...and commit the result -- NEVER hand-edit `docs/openapi-v1.json`."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fiscal_svc.main import app
|
||||
|
||||
_OPENAPI_PATH = Path(__file__).resolve().parents[1] / "docs" / "openapi-v1.json"
|
||||
|
||||
|
||||
def test_committed_openapi_matches_generated_schema():
|
||||
committed = json.loads(_OPENAPI_PATH.read_text(encoding="utf-8"))
|
||||
generated = app.openapi()
|
||||
|
||||
assert committed == generated, (
|
||||
"docs/openapi-v1.json divergiu do schema gerado por app.openapi() -- "
|
||||
"regenere (ver o docstring deste teste) e commite o resultado; nunca "
|
||||
"edite o JSON à mão"
|
||||
)
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Task 6: fecha o gap da EMENDA 7b (design spec's "os 3 seguros de
|
||||
portabilidade", item (b)) -- o corpus golden da lib (`sowai_fiscal.
|
||||
goldens`) só cobre XML PRÉ-assinatura; o caminho de ASSINATURA fica sem
|
||||
seguro golden até aqui. Para cada caso golden, este teste emite ATRAVÉS DA
|
||||
API real (`POST /v1/emissoes`, o mesmo caminho de qualquer produto
|
||||
consumidor) com `cnf`/`dh_emi` PINADOS (via monkeypatch -- os únicos dois
|
||||
elementos do payload real que a SERVICE gera de forma não-determinística;
|
||||
`numero` é controlado plantando o `next_number` da série) e o certificado de
|
||||
TESTE fixo (`tests/fixtures/test_cert.pfx`, NUNCA um certificado real) e
|
||||
compara o XML assinado resultante, BYTE A BYTE, contra `tests/
|
||||
goldens_signed/<case>.expected.xml` (gerado uma vez por
|
||||
`scripts/generate_signed_goldens.py`).
|
||||
|
||||
RSA PKCS#1v1.5 (o que `erpbrasil.assinatura` usa) não tem padding
|
||||
aleatório -- a MESMA chave privada assinando o MESMO XML canonicalizado
|
||||
produz sempre a MESMA assinatura. Isso é o que torna esta comparação byte a
|
||||
byte estável entre execuções (provado por `scripts/generate_signed_goldens.
|
||||
py` sendo rodado duas vezes seguidas e comparado -- ver seu commit)."""
|
||||
import importlib.resources
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from fiscal_svc.core.db import get_session
|
||||
from fiscal_svc.emission import service as emission_service
|
||||
from fiscal_svc.main import app
|
||||
from fiscal_svc.tenancy.service import create_product
|
||||
|
||||
_GOLDENS_DIR = Path(str(importlib.resources.files("sowai_fiscal") / "goldens"))
|
||||
_EXPECTED_DIR = Path(__file__).resolve().parent / "goldens_signed"
|
||||
_CERT_PATH = Path(__file__).resolve().parent / "fixtures" / "test_cert.pfx"
|
||||
_CERT_PASSWORD = "test-cert-password"
|
||||
|
||||
_CASES = sorted(p.name.removesuffix(".input.json") for p in _GOLDENS_DIR.glob("*.input.json"))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _override_db(db_session):
|
||||
async def _get_session_override():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_session] = _get_session_override
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fiscal_cert_encryption_key(monkeypatch):
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from fiscal_svc.certificates import crypto as certificate_lib
|
||||
|
||||
monkeypatch.setenv("FISCAL_CERT_ENCRYPTION_KEY", Fernet.generate_key().decode("ascii"))
|
||||
certificate_lib._get_fernet.cache_clear()
|
||||
yield
|
||||
certificate_lib._get_fernet.cache_clear()
|
||||
|
||||
|
||||
def _headers(api_key: str) -> dict[str, str]:
|
||||
return {"X-Api-Key": api_key}
|
||||
|
||||
|
||||
class _FrozenDatetime:
|
||||
"""Stand-in for `emission.service`'s module-level `datetime` NAME
|
||||
(`from datetime import datetime` -- a plain attribute of the module,
|
||||
monkeypatch-replaceable). Exposes only `.now(tz)`, the ONLY method
|
||||
`emission.service` calls on it -- returns the SAME fixed instant
|
||||
regardless of the requested `tz` (converted via `.astimezone`), so both
|
||||
call sites (`datetime.now(_TZ_EMISSAO)` for `dh_emi`/AAMM and `datetime.
|
||||
now(timezone.utc)` for the certificate validity check) see a coherent,
|
||||
pinned "now"."""
|
||||
|
||||
def __init__(self, fixed: datetime):
|
||||
self._fixed = fixed
|
||||
|
||||
def now(self, tz=None):
|
||||
return self._fixed.astimezone(tz) if tz is not None else self._fixed
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", _CASES)
|
||||
@pytest.mark.asyncio
|
||||
async def test_signed_golden_matches_byte_for_byte(db_session, monkeypatch, case):
|
||||
raw = json.loads((_GOLDENS_DIR / f"{case}.input.json").read_text(encoding="utf-8"))
|
||||
expected = (_EXPECTED_DIR / f"{case}.expected.xml").read_text(encoding="utf-8")
|
||||
|
||||
# cNF pinado -- o único elemento aleatório de `emitir_documento` além do
|
||||
# relógio.
|
||||
monkeypatch.setattr(emission_service, "gerar_cnf", lambda numero: raw["cnf"])
|
||||
# dh_emi/AAMM pinados no MESMO instante do golden.
|
||||
fixed_now = datetime.fromisoformat(raw["dh_emi"])
|
||||
monkeypatch.setattr(emission_service, "datetime", _FrozenDatetime(fixed_now))
|
||||
|
||||
key = f"k-{uuid.uuid4().hex}"
|
||||
product = await create_product(db_session, name=f"golden-{case}", api_key=key)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
pfx_bytes = _CERT_PATH.read_bytes()
|
||||
upload_response = await client.post(
|
||||
"/v1/certificados",
|
||||
params={"tenant_ref": "t1", "branch_ref": "b1"},
|
||||
files={"file": ("cert.pfx", pfx_bytes, "application/x-pkcs12")},
|
||||
data={"password": _CERT_PASSWORD, "cnpj": raw["emitente"]["cnpj"]},
|
||||
headers=_headers(key),
|
||||
)
|
||||
assert upload_response.status_code == 201, upload_response.text
|
||||
|
||||
# `numero` é controlado plantando o `next_number` da série -- a
|
||||
# primeira (e única) alocação desta série devolve exatamente
|
||||
# `raw["numero"]`.
|
||||
series_response = await client.post(
|
||||
"/v1/series",
|
||||
json={
|
||||
"tenant_ref": "t1", "branch_ref": "b1", "document_model": "55",
|
||||
"serie": raw["serie"], "next_number": raw["numero"],
|
||||
},
|
||||
headers=_headers(key),
|
||||
)
|
||||
assert series_response.status_code == 201, series_response.text
|
||||
|
||||
payload = {
|
||||
"tenant_ref": "t1",
|
||||
"branch_ref": "b1",
|
||||
"document_model": "55",
|
||||
"serie": raw["serie"],
|
||||
"emitente": raw["emitente"],
|
||||
"itens": raw["itens"],
|
||||
"pagamento": raw["pagamento"],
|
||||
"ambiente": raw["ambiente"],
|
||||
"uf_destino_tipo": raw["uf_destino_tipo"],
|
||||
"destinatario": raw.get("destinatario"),
|
||||
# A lib congela `ver_proc="sowai-auto/1b.1"` como default do
|
||||
# PRÓPRIO `DadosEmissao` quando o golden JSON não o especifica
|
||||
# (todos os 6 casos hoje) -- reproduzido aqui EXPLICITAMENTE
|
||||
# (design spec EMENDA F1: este serviço nunca aplica esse default
|
||||
# sozinho, o produto chamador sempre declara).
|
||||
"ver_proc": raw.get("ver_proc", "sowai-auto/1b.1"),
|
||||
# Campos opcionais que `sowai_fiscal.golden_helpers.dados_from_
|
||||
# json` só seta quando presentes no JSON golden (caindo no
|
||||
# default do próprio `DadosEmissao` quando ausentes) -- mesmo
|
||||
# racional aqui: `EmissaoRequest` tem os MESMOS defaults
|
||||
# ("Venda"/"1"/"1"/"1"/"1"), então só repassamos quando o golden
|
||||
# declara um valor NÃO-default. Faltar isto (bug real corrigido
|
||||
# aqui) fazia `caso_devolucao_intra` -- o único dos 6 casos com
|
||||
# `nat_op` != "Venda" -- emitir com `<natOp>Venda</natOp>` em vez
|
||||
# de `<natOp>Devolucao de venda</natOp>`, divergindo do golden
|
||||
# assinado byte a byte.
|
||||
"nat_op": raw.get("nat_op", "Venda"),
|
||||
"tp_emis": raw.get("tp_emis", "1"),
|
||||
"ind_final": raw.get("ind_final", "1"),
|
||||
"ind_pres": raw.get("ind_pres", "1"),
|
||||
"fin_nfe": raw.get("fin_nfe", "1"),
|
||||
}
|
||||
emit_response = await client.post(
|
||||
"/v1/emissoes", json=payload,
|
||||
headers={**_headers(key), "Idempotency-Key": f"golden-{case}-{uuid.uuid4().hex}"},
|
||||
)
|
||||
assert emit_response.status_code == 201, emit_response.text
|
||||
document_id = emit_response.json()["id"]
|
||||
|
||||
xml_response = await client.get(f"/v1/documentos/{document_id}/xml", headers=_headers(key))
|
||||
|
||||
assert emit_response.json()["chave_acesso"] == raw["chave_acesso"], (
|
||||
"a chave de acesso montada pelo serviço deveria bater com a do golden -- todos os "
|
||||
"componentes (uf_ibge/aamm/cnpj/modelo/serie/numero/tp_emis/cnf) precisam coincidir"
|
||||
)
|
||||
assert xml_response.text == expected, (
|
||||
f"XML assinado do caso {case!r} divergiu byte a byte do golden esperado -- "
|
||||
"regenere com scripts/generate_signed_goldens.py SE E SOMENTE SE a mudança for "
|
||||
"intencional (ex.: golden novo/atualizado na lib), nunca para 'fazer passar'"
|
||||
)
|
||||
@@ -961,6 +961,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.32"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2026.2"
|
||||
@@ -1064,6 +1073,7 @@ dependencies = [
|
||||
{ name = "passlib", extra = ["bcrypt"] },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "sowai-fiscal" },
|
||||
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
@@ -1087,6 +1097,7 @@ requires-dist = [
|
||||
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
|
||||
{ name = "pydantic", specifier = ">=2.13.4" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.20" },
|
||||
{ name = "sowai-fiscal", git = "https://git.sowai.com.br/jonatan/sowai-fiscal.git?rev=v0.1.0" },
|
||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.49.0" },
|
||||
|
||||
Reference in New Issue
Block a user