Compare commits

..
14 Commits
Author SHA1 Message Date
jonatanritterandClaude Opus 4.8 cd0aeaa31f chore(k8s): drop fiscal-svc cpu request to 10m (dev node is CPU-saturated)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 16:47:32 -03:00
jonatanritterandClaude Opus 4.8 d27d8c242b chore(k8s): dev deploy manifests — ClusterIP, migrate initContainer, dedicated DB
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 16:14:40 -03:00
jonatanritterandClaude Opus 4.8 1625e8a257 test(emission): scope the FIX 4 test to the filter's real contract
The FIX 4 test asserted a full new 201 emission after the only document
under an idempotency key was soft-deleted. That over-reached: the surviving
FiscalIdempotencyKey (never soft-deleted, by design) still collides on the
UNIQUE(product_id, idempotency_key), and the camada-2 winner lookup — now
filtered by deleted_at IS NULL — returns None and re-raises. Re-emission
under a burned key is a future CANCEL feature (no soft-delete path exists in
F2), out of scope for this MINOR. The fix's real contract is narrow: a replay
must not RETURN a soft-deleted document. Test now proves exactly that on the
query (seeded on a separate session so it doesn't pollute the app's shared
db_session — the source of the MissingGreenlet the end-to-end POST hit).
Detection proven: filter removed -> FAILED; restored -> passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 16:14:40 -03:00
jonatanritterandClaude Opus 4.8 4b8532c604 docs(emission): fix stale schemas.py docstring re FiscalResultPayload/TributoLinhaPayload
MINOR (F2 review): the module docstring claimed FiscalResult/TributoLinha
'are NOT re-mirrored here' but the file defines FiscalResultPayload/
TributoLinhaPayload right below it. Corrects the prose to describe reality:
they ARE re-mirrored, deliberately kept separate from the lib's own pydantic
classes (this module is the wire contract the service owns), converted via
emission.service._to_fiscal_result/_to_tributo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 15:37:15 -03:00
jonatanritterandClaude Opus 4.8 c2d2d30b70 feat(openapi): declare the full response contract (401/404/409/413) on every v1 route
Honors portability safeguard (c) -- the committed docs/openapi-v1.json was
enforced to stay in sync with app.openapi() (test_openapi_committed.py),
but the app declared almost no error responses: POST /v1/emissoes showed
only 200/422 (no 201), and no route declared 401/404/409/413 or the
structured 409 codes the spec names as contract. A Go reimplementation
reading only the committed OpenAPI as source of truth wouldn't learn them.

Adds responses={...} to every v1 router (emissao, certificados, series,
documentos GET/xml) covering the status codes each route actually returns
-- 201 as the default on POST /v1/emissoes (with 200 documented for the
Idempotency-Key replay case), 401 on every authenticated route, 404 where
the anti-oracle boundary applies, 409 naming the structured codes each
route raises, 413 on the certificate upload's size cap. Regenerated
docs/openapi-v1.json from app.openapi() (uv run python -c '...json.dump...'
per test_openapi_committed.py's own docstring) so the committed==generated
assertion stays green with FIX 1-4's new 409 codes included.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 15:36:58 -03:00
jonatanritterandClaude Opus 4.8 260644c961 fix(emission): idempotency replay ignores soft-deleted documents
MINOR (F2 review): _get_document_by_idempotency_key lacked the
deleted_at IS NULL filter that get_fiscal_document already has. No F2
trigger today (nothing soft-deletes a FiscalDocument yet), but a future
cancel-by-soft-delete would replay a dead document as if it were still the
valid idempotent response, instead of treating the key as free for a new
emission.

Test: tests/emission/test_emissao.py::
test_idempotency_key_apontando_para_documento_soft_deletado_e_tratada_como_ausente
-- manually inserts a soft-deleted FiscalDocument + its FiscalIdempotencyKey
row (constructing the pre-cancel scenario directly, since nothing else in
F2 produces one yet), asserts the query-level replay returns None, and that
replaying via POST /v1/emissoes with that key emits a brand-new document
(201, new number allocated) rather than resolving to the dead one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 15:35:27 -03:00
jonatanritterandClaude Opus 4.8 0b64602716 fix(emission): validate Σ vPag against the document total (vNF)
IMPORTANT (F2 review): the service promises to validate structural
coherence (sums, required leiaute fields) per design spec decision #3, but
the one sum that can genuinely diverge -- pagamento.valor vs the document
total -- was unchecked. The lib documents SEFAZ rejects this with 'Valor do
Pagamento difere do total' (sowai_fiscal.xml_builder).

Adds PagamentoTotalMismatchError, computed in the completeness block
(before allocate_fiscal_number) by reusing sowai_fiscal.xml_builder.
soma_itens_quantizados -- the SAME function the lib's own _build_total uses
for vNF/vProd, avoiding a second source of truth for the same total.
Decimal-exact comparison (never float), both sides quantized to cents the
same way _build_pag does. Router maps it to 409 pagamento_total_diverge.

Test: tests/emission/test_emissao.py::
test_pagamento_divergente_do_total_dos_itens_e_409_e_nao_queima_numero --
pagamento.valor != Σ itens -> 409 pagamento_total_diverge, no fiscal number
consumed. The existing golden-backed happy-path tests (already balanced)
continue to prove the matching-totals case still succeeds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 15:34:24 -03:00
jonatanritterandClaude Opus 4.8 72bb089222 fix(certificates): scope the live certificate uniquely per tenant, not just per branch_ref
IMPORTANT (F2 review): certificate is per-tenant, matching series (already
tenant-scoped) and the GET/DELETE anti-oracle boundary. Emission's
_get_live_certificate and the upload-replace pre-check (certificates.
service, renamed _get_live_certificate_by_branch ->
_get_live_certificate_by_tenant_branch) both omitted tenant_ref -- two
tenants of one product reusing branch_ref="matriz" collapsed onto the same
slot: B's upload soft-deleted A's still-live certificate, and A's emission
went on to sign with B's certificate.

Migration 8f1a2c9d4b6e replaces the partial-unique index
ix_fiscal_certificates_product_branch_live with
ix_fiscal_certificates_product_tenant_branch_live on
(product_id, tenant_ref, branch_ref) WHERE deleted_at IS NULL, with a
working downgrade. Upload's two-layer defense (pre-check + IntegrityError ->
CertificateUploadConflictError) still holds against the new index.

Tests:
- tests/emission/test_emissao.py::
  test_dois_tenants_do_mesmo_produto_reusando_branch_ref_tem_certificados_isolados
  -- two tenants upload for the same product/branch_ref, both stay live;
  emission for each signs with its OWN certificate (observable via FIX 1's
  CNPJ check: without FIX 2, tenant A's emission would 409
  emitente_certificate_cnpj_mismatch because the "live" cert would
  actually be B's).
- tests/migrations/test_fiscal_documents_schema.py::
  test_two_tenants_can_both_hold_a_live_certificate_for_the_same_branch_ref_on_real_migration
  -- real alembic upgrade head, raw INSERTs proving both tenants' certs
  land live.
- tests/migrations/test_fiscal_documents_schema.py::
  test_two_live_certificates_for_same_product_tenant_branch_violate_unique_index_on_real_migration
  (renamed from ..._product_branch_...) -- same (product, tenant, branch)
  still rejects a second live certificate on the real migration.
- tests/certificates/test_certificates.py::
  test_concurrent_uploads_for_same_product_branch_only_one_wins_the_other_gets_409
  updated for the renamed/re-scoped precheck function (still same-tenant
  race, still 1 winner + 1 CertificateUploadConflictError).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 15:32:20 -03:00
jonatanritterandClaude Opus 4.8 3e3a1abc6f fix(emission): validate emitente CNPJ against branch certificate at emission
CRITICAL (F2 review, Opus+Fable): the service decoupled emitente
(free-form payload.emitente.cnpj) from the certificate (looked up by
branch_ref) with nothing re-checking the equality the auto had structurally
via Branch.cnpj. A caller passing emitente.cnpj=B with a branch whose
certificate CNPJ=A got a signed, persisted ASSINADO document with
chave/emit=B but signature=A, having burned a nNF.

Adds EmitenteCertificateCnpjMismatchError, checked in the completeness
block (before allocate_fiscal_number, so a mismatch never consumes a
número), normalizing both sides (digits only) before comparing. Router maps
it to 409 emitente_certificate_cnpj_mismatch; message never leaks the
certificate's CNPJ.

Test: tests/emission/test_emissao.py::
test_emitente_cnpj_divergente_do_certificado_e_409_e_nao_queima_numero --
uploads a cert for CNPJ A, POSTs emissão with the same branch_ref but
emitente.cnpj=B, asserts 409 + no fiscal number consumed (a follow-up
emission with the matching CNPJ gets the number that would have been
burned).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 15:30:58 -03:00
jonatanritterandClaude Opus 4.8 d9c1c444fb test: pass all non-default DadosEmissao fields from the golden JSON
The signed-golden payload builder forwarded only ver_proc from the golden,
letting nat_op/tp_emis/ind_* fall to EmissaoRequest defaults. caso_devolucao_
intra is the only case with a non-default nat_op ('Devolucao de venda'), so
the emitted XML carried <natOp>Venda</natOp> and diverged from the (correct)
golden byte-for-byte. The golden was right and the emission handles natOp
correctly; the harness was feeding incomplete input. Byte-for-byte assertion
unchanged, golden unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 02:15:16 -03:00
jonatanritterandClaude Opus 4.8 39ba81efad test: force the concurrent-upload race deterministically with asyncio.Event
Plain asyncio.gather on one event loop could let the first upload commit
before the second's pre-check, making the second a legitimate replace (2
successes, 1 live row) -- correct behavior that fails the '1 conflict'
assertion. Same Event-synchronization pattern as the auto's HTTP race tests:
hold the first past its pre-check until the second also pre-checks, so both
read None before either writes and the partial-unique index decides
deterministically. Code under test untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 02:15:16 -03:00
jonatanritterandClaude Opus 4.8 fb2b8ce372 feat: portability safeguards — signed goldens, HTTP contract suite, committed OpenAPI (Task 6)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 18:25:08 -03:00
jonatanritterandClaude Opus 4.8 3aae8b67ef feat: emission v1 + idempotency (Task 5)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 18:25:08 -03:00
jonatanritter c903a9ce0e feat: certificates + series API v1 (Task 4)
Port certificate lifecycle (parse/encrypt/upload/deactivate) and
FiscalSeries CRUD from the auto, adapted to (product_id, tenant_ref,
branch_ref) tenancy. Closes the "guard retroativo" PATCH /v1/series
next_number regression check that Task 3 deferred to this task.

Routes: POST/GET/DELETE /v1/certificados, POST/GET/PATCH /v1/series.
2026-07-22 16:51:22 -03:00
41 changed files with 6215 additions and 24 deletions
+6
View File
@@ -1,2 +1,8 @@
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/fiscal_svc_dev DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/fiscal_svc_dev
TEST_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/fiscal_svc_test 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"),
)
+1931
View File
File diff suppressed because it is too large Load Diff
+107
View File
@@ -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
+1
View File
@@ -13,6 +13,7 @@ dependencies = [
"passlib[bcrypt]>=1.7.4", "passlib[bcrypt]>=1.7.4",
"pydantic-settings>=2.0", "pydantic-settings>=2.0",
"pydantic>=2.13.4", "pydantic>=2.13.4",
"python-multipart>=0.0.20",
"sowai-fiscal", "sowai-fiscal",
"sqlalchemy[asyncio]>=2.0", "sqlalchemy[asyncio]>=2.0",
"uvicorn[standard]>=0.49.0", "uvicorn[standard]>=0.49.0",
+55
View File
@@ -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()
+67
View File
@@ -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()
+158
View File
@@ -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
+140
View File
@@ -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
+23
View File
@@ -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
+237
View File
@@ -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()
+66 -20
View File
@@ -109,27 +109,33 @@ class FiscalSeries(Base, UUIDPKMixin, TimestampMixin, SoftDeleteMixin):
class FiscalCertificate(Base, UUIDPKMixin, TimestampMixin, SoftDeleteMixin): class FiscalCertificate(Base, UUIDPKMixin, TimestampMixin, SoftDeleteMixin):
"""Ported from the auto's `FiscalCertificate` (`app/modules/fiscal/ """Ported from the auto's `FiscalCertificate` (`app/modules/fiscal/
models.py`) -- A1 certificate (.pfx) for a `branch_ref`. Per the porte models.py`) -- A1 certificate (.pfx) for a `branch_ref`. Per the porte
table, the emitter is `(product_id, branch_ref)`, with `cnpj` carried table, the emitter is `(product_id, tenant_ref, branch_ref)`, with
on the row itself (the "vínculo forte", design spec decision #4: the `cnpj` carried on the row itself (the "vínculo forte", design spec
CNPJ is what's validated against the certificate at upload and against decision #4: the CNPJ is what's validated against the certificate at
the emitente at emission -- `branch_ref` alone is an opaque string this upload and against the emitente at emission -- `branch_ref` alone is an
service never interprets). No fallback to a tenant-level or opaque string this service never interprets). No fallback to a
product-level certificate -- fail-closed by construction, same as the 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 One VIVO (`deleted_at IS NULL`) row per `(product_id, tenant_ref,
second upload soft-deletes the previous live row (Task 4's branch_ref)`: a second upload soft-deletes the previous live row
`certificates.service`). `ix_fiscal_certificates_product_branch_live` (Task 4's `certificates.service`). `ix_fiscal_certificates_product_
is a PARTIAL UNIQUE index enforcing that at the database level (same tenant_branch_live` is a PARTIAL UNIQUE index enforcing that at the
"two concurrent uploads must not both land a live row" reasoning as the database level (same "two concurrent uploads must not both land a live
auto's own migration `a1b2c3d4e5f6`'s fix) -- scoped by `product_id` in row" reasoning as the auto's own migration `a1b2c3d4e5f6`'s fix) --
ADDITION to `branch_ref` (the auto's version only needed `branch_id`, scoped by `product_id` AND `tenant_ref` in ADDITION to `branch_ref`
already product-scoped by being a real FK; here `branch_ref` is an (the auto's version only needed `branch_id`, already product-scoped by
OPAQUE string owned by the calling product, so two DIFFERENT products being a real FK; here `branch_ref` is an OPAQUE string owned by the
could coincidentally pick the identical string for two DIFFERENT real calling product, so two DIFFERENT products -- or two DIFFERENT tenants
branches -- scoping the uniqueness by `product_id` too is what keeps of the SAME product -- could coincidentally pick the identical string
that from cross-contaminating one product's certificate slot with for two DIFFERENT real branches -- scoping the uniqueness by
another's). `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, `pfx_encrypted`/`password_encrypted` are Fernet ciphertext (Task 4,
`FISCAL_CERT_ENCRYPTION_KEY` env var, own key -- never `FISCAL_CERT_ENCRYPTION_KEY` env var, own key -- never
@@ -139,8 +145,9 @@ class FiscalCertificate(Base, UUIDPKMixin, TimestampMixin, SoftDeleteMixin):
__tablename__ = "fiscal_certificates" __tablename__ = "fiscal_certificates"
__table_args__ = ( __table_args__ = (
Index( Index(
"ix_fiscal_certificates_product_branch_live", "ix_fiscal_certificates_product_tenant_branch_live",
"product_id", "product_id",
"tenant_ref",
"branch_ref", "branch_ref",
unique=True, unique=True,
postgresql_where=text("deleted_at IS NULL"), 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) rejeicao_motivo: Mapped[str | None] = mapped_column(String(500), nullable=True)
protocolo: Mapped[str | None] = mapped_column(String(20), nullable=True) protocolo: Mapped[str | None] = mapped_column(String(20), nullable=True)
autorizada_em: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), 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)
View File
+128
View File
@@ -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]
+177
View File
@@ -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
+528
View File
@@ -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())
+7
View File
@@ -1,9 +1,16 @@
from fastapi import FastAPI from fastapi import FastAPI
from fiscal_svc.certificates.router import router as certificates_router
from fiscal_svc.core.config import settings 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 = FastAPI(title=settings.app_name)
app.include_router(certificates_router)
app.include_router(series_router)
app.include_router(emission_router)
@app.get("/v1/health") @app.get("/v1/health")
async def health_check() -> dict[str, str]: async def health_check() -> dict[str, str]:
View File
+80
View File
@@ -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)
+55
View File
@@ -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
+215
View File
@@ -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
View File
+436
View File
@@ -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"
)
View File
+644
View File
@@ -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"
BIN
View File
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 @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, migration_database,
): ):
"""Same fix/reasoning as the auto's `a1b2c3d4e5f6` migration: a PARTIAL """Same fix/reasoning as the auto's `a1b2c3d4e5f6` migration: a PARTIAL
UNIQUE index (here on `(product_id, branch_ref) WHERE deleted_at IS UNIQUE index (here on `(product_id, tenant_ref, branch_ref) WHERE
NULL`) makes two concurrently-uploaded LIVE certificates for the same deleted_at IS NULL` -- FIX 2/F2 review, migration `8f1a2c9d4b6e`) makes
slot structurally impossible, not just avoided by the service layer.""" 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") result = run_alembic(_MIGRATION_DB_URL, "upgrade", "head")
assert result.returncode == 0, result.stderr 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() 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 @pytest.mark.asyncio
async def test_duplicate_fiscal_series_tuple_violates_unique_constraint_on_real_migration( async def test_duplicate_fiscal_series_tuple_violates_unique_constraint_on_real_migration(
migration_database, migration_database,
View File
+263
View File
@@ -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
+400
View File
@@ -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
+31
View File
@@ -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"
)
+175
View File
@@ -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'"
)
Generated
+11
View File
@@ -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" }, { 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]] [[package]]
name = "pytz" name = "pytz"
version = "2026.2" version = "2026.2"
@@ -1064,6 +1073,7 @@ dependencies = [
{ name = "passlib", extra = ["bcrypt"] }, { name = "passlib", extra = ["bcrypt"] },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "python-multipart" },
{ name = "sowai-fiscal" }, { name = "sowai-fiscal" },
{ name = "sqlalchemy", extra = ["asyncio"] }, { name = "sqlalchemy", extra = ["asyncio"] },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
@@ -1087,6 +1097,7 @@ requires-dist = [
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
{ name = "pydantic", specifier = ">=2.13.4" }, { name = "pydantic", specifier = ">=2.13.4" },
{ name = "pydantic-settings", specifier = ">=2.0" }, { 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 = "sowai-fiscal", git = "https://git.sowai.com.br/jonatan/sowai-fiscal.git?rev=v0.1.0" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.49.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.49.0" },