feat: extract pure fiscal core from sowai-auto (parity-preserving copy)

Copies the 6 pure fiscal modules (domains, chave_acesso, tpag, presets,
resolver, xml_builder) from sowai-auto's backend/app/modules/fiscal/ into
this standalone lib, with only the two mechanical changes required to drop
the app.* dependency: import prefix rename, and TaxRule (ORM) -> TaxRuleLike
(structural Protocol) in the resolver. No other line changes -- byte-level
parity with the auto is the point.

Ports the pure test suites (domains, chave_acesso, resolver with a local
FakeTaxRule satisfying TaxRuleLike, xml_builder) plus a new test_presets_data
covering the PRESETS dict directly (the auto's equivalent test exercises the
apply-preset HTTP/DB flow, which isn't part of the extracted pure core).

55 tests pass locally via `uv run pytest`, no Postgres/k8s required.
This commit is contained in:
jonatanritter
2026-07-22 15:05:10 -03:00
commit 032151af34
17 changed files with 2413 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
"""Chave de acesso NF-e: cUF(2)+AAMM(4)+CNPJ(14)+mod(2)+série(3)+nNF(9)+
tpEmis(1)+cNF(8)+DV(1) = 44 dígitos. DV = módulo 11 (pesos 2..9, da direita
para a esquerda; resto 0 ou 1 -> DV 0).
`dv_modulo11` é validado contra chaves REAIS (não inventadas) -- mas NÃO as
de `tests/modules/estoque/test_nfe_import.py` como o plano original
cogitava: aquele fixture é hand-written (docstring do próprio arquivo o diz)
e tem só UMA chave de 44 dígitos, cujo último dígito NÃO bate com o DV
módulo 11 (conferido por script antes de escrever este teste -- não é uma
chave real, é um ID de exemplo forjado só para exercitar o parser). Fonte
usada em vez disso: os XMLs de amostra que o próprio pacote `nfelib`
(dependência já pinada em pyproject.toml, versão instalada no pod
confirmada em 2.5.2 na Task 1 Step 0) empacota em
`nfelib/nfe/samples/v4_0/leiauteNFe/*.xml` -- os NOMES desses arquivos SÃO
as chaves de acesso de NF-e reais/realistas de exemplo, com DV
correto -- confirmado por decomposição manual antes de escrever este teste:
`35180834128745000152550010000476711079516696` (mod=55, DV correto=6) e
`35200159594315000157550010000000012062777161` (mod=55, DV correto=1,
também o `Id` do `infNFe` dentro de
`NFe35200159594315000157550010000000012062777161.xml`)."""
import pytest
from sowai_fiscal.chave_acesso import dv_modulo11, gerar_cnf, montar_chave_acesso
def test_dv_modulo11_contra_chave_real_1_amostra_nfelib():
chave44 = "35180834128745000152550010000476711079516696"
assert dv_modulo11(chave44[:43]) == chave44[43]
def test_dv_modulo11_contra_chave_real_2_amostra_nfelib():
chave44 = "35200159594315000157550010000000012062777161"
assert dv_modulo11(chave44[:43]) == chave44[43]
def test_dv_modulo11_rejeita_chave_com_tamanho_errado():
with pytest.raises(ValueError):
dv_modulo11("123")
def test_montar_chave_tem_44_digitos_e_dv_valido():
chave = montar_chave_acesso(
uf_ibge="41", aamm="2607", cnpj="12345678000190",
modelo="55", serie=1, numero=1014, tp_emis="1", cnf="87654321",
)
assert len(chave) == 44 and chave.isdigit()
assert chave[-1] == dv_modulo11(chave[:43])
assert chave[22:25] == "001" and chave[25:34] == "000001014"
def test_gerar_cnf_8_digitos_e_diferente_do_nnf():
for _ in range(50):
cnf = gerar_cnf(numero_nnf=1014)
assert len(cnf) == 8 and cnf.isdigit()
assert int(cnf) != 1014 # regra NT2019.001: cNF != nNF
def test_gerar_cnf_nunca_all_zeros():
# F3a (review 2026-07-16): all-zeros é 8 dígitos válidos no FORMATO mas
# é o chute óbvio de um gerador fraco -- nunca deve sair do sorteio,
# mesmo por acaso estatístico (1 em 10^8 por chamada; 200 chamadas aqui
# deixa a chance de falso-negativo desprezível).
for _ in range(200):
assert gerar_cnf(numero_nnf=1014) != "00000000"
def test_montar_chave_acesso_rejeita_componentes_que_se_compensam_no_total():
# F1 (review 2026-07-16, Important): o bug real -- checar SÓ o tamanho
# agregado (43 dígitos) deixava passar um CNPJ de 13 dígitos (errado,
# deveria ter 14) somado a um cNF de 9 dígitos (errado, deveria ter 8):
# 13 + 9 = 22, o mesmo total de dígitos que o par correto 14 + 8 = 22 --
# a chave "compensada" saía com 43 dígitos e um DV módulo-11 sintaticamente
# válido, mas apontando para o CNPJ ERRADO (um dígito do cNF vazando para
# dentro do que deveria ser o CNPJ). A validação por COMPONENTE (não só o
# total) tem que capturar isto.
with pytest.raises(ValueError, match="cnpj"):
montar_chave_acesso(
uf_ibge="41", aamm="2607", cnpj="1234567800019", # 13 dígitos, não 14
modelo="55", serie=1, numero=1014, tp_emis="1",
cnf="876543210", # 9 dígitos, não 8 -- compensa o total em 43
)
@pytest.mark.parametrize(
"campo,valor",
[
("uf_ibge", "4"),
("uf_ibge", "411"),
("aamm", "260"),
("cnpj", "1234567800019"),
("cnpj", "123456780001900"),
("modelo", "5"),
("tp_emis", "12"),
("cnf", "8765432"),
("cnf", "876543210"),
],
)
def test_montar_chave_acesso_rejeita_cada_componente_fora_do_tamanho(campo, valor):
kwargs = dict(
uf_ibge="41", aamm="2607", cnpj="12345678000190",
modelo="55", serie=1, numero=1014, tp_emis="1", cnf="87654321",
)
kwargs[campo] = valor
with pytest.raises(ValueError):
montar_chave_acesso(**kwargs)
def test_montar_chave_acesso_rejeita_componente_nao_numerico():
with pytest.raises(ValueError, match="cnpj"):
montar_chave_acesso(
uf_ibge="41", aamm="2607", cnpj="1234567800019A",
modelo="55", serie=1, numero=1014, tp_emis="1", cnf="87654321",
)
+73
View File
@@ -0,0 +1,73 @@
"""Bloco B (spec 2026-07-14): catálogo de domínios tributários.
`tax_domain` é String no banco de propósito (imposto-como-dado): um domínio
novo (IBS/CBS/futuro) é um membro novo NO ENUM PYTHON, sem ALTER TYPE, sem
migration, sem o gotcha enum-NAME-vs-value. Estes testes travam o contrato
que o frontend consome via GET /fiscal/tax-domains e a tabela de pesos/
transposição que o resolvedor usa.
"""
from sowai_fiscal.domains import (
DOMAIN_FIELDS,
MATCHER_WEIGHTS,
TaxDomain,
transpose_cfop,
)
def test_tax_domain_has_the_reform_domains_without_any_migration():
# A prova literal do "imposto-como-dado": IBS/CBS já são domínios aceitos
# hoje, e não existe NENHUM tipo enum Postgres para tax_domain.
assert TaxDomain.IBS.value == "ibs"
assert TaxDomain.CBS.value == "cbs"
assert {d.value for d in TaxDomain} >= {
"icms", "icmsst", "ipi", "pis", "cofins", "difal", "fcp", "ibs", "cbs", "iss",
}
def test_domain_fields_catalog_covers_every_domain():
# O frontend monta o form por domínio a partir deste catálogo — cada
# domínio precisa declarar campos aplicáveis e obrigatórios.
for domain in TaxDomain:
spec = DOMAIN_FIELDS[domain.value]
assert spec.label
assert set(spec.campos_obrigatorios) <= set(spec.campos_aplicaveis)
for grupo in spec.campos_um_de:
assert set(grupo) <= set(spec.campos_aplicaveis)
assert not set(grupo) & set(spec.campos_obrigatorios) # um-de nunca também obrigatório
def test_icms_declares_the_exclusive_situacao_group():
# A UI exige "situação tributária" (cst OU csosn) a partir DAQUI — sem
# este grupo no catálogo, o form dinâmico deixaria passar regra de ICMS
# sem situação e o usuário comeria um 422 imprevisto (review frontend).
assert ("cst", "csosn") in DOMAIN_FIELDS["icms"].campos_um_de
assert ("cst", "csosn") in DOMAIN_FIELDS["icmsst"].campos_um_de
def test_matcher_weights_are_strict_powers_of_two():
# Cada peso supera a SOMA de todos os mais fracos (64 > 63): nenhuma
# combinação de matchers genéricos vence um mais seletivo. É a correção
# do review do frontend sobre "contagem de matchers".
ordered = sorted(MATCHER_WEIGHTS.values())
for i, w in enumerate(ordered):
assert w > sum(ordered[:i])
assert MATCHER_WEIGHTS["ncm_prefix"] == 64
assert MATCHER_WEIGHTS["cest"] == 32
assert MATCHER_WEIGHTS["consumidor_final"] == 16
assert MATCHER_WEIGHTS["indicador_ie"] == 8
assert MATCHER_WEIGHTS["uf_destino_tipo"] == 4
assert MATCHER_WEIGHTS["tipo_operacao"] == 2
assert MATCHER_WEIGHTS["crt"] == 1
def test_transpose_cfop_uses_the_explicit_table_for_st():
# 5405 → 6403, NÃO 6405: é por isso que a tabela explícita existe.
assert transpose_cfop("5405") == "6403"
assert transpose_cfop("5102") == "6102"
assert transpose_cfop("1202") == "2202"
def test_transpose_cfop_generic_fallback_swaps_the_first_digit():
# CFOP fora da tabela: regra geral 5xxx→6xxx / 1xxx→2xxx.
assert transpose_cfop("5949") == "6949"
assert transpose_cfop("1949") == "2949"
+69
View File
@@ -0,0 +1,69 @@
"""Presets fiscais seed-editáveis: teste PURO do dict `PRESETS` (dados,
não a API `/tax-profiles/apply-preset/*` -- aquela é do produto, DB/HTTP,
não faz parte do núcleo puro extraído). Os valores REAIS vieram do iCode
da Thiago (perfis "Padrão" cód 110 e "Óleos/ST" cód 108) -- estes testes
travam o contrato de dados que o consumidor (auto) usa para semear
`TaxRule`/`TaxProfile`."""
from decimal import Decimal
from sowai_fiscal.presets import PRESETS, FiscalPreset, PresetRule
def test_presets_catalog_has_the_two_icode_profiles():
assert {"autopecas_simples_padrao", "autopecas_simples_st"} <= PRESETS.keys()
def test_padrao_preset_has_venda_and_devolucao_icms_rules():
preset = PRESETS["autopecas_simples_padrao"]
assert isinstance(preset, FiscalPreset)
assert preset.regime_alvo == "1"
icms_rules = [r for r in preset.rules if r.tax_domain == "icms"]
assert len(icms_rules) == 2
venda = next(r for r in icms_rules if r.tipo_operacao == "venda")
assert venda.csosn == "102"
assert venda.cfop == "5102"
assert venda.aliquota == Decimal("0")
devolucao = next(r for r in icms_rules if r.tipo_operacao == "devolucao")
assert devolucao.csosn == "102"
assert devolucao.cfop == "1202"
def test_padrao_preset_pis_cofins_cst_08():
preset = PRESETS["autopecas_simples_padrao"]
pis = next(r for r in preset.rules if r.tax_domain == "pis")
cofins = next(r for r in preset.rules if r.tax_domain == "cofins")
assert pis.cst == "08"
assert cofins.cst == "08"
assert pis.aliquota == Decimal("0")
assert cofins.aliquota == Decimal("0")
def test_st_preset_has_venda_and_devolucao_icmsst_rules():
preset = PRESETS["autopecas_simples_st"]
icmsst_rules = [r for r in preset.rules if r.tax_domain == "icmsst"]
assert len(icmsst_rules) == 2
venda = next(r for r in icmsst_rules if r.tipo_operacao == "venda")
assert venda.csosn == "500"
assert venda.cfop == "5405"
devolucao = next(r for r in icmsst_rules if r.tipo_operacao == "devolucao")
assert devolucao.csosn == "500"
assert devolucao.cfop == "1202"
def test_st_preset_pis_cofins_cst_04_monofasico():
preset = PRESETS["autopecas_simples_st"]
pis = next(r for r in preset.rules if r.tax_domain == "pis")
cofins = next(r for r in preset.rules if r.tax_domain == "cofins")
assert pis.cst == "04"
assert cofins.cst == "04"
def test_no_preset_rule_carries_origem():
# NENHUM preset carrega `origem`: origem é sempre do Part (review do
# frontend, 2026-07-14) -- `PresetRule` não tem sequer o campo.
assert not hasattr(PresetRule(tax_domain="icms"), "origem")
+217
View File
@@ -0,0 +1,217 @@
"""Resolvedor puro do Bloco B. Sem banco, sem I/O — TaxRule montada em
memória. Os dois perfis REAIS da Thiago (extração do iCode,
thoughts/2026-07-13-icode-config-fiscal-extraida.md) são casos de teste
literais: CSOSN 102 → 5102/6102; CSOSN 500 → 5405/6403."""
import uuid
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
import pytest
from sowai_fiscal.resolver import (
AmbiguousRuleError,
FiscalConfigError,
FiscalItem,
FiscalOperation,
resolve_fiscal,
)
_PROFILE = uuid.uuid4()
_ORG = uuid.uuid4()
@dataclass
class FakeTaxRule:
"""Satisfaz `sowai_fiscal.types.TaxRuleLike` por estrutura — o
substituto, nos testes da lib, do ORM `TaxRule` que o auto usa."""
id: uuid.UUID
organization_id: uuid.UUID
tax_profile_id: uuid.UUID
tax_domain: str
crt: str | None = None
uf_destino_tipo: str | None = None
consumidor_final: bool | None = None
indicador_ie: str | None = None
tipo_operacao: str | None = None
ncm_prefix: str | None = None
cest: str | None = None
cst: str | None = None
csosn: str | None = None
cfop: str | None = None
base_calc_percent: Decimal | None = None
aliquota: Decimal | None = None
mva: Decimal | None = None
aliquota_st: Decimal | None = None
fcp_percent: Decimal | None = None
codigo_beneficio: str | None = None
deleted_at: datetime | None = None
def _rule(**kw) -> FakeTaxRule:
defaults = dict(
id=uuid.uuid4(), organization_id=_ORG, tax_profile_id=_PROFILE,
)
return FakeTaxRule(**{**defaults, **kw})
def _op(**kw) -> FiscalOperation:
defaults = dict(
crt="1", uf_origem="PR", uf_destino="PR",
indicador_ie="nao_contribuinte", consumidor_final=True,
tipo_operacao="venda",
item=FiscalItem(
tax_profile_id=_PROFILE, ncm="87089990", cest=None, origem="0",
quantity=Decimal("2"), unit_price=Decimal("75.00"),
),
)
return FiscalOperation(**{**defaults, **kw})
_PADRAO = [ # preset "Padrão" da Thiago (CSOSN 102)
_rule(tax_domain="icms", crt="1", csosn="102", cfop="5102", aliquota=Decimal("0")),
_rule(tax_domain="pis", cst="08", aliquota=Decimal("0")),
_rule(tax_domain="cofins", cst="08", aliquota=Decimal("0")),
]
def test_padrao_interna_resolves_5102_csosn_102():
result = resolve_fiscal(_PADRAO, _op())
assert result.cfop == "5102"
assert result.csosn == "102"
assert result.cst is None
assert result.origem == "0" # ecoado do ITEM (Part), nunca de regra
icms = next(t for t in result.tributos if t.tax_domain == "icms")
assert icms.base_calc == Decimal("150.00")
assert icms.valor == Decimal("0.00")
assert icms.rule_id == _PADRAO[0].id
def test_padrao_interestadual_transposes_to_6102():
result = resolve_fiscal(_PADRAO, _op(uf_destino="SP"))
assert result.cfop == "6102"
def test_st_profile_resolves_5405_and_6403():
st_rules = [_rule(tax_domain="icmsst", crt="1", csosn="500", cfop="5405")]
assert resolve_fiscal(st_rules, _op()).cfop == "5405"
assert resolve_fiscal(st_rules, _op(uf_destino="SP")).cfop == "6403" # NÃO 6405
def test_more_selective_ncm_rule_beats_generic_rule_with_more_matchers():
# A correção do review: NCM (peso 64) vence 3 matchers genéricos
# (crt=1 + uf=4 + indicador_ie=8 = 13).
generic = _rule(
tax_domain="icms", crt="1", uf_destino_tipo="interna",
indicador_ie="nao_contribuinte", csosn="102", cfop="5102",
)
by_ncm = _rule(tax_domain="icms", ncm_prefix="8708", csosn="500", cfop="5405")
result = resolve_fiscal([generic, by_ncm], _op())
assert result.csosn == "500"
def test_rule_with_non_matching_matcher_is_skipped():
rule_sp = _rule(tax_domain="icms", uf_destino_tipo="interestadual", csosn="102", cfop="5102")
with pytest.raises(FiscalConfigError):
resolve_fiscal([rule_sp], _op()) # operação interna; a regra não casa
def test_tie_same_weight_raises_ambiguous():
a = _rule(tax_domain="icms", crt="1", csosn="102", cfop="5102")
b = _rule(tax_domain="icms", crt="1", csosn="101", cfop="5102")
with pytest.raises(AmbiguousRuleError) as exc:
resolve_fiscal([a, b], _op())
assert {a.id, b.id} == set(exc.value.rule_ids)
def test_fail_closed_lists_the_missing_domain():
with pytest.raises(FiscalConfigError) as exc:
resolve_fiscal([], _op())
assert "icms" in " ".join(exc.value.missing)
def test_optional_domain_absent_does_not_block():
# Sem regra de FCP/IPI → simplesmente não entra no resultado.
result = resolve_fiscal(_PADRAO, _op())
assert {t.tax_domain for t in result.tributos} == {"icms", "pis", "cofins"}
def test_st_value_math_with_mva():
# base 100; MVA 40% → base_st 140; aliq_st 18% → 25.20 ICMS próprio 12.00 = 13.20
rules = [
_rule(tax_domain="icms", cst="00", cfop="5102", aliquota=Decimal("12")),
_rule(
tax_domain="icmsst", cst="10", cfop="5405",
mva=Decimal("40"), aliquota_st=Decimal("18"),
),
]
op = _op(crt="3", item=FiscalItem(
tax_profile_id=_PROFILE, ncm="27101259", cest="0600100", origem="0",
quantity=Decimal("1"), unit_price=Decimal("100.00"),
))
result = resolve_fiscal(rules, op)
st = next(t for t in result.tributos if t.tax_domain == "icmsst")
assert st.valor == Decimal("13.20")
def test_reducao_de_base():
# base 100 com redução p/ 60% e alíquota 18% → 10.80
rules = [_rule(
tax_domain="icms", cst="20", cfop="5102",
base_calc_percent=Decimal("60"), aliquota=Decimal("18"),
)]
result = resolve_fiscal(rules, _op(crt="3", item=FiscalItem(
tax_profile_id=_PROFILE, ncm="87089990", cest=None, origem="0",
quantity=Decimal("1"), unit_price=Decimal("100.00"),
)))
icms = next(t for t in result.tributos if t.tax_domain == "icms")
assert icms.valor == Decimal("10.80")
def test_anchor_matches_without_cfop_names_the_field():
"""M5 (review 2026-07-15): a âncora casa (tem csosn) mas não tem CFOP --
`FiscalConfigError.missing` deve nomear o campo faltante, não só o
domínio."""
rule = _rule(tax_domain="icms", csosn="102", cfop=None)
with pytest.raises(FiscalConfigError) as exc:
resolve_fiscal([rule], _op())
assert "cfop" in " ".join(exc.value.missing)
def test_anchor_matches_without_cst_or_csosn_names_the_field():
"""M5 (review 2026-07-15): a âncora casa (tem cfop) mas não tem cst NEM
csosn -- `FiscalConfigError.missing` deve nomear cst/csosn."""
rule = _rule(tax_domain="icms", cfop="5102", cst=None, csosn=None)
with pytest.raises(FiscalConfigError) as exc:
resolve_fiscal([rule], _op())
assert "cst/csosn" in " ".join(exc.value.missing)
def test_deleted_rule_is_ignored():
from datetime import datetime, timezone
dead = _rule(tax_domain="icms", csosn="102", cfop="5102",
deleted_at=datetime.now(timezone.utc))
with pytest.raises(FiscalConfigError):
resolve_fiscal([dead], _op())
def test_fcp_calculates_from_fcp_percent():
"""I1 (auditoria Fable Bloco B): o domínio `fcp` não tem `aliquota` no
catálogo (só `fcp_percent`, DOMAIN_FIELDS['fcp']) -- `_linha` genérico
só multiplica `aliquota`, então toda linha FCP saía com `valor=0.00`
(preview que mente). Prova de detecção: SEM o fix deste teste, `valor`
vem 0.00 em vez de 2.00 -- FCP 2% sobre base 100."""
rules = [
*_PADRAO,
_rule(tax_domain="fcp", fcp_percent=Decimal("2")),
]
op = _op(item=FiscalItem(
tax_profile_id=_PROFILE, ncm="87089990", cest=None, origem="0",
quantity=Decimal("1"), unit_price=Decimal("100.00"),
))
result = resolve_fiscal(rules, op)
fcp = next(t for t in result.tributos if t.tax_domain == "fcp")
assert fcp.valor == Decimal("2.00")
assert fcp.fcp_percent == Decimal("2")
+303
View File
@@ -0,0 +1,303 @@
"""1b.1 Task 5: `xml_builder.build_nfe` -- montagem PURA do XML da NF-e 55
(NT 2025.002 v1.40). Todos os testes montam `DadosEmissao` em memória
(nenhum banco, nenhum I/O) e inspecionam o objeto `Nfe` resultante
diretamente OU sua serialização via `xsdata.formats.dataclass.serializers.
XmlSerializer` (o mesmo serializador que `emissao.py`, Task 6, usará antes
de assinar)."""
import uuid
from decimal import Decimal
import pytest
from xsdata.formats.dataclass.serializers import XmlSerializer
from xsdata.formats.dataclass.serializers.config import SerializerConfig
from sowai_fiscal.resolver import FiscalResult, TributoLinha
from sowai_fiscal.xml_builder import (
TEXTO_HOMOLOGACAO,
DadosEmissao,
DestinatarioData,
EmitenteData,
ItemData,
PagamentoData,
build_nfe,
)
_NFE_NAMESPACE = "http://www.portalfiscal.inf.br/nfe"
def _emitente(**overrides) -> EmitenteData:
base = dict(
cnpj="12345678000190",
razao_social="EMPRESA TESTE LTDA",
nome_fantasia="Empresa Teste",
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",
)
base.update(overrides)
return EmitenteData(**base)
def _destinatario(**overrides) -> DestinatarioData:
base = dict(
nome="CLIENTE TESTE LTDA",
cnpj="99887766000155",
cpf=None,
indicador_ie="9",
address_street="Av Cliente",
address_number="50",
address_district="Bairro",
address_city="Curitiba",
address_state="PR",
address_zip="80000000",
address_city_ibge_code="4106902",
)
base.update(overrides)
return DestinatarioData(**base)
def _tributo(tax_domain: str, *, cst=None, csosn=None, aliquota=None, valor="0.00", base_calc="100.00") -> TributoLinha:
return TributoLinha(
tax_domain=tax_domain,
cst=cst,
csosn=csosn,
base_calc=Decimal(base_calc),
base_calc_percent=Decimal("100"),
aliquota=Decimal(aliquota) if aliquota is not None else None,
valor=Decimal(valor),
mva=None,
aliquota_st=None,
fcp_percent=None,
codigo_beneficio=None,
rule_id=uuid.uuid4(),
)
def _fiscal_result(cfop: str, *, csosn="102", cst=None, tributos=None, origem="0") -> FiscalResult:
return FiscalResult(
cfop=cfop,
cst=cst,
csosn=csosn,
origem=origem,
consumidor_final=True,
indicador_ie="9",
tributos=tributos if tributos is not None else [
_tributo("pis", cst="08"),
_tributo("cofins", cst="08"),
],
)
def _item(cfop: str, **overrides) -> ItemData:
base = dict(
codigo="PC001",
descricao="Filtro de oleo",
ncm="84212300",
cfop=cfop,
unidade_comercial="UN",
unidade_tributavel="UN",
quantidade=Decimal("2"),
valor_unitario=Decimal("50.00"),
fiscal_result=_fiscal_result(cfop),
)
base.update(overrides)
return ItemData(**base)
def _pagamento(**overrides) -> PagamentoData:
base = dict(tpag="01", valor=Decimal("100.00"))
base.update(overrides)
return PagamentoData(**base)
def _dados(**overrides) -> DadosEmissao:
base = dict(
emitente=_emitente(),
itens=[_item("5102")],
pagamento=_pagamento(),
ambiente="homologacao",
chave_acesso="4" * 44,
numero=1014,
serie=1,
cnf="87654321",
dh_emi="2026-07-16T10:00:00-03:00",
uf_destino_tipo="interna",
destinatario=_destinatario(),
)
base.update(overrides)
return DadosEmissao(**base)
def _render(nfe) -> str:
config = SerializerConfig(xml_declaration=False, indent=None)
return XmlSerializer(config=config).render(nfe, ns_map={None: _NFE_NAMESPACE})
# --- venda Padrão intra-PR ---------------------------------------------
def test_venda_padrao_intra_estado_cfop_5102_csosn_102_e_origem_do_item():
dados = _dados(uf_destino_tipo="interna", itens=[_item("5102", fiscal_result=_fiscal_result("5102", csosn="102", origem="1"))])
nfe = build_nfe(dados)
det = nfe.infNFe.det[0]
assert det.prod.CFOP == "5102"
assert det.imposto.ICMS.ICMSSN102.CSOSN == "102"
assert det.imposto.ICMS.ICMSSN102.orig == "1" # origem SEMPRE do item, nunca de regra
assert nfe.infNFe.ide.idDest == "1"
def test_totais_batem_com_a_soma_dos_itens_decimal_2_casas():
itens = [
_item("5102", quantidade=Decimal("2"), valor_unitario=Decimal("50.00")),
_item("5102", quantidade=Decimal("3"), valor_unitario=Decimal("10.005")),
]
dados = _dados(itens=itens)
nfe = build_nfe(dados)
# 2*50.00 = 100.00 ; 3*10.005 = 30.015 -> arredonda p/ 30.02 (ROUND_HALF_UP)
assert nfe.infNFe.total.ICMSTot.vProd == "130.02"
assert nfe.infNFe.total.ICMSTot.vNF == "130.02"
# Decimal com 2 casas sempre (nunca "100.0" ou "100")
assert nfe.infNFe.total.ICMSTot.vProd.count(".") == 1
assert len(nfe.infNFe.total.ICMSTot.vProd.split(".")[1]) == 2
# --- interestadual --------------------------------------------------------
def test_venda_interestadual_cfop_6102_iddest_2():
dados = _dados(
uf_destino_tipo="interestadual",
itens=[_item("6102", fiscal_result=_fiscal_result("6102", csosn="102"))],
ind_final="1",
destinatario=_destinatario(indicador_ie="1", address_state="SP"),
)
nfe = build_nfe(dados)
det = nfe.infNFe.det[0]
assert det.prod.CFOP == "6102"
assert nfe.infNFe.ide.idDest == "2"
assert nfe.infNFe.ide.indFinal == "1"
assert nfe.infNFe.dest.indIEDest == "1"
# --- pagamento -------------------------------------------------------------
def test_pag_detpag_tpag_do_pagamento():
dados = _dados(pagamento=_pagamento(tpag="03", valor=Decimal("55.50")))
nfe = build_nfe(dados)
det_pag = nfe.infNFe.pag.detPag[0]
assert det_pag.tPag == "03"
assert det_pag.vPag == "55.50"
def test_pag_tpag_17_para_pix():
dados = _dados(pagamento=_pagamento(tpag="17", valor=Decimal("100.00")))
nfe = build_nfe(dados)
assert nfe.infNFe.pag.detPag[0].tPag == "17"
# --- homologação -------------------------------------------------------------
def test_homologacao_forca_xnome_do_destinatario_para_texto_oficial():
dados = _dados(ambiente="homologacao", destinatario=_destinatario(nome="Cliente Real Ltda"))
nfe = build_nfe(dados)
assert nfe.infNFe.dest.xNome == TEXTO_HOMOLOGACAO
assert nfe.infNFe.ide.tpAmb == "2"
def test_producao_mantem_xnome_real_do_destinatario():
dados = _dados(ambiente="producao", destinatario=_destinatario(nome="Cliente Real Ltda"))
nfe = build_nfe(dados)
assert nfe.infNFe.dest.xNome == "Cliente Real Ltda"
assert nfe.infNFe.ide.tpAmb == "1"
# --- grupo UB (IBS/CBS) -----------------------------------------------------
def test_grupo_ibscbs_ausente_sem_linhas_ibs_cbs_crt1_2026():
dados = _dados(itens=[_item("5102", fiscal_result=_fiscal_result("5102"))])
nfe = build_nfe(dados)
assert nfe.infNFe.det[0].imposto.IBSCBS is None
def test_grupo_ibscbs_presente_com_linhas_ibs_cbs():
fr = _fiscal_result(
"5102",
tributos=[
_tributo("pis", cst="08"),
_tributo("cofins", cst="08"),
_tributo("ibs", aliquota="0.1000", valor="10.00", base_calc="100.00"),
_tributo("cbs", aliquota="0.9000", valor="90.00", base_calc="100.00"),
],
)
dados = _dados(itens=[_item("5102", fiscal_result=fr)])
nfe = build_nfe(dados)
ibscbs = nfe.infNFe.det[0].imposto.IBSCBS
assert ibscbs is not None
assert ibscbs.gIBSCBS.gIBSUF.vIBSUF == "10.00"
assert ibscbs.gIBSCBS.gCBS.vCBS == "90.00"
# --- serialização -----------------------------------------------------------
def test_serializacao_gera_xml_bem_formado_com_namespace_correto():
dados = _dados()
nfe = build_nfe(dados)
xml = _render(nfe)
assert xml.startswith(f'<NFe xmlns="{_NFE_NAMESPACE}">')
assert f'Id="NFe{dados.chave_acesso}"' in xml
assert "<infNFe" in xml and "</infNFe>" in xml
# bem-formado: um parser XML real não estoura
from lxml import etree
etree.fromstring(xml.encode("utf-8"))
def test_unsupported_icms_situation_raises_clear_error():
from sowai_fiscal.xml_builder import UnsupportedIcmsSituationError
dados = _dados(itens=[_item("5102", fiscal_result=_fiscal_result("5102", csosn="201"))])
with pytest.raises(UnsupportedIcmsSituationError):
build_nfe(dados)
def test_origem_none_levanta_valueerror_sem_fallback_silencioso():
"""I3 (review Opus, 2026-07-16): origem None chegando no builder é erro
de PROGRAMAÇÃO (a validação de completude da emissão barra peça sem
`icms_origem` com 409 antes) -- jamais um default '0' silencioso, que
emitiria origem nacional para peça importada (imposto errado)."""
dados = _dados(itens=[_item("5102", fiscal_result=_fiscal_result("5102", origem=None))])
with pytest.raises(ValueError, match="origem"):
build_nfe(dados)