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
@@ -0,0 +1,274 @@
"""Task 3: `documents.service.allocate_fiscal_number` -- ported from the
auto's `tenants.service.allocate_fiscal_number`
(`tests/modules/tenants/test_fiscal_series.py`), same mechanism/contract,
tenancy adapted to `(product_id, tenant_ref, branch_ref)`. Covers: sequential
allocation, the no-commit contract (Fix 1's two directions), the genuine
N=10 concurrency guarantee, the identity-map staleness repro
`populate_existing` exists to fix, and not-found (missing/soft-deleted/
wrong product/wrong tenant/wrong branch -- every dimension of the new
tenancy, not just product)."""
import asyncio
import uuid
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker
from fiscal_svc.documents.models import FiscalDocumentModel, FiscalSeries
from fiscal_svc.documents.service import FiscalSeriesNotFoundError, allocate_fiscal_number
from fiscal_svc.tenancy.service import create_product
async def _product(db_session, name="auto"):
return await create_product(db_session, name=name, api_key=f"k-{uuid.uuid4().hex}")
async def _series(db_session, product, *, tenant_ref="tenant-1", branch_ref="branch-1", next_number=1014):
series = FiscalSeries(
product_id=product.id,
tenant_ref=tenant_ref,
branch_ref=branch_ref,
document_model=FiscalDocumentModel.NFE_55.value,
serie=1,
next_number=next_number,
)
db_session.add(series)
await db_session.commit()
await db_session.refresh(series)
return series
@pytest.mark.asyncio
async def test_allocate_fiscal_number_sequential_calls_increment(db_session):
product = await _product(db_session)
series = await _series(db_session, product)
first = await allocate_fiscal_number(
db_session, product.id, series.tenant_ref, series.branch_ref, FiscalDocumentModel.NFE_55, 1
)
await db_session.commit()
second = await allocate_fiscal_number(
db_session, product.id, series.tenant_ref, series.branch_ref, FiscalDocumentModel.NFE_55, 1
)
await db_session.commit()
assert first == 1014
assert second == 1015
await db_session.refresh(series)
assert series.next_number == 1016
@pytest.mark.asyncio
async def test_allocate_fiscal_number_accepts_the_raw_string_document_model(db_session):
"""`document_model` is a plain `String(2)` column (zero enum PG) -- the
function must accept either the `FiscalDocumentModel` enum member or
its raw `.value` string, since callers (Task 4/5) may hold either."""
product = await _product(db_session)
series = await _series(db_session, product)
allocated = await allocate_fiscal_number(
db_session, product.id, series.tenant_ref, series.branch_ref, "55", 1
)
await db_session.commit()
assert allocated == 1014
@pytest.mark.asyncio
async def test_allocate_fiscal_number_without_commit_does_not_persist(test_engine, db_session):
"""Fix 1's contract, safe direction: a caller that allocates and then
rolls back (or never commits) leaves NO durable trace -- the next
allocation must hand out the SAME number again."""
product = await _product(db_session)
series = await _series(db_session, product, next_number=100)
session_maker = async_sessionmaker(test_engine, expire_on_commit=False)
rollback_session = session_maker()
try:
allocated = await allocate_fiscal_number(
rollback_session, product.id, series.tenant_ref, series.branch_ref,
FiscalDocumentModel.NFE_55, 1,
)
assert allocated == 100
await rollback_session.rollback()
finally:
await rollback_session.close()
await db_session.refresh(series)
assert series.next_number == 100, (
f"expected next_number to revert to 100 after rollback, got "
f"{series.next_number} -- the un-committed allocation leaked a "
"durable side effect"
)
reallocated = await allocate_fiscal_number(
db_session, product.id, series.tenant_ref, series.branch_ref, FiscalDocumentModel.NFE_55, 1
)
await db_session.commit()
assert reallocated == 100
@pytest.mark.asyncio
async def test_allocate_fiscal_number_missing_series_raises(db_session):
product = await _product(db_session)
with pytest.raises(FiscalSeriesNotFoundError):
await allocate_fiscal_number(
db_session, product.id, "tenant-1", "branch-1", FiscalDocumentModel.NFE_55, 1
)
@pytest.mark.asyncio
async def test_allocate_fiscal_number_soft_deleted_series_raises(db_session):
from datetime import datetime, timezone
product = await _product(db_session)
series = await _series(db_session, product)
series.deleted_at = datetime.now(timezone.utc)
await db_session.commit()
with pytest.raises(FiscalSeriesNotFoundError):
await allocate_fiscal_number(
db_session, product.id, series.tenant_ref, series.branch_ref, FiscalDocumentModel.NFE_55, 1
)
@pytest.mark.asyncio
async def test_allocate_fiscal_number_other_product_raises(db_session):
"""Tenancy-scoping guard (porte adaptation): the SAME
tenant_ref/branch_ref/document_model/serie under a DIFFERENT product_id
must never allocate from a series that belongs to a different product --
the anti-oracle boundary is (product_id, tenant_ref), so this exercises
the product_id half of it."""
product_a = await _product(db_session, name="auto")
series = await _series(db_session, product_a)
product_b = await _product(db_session, name="crm")
with pytest.raises(FiscalSeriesNotFoundError):
await allocate_fiscal_number(
db_session, product_b.id, series.tenant_ref, series.branch_ref,
FiscalDocumentModel.NFE_55, 1,
)
@pytest.mark.asyncio
async def test_allocate_fiscal_number_other_tenant_ref_raises(db_session):
"""Same tenancy boundary, tenant_ref half: two tenants of the SAME
product must not share a series just because branch_ref/model/serie
coincide."""
product = await _product(db_session)
series = await _series(db_session, product, tenant_ref="tenant-a")
with pytest.raises(FiscalSeriesNotFoundError):
await allocate_fiscal_number(
db_session, product.id, "tenant-b", series.branch_ref, FiscalDocumentModel.NFE_55, 1
)
@pytest.mark.asyncio
async def test_allocate_fiscal_number_other_branch_ref_raises(db_session):
"""Same tenancy boundary, branch_ref half -- the auto's own guard test
(`test_allocate_fiscal_number_other_org_raises`) had no branch-level
equivalent since `branch_id` there is a real FK scoped by
`organization_id`; here `branch_ref` is its own opaque dimension."""
product = await _product(db_session)
series = await _series(db_session, product, branch_ref="branch-a")
with pytest.raises(FiscalSeriesNotFoundError):
await allocate_fiscal_number(
db_session, product.id, series.tenant_ref, "branch-b", FiscalDocumentModel.NFE_55, 1
)
@pytest.mark.asyncio
async def test_allocate_fiscal_number_concurrent_calls_produce_distinct_sequential_numbers(
test_engine, db_session
):
"""The regression guard this whole feature exists for, ported verbatim
from the auto's own concurrency test: N=10 GENUINELY concurrent
`allocate_fiscal_number` calls against the SAME series must produce 10
DISTINCT, SEQUENTIAL numbers. Each call uses its OWN
`AsyncSession(test_engine)` (a real, separate DB connection) so the
event loop can genuinely interleave their network round-trips."""
product = await _product(db_session)
series = await _series(db_session, product, next_number=100)
async def _allocate_and_commit(session):
allocated = await allocate_fiscal_number(
session, product.id, series.tenant_ref, series.branch_ref,
FiscalDocumentModel.NFE_55, 1,
)
await session.commit()
return allocated
session_maker = async_sessionmaker(test_engine, expire_on_commit=False)
sessions = [session_maker() for _ in range(10)]
try:
results = await asyncio.gather(
*(_allocate_and_commit(session) for session in sessions),
return_exceptions=True,
)
finally:
for session in sessions:
await session.close()
errors = [r for r in results if isinstance(r, BaseException)]
assert not errors, f"expected all 10 concurrent allocations to succeed, got errors={errors!r}"
allocated = sorted(results)
assert allocated == list(range(100, 110)), (
f"expected 10 distinct sequential numbers 100..109, got {allocated!r} "
"(a repeat means two concurrent callers received the same NF-e "
"number; a gap means one was skipped)"
)
await db_session.refresh(series)
assert series.next_number == 110
@pytest.mark.asyncio
async def test_allocate_fiscal_number_repopulates_identity_mapped_object(test_engine, db_session):
"""`SELECT ... FOR UPDATE` locks the ROW in Postgres, but if the calling
session already has that row's Python object in its IDENTITY MAP, plain
SQLAlchemy does NOT repopulate that object's attributes from the newly
fetched row by default. Reproduced with ordinary sequential awaits
across two sessions (no `asyncio.gather` needed) -- ported from the
auto's own reproduction."""
from fiscal_svc.documents.models import FiscalSeries as _FiscalSeries
from sqlalchemy import select
product = await _product(db_session)
series = await _series(db_session, product, next_number=100)
# Step 1: put the series into S1 (db_session)'s identity map with
# next_number=100.
loaded = (
await db_session.execute(select(_FiscalSeries).where(_FiscalSeries.id == series.id))
).scalar_one()
assert loaded.next_number == 100
# Step 2: a genuinely separate session allocates 100 and commits -- the
# DB row now holds 101.
session_maker = async_sessionmaker(test_engine, expire_on_commit=False)
other_session = session_maker()
try:
other_allocated = await allocate_fiscal_number(
other_session, product.id, series.tenant_ref, series.branch_ref,
FiscalDocumentModel.NFE_55, 1,
)
await other_session.commit()
finally:
await other_session.close()
assert other_allocated == 100
# Step 3: S1 allocates next -- MUST be 101 (the real, post-S2 value),
# never 100 again (that would be a duplicate NF-e number).
allocated = await allocate_fiscal_number(
db_session, product.id, series.tenant_ref, series.branch_ref, FiscalDocumentModel.NFE_55, 1
)
await db_session.commit()
assert allocated == 101, (
f"expected 101 (the value S2 already committed), got {allocated} -- "
"S1's allocate_fiscal_number returned a STALE identity-map-cached "
"next_number instead of repopulating from the FOR UPDATE-locked row"
)