From 9133d2481e3904f6c7138b5b1e8e8678ea1428ba Mon Sep 17 00:00:00 2001 From: Felipe Correa Date: Thu, 23 Apr 2026 13:54:15 -0300 Subject: [PATCH 01/10] feat(reforma): suporte a gIBSCBSMono para CST 620 monofasica [DEV-1929] (#2) * feat(reforma): suporte a gIBSCBSMono para CST 620 monofasica [DEV-1929] Adiciona suporte a tributacao monofasica IBS/CBS conforme NT 2025.002-RTC. Produtos com CST 620 (combustiveis e demais itens monofasicos) agora emitem com qBCMono, adRemIBS, vIBSMono, adRemCBS e vCBSMono no lugar do grupo padrao (pIBSUF/pIBSMun/pCBS). Contexto: SEFAZ rejeita (cStat 1026 - "Aliquota do IBS da UF invalida") qualquer NF-e com CST 620 emitida com gIBSCBS padrao, pois a spec exige o grupo gIBSCBSMono para regime monofasico. Mudancas: - NotaFiscalProduto ganha 5 novos atributos (ibscbs_q_bc_mono, ibscbs_ad_rem_ibs, ibscbs_v_ibs_mono, ibscbs_ad_rem_cbs, ibscbs_v_cbs_mono). - _serializar_ibscbs roteia para _serializar_gibscbs_mono quando CST in _IBSCBS_CST_MONOFASICO (por ora so "620"; 630/640 virao no futuro). - CST 620 removido de _IBSCBS_CST_TRIBUTADOS (agora pertence ao conjunto monofasico) para evitar emissao duplicada. - Docs atualizadas: gIBSCBSMono removido da lista "Nao inclui". - 3 novos testes (CST 620 com valores zero, CST 620 com ad rem, regressao CST 000). * style(serializacao): aplica ruff format em gIBSCBSMono [DEV-1929] --------- Co-authored-by: felps-dev --- docs/reforma_tributaria.md | 19 ++- pynfe/entidades/notafiscal.py | 11 ++ pynfe/processamento/serializacao.py | 103 ++++++++---- ...est_nfe_serializacao_reforma_tributaria.py | 156 ++++++++++++++++++ 4 files changed, 258 insertions(+), 31 deletions(-) diff --git a/docs/reforma_tributaria.md b/docs/reforma_tributaria.md index cae67eb2..cb11c879 100644 --- a/docs/reforma_tributaria.md +++ b/docs/reforma_tributaria.md @@ -37,8 +37,25 @@ A implementacao cobre: - `vNF` **NAO inclui** IBS/CBS (proibido em 2025-2026) - `finNFe=5` (Nota de Debito) e `finNFe=6` (Nota de Credito) - Campos de entidade para IS (Imposto Seletivo) — **armazenados mas nao serializados** ate o schema suportar (2027) +- Tributacao monofasica (`gIBSCBSMono`) para CST 620 — combustiveis e demais produtos sujeitos ao regime monofasico de IBS/CBS -**Nao inclui** (ainda): Split Payment, cashback, eventos de apuracao assistida, Grupo VB (total do item), Grupo VC (referenciamento de DF-e), Grupo BB (antecipacao de pagamento), tributacao monofasica (`gIBSCBSMono`), diferimento per-item (`gDif`), devolucao de tributos per-item (`gDevTrib`), reducao de aliquota per-item (`gRed`), estorno de credito (`gEstornoCred`), credito presumido per-item (`gCredPresOper`, `gCredPresIBSZFM`). +**Nao inclui** (ainda): Split Payment, cashback, eventos de apuracao assistida, Grupo VB (total do item), Grupo VC (referenciamento de DF-e), Grupo BB (antecipacao de pagamento), diferimento per-item (`gDif`), devolucao de tributos per-item (`gDevTrib`), reducao de aliquota per-item (`gRed`), estorno de credito (`gEstornoCred`), credito presumido per-item (`gCredPresOper`, `gCredPresIBSZFM`). + +### Tributacao monofasica — `gIBSCBSMono` + +Para produtos com CST 620 (combustiveis, etc.) o grupo emitido dentro de `` e `` ao inves de ``. Campos obrigatorios: + +| Campo | Tipo | Descricao | +|-------|------|-----------| +| `qBCMono` | TDec_1104v | Quantidade tributada na base monofasica | +| `adRemIBS` | TDec_0302a10 | Aliquota ad rem IBS (valor em BRL por unidade) | +| `vIBSMono` | TDec_1302 | Valor IBS monofasico | +| `adRemCBS` | TDec_0302a10 | Aliquota ad rem CBS (valor em BRL por unidade) | +| `vCBSMono` | TDec_1302 | Valor CBS monofasico | + +Atributos na entidade `NotaFiscalProduto`: `ibscbs_q_bc_mono`, `ibscbs_ad_rem_ibs`, `ibscbs_v_ibs_mono`, `ibscbs_ad_rem_cbs`, `ibscbs_v_cbs_mono`. + +Durante o Teste de Carga 2026 os ad rem ainda nao foram publicados pela SEFAZ, entao os valores podem ser zerados — o grupo `gIBSCBSMono` ainda sera emitido corretamente. ## CSTs disponiveis diff --git a/pynfe/entidades/notafiscal.py b/pynfe/entidades/notafiscal.py index 7047138e..f65114ef 100644 --- a/pynfe/entidades/notafiscal.py +++ b/pynfe/entidades/notafiscal.py @@ -1046,6 +1046,17 @@ class NotaFiscalProduto(Entidade): ibscbs_p_cbs = Decimal() # pCBS ibscbs_v_cbs = Decimal() # vCBS + # gIBSCBSMono - Tributacao monofasica (CST 620) + # Emitted as instead of for CST 620 items. + # qBCMono = quantity in monophasic base unit (TDec_1104v) + # adRemIBS / adRemCBS = ad rem rate in BRL per unit (TDec_0302a10) + # vIBSMono / vCBSMono = final value in BRL + ibscbs_q_bc_mono = Decimal() # qBCMono + ibscbs_ad_rem_ibs = Decimal() # adRemIBS + ibscbs_v_ibs_mono = Decimal() # vIBSMono + ibscbs_ad_rem_cbs = Decimal() # adRemCBS + ibscbs_v_cbs_mono = Decimal() # vCBSMono + # IS (Imposto Seletivo) - Group UB-IS is_cst_selec = str() # CSTSelec (2-digit) is_c_class_trib = str() # cClassTribIS 6-digit diff --git a/pynfe/processamento/serializacao.py b/pynfe/processamento/serializacao.py index cb463000..2ca28645 100644 --- a/pynfe/processamento/serializacao.py +++ b/pynfe/processamento/serializacao.py @@ -1316,8 +1316,13 @@ def _serializar_imposto_importacao( # Reforma Tributaria - IVA Dual (NT 2025.002-RTC) # ============================================= - # CSTs that have taxable values (vBC, rates, amounts) - _IBSCBS_CST_TRIBUTADOS = ("000", "010", "200", "400", "510", "600", "620", "800", "810", "900") + # CSTs that have taxable values (vBC, rates, amounts) and use + _IBSCBS_CST_TRIBUTADOS = ("000", "010", "200", "400", "510", "600", "800", "810", "900") + + # CSTs that use the monophasic tax regime and emit instead of + # (qBCMono, adRemIBS/adRemCBS, vIBSMono/vCBSMono). Start with 620 (combustiveis); + # 630/640 will be added when SEFAZ publishes the corresponding cClassTrib codes. + _IBSCBS_CST_MONOFASICO = ("620",) def _serializar_imposto_ibscbs( self, produto_servico, modelo, tag_raiz="imposto", retorna_string=True @@ -1340,45 +1345,83 @@ def _serializar_imposto_ibscbs( # self._serializar_is(produto_servico, tag_raiz) def _serializar_ibscbs(self, produto_servico, tag_raiz): - """Serializa com gIBSCBS contendo gIBSUF, gIBSMun e gCBS.""" + """Serializa . + + Para CSTs monofasicas (ex: 620) emite com qBCMono, adRemIBS, + vIBSMono, adRemCBS, vCBSMono. Para demais CSTs tributados emite + com vBC + gIBSUF + gIBSMun + gCBS. + """ ibscbs = etree.SubElement(tag_raiz, "IBSCBS") etree.SubElement(ibscbs, "CST").text = produto_servico.ibscbs_cst if produto_servico.ibscbs_c_class_trib: etree.SubElement(ibscbs, "cClassTrib").text = produto_servico.ibscbs_c_class_trib - if produto_servico.ibscbs_cst in self._IBSCBS_CST_TRIBUTADOS: - gibscbs = etree.SubElement(ibscbs, "gIBSCBS") + if produto_servico.ibscbs_cst in self._IBSCBS_CST_MONOFASICO: + self._serializar_gibscbs_mono(produto_servico, ibscbs) + elif produto_servico.ibscbs_cst in self._IBSCBS_CST_TRIBUTADOS: + self._serializar_gibscbs(produto_servico, ibscbs) - etree.SubElement(gibscbs, "vBC").text = "{:.2f}".format(produto_servico.ibscbs_vbc or 0) + def _serializar_gibscbs(self, produto_servico, ibscbs): + """Serializa padrao com vBC, gIBSUF, gIBSMun, vIBS e gCBS.""" + gibscbs = etree.SubElement(ibscbs, "gIBSCBS") - # gIBSUF - gibsuf = etree.SubElement(gibscbs, "gIBSUF") - etree.SubElement(gibsuf, "pIBSUF").text = "{:.4f}".format( - produto_servico.ibscbs_p_ibs_uf or 0 - ) - etree.SubElement(gibsuf, "vIBSUF").text = "{:.2f}".format( - produto_servico.ibscbs_v_ibs_uf or 0 - ) + etree.SubElement(gibscbs, "vBC").text = "{:.2f}".format(produto_servico.ibscbs_vbc or 0) - # gIBSMun - gibsmun = etree.SubElement(gibscbs, "gIBSMun") - etree.SubElement(gibsmun, "pIBSMun").text = "{:.4f}".format( - produto_servico.ibscbs_p_ibs_mun or 0 - ) - etree.SubElement(gibsmun, "vIBSMun").text = "{:.2f}".format( - produto_servico.ibscbs_v_ibs_mun or 0 - ) + # gIBSUF + gibsuf = etree.SubElement(gibscbs, "gIBSUF") + etree.SubElement(gibsuf, "pIBSUF").text = "{:.4f}".format( + produto_servico.ibscbs_p_ibs_uf or 0 + ) + etree.SubElement(gibsuf, "vIBSUF").text = "{:.2f}".format( + produto_servico.ibscbs_v_ibs_uf or 0 + ) - # vIBS total - etree.SubElement(gibscbs, "vIBS").text = "{:.2f}".format( - produto_servico.ibscbs_v_ibs or 0 - ) + # gIBSMun + gibsmun = etree.SubElement(gibscbs, "gIBSMun") + etree.SubElement(gibsmun, "pIBSMun").text = "{:.4f}".format( + produto_servico.ibscbs_p_ibs_mun or 0 + ) + etree.SubElement(gibsmun, "vIBSMun").text = "{:.2f}".format( + produto_servico.ibscbs_v_ibs_mun or 0 + ) - # gCBS - gcbs = etree.SubElement(gibscbs, "gCBS") - etree.SubElement(gcbs, "pCBS").text = "{:.4f}".format(produto_servico.ibscbs_p_cbs or 0) - etree.SubElement(gcbs, "vCBS").text = "{:.2f}".format(produto_servico.ibscbs_v_cbs or 0) + # vIBS total + etree.SubElement(gibscbs, "vIBS").text = "{:.2f}".format(produto_servico.ibscbs_v_ibs or 0) + + # gCBS + gcbs = etree.SubElement(gibscbs, "gCBS") + etree.SubElement(gcbs, "pCBS").text = "{:.4f}".format(produto_servico.ibscbs_p_cbs or 0) + etree.SubElement(gcbs, "vCBS").text = "{:.2f}".format(produto_servico.ibscbs_v_cbs or 0) + + def _serializar_gibscbs_mono(self, produto_servico, ibscbs): + """Serializa para CSTs monofasicas (620). + + Estrutura obrigatoria por NT 2025.002-RTC: + + TDec_1104v (4 casas) + TDec_0302a10 (4 casas) + TDec_1302 (2 casas) + TDec_0302a10 (4 casas) + TDec_1302 (2 casas) + + """ + gibscbs_mono = etree.SubElement(ibscbs, "gIBSCBSMono") + etree.SubElement(gibscbs_mono, "qBCMono").text = "{:.4f}".format( + produto_servico.ibscbs_q_bc_mono or 0 + ) + etree.SubElement(gibscbs_mono, "adRemIBS").text = "{:.4f}".format( + produto_servico.ibscbs_ad_rem_ibs or 0 + ) + etree.SubElement(gibscbs_mono, "vIBSMono").text = "{:.2f}".format( + produto_servico.ibscbs_v_ibs_mono or 0 + ) + etree.SubElement(gibscbs_mono, "adRemCBS").text = "{:.4f}".format( + produto_servico.ibscbs_ad_rem_cbs or 0 + ) + etree.SubElement(gibscbs_mono, "vCBSMono").text = "{:.2f}".format( + produto_servico.ibscbs_v_cbs_mono or 0 + ) def _serializar_is(self, produto_servico, tag_raiz): """Serializa (Imposto Seletivo) como filho direto de . diff --git a/tests/test_nfe_serializacao_reforma_tributaria.py b/tests/test_nfe_serializacao_reforma_tributaria.py index ee042b8a..a0f3b305 100644 --- a/tests/test_nfe_serializacao_reforma_tributaria.py +++ b/tests/test_nfe_serializacao_reforma_tributaria.py @@ -667,6 +667,162 @@ def test_cmunfgibs_emitido_no_ide(self): cmunfgibs_idx = tags.index("cMunFGIBS") self.assertGreater(cmunfgibs_idx, cmunfg_idx) + # ------------------------------------------------------------------ + # Test gIBSCBSMono: CST 620 emits monophasic group + # ------------------------------------------------------------------ + def test_cst620_monofasica_emite_gibscbsmono(self): + """CST 620 (tributacao monofasica) must emit with + qBCMono, adRemIBS, vIBSMono, adRemCBS, vCBSMono — NOT .""" + emitente = self._emitente() + cliente = self._cliente() + nf = self._nota_fiscal(emitente, cliente) + + kwargs = self._base_product_kwargs() + kwargs.update( + codigo="010", + descricao="GLP em Botijao 13KG (CST 620 monofasica)", + ncm="27111910", + quantidade_comercial=Decimal("18"), + valor_unitario_comercial=Decimal("74.04"), + valor_total_bruto=Decimal("1332.72"), + quantidade_tributavel=Decimal("18"), + valor_unitario_tributavel=Decimal("74.04"), + ibscbs_cst="620", + ibscbs_c_class_trib="620006", + # Monophasic fields + ibscbs_q_bc_mono=Decimal("18.0000"), + ibscbs_ad_rem_ibs=Decimal("0.0000"), + ibscbs_v_ibs_mono=Decimal("0.00"), + ibscbs_ad_rem_cbs=Decimal("0.0000"), + ibscbs_v_cbs_mono=Decimal("0.00"), + ) + nf.adicionar_produto_servico(**kwargs) + nf.adicionar_pagamento(t_pag="01", x_pag="Dinheiro", v_pag=1332.72, ind_pag=0) + + xml = self._serializar_e_assinar() + + # is emitted + ibscbs = xml.xpath("//ns:det/ns:imposto/ns:IBSCBS", namespaces=self.ns) + self.assertEqual(len(ibscbs), 1) + + # CST is 620 + cst = xml.xpath("//ns:IBSCBS/ns:CST", namespaces=self.ns)[0].text + self.assertEqual(cst, "620") + + # cClassTrib is 620006 + cclass = xml.xpath("//ns:IBSCBS/ns:cClassTrib", namespaces=self.ns)[0].text + self.assertEqual(cclass, "620006") + + # is emitted + gibscbs_mono = xml.xpath("//ns:IBSCBS/ns:gIBSCBSMono", namespaces=self.ns) + self.assertEqual(len(gibscbs_mono), 1) + + # is NOT emitted (we use monophasic instead) + gibscbs = xml.xpath("//ns:IBSCBS/ns:gIBSCBS", namespaces=self.ns) + self.assertEqual(len(gibscbs), 0) + + # Verify the 5 required monophasic fields in correct order + q_bc_mono = xml.xpath("//ns:gIBSCBSMono/ns:qBCMono", namespaces=self.ns)[0].text + self.assertEqual(q_bc_mono, "18.0000") + + ad_rem_ibs = xml.xpath("//ns:gIBSCBSMono/ns:adRemIBS", namespaces=self.ns)[0].text + self.assertEqual(ad_rem_ibs, "0.0000") + + v_ibs_mono = xml.xpath("//ns:gIBSCBSMono/ns:vIBSMono", namespaces=self.ns)[0].text + self.assertEqual(v_ibs_mono, "0.00") + + ad_rem_cbs = xml.xpath("//ns:gIBSCBSMono/ns:adRemCBS", namespaces=self.ns)[0].text + self.assertEqual(ad_rem_cbs, "0.0000") + + v_cbs_mono = xml.xpath("//ns:gIBSCBSMono/ns:vCBSMono", namespaces=self.ns)[0].text + self.assertEqual(v_cbs_mono, "0.00") + + # Field order: qBCMono, adRemIBS, vIBSMono, adRemCBS, vCBSMono + mono_elem = gibscbs_mono[0] + field_names = [child.tag.split("}")[-1] for child in mono_elem] + self.assertEqual( + field_names, + ["qBCMono", "adRemIBS", "vIBSMono", "adRemCBS", "vCBSMono"], + ) + + def test_cst620_monofasica_com_valores_calculados(self): + """CST 620 with non-zero ad rem rates produces non-zero monophasic values.""" + emitente = self._emitente() + cliente = self._cliente() + nf = self._nota_fiscal(emitente, cliente) + + kwargs = self._base_product_kwargs() + kwargs.update( + codigo="011", + descricao="Combustivel monofasico com ad rem", + ibscbs_cst="620", + ibscbs_c_class_trib="620001", + ibscbs_q_bc_mono=Decimal("100.0000"), + ibscbs_ad_rem_ibs=Decimal("0.1500"), + ibscbs_v_ibs_mono=Decimal("15.00"), + ibscbs_ad_rem_cbs=Decimal("0.8500"), + ibscbs_v_cbs_mono=Decimal("85.00"), + ) + nf.adicionar_produto_servico(**kwargs) + nf.adicionar_pagamento(t_pag="01", x_pag="Dinheiro", v_pag=1000.00, ind_pag=0) + + xml = self._serializar_e_assinar() + + self.assertEqual( + xml.xpath("//ns:gIBSCBSMono/ns:qBCMono", namespaces=self.ns)[0].text, "100.0000" + ) + self.assertEqual( + xml.xpath("//ns:gIBSCBSMono/ns:adRemIBS", namespaces=self.ns)[0].text, "0.1500" + ) + self.assertEqual( + xml.xpath("//ns:gIBSCBSMono/ns:vIBSMono", namespaces=self.ns)[0].text, "15.00" + ) + self.assertEqual( + xml.xpath("//ns:gIBSCBSMono/ns:adRemCBS", namespaces=self.ns)[0].text, "0.8500" + ) + self.assertEqual( + xml.xpath("//ns:gIBSCBSMono/ns:vCBSMono", namespaces=self.ns)[0].text, "85.00" + ) + + def test_cst000_nao_emite_gibscbsmono_regressao(self): + """Regression test: CST 000 (regular taxation) must still emit + and must NOT emit .""" + emitente = self._emitente() + cliente = self._cliente() + nf = self._nota_fiscal(emitente, cliente) + + kwargs = self._base_product_kwargs() + kwargs.update( + ibscbs_cst="000", + ibscbs_c_class_trib="000001", + ibscbs_vbc=Decimal("1000.00"), + ibscbs_p_ibs_uf=Decimal("0.1000"), + ibscbs_v_ibs_uf=Decimal("1.00"), + ibscbs_p_ibs_mun=Decimal("0.0000"), + ibscbs_v_ibs_mun=Decimal("0.00"), + ibscbs_v_ibs=Decimal("1.00"), + ibscbs_p_cbs=Decimal("0.9000"), + ibscbs_v_cbs=Decimal("9.00"), + ) + nf.adicionar_produto_servico(**kwargs) + nf.adicionar_pagamento(t_pag="01", x_pag="Dinheiro", v_pag=1000.00, ind_pag=0) + + xml = self._serializar_e_assinar() + + # is emitted (regular taxation path unchanged) + gibscbs = xml.xpath("//ns:IBSCBS/ns:gIBSCBS", namespaces=self.ns) + self.assertEqual(len(gibscbs), 1) + + # must NOT be emitted + gibscbs_mono = xml.xpath("//ns:IBSCBS/ns:gIBSCBSMono", namespaces=self.ns) + self.assertEqual(len(gibscbs_mono), 0) + + # Verify still has pIBSUF/pIBSMun/pCBS (regression) + p_ibs_uf = xml.xpath("//ns:gIBSCBS/ns:gIBSUF/ns:pIBSUF", namespaces=self.ns)[0].text + self.assertEqual(p_ibs_uf, "0.1000") + p_cbs = xml.xpath("//ns:gIBSCBS/ns:gCBS/ns:pCBS", namespaces=self.ns)[0].text + self.assertEqual(p_cbs, "0.9000") + # ------------------------------------------------------------------ # Test 10: cMunFGIBS NOT emitted when not set # ------------------------------------------------------------------ From 135ec04f22aaf625a5ce4c24f4908c5287502d85 Mon Sep 17 00:00:00 2001 From: Felipe Correa Date: Fri, 24 Apr 2026 21:24:20 -0300 Subject: [PATCH 02/10] fix(reforma): envolve gIBSCBSMono em gMonoPadrao conforme NT 2025.002-RTC [DEV-1953] (#3) Ao emitir NF-e com CST 620 (Tributacao monofasica), o grupo estava sendo serializado com os cinco campos direto (qBCMono, adRemIBS, vIBSMono, adRemCBS, vCBSMono), sem o wrapper exigido pelo schema oficial (DFeTiposBasicos_v1.00.xsd, type TMonofasia). SEFAZ rejeitava toda emissao com cStat 225: Falha no Schema XML da NFe Elemento: enviNFe/NFe[1]/infNFe/det[1]/imposto/IBSCBS/gIBSCBSMono/qBCMono Alem do wrapper ausente, a ordem dos campos tambem violava o schema (adRemCBS deve vir antes de vIBSMono, nao depois). Estrutura corrigida: TDec1104RTC TDec_0302_04RTC TDec_0302_04RTC TDec1302RTC TDec1302RTC Tests atualizados para validar wrapper + ordem correta conforme o schema TMonofasia/gMonoPadrao (xsd linhas 687-727). Co-authored-by: Claude Opus 4.7 (1M context) --- docs/reforma_tributaria.md | 24 ++++++++--- pynfe/processamento/serializacao.py | 43 +++++++++++++------ ...est_nfe_serializacao_reforma_tributaria.py | 36 +++++++++------- 3 files changed, 69 insertions(+), 34 deletions(-) diff --git a/docs/reforma_tributaria.md b/docs/reforma_tributaria.md index cb11c879..14c06627 100644 --- a/docs/reforma_tributaria.md +++ b/docs/reforma_tributaria.md @@ -43,15 +43,27 @@ A implementacao cobre: ### Tributacao monofasica — `gIBSCBSMono` -Para produtos com CST 620 (combustiveis, etc.) o grupo emitido dentro de `` e `` ao inves de ``. Campos obrigatorios: +Para produtos com CST 620 (combustiveis, etc.) o grupo emitido dentro de `` e `` ao inves de ``. Conforme o schema oficial (`DFeTiposBasicos_v1.00.xsd`, type `TMonofasia`), os cinco campos monofasicos vivem sob o wrapper obrigatorio `` e na ordem definida pelo schema: + +```xml + + + 18.0000 + 0.1000 + 0.0000 + 1.80 + 0.00 + + +``` | Campo | Tipo | Descricao | |-------|------|-----------| -| `qBCMono` | TDec_1104v | Quantidade tributada na base monofasica | -| `adRemIBS` | TDec_0302a10 | Aliquota ad rem IBS (valor em BRL por unidade) | -| `vIBSMono` | TDec_1302 | Valor IBS monofasico | -| `adRemCBS` | TDec_0302a10 | Aliquota ad rem CBS (valor em BRL por unidade) | -| `vCBSMono` | TDec_1302 | Valor CBS monofasico | +| `qBCMono` | TDec1104RTC | Quantidade tributada na base monofasica | +| `adRemIBS` | TDec_0302_04RTC | Aliquota ad rem IBS (valor em BRL por unidade) | +| `adRemCBS` | TDec_0302_04RTC | Aliquota ad rem CBS (valor em BRL por unidade) | +| `vIBSMono` | TDec1302RTC | Valor IBS monofasico | +| `vCBSMono` | TDec1302RTC | Valor CBS monofasico | Atributos na entidade `NotaFiscalProduto`: `ibscbs_q_bc_mono`, `ibscbs_ad_rem_ibs`, `ibscbs_v_ibs_mono`, `ibscbs_ad_rem_cbs`, `ibscbs_v_cbs_mono`. diff --git a/pynfe/processamento/serializacao.py b/pynfe/processamento/serializacao.py index 2ca28645..a8f60804 100644 --- a/pynfe/processamento/serializacao.py +++ b/pynfe/processamento/serializacao.py @@ -1397,29 +1397,46 @@ def _serializar_gibscbs(self, produto_servico, ibscbs): def _serializar_gibscbs_mono(self, produto_servico, ibscbs): """Serializa para CSTs monofasicas (620). - Estrutura obrigatoria por NT 2025.002-RTC: + Estrutura obrigatoria por NT 2025.002-RTC (type TMonofasia do + DFeTiposBasicos_v1.00.xsd, sequencia gMonoPadrao -> gMonoReten -> + gMonoRet -> gMonoDif). Para CST 620 "Tributacao monofasica padrao" + emitimos apenas com os cinco campos na ordem do + schema: + - TDec_1104v (4 casas) - TDec_0302a10 (4 casas) - TDec_1302 (2 casas) - TDec_0302a10 (4 casas) - TDec_1302 (2 casas) + + TDec1104RTC (4 casas) + TDec_0302_04RTC (4 casas) + TDec_0302_04RTC (4 casas) + TDec1302RTC (2 casas) + TDec1302RTC (2 casas) + + + IMPORTANT: + - O wrapper e obrigatorio. Antes desta correcao o + grupo era emitido flat (sem o wrapper) e SEFAZ rejeitava com + cStat 225 "Falha no Schema XML da NFe (Elemento: + enviNFe/NFe[1]/infNFe/det[1]/imposto/IBSCBS/gIBSCBSMono/qBCMono)". + - A ordem antes de segue o schema oficial + (linhas 701-725 de DFeTiposBasicos_v1.00.xsd). A ordem anterior + ( antes de ) tambem violava o schema. """ gibscbs_mono = etree.SubElement(ibscbs, "gIBSCBSMono") - etree.SubElement(gibscbs_mono, "qBCMono").text = "{:.4f}".format( + gmono_padrao = etree.SubElement(gibscbs_mono, "gMonoPadrao") + etree.SubElement(gmono_padrao, "qBCMono").text = "{:.4f}".format( produto_servico.ibscbs_q_bc_mono or 0 ) - etree.SubElement(gibscbs_mono, "adRemIBS").text = "{:.4f}".format( + etree.SubElement(gmono_padrao, "adRemIBS").text = "{:.4f}".format( produto_servico.ibscbs_ad_rem_ibs or 0 ) - etree.SubElement(gibscbs_mono, "vIBSMono").text = "{:.2f}".format( - produto_servico.ibscbs_v_ibs_mono or 0 - ) - etree.SubElement(gibscbs_mono, "adRemCBS").text = "{:.4f}".format( + etree.SubElement(gmono_padrao, "adRemCBS").text = "{:.4f}".format( produto_servico.ibscbs_ad_rem_cbs or 0 ) - etree.SubElement(gibscbs_mono, "vCBSMono").text = "{:.2f}".format( + etree.SubElement(gmono_padrao, "vIBSMono").text = "{:.2f}".format( + produto_servico.ibscbs_v_ibs_mono or 0 + ) + etree.SubElement(gmono_padrao, "vCBSMono").text = "{:.2f}".format( produto_servico.ibscbs_v_cbs_mono or 0 ) diff --git a/tests/test_nfe_serializacao_reforma_tributaria.py b/tests/test_nfe_serializacao_reforma_tributaria.py index a0f3b305..df076230 100644 --- a/tests/test_nfe_serializacao_reforma_tributaria.py +++ b/tests/test_nfe_serializacao_reforma_tributaria.py @@ -713,36 +713,41 @@ def test_cst620_monofasica_emite_gibscbsmono(self): cclass = xml.xpath("//ns:IBSCBS/ns:cClassTrib", namespaces=self.ns)[0].text self.assertEqual(cclass, "620006") - # is emitted + # is emitted with wrapper (NT 2025.002-RTC) gibscbs_mono = xml.xpath("//ns:IBSCBS/ns:gIBSCBSMono", namespaces=self.ns) self.assertEqual(len(gibscbs_mono), 1) + gmono_padrao = xml.xpath("//ns:IBSCBS/ns:gIBSCBSMono/ns:gMonoPadrao", namespaces=self.ns) + self.assertEqual(len(gmono_padrao), 1) + # is NOT emitted (we use monophasic instead) gibscbs = xml.xpath("//ns:IBSCBS/ns:gIBSCBS", namespaces=self.ns) self.assertEqual(len(gibscbs), 0) # Verify the 5 required monophasic fields in correct order - q_bc_mono = xml.xpath("//ns:gIBSCBSMono/ns:qBCMono", namespaces=self.ns)[0].text + q_bc_mono = xml.xpath("//ns:gMonoPadrao/ns:qBCMono", namespaces=self.ns)[0].text self.assertEqual(q_bc_mono, "18.0000") - ad_rem_ibs = xml.xpath("//ns:gIBSCBSMono/ns:adRemIBS", namespaces=self.ns)[0].text + ad_rem_ibs = xml.xpath("//ns:gMonoPadrao/ns:adRemIBS", namespaces=self.ns)[0].text self.assertEqual(ad_rem_ibs, "0.0000") - v_ibs_mono = xml.xpath("//ns:gIBSCBSMono/ns:vIBSMono", namespaces=self.ns)[0].text + v_ibs_mono = xml.xpath("//ns:gMonoPadrao/ns:vIBSMono", namespaces=self.ns)[0].text self.assertEqual(v_ibs_mono, "0.00") - ad_rem_cbs = xml.xpath("//ns:gIBSCBSMono/ns:adRemCBS", namespaces=self.ns)[0].text + ad_rem_cbs = xml.xpath("//ns:gMonoPadrao/ns:adRemCBS", namespaces=self.ns)[0].text self.assertEqual(ad_rem_cbs, "0.0000") - v_cbs_mono = xml.xpath("//ns:gIBSCBSMono/ns:vCBSMono", namespaces=self.ns)[0].text + v_cbs_mono = xml.xpath("//ns:gMonoPadrao/ns:vCBSMono", namespaces=self.ns)[0].text self.assertEqual(v_cbs_mono, "0.00") - # Field order: qBCMono, adRemIBS, vIBSMono, adRemCBS, vCBSMono - mono_elem = gibscbs_mono[0] - field_names = [child.tag.split("}")[-1] for child in mono_elem] + # Field order per schema TMonofasia/gMonoPadrao: + # qBCMono, adRemIBS, adRemCBS, vIBSMono, vCBSMono + # (adRemCBS must come BEFORE vIBSMono per DFeTiposBasicos_v1.00.xsd) + padrao_elem = gmono_padrao[0] + field_names = [child.tag.split("}")[-1] for child in padrao_elem] self.assertEqual( field_names, - ["qBCMono", "adRemIBS", "vIBSMono", "adRemCBS", "vCBSMono"], + ["qBCMono", "adRemIBS", "adRemCBS", "vIBSMono", "vCBSMono"], ) def test_cst620_monofasica_com_valores_calculados(self): @@ -768,20 +773,21 @@ def test_cst620_monofasica_com_valores_calculados(self): xml = self._serializar_e_assinar() + # Values live under / per NT 2025.002-RTC self.assertEqual( - xml.xpath("//ns:gIBSCBSMono/ns:qBCMono", namespaces=self.ns)[0].text, "100.0000" + xml.xpath("//ns:gMonoPadrao/ns:qBCMono", namespaces=self.ns)[0].text, "100.0000" ) self.assertEqual( - xml.xpath("//ns:gIBSCBSMono/ns:adRemIBS", namespaces=self.ns)[0].text, "0.1500" + xml.xpath("//ns:gMonoPadrao/ns:adRemIBS", namespaces=self.ns)[0].text, "0.1500" ) self.assertEqual( - xml.xpath("//ns:gIBSCBSMono/ns:vIBSMono", namespaces=self.ns)[0].text, "15.00" + xml.xpath("//ns:gMonoPadrao/ns:vIBSMono", namespaces=self.ns)[0].text, "15.00" ) self.assertEqual( - xml.xpath("//ns:gIBSCBSMono/ns:adRemCBS", namespaces=self.ns)[0].text, "0.8500" + xml.xpath("//ns:gMonoPadrao/ns:adRemCBS", namespaces=self.ns)[0].text, "0.8500" ) self.assertEqual( - xml.xpath("//ns:gIBSCBSMono/ns:vCBSMono", namespaces=self.ns)[0].text, "85.00" + xml.xpath("//ns:gMonoPadrao/ns:vCBSMono", namespaces=self.ns)[0].text, "85.00" ) def test_cst000_nao_emite_gibscbsmono_regressao(self): From fa099e8e067e713f9d6650da61f3eeeb4b14ec09 Mon Sep 17 00:00:00 2001 From: Felipe Correa Date: Sat, 25 Apr 2026 12:32:47 -0300 Subject: [PATCH 03/10] fix(reforma): adiciona vTotIBSMonoItem e vTotCBSMonoItem em gIBSCBSMono [DEV-1954] (#4) Schema TMonofasia (DFeTiposBasicos_v1.00.xsd) define que gIBSCBSMono possui duas tags REQUERIDAS (sem minOccurs=0) como siblings de gMonoPadrao: vTotIBSMonoItem e vTotCBSMonoItem (ambos TDec1302RTC). Antes deste fix, mesmo apos a DEV-1953 que adicionou o wrapper gMonoPadrao, o XML emitido por PyNFe ainda terminava em sem os dois totais, e SEFAZ rejeitava com cStat 225 "Falha no Schema XML da NFe (Elemento: ...gIBSCBSMono/)" para todas as notas com CST 620 do cliente E B DA FONSECA (loja 37, CNPJ 24712859000191). Mudancas: - NotaFiscalProduto: dois novos atributos ibscbs_v_tot_ibs_mono_item e ibscbs_v_tot_cbs_mono_item (default Decimal()) aceitos via kwargs em adicionar_produto_servico (Entidade.__setattr__ exige existencia previa). - _serializar_gibscbs_mono: emite os dois totais como filhos diretos de gIBSCBSMono apos o gMonoPadrao, formatados em 2 casas decimais. Para itens single-line (sem retencao/diferimento) os totais devem igualar vIBSMono / vCBSMono. Quando nao informados pelo caller, emite 0.00 (default seguro durante o Teste de Carga 2026 com ad rem zerados). - Tests: estende test_cst620_monofasica_emite_gibscbsmono e test_cst620_monofasica_com_valores_calculados para asserir presenca, ordem e tipo decimal dos totais. Adiciona test_cst620_v_tot_mono_item_default_zero_when_unset para garantir que o default seguro funciona quando o caller omite os campos. - Docs (reforma_tributaria.md): atualiza o exemplo XML e a tabela de campos com vTotIBSMonoItem / vTotCBSMonoItem. --- docs/reforma_tributaria.md | 10 ++- pynfe/entidades/notafiscal.py | 6 ++ pynfe/processamento/serializacao.py | 28 ++++-- ...est_nfe_serializacao_reforma_tributaria.py | 89 ++++++++++++++++++- 4 files changed, 123 insertions(+), 10 deletions(-) diff --git a/docs/reforma_tributaria.md b/docs/reforma_tributaria.md index 14c06627..cc2c410f 100644 --- a/docs/reforma_tributaria.md +++ b/docs/reforma_tributaria.md @@ -43,7 +43,7 @@ A implementacao cobre: ### Tributacao monofasica — `gIBSCBSMono` -Para produtos com CST 620 (combustiveis, etc.) o grupo emitido dentro de `` e `` ao inves de ``. Conforme o schema oficial (`DFeTiposBasicos_v1.00.xsd`, type `TMonofasia`), os cinco campos monofasicos vivem sob o wrapper obrigatorio `` e na ordem definida pelo schema: +Para produtos com CST 620 (combustiveis, etc.) o grupo emitido dentro de `` e `` ao inves de ``. Conforme o schema oficial (`DFeTiposBasicos_v1.00.xsd`, type `TMonofasia`), os cinco campos monofasicos vivem sob o wrapper obrigatorio `` e na ordem definida pelo schema. Alem disso, `` e `` sao SIBLINGS de `` (NAO filhos) e ambos sao OBRIGATORIOS por schema (sem `minOccurs=0`): ```xml @@ -54,6 +54,8 @@ Para produtos com CST 620 (combustiveis, etc.) o grupo emitido dentro de `1.80 0.00 + 1.80 + 0.00 ``` @@ -64,8 +66,12 @@ Para produtos com CST 620 (combustiveis, etc.) o grupo emitido dentro de ` inside (NOT children of ). For a + # single-line item without retencao/diferimento, they equal vIBSMono/vCBSMono. ibscbs_q_bc_mono = Decimal() # qBCMono ibscbs_ad_rem_ibs = Decimal() # adRemIBS ibscbs_v_ibs_mono = Decimal() # vIBSMono ibscbs_ad_rem_cbs = Decimal() # adRemCBS ibscbs_v_cbs_mono = Decimal() # vCBSMono + ibscbs_v_tot_ibs_mono_item = Decimal() # vTotIBSMonoItem + ibscbs_v_tot_cbs_mono_item = Decimal() # vTotCBSMonoItem # IS (Imposto Seletivo) - Group UB-IS is_cst_selec = str() # CSTSelec (2-digit) diff --git a/pynfe/processamento/serializacao.py b/pynfe/processamento/serializacao.py index a8f60804..c74f8cdb 100644 --- a/pynfe/processamento/serializacao.py +++ b/pynfe/processamento/serializacao.py @@ -1399,9 +1399,10 @@ def _serializar_gibscbs_mono(self, produto_servico, ibscbs): Estrutura obrigatoria por NT 2025.002-RTC (type TMonofasia do DFeTiposBasicos_v1.00.xsd, sequencia gMonoPadrao -> gMonoReten -> - gMonoRet -> gMonoDif). Para CST 620 "Tributacao monofasica padrao" - emitimos apenas com os cinco campos na ordem do - schema: + gMonoRet -> gMonoDif -> vTotIBSMonoItem -> vTotCBSMonoItem). Para + CST 620 "Tributacao monofasica padrao" emitimos apenas + com os cinco campos na ordem do schema, seguido pelos dois totais + item-level que sao REQUERIDOS pelo schema (sem minOccurs=0): @@ -1411,16 +1412,22 @@ def _serializar_gibscbs_mono(self, produto_servico, ibscbs): TDec1302RTC (2 casas) TDec1302RTC (2 casas) + TDec1302RTC (2 casas) + TDec1302RTC (2 casas) IMPORTANT: - - O wrapper e obrigatorio. Antes desta correcao o - grupo era emitido flat (sem o wrapper) e SEFAZ rejeitava com - cStat 225 "Falha no Schema XML da NFe (Elemento: + - O wrapper e obrigatorio. Antes da correcao da + DEV-1953 o grupo era emitido flat (sem o wrapper) e SEFAZ + rejeitava com cStat 225 "Falha no Schema XML da NFe (Elemento: enviNFe/NFe[1]/infNFe/det[1]/imposto/IBSCBS/gIBSCBSMono/qBCMono)". - A ordem antes de segue o schema oficial (linhas 701-725 de DFeTiposBasicos_v1.00.xsd). A ordem anterior ( antes de ) tambem violava o schema. + - e sao SIBLINGS de + (NAO filhos), ambos OBRIGATORIOS por schema (DEV-1954). + Sem eles SEFAZ rejeita com cStat 225 "Falha no Schema XML da NFe + (Elemento: ... gIBSCBSMono/)" (barra final indica fechamento). """ gibscbs_mono = etree.SubElement(ibscbs, "gIBSCBSMono") gmono_padrao = etree.SubElement(gibscbs_mono, "gMonoPadrao") @@ -1439,6 +1446,15 @@ def _serializar_gibscbs_mono(self, produto_servico, ibscbs): etree.SubElement(gmono_padrao, "vCBSMono").text = "{:.2f}".format( produto_servico.ibscbs_v_cbs_mono or 0 ) + # vTotIBSMonoItem / vTotCBSMonoItem: required siblings of + # per schema TMonofasia (DEV-1954). For a single-line mono item without + # retencao/diferimento, these equal vIBSMono/vCBSMono. + etree.SubElement(gibscbs_mono, "vTotIBSMonoItem").text = "{:.2f}".format( + produto_servico.ibscbs_v_tot_ibs_mono_item or 0 + ) + etree.SubElement(gibscbs_mono, "vTotCBSMonoItem").text = "{:.2f}".format( + produto_servico.ibscbs_v_tot_cbs_mono_item or 0 + ) def _serializar_is(self, produto_servico, tag_raiz): """Serializa (Imposto Seletivo) como filho direto de . diff --git a/tests/test_nfe_serializacao_reforma_tributaria.py b/tests/test_nfe_serializacao_reforma_tributaria.py index df076230..1179cec6 100644 --- a/tests/test_nfe_serializacao_reforma_tributaria.py +++ b/tests/test_nfe_serializacao_reforma_tributaria.py @@ -672,7 +672,11 @@ def test_cmunfgibs_emitido_no_ide(self): # ------------------------------------------------------------------ def test_cst620_monofasica_emite_gibscbsmono(self): """CST 620 (tributacao monofasica) must emit with - qBCMono, adRemIBS, vIBSMono, adRemCBS, vCBSMono — NOT .""" + qBCMono, adRemIBS, vIBSMono, adRemCBS, vCBSMono — NOT . + + DEV-1954: also asserts vTotIBSMonoItem / vTotCBSMonoItem siblings of + (required by schema TMonofasia). + """ emitente = self._emitente() cliente = self._cliente() nf = self._nota_fiscal(emitente, cliente) @@ -695,6 +699,9 @@ def test_cst620_monofasica_emite_gibscbsmono(self): ibscbs_v_ibs_mono=Decimal("0.00"), ibscbs_ad_rem_cbs=Decimal("0.0000"), ibscbs_v_cbs_mono=Decimal("0.00"), + # Item-level totals (DEV-1954) - required siblings of + ibscbs_v_tot_ibs_mono_item=Decimal("0.00"), + ibscbs_v_tot_cbs_mono_item=Decimal("0.00"), ) nf.adicionar_produto_servico(**kwargs) nf.adicionar_pagamento(t_pag="01", x_pag="Dinheiro", v_pag=1332.72, ind_pag=0) @@ -750,8 +757,37 @@ def test_cst620_monofasica_emite_gibscbsmono(self): ["qBCMono", "adRemIBS", "adRemCBS", "vIBSMono", "vCBSMono"], ) + # DEV-1954: vTotIBSMonoItem / vTotCBSMonoItem must be SIBLINGS of + # (children of ) — NOT children of + # . Both are REQUIRED by schema TMonofasia. + v_tot_ibs = xml.xpath("//ns:IBSCBS/ns:gIBSCBSMono/ns:vTotIBSMonoItem", namespaces=self.ns) + self.assertEqual(len(v_tot_ibs), 1) + self.assertEqual(v_tot_ibs[0].text, "0.00") + v_tot_cbs = xml.xpath("//ns:IBSCBS/ns:gIBSCBSMono/ns:vTotCBSMonoItem", namespaces=self.ns) + self.assertEqual(len(v_tot_cbs), 1) + self.assertEqual(v_tot_cbs[0].text, "0.00") + + # Totals must NOT be children of (common misreading of schema) + wrong_v_tot_ibs = xml.xpath("//ns:gMonoPadrao/ns:vTotIBSMonoItem", namespaces=self.ns) + self.assertEqual(len(wrong_v_tot_ibs), 0) + wrong_v_tot_cbs = xml.xpath("//ns:gMonoPadrao/ns:vTotCBSMonoItem", namespaces=self.ns) + self.assertEqual(len(wrong_v_tot_cbs), 0) + + # Direct children of must be in schema order: + # gMonoPadrao -> vTotIBSMonoItem -> vTotCBSMonoItem + gibscbs_mono_elem = gibscbs_mono[0] + direct_children = [child.tag.split("}")[-1] for child in gibscbs_mono_elem] + self.assertEqual( + direct_children, + ["gMonoPadrao", "vTotIBSMonoItem", "vTotCBSMonoItem"], + ) + def test_cst620_monofasica_com_valores_calculados(self): - """CST 620 with non-zero ad rem rates produces non-zero monophasic values.""" + """CST 620 with non-zero ad rem rates produces non-zero monophasic values. + + DEV-1954: For a single-line item without retencao/diferimento, + vTotIBSMonoItem == vIBSMono and vTotCBSMonoItem == vCBSMono. + """ emitente = self._emitente() cliente = self._cliente() nf = self._nota_fiscal(emitente, cliente) @@ -767,6 +803,9 @@ def test_cst620_monofasica_com_valores_calculados(self): ibscbs_v_ibs_mono=Decimal("15.00"), ibscbs_ad_rem_cbs=Decimal("0.8500"), ibscbs_v_cbs_mono=Decimal("85.00"), + # Item-level totals equal the mono values for single-line items + ibscbs_v_tot_ibs_mono_item=Decimal("15.00"), + ibscbs_v_tot_cbs_mono_item=Decimal("85.00"), ) nf.adicionar_produto_servico(**kwargs) nf.adicionar_pagamento(t_pag="01", x_pag="Dinheiro", v_pag=1000.00, ind_pag=0) @@ -789,6 +828,52 @@ def test_cst620_monofasica_com_valores_calculados(self): self.assertEqual( xml.xpath("//ns:gMonoPadrao/ns:vCBSMono", namespaces=self.ns)[0].text, "85.00" ) + # DEV-1954: item-level totals equal the mono values for single-line items + self.assertEqual( + xml.xpath("//ns:IBSCBS/ns:gIBSCBSMono/ns:vTotIBSMonoItem", namespaces=self.ns)[0].text, + "15.00", + ) + self.assertEqual( + xml.xpath("//ns:IBSCBS/ns:gIBSCBSMono/ns:vTotCBSMonoItem", namespaces=self.ns)[0].text, + "85.00", + ) + + def test_cst620_v_tot_mono_item_default_zero_when_unset(self): + """DEV-1954: vTotIBSMonoItem / vTotCBSMonoItem default to 0.00 when not provided. + + Both are REQUIRED by schema TMonofasia (no minOccurs=0). Falling back + to 0 keeps the XML schema-valid even when callers forget to set them. + """ + emitente = self._emitente() + cliente = self._cliente() + nf = self._nota_fiscal(emitente, cliente) + + kwargs = self._base_product_kwargs() + kwargs.update( + codigo="012", + descricao="Combustivel monofasico sem totais explicitos", + ibscbs_cst="620", + ibscbs_c_class_trib="620006", + ibscbs_q_bc_mono=Decimal("18.0000"), + ibscbs_ad_rem_ibs=Decimal("0.0000"), + ibscbs_v_ibs_mono=Decimal("0.00"), + ibscbs_ad_rem_cbs=Decimal("0.0000"), + ibscbs_v_cbs_mono=Decimal("0.00"), + # NOTE: ibscbs_v_tot_ibs_mono_item / ibscbs_v_tot_cbs_mono_item NOT set + ) + nf.adicionar_produto_servico(**kwargs) + nf.adicionar_pagamento(t_pag="01", x_pag="Dinheiro", v_pag=1332.72, ind_pag=0) + + xml = self._serializar_e_assinar() + + # Both totals MUST be emitted as direct children of + # with the schema-required default of 0.00. + v_tot_ibs = xml.xpath("//ns:IBSCBS/ns:gIBSCBSMono/ns:vTotIBSMonoItem", namespaces=self.ns) + self.assertEqual(len(v_tot_ibs), 1) + self.assertEqual(v_tot_ibs[0].text, "0.00") + v_tot_cbs = xml.xpath("//ns:IBSCBS/ns:gIBSCBSMono/ns:vTotCBSMonoItem", namespaces=self.ns) + self.assertEqual(len(v_tot_cbs), 1) + self.assertEqual(v_tot_cbs[0].text, "0.00") def test_cst000_nao_emite_gibscbsmono_regressao(self): """Regression test: CST 000 (regular taxation) must still emit From 354cb8ef6ec306e0c27c852df1208510cc096c86 Mon Sep 17 00:00:00 2001 From: Felipe Correa Date: Mon, 27 Apr 2026 16:08:25 -0300 Subject: [PATCH 04/10] fix(reforma): adiciona gMono em IBSCBSTot para itens monofasicos [DEV-1955] (#5) * fix(reforma): adiciona gMono em IBSCBSTot para itens monofasicos [DEV-1955] Quando uma NF-e contem itens com (CST 620), o totalizador precisa emitir o subgrupo com os seis campos obrigatorios (vIBSMono, vCBSMono, vIBSMonoReten, vCBSMonoReten, vIBSMonoRet, vCBSMonoRet) conforme schema TIBSCBSMonoTot/gMono em DFeTiposBasicos_v1.00.xsd. Sem o subgrupo, SEFAZ rejeita com cStat 1119 ("Total de IBS e CBS nao informado"). Mudancas: - pynfe/entidades/notafiscal.py: adiciona constante _IBSCBS_CST_MONOFASICO (espelho do set definido em SerializacaoXML), seis acumuladores totais_v_ibs_mono / totais_v_cbs_mono / totais_v_*_mono_reten / totais_v_*_mono_ret e contador totais_mono_item_count. Em adicionar_produto_servico, soma vTotIBSMonoItem / vTotCBSMonoItem dos itens e incrementa o contador quando o CST e monofasico. - pynfe/processamento/serializacao.py: forca emissao de quando ha pelo menos um item monofasico (mesmo com totais standard zerados) e emite com os seis filhos quando totais_mono_item_count > 0. Os filhos Reten/Ret ainda sao zero pois PyNFe so suporta a nivel de item, mas o schema exige todos os seis quando e emitido. - tests/test_nfe_serializacao_reforma_tributaria.py: 4 testes novos: test_ibscbstot_gmono_emitido_para_item_monofasico_unico (cenario E B DA FONSECA com qtde 18 e ad rem zero), test_ibscbstot_gmono_soma_ multiplos_itens_monofasicos, test_ibscbstot_sem_gmono_quando_so_itens_ padrao (regressao), test_ibscbstot_misto_emite_gibs_gcbs_e_gmono. - docs/reforma_tributaria.md: atualiza exemplo de IBSCBSTot para incluir e adiciona notas sobre quando o subgrupo e obrigatorio. Tests: 153 passed (149 baseline + 4 novos). ruff check + format clean. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(reforma): substitui em-dashes por ASCII em comentarios [DEV-1955] Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- docs/reforma_tributaria.md | 13 +- pynfe/entidades/notafiscal.py | 39 ++++ pynfe/processamento/serializacao.py | 41 +++- ...est_nfe_serializacao_reforma_tributaria.py | 221 ++++++++++++++++++ 4 files changed, 310 insertions(+), 4 deletions(-) diff --git a/docs/reforma_tributaria.md b/docs/reforma_tributaria.md index cc2c410f..2312ecb5 100644 --- a/docs/reforma_tributaria.md +++ b/docs/reforma_tributaria.md @@ -335,13 +335,22 @@ Os totais ficam em um grupo **separado** de ``, como irmao dentro de `< 0.00 0.00 - + + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + ``` > Os subgrupos `gIBS` e `gCBS` sao opcionais (`minOccurs="0"`) — emitidos apenas quando ha valores. Os campos `vDif`, `vDevTrib`, `vCredPres` e `vCredPresCondSus` sao obrigatorios dentro de cada subgrupo (emitidos como "0.00" quando nao utilizados). +> +> O subgrupo `gMono` e opcional, mas obrigatorio sempre que algum item da NF-e carregar `` (CST 620, etc.). Quando emitido, **todos os seis filhos** sao obrigatorios (`vIBSMono`, `vCBSMono`, `vIBSMonoReten`, `vCBSMonoReten`, `vIBSMonoRet`, `vCBSMonoRet`). Omitir `` em uma NF-e com items monofasicos faz a SEFAZ rejeitar com `cStat 1119 - "Total de IBS e CBS nao informado"`. Os totais `Reten`/`Ret` ainda nao sao acumulados a nivel de item (PyNFe ainda so emite ``), entao serao "0.00" ate que `` / `` / `` sejam suportados a nivel de item. ### Cabecalho — `cMunFGIBS` no `` @@ -373,7 +382,7 @@ Esses CSTs geram apenas `` e ``, sem ``. - **`cClassTrib`**: Emitido quando informado (campo obrigatorio na pratica) - **`cMunFGIBS`**: Emitido no `` apenas quando informado -- **``**: Tipo `TIBSCBSMonoTot`. Omitido se todos os totais forem zero. Quando emitido, `vBCIBSCBS` e obrigatorio como primeiro filho; `gIBS` e `gCBS` sao opcionais +- **``**: Tipo `TIBSCBSMonoTot`. Omitido se todos os totais forem zero E nenhum item carregar ``. Quando emitido, `vBCIBSCBS` e obrigatorio como primeiro filho; `gIBS`, `gCBS` e `gMono` sao opcionais (mas `gMono` e obrigatorio sempre que houver items monofasicos) - **``**: Tipo `TTribNFe`. Omitido completamente se `ibscbs_cst` nao for informado - **IS (``)**: Tipo `TIS`. **Nao emitido no XML** — serializacao desabilitada ate 2027 - **``**: Tipo `TISTot`. **Nao emitido** — sera irmao de `` (antes dele no schema) diff --git a/pynfe/entidades/notafiscal.py b/pynfe/entidades/notafiscal.py index 42727035..7407e44e 100644 --- a/pynfe/entidades/notafiscal.py +++ b/pynfe/entidades/notafiscal.py @@ -10,6 +10,12 @@ from .base import CampoDeprecated, Entidade +# CSTs IBS/CBS that route to instead of . Mirrors +# the constant in pynfe.processamento.serializacao but defined here so the +# NotaFiscal accumulator can detect monofasic items without importing the +# serializer. Keep in sync with SerializacaoXML._IBSCBS_CST_MONOFASICO. +_IBSCBS_CST_MONOFASICO = ("620",) + class NotaFiscal(Entidade): # campos deprecados @@ -310,6 +316,24 @@ class NotaFiscal(Entidade): totais_cbs = Decimal() totais_is = Decimal() + # Reforma Tributaria - Totais Monofasia (Group W03 - IBSCBSTot/gMono) + # Per NT 2025.002-RTC schema TIBSCBSMonoTot, when ANY item carries + # , the IBSCBSTot wrapper MUST emit a sibling with + # six TDec1302RTC values (all REQUIRED inside ): vIBSMono, + # vCBSMono, vIBSMonoReten, vCBSMonoReten, vIBSMonoRet, vCBSMonoRet. + # SEFAZ rejects with cStat 1119 ("Total de IBS e CBS nao informado") + # when items emit but the totalizer omits . + # ``totais_mono_item_count`` tracks the number of monofasic items so + # the serializer can decide to emit even when all six values + # are zero (Teste de Carga 2026 scenario, where every ad rem is 0). + totais_v_ibs_mono = Decimal() # sum of vTotIBSMonoItem (gMonoPadrao) + totais_v_cbs_mono = Decimal() # sum of vTotCBSMonoItem (gMonoPadrao) + totais_v_ibs_mono_reten = Decimal() # sum of vTotIBSMonoItem (gMonoReten) + totais_v_cbs_mono_reten = Decimal() # sum of vTotCBSMonoItem (gMonoReten) + totais_v_ibs_mono_ret = Decimal() # sum of vTotIBSMonoItem (gMonoRet) + totais_v_cbs_mono_ret = Decimal() # sum of vTotCBSMonoItem (gMonoRet) + totais_mono_item_count = int() + # Reforma Tributaria - cMunFGIBS (Group B) municipio_fato_gerador_ibs = str() @@ -483,6 +507,21 @@ def adicionar_produto_servico(self, **kwargs): self.totais_cbs += obj.ibscbs_v_cbs self.totais_is += obj.is_valor + # Reforma Tributaria - Totais Monofasia (IBSCBSTot/gMono per NT 2025.002-RTC) + # Aggregate item-level vTotIBSMonoItem / vTotCBSMonoItem so the + # serializer can emit with the required totals. PyNFe today + # only supports at item level (see _serializar_gibscbs_mono), + # so the Reten/Ret accumulators stay at zero, but the schema requires + # all six fields to be emitted whenever is present, so they + # are kept here to align with TIBSCBSMonoTot/gMono. + # ``totais_mono_item_count`` is incremented when CST routes to mono, + # giving the serializer a stable signal to emit even if every + # value is zero (Teste de Carga 2026 scenario). + self.totais_v_ibs_mono += obj.ibscbs_v_tot_ibs_mono_item + self.totais_v_cbs_mono += obj.ibscbs_v_tot_cbs_mono_item + if obj.ibscbs_cst in _IBSCBS_CST_MONOFASICO: + self.totais_mono_item_count += 1 + # TODO calcular impostos aproximados # self.totais_tributos_aproximado += obj.tributos diff --git a/pynfe/processamento/serializacao.py b/pynfe/processamento/serializacao.py index c74f8cdb..c94aec2e 100644 --- a/pynfe/processamento/serializacao.py +++ b/pynfe/processamento/serializacao.py @@ -1875,8 +1875,17 @@ def _serializar_nota_fiscal(self, nota_fiscal, tag_raiz="infNFe", retorna_string # Reforma Tributaria - Totais IVA Dual (Group W03 - IBSCBSTot) # Type: TIBSCBSMonoTot (PL 010b DFeTiposBasicos_v1.00.xsd) + # ``has_mono`` is true when at least one item routed to : + # SEFAZ rejects with cStat 1119 ("Total de IBS e CBS nao informado") + # when items emit monofasia but the IBSCBSTot/gMono total is missing. + # During Teste de Carga 2026, every ad rem is zero so the regular + # accumulators stay at 0; the count flag forces emission anyway. + has_mono = nota_fiscal.totais_mono_item_count > 0 has_reforma = ( - nota_fiscal.totais_vbc_ibscbs or nota_fiscal.totais_ibs or nota_fiscal.totais_cbs + nota_fiscal.totais_vbc_ibscbs + or nota_fiscal.totais_ibs + or nota_fiscal.totais_cbs + or has_mono ) if has_reforma: ibscbs_tot = etree.SubElement(total, "IBSCBSTot") @@ -1915,7 +1924,35 @@ def _serializar_nota_fiscal(self, nota_fiscal, tag_raiz="infNFe", retorna_string etree.SubElement(g_cbs, "vCredPres").text = "0.00" etree.SubElement(g_cbs, "vCredPresCondSus").text = "0.00" - # gMono: not implemented yet (monofasia totals) + # gMono - totals da monofasia (DEV-1955) + # Per NT 2025.002-RTC schema TIBSCBSMonoTot, the wrapper is + # itself optional (minOccurs=0) but, when present, ALL six children + # are REQUIRED (no minOccurs=0 inside). SEFAZ requires + # whenever the NF-e contains items with ; omitting it + # raises cStat 1119 ("Total de IBS e CBS nao informado"). For the + # Teste de Carga 2026 scenario every value is "0.00", but the + # block itself must still be emitted. + if has_mono: + g_mono = etree.SubElement(ibscbs_tot, "gMono") + etree.SubElement(g_mono, "vIBSMono").text = "{:.2f}".format( + nota_fiscal.totais_v_ibs_mono + ) + etree.SubElement(g_mono, "vCBSMono").text = "{:.2f}".format( + nota_fiscal.totais_v_cbs_mono + ) + etree.SubElement(g_mono, "vIBSMonoReten").text = "{:.2f}".format( + nota_fiscal.totais_v_ibs_mono_reten + ) + etree.SubElement(g_mono, "vCBSMonoReten").text = "{:.2f}".format( + nota_fiscal.totais_v_cbs_mono_reten + ) + etree.SubElement(g_mono, "vIBSMonoRet").text = "{:.2f}".format( + nota_fiscal.totais_v_ibs_mono_ret + ) + etree.SubElement(g_mono, "vCBSMonoRet").text = "{:.2f}".format( + nota_fiscal.totais_v_cbs_mono_ret + ) + # gEstornoCred: not implemented yet (estorno de credito totals) # Transporte diff --git a/tests/test_nfe_serializacao_reforma_tributaria.py b/tests/test_nfe_serializacao_reforma_tributaria.py index 1179cec6..4ce522f7 100644 --- a/tests/test_nfe_serializacao_reforma_tributaria.py +++ b/tests/test_nfe_serializacao_reforma_tributaria.py @@ -914,6 +914,227 @@ def test_cst000_nao_emite_gibscbsmono_regressao(self): p_cbs = xml.xpath("//ns:gIBSCBS/ns:gCBS/ns:pCBS", namespaces=self.ns)[0].text self.assertEqual(p_cbs, "0.9000") + # ------------------------------------------------------------------ + # DEV-1955 — IBSCBSTot/ totals when items carry + # ------------------------------------------------------------------ + def test_ibscbstot_gmono_emitido_para_item_monofasico_unico(self): + """A NF-e with a single item must emit IBSCBSTot/. + + Reproduces the cliente E B DA FONSECA (DEV-1955) scenario: GLP em Botijao + 13KG, qtde 18, CST 620, cClassTrib 620006, all ad-rem zero (Teste de Carga + 2026). Before the fix, the IBSCBSTot wrapper was suppressed entirely + (because totais_vbc_ibscbs / totais_ibs / totais_cbs were all 0) and + SEFAZ rejected with cStat 1119 "Total de IBS e CBS nao informado". + """ + emitente = self._emitente() + cliente = self._cliente() + nf = self._nota_fiscal(emitente, cliente) + + kwargs = self._base_product_kwargs() + kwargs.update( + codigo="020", + descricao="GLP em Botijao 13KG", + ncm="27111910", + quantidade_comercial=Decimal("18"), + valor_unitario_comercial=Decimal("74.04"), + valor_total_bruto=Decimal("1332.72"), + quantidade_tributavel=Decimal("18"), + valor_unitario_tributavel=Decimal("74.04"), + ibscbs_cst="620", + ibscbs_c_class_trib="620006", + ibscbs_q_bc_mono=Decimal("18.0000"), + ibscbs_ad_rem_ibs=Decimal("0.0000"), + ibscbs_v_ibs_mono=Decimal("0.00"), + ibscbs_ad_rem_cbs=Decimal("0.0000"), + ibscbs_v_cbs_mono=Decimal("0.00"), + ibscbs_v_tot_ibs_mono_item=Decimal("0.00"), + ibscbs_v_tot_cbs_mono_item=Decimal("0.00"), + ) + nf.adicionar_produto_servico(**kwargs) + nf.adicionar_pagamento(t_pag="01", x_pag="Dinheiro", v_pag=1332.72, ind_pag=0) + + xml = self._serializar_e_assinar() + + # IBSCBSTot must be present even when standard totals are zero, because + # at least one item carries . + ibscbs_tot = xml.xpath("//ns:total/ns:IBSCBSTot", namespaces=self.ns) + self.assertEqual(len(ibscbs_tot), 1) + + # gMono inside IBSCBSTot must contain the six required TDec1302RTC fields, + # all "0.00" for the Teste de Carga scenario. + g_mono = xml.xpath("//ns:IBSCBSTot/ns:gMono", namespaces=self.ns) + self.assertEqual(len(g_mono), 1) + + v_ibs_mono = xml.xpath("//ns:IBSCBSTot/ns:gMono/ns:vIBSMono", namespaces=self.ns) + self.assertEqual(len(v_ibs_mono), 1) + self.assertEqual(v_ibs_mono[0].text, "0.00") + + v_cbs_mono = xml.xpath("//ns:IBSCBSTot/ns:gMono/ns:vCBSMono", namespaces=self.ns) + self.assertEqual(v_cbs_mono[0].text, "0.00") + + # Reten/Ret children are required by schema TIBSCBSMonoTot/gMono even + # when the items only carry (PyNFe item-level Reten/Ret + # serialization is not implemented yet — totals stay at zero). + v_ibs_mono_reten = xml.xpath("//ns:IBSCBSTot/ns:gMono/ns:vIBSMonoReten", namespaces=self.ns) + self.assertEqual(v_ibs_mono_reten[0].text, "0.00") + v_cbs_mono_reten = xml.xpath("//ns:IBSCBSTot/ns:gMono/ns:vCBSMonoReten", namespaces=self.ns) + self.assertEqual(v_cbs_mono_reten[0].text, "0.00") + v_ibs_mono_ret = xml.xpath("//ns:IBSCBSTot/ns:gMono/ns:vIBSMonoRet", namespaces=self.ns) + self.assertEqual(v_ibs_mono_ret[0].text, "0.00") + v_cbs_mono_ret = xml.xpath("//ns:IBSCBSTot/ns:gMono/ns:vCBSMonoRet", namespaces=self.ns) + self.assertEqual(v_cbs_mono_ret[0].text, "0.00") + + # Field order must match TIBSCBSMonoTot/gMono per + # DFeTiposBasicos_v1.00.xsd: vIBSMono -> vCBSMono -> vIBSMonoReten -> + # vCBSMonoReten -> vIBSMonoRet -> vCBSMonoRet. + gmono_elem = g_mono[0] + gmono_children = [child.tag.split("}")[-1] for child in gmono_elem] + self.assertEqual( + gmono_children, + [ + "vIBSMono", + "vCBSMono", + "vIBSMonoReten", + "vCBSMonoReten", + "vIBSMonoRet", + "vCBSMonoRet", + ], + ) + + def test_ibscbstot_gmono_soma_multiplos_itens_monofasicos(self): + """Two items must sum into IBSCBSTot//.""" + emitente = self._emitente() + cliente = self._cliente() + nf = self._nota_fiscal(emitente, cliente) + + item1 = self._base_product_kwargs() + item1.update( + codigo="021", + descricao="Combustivel mono A", + ibscbs_cst="620", + ibscbs_c_class_trib="620001", + ibscbs_q_bc_mono=Decimal("100.0000"), + ibscbs_ad_rem_ibs=Decimal("0.1500"), + ibscbs_v_ibs_mono=Decimal("15.00"), + ibscbs_ad_rem_cbs=Decimal("0.8500"), + ibscbs_v_cbs_mono=Decimal("85.00"), + ibscbs_v_tot_ibs_mono_item=Decimal("15.00"), + ibscbs_v_tot_cbs_mono_item=Decimal("85.00"), + ) + item2 = self._base_product_kwargs() + item2.update( + codigo="022", + descricao="Combustivel mono B", + ibscbs_cst="620", + ibscbs_c_class_trib="620002", + ibscbs_q_bc_mono=Decimal("50.0000"), + ibscbs_ad_rem_ibs=Decimal("0.2000"), + ibscbs_v_ibs_mono=Decimal("10.00"), + ibscbs_ad_rem_cbs=Decimal("0.4000"), + ibscbs_v_cbs_mono=Decimal("20.00"), + ibscbs_v_tot_ibs_mono_item=Decimal("10.00"), + ibscbs_v_tot_cbs_mono_item=Decimal("20.00"), + ) + nf.adicionar_produto_servico(**item1) + nf.adicionar_produto_servico(**item2) + nf.adicionar_pagamento(t_pag="01", x_pag="Dinheiro", v_pag=2000.00, ind_pag=0) + + xml = self._serializar_e_assinar() + + v_ibs_mono = xml.xpath("//ns:IBSCBSTot/ns:gMono/ns:vIBSMono", namespaces=self.ns) + self.assertEqual(v_ibs_mono[0].text, "25.00") # 15.00 + 10.00 + v_cbs_mono = xml.xpath("//ns:IBSCBSTot/ns:gMono/ns:vCBSMono", namespaces=self.ns) + self.assertEqual(v_cbs_mono[0].text, "105.00") # 85.00 + 20.00 + + def test_ibscbstot_sem_gmono_quando_so_itens_padrao(self): + """Pure standard NF-e (no items) must NOT emit .""" + emitente = self._emitente() + cliente = self._cliente() + nf = self._nota_fiscal(emitente, cliente) + + kwargs = self._base_product_kwargs() + kwargs.update( + ibscbs_cst="000", + ibscbs_c_class_trib="000001", + ibscbs_vbc=Decimal("1000.00"), + ibscbs_p_ibs_uf=Decimal("0.1000"), + ibscbs_v_ibs_uf=Decimal("1.00"), + ibscbs_p_ibs_mun=Decimal("0.0000"), + ibscbs_v_ibs_mun=Decimal("0.00"), + ibscbs_v_ibs=Decimal("1.00"), + ibscbs_p_cbs=Decimal("0.9000"), + ibscbs_v_cbs=Decimal("9.00"), + ) + nf.adicionar_produto_servico(**kwargs) + nf.adicionar_pagamento(t_pag="01", x_pag="Dinheiro", v_pag=1000.00, ind_pag=0) + + xml = self._serializar_e_assinar() + + # IBSCBSTot is present (we have standard reforma values) + ibscbs_tot = xml.xpath("//ns:total/ns:IBSCBSTot", namespaces=self.ns) + self.assertEqual(len(ibscbs_tot), 1) + # But gMono must NOT be there for a pure-standard NF-e + g_mono = xml.xpath("//ns:IBSCBSTot/ns:gMono", namespaces=self.ns) + self.assertEqual(len(g_mono), 0) + + def test_ibscbstot_misto_emite_gibs_gcbs_e_gmono(self): + """Mixed NF-e (1 standard + 1 mono) emits gIBS, gCBS AND gMono.""" + emitente = self._emitente() + cliente = self._cliente() + nf = self._nota_fiscal(emitente, cliente) + + item_padrao = self._base_product_kwargs() + item_padrao.update( + codigo="030", + descricao="Item padrao", + ibscbs_cst="000", + ibscbs_c_class_trib="000001", + ibscbs_vbc=Decimal("1000.00"), + ibscbs_p_ibs_uf=Decimal("0.1000"), + ibscbs_v_ibs_uf=Decimal("1.00"), + ibscbs_p_ibs_mun=Decimal("0.0000"), + ibscbs_v_ibs_mun=Decimal("0.00"), + ibscbs_v_ibs=Decimal("1.00"), + ibscbs_p_cbs=Decimal("0.9000"), + ibscbs_v_cbs=Decimal("9.00"), + ) + item_mono = self._base_product_kwargs() + item_mono.update( + codigo="031", + descricao="Item monofasico", + ibscbs_cst="620", + ibscbs_c_class_trib="620001", + ibscbs_q_bc_mono=Decimal("10.0000"), + ibscbs_ad_rem_ibs=Decimal("0.5000"), + ibscbs_v_ibs_mono=Decimal("5.00"), + ibscbs_ad_rem_cbs=Decimal("1.0000"), + ibscbs_v_cbs_mono=Decimal("10.00"), + ibscbs_v_tot_ibs_mono_item=Decimal("5.00"), + ibscbs_v_tot_cbs_mono_item=Decimal("10.00"), + ) + nf.adicionar_produto_servico(**item_padrao) + nf.adicionar_produto_servico(**item_mono) + nf.adicionar_pagamento(t_pag="01", x_pag="Dinheiro", v_pag=2000.00, ind_pag=0) + + xml = self._serializar_e_assinar() + + # Standard groups present (gIBS, gCBS) AND gMono present + self.assertEqual(len(xml.xpath("//ns:IBSCBSTot/ns:gIBS", namespaces=self.ns)), 1) + self.assertEqual(len(xml.xpath("//ns:IBSCBSTot/ns:gCBS", namespaces=self.ns)), 1) + self.assertEqual(len(xml.xpath("//ns:IBSCBSTot/ns:gMono", namespaces=self.ns)), 1) + + # gMono carries the mono item totals + v_ibs_mono = xml.xpath("//ns:IBSCBSTot/ns:gMono/ns:vIBSMono", namespaces=self.ns) + self.assertEqual(v_ibs_mono[0].text, "5.00") + v_cbs_mono = xml.xpath("//ns:IBSCBSTot/ns:gMono/ns:vCBSMono", namespaces=self.ns) + self.assertEqual(v_cbs_mono[0].text, "10.00") + + # IBSCBSTot direct-child order per TIBSCBSMonoTot: + # vBCIBSCBS -> gIBS -> gCBS -> gMono (gEstornoCred not implemented) + ibscbs_tot_elem = xml.xpath("//ns:IBSCBSTot", namespaces=self.ns)[0] + ibscbs_tot_children = [child.tag.split("}")[-1] for child in ibscbs_tot_elem] + self.assertEqual(ibscbs_tot_children, ["vBCIBSCBS", "gIBS", "gCBS", "gMono"]) + # ------------------------------------------------------------------ # Test 10: cMunFGIBS NOT emitted when not set # ------------------------------------------------------------------ From 8c3b9469138273cebf90ba8dfcf79e3de8166dd0 Mon Sep 17 00:00:00 2001 From: Felipe Correa Date: Tue, 16 Jun 2026 21:52:51 -0300 Subject: [PATCH 05/10] fix(nfce): atualiza host do QRCode de GO para nfeweb (IT 2025.003) [DEV-2177] (#6) O Informe Tecnico 2025.003 trocou o host de consulta da NFC-e de Goias de `nfe.sefaz.go.gov.br` / `homolog.sefaz.go.gov.br` para `nfeweb.sefaz.go.gov.br` (producao) e `nfewebhomolog.sefaz.go.gov.br` (homologacao). Emitir com o host antigo gera rejeicao 395 no SEFAZ-GO. Ajusta os prefixos HTTPS/HOMOLOGACAO do dict NFCE["GO"], que alimentam tanto o quanto o via SerializacaoQrcode.gerar_qrcode. Segue a mesma convencao de subdominio-por-UF ja usada por RS/MS no mesmo dict, sem mudanca na logica de serializacao. Adiciona teste de regressao cobrindo producao e homologacao para GO. Co-authored-by: Claude Opus 4.8 (1M context) --- pynfe/utils/webservices.py | 4 +- tests/test_nfce_qrcode_go.py | 95 ++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 tests/test_nfce_qrcode_go.py diff --git a/pynfe/utils/webservices.py b/pynfe/utils/webservices.py index fc3360a4..d78bae4b 100644 --- a/pynfe/utils/webservices.py +++ b/pynfe/utils/webservices.py @@ -273,8 +273,8 @@ "EVENTOS": "sefaz.go.gov.br/nfe/services/NFeRecepcaoEvento4?wsdl", "QR": "sefaz.go.gov.br/nfeweb/sites/nfce/danfeNFCe?", "CADASTRO": "sefaz.go.gov.br/nfe/services/CadConsultaCadastro4?wsdl", - "HTTPS": "https://nfe.", - "HOMOLOGACAO": "https://homolog.", + "HTTPS": "https://nfeweb.", + "HOMOLOGACAO": "https://nfewebhomolog.", "URL": "sefaz.go.gov.br/nfeweb/sites/nfce/danfeNFCe", }, "DF": { diff --git a/tests/test_nfce_qrcode_go.py b/tests/test_nfce_qrcode_go.py new file mode 100644 index 00000000..85c775c5 --- /dev/null +++ b/tests/test_nfce_qrcode_go.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python +# *-* encoding: utf8 *-* +"""Regression tests for the Goias (GO) NFC-e QRCode/urlChave host. + +Informe Tecnico 2025.003 moved the GO NFC-e consultation host from the old +``nfe.sefaz.go.gov.br`` / ``homolog.sefaz.go.gov.br`` subdomains to +``nfeweb.sefaz.go.gov.br`` (producao) and ``nfewebhomolog.sefaz.go.gov.br`` +(homologacao). Emitting with the old host triggers SEFAZ rejeicao 395 +("Informado QR-Code para NFC-e com formato invalido"). These tests pin both +the ```` base and the ```` so the host can never silently +regress to the rejected subdomain again. See DEV-2177. +""" + +import unittest + +from lxml import etree + +from pynfe.processamento.serializacao import SerializacaoQrcode +from pynfe.utils.flags import NAMESPACE_NFE, NAMESPACE_SIG + +# cUF for Goias. +CUF_GO = "52" +# Producao = 1, Homologacao = 2 (NFe ide/tpAmb). +TP_AMB_PRODUCAO = "1" +TP_AMB_HOMOLOGACAO = "2" + +QRCODE_HOST_PRODUCAO = "https://nfeweb.sefaz.go.gov.br/" +QRCODE_HOST_HOMOLOGACAO = "https://nfewebhomolog.sefaz.go.gov.br/" + + +class QrcodeGoNFCeTestCase(unittest.TestCase): + """Exercises ``SerializacaoQrcode.gerar_qrcode`` for the GO branch.""" + + def _build_nfe_go(self, tp_amb): + """Build a minimal signed-NFe etree for GO with the fields gerar_qrcode reads.""" + chave = CUF_GO + "0" * 42 # 44-digit access key starting with cUF 52. + xml = ( + f'' + f'' + f"" + f"{CUF_GO}" + f"2025-01-14T12:00:00-03:00" + f"{tp_amb}" + f"" + f"12345678900" + f"10.00" + f"" + f'' + f"" + f"ABCDEF==" + f"" + f"" + f"" + ) + return etree.fromstring(xml.encode()) + + def _gerar(self, tp_amb): + nfe = self._build_nfe_go(tp_amb) + nfe, qrcode = SerializacaoQrcode().gerar_qrcode("000001", "CSC123", nfe, return_qr=True) + supl = nfe.find("infNFeSupl") + self.assertIsNotNone(supl, "infNFeSupl deve ser inserido") + url_chave = supl.find("urlChave").text + # qrCode esta dentro de CDATA; o valor retornado por gerar_qrcode e o mesmo texto. + return qrcode, url_chave + + def test_qrcode_producao_usa_host_nfeweb(self): + qrcode, url_chave = self._gerar(TP_AMB_PRODUCAO) + self.assertTrue( + qrcode.startswith(QRCODE_HOST_PRODUCAO), + f"qrCode de producao deve comecar com {QRCODE_HOST_PRODUCAO}, veio: {qrcode}", + ) + self.assertTrue( + url_chave.startswith(QRCODE_HOST_PRODUCAO), + f"urlChave de producao deve comecar com {QRCODE_HOST_PRODUCAO}, veio: {url_chave}", + ) + # Garante que nao reverteu para o subdominio rejeitado (rejeicao 395). + self.assertNotIn("https://nfe.sefaz.go.gov.br", qrcode) + self.assertNotIn("https://nfe.sefaz.go.gov.br", url_chave) + + def test_qrcode_homologacao_usa_host_nfewebhomolog(self): + qrcode, url_chave = self._gerar(TP_AMB_HOMOLOGACAO) + self.assertTrue( + qrcode.startswith(QRCODE_HOST_HOMOLOGACAO), + f"qrCode de homologacao deve comecar com {QRCODE_HOST_HOMOLOGACAO}, veio: {qrcode}", + ) + self.assertTrue( + url_chave.startswith(QRCODE_HOST_HOMOLOGACAO), + f"urlChave de homologacao deve comecar com {QRCODE_HOST_HOMOLOGACAO}, veio: {url_chave}", + ) + self.assertNotIn("https://homolog.sefaz.go.gov.br", qrcode) + self.assertNotIn("https://homolog.sefaz.go.gov.br", url_chave) + + +if __name__ == "__main__": + unittest.main() From 04749688a797f5216fcfd33146de76d6d5e331ac Mon Sep 17 00:00:00 2001 From: Felipe Correa Date: Fri, 3 Jul 2026 19:59:27 -0300 Subject: [PATCH 06/10] fix(mdfe): envio sincrono via gzip+base64 e omissao de valePed vazio [DEV-2266] (#7) - MDFeRecepcaoSinc agora envia o XML assinado do compactado com gzip e codificado em base64 como texto de mdfeDadosMsg, conforme o MOC do MDF-e (o envelope / vale apenas para o envio assincrono). SVRS retornava HTTP 400 com corpo vazio para o formato antigo. - Resposta sincrona parseada: retorna (0, mdfeProc) quando autorizado e (1, retMDFe, manifesto) em rejeicao (tanto no nivel do retMDFe, ex. 580, quanto no nivel do protMDFe/infProt); resposta HTTP crua so quando o corpo nao e parseavel. - Content-Type application/soap+xml para o servico sincrono (SOAP 1.2). - Serializacao: omite vazio (rejeitado com cStat 580 pelo schema do modal) e omite quando nenhum filho e serializado. --- pynfe/processamento/comunicacao.py | 130 ++++++++++-------- pynfe/processamento/serializacao.py | 9 +- tests/test_mdfe_comunicacao_sinc.py | 199 ++++++++++++++++++++++++++++ 3 files changed, 280 insertions(+), 58 deletions(-) create mode 100644 tests/test_mdfe_comunicacao_sinc.py diff --git a/pynfe/processamento/comunicacao.py b/pynfe/processamento/comunicacao.py index 14234649..edb3a9f5 100644 --- a/pynfe/processamento/comunicacao.py +++ b/pynfe/processamento/comunicacao.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +import base64 import datetime +import gzip import re import requests @@ -876,39 +878,34 @@ def autorizacao(self, manifesto, id_lote=1, ind_sinc=1): Método para realizar autorização do manifesto :param manifesto: XML assinado :param id_lote: Id do lote - numero autoincremental gerado pelo sistema + (usado apenas no envio assincrono) :param ind_sinc: Indicador de sincrono e assincrono, 0 para assincrono, 1 para sincrono :return: Uma tupla que em caso de sucesso, retorna xml com manifesto e protocolo de autorização. - Caso contrário, envia todo o soap de resposta da Sefaz para decisão do usuário. + Caso contrário, retorna o retMDFe parseado (quando a SEFAZ respondeu) ou a + resposta HTTP crua para decisão do usuário. """ - # url do serviço - if ind_sinc == 0: - url = self._get_url(consulta="RECEPCAO") - elif ind_sinc == 1: - url = self._get_url(consulta="RECEPCAO_SINC") - else: - raise "ind_sinc deve ser 0=assincrono ou 1=sincrono" + if ind_sinc == 1: + return self._autorizacao_sincrona(manifesto) + if ind_sinc != 0: + raise ValueError("ind_sinc deve ser 0=assincrono ou 1=sincrono") + + # Envio assincrono: lote enviMDFe para o servico MDFeRecepcao + url = self._get_url(consulta="RECEPCAO") # Monta XML do corpo da requisição raiz = etree.Element("enviMDFe", xmlns=NAMESPACE_MDFE, versao=VERSAO_MDFE) etree.SubElement(raiz, "idLote").text = str( id_lote ) # numero autoincremental gerado pelo sistema - # etree.SubElement(raiz, 'indSinc').text = str(ind_sinc) - # # 0 para assincrono, 1 para sincrono raiz.append(manifesto) # Monta XML para envio da requisição - if ind_sinc == 0: - xml = self._construir_xml_soap("MDFeRecepcao", raiz) - elif ind_sinc == 1: - xml = self._construir_xml_soap("MDFeRecepcaoSinc", raiz) + xml = self._construir_xml_soap("MDFeRecepcao", raiz) # Faz request no Servidor da Sefaz retorno = self._post(url, xml) - # Em caso de sucesso, retorna xml com o mdfe e protocolo de autorização. - # Caso contrário, envia todo o soap de resposta da Sefaz para decisão do usuário. if retorno.status_code == 200: # namespace ns = {"ns": NAMESPACE_MDFE} @@ -919,41 +916,59 @@ def autorizacao(self, manifesto, id_lote=1, ind_sinc=1): # em SP retorno.text apresenta erro prot = etree.fromstring(retorno.content) - if ind_sinc == 1: - try: - # Protocolo com envio OK - inf_prot = prot[1][0] - lote_status = inf_prot.xpath("ns:retEnviMDFe/ns:cStat", namespaces=ns)[0].text + # Retorna id do protocolo para posterior consulta em caso de sucesso. + rec = prot[1][0] + status = rec.xpath("ns:retEnviMDFe/ns:cStat", namespaces=ns)[0].text + # Lote Recebido com Sucesso! + if status in ( + self._edoc_situacao_arquivo_recebido_com_sucesso, + self._edoc_situacao_em_processamento, + ): + nrec = rec.xpath("ns:retEnviMDFe/ns:infRec/ns:nRec", namespaces=ns)[0].text + return 0, nrec, manifesto + return 1, retorno, manifesto - # Lote processado - if lote_status == self._edoc_situacao_lote_processado: - prot_mdfe = inf_prot.xpath("ns:retEnviMDFe/ns:protMDFe", namespaces=ns)[0] - status = prot_mdfe.xpath("ns:infProt/ns:cStat", namespaces=ns)[0].text + def _autorizacao_sincrona(self, manifesto): + """Envia o MDF-e para o servico sincrono MDFeRecepcaoSinc. - # autorizado uso do MDF-e - # retorna xml final (protMDFe + MDFe) - if status in self._edoc_situacao_ja_enviado: # if status == '100': - raiz = etree.Element( - "mdfeProc", xmlns=NAMESPACE_MDFE, versao=VERSAO_MDFE - ) - raiz.append(manifesto) - raiz.append(prot_mdfe) - return 0, raiz - except IndexError: - # Protocolo com algum erro no Envio - return 1, retorno, manifesto - else: - # Retorna id do protocolo para posterior consulta em caso de sucesso. - rec = prot[1][0] - status = rec.xpath("ns:retEnviMDFe/ns:cStat", namespaces=ns)[0].text - # Lote Recebido com Sucesso! - if status in ( - self._edoc_situacao_arquivo_recebido_com_sucesso, - self._edoc_situacao_em_processamento, - ): - nrec = rec.xpath("ns:retEnviMDFe/ns:infRec/ns:nRec", namespaces=ns)[0].text - return 0, nrec, manifesto - return 1, retorno, manifesto + Conforme o MOC do MDF-e, o servico de recepcao sincrona recebe o XML + assinado do compactado com gzip e codificado em base64 como + conteudo texto de mdfeDadosMsg — sem o envelope / + usado no envio assincrono. + """ + url = self._get_url(consulta="RECEPCAO_SINC") + + mdfe_compactado = base64.b64encode(gzip.compress(etree.tostring(manifesto))).decode("ascii") + xml = self._construir_xml_soap("MDFeRecepcaoSinc", mdfe_compactado) + + retorno = self._post(url, xml, content_type=b"application/soap+xml; charset=utf-8;") + + if retorno.status_code != 200: + return 1, retorno, manifesto + + ns = {"ns": NAMESPACE_MDFE} + try: + envelope = etree.fromstring(retorno.content) + ret_mdfe = envelope.xpath("//ns:retMDFe", namespaces=ns)[0] + except (ValueError, IndexError, etree.XMLSyntaxError): + # Resposta nao parseavel: devolve a resposta HTTP crua + return 1, retorno, manifesto + + # Rejeicao pode vir no nivel do retMDFe (ex.: 580, sem protMDFe) + # ou no nivel do protMDFe/infProt. + prot_mdfe_list = ret_mdfe.xpath("ns:protMDFe", namespaces=ns) + if prot_mdfe_list: + prot_mdfe = prot_mdfe_list[0] + status = prot_mdfe.xpath("ns:infProt/ns:cStat", namespaces=ns) + # autorizado uso do MDF-e: retorna xml final (MDFe + protMDFe) + if status and status[0].text in self._edoc_situacao_ja_enviado: + raiz = etree.Element("mdfeProc", xmlns=NAMESPACE_MDFE, versao=VERSAO_MDFE) + raiz.append(manifesto) + raiz.append(prot_mdfe) + return 0, raiz + + # Devolve o retMDFe parseado para o chamador consultar cStat/xMotivo + return 1, ret_mdfe, manifesto def status_servico(self): url = self._get_url("STATUS") @@ -1037,16 +1052,17 @@ def _construir_xml_soap(self, metodo, dados): a = etree.SubElement(body, self._envio_mensagem, xmlns=self._namespace_metodo + metodo) - # if metodo == 'MDFeRecepcaoSinc': - # body_base64 = base64.b16encode(a).decode() - - a.append(dados) + if isinstance(dados, str): + # payload compactado (gzip+base64) vai como texto de mdfeDadosMsg + a.text = dados + else: + a.append(dados) return raiz - def _post_header(self, soap_webservice_method=False): + def _post_header(self, soap_webservice_method=False, content_type=None): """Retorna um dicionário com os atributos para o cabeçalho da requisição HTTP""" header = { - b"content-type": b"text/xml; charset=utf-8;", + b"content-type": content_type or b"text/xml; charset=utf-8;", } # PE é a únca UF que exige SOAPAction no header @@ -1060,7 +1076,7 @@ def _post_header(self, soap_webservice_method=False): return header - def _post(self, url, xml): + def _post(self, url, xml, content_type=None): certificado_a1 = CertificadoA1(self.certificado) chave, cert = certificado_a1.separar_arquivo(self.certificado_senha, caminho=True) chave_cert = (cert, chave) @@ -1084,7 +1100,7 @@ def _post(self, url, xml): result = requests.post( url, xml, - headers=self._post_header(), + headers=self._post_header(content_type=content_type), cert=chave_cert, verify=False, timeout=50, diff --git a/pynfe/processamento/serializacao.py b/pynfe/processamento/serializacao.py index c94aec2e..1cf8dad5 100644 --- a/pynfe/processamento/serializacao.py +++ b/pynfe/processamento/serializacao.py @@ -2561,7 +2561,9 @@ def _serializar_modal_rodoviario( etree.SubElement(infCIOT, "CNPJ").text = item.cpfcnpj # Vale Pedágio - if modal_rodoviario.pedagio is not None: + # valePed must be omitted when there are no disp entries: an empty + # is rejected by the MDF-e modal schema (cStat 580). + if modal_rodoviario.pedagio: valePed = etree.SubElement(infANTT, "valePed") for num, item in enumerate(modal_rodoviario.pedagio): disp = etree.SubElement(valePed, "disp") @@ -2593,6 +2595,11 @@ def _serializar_modal_rodoviario( item.vContratoGlobal or 0 ) + # infANTT is fully optional: omit it when no child was serialized + # (e.g. carga própria without RNTRC/CIOT/vale-pedágio/contratantes). + if len(infANTT) == 0: + rodo.remove(infANTT) + # Veículo Tração if len(modal_rodoviario.veiculo_tracao) != 1: raise "Permitido somente um único veículo Tração" diff --git a/tests/test_mdfe_comunicacao_sinc.py b/tests/test_mdfe_comunicacao_sinc.py new file mode 100644 index 00000000..ed6ae4be --- /dev/null +++ b/tests/test_mdfe_comunicacao_sinc.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python +# *-* encoding: utf8 *-* +"""Testes do envio sincrono do MDF-e (MDFeRecepcaoSinc) e da serializacao do valePed.""" + +import base64 +import gzip +import unittest +from decimal import Decimal +from unittest import mock + +from pynfe.entidades.manifesto import ( + ManifestoCondutor, + ManifestoRodoviario, + ManifestoVeiculoTracao, +) +from pynfe.processamento.comunicacao import ComunicacaoMDFe +from pynfe.processamento.serializacao import SerializacaoMDFe +from pynfe.utils import etree +from pynfe.utils.flags import NAMESPACE_MDFE + +NS = {"ns": NAMESPACE_MDFE} + + +def _fake_response(status_code, text): + response = mock.Mock() + response.status_code = status_code + response.text = text + response.content = text.encode("utf-8") + return response + + +def _soap_response(ret_mdfe_xml): + return ( + '' + '' + "" + '' + + ret_mdfe_xml + + "" + "" + "" + ) + + +def _manifesto_assinado_fake(): + mdfe = etree.Element("MDFe", xmlns=NAMESPACE_MDFE) + inf = etree.SubElement( + mdfe, "infMDFe", Id="MDFe51210199999999000199580009200000000011000000008" + ) + etree.SubElement(inf, "ide") + return mdfe + + +class ComunicacaoMDFeRecepcaoSincTestCase(unittest.TestCase): + def setUp(self): + self.con = ComunicacaoMDFe("rj", "certificado.pfx", "senha", homologacao=True) + self.manifesto = _manifesto_assinado_fake() + + def test_envelope_sinc_envia_mdfe_gzip_base64_como_texto(self): + """O servico sincrono exige o MDFe gzip+base64 como texto de mdfeDadosMsg.""" + with mock.patch.object(self.con, "_post") as post: + post.return_value = _fake_response(400, "") + self.con.autorizacao(self.manifesto, ind_sinc=1) + + (url, envelope), kwargs = post.call_args + self.assertIn("RecepcaoSinc", url) + self.assertEqual(kwargs.get("content_type"), b"application/soap+xml; charset=utf-8;") + + dados_msg = envelope.xpath("//*[local-name()='Body']/*[local-name()='mdfeDadosMsg']")[0] + # sem envelope enviMDFe/idLote e sem elementos filhos + self.assertEqual(len(dados_msg), 0) + payload = gzip.decompress(base64.b64decode(dados_msg.text)) + self.assertEqual(payload, etree.tostring(self.manifesto)) + raiz_payload = etree.fromstring(payload) + self.assertEqual(etree.QName(raiz_payload).localname, "MDFe") + + def test_sinc_autorizado_retorna_mdfe_proc(self): + ret = ( + '' + "233104Lote processado" + '2100' + "Autorizado o uso do MDF-e" + "933210000000000" + "" + ) + with mock.patch.object(self.con, "_post") as post: + post.return_value = _fake_response(200, _soap_response(ret)) + resultado = self.con.autorizacao(self.manifesto, ind_sinc=1) + + self.assertEqual(resultado[0], 0) + proc = resultado[1] + self.assertEqual(etree.QName(proc).localname, "mdfeProc") + self.assertEqual( + proc.xpath("ns:protMDFe/ns:infProt/ns:cStat", namespaces=NS)[0].text, "100" + ) + self.assertEqual(len(proc.xpath("*[local-name()='MDFe']")), 1) + + def test_sinc_rejeicao_nivel_retmdfe_sem_protmdfe(self): + """Rejeicao 580 (falha de schema do modal) vem sem protMDFe.""" + ret = ( + '' + "233580" + "Rejeição: Falha no Schema XML específico para o modal" + "" + ) + with mock.patch.object(self.con, "_post") as post: + post.return_value = _fake_response(200, _soap_response(ret)) + resultado = self.con.autorizacao(self.manifesto, ind_sinc=1) + + self.assertEqual(resultado[0], 1) + ret_mdfe = resultado[1] + self.assertEqual(etree.QName(ret_mdfe).localname, "retMDFe") + self.assertEqual(ret_mdfe.xpath("ns:cStat", namespaces=NS)[0].text, "580") + + def test_sinc_rejeicao_nivel_protmdfe(self): + ret = ( + '' + "233104Lote processado" + '2204' + "Rejeição: Duplicidade de MDF-e" + "" + ) + with mock.patch.object(self.con, "_post") as post: + post.return_value = _fake_response(200, _soap_response(ret)) + resultado = self.con.autorizacao(self.manifesto, ind_sinc=1) + + self.assertEqual(resultado[0], 1) + ret_mdfe = resultado[1] + self.assertEqual(etree.QName(ret_mdfe).localname, "retMDFe") + self.assertEqual( + ret_mdfe.xpath("ns:protMDFe/ns:infProt/ns:cStat", namespaces=NS)[0].text, "204" + ) + + def test_sinc_http_400_corpo_vazio_retorna_response_crua(self): + with mock.patch.object(self.con, "_post") as post: + post.return_value = _fake_response(400, "") + resultado = self.con.autorizacao(self.manifesto, ind_sinc=1) + + self.assertEqual(resultado[0], 1) + self.assertIs(resultado[1], post.return_value) + + def test_sinc_corpo_nao_parseavel_retorna_response_crua(self): + with mock.patch.object(self.con, "_post") as post: + post.return_value = _fake_response(200, "isso nao e xml") + resultado = self.con.autorizacao(self.manifesto, ind_sinc=1) + + self.assertEqual(resultado[0], 1) + self.assertIs(resultado[1], post.return_value) + + +class SerializacaoValePedTestCase(unittest.TestCase): + def _modal(self, **kwargs): + condutor = ManifestoCondutor(nome_motorista="JOAO DA SILVA", cpf_motorista="12345678912") + veiculo_tracao = [ + ManifestoVeiculoTracao( + cInt="001", + placa="ABC1234", + RENAVAM="123456789", + tara=Decimal("5000"), + capKG=Decimal("4500"), + capM3=Decimal("400"), + proprietario=None, + condutor=[condutor], + tpRod="01", + tpCar="02", + UF="MT", + ) + ] + params = dict( + rntrc=None, + ciot=[], + pedagio=[], + contratante=[], + pagamento=None, + veiculo_tracao=veiculo_tracao, + veiculo_reboque=[], + ) + params.update(kwargs) + return ManifestoRodoviario(**params) + + def _serializar(self, modal): + serializador = SerializacaoMDFe.__new__(SerializacaoMDFe) + serializador._versao = "3.00" + return serializador._serializar_modal_rodoviario(modal, retorna_string=False) + + def test_vale_ped_omitido_sem_disp(self): + """valePed vazio e rejeitado pelo schema do modal (cStat 580) e deve ser omitido.""" + raiz = self._serializar(self._modal(rntrc="12345678")) + self.assertEqual(len(raiz.xpath("rodo/infANTT/valePed")), 0) + self.assertEqual(len(raiz.xpath("rodo/infANTT/RNTRC")), 1) + + def test_inf_antt_omitido_quando_totalmente_vazio(self): + raiz = self._serializar(self._modal()) + self.assertEqual(len(raiz.xpath("rodo/infANTT")), 0) + self.assertEqual(len(raiz.xpath("rodo/veicTracao")), 1) + + +if __name__ == "__main__": + unittest.main() From b613366aa3e2207177c918775fb82b75a0145799 Mon Sep 17 00:00:00 2001 From: Felipe Correa Date: Wed, 12 Aug 2026 13:20:38 -0300 Subject: [PATCH 07/10] fix(nfce): NFC-e de GO transmitida para o portal de consulta em vez do autorizador [DEV-2468] (#8) * fix(nfce): separa host de QRCode do host de webservice em GO [DEV-2468] As chaves HTTPS/HOMOLOGACAO de NFCE["GO"] eram lidas por dois consumidores: SerializacaoQrcode.gerar_qrcode (host do portal de consulta) e ComunicacaoSefaz._get_url (host do autorizador). Como GO usa servidores diferentes para os dois papeis, atualizar o host do QRCode para nfeweb (IT 2025.003) passou a POSTar toda autorizacao de NFC-e para o portal de consulta, que responde redirect + HTML - a nota nunca recebia veredito da SEFAZ e a falha aparecia como erro de transporte, sem rejeicao. Agora HTTPS/HOMOLOGACAO voltam ao host do autorizador (identicos a NFE["GO"]) e QR_HOST/QR_HOST_HOMOLOGACAO carregam o host do portal de consulta, lidos apenas pelo novo helper qrcode_host. SP e AM tambem passam a ler o host de QR pelo helper, com valores que preservam a saida byte a byte. Nenhuma UF fora de GO muda de comportamento. * docs(webservices): registra distincao entre host de consulta e de autorizador [DEV-2468] Documenta as duas familias de chave de host em webservices.py (autorizador vs portal de consulta) no AGENTS.md e no source map, e atualiza as contagens de linha dos mapas de webservices.py e serializacao.py. * test(nfce): trava hosts de qrCode e urlChave de SP, AM, BA e MG [DEV-2468] --- AGENTS.md | 11 +- docs/serializacao_map.md | 2 +- docs/webservices_map.md | 24 +++- pynfe/processamento/serializacao.py | 44 +++----- pynfe/utils/webservices.py | 43 +++++++- .../test_comunicacao_url_nfce_go.py | 91 ++++++++++++++++ tests/test_nfce_qrcode_hosts_por_uf.py | 103 ++++++++++++++++++ 7 files changed, 279 insertions(+), 39 deletions(-) create mode 100644 tests/processamento/test_comunicacao_url_nfce_go.py create mode 100644 tests/test_nfce_qrcode_hosts_por_uf.py diff --git a/AGENTS.md b/AGENTS.md index 63cc9e21..edf8299c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,14 +15,14 @@ This allows you to navigate directly to the specific line-window you need instea | Source Map | File | Lines | Description | |------------|------|-------|-------------| -| `docs/serializacao_map.md` | `pynfe/processamento/serializacao.py` | 2630 | XML serialization (NF-e, MDF-e, QR codes) | +| `docs/serializacao_map.md` | `pynfe/processamento/serializacao.py` | 2895 | XML serialization (NF-e, MDF-e, QR codes) | | `docs/comunicacao_map.md` | `pynfe/processamento/comunicacao.py` | 1348 | SEFAZ webservice communication | | `docs/autorizador_nfse_map.md` | `pynfe/processamento/autorizador_nfse.py` | 538 | NFS-e authorization (Betha/Ginfes) | | `docs/notafiscal_map.md` | `pynfe/entidades/notafiscal.py` | 1253 | Invoice entities and tax fields | | `docs/manifesto_map.md` | `pynfe/entidades/manifesto.py` | 447 | MDF-e manifest entities | | `docs/evento_map.md` | `pynfe/entidades/evento.py` | 237 | Event entities (cancel, correction, etc.) | | `docs/flags_map.md` | `pynfe/utils/flags.py` | 645 | Constants, namespaces, tax codes | -| `docs/webservices_map.md` | `pynfe/utils/webservices.py` | 572 | SEFAZ endpoint URLs by state | +| `docs/webservices_map.md` | `pynfe/utils/webservices.py` | 612 | SEFAZ endpoint URLs by state | | `docs/utils_map.md` | `pynfe/utils/__init__.py` | 253 | Utility functions (municipality lookup, signing) | ### How to Use Source Maps @@ -113,3 +113,10 @@ ruff format pynfe/ - The `pynfe/data/` directory contains reference data files that should not be modified casually - Tax code serialization follows strict SEFAZ XML schema ordering — field order matters - Each Brazilian state has its own SEFAZ endpoint configuration in `webservices.py` +- **A UF's consultation portal host and its authorizer host are different endpoints.** In + `webservices.py`, `HTTPS`/`HOMOLOGACAO` are authorizer host prefixes read only by + `ComunicacaoSefaz._get_url`; `QR_HOST`/`QR_HOST_HOMOLOGACAO` are consultation-portal host + prefixes read only by `qrcode_host` (used for ``/``). Never make one key + serve both roles: a webservice pointed at the consultation portal gets a redirect plus HTML + instead of a SEFAZ verdict, so emissions fail as transport errors with no rejeicao to explain + them. When a UF changes its consultation host, touch only the `QR_*` keys diff --git a/docs/serializacao_map.md b/docs/serializacao_map.md index f4594710..de7b0d6c 100644 --- a/docs/serializacao_map.md +++ b/docs/serializacao_map.md @@ -1,4 +1,4 @@ -# Source Map: `serializacao.py` (2771 lines) +# Source Map: `serializacao.py` (2895 lines) XML serialization of NF-e, NFC-e, NFS-e and MDF-e documents into SEFAZ-compliant XML format. diff --git a/docs/webservices_map.md b/docs/webservices_map.md index 5ab7bc9e..dc7e117d 100644 --- a/docs/webservices_map.md +++ b/docs/webservices_map.md @@ -1,4 +1,4 @@ -# Source Map: `webservices.py` (572 lines) +# Source Map: `webservices.py` (612 lines) SEFAZ webservice endpoint URLs organized by document type, state, and environment. @@ -22,10 +22,18 @@ Each state/virtual environment entry contains: - `INUTILIZACAO` — Number invalidation endpoint - `EVENTOS` — Event reception endpoint - `CADASTRO` — Registration query endpoint (some states) -- `HTTPS` — Production base URL prefix -- `HOMOLOGACAO` — Homologation base URL prefix -- `QR` — QR Code URL (NFC-e only) -- `URL` — Consultation URL (NFC-e only) +- `HTTPS` — Production base URL prefix of the AUTHORIZER (webservices; read only by + `ComunicacaoSefaz._get_url`) +- `HOMOLOGACAO` — Homologation base URL prefix of the AUTHORIZER +- `QR_HOST` / `QR_HOST_HOMOLOGACAO` — Base URL prefix of the CONSULTATION portal, used for + ``/`` (read only by `qrcode_host`); falls back to `HTTPS`/`HOMOLOGACAO` + for UFs that serve both roles from the same host +- `QR` — QR Code path (NFC-e only; `QR_HOMOLOGACAO` where the path differs per environment) +- `URL` — Consultation path (NFC-e only) + +The two host families must never be shared: a UF such as GO answers webservice POSTs sent to +its consultation host with a load-balancer redirect and HTML, so the invoice never receives a +SEFAZ verdict and the failure surfaces as a transport/XML-parse error, not a rejeicao. ## State/Virtual Environment Groups @@ -61,3 +69,9 @@ Only `SVRS` — single authorizer for all states. | Individual states | 524-558 | MT, MS, MG, PR, RS, SP | | `SVRS` | 560-565 | Virtual SEFAZ RS | | `SVSP` | 566-571 | Virtual SEFAZ SP (AP, PE, RR) | + +## Helpers + +| Function | Lines | Purpose | +|----------|-------|---------| +| `qrcode_host(uf, producao=True)` | 323-335 | Consultation-portal host prefix for ``/``, with fallback to the webservice host | diff --git a/pynfe/processamento/serializacao.py b/pynfe/processamento/serializacao.py index 1cf8dad5..ece0ab2d 100644 --- a/pynfe/processamento/serializacao.py +++ b/pynfe/processamento/serializacao.py @@ -25,7 +25,7 @@ VERSAO_PADRAO, VERSAO_QRCODE, ) -from pynfe.utils.webservices import MDFE, NFCE +from pynfe.utils.webservices import MDFE, NFCE, qrcode_host class Serializacao(object): @@ -2286,42 +2286,28 @@ def gerar_qrcode(self, token, csc, xml, return_qr=False, online=True): qrcode = NFCE[uf]["QR"] + url url_chave = NFCE[uf]["URL"] elif uf == "SP": - if tpamb == "1": - qrcode = NFCE[uf]["HTTPS"] + "www." + NFCE[uf]["QR"] + url - url_chave = NFCE[uf]["HTTPS"] + "www." + NFCE[uf]["URL"] - else: - qrcode = NFCE[uf]["HTTPS"] + "www.homologacao." + NFCE[uf]["QR"] + url - url_chave = NFCE[uf]["HTTPS"] + "www.homologacao." + NFCE[uf]["URL"] + host = qrcode_host(uf, producao=tpamb == "1") + qrcode = host + NFCE[uf]["QR"] + url + url_chave = host + NFCE[uf]["URL"] # BA tem comportamento distindo para qrcode e url elif uf == "BA": - if tpamb == "1": - qrcode = NFCE[uf]["HTTPS"] + NFCE[uf]["QR"] + url - else: - qrcode = NFCE[uf]["HOMOLOGACAO"] + NFCE[uf]["QR"] + url - url_chave = url_chave = NFCE[uf]["URL"] + qrcode = qrcode_host(uf, producao=tpamb == "1") + NFCE[uf]["QR"] + url + url_chave = NFCE[uf]["URL"] # MG tem comportamento distintos para qrcode e url elif uf == "MG": qrcode = NFCE[uf]["QR"] + url - if tpamb == "1": - url_chave = NFCE[uf]["HTTPS"] + NFCE[uf]["URL"] - else: - url_chave = NFCE[uf]["HOMOLOGACAO"] + NFCE[uf]["URL"] + url_chave = qrcode_host(uf, producao=tpamb == "1") + NFCE[uf]["URL"] # AM tem comportamento distintos para qrcode e url elif uf == "AM": - if tpamb == "1": - qrcode = NFCE[uf]["HTTPS"] + NFCE[uf]["QR"] + url - url_chave = NFCE[uf]["HTTPS"] + NFCE[uf]["URL"] - else: - qrcode = NFCE[uf]["HTTPS"] + NFCE[uf]["QR_HOMOLOGACAO"] + url - url_chave = NFCE[uf]["HTTPS"] + NFCE[uf]["URL"] - # AC, RR, PA, SE + host = qrcode_host(uf, producao=tpamb == "1") + caminho_qr = NFCE[uf]["QR"] if tpamb == "1" else NFCE[uf]["QR_HOMOLOGACAO"] + qrcode = host + caminho_qr + url + url_chave = host + NFCE[uf]["URL"] + # AC, RR, PA, SE, GO else: - if tpamb == "1": - qrcode = NFCE[uf]["HTTPS"] + NFCE[uf]["QR"] + url - url_chave = NFCE[uf]["HTTPS"] + NFCE[uf]["URL"] - else: - qrcode = NFCE[uf]["HOMOLOGACAO"] + NFCE[uf]["QR"] + url - url_chave = NFCE[uf]["HOMOLOGACAO"] + NFCE[uf]["URL"] + host = qrcode_host(uf, producao=tpamb == "1") + qrcode = host + NFCE[uf]["QR"] + url + url_chave = host + NFCE[uf]["URL"] # adicionta tag infNFeSupl com qrcode info = etree.Element("infNFeSupl") etree.SubElement(info, "qrCode").text = etree.CDATA(qrcode.strip()) diff --git a/pynfe/utils/webservices.py b/pynfe/utils/webservices.py index d78bae4b..49bcdcae 100644 --- a/pynfe/utils/webservices.py +++ b/pynfe/utils/webservices.py @@ -1,5 +1,21 @@ """ @author: Junior Tada, Leonardo Tada + +Host de CONSULTA vs host de AUTORIZADOR +--------------------------------------- +Numa UF, o portal publico de consulta (destino do ````/````) e o +autorizador (webservices SOAP com mTLS) podem ser servidores DIFERENTES. Por isso as +duas familias de chave sao separadas em ``NFCE`` e nunca devem ser reaproveitadas +uma pela outra: + +- ``HTTPS`` / ``HOMOLOGACAO``: prefixo de host do AUTORIZADOR, lido apenas por + ``ComunicacaoSefaz._get_url`` para montar URLs de webservice. +- ``QR_HOST`` / ``QR_HOST_HOMOLOGACAO``: prefixo de host do portal de CONSULTA, lido + apenas por ``qrcode_host`` (usado em ``SerializacaoQrcode.gerar_qrcode``). + +Apontar webservice para o host de consulta nao gera rejeicao: o portal responde +redirect + HTML, entao a nota nunca recebe veredito da SEFAZ. Ao atualizar o host de +consulta de uma UF, mexa somente nas chaves ``QR_*``. """ # http://nfce.encat.org/desenvolvedor/qrcode/ @@ -36,6 +52,8 @@ "URL": "sefaz.am.gov.br/nfceweb/formConsulta.do", "HTTPS": "https://sistemas.", "HOMOLOGACAO": "https://hom", + "QR_HOST": "https://sistemas.", + "QR_HOST_HOMOLOGACAO": "https://sistemas.", }, "RR": { "STATUS": "", @@ -209,6 +227,8 @@ "URL": "nfce.fazenda.sp.gov.br/consulta", "HTTPS": "https://", "HOMOLOGACAO": "https://homologacao.", + "QR_HOST": "https://www.", + "QR_HOST_HOMOLOGACAO": "https://www.homologacao.", }, "PR": { "STATUS": "nfce.sefa.pr.gov.br/nfce/NFeStatusServico4?wsdl", @@ -273,8 +293,12 @@ "EVENTOS": "sefaz.go.gov.br/nfe/services/NFeRecepcaoEvento4?wsdl", "QR": "sefaz.go.gov.br/nfeweb/sites/nfce/danfeNFCe?", "CADASTRO": "sefaz.go.gov.br/nfe/services/CadConsultaCadastro4?wsdl", - "HTTPS": "https://nfeweb.", - "HOMOLOGACAO": "https://nfewebhomolog.", + # Host do AUTORIZADOR (webservices SOAP, mTLS) - igual ao de NFE["GO"]. + "HTTPS": "https://nfe.", + "HOMOLOGACAO": "https://homolog.", + # Host do portal de CONSULTA (qrCode/urlChave) - IT 2025.003, DEV-2177. + "QR_HOST": "https://nfeweb.", + "QR_HOST_HOMOLOGACAO": "https://nfewebhomolog.", "URL": "sefaz.go.gov.br/nfeweb/sites/nfce/danfeNFCe", }, "DF": { @@ -295,6 +319,21 @@ }, } + +def qrcode_host(uf, producao=True): + """Prefixo de host do portal de consulta usado no ````/````. + + Prefere as chaves dedicadas ``QR_HOST``/``QR_HOST_HOMOLOGACAO``; para UFs que nao + declaram host de consulta proprio (portal e autorizador no mesmo servidor), cai no + host de webservice, preservando o comportamento historico. + """ + dados = NFCE[uf] + chave_qr = "QR_HOST" if producao else "QR_HOST_HOMOLOGACAO" + if dados.get(chave_qr): + return dados[chave_qr] + return dados["HTTPS"] if producao else dados["HOMOLOGACAO"] + + # Nfe # homologação => http://hom.nfe.fazenda.gov.br/PORTAL/WebServices.aspx # produção => https://www.nfe.fazenda.gov.br/portal/webServices.aspx diff --git a/tests/processamento/test_comunicacao_url_nfce_go.py b/tests/processamento/test_comunicacao_url_nfce_go.py new file mode 100644 index 00000000..7000eddd --- /dev/null +++ b/tests/processamento/test_comunicacao_url_nfce_go.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python +# *-* encoding: utf8 *-* +"""Regression tests for the GO NFC-e WEBSERVICE host (autorizador, not consulta portal). + +GO serves the public consultation portal (``nfeweb.sefaz.go.gov.br``, destination of +````/````) and the SOAP authorizer (``nfe.sefaz.go.gov.br``) from +different hosts. Pointing the webservice URL at the consultation portal does NOT produce +a SEFAZ rejeicao: the portal answers a BigIP redirect plus HTML, so the invoice never gets +a verdict at all and every emission fails as a transport error. These tests pin the +authorizer host for NFC-e to the same host used for NF-e, and keep the QR host separate. +See DEV-2468. +""" + +import unittest + +from pynfe.processamento.comunicacao import ComunicacaoSefaz +from pynfe.utils.webservices import NFCE, NFE, qrcode_host + +CONSULTAS = ["AUTORIZACAO", "STATUS", "EVENTOS", "INUTILIZACAO", "CHAVE"] + +URL_AUTORIZACAO_PRODUCAO = "https://nfe.sefaz.go.gov.br/nfe/services/NFeAutorizacao4?wsdl" +URL_AUTORIZACAO_HOMOLOGACAO = "https://homolog.sefaz.go.gov.br/nfe/services/NFeAutorizacao4?wsdl" + + +def _comunicacao(homologacao): + return ComunicacaoSefaz( + uf="go", + certificado="./tests/certificado.pfx", + certificado_senha=bytes("123456", "utf-8"), + homologacao=homologacao, + ) + + +class UrlWebserviceNFCeGoTestCase(unittest.TestCase): + def test_autorizacao_producao_aponta_para_autorizador(self): + url = _comunicacao(homologacao=False)._get_url(modelo="nfce", consulta="AUTORIZACAO") + self.assertEqual(url, URL_AUTORIZACAO_PRODUCAO) + + def test_autorizacao_homologacao_aponta_para_autorizador(self): + url = _comunicacao(homologacao=True)._get_url(modelo="nfce", consulta="AUTORIZACAO") + self.assertEqual(url, URL_AUTORIZACAO_HOMOLOGACAO) + + def test_nfce_usa_o_mesmo_host_da_nfe(self): + for homologacao in (False, True): + nfce = _comunicacao(homologacao=homologacao) + nfe = _comunicacao(homologacao=homologacao) + for consulta in CONSULTAS: + url_nfce = nfce._get_url(modelo="nfce", consulta=consulta) + url_nfe = nfe._get_url(modelo="nfe", consulta=consulta) + self.assertEqual( + url_nfce.split("/")[2], + url_nfe.split("/")[2], + f"host de {consulta} (homologacao={homologacao}) divergiu entre nfce e nfe", + ) + + def test_webservice_nunca_usa_host_do_portal_de_consulta(self): + for homologacao in (False, True): + comunicacao = _comunicacao(homologacao=homologacao) + for consulta in CONSULTAS: + url = comunicacao._get_url(modelo="nfce", consulta=consulta) + self.assertNotIn("nfeweb", url, f"{consulta} aponta para o portal de consulta") + + +class QrcodeHostTestCase(unittest.TestCase): + def test_go_mantem_host_do_portal_de_consulta(self): + self.assertEqual(qrcode_host("GO", producao=True), "https://nfeweb.") + self.assertEqual(qrcode_host("GO", producao=False), "https://nfewebhomolog.") + + def test_uf_sem_host_de_qr_cai_no_host_de_webservice(self): + self.assertEqual(qrcode_host("SE", producao=True), NFCE["SE"]["HTTPS"]) + self.assertEqual(qrcode_host("SE", producao=False), NFCE["SE"]["HOMOLOGACAO"]) + + def test_sp_e_am_mantem_o_host_de_qr_historico(self): + # SP e AM tinham o host de QR montado a partir da chave de webservice; os valores + # abaixo travam a equivalencia byte a byte apos a separacao das chaves. + self.assertEqual(qrcode_host("SP", producao=True), NFCE["SP"]["HTTPS"] + "www.") + self.assertEqual( + qrcode_host("SP", producao=False), NFCE["SP"]["HTTPS"] + "www.homologacao." + ) + self.assertEqual(qrcode_host("AM", producao=True), NFCE["AM"]["HTTPS"]) + self.assertEqual(qrcode_host("AM", producao=False), NFCE["AM"]["HTTPS"]) + + def test_chaves_de_qr_e_de_webservice_de_go_sao_distintas(self): + self.assertEqual(NFCE["GO"]["HTTPS"], NFE["GO"]["HTTPS"]) + self.assertEqual(NFCE["GO"]["HOMOLOGACAO"], NFE["GO"]["HOMOLOGACAO"]) + self.assertNotEqual(NFCE["GO"]["HTTPS"], NFCE["GO"]["QR_HOST"]) + self.assertNotEqual(NFCE["GO"]["HOMOLOGACAO"], NFCE["GO"]["QR_HOST_HOMOLOGACAO"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nfce_qrcode_hosts_por_uf.py b/tests/test_nfce_qrcode_hosts_por_uf.py new file mode 100644 index 00000000..75255e7e --- /dev/null +++ b/tests/test_nfce_qrcode_hosts_por_uf.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python +# *-* encoding: utf8 *-* +"""Pins the ````/```` host of every UF whose branch reads a host prefix. + +The GO fix (DEV-2468) routed those branches through ``webservices.qrcode_host`` so the QR +host can no longer be taken from the authorizer keys. These tests lock the resulting URLs +for SP, AM, BA and MG in both environments, which is what makes the refactor verifiable as +byte-identical outside GO. +""" + +import unittest + +from lxml import etree + +from pynfe.processamento.serializacao import SerializacaoQrcode +from pynfe.utils.flags import CODIGOS_ESTADOS, NAMESPACE_NFE, NAMESPACE_SIG + +TP_AMB_PRODUCAO = "1" +TP_AMB_HOMOLOGACAO = "2" + +# uf -> (prefixo esperado do qrCode, urlChave esperada) por ambiente. +ESPERADO = { + "SP": { + TP_AMB_PRODUCAO: ( + "https://www.nfce.fazenda.sp.gov.br/NFCeConsultaPublica/Paginas/ConsultaQRCode.aspx?", + "https://www.nfce.fazenda.sp.gov.br/consulta", + ), + TP_AMB_HOMOLOGACAO: ( + "https://www.homologacao.nfce.fazenda.sp.gov.br/NFCeConsultaPublica/Paginas/" + "ConsultaQRCode.aspx?", + "https://www.homologacao.nfce.fazenda.sp.gov.br/consulta", + ), + }, + "AM": { + TP_AMB_PRODUCAO: ( + "https://sistemas.sefaz.am.gov.br/nfceweb/consultarNFCe.jsp?", + "https://sistemas.sefaz.am.gov.br/nfceweb/formConsulta.do", + ), + TP_AMB_HOMOLOGACAO: ( + "https://sistemas.sefaz.am.gov.br/nfceweb-hom/consultarNFCe.jsp?", + "https://sistemas.sefaz.am.gov.br/nfceweb/formConsulta.do", + ), + }, + "BA": { + TP_AMB_PRODUCAO: ( + "http://nfe.sefaz.ba.gov.br/servicos/nfce/qrcode.aspx?", + "http://hinternet.sefaz.ba.gov.br/nfce/consulta", + ), + TP_AMB_HOMOLOGACAO: ( + "http://hnfe.sefaz.ba.gov.br/servicos/nfce/qrcode.aspx?", + "http://hinternet.sefaz.ba.gov.br/nfce/consulta", + ), + }, + "MG": { + TP_AMB_PRODUCAO: ( + "https://portalsped.fazenda.mg.gov.br/portalnfce/sistema/qrcode.xhtml", + "https://nfce.fazenda.mg.gov.br/portalnfce", + ), + TP_AMB_HOMOLOGACAO: ( + "https://portalsped.fazenda.mg.gov.br/portalnfce/sistema/qrcode.xhtml", + "https://hnfce.fazenda.mg.gov.br/portalnfce", + ), + }, +} + + +def _nfe(uf, tp_amb): + cuf = CODIGOS_ESTADOS[uf] + chave = cuf + "0" * 42 + xml = ( + f'' + f'' + f"{cuf}2025-01-14T12:00:00-03:00" + f"{tp_amb}" + f"12345678900" + f"10.00" + f"" + f'' + f"ABCDEF==" + f"" + f"" + ) + return etree.fromstring(xml.encode()) + + +class QrcodeHostsPorUfTestCase(unittest.TestCase): + def test_hosts_de_qrcode_e_urlchave(self): + for uf, ambientes in ESPERADO.items(): + for tp_amb, (prefixo_qr, url_chave_esperada) in ambientes.items(): + with self.subTest(uf=uf, tpAmb=tp_amb): + nfe, qrcode = SerializacaoQrcode().gerar_qrcode( + "000001", "CSC123", _nfe(uf, tp_amb), return_qr=True + ) + url_chave = nfe.find("infNFeSupl").find("urlChave").text + self.assertTrue( + qrcode.startswith(prefixo_qr), + f"{uf}/{tp_amb}: qrCode deveria comecar com {prefixo_qr}, veio {qrcode}", + ) + self.assertEqual(url_chave, url_chave_esperada) + + +if __name__ == "__main__": + unittest.main() From 00d1d5000b676dadf01c50584bfc005fed2bdcd2 Mon Sep 17 00:00:00 2001 From: Felipe Correa Date: Wed, 12 Aug 2026 13:36:19 -0300 Subject: [PATCH 08/10] fix(nfce): host de webservice de AM apontava para host inexistente [DEV-2468] (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mesma classe de defeito do GO: `NFCE["AM"]["HTTPS"]` carregava o prefixo do portal de consulta (`https://sistemas.`) enquanto os caminhos de endpoint de AM ja embutem o subdominio `nfce.`, produzindo `https://sistemas.nfce.sefaz.am.gov.br/nfce-services/...` — host que NAO existe (NXDOMAIN). Toda emissao de NFC-e em AM falhava sem nem abrir conexao. Dois fatos independentes fixam o prefixo correto: - `HOMOLOGACAO` = `https://hom` concatena para `homnfce.sefaz.am.gov.br`, que resolve e exige certificado de cliente (alert TLS 42, "Acceptable client certificate CA names", cert `CN=*.sefaz.am.gov.br`, `O=SECRETARIA DE ESTADO DA FAZENDA SEFAZ`) — assinatura de endpoint SOAP com mTLS. Logo os caminhos sao prefixados apenas com o esquema, e producao tem de ser `https://` -> `nfce.sefaz.am.gov.br`, que apresenta o mesmo certificado e a mesma exigencia de certificado de cliente. - `sistemas.sefaz.am.gov.br` completa o handshake sem certificado de cliente e apresenta `CN=sistemas.sefaz.am.gov.br` — e o portal publico, correto para ``/`` e errado para SOAP. O ``/`` de AM continua em `https://sistemas.` via `QR_HOST` (byte a byte identico, travado em `tests/test_nfce_qrcode_hosts_por_uf.py`). Essa separacao de chaves, introduzida pelo #8, e o que torna a correcao possivel sem tocar no QR — a razao registrada no #8 para postergar AM ("quebraria a garantia de byte-identidade") deixou de valer. Tambem neste commit: - `test_sp_e_am_mantem_o_host_de_qr_historico` passa a comparar com literais em vez de derivar o esperado de `NFCE[uf]["HTTPS"]` — derivar da chave sob correcao tornava a trava vazia exatamente quando ela importa. - `docs/webservices_map.md` e `docs/serializacao_map.md`: as ancoras de linha estavam desatualizadas (o docstring do #8 deslocou todo o `webservices.py`), o que anula o proposito dos source maps. Recalculadas contra o arquivo real. --- AGENTS.md | 5 +- docs/serializacao_map.md | 24 +++--- docs/webservices_map.md | 56 ++++++------ pynfe/utils/webservices.py | 5 +- .../test_comunicacao_url_nfce_am.py | 86 +++++++++++++++++++ .../test_comunicacao_url_nfce_go.py | 16 ++-- 6 files changed, 142 insertions(+), 50 deletions(-) create mode 100644 tests/processamento/test_comunicacao_url_nfce_am.py diff --git a/AGENTS.md b/AGENTS.md index edf8299c..f7400e65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ This allows you to navigate directly to the specific line-window you need instea | `docs/manifesto_map.md` | `pynfe/entidades/manifesto.py` | 447 | MDF-e manifest entities | | `docs/evento_map.md` | `pynfe/entidades/evento.py` | 237 | Event entities (cancel, correction, etc.) | | `docs/flags_map.md` | `pynfe/utils/flags.py` | 645 | Constants, namespaces, tax codes | -| `docs/webservices_map.md` | `pynfe/utils/webservices.py` | 612 | SEFAZ endpoint URLs by state | +| `docs/webservices_map.md` | `pynfe/utils/webservices.py` | 615 | SEFAZ endpoint URLs by state | | `docs/utils_map.md` | `pynfe/utils/__init__.py` | 253 | Utility functions (municipality lookup, signing) | ### How to Use Source Maps @@ -119,4 +119,5 @@ ruff format pynfe/ prefixes read only by `qrcode_host` (used for ``/``). Never make one key serve both roles: a webservice pointed at the consultation portal gets a redirect plus HTML instead of a SEFAZ verdict, so emissions fail as transport errors with no rejeicao to explain - them. When a UF changes its consultation host, touch only the `QR_*` keys + them. When a UF changes its consultation host, touch only the `QR_*` keys, and when a UF's + endpoint paths already embed a subdomain, the authorizer prefix is the scheme alone. diff --git a/docs/serializacao_map.md b/docs/serializacao_map.md index de7b0d6c..1e6cfd4f 100644 --- a/docs/serializacao_map.md +++ b/docs/serializacao_map.md @@ -6,20 +6,20 @@ XML serialization of NF-e, NFC-e, NFS-e and MDF-e documents into SEFAZ-compliant | Class | Lines | Purpose | |-------|-------|---------| -| `Serializacao` | 30-63 | Abstract base class (not instantiable directly) | -| `SerializacaoXML` | 66-1860 | Main NF-e/NFC-e XML serialization | -| `SerializacaoQrcode` | 2102-2206 | NFC-e QR Code generation | -| `SerializacaoNfse` | 2209-2275 | NFS-e serialization (Betha/Ginfes) | -| `SerializacaoQrcodeMDFe` | 2278-2301 | MDF-e QR Code generation | -| `SerializacaoMDFe` | 2304-2771 | MDF-e XML serialization | +| `Serializacao` | 31-65 | Abstract base class (not instantiable directly) | +| `SerializacaoXML` | 67-2222 | Main NF-e/NFC-e XML serialization | +| `SerializacaoQrcode` | 2225-2323 | NFC-e QR Code generation | +| `SerializacaoNfse` | 2326-2392 | NFS-e serialization (Betha/Ginfes) | +| `SerializacaoQrcodeMDFe` | 2395-2418 | MDF-e QR Code generation | +| `SerializacaoMDFe` | 2421-2895 | MDF-e XML serialization | --- -## `Serializacao` (base class) — Lines 30-63 +## `Serializacao` (base class) — Lines 31-65 Abstract base for all serializers. Stores `_fonte_dados`, `_ambiente` (1=prod, 2=homolog), `_contingencia`, `_so_cpf`. -## `SerializacaoXML` — Lines 66-1860 +## `SerializacaoXML` — Lines 67-2222 ### Exported Methods | Method | Lines | Purpose | @@ -102,19 +102,19 @@ Abstract base for all serializers. Stores `_fonte_dados`, `_ambiente` (1=prod, 2 --- -## `SerializacaoQrcode` — Lines 2102-2206 +## `SerializacaoQrcode` — Lines 2225-2323 Generates NFC-e QR Code URL. Handles online/offline modes and state-specific URL patterns (SP, BA, MG, etc.). -## `SerializacaoNfse` — Lines 2209-2275 +## `SerializacaoNfse` — Lines 2326-2392 Delegates to Betha or Ginfes serializers. Methods: `gerar`, `gerar_lote`, `consultar_nfse`, `consultar_lote`, `consultar_rps`, `consultar_situacao_lote`, `cancelar`. -## `SerializacaoQrcodeMDFe` — Lines 2278-2301 +## `SerializacaoQrcodeMDFe` — Lines 2395-2418 Generates MDF-e QR Code URL using SVRS endpoint. -## `SerializacaoMDFe` — Lines 2304-2771 +## `SerializacaoMDFe` — Lines 2421-2895 ### Methods | Method | Lines | Purpose | diff --git a/docs/webservices_map.md b/docs/webservices_map.md index dc7e117d..49282270 100644 --- a/docs/webservices_map.md +++ b/docs/webservices_map.md @@ -1,4 +1,4 @@ -# Source Map: `webservices.py` (612 lines) +# Source Map: `webservices.py` (615 lines) SEFAZ webservice endpoint URLs organized by document type, state, and environment. @@ -6,11 +6,13 @@ SEFAZ webservice endpoint URLs organized by document type, state, and environmen | Section | Lines | Variable | Purpose | |---------|-------|----------|---------| -| NFC-e endpoints | 8-295 | `NFCE` | NFC-e webservice URLs and QR Code URLs by state | -| NF-e endpoints | 297-471 | `NFE` | NF-e webservice URLs by state | -| NFS-e endpoints | 473-499 | `NFSE` | NFS-e URLs (Betha, Ginfes) | -| MDF-e endpoints | 501-516 | `MDFE` | MDF-e URLs (SVRS only) | -| CT-e endpoints | 518-572 | `CTE` | CT-e URLs by state | +| Host roles (consulta vs autorizador) | 1-22 | — | Module docstring: which key family each consumer may read | +| NFC-e endpoints | 24-323 | `NFCE` | NFC-e webservice URLs and QR Code URLs by state | +| `qrcode_host` helper | 326-337 | — | Consultation-portal host prefix for ``/`` | +| NF-e endpoints | 343-514 | `NFE` | NF-e webservice URLs by state | +| NFS-e endpoints | 517-542 | `NFSE` | NFS-e URLs (Betha, Ginfes) | +| MDF-e endpoints | 545-559 | `MDFE` | MDF-e URLs (SVRS only) | +| CT-e endpoints | 561-615 | `CTE` | CT-e URLs by state | ## URL Structure @@ -37,41 +39,41 @@ SEFAZ verdict and the failure surfaces as a transport/XML-parse error, not a rej ## State/Virtual Environment Groups -### NFC-e (`NFCE`) — Lines 8-295 +### NFC-e (`NFCE`) — Lines 24-323 | Key | Lines | Description | |-----|-------|-------------| -| Individual states | 9-278 | RO, AC, AM, RR, PA, AP, TO, MA, PI, CE, RN, PB, PE, AL, SE, BA, MG, ES, RJ, SP, PR, SC, RS, MS, MT, GO, DF | -| `SVRS` | 284-294 | Virtual SEFAZ RS (fallback for states without own NFC-e) | +| Individual states | 25-311 | RO, AC, AM, RR, PA, AP, TO, MA, PI, CE, RN, PB, PE, AL, SE, BA, MG, ES, RJ, SP, PR, SC, RS, MS, MT, GO, DF | +| `SVRS` | 312-322 | Virtual SEFAZ RS (fallback for states without own NFC-e) | -### NF-e (`NFE`) — Lines 297-471 +### NF-e (`NFE`) — Lines 343-514 | Key | Lines | Description | |-----|-------|-------------| -| `AN` | 302-309 | National environment (events, distribution) | -| Individual states | 310-428 | AM, MA, PE, BA, MG, SP, PR, RS, MS, MT, GO | -| `SVAN` | 430-440 | Virtual SEFAZ AN (MA for NF-e) | -| `SVRS` | 441-451 | Virtual SEFAZ RS (most states) | -| `SVC-AN` | 452-460 | Contingency AN | -| `SVC-RS` | 461-470 | Contingency RS | +| `AN` | 345-352 | National environment (events, distribution) | +| Individual states | 353-472 | AM, MA, PE, BA, MG, SP, PR, RS, MS, MT, GO | +| `SVAN` | 473-483 | Virtual SEFAZ AN (MA for NF-e) | +| `SVRS` | 484-494 | Virtual SEFAZ RS (most states) | +| `SVC-AN` | 495-503 | Contingency AN | +| `SVC-RS` | 504-513 | Contingency RS | -### NFS-e (`NFSE`) — Lines 473-499 +### NFS-e (`NFSE`) — Lines 517-542 | Key | Lines | Description | |-----|-------|-------------| -| `BETHA` | 476-486 | Betha provider (HTTP WSDL) | -| `GINFES` | 488-498 | Ginfes provider (HTTPS WSDL) | +| `BETHA` | 519-530 | Betha provider (HTTP WSDL) | +| `GINFES` | 531-541 | Ginfes provider (HTTPS WSDL) | -### MDF-e (`MDFE`) — Lines 501-516 -Only `SVRS` — single authorizer for all states. +### MDF-e (`MDFE`) — Lines 545-559 +Only `SVRS` (547-558) — single authorizer for all states. -### CT-e (`CTE`) — Lines 518-572 +### CT-e (`CTE`) — Lines 561-615 | Key | Lines | Description | |-----|-------|-------------| -| `AN` | 519-523 | National environment (distribution) | -| Individual states | 524-558 | MT, MS, MG, PR, RS, SP | -| `SVRS` | 560-565 | Virtual SEFAZ RS | -| `SVSP` | 566-571 | Virtual SEFAZ SP (AP, PE, RR) | +| `AN` | 562-566 | National environment (distribution) | +| Individual states | 567-602 | MT, MS, MG, PR, RS, SP | +| `SVRS` | 603-608 | Virtual SEFAZ RS | +| `SVSP` | 609-614 | Virtual SEFAZ SP (AP, PE, RR) | ## Helpers | Function | Lines | Purpose | |----------|-------|---------| -| `qrcode_host(uf, producao=True)` | 323-335 | Consultation-portal host prefix for ``/``, with fallback to the webservice host | +| `qrcode_host(uf, producao=True)` | 326-337 | Consultation-portal host prefix for ``/``, with fallback to the webservice host | diff --git a/pynfe/utils/webservices.py b/pynfe/utils/webservices.py index 49bcdcae..39b0f80f 100644 --- a/pynfe/utils/webservices.py +++ b/pynfe/utils/webservices.py @@ -50,8 +50,11 @@ "QR": "sefaz.am.gov.br/nfceweb/consultarNFCe.jsp?", "QR_HOMOLOGACAO": "sefaz.am.gov.br/nfceweb-hom/consultarNFCe.jsp?", "URL": "sefaz.am.gov.br/nfceweb/formConsulta.do", - "HTTPS": "https://sistemas.", + # Host do AUTORIZADOR (webservices SOAP, mTLS) - os caminhos acima ja embutem o + # subdominio "nfce.", entao o prefixo de producao e apenas o esquema. + "HTTPS": "https://", "HOMOLOGACAO": "https://hom", + # Host do portal de CONSULTA (qrCode/urlChave). "QR_HOST": "https://sistemas.", "QR_HOST_HOMOLOGACAO": "https://sistemas.", }, diff --git a/tests/processamento/test_comunicacao_url_nfce_am.py b/tests/processamento/test_comunicacao_url_nfce_am.py new file mode 100644 index 00000000..cd1a1cab --- /dev/null +++ b/tests/processamento/test_comunicacao_url_nfce_am.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +# *-* encoding: utf8 *-* +"""Regression tests for the AM NFC-e WEBSERVICE host (autorizador, not consulta portal). + +Same defect class as GO (DEV-2468): ``NFCE["AM"]["HTTPS"]`` carried the consultation-portal +prefix ``https://sistemas.`` while the endpoint paths already embed the ``nfce.`` subdomain, +so every AM NFC-e webservice URL resolved to ``sistemas.nfce.sefaz.am.gov.br`` — a host that +does not exist (NXDOMAIN), i.e. AM emission could not even open a connection. Two independent +facts settle the correct prefix: + +- ``NFCE["AM"]["HOMOLOGACAO"] = "https://hom"`` concatenates to ``homnfce.sefaz.am.gov.br``, + which resolves and demands a client certificate (TLS alert 42 with "Acceptable client + certificate CA names", cert ``CN=*.sefaz.am.gov.br``, ``O=SECRETARIA DE ESTADO DA FAZENDA + SEFAZ``) — the mTLS SOAP signature. So the webservice paths are meant to be prefixed with + the scheme only, and production must be ``https://`` → ``nfce.sefaz.am.gov.br``, which shows + the same certificate and the same client-certificate demand. +- ``sistemas.sefaz.am.gov.br`` completes the TLS handshake with no client certificate and a + plain ``CN=sistemas.sefaz.am.gov.br`` cert — it is the public portal, correct for + ````/```` and wrong for SOAP. + +The QR host stays on ``https://sistemas.`` via ``QR_HOST``; the byte-identity of AM's +````/```` is locked in ``tests/test_nfce_qrcode_hosts_por_uf.py``. +""" + +import unittest + +from pynfe.processamento.comunicacao import ComunicacaoSefaz +from pynfe.utils.webservices import NFCE, qrcode_host + +CONSULTAS = ["AUTORIZACAO", "STATUS", "EVENTOS", "INUTILIZACAO", "CHAVE", "RECIBO"] + +HOST_AUTORIZADOR_PRODUCAO = "nfce.sefaz.am.gov.br" +HOST_AUTORIZADOR_HOMOLOGACAO = "homnfce.sefaz.am.gov.br" + +URL_AUTORIZACAO_PRODUCAO = "https://nfce.sefaz.am.gov.br/nfce-services/services/NfeAutorizacao4" +URL_AUTORIZACAO_HOMOLOGACAO = ( + "https://homnfce.sefaz.am.gov.br/nfce-services/services/NfeAutorizacao4" +) + + +def _comunicacao(homologacao): + return ComunicacaoSefaz( + uf="am", + certificado="./tests/certificado.pfx", + certificado_senha=bytes("123456", "utf-8"), + homologacao=homologacao, + ) + + +class UrlWebserviceNFCeAmTestCase(unittest.TestCase): + def test_autorizacao_producao_aponta_para_autorizador(self): + url = _comunicacao(homologacao=False)._get_url(modelo="nfce", consulta="AUTORIZACAO") + self.assertEqual(url, URL_AUTORIZACAO_PRODUCAO) + + def test_autorizacao_homologacao_aponta_para_autorizador(self): + url = _comunicacao(homologacao=True)._get_url(modelo="nfce", consulta="AUTORIZACAO") + self.assertEqual(url, URL_AUTORIZACAO_HOMOLOGACAO) + + def test_host_de_webservice_e_o_mesmo_em_todas_as_consultas(self): + for homologacao, host_esperado in ( + (False, HOST_AUTORIZADOR_PRODUCAO), + (True, HOST_AUTORIZADOR_HOMOLOGACAO), + ): + comunicacao = _comunicacao(homologacao=homologacao) + for consulta in CONSULTAS: + url = comunicacao._get_url(modelo="nfce", consulta=consulta) + self.assertEqual( + url.split("/")[2], + host_esperado, + f"host de {consulta} (homologacao={homologacao}) divergiu do autorizador", + ) + + def test_webservice_nunca_usa_host_do_portal_de_consulta(self): + for homologacao in (False, True): + comunicacao = _comunicacao(homologacao=homologacao) + for consulta in CONSULTAS: + url = comunicacao._get_url(modelo="nfce", consulta=consulta) + self.assertNotIn("sistemas.", url, f"{consulta} aponta para o portal de consulta") + + def test_chaves_de_qr_e_de_webservice_de_am_sao_distintas(self): + self.assertEqual(qrcode_host("AM", producao=True), "https://sistemas.") + self.assertNotEqual(NFCE["AM"]["HTTPS"], NFCE["AM"]["QR_HOST"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/processamento/test_comunicacao_url_nfce_go.py b/tests/processamento/test_comunicacao_url_nfce_go.py index 7000eddd..67dcd590 100644 --- a/tests/processamento/test_comunicacao_url_nfce_go.py +++ b/tests/processamento/test_comunicacao_url_nfce_go.py @@ -71,14 +71,14 @@ def test_uf_sem_host_de_qr_cai_no_host_de_webservice(self): self.assertEqual(qrcode_host("SE", producao=False), NFCE["SE"]["HOMOLOGACAO"]) def test_sp_e_am_mantem_o_host_de_qr_historico(self): - # SP e AM tinham o host de QR montado a partir da chave de webservice; os valores - # abaixo travam a equivalencia byte a byte apos a separacao das chaves. - self.assertEqual(qrcode_host("SP", producao=True), NFCE["SP"]["HTTPS"] + "www.") - self.assertEqual( - qrcode_host("SP", producao=False), NFCE["SP"]["HTTPS"] + "www.homologacao." - ) - self.assertEqual(qrcode_host("AM", producao=True), NFCE["AM"]["HTTPS"]) - self.assertEqual(qrcode_host("AM", producao=False), NFCE["AM"]["HTTPS"]) + # SP e AM tinham o host de QR montado a partir da chave de webservice; os literais + # abaixo travam a equivalencia byte a byte apos a separacao das chaves. Sao literais + # de proposito: derivar o esperado de NFCE[uf]["HTTPS"] tornaria a trava vazia + # justamente quando essa chave for corrigida. + self.assertEqual(qrcode_host("SP", producao=True), "https://www.") + self.assertEqual(qrcode_host("SP", producao=False), "https://www.homologacao.") + self.assertEqual(qrcode_host("AM", producao=True), "https://sistemas.") + self.assertEqual(qrcode_host("AM", producao=False), "https://sistemas.") def test_chaves_de_qr_e_de_webservice_de_go_sao_distintas(self): self.assertEqual(NFCE["GO"]["HTTPS"], NFE["GO"]["HTTPS"]) From 8fd41f7909ead4a9273e2c5bca40754395891499 Mon Sep 17 00:00:00 2001 From: Felipe Correa Date: Wed, 12 Aug 2026 16:00:17 -0300 Subject: [PATCH 09/10] =?UTF-8?q?fix(nfce):=20urlChave=20e=20campo=20propr?= =?UTF-8?q?io,=20nao=20o=20endereco=20do=20QR=20Code=20=E2=80=94=20GO/BA/P?= =?UTF-8?q?R=20[DEV-2468]=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(nfce): urlChave e campo proprio, nao o endereco do QR Code [DEV-2468] O era montado como qrcode_host(uf) + NFCE[uf]["URL"], reaproveitando a familia de host do QR Code. Sao registros oficiais DIFERENTES: GO rejeita 878 ("Endereco do site da UF da Consulta por chave de acesso diverge do previsto") ao receber o endereco do leitor de QR no urlChave. O IT 2025.003, que originou o DEV-2177, define somente a URL do QR Code, por isso a rodada anterior acertou o e nao percebeu o outro campo. Introduz CONSULTA_CHAVE / CONSULTA_CHAVE_HOMOLOGACAO com a URL COMPLETA e verbatim do registro, lida por url_consulta_chave. Nenhum prefixo de host e concatenado nesse valor - foi essa concatenacao sobre URLs ja completas que gerou lixo do tipo https://nfce.http://www.dfe.ms.gov.br/nfce/consulta. gerar_qrcode passa a ter um unico ponto de montagem do urlChave, sem host, e as ramificacoes por UF ficam restritas ao . Valores de ACBrNFeServicos.ini (URL-ConsultaNFCe_2.00), confirmados para GO pelo registro ENCAT: - GO [NFCe_GO_P]/[NFCe_GO_H]: emitia o portal nfeweb; agora http://www.sefaz.go.gov.br/nfce/consulta e a pagina de homologacao do nfce.go. - BA [NFCe_BA_P]/[NFCe_BA_H]: emitia hinternet (valor de HOMOLOGACAO) tambem em producao; agora http://www.sefaz.ba.gov.br/nfce/consulta em producao. - PR [NFCe_PR_P]/[NFCe_PR_H]: valor ja correto, agora declarado na chave dedicada para nao voltar a depender do caminho legado. Saida byte-identica. O nao muda em nenhuma UF, e o urlChave de RJ, AC, DF, PE e SC continua byte-identico. As demais UFs divergentes (AP, ES, MS, RS, AM, CE, MG, PA, RO, RR, SE) ficam como estao: nao ha loja nelas para validar a correcao. * docs(nfce): docstrings de qrcode_host paravam de citar o urlChave [DEV-2468] Depois da separacao dos dois campos, qrcode_host serve apenas o . As docstrings que ainda diziam "/" descrevem exatamente o acoplamento que gerou a rejeicao 878, e apontam o proximo leitor para o helper errado. --- AGENTS.md | 12 +- docs/serializacao_map.md | 20 +- docs/webservices_map.md | 30 ++- pynfe/processamento/serializacao.py | 40 ++-- pynfe/utils/webservices.py | 63 +++++- .../test_comunicacao_url_nfce_am.py | 2 +- .../test_comunicacao_url_nfce_go.py | 2 +- tests/test_nfce_qrcode_go.py | 29 ++- tests/test_nfce_qrcode_hosts_por_uf.py | 10 +- tests/test_nfce_urlchave_por_uf.py | 202 ++++++++++++++++++ 10 files changed, 341 insertions(+), 69 deletions(-) create mode 100644 tests/test_nfce_urlchave_por_uf.py diff --git a/AGENTS.md b/AGENTS.md index f7400e65..068d303a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,5 +119,15 @@ ruff format pynfe/ prefixes read only by `qrcode_host` (used for ``/``). Never make one key serve both roles: a webservice pointed at the consultation portal gets a redirect plus HTML instead of a SEFAZ verdict, so emissions fail as transport errors with no rejeicao to explain - them. When a UF changes its consultation host, touch only the `QR_*` keys, and when a UF's + them. When a UF changes its consultation host, touch only the `QR_*` keys +- **`` and `` are separate registries; no UF uses one address for both.** + `urlChave` (consulta por chave de acesso) comes from `CONSULTA_CHAVE`/ + `CONSULTA_CHAVE_HOMOLOGACAO` — the COMPLETE, verbatim URL from the official registry + (`URL-ConsultaNFCe_2.00` in ACBr's `ACBrNFeServicos.ini`, cross-checked against ENCAT) — + returned by `url_consulta_chave` with no host prefix ever concatenated onto it. GO rejects + 878 when `urlChave` carries the QR Code address, and concatenating a host prefix onto an + already-complete URL is what produced values like `https://nfce.http://www.dfe.ms.gov.br/…`. + Source the value from the registry, never infer it from a sibling UF; details and the + per-UF divergence inventory are in `docs/webservices_map.md` and + `tests/test_nfce_urlchave_por_uf.py`, and when a UF's endpoint paths already embed a subdomain, the authorizer prefix is the scheme alone. diff --git a/docs/serializacao_map.md b/docs/serializacao_map.md index 1e6cfd4f..8dd4b6b5 100644 --- a/docs/serializacao_map.md +++ b/docs/serializacao_map.md @@ -1,4 +1,4 @@ -# Source Map: `serializacao.py` (2895 lines) +# Source Map: `serializacao.py` (2881 lines) XML serialization of NF-e, NFC-e, NFS-e and MDF-e documents into SEFAZ-compliant XML format. @@ -8,10 +8,10 @@ XML serialization of NF-e, NFC-e, NFS-e and MDF-e documents into SEFAZ-compliant |-------|-------|---------| | `Serializacao` | 31-65 | Abstract base class (not instantiable directly) | | `SerializacaoXML` | 67-2222 | Main NF-e/NFC-e XML serialization | -| `SerializacaoQrcode` | 2225-2323 | NFC-e QR Code generation | +| `SerializacaoQrcode` | 2225-2309 | NFC-e QR Code generation | | `SerializacaoNfse` | 2326-2392 | NFS-e serialization (Betha/Ginfes) | -| `SerializacaoQrcodeMDFe` | 2395-2418 | MDF-e QR Code generation | -| `SerializacaoMDFe` | 2421-2895 | MDF-e XML serialization | +| `SerializacaoQrcodeMDFe` | 2381-2404 | MDF-e QR Code generation | +| `SerializacaoMDFe` | 2407-2881 | MDF-e XML serialization | --- @@ -102,19 +102,21 @@ Abstract base for all serializers. Stores `_fonte_dados`, `_ambiente` (1=prod, 2 --- -## `SerializacaoQrcode` — Lines 2225-2323 +## `SerializacaoQrcode` — Lines 2225-2309 -Generates NFC-e QR Code URL. Handles online/offline modes and state-specific URL patterns (SP, BA, MG, etc.). +Generates the `` block. `` handles online/offline modes and the +state-specific host/path patterns; `` is a separate registry and comes straight from +`webservices.url_consulta_chave` — never concatenate a host onto it (DEV-2468, rejeicao 878). -## `SerializacaoNfse` — Lines 2326-2392 +## `SerializacaoNfse` — Lines 2312-2378 Delegates to Betha or Ginfes serializers. Methods: `gerar`, `gerar_lote`, `consultar_nfse`, `consultar_lote`, `consultar_rps`, `consultar_situacao_lote`, `cancelar`. -## `SerializacaoQrcodeMDFe` — Lines 2395-2418 +## `SerializacaoQrcodeMDFe` — Lines 2381-2404 Generates MDF-e QR Code URL using SVRS endpoint. -## `SerializacaoMDFe` — Lines 2421-2895 +## `SerializacaoMDFe` — Lines 2407-2881 ### Methods | Method | Lines | Purpose | diff --git a/docs/webservices_map.md b/docs/webservices_map.md index 49282270..3401f178 100644 --- a/docs/webservices_map.md +++ b/docs/webservices_map.md @@ -1,4 +1,4 @@ -# Source Map: `webservices.py` (615 lines) +# Source Map: `webservices.py` (673 lines) SEFAZ webservice endpoint URLs organized by document type, state, and environment. @@ -6,13 +6,14 @@ SEFAZ webservice endpoint URLs organized by document type, state, and environmen | Section | Lines | Variable | Purpose | |---------|-------|----------|---------| -| Host roles (consulta vs autorizador) | 1-22 | — | Module docstring: which key family each consumer may read | -| NFC-e endpoints | 24-323 | `NFCE` | NFC-e webservice URLs and QR Code URLs by state | -| `qrcode_host` helper | 326-337 | — | Consultation-portal host prefix for ``/`` | -| NF-e endpoints | 343-514 | `NFE` | NF-e webservice URLs by state | -| NFS-e endpoints | 517-542 | `NFSE` | NFS-e URLs (Betha, Ginfes) | -| MDF-e endpoints | 545-559 | `MDFE` | MDF-e URLs (SVRS only) | -| CT-e endpoints | 561-615 | `CTE` | CT-e URLs by state | +| Host roles + `qrCode` vs `urlChave` | 1-40 | — | Module docstring: which key family each consumer may read | +| NFC-e endpoints | 45-359 | `NFCE` | NFC-e webservice URLs, QR Code URLs and consultation URLs by state | +| `qrcode_host` helper | 362-375 | — | Consultation-portal host prefix for `` | +| `url_consulta_chave` helper | 378-399 | — | Complete `` URL, never concatenated | +| NF-e endpoints | 401-572 | `NFE` | NF-e webservice URLs by state | +| NFS-e endpoints | 575-600 | `NFSE` | NFS-e URLs (Betha, Ginfes) | +| MDF-e endpoints | 603-617 | `MDFE` | MDF-e URLs (SVRS only) | +| CT-e endpoints | 619-673 | `CTE` | CT-e URLs by state | ## URL Structure @@ -31,12 +32,20 @@ Each state/virtual environment entry contains: ``/`` (read only by `qrcode_host`); falls back to `HTTPS`/`HOMOLOGACAO` for UFs that serve both roles from the same host - `QR` — QR Code path (NFC-e only; `QR_HOMOLOGACAO` where the path differs per environment) -- `URL` — Consultation path (NFC-e only) +- `URL` — Legacy consultation path (NFC-e only), still read by `url_consulta_chave` as a + fallback for UFs that declare no `CONSULTA_CHAVE` +- `CONSULTA_CHAVE` / `CONSULTA_CHAVE_HOMOLOGACAO` — COMPLETE, verbatim `` from the + official registry (`URL-ConsultaNFCe_2.00` in ACBr / the ENCAT listing). Returned as-is: no + host prefix is ever concatenated onto it. Declare this for any UF you add or fix The two host families must never be shared: a UF such as GO answers webservice POSTs sent to its consultation host with a load-balancer redirect and HTML, so the invoice never receives a SEFAZ verdict and the failure surfaces as a transport/XML-parse error, not a rejeicao. +`` and `` are likewise separate registries — no UF uses the QR Code address +as its consultation-by-key address. GO rejects 878 when they are the same. `urlChave` values +live in `CONSULTA_CHAVE*`; `qrCode` is built from `QR*` over `qrcode_host`. + ## State/Virtual Environment Groups ### NFC-e (`NFCE`) — Lines 24-323 @@ -76,4 +85,5 @@ Only `SVRS` (547-558) — single authorizer for all states. | Function | Lines | Purpose | |----------|-------|---------| -| `qrcode_host(uf, producao=True)` | 326-337 | Consultation-portal host prefix for ``/``, with fallback to the webservice host | +| `qrcode_host(uf, producao=True)` | 362-375 | Consultation-portal host prefix for ``, with fallback to the webservice host | +| `url_consulta_chave(uf, producao=True)` | 378-399 | Complete `` URL from `CONSULTA_CHAVE*`, falling back to the legacy `URL` path | diff --git a/pynfe/processamento/serializacao.py b/pynfe/processamento/serializacao.py index ece0ab2d..2d4c1c2a 100644 --- a/pynfe/processamento/serializacao.py +++ b/pynfe/processamento/serializacao.py @@ -25,7 +25,7 @@ VERSAO_PADRAO, VERSAO_QRCODE, ) -from pynfe.utils.webservices import MDFE, NFCE, qrcode_host +from pynfe.utils.webservices import MDFE, NFCE, qrcode_host, url_consulta_chave class Serializacao(object): @@ -2278,36 +2278,22 @@ def gerar_qrcode(self, token, csc, xml, return_qr=False, online=True): url = "p={}|{}".format(url, url_hash) - # url_chave -Texto com a URL de consulta por chave de acesso a ser impressa no DANFE NFC-e. - # Informar a URL da “Consulta por chave de acesso da NFC-e”. - # A mesma URL que deve estar informada no DANFE NFC-e para consulta por chave de acesso - lista_uf_padrao = ["PR", "CE", "RS", "RJ", "RO", "DF"] - if uf in lista_uf_padrao: + # As UFs abaixo divergem apenas em COMO montar o : para quais delas o caminho + # "QR" ja embute o host, e qual caminho vale por ambiente. + lista_uf_qr_sem_host = ["PR", "CE", "RS", "RJ", "RO", "DF", "MG"] + if uf in lista_uf_qr_sem_host: qrcode = NFCE[uf]["QR"] + url - url_chave = NFCE[uf]["URL"] - elif uf == "SP": - host = qrcode_host(uf, producao=tpamb == "1") - qrcode = host + NFCE[uf]["QR"] + url - url_chave = host + NFCE[uf]["URL"] - # BA tem comportamento distindo para qrcode e url - elif uf == "BA": - qrcode = qrcode_host(uf, producao=tpamb == "1") + NFCE[uf]["QR"] + url - url_chave = NFCE[uf]["URL"] - # MG tem comportamento distintos para qrcode e url - elif uf == "MG": - qrcode = NFCE[uf]["QR"] + url - url_chave = qrcode_host(uf, producao=tpamb == "1") + NFCE[uf]["URL"] - # AM tem comportamento distintos para qrcode e url elif uf == "AM": - host = qrcode_host(uf, producao=tpamb == "1") caminho_qr = NFCE[uf]["QR"] if tpamb == "1" else NFCE[uf]["QR_HOMOLOGACAO"] - qrcode = host + caminho_qr + url - url_chave = host + NFCE[uf]["URL"] - # AC, RR, PA, SE, GO + qrcode = qrcode_host(uf, producao=tpamb == "1") + caminho_qr + url + # AC, AP, BA, ES, GO, MS, PA, PE, RR, SC, SE, SP else: - host = qrcode_host(uf, producao=tpamb == "1") - qrcode = host + NFCE[uf]["QR"] + url - url_chave = host + NFCE[uf]["URL"] + qrcode = qrcode_host(uf, producao=tpamb == "1") + NFCE[uf]["QR"] + url + + # e a URL da "Consulta por chave de acesso da NFC-e" impressa no DANFE + # NFC-e - registro oficial DIFERENTE do endereco do QR Code. url_consulta_chave + # devolve o valor completo; nunca concatene host aqui (DEV-2468). + url_chave = url_consulta_chave(uf, producao=tpamb == "1") # adicionta tag infNFeSupl com qrcode info = etree.Element("infNFeSupl") etree.SubElement(info, "qrCode").text = etree.CDATA(qrcode.strip()) diff --git a/pynfe/utils/webservices.py b/pynfe/utils/webservices.py index 39b0f80f..b7b3fe71 100644 --- a/pynfe/utils/webservices.py +++ b/pynfe/utils/webservices.py @@ -16,6 +16,27 @@ Apontar webservice para o host de consulta nao gera rejeicao: o portal responde redirect + HTML, entao a nota nunca recebe veredito da SEFAZ. Ao atualizar o host de consulta de uma UF, mexa somente nas chaves ``QR_*``. + +``qrCode`` vs ``urlChave`` +------------------------- +Sao dois campos do ```` com registros oficiais DIFERENTES, e nenhuma UF +usa o mesmo endereco nos dois: + +- ````: endereco do leitor de QR Code (``QR``/``QR_HOMOLOGACAO`` sobre + ``qrcode_host``). +- ````: endereco da "Consulta por chave de acesso" (``URL-ConsultaNFCe_2.00`` + no registro do ACBr / ENCAT), lido por ``url_consulta_chave``. + +Para o ``urlChave`` a chave dedicada e ``CONSULTA_CHAVE`` / +``CONSULTA_CHAVE_HOMOLOGACAO``, que guarda a URL COMPLETA e VERBATIM do registro - +inclusive o esquema quando o registro o traz, e sem esquema quando nao traz. Nenhum +prefixo de host e concatenado nesse valor: foi exatamente essa concatenacao sobre uma +URL ja completa que produziu lixo como ``https://nfce.http://www.dfe.ms.gov.br/...`` +nas UFs que ainda dependem do caminho legado ``URL``. Ao registrar uma UF nova, +declare ``CONSULTA_CHAVE`` em vez de mexer em ``URL``. + +Usar o endereco de QR Code no ``urlChave`` gera rejeicao 878 em GO ("Endereco do site +da UF da Consulta por chave de acesso diverge do previsto") - DEV-2468. """ # http://nfce.encat.org/desenvolvedor/qrcode/ @@ -176,6 +197,10 @@ "HTTPS": "http://nfe.", "HOMOLOGACAO": "http://hnfe.", "URL": "http://hinternet.sefaz.ba.gov.br/nfce/consulta", + # ACBrNFeServicos.ini [NFCe_BA_P]/[NFCe_BA_H] URL-ConsultaNFCe_2.00. O "URL" acima + # era o valor de homologacao, emitido tambem em producao. + "CONSULTA_CHAVE": "http://www.sefaz.ba.gov.br/nfce/consulta", + "CONSULTA_CHAVE_HOMOLOGACAO": "http://hinternet.sefaz.ba.gov.br/nfce/consulta", }, "MG": { "STATUS": "fazenda.mg.gov.br/nfce/services/NFeStatusServico4", @@ -243,6 +268,10 @@ "CADASTRO": "nfce.sefa.pr.gov.br/nfce/CadConsultaCadastro4?wsdl", "QR": "http://www.fazenda.pr.gov.br/nfce/qrcode?", "URL": "http://www.fazenda.pr.gov.br/nfce/consulta", + # ACBrNFeServicos.ini [NFCe_PR_P]/[NFCe_PR_H] URL-ConsultaNFCe_2.00 - mesmo valor nos + # dois ambientes, byte-identico ao que o caminho legado ja emitia. + "CONSULTA_CHAVE": "http://www.fazenda.pr.gov.br/nfce/consulta", + "CONSULTA_CHAVE_HOMOLOGACAO": "http://www.fazenda.pr.gov.br/nfce/consulta", "HTTPS": "https://", "HOMOLOGACAO": "https://homologacao.", }, @@ -303,6 +332,13 @@ "QR_HOST": "https://nfeweb.", "QR_HOST_HOMOLOGACAO": "https://nfewebhomolog.", "URL": "sefaz.go.gov.br/nfeweb/sites/nfce/danfeNFCe", + # ACBrNFeServicos.ini [NFCe_GO_P]/[NFCe_GO_H] URL-ConsultaNFCe_2.00, confirmado pelo + # registro ENCAT. O IT 2025.003 definiu somente a URL do QR Code, por isso o "URL" + # acima ficou apontando para o leitor de QR e GO passou a rejeitar 878. + "CONSULTA_CHAVE": "http://www.sefaz.go.gov.br/nfce/consulta", + "CONSULTA_CHAVE_HOMOLOGACAO": ( + "http://www.nfce.go.gov.br/post/ver/214413/consulta-nfc-e-homologacao" + ), }, "DF": { "QR": "http://www.fazenda.df.gov.br/nfce/qrcode?", @@ -324,11 +360,14 @@ def qrcode_host(uf, producao=True): - """Prefixo de host do portal de consulta usado no ````/````. + """Prefixo de host do portal de consulta usado no ````. Prefere as chaves dedicadas ``QR_HOST``/``QR_HOST_HOMOLOGACAO``; para UFs que nao declaram host de consulta proprio (portal e autorizador no mesmo servidor), cai no host de webservice, preservando o comportamento historico. + + Nao serve o ````: esse campo tem registro proprio e URL completa, ver + ``url_consulta_chave``. """ dados = NFCE[uf] chave_qr = "QR_HOST" if producao else "QR_HOST_HOMOLOGACAO" @@ -337,6 +376,28 @@ def qrcode_host(uf, producao=True): return dados["HTTPS"] if producao else dados["HOMOLOGACAO"] +# UFs cujo ``URL`` legado ja e a urlChave completa, sem prefixo de host. Lista fechada: +# UFs novas devem declarar ``CONSULTA_CHAVE`` em vez de entrar aqui. +UFS_URLCHAVE_SEM_HOST = frozenset({"CE", "DF", "RJ", "RO", "RS"}) + + +def url_consulta_chave(uf, producao=True): + """URL completa de consulta por chave de acesso, para o ````. + + Prefere ``CONSULTA_CHAVE``/``CONSULTA_CHAVE_HOMOLOGACAO``, que guardam o valor + verbatim do registro oficial e sao devolvidos sem nenhuma concatenacao. Sem essas + chaves, reproduz o caminho legado (``URL``, com prefixo de host onde a UF nunca + declarou host no proprio ``URL``). + """ + dados = NFCE[uf] + chave = "CONSULTA_CHAVE" if producao else "CONSULTA_CHAVE_HOMOLOGACAO" + if dados.get(chave): + return dados[chave] + if uf in UFS_URLCHAVE_SEM_HOST: + return dados["URL"] + return qrcode_host(uf, producao=producao) + dados["URL"] + + # Nfe # homologação => http://hom.nfe.fazenda.gov.br/PORTAL/WebServices.aspx # produção => https://www.nfe.fazenda.gov.br/portal/webServices.aspx diff --git a/tests/processamento/test_comunicacao_url_nfce_am.py b/tests/processamento/test_comunicacao_url_nfce_am.py index cd1a1cab..ea1fadc0 100644 --- a/tests/processamento/test_comunicacao_url_nfce_am.py +++ b/tests/processamento/test_comunicacao_url_nfce_am.py @@ -16,7 +16,7 @@ the same certificate and the same client-certificate demand. - ``sistemas.sefaz.am.gov.br`` completes the TLS handshake with no client certificate and a plain ``CN=sistemas.sefaz.am.gov.br`` cert — it is the public portal, correct for - ````/```` and wrong for SOAP. + ```` and wrong for SOAP. The QR host stays on ``https://sistemas.`` via ``QR_HOST``; the byte-identity of AM's ````/```` is locked in ``tests/test_nfce_qrcode_hosts_por_uf.py``. diff --git a/tests/processamento/test_comunicacao_url_nfce_go.py b/tests/processamento/test_comunicacao_url_nfce_go.py index 67dcd590..fe6531bf 100644 --- a/tests/processamento/test_comunicacao_url_nfce_go.py +++ b/tests/processamento/test_comunicacao_url_nfce_go.py @@ -3,7 +3,7 @@ """Regression tests for the GO NFC-e WEBSERVICE host (autorizador, not consulta portal). GO serves the public consultation portal (``nfeweb.sefaz.go.gov.br``, destination of -````/````) and the SOAP authorizer (``nfe.sefaz.go.gov.br``) from +````) and the SOAP authorizer (``nfe.sefaz.go.gov.br``) from different hosts. Pointing the webservice URL at the consultation portal does NOT produce a SEFAZ rejeicao: the portal answers a BigIP redirect plus HTML, so the invoice never gets a verdict at all and every emission fails as a transport error. These tests pin the diff --git a/tests/test_nfce_qrcode_go.py b/tests/test_nfce_qrcode_go.py index 85c775c5..d09ecf82 100644 --- a/tests/test_nfce_qrcode_go.py +++ b/tests/test_nfce_qrcode_go.py @@ -1,14 +1,19 @@ #!/usr/bin/env python # *-* encoding: utf8 *-* -"""Regression tests for the Goias (GO) NFC-e QRCode/urlChave host. +"""Regression tests for the Goias (GO) NFC-e ```` host. -Informe Tecnico 2025.003 moved the GO NFC-e consultation host from the old +Informe Tecnico 2025.003 moved the GO NFC-e QR Code host from the old ``nfe.sefaz.go.gov.br`` / ``homolog.sefaz.go.gov.br`` subdomains to ``nfeweb.sefaz.go.gov.br`` (producao) and ``nfewebhomolog.sefaz.go.gov.br`` (homologacao). Emitting with the old host triggers SEFAZ rejeicao 395 -("Informado QR-Code para NFC-e com formato invalido"). These tests pin both -the ```` base and the ```` so the host can never silently -regress to the rejected subdomain again. See DEV-2177. +("Informado QR-Code para NFC-e com formato invalido"). These tests pin the +```` base so the host can never silently regress to the rejected +subdomain again. See DEV-2177. + +The ```` is deliberately NOT asserted against this host: IT 2025.003 +defines only the QR Code address, and emitting it as ``urlChave`` is what made GO +reject 878. That field has its own registry and its own tests in +``test_nfce_urlchave_por_uf.py`` (DEV-2468). """ import unittest @@ -64,31 +69,21 @@ def _gerar(self, tp_amb): return qrcode, url_chave def test_qrcode_producao_usa_host_nfeweb(self): - qrcode, url_chave = self._gerar(TP_AMB_PRODUCAO) + qrcode, _ = self._gerar(TP_AMB_PRODUCAO) self.assertTrue( qrcode.startswith(QRCODE_HOST_PRODUCAO), f"qrCode de producao deve comecar com {QRCODE_HOST_PRODUCAO}, veio: {qrcode}", ) - self.assertTrue( - url_chave.startswith(QRCODE_HOST_PRODUCAO), - f"urlChave de producao deve comecar com {QRCODE_HOST_PRODUCAO}, veio: {url_chave}", - ) # Garante que nao reverteu para o subdominio rejeitado (rejeicao 395). self.assertNotIn("https://nfe.sefaz.go.gov.br", qrcode) - self.assertNotIn("https://nfe.sefaz.go.gov.br", url_chave) def test_qrcode_homologacao_usa_host_nfewebhomolog(self): - qrcode, url_chave = self._gerar(TP_AMB_HOMOLOGACAO) + qrcode, _ = self._gerar(TP_AMB_HOMOLOGACAO) self.assertTrue( qrcode.startswith(QRCODE_HOST_HOMOLOGACAO), f"qrCode de homologacao deve comecar com {QRCODE_HOST_HOMOLOGACAO}, veio: {qrcode}", ) - self.assertTrue( - url_chave.startswith(QRCODE_HOST_HOMOLOGACAO), - f"urlChave de homologacao deve comecar com {QRCODE_HOST_HOMOLOGACAO}, veio: {url_chave}", - ) self.assertNotIn("https://homolog.sefaz.go.gov.br", qrcode) - self.assertNotIn("https://homolog.sefaz.go.gov.br", url_chave) if __name__ == "__main__": diff --git a/tests/test_nfce_qrcode_hosts_por_uf.py b/tests/test_nfce_qrcode_hosts_por_uf.py index 75255e7e..a8a30c02 100644 --- a/tests/test_nfce_qrcode_hosts_por_uf.py +++ b/tests/test_nfce_qrcode_hosts_por_uf.py @@ -1,11 +1,15 @@ #!/usr/bin/env python # *-* encoding: utf8 *-* -"""Pins the ````/```` host of every UF whose branch reads a host prefix. +"""Pins the ```` host of every UF whose branch reads a host prefix. The GO fix (DEV-2468) routed those branches through ``webservices.qrcode_host`` so the QR host can no longer be taken from the authorizer keys. These tests lock the resulting URLs for SP, AM, BA and MG in both environments, which is what makes the refactor verifiable as byte-identical outside GO. + +``urlChave`` is asserted here only as the value that pairs with each of those QR hosts; the +field's own registry, property tests and non-regression set live in +``test_nfce_urlchave_por_uf.py``. """ import unittest @@ -44,7 +48,9 @@ "BA": { TP_AMB_PRODUCAO: ( "http://nfe.sefaz.ba.gov.br/servicos/nfce/qrcode.aspx?", - "http://hinternet.sefaz.ba.gov.br/nfce/consulta", + # ACBr [NFCe_BA_P] URL-ConsultaNFCe_2.00; o hinternet e o valor de homologacao, + # emitido em producao ate DEV-2468. + "http://www.sefaz.ba.gov.br/nfce/consulta", ), TP_AMB_HOMOLOGACAO: ( "http://hnfe.sefaz.ba.gov.br/servicos/nfce/qrcode.aspx?", diff --git a/tests/test_nfce_urlchave_por_uf.py b/tests/test_nfce_urlchave_por_uf.py new file mode 100644 index 00000000..d1135c96 --- /dev/null +++ b/tests/test_nfce_urlchave_por_uf.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python +# *-* encoding: utf8 *-* +"""Pins the ```` of every UF that can emit NFC-e. + +```` and ```` are different fields with different official registries. +GO rejects 878 ("Endereco do site da UF da Consulta por chave de acesso diverge do +previsto") when the QR Code address is emitted as ``urlChave`` - which is what PyNFe did +for GO until DEV-2468, because IT 2025.003 only ever defined the QR Code URL. + +Reference values come from the ACBr registry +(``ACBrNFeServicos.ini``, ``URL-ConsultaNFCe_2.00`` of ``[NFCe__P]`` / ``[NFCe__H]``), +cross-checked against the ENCAT listing for GO. The corrupted/outdated UFs left untouched in +this round are inventoried in +``docs/issues/followup-dev-2468-urlchave-ufs-sem-loja.md`` of the workspace repo, and pinned +here as exact sets so a NEW corruption fails the suite and fixing an old one forces the list +to shrink. +""" + +import unittest + +from lxml import etree + +from pynfe.processamento.serializacao import SerializacaoQrcode +from pynfe.utils.flags import CODIGOS_ESTADOS, NAMESPACE_NFE, NAMESPACE_SIG +from pynfe.utils.webservices import NFCE, url_consulta_chave + +TP_AMB_PRODUCAO = "1" +TP_AMB_HOMOLOGACAO = "2" + +# UFs corrigidas nesta rodada: valor verbatim do registro, por ambiente. +CORRIGIDAS = { + "GO": { + TP_AMB_PRODUCAO: "http://www.sefaz.go.gov.br/nfce/consulta", + TP_AMB_HOMOLOGACAO: ( + "http://www.nfce.go.gov.br/post/ver/214413/consulta-nfc-e-homologacao" + ), + }, + "BA": { + TP_AMB_PRODUCAO: "http://www.sefaz.ba.gov.br/nfce/consulta", + TP_AMB_HOMOLOGACAO: "http://hinternet.sefaz.ba.gov.br/nfce/consulta", + }, + "PR": { + TP_AMB_PRODUCAO: "http://www.fazenda.pr.gov.br/nfce/consulta", + TP_AMB_HOMOLOGACAO: "http://www.fazenda.pr.gov.br/nfce/consulta", + }, +} + +# UFs que ja batiam com o registro e nao podem mudar um byte. RJ carrega a maior parte da +# frota, entao e a nao-regressao mais importante do arquivo. +NAO_REGRESSAO = { + "RJ": { + TP_AMB_PRODUCAO: "www.fazenda.rj.gov.br/nfce/consulta", + TP_AMB_HOMOLOGACAO: "www.fazenda.rj.gov.br/nfce/consulta", + }, + "AC": { + TP_AMB_PRODUCAO: "http://www.sefaznet.ac.gov.br/nfce/consulta", + TP_AMB_HOMOLOGACAO: "http://hml.sefaznet.ac.gov.br/nfce/consulta", + }, + "DF": { + TP_AMB_PRODUCAO: "www.fazenda.df.gov.br/nfce/consulta", + TP_AMB_HOMOLOGACAO: "www.fazenda.df.gov.br/nfce/consulta", + }, + "PE": { + TP_AMB_PRODUCAO: "http://nfce.sefaz.pe.gov.br/nfce/consulta", + TP_AMB_HOMOLOGACAO: "http://nfcehomolog.sefaz.pe.gov.br/nfce/consulta", + }, + "SC": { + TP_AMB_PRODUCAO: "https://sat.sef.sc.gov.br/nfce/consulta", + TP_AMB_HOMOLOGACAO: "https://hom.sat.sef.sc.gov.br/nfce/consulta", + }, +} + +# UFs sem endereco de consulta cadastrado: nao emitem NFC-e por este fork. +UFS_SEM_URLCHAVE = frozenset({"AL", "MA", "MT", "PB", "PI", "RN", "TO"}) + +# Divergencias conhecidas e deliberadamente NAO corrigidas (sem loja para validar). +# "http" repetido = prefixo de host concatenado sobre uma URL que ja tinha esquema. +UFS_URLCHAVE_COM_ESQUEMA_DUPLICADO = frozenset({"AP", "MS"}) +# ".www." no meio do host = prefixo de host concatenado sobre um hostname completo. +UFS_URLCHAVE_COM_HOST_DUPLICADO = frozenset({"ES"}) +# UFs em que o registro oficial usa o MESMO endereco para consulta e QR Code (SC: o proprio +# ACBr registra sat.sef.sc.gov.br/nfce/consulta nos dois) ou em que o valor legado ainda +# aponta para o leitor de QR (RS, pendente de correcao). +UFS_URLCHAVE_IGUAL_AO_QRCODE = frozenset({"RS", "SC"}) + +UFS_EMISSORAS = sorted(set(NFCE) - {"SVRS"} - UFS_SEM_URLCHAVE) + + +def _nfe(uf, tp_amb): + cuf = CODIGOS_ESTADOS[uf] + chave = cuf + "0" * 42 + xml = ( + f'' + f'' + f"{cuf}2025-01-14T12:00:00-03:00" + f"{tp_amb}" + f"12345678900" + f"10.00" + f"" + f'' + f"ABCDEF==" + f"" + f"" + ) + return etree.fromstring(xml.encode()) + + +def _emitir(uf, tp_amb): + nfe, qrcode = SerializacaoQrcode().gerar_qrcode( + "000001", "CSC123", _nfe(uf, tp_amb), return_qr=True + ) + return qrcode, nfe.find("infNFeSupl").find("urlChave").text + + +class UrlChaveRegistroTestCase(unittest.TestCase): + def test_ufs_corrigidas_emitem_valor_do_registro(self): + for uf, ambientes in CORRIGIDAS.items(): + for tp_amb, esperado in ambientes.items(): + with self.subTest(uf=uf, tpAmb=tp_amb): + _, url_chave = _emitir(uf, tp_amb) + self.assertEqual(url_chave, esperado) + + def test_ufs_ja_corretas_nao_mudam(self): + for uf, ambientes in NAO_REGRESSAO.items(): + for tp_amb, esperado in ambientes.items(): + with self.subTest(uf=uf, tpAmb=tp_amb): + _, url_chave = _emitir(uf, tp_amb) + self.assertEqual(url_chave, esperado) + + def test_qrcode_de_go_continua_no_portal_do_it_2025_003(self): + """A correcao do urlChave nao pode arrastar o qrCode (DEV-2177/IT 2025.003).""" + qrcode, _ = _emitir("GO", TP_AMB_PRODUCAO) + self.assertTrue( + qrcode.startswith("https://nfeweb.sefaz.go.gov.br/nfeweb/sites/nfce/danfeNFCe?"), + f"qrCode de GO regrediu: {qrcode}", + ) + qrcode_hom, _ = _emitir("GO", TP_AMB_HOMOLOGACAO) + self.assertTrue( + qrcode_hom.startswith( + "https://nfewebhomolog.sefaz.go.gov.br/nfeweb/sites/nfce/danfeNFCe?" + ), + f"qrCode de GO em homologacao regrediu: {qrcode_hom}", + ) + + def test_ufs_sem_endereco_de_consulta_nao_emitem(self): + for uf in sorted(UFS_SEM_URLCHAVE): + with self.subTest(uf=uf): + with self.assertRaises(KeyError): + url_consulta_chave(uf, producao=True) + + +class UrlChaveIntegridadeTestCase(unittest.TestCase): + """Propriedades que valem para a tabela inteira, nao so para as UFs desta rodada.""" + + def test_urlchave_nunca_tem_esquema_concatenado(self): + encontradas = set() + for uf in UFS_EMISSORAS: + for tp_amb in (TP_AMB_PRODUCAO, TP_AMB_HOMOLOGACAO): + _, url_chave = _emitir(uf, tp_amb) + if url_chave.count("http") > 1: + encontradas.add(uf) + self.assertEqual( + encontradas, + set(UFS_URLCHAVE_COM_ESQUEMA_DUPLICADO), + "conjunto de UFs com esquema duplicado no urlChave mudou; corrija a UF ou " + "atualize UFS_URLCHAVE_COM_ESQUEMA_DUPLICADO", + ) + + def test_urlchave_nunca_tem_host_concatenado(self): + encontradas = set() + for uf in UFS_EMISSORAS: + for tp_amb in (TP_AMB_PRODUCAO, TP_AMB_HOMOLOGACAO): + _, url_chave = _emitir(uf, tp_amb) + host = url_chave.split("://")[-1].split("/")[0] + if ".www." in host: + encontradas.add(uf) + self.assertEqual( + encontradas, + set(UFS_URLCHAVE_COM_HOST_DUPLICADO), + "conjunto de UFs com host duplicado no urlChave mudou; corrija a UF ou " + "atualize UFS_URLCHAVE_COM_HOST_DUPLICADO", + ) + + def test_urlchave_nao_e_o_endereco_do_qrcode(self): + encontradas = set() + for uf in UFS_EMISSORAS: + for tp_amb in (TP_AMB_PRODUCAO, TP_AMB_HOMOLOGACAO): + qrcode, url_chave = _emitir(uf, tp_amb) + base_qr = qrcode.split("?")[0] + self.assertNotEqual(qrcode, url_chave, f"{uf}/{tp_amb}") + if base_qr == url_chave: + encontradas.add(uf) + self.assertEqual( + encontradas, + set(UFS_URLCHAVE_IGUAL_AO_QRCODE), + "conjunto de UFs cujo urlChave repete o endereco do qrCode mudou; foi " + "exatamente esse defeito que gerou a rejeicao 878 em GO", + ) + + +if __name__ == "__main__": + unittest.main() From 6d50c7bd63df58d8342d99816df86d9e7abd2777 Mon Sep 17 00:00:00 2001 From: Felipe Correa Date: Wed, 12 Aug 2026 16:13:56 -0300 Subject: [PATCH 10/10] docs(nfce): corrige regra falsa de urlChave e source maps do DEV-2468 [DEV-2468] (#11) Revisao da rodada DEV-2468 (PRs #8, #9, #10). Nenhuma mudanca de comportamento: so corrige documentacao que os proprios testes da rodada contradizem. 1. A regra "nenhuma UF usa o mesmo endereco no qrCode e no urlChave" era falsa em tres lugares (AGENTS.md, docstring de webservices.py, webservices_map.md). O teste da mesma rodada trava UFS_URLCHAVE_IGUAL_AO_QRCODE = {"RS", "SC"} e documenta que o ACBr registra o mesmo endereco nos dois campos para SC. Como estava, a regra levaria um agente futuro a "corrigir" SC, que esta pinado byte-identico. 2. A docstring de qrcode_host afirmava nao servir o urlChave, mas url_consulta_chave chama qrcode_host no caminho legado (UF sem CONSULTA_CHAVE). E justamente esse acoplamento que exigiu QR_HOST em AM e SP nesta rodada. 3. Source maps com numeros errados e contraditorios entre si: AGENTS.md dizia 2895 linhas para serializacao.py (real 2881) e 615 para webservices.py; a linha de SerializacaoNfse no serializacao_map.md apontava 2326-2392 (real 2312-2378, sobrepondo SerializacaoQrcodeMDFe); e todas as faixas de secao do webservices_map.md estavam deslocadas (NFE listada em 343-514, real 412-583). AGENTS.md torna a leitura do map obrigatoria antes de abrir arquivo grande, entao numero errado manda o proximo agente para a janela errada. Todas as faixas foram recalculadas do arquivo. --- AGENTS.md | 15 ++++---- docs/serializacao_map.md | 2 +- docs/webservices_map.md | 72 ++++++++++++++++++++------------------ pynfe/utils/webservices.py | 18 +++++++--- 4 files changed, 60 insertions(+), 47 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 068d303a..1f1bde1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,14 +15,14 @@ This allows you to navigate directly to the specific line-window you need instea | Source Map | File | Lines | Description | |------------|------|-------|-------------| -| `docs/serializacao_map.md` | `pynfe/processamento/serializacao.py` | 2895 | XML serialization (NF-e, MDF-e, QR codes) | +| `docs/serializacao_map.md` | `pynfe/processamento/serializacao.py` | 2881 | XML serialization (NF-e, MDF-e, QR codes) | | `docs/comunicacao_map.md` | `pynfe/processamento/comunicacao.py` | 1348 | SEFAZ webservice communication | | `docs/autorizador_nfse_map.md` | `pynfe/processamento/autorizador_nfse.py` | 538 | NFS-e authorization (Betha/Ginfes) | | `docs/notafiscal_map.md` | `pynfe/entidades/notafiscal.py` | 1253 | Invoice entities and tax fields | | `docs/manifesto_map.md` | `pynfe/entidades/manifesto.py` | 447 | MDF-e manifest entities | | `docs/evento_map.md` | `pynfe/entidades/evento.py` | 237 | Event entities (cancel, correction, etc.) | | `docs/flags_map.md` | `pynfe/utils/flags.py` | 645 | Constants, namespaces, tax codes | -| `docs/webservices_map.md` | `pynfe/utils/webservices.py` | 615 | SEFAZ endpoint URLs by state | +| `docs/webservices_map.md` | `pynfe/utils/webservices.py` | 684 | SEFAZ endpoint URLs by state | | `docs/utils_map.md` | `pynfe/utils/__init__.py` | 253 | Utility functions (municipality lookup, signing) | ### How to Use Source Maps @@ -120,14 +120,15 @@ ruff format pynfe/ serve both roles: a webservice pointed at the consultation portal gets a redirect plus HTML instead of a SEFAZ verdict, so emissions fail as transport errors with no rejeicao to explain them. When a UF changes its consultation host, touch only the `QR_*` keys -- **`` and `` are separate registries; no UF uses one address for both.** +- **`` and `` are separate registry entries; each is sourced on its own.** `urlChave` (consulta por chave de acesso) comes from `CONSULTA_CHAVE`/ `CONSULTA_CHAVE_HOMOLOGACAO` — the COMPLETE, verbatim URL from the official registry (`URL-ConsultaNFCe_2.00` in ACBr's `ACBrNFeServicos.ini`, cross-checked against ENCAT) — returned by `url_consulta_chave` with no host prefix ever concatenated onto it. GO rejects 878 when `urlChave` carries the QR Code address, and concatenating a host prefix onto an already-complete URL is what produced values like `https://nfce.http://www.dfe.ms.gov.br/…`. - Source the value from the registry, never infer it from a sibling UF; details and the - per-UF divergence inventory are in `docs/webservices_map.md` and - `tests/test_nfce_urlchave_por_uf.py`, and when a UF's - endpoint paths already embed a subdomain, the authorizer prefix is the scheme alone. + Source each field from its own registry entry, never from the sibling field and never from a + sibling UF — a few UFs legitimately register the same address for both, so equality is not by + itself the defect. Per-UF detail and the closed sets of known divergences live in + `docs/webservices_map.md` and `tests/test_nfce_urlchave_por_uf.py`. When a UF's endpoint paths + already embed a subdomain, the authorizer prefix is the scheme alone. diff --git a/docs/serializacao_map.md b/docs/serializacao_map.md index 8dd4b6b5..ab490e9f 100644 --- a/docs/serializacao_map.md +++ b/docs/serializacao_map.md @@ -9,7 +9,7 @@ XML serialization of NF-e, NFC-e, NFS-e and MDF-e documents into SEFAZ-compliant | `Serializacao` | 31-65 | Abstract base class (not instantiable directly) | | `SerializacaoXML` | 67-2222 | Main NF-e/NFC-e XML serialization | | `SerializacaoQrcode` | 2225-2309 | NFC-e QR Code generation | -| `SerializacaoNfse` | 2326-2392 | NFS-e serialization (Betha/Ginfes) | +| `SerializacaoNfse` | 2312-2378 | NFS-e serialization (Betha/Ginfes) | | `SerializacaoQrcodeMDFe` | 2381-2404 | MDF-e QR Code generation | | `SerializacaoMDFe` | 2407-2881 | MDF-e XML serialization | diff --git a/docs/webservices_map.md b/docs/webservices_map.md index 3401f178..395d8009 100644 --- a/docs/webservices_map.md +++ b/docs/webservices_map.md @@ -1,4 +1,4 @@ -# Source Map: `webservices.py` (673 lines) +# Source Map: `webservices.py` (684 lines) SEFAZ webservice endpoint URLs organized by document type, state, and environment. @@ -6,14 +6,14 @@ SEFAZ webservice endpoint URLs organized by document type, state, and environmen | Section | Lines | Variable | Purpose | |---------|-------|----------|---------| -| Host roles + `qrCode` vs `urlChave` | 1-40 | — | Module docstring: which key family each consumer may read | -| NFC-e endpoints | 45-359 | `NFCE` | NFC-e webservice URLs, QR Code URLs and consultation URLs by state | -| `qrcode_host` helper | 362-375 | — | Consultation-portal host prefix for `` | -| `url_consulta_chave` helper | 378-399 | — | Complete `` URL, never concatenated | -| NF-e endpoints | 401-572 | `NFE` | NF-e webservice URLs by state | -| NFS-e endpoints | 575-600 | `NFSE` | NFS-e URLs (Betha, Ginfes) | -| MDF-e endpoints | 603-617 | `MDFE` | MDF-e URLs (SVRS only) | -| CT-e endpoints | 619-673 | `CTE` | CT-e URLs by state | +| Host roles + `qrCode` vs `urlChave` | 1-44 | — | Module docstring: which key family each consumer may read | +| NFC-e endpoints | 49-363 | `NFCE` | NFC-e webservice URLs, QR Code URLs and consultation URLs by state | +| `qrcode_host` helper | 366-384 | — | Consultation-portal host prefix for `` | +| `url_consulta_chave` helper | 392-406 | — | Complete `` URL, never concatenated | +| NF-e endpoints | 412-583 | `NFE` | NF-e webservice URLs by state | +| NFS-e endpoints | 586-611 | `NFSE` | NFS-e URLs (Betha, Ginfes) | +| MDF-e endpoints | 614-628 | `MDFE` | MDF-e URLs (SVRS only) | +| CT-e endpoints | 630-684 | `CTE` | CT-e URLs by state | ## URL Structure @@ -42,48 +42,52 @@ The two host families must never be shared: a UF such as GO answers webservice P its consultation host with a load-balancer redirect and HTML, so the invoice never receives a SEFAZ verdict and the failure surfaces as a transport/XML-parse error, not a rejeicao. -`` and `` are likewise separate registries — no UF uses the QR Code address -as its consultation-by-key address. GO rejects 878 when they are the same. `urlChave` values -live in `CONSULTA_CHAVE*`; `qrCode` is built from `QR*` over `qrcode_host`. +`` and `` are likewise separate registries, each with its own entry in the +official listing, so one is never a safe substitute for the other: GO rejects 878 when +`urlChave` carries the QR Code address. Equality is not by itself the defect — the registry +does list the same address for both fields in a few UFs (SC today) — so always compare against +the registry entry for the field you are changing, never against the sibling field. `urlChave` +values live in `CONSULTA_CHAVE*`; `qrCode` is built from `QR*` over `qrcode_host`. The exact set +of UFs where the two addresses coincide is pinned in `tests/test_nfce_urlchave_por_uf.py`. ## State/Virtual Environment Groups -### NFC-e (`NFCE`) — Lines 24-323 +### NFC-e (`NFCE`) — Lines 49-363 | Key | Lines | Description | |-----|-------|-------------| -| Individual states | 25-311 | RO, AC, AM, RR, PA, AP, TO, MA, PI, CE, RN, PB, PE, AL, SE, BA, MG, ES, RJ, SP, PR, SC, RS, MS, MT, GO, DF | -| `SVRS` | 312-322 | Virtual SEFAZ RS (fallback for states without own NFC-e) | +| Individual states | 50-350 | RO, AC, AM, RR, PA, AP, TO, MA, PI, CE, RN, PB, PE, AL, SE, BA, MG, ES, RJ, SP, PR, SC, RS, MS, MT, GO, DF | +| `SVRS` | 352-362 | Virtual SEFAZ RS (fallback for states without own NFC-e) | -### NF-e (`NFE`) — Lines 343-514 +### NF-e (`NFE`) — Lines 412-583 | Key | Lines | Description | |-----|-------|-------------| -| `AN` | 345-352 | National environment (events, distribution) | -| Individual states | 353-472 | AM, MA, PE, BA, MG, SP, PR, RS, MS, MT, GO | -| `SVAN` | 473-483 | Virtual SEFAZ AN (MA for NF-e) | -| `SVRS` | 484-494 | Virtual SEFAZ RS (most states) | -| `SVC-AN` | 495-503 | Contingency AN | -| `SVC-RS` | 504-513 | Contingency RS | +| `AN` | 414-421 | National environment (events, distribution) | +| Individual states | 422-541 | AM, MA, PE, BA, MG, SP, PR, RS, MS, MT, GO | +| `SVAN` | 542-552 | Virtual SEFAZ AN (MA for NF-e) | +| `SVRS` | 553-563 | Virtual SEFAZ RS (most states) | +| `SVC-AN` | 564-572 | Contingency AN | +| `SVC-RS` | 573-582 | Contingency RS | -### NFS-e (`NFSE`) — Lines 517-542 +### NFS-e (`NFSE`) — Lines 586-611 | Key | Lines | Description | |-----|-------|-------------| -| `BETHA` | 519-530 | Betha provider (HTTP WSDL) | -| `GINFES` | 531-541 | Ginfes provider (HTTPS WSDL) | +| `BETHA` | 588-598 | Betha provider (HTTP WSDL) | +| `GINFES` | 600-610 | Ginfes provider (HTTPS WSDL) | -### MDF-e (`MDFE`) — Lines 545-559 -Only `SVRS` (547-558) — single authorizer for all states. +### MDF-e (`MDFE`) — Lines 614-628 +Only `SVRS` (616-627) — single authorizer for all states. -### CT-e (`CTE`) — Lines 561-615 +### CT-e (`CTE`) — Lines 630-684 | Key | Lines | Description | |-----|-------|-------------| -| `AN` | 562-566 | National environment (distribution) | -| Individual states | 567-602 | MT, MS, MG, PR, RS, SP | -| `SVRS` | 603-608 | Virtual SEFAZ RS | -| `SVSP` | 609-614 | Virtual SEFAZ SP (AP, PE, RR) | +| `AN` | 631-635 | National environment (distribution) | +| Individual states | 636-671 | MT, MS, MG, PR, RS, SP | +| `SVRS` | 672-677 | Virtual SEFAZ RS | +| `SVSP` | 678-683 | Virtual SEFAZ SP (AP, PE, RR) | ## Helpers | Function | Lines | Purpose | |----------|-------|---------| -| `qrcode_host(uf, producao=True)` | 362-375 | Consultation-portal host prefix for ``, with fallback to the webservice host | -| `url_consulta_chave(uf, producao=True)` | 378-399 | Complete `` URL from `CONSULTA_CHAVE*`, falling back to the legacy `URL` path | +| `qrcode_host(uf, producao=True)` | 366-384 | Consultation-portal host prefix for ``, with fallback to the webservice host | +| `url_consulta_chave(uf, producao=True)` | 392-406 | Complete `` URL from `CONSULTA_CHAVE*`, falling back to the legacy `URL` path (which it prefixes with `qrcode_host`) | diff --git a/pynfe/utils/webservices.py b/pynfe/utils/webservices.py index b7b3fe71..d2bdce86 100644 --- a/pynfe/utils/webservices.py +++ b/pynfe/utils/webservices.py @@ -19,8 +19,8 @@ ``qrCode`` vs ``urlChave`` ------------------------- -Sao dois campos do ```` com registros oficiais DIFERENTES, e nenhuma UF -usa o mesmo endereco nos dois: +Sao dois campos do ```` com registros oficiais DIFERENTES, cada um lido da +sua propria entrada - nunca do campo irmao: - ````: endereco do leitor de QR Code (``QR``/``QR_HOMOLOGACAO`` sobre ``qrcode_host``). @@ -36,7 +36,11 @@ declare ``CONSULTA_CHAVE`` em vez de mexer em ``URL``. Usar o endereco de QR Code no ``urlChave`` gera rejeicao 878 em GO ("Endereco do site -da UF da Consulta por chave de acesso diverge do previsto") - DEV-2468. +da UF da Consulta por chave de acesso diverge do previsto") - DEV-2468. A igualdade entre +os dois campos nao e por si um defeito: em algumas UFs o registro oficial declara o mesmo +endereco nos dois (SC hoje). O conjunto fechado dessas UFs esta travado em +``tests/test_nfce_urlchave_por_uf.py``; compare sempre com a entrada do registro do campo +que voce esta mexendo. """ # http://nfce.encat.org/desenvolvedor/qrcode/ @@ -366,8 +370,12 @@ def qrcode_host(uf, producao=True): declaram host de consulta proprio (portal e autorizador no mesmo servidor), cai no host de webservice, preservando o comportamento historico. - Nao serve o ````: esse campo tem registro proprio e URL completa, ver - ``url_consulta_chave``. + Uma UF com ``CONSULTA_CHAVE`` nunca passa por aqui para montar o ```` - + esse campo tem registro proprio e URL completa, ver ``url_consulta_chave``. So o + caminho legado (UF sem ``CONSULTA_CHAVE``) ainda concatena este prefixo sobre ``URL``, + e e por isso que corrigir ``HTTPS``/``HOMOLOGACAO`` de uma dessas UFs move tambem o + ``urlChave`` dela: declare ``QR_HOST``/``QR_HOST_HOMOLOGACAO`` antes de mexer no host + de autorizador, como foi feito em AM e SP. """ dados = NFCE[uf] chave_qr = "QR_HOST" if producao else "QR_HOST_HOMOLOGACAO"