feat: fiscal_series, fiscal_certificates, fiscal_documents models + migrations

Ports the three tables that change owner per the design spec (decision #2):
FiscalSeries (was tenants.FiscalDocumentSeries), FiscalCertificate,
FiscalDocument (both from fiscal.models) — organization_id/branch_id
replaced by product_id (FK products) + tenant_ref/branch_ref (opaque
strings) per the porte table. sale_id/service_order_id dropped (no Sale
concept here). document_model is now a plain String(2) + Python enum,
never a Postgres enum (Global Constraints: zero enum PG — the auto's own
version of this column was a real PG enum, a documented debt not repeated
here).

Constraints preserved: UNIQUE chave_acesso, partial-unique
cert-vivo-per-(product_id, branch_ref) (product-scoped in addition to the
auto's branch_id, since branch_ref is an opaque string two different
products could coincidentally share), UNIQUE (product_id, tenant_ref,
branch_ref, document_model, serie) on the series.

documents.service.allocate_fiscal_number ported verbatim (mechanism +
contract): SELECT ... FOR UPDATE + populate_existing=True, no-commit
contract (caller commits together with the FiscalDocument insert, Task 5).
The next_number regression guard is deliberately deferred to Task 4's
PATCH /v1/series endpoint (needs FiscalDocument, which now exists).

AST guard (tests/shared/test_for_update_populate_existing.py) ported and
adapted to scan src/fiscal_svc/, plus two new self-tests proving the
detection logic itself in both directions (flags a missing fix, does not
false-positive on a correctly fixed multi-line chain) — the ported guard
alone only proves "currently green", not "actually detects".

33 tests green via `make k8s-test` (real-migration round trips + unique
constraint violations, N=10 concurrency, identity-map staleness repro,
tenancy-scoping not-found across product/tenant_ref/branch_ref).
This commit is contained in:
jonatanritter
2026-07-22 16:25:41 -03:00
parent 1791435a8d
commit 836c267e09
10 changed files with 1237 additions and 1 deletions
+217
View File
@@ -0,0 +1,217 @@
"""Task 3: the three fiscal tables that "change owner" per the design spec
(decision #2) -- they used to live in the auto (`app/modules/tenants/
models.py`'s `FiscalDocumentSeries`, `app/modules/fiscal/models.py`'s
`FiscalCertificate`/`FiscalDocument`) and now live HERE, as the source of
truth this service was built to be. Ported with the porte table
(2026-07-17-fiscal-svc-f2-servico.md) applied throughout:
| No auto | No serviço |
|-----------------------------------|-----------------------------------|
| organization_id (FK organizations)| product_id (FK products) + |
| | tenant_ref: String(64) |
| branch_id (FK branches) | branch_ref: String(64) + cnpj |
| | (the strong link) |
| Sale/rotas por venda | não existem (sale_id/ |
| | service_order_id DROPPED -- |
| | emissão recebe DadosEmissao |
| | completo, sem correlação a uma |
| | linha de venda deste lado) |
Global Constraints (zero enum PG): unlike the auto's `FiscalDocumentSeries.
document_model` (a REAL Postgres `Enum` -- a documented, deliberately
un-repeated debt there), `document_model` here is plain `String(2)` +
Python enum at the Pydantic border ONLY, same convention the auto's own
`FiscalDocument.document_model`/`.status` already used (see their
docstrings) -- this port does not re-introduce the PG enum anywhere."""
import enum
import uuid
from datetime import datetime
from sqlalchemy import (
DateTime,
ForeignKey,
Index,
Integer,
LargeBinary,
String,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
from fiscal_svc.core.db import Base
from fiscal_svc.shared.base_model import SoftDeleteMixin, TimestampMixin, UUIDPKMixin
class FiscalDocumentModel(str, enum.Enum):
"""Ported from `auto/backend/app/modules/tenants/models.py`. Python
enum only -- see this module's docstring for why the auto's Postgres
`Enum` column for this same enum is NOT repeated here."""
NFE_55 = "55"
NFCE_65 = "65"
class FiscalDocumentStatus(str, enum.Enum):
"""Ported verbatim from `auto/backend/app/modules/fiscal/models.py` --
already `String` + Python enum there (ZERO Postgres enum), so no
adaptation needed. State machine: `ASSINADO -> TRANSMITINDO ->
AUTORIZADA | REJEITADA | PENDENTE_CONSULTA` (+ `CANCELADA`/`DENEGADA`).
Task 3 (this port) only ever WRITES `ASSINADO` -- transmission (F4)
writes the rest; the full vocabulary exists now so a later phase never
needs a column-shape migration for a new status."""
ASSINADO = "ASSINADO"
TRANSMITINDO = "TRANSMITINDO"
AUTORIZADA = "AUTORIZADA"
REJEITADA = "REJEITADA"
PENDENTE_CONSULTA = "PENDENTE_CONSULTA"
CANCELADA = "CANCELADA"
DENEGADA = "DENEGADA"
class FiscalSeries(Base, UUIDPKMixin, TimestampMixin, SoftDeleteMixin):
"""Ported from the auto's `FiscalDocumentSeries` (`tenants/models.py`) --
per the design spec this table is the NEW owner of fiscal numbering
(decision #2: "mudam de dono"). `product_id`/`tenant_ref`/`branch_ref`
replace `organization_id`/`branch_id` per the porte table (this
module's docstring).
Atomic allocation (`SELECT ... FOR UPDATE` + `populate_existing`) is
`documents.service.allocate_fiscal_number` (Task 3, ported with its
exact no-commit contract -- see that function's docstring). The
"guard retroativo" (`next_number` can never regress below the highest
`numero` this series has already emitted, per `FiscalDocument.numero`)
is deliberately NOT implemented in this task -- it belongs to the
`PATCH /v1/series` endpoint (Task 4), which needs this exact model
plus `FiscalDocument` (both now exist, Task 3) to check against. Same
"documented now, wired later" shape as the auto's own
`FiscalDocumentSeries` docstring ("a emissão em si nasce no módulo
fiscal da 1b")."""
__tablename__ = "fiscal_series"
__table_args__ = (
UniqueConstraint(
"product_id", "tenant_ref", "branch_ref", "document_model", "serie",
name="uq_fiscal_series_product_tenant_branch_model_serie",
),
)
product_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("products.id"), nullable=False)
tenant_ref: Mapped[str] = mapped_column(String(64), nullable=False)
branch_ref: Mapped[str] = mapped_column(String(64), nullable=False)
document_model: Mapped[str] = mapped_column(String(2), nullable=False)
serie: Mapped[int] = mapped_column(Integer, nullable=False)
next_number: Mapped[int] = mapped_column(Integer, nullable=False)
class FiscalCertificate(Base, UUIDPKMixin, TimestampMixin, SoftDeleteMixin):
"""Ported from the auto's `FiscalCertificate` (`app/modules/fiscal/
models.py`) -- A1 certificate (.pfx) for a `branch_ref`. Per the porte
table, the emitter is `(product_id, branch_ref)`, with `cnpj` carried
on the row itself (the "vínculo forte", design spec decision #4: the
CNPJ is what's validated against the certificate at upload and against
the emitente at emission -- `branch_ref` alone is an opaque string this
service never interprets). No fallback to a tenant-level or
product-level certificate -- fail-closed by construction, same as the
auto: a branch_ref without its own live certificate cannot emit.
One VIVO (`deleted_at IS NULL`) row per `(product_id, branch_ref)`: a
second upload soft-deletes the previous live row (Task 4's
`certificates.service`). `ix_fiscal_certificates_product_branch_live`
is a PARTIAL UNIQUE index enforcing that at the database level (same
"two concurrent uploads must not both land a live row" reasoning as the
auto's own migration `a1b2c3d4e5f6`'s fix) -- scoped by `product_id` in
ADDITION to `branch_ref` (the auto's version only needed `branch_id`,
already product-scoped by being a real FK; here `branch_ref` is an
OPAQUE string owned by the calling product, so two DIFFERENT products
could coincidentally pick the identical string for two DIFFERENT real
branches -- scoping the uniqueness by `product_id` too is what keeps
that from cross-contaminating one product's certificate slot with
another's).
`pfx_encrypted`/`password_encrypted` are Fernet ciphertext (Task 4,
`FISCAL_CERT_ENCRYPTION_KEY` env var, own key -- never
`app.core.config.Settings`, same "secrets never live in Settings"
convention as the auto)."""
__tablename__ = "fiscal_certificates"
__table_args__ = (
Index(
"ix_fiscal_certificates_product_branch_live",
"product_id",
"branch_ref",
unique=True,
postgresql_where=text("deleted_at IS NULL"),
),
)
product_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("products.id"), nullable=False)
tenant_ref: Mapped[str] = mapped_column(String(64), nullable=False)
branch_ref: Mapped[str] = mapped_column(String(64), nullable=False)
cnpj: Mapped[str] = mapped_column(String(14), nullable=False)
pfx_encrypted: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
password_encrypted: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
subject_cn: Mapped[str] = mapped_column(String(255), nullable=False)
cnpj_certificado: Mapped[str] = mapped_column(String(14), nullable=False)
not_valid_before: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
not_valid_after: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
class FiscalDocument(Base, UUIDPKMixin, TimestampMixin, SoftDeleteMixin):
"""Ported from the auto's `FiscalDocument` (`app/modules/fiscal/
models.py`). Per the porte table, `sale_id`/`service_order_id` are
DROPPED -- this service has no notion of a Sale/ServiceOrder (decision
#3 of the design spec: it receives a complete `DadosEmissao`, never
resolves or references the caller's own domain objects). A product's
own correlation to ITS sale/OS is the product's problem, carried via
the `Idempotency-Key` (Task 5) it sends, not a column here.
Invariants preserved from the auto (contract, not mere convention):
(a) a row is only inserted in the SAME commit that allocates `numero`
via `documents.service.allocate_fiscal_number` (which does not
commit by itself -- see its docstring) -- outbox transactional,
exactly as ported;
(b) documento é IMUTÁVEL após `ASSINADO` -- correção é cancelar (evento
futuro) e emitir outro, nunca um UPDATE no XML/chave já assinados;
(c) `chave_acesso` is UNIQUE GLOBALLY (not just per product) -- it
already embeds the emitente's CNPJ in its own digits, so two
DIFFERENT products (or tenants) can never legitimately produce the
same one; a collision here can only be a generation bug, never a
false positive between tenants OR between products.
`ix_fiscal_documents_product_tenant_branch_status` replaces the auto's
`ix_fiscal_documents_org_branch_status` (porte table: anti-oracle 404
is now by `(product_id, tenant_ref)`, and `GET /v1/documentos` filters
by `tenant_ref`/`branch_ref`/`status` per the design spec's API
table) -- the index covers exactly that filter shape."""
__tablename__ = "fiscal_documents"
__table_args__ = (
Index(
"ix_fiscal_documents_product_tenant_branch_status",
"product_id",
"tenant_ref",
"branch_ref",
"status",
),
UniqueConstraint("chave_acesso", name="uq_fiscal_documents_chave_acesso"),
)
product_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("products.id"), nullable=False)
tenant_ref: Mapped[str] = mapped_column(String(64), nullable=False)
branch_ref: Mapped[str] = mapped_column(String(64), nullable=False)
series_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("fiscal_series.id"), nullable=False)
document_model: Mapped[str] = mapped_column(String(2), nullable=False)
serie: Mapped[int] = mapped_column(Integer, nullable=False)
numero: Mapped[int] = mapped_column(Integer, nullable=False)
chave_acesso: Mapped[str] = mapped_column(String(44), nullable=False)
codigo_numerico: Mapped[str] = mapped_column(String(8), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False)
ambiente: Mapped[str] = mapped_column(String(12), nullable=False)
xml_assinado: Mapped[str] = mapped_column(Text, nullable=False)
rejeicao_codigo: Mapped[str | None] = mapped_column(String(10), nullable=True)
rejeicao_motivo: Mapped[str | None] = mapped_column(String(500), nullable=True)
protocolo: Mapped[str | None] = mapped_column(String(20), nullable=True)
autorizada_em: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)