diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..1be0cc23 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "cat \"${CLAUDE_PROJECT_DIR}/AGENTS.md\"" + } + ] + } + ] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce7f1f46..259a9305 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} diff --git a/AGENTS.md b/AGENTS.md index 63cc9e21..4533eba4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,115 +1,46 @@ -# AGENTS.md - PyNFe +# AGENTS.md — PyNFe -Brazilian electronic fiscal document library (NF-e, NFC-e, NFS-e, MDF-e, CT-e) for SEFAZ webservice communication. +Brazilian electronic fiscal document library for NF-e, NFC-e, NFS-e, MDF-e, CT-e, and SEFAZ communication. `AGENTS.md` is the sole canonical instruction source; client settings may load it but must not copy its policies. -## Source Map Navigation (MANDATORY) +## Bootstrap and Git identity -**Before reading any large file (>200 lines), you MUST first read its `{filename}_map.md` file** in the `docs/` directory. The source map contains: -- Section-by-section breakdown with exact line ranges -- Class/method index with purpose descriptions -- Field group documentation +- Resolve the repository with `git rev-parse --show-toplevel`. For shared Nuvel resources, prefer a validated `NUVEL_WORKSPACE_ROOT`; otherwise derive candidates from the common Git directory/original checkout and accept only a directory containing `.claude/workflows/issue-orchestrator.js`. Never assume `../docs` works from a detached worktree. +- Commit with the machine's global Git identity exactly as configured. Never set or override `user.name`, `user.email`, signing settings, author/committer environment variables, or `.git/config`. Derive branch prefixes and paths; never hardcode a developer identity or home path. +- Preserve user changes. Use focused branches and squash PR merges. Verify official SEFAZ/provider specifications before changing external fiscal contracts. -This allows you to navigate directly to the specific line-window you need instead of reading the entire file. +## Navigation gate -### Available Source Maps +Before reading a mapped source file longer than 200 lines, read its map in `docs/` and then open only the relevant source range. The maps cover `serializacao.py`, `comunicacao.py`, `autorizador_nfse.py`, `notafiscal.py`, `manifesto.py`, `evento.py`, `flags.py`, `webservices.py`, and `utils/__init__.py`; use [`docs/README.md`](docs/README.md) to select one. -| Source Map | File | Lines | Description | -|------------|------|-------|-------------| -| `docs/serializacao_map.md` | `pynfe/processamento/serializacao.py` | 2630 | 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/utils_map.md` | `pynfe/utils/__init__.py` | 253 | Utility functions (municipality lookup, signing) | +## Commands and full checks -### How to Use Source Maps +Use Python 3.9+ in an isolated environment. -1. **Read the `_map.md` file first** to understand the file structure -2. **Identify the line range** you need from the map tables -3. **Read only that section** using `offset` and `limit` parameters -4. Example: To understand ICMS CST 60 serialization, read `docs/serializacao_map.md`, find it's at lines 747-770, then read `pynfe/processamento/serializacao.py` with `offset=747, limit=25` - -## Project Structure - -``` -docs/ # Documentation and source maps (*_map.md, reforma_tributaria.md) -pynfe/ -├── entidades/ # Domain entities (data models) -│ ├── base.py # Base entity class with kwargs init -│ ├── certificado.py # A1 certificate handling -│ ├── cliente.py # Customer entity -│ ├── emitente.py # Issuer entity -│ ├── evento.py # Event entities (cancel, correction, MDF-e events) -│ ├── manifesto.py # MDF-e manifest entity -│ ├── notafiscal.py # NF-e/NFC-e invoice entity + products + taxes -│ ├── produto.py # Product entity (standalone) -│ ├── servico.py # Service entity (NFS-e) -│ └── transportadora.py # Carrier entity -├── processamento/ # Core processing logic -│ ├── assinatura.py # XML digital signing with A1 certificates -│ ├── autorizador_nfse.py # NFS-e serialization (Betha/Ginfes PyXB bindings) -│ ├── comunicacao.py # SEFAZ SOAP webservice communication -│ ├── serializacao.py # XML serialization (entities → SEFAZ XML) -│ └── validacao.py # XML schema validation -├── utils/ # Utilities -│ ├── __init__.py # Municipality lookup, XML signing helpers -│ ├── flags.py # Constants, namespaces, tax code enumerations -│ ├── webservices.py # SEFAZ endpoint URLs by state/environment -│ ├── bar_code_128.py # Code 128 barcode generation for DANFE -│ ├── xml_writer.py # XML element writing helpers -│ └── nfse/ # NFS-e provider-specific PyXB bindings (GENERATED - do not edit) -│ ├── betha/ # Betha provider bindings (13,941+ lines) -│ └── ginfes/ # Ginfes provider bindings (8,028+ lines) -├── data/ # Reference data files -│ ├── IBPT/ # Tax tables by state (CSV) -│ ├── ISSQN/ # Service tax classification -│ ├── MunIBGE/ # Municipality IBGE codes by UF -│ └── XSDs/ # XML Schema definitions (NF-e, NFC-e, NFS-e, MDF-e, CT-e) -tests/ # Test suite (37 test files) +```bash +python -m pip install --upgrade pip +python -m pip install build -e . -r requirements.txt -r requirements-dev.txt -r requirements-nfse.txt +pytest -v +ruff check . +ruff format --check . +python -m build +git diff --check ``` -## Key Concepts - -- **NF-e** (modelo 55): Standard electronic invoice -- **NFC-e** (modelo 65): Consumer electronic invoice (retail) -- **NFS-e**: Municipal service invoice (Betha/Ginfes providers) -- **MDF-e** (modelo 58): Transport manifest -- **CT-e**: Transport knowledge document (partial support) -- **SEFAZ**: State tax authority webservices -- **Homologacao**: Test environment (`_ambiente=2`) -- **SVRS/SVAN**: Virtual SEFAZ environments for states without own webservices +`requirements-dev.txt` pins Ruff 0.12.5, matching the existing Poetry minimum; use that environment so lint results are reproducible. -## Commands +A focused `pytest tests/.py` is useful during development but never replaces the full suite. Project metadata requires Python 3.9+, and CI tests every supported minor from 3.9 through 3.13; do not claim support outside that declared range or weaken it to accommodate an obsolete runner. Pushes and PRs run format, lint, and tests across the CI matrix. Every push also builds distributions; publishing occurs only for tags. Do not create a tag or publish without explicit authorization. -```bash -# Run tests -pytest tests/ - -# Run specific test -pytest tests/test_nfe_serializacao_geral.py +## Architecture and invariants -# Lint -ruff check pynfe/ - -# Format -ruff format pynfe/ -``` +- `pynfe/entidades/` contains fiscal domain entities; `pynfe/processamento/` owns signing, serialization, communication, and validation; `pynfe/utils/` contains flags, endpoint tables, XML helpers, and generated NFS-e bindings; `pynfe/data/` holds schemas and reference data. +- Never manually edit generated PyXB bindings under `pynfe/utils/nfse/`. Treat XSDs and reference tables as controlled fiscal inputs; change them only with verified source material. +- SEFAZ XML order, namespaces, decimal formatting, optional element rules, endpoint/environment selection, and signature/certificate handling are protocol contracts. Preserve them and add fixture-based tests for changed output. +- Extend existing entities, flags, serializers, and communication paths before creating parallel models or pipelines. Keep public APIs backward-compatible unless a breaking release is explicitly authorized. +- Tests must exercise real entity serialization/validation state. Do not mock core entity or XML behavior merely to simulate fiscal success or failure. +- Read [`docs/reforma_tributaria.md`](docs/reforma_tributaria.md) before changing IBS/CBS or tax-reform fields. ## Dependencies -- `lxml` — XML processing -- `signxml` — XML digital signatures -- `cryptography` / `pyopenssl` — Certificate handling -- `requests` — HTTP communication with SEFAZ -- `suds-community` — SOAP client (NFS-e only) -- `PyXB-X` — XML Schema bindings (NFS-e only) - -## Important Notes +Core dependencies include `lxml`, `signxml`, `cryptography`/`pyopenssl`, and `requests`; NFS-e additionally uses `suds-community` and `PyXB-X`. Do not casually broaden supported Python/dependency ranges because certificate and generated-binding compatibility is sensitive. -- Files under `pynfe/utils/nfse/` are **auto-generated** PyXB bindings — do not edit manually -- 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` +Language: anything not customer-visible (docs, rules, prompts, code comments, commits, PRs) is written in English; only customer-facing copy stays PT-BR. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..933a29a4 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,16 @@ +# Documentation index + +| Document | What it covers | +|---|---| +| [reforma_tributaria.md](reforma_tributaria.md) | Brazilian tax reform support and fiscal serialization decisions. | +| [serializacao_map.md](serializacao_map.md) | Navigation map for NF-e, NFC-e, and MDF-e XML serialization. | +| [comunicacao_map.md](comunicacao_map.md) | Navigation map for SEFAZ communication. | +| [autorizador_nfse_map.md](autorizador_nfse_map.md) | Navigation map for municipal NFS-e authorization. | +| [notafiscal_map.md](notafiscal_map.md) | Navigation map for invoice entities and tax fields. | +| [manifesto_map.md](manifesto_map.md) | Navigation map for MDF-e entities. | +| [evento_map.md](evento_map.md) | Navigation map for fiscal event entities. | +| [flags_map.md](flags_map.md) | Navigation map for fiscal constants, namespaces, and codes. | +| [webservices_map.md](webservices_map.md) | Navigation map for endpoints by state and environment. | +| [utils_map.md](utils_map.md) | Navigation map for shared utility functions. | + +Read the matching source map before opening any mapped source file longer than 200 lines. Repository-wide commands and invariants live in [`../AGENTS.md`](../AGENTS.md). diff --git a/docs/reforma_tributaria.md b/docs/reforma_tributaria.md index cae67eb2..a8fccf59 100644 --- a/docs/reforma_tributaria.md +++ b/docs/reforma_tributaria.md @@ -1,98 +1,133 @@ -# Reforma Tributaria - IBS/CBS (NT 2025.002-RTC) +# Tax Reform (Reforma Tributaria) - IBS/CBS (NT 2025.002-RTC) -Suporte ao IVA Dual brasileiro conforme a Emenda Constitucional 132/2023, Lei Complementar 214/2025 e Nota Tecnica NT 2025.002-RTC. +Support for the Brazilian Dual VAT (IVA Dual) per Constitutional Amendment 132/2023, Complementary Law 214/2025 and Technical Note NT 2025.002-RTC. -## Contexto +## Context -A Reforma Tributaria substitui gradualmente cinco tributos por um modelo de IVA Dual: +The Tax Reform gradually replaces five taxes with a Dual VAT model: -| Imposto | Tipo | Substitui | Competencia | Inicio | +| Tax | Type | Replaces | Jurisdiction | Start | |---------|------|-----------|-------------|--------| -| **CBS** (Contribuicao sobre Bens e Servicos) | Federal | PIS, COFINS | Uniao | 2026 (teste 0,9%) | -| **IBS** (Imposto sobre Bens e Servicos) | Subnacional | ICMS, ISS | Estados + Municipios | 2026 (teste 0,1% UF) | -| **IS** (Imposto Seletivo) | Extrafiscal | IPI (parcial) | Uniao | **2027** | +| **CBS** (Contribuicao sobre Bens e Servicos) | Federal | PIS, COFINS | Union | 2026 (test 0.9%) | +| **IBS** (Imposto sobre Bens e Servicos) | Subnational | ICMS, ISS | States + Municipalities | 2026 (test 0.1% UF) | +| **IS** (Imposto Seletivo) | Extrafiscal | IPI (partial) | Union | **2027** | -> **IBS e dividido em duas parcelas**: IBS-UF (estadual) e IBS-Mun (municipal), informadas separadamente no XML. +> **IBS is split into two parts**: IBS-UF (state) and IBS-Mun (municipal), reported separately in the XML. -### Cronograma de transicao +### Transition schedule -- **2026**: Fase de testes — CBS 0,9%, IBS-UF 0,1%, IBS-Mun 0%. Obrigatorio para CRT=3. -- **2027**: CBS em aliquota plena. IS entra em vigor. Split Payment inicia. -- **2029-2032**: Reducao gradual de ICMS/ISS, aumento proporcional do IBS. -- **2033**: Extincao completa de ICMS, ISS, PIS, COFINS e IPI. +- **2026**: Test phase — CBS 0.9%, IBS-UF 0.1%, IBS-Mun 0%. Mandatory for CRT=3. +- **2027**: CBS at full rate. IS takes effect. Split Payment starts. +- **2029-2032**: Gradual reduction of ICMS/ISS, proportional increase of IBS. +- **2033**: Full extinction of ICMS, ISS, PIS, COFINS and IPI. -Durante a transicao, os impostos legados (ICMS, PIS, COFINS) **coexistem** com os novos (IBS, CBS) na mesma NF-e. +During the transition, the legacy taxes (ICMS, PIS, COFINS) **coexist** with the new ones (IBS, CBS) in the same NF-e. -## Escopo da implementacao +## Implementation scope -A implementacao cobre: +The implementation covers: -- Grupo `` como filho direto de `` (sem wrapper ``) -- CSTs de 3 digitos conforme NT 2025.002-RTC (ex: "000", "010", "222") -- `cClassTrib` (codigo de classificacao tributaria de 6 digitos) -- IBS dividido em `gIBSUF` e `gIBSMun` -- Base de calculo compartilhada (`vBC`) para IBS e CBS -- Totais em `` separado de `` -- `cMunFGIBS` no cabecalho (Group B) -- `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) +- Grupo `` as a direct child of `` (no `` wrapper) +- 3-digit CSTs per NT 2025.002-RTC (e.g. "000", "010", "222") +- `cClassTrib` (6-digit tax classification code) +- IBS split into `gIBSUF` and `gIBSMun` +- Shared tax base (`vBC`) for IBS and CBS +- Totals in ``, separate from `` +- `cMunFGIBS` in the header (Group B) +- `vNF` **does NOT include** IBS/CBS (forbidden in 2025-2026) +- `finNFe=5` (Debit Note) and `finNFe=6` (Credit Note) +- Entity fields for IS (Selective Tax) — **stored but not serialized** until the schema supports it (2027) +- Single-phase taxation (`gIBSCBSMono`) for CST 620 — fuels and other products under the IBS/CBS single-phase regime -**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`). +**Does not include** (yet): Split Payment, cashback, assisted assessment events, Group VB (item total), Group VC (DF-e referencing), Group BB (payment anticipation), per-item deferral (`gDif`), per-item tax refund (`gDevTrib`), per-item rate reduction (`gRed`), credit reversal (`gEstornoCred`), per-item presumed credit (`gCredPresOper`, `gCredPresIBSZFM`). -## CSTs disponiveis +### Single-phase taxation — `gIBSCBSMono` -### IBS e CBS — CST de 3 digitos (compartilham a mesma tabela) +For products with CST 620 (fuels, etc.) the group emitted inside `` is `` instead of ``. Per the official schema (`DFeTiposBasicos_v1.00.xsd`, type `TMonofasia`), the five single-phase fields live under the mandatory `` wrapper, in the order defined by the schema. In addition, `` and `` are SIBLINGS of `` (NOT children) and both are MANDATORY per schema (no `minOccurs=0`): + +```xml + + + 18.0000 + 0.1000 + 0.0000 + 1.80 + 0.00 + + 1.80 + 0.00 + +``` + +| Field | Type | Description | +|-------|------|-----------| +| `qBCMono` | TDec1104RTC | Quantity taxed on the single-phase base | +| `adRemIBS` | TDec_0302_04RTC | IBS ad rem rate (BRL amount per unit) | +| `adRemCBS` | TDec_0302_04RTC | CBS ad rem rate (BRL amount per unit) | +| `vIBSMono` | TDec1302RTC | Single-phase IBS amount | +| `vCBSMono` | TDec1302RTC | Single-phase CBS amount | +| `vTotIBSMonoItem` | TDec1302RTC | Item single-phase IBS total (sibling of `gMonoPadrao`) | +| `vTotCBSMonoItem` | TDec1302RTC | Item single-phase CBS total (sibling of `gMonoPadrao`) | + +Attributes on the `NotaFiscalProduto` entity: `ibscbs_q_bc_mono`, `ibscbs_ad_rem_ibs`, `ibscbs_v_ibs_mono`, `ibscbs_ad_rem_cbs`, `ibscbs_v_cbs_mono`, `ibscbs_v_tot_ibs_mono_item`, `ibscbs_v_tot_cbs_mono_item`. + +For single-line items (no withholding / prior withholding / deferral), `vTotIBSMonoItem == vIBSMono` and `vTotCBSMonoItem == vCBSMono`. When the caller does not provide the attributes, the serializer emits `0.00` (safe default during the 2026 Load Test with zeroed ad rem rates). + +During the 2026 Load Test SEFAZ has not published the ad rem rates yet, so the values may be zero — the `gIBSCBSMono` group is still emitted correctly. + +## Available CSTs + +### IBS and CBS — 3-digit CST (they share the same table) ```python from pynfe.utils.flags import IBS_CBS_TIPOS_TRIBUTACAO ``` -| CST | Descricao | +| CST | Description | |-----|-----------| -| 000 | Tributacao integral | -| 010 | Tributacao com aliquota reduzida | -| 100 | Tributacao com suspensao | -| 110 | Imunidade — exportacao | -| 200 | Tributacao com diferimento | -| 222 | Isencao | -| 300 | Nao incidencia | -| 400 | Tributacao por substituicao | -| 410 | Tributacao com aliquota zero | -| 510 | Tributacao integral — regime especifico | -| 600 | Tributacao monofasica — incidencia padrao | -| 620 | Tributacao monofasica — demais operacoes | -| 800 | Credito presumido | -| 810 | Credito presumido — ZFM | -| 900 | Outros | - -### IS (Imposto Seletivo) — CSTIS de 2 digitos +| 000 | Full taxation | +| 010 | Taxation at a reduced rate | +| 100 | Taxation with suspension | +| 110 | Immunity — export | +| 200 | Taxation with deferral | +| 222 | Exemption | +| 300 | Non-incidence | +| 400 | Taxation by substitution | +| 410 | Taxation at zero rate | +| 510 | Full taxation — specific regime | +| 600 | Single-phase taxation — standard incidence | +| 620 | Single-phase taxation — other operations | +| 800 | Presumed credit | +| 810 | Presumed credit — ZFM | +| 900 | Other | + +### IS (Imposto Seletivo, Selective Tax) — 2-digit CSTIS ```python from pynfe.utils.flags import IS_TIPOS_TRIBUTACAO ``` -| CSTIS | Descricao | +| CSTIS | Description | |-------|-----------| -| 01 | Tributada integralmente | -| 02 | Tributada com reducao | -| 03 | Isencao | -| 04 | Imunidade | -| 05 | Suspensao | -| 06 | Diferimento | -| 90 | Outros | +| 01 | Fully taxed | +| 02 | Taxed with reduction | +| 03 | Exemption | +| 04 | Immunity | +| 05 | Suspension | +| 06 | Deferral | +| 90 | Other | -> **IS nao e serializado no XML em 2026.** O schema SEFAZ (PL 010b) inclui o tipo `TIS` com tags `CSTIS`, `cClassTribIS`, `vBCIS`, `pIS`, `vIS`, porem a serializacao esta desabilitada ate 2027. Os campos de entidade estao prontos e serao ativados quando necessario. +> **IS is not serialized in the XML in 2026.** The SEFAZ schema (PL 010b) includes the `TIS` type with tags `CSTIS`, `cClassTribIS`, `vBCIS`, `pIS`, `vIS`, but serialization is disabled until 2027. The entity fields are ready and will be enabled when needed. -## Como usar +## How to use -### Exemplo: produto com IBSCBS (tributacao integral CST 000) +### Example: product with IBSCBS (full taxation, CST 000) ```python from decimal import Decimal nota_fiscal.adicionar_produto_servico( - # ... campos obrigatorios do produto ... + # ... mandatory product fields ... codigo="001", descricao="Produto exemplo", ncm="99999999", @@ -108,7 +143,7 @@ nota_fiscal.adicionar_produto_servico( ean_tributavel="SEM GTIN", ind_total=1, - # ... impostos legados (ICMS, PIS, COFINS) continuam obrigatorios ... + # ... legacy taxes (ICMS, PIS, COFINS) are still mandatory ... icms_modalidade="00", icms_origem=0, icms_csosn="", @@ -118,18 +153,18 @@ nota_fiscal.adicionar_produto_servico( cofins_valor=Decimal("0.00"), # IBSCBS — Group UB - ibscbs_cst="000", # CST 3 digitos - ibscbs_c_class_trib="000001", # cClassTrib 6 digitos - ibscbs_vbc=Decimal("1000.00"), # Base de calculo compartilhada IBS+CBS - ibscbs_p_ibs_uf=Decimal("0.1000"), # Aliquota IBS UF (4 casas) - ibscbs_v_ibs_uf=Decimal("1.00"), # Valor IBS UF - ibscbs_p_ibs_mun=Decimal("0.0000"), # Aliquota IBS Municipal - ibscbs_v_ibs_mun=Decimal("0.00"), # Valor IBS Municipal - ibscbs_v_ibs=Decimal("1.00"), # Valor total IBS (UF + Mun) - ibscbs_p_cbs=Decimal("0.9000"), # Aliquota CBS (4 casas) - ibscbs_v_cbs=Decimal("9.00"), # Valor CBS - - # IS — armazenado na entidade mas NAO serializado no XML (2026) + ibscbs_cst="000", # 3-digit CST + ibscbs_c_class_trib="000001", # 6-digit cClassTrib + ibscbs_vbc=Decimal("1000.00"), # Shared IBS+CBS tax base + ibscbs_p_ibs_uf=Decimal("0.1000"), # IBS UF rate (4 decimals) + ibscbs_v_ibs_uf=Decimal("1.00"), # IBS UF amount + ibscbs_p_ibs_mun=Decimal("0.0000"), # Municipal IBS rate + ibscbs_v_ibs_mun=Decimal("0.00"), # Municipal IBS amount + ibscbs_v_ibs=Decimal("1.00"), # Total IBS amount (UF + Mun) + ibscbs_p_cbs=Decimal("0.9000"), # CBS rate (4 decimals) + ibscbs_v_cbs=Decimal("9.00"), # CBS amount + + # IS — stored on the entity but NOT serialized in the XML (2026) # is_cst_selec="01", # is_c_class_trib="010001", # is_vbc=Decimal("1000.00"), @@ -138,93 +173,93 @@ nota_fiscal.adicionar_produto_servico( ) ``` -### Exemplo: produto isento (CST 222) +### Example: exempt product (CST 222) ```python nota_fiscal.adicionar_produto_servico( - # ... campos do produto ... + # ... product fields ... - # IBSCBS isento — apenas CST e cClassTrib, sem valores + # Exempt IBSCBS — only CST and cClassTrib, no amounts ibscbs_cst="222", ibscbs_c_class_trib="000002", ) ``` -### Exemplo: sem reforma tributaria (compatibilidade retroativa) +### Example: without the tax reform (backward compatibility) -Se nenhum campo `ibscbs_*` for informado, o grupo `` **nao e emitido** no XML. Isso garante compatibilidade total com NF-e que nao precisam dos novos impostos. +If no `ibscbs_*` field is provided, the `` group **is not emitted** in the XML. This guarantees full compatibility with NF-e that do not need the new taxes. ```python -# Produto sem campos de reforma = XML identico ao anterior +# Product without reform fields = XML identical to before nota_fiscal.adicionar_produto_servico( - # ... apenas campos legados (ICMS, PIS, COFINS) ... + # ... only legacy fields (ICMS, PIS, COFINS) ... ) ``` -### cMunFGIBS no cabecalho +### cMunFGIBS in the header ```python nota_fiscal = NotaFiscal( - # ... demais campos ... - municipio_fato_gerador_ibs="4118402", # Codigo IBGE — gera no + # ... other fields ... + municipio_fato_gerador_ibs="4118402", # IBGE code — generates in ) ``` -## Campos disponiveis +## Available fields -### Campos do produto (`adicionar_produto_servico`) +### Product fields (`adicionar_produto_servico`) #### IBSCBS (Group UB) -| Campo | Tipo | Descricao | +| Field | Type | Description | |-------|------|-----------| -| `ibscbs_cst` | `str` | CST 3 digitos (ex: "000", "222") | -| `ibscbs_c_class_trib` | `str` | cClassTrib 6 digitos (classificacao tributaria) | -| `ibscbs_vbc` | `Decimal` | Base de calculo compartilhada IBS + CBS | -| `ibscbs_p_ibs_uf` | `Decimal` | Aliquota IBS estadual (4 casas decimais) | -| `ibscbs_v_ibs_uf` | `Decimal` | Valor IBS estadual | -| `ibscbs_p_ibs_mun` | `Decimal` | Aliquota IBS municipal (4 casas decimais) | -| `ibscbs_v_ibs_mun` | `Decimal` | Valor IBS municipal | -| `ibscbs_v_ibs` | `Decimal` | Valor total IBS (UF + Mun) | -| `ibscbs_p_cbs` | `Decimal` | Aliquota CBS (4 casas decimais) | -| `ibscbs_v_cbs` | `Decimal` | Valor CBS | - -#### IS (Imposto Seletivo) — armazenado, nao serializado em 2026 - -| Campo | XML Tag | Descricao | +| `ibscbs_cst` | `str` | 3-digit CST (e.g. "000", "222") | +| `ibscbs_c_class_trib` | `str` | 6-digit cClassTrib (tax classification) | +| `ibscbs_vbc` | `Decimal` | Shared IBS + CBS tax base | +| `ibscbs_p_ibs_uf` | `Decimal` | State IBS rate (4 decimal places) | +| `ibscbs_v_ibs_uf` | `Decimal` | State IBS amount | +| `ibscbs_p_ibs_mun` | `Decimal` | Municipal IBS rate (4 decimal places) | +| `ibscbs_v_ibs_mun` | `Decimal` | Municipal IBS amount | +| `ibscbs_v_ibs` | `Decimal` | Total IBS amount (UF + Mun) | +| `ibscbs_p_cbs` | `Decimal` | CBS rate (4 decimal places) | +| `ibscbs_v_cbs` | `Decimal` | CBS amount | + +#### IS (Selective Tax) — stored, not serialized in 2026 + +| Field | XML Tag | Description | |-------|---------|-----------| -| `is_cst_selec` | `CSTIS` | CSTIS 2 digitos (ex: "01", "02") | -| `is_c_class_trib` | `cClassTribIS` | cClassTribIS 6 digitos | -| `is_vbc` | `vBCIS` | Base de calculo IS | -| `is_aliquota` | `pIS` | Aliquota IS (4 casas decimais) | -| `is_valor` | `vIS` | Valor IS | +| `is_cst_selec` | `CSTIS` | 2-digit CSTIS (e.g. "01", "02") | +| `is_c_class_trib` | `cClassTribIS` | 6-digit cClassTribIS | +| `is_vbc` | `vBCIS` | IS tax base | +| `is_aliquota` | `pIS` | IS rate (4 decimal places) | +| `is_valor` | `vIS` | IS amount | -### Totais da nota fiscal (acumulados automaticamente) +### Invoice totals (accumulated automatically) -| Campo | XML Path em `IBSCBSTot` | Descricao | +| Field | XML path in `IBSCBSTot` | Description | |-------|-------------------------|-----------| -| `totais_vbc_ibscbs` | `vBCIBSCBS` | Soma de `ibscbs_vbc` de todos os produtos | -| `totais_ibs_uf` | `gIBS/gIBSUF/vIBSUF` | Soma de `ibscbs_v_ibs_uf` de todos os produtos | -| `totais_ibs_mun` | `gIBS/gIBSMun/vIBSMun` | Soma de `ibscbs_v_ibs_mun` de todos os produtos | -| `totais_ibs` | `gIBS/vIBS` | Soma de `ibscbs_v_ibs` de todos os produtos | -| `totais_cbs` | `gCBS/vCBS` | Soma de `ibscbs_v_cbs` de todos os produtos | -| `totais_is` | *(nao emitido)* | Soma de `is_valor` — acumulado internamente, nao no XML | +| `totais_vbc_ibscbs` | `vBCIBSCBS` | Sum of `ibscbs_vbc` across all products | +| `totais_ibs_uf` | `gIBS/gIBSUF/vIBSUF` | Sum of `ibscbs_v_ibs_uf` across all products | +| `totais_ibs_mun` | `gIBS/gIBSMun/vIBSMun` | Sum of `ibscbs_v_ibs_mun` across all products | +| `totais_ibs` | `gIBS/vIBS` | Sum of `ibscbs_v_ibs` across all products | +| `totais_cbs` | `gCBS/vCBS` | Sum of `ibscbs_v_cbs` across all products | +| `totais_is` | *(not emitted)* | Sum of `is_valor` — accumulated internally, not in the XML | -Os totais sao acumulados automaticamente ao chamar `adicionar_produto_servico()`. +Totals are accumulated automatically when calling `adicionar_produto_servico()`. -> **IMPORTANTE**: IBS/CBS/IS **NAO sao somados** ao `vNF` (proibido em 2025-2026 conforme NT 2025.002-RTC). +> **IMPORTANT**: IBS/CBS/IS **are NOT added** to `vNF` (forbidden in 2025-2026 per NT 2025.002-RTC). -### Campo do cabecalho +### Header field -| Campo | XML Tag | Descricao | +| Field | XML Tag | Description | |-------|---------|-----------| -| `municipio_fato_gerador_ibs` | `cMunFGIBS` | Codigo IBGE do municipio do fato gerador IBS (7 digitos) | +| `municipio_fato_gerador_ibs` | `cMunFGIBS` | IBGE code of the municipality of the IBS taxable event (7 digits) | -## Estrutura XML gerada +## Generated XML structure -### Item — dentro de `` +### Item — inside `` -O grupo `` e adicionado como filho direto de ``, apos ``: +The `` group is added as a direct child of ``, after ``: ```xml @@ -257,7 +292,7 @@ O grupo `` e adicionado como filho direto de ``, apos ` ``` -Para CSTs nao tributados (ex: 222 — isencao): +For non-taxed CSTs (e.g. 222 — exemption): ```xml @@ -266,53 +301,62 @@ Para CSTs nao tributados (ex: 222 — isencao): ``` -### Totais — Group W03 (``) +### Totals — Group W03 (``) -Os totais ficam em um grupo **separado** de ``, como irmao dentro de ``. O tipo XSD e `TIBSCBSMonoTot` (definido em `DFeTiposBasicos_v1.00.xsd`). +The totals live in a group **separate** from ``, as a sibling inside ``. The XSD type is `TIBSCBSMonoTot` (defined in `DFeTiposBasicos_v1.00.xsd`). ```xml - - 1250.00 + + 1250.00 - 1000.00 - + 1000.00 + - 0.00 - 0.00 - 1.00 + 0.00 + 0.00 + 1.00 0.00 0.00 - 0.50 + 0.50 1.50 - 0.00 + 0.00 0.00 - + 0.00 0.00 9.00 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). +> The `gIBS` and `gCBS` subgroups are optional (`minOccurs="0"`) — emitted only when there are amounts. The `vDif`, `vDevTrib`, `vCredPres` and `vCredPresCondSus` fields are mandatory inside each subgroup (emitted as "0.00" when unused). +> +> The `gMono` subgroup is optional, but mandatory whenever any NF-e item carries `` (CST 620, etc.). When emitted, **all six children** are mandatory (`vIBSMono`, `vCBSMono`, `vIBSMonoReten`, `vCBSMonoReten`, `vIBSMonoRet`, `vCBSMonoRet`). Omitting `` in an NF-e with single-phase items makes SEFAZ reject it with `cStat 1119 - "Total de IBS e CBS nao informado"`. The `Reten`/`Ret` totals are not accumulated at item level yet (PyNFe still only emits ``), so they will be "0.00" until `` / `` / `` are supported at item level. -### Cabecalho — `cMunFGIBS` no `` +### Header — `cMunFGIBS` in `` ```xml - + 4118402 4118402 1 @@ -320,74 +364,74 @@ Os totais ficam em um grupo **separado** de ``, como irmao dentro de `< ``` -## Regras de serializacao +## Serialization rules -### CSTs tributados (emitem `gIBSCBS` com valores) +### Taxed CSTs (emit `gIBSCBS` with amounts) CSTs: 000, 010, 200, 400, 510, 600, 620, 800, 810, 900 -Esses CSTs geram o subgrupo `` completo com `vBC`, `gIBSUF`, `gIBSMun`, `vIBS` e `gCBS`. +These CSTs generate the full `` subgroup with `vBC`, `gIBSUF`, `gIBSMun`, `vIBS` and `gCBS`. -### CSTs nao tributados (apenas CST + cClassTrib) +### Non-taxed CSTs (only CST + cClassTrib) CSTs: 100, 110, 222, 300, 410 -Esses CSTs geram apenas `` e ``, sem ``. +These CSTs generate only `` and ``, without ``. -### Outras regras +### Other rules -- **`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 `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) -- **`vNF`**: NAO inclui valores de IBS, CBS ou IS (proibido em 2025-2026) +- **`cClassTrib`**: Emitted when provided (mandatory field in practice) +- **`cMunFGIBS`**: Emitted in `` only when provided +- **``**: Tipo `TIBSCBSMonoTot`. Omitted if all totals are zero AND no item carries ``. When emitted, `vBCIBSCBS` is mandatory as the first child; `gIBS`, `gCBS` and `gMono` are optional (but `gMono` is mandatory whenever there are single-phase items) +- **``**: Tipo `TTribNFe`. Omitted entirely if `ibscbs_cst` is not provided +- **IS (``)**: Tipo `TIS`. **Not emitted in the XML** — serialization disabled until 2027 +- **``**: Tipo `TISTot`. **Not emitted** — will be a sibling of `` (before it in the schema) +- **`vNF`**: does NOT include IBS, CBS or IS amounts (forbidden in 2025-2026) -## IS (Imposto Seletivo) — preparado para 2027 +## IS (Selective Tax) — prepared for 2027 -O IS esta implementado nas entidades mas desabilitado na serializacao: +IS is implemented in the entities but disabled in serialization: -- **Entidade** (`NotaFiscalProduto`): campos `is_cst_selec`, `is_c_class_trib`, `is_vbc`, `is_aliquota`, `is_valor` — podem ser preenchidos normalmente -- **Totais** (`NotaFiscal`): `totais_is` e acumulado internamente -- **Serializacao**: `_serializar_is()` existe e usa as tags corretas do schema (`CSTIS`, `cClassTribIS`, `vBCIS`, `pIS`, `vIS`) mas nao e chamado -- **XML**: nao emite `` nem `` +- **Entity** (`NotaFiscalProduto`): fields `is_cst_selec`, `is_c_class_trib`, `is_vbc`, `is_aliquota`, `is_valor` — can be filled in normally +- **Totals** (`NotaFiscal`): `totais_is` is accumulated internally +- **Serialization**: `_serializar_is()` exists and uses the correct schema tags (`CSTIS`, `cClassTribIS`, `vBCIS`, `pIS`, `vIS`) but is not called +- **XML**: emits neither `` nor `` -Quando a serializacao do IS for ativada (previsto para 2027): -1. Descomentar a chamada em `_serializar_imposto_ibscbs()` -2. Adicionar serializacao de `` em `_serializar_nota_fiscal()` (irmao de ``, antes dele) -3. Atualizar os testes +When IS serialization is enabled (planned for 2027): +1. Uncomment the call in `_serializar_imposto_ibscbs()` +2. Add `` serialization in `_serializar_nota_fiscal()` (sibling of ``, before it) +3. Update the tests -## Referencia tecnica +## Technical reference -| Recurso | Descricao | +| Resource | Description | |---------|-----------| -| NT 2025.002-RTC (v1.30+) | Nota Tecnica com alteracoes nos layouts XML | -| PL 010b | Pacote de schemas XML (`leiauteNFe_v4.00.xsd` + `DFeTiposBasicos_v1.00.xsd`) | -| LC 214/2025 | Lei Complementar que regulamenta a reforma | -| [Tabela cClassTrib](https://dfe-portal.svrs.rs.gov.br/DFE/TabelaClassificacaoTributaria) | Tabela oficial de classificacao tributaria (JSON disponivel) | -| [Tabela cCredPres](https://dfe-portal.svrs.rs.gov.br/DFE/TabelaCreditoPresumido) | Tabela de credito presumido | +| NT 2025.002-RTC (v1.30+) | Technical Note with changes to the XML layouts | +| PL 010b | XML schema package (`leiauteNFe_v4.00.xsd` + `DFeTiposBasicos_v1.00.xsd`) | +| LC 214/2025 | Complementary Law that regulates the reform | +| [Tabela cClassTrib](https://dfe-portal.svrs.rs.gov.br/DFE/TabelaClassificacaoTributaria) | Official tax classification table (JSON available) | +| [Tabela cCredPres](https://dfe-portal.svrs.rs.gov.br/DFE/TabelaCreditoPresumido) | Presumed credit table | -### Tipos XSD relevantes (DFeTiposBasicos_v1.00.xsd) +### Relevant XSD types (DFeTiposBasicos_v1.00.xsd) -| Tipo XSD | Elemento XML | Uso | +| XSD type | XML element | Use | |----------|-------------|-----| | `TTribNFe` | `IBSCBS` | Per-item: CST, cClassTrib, choice(gIBSCBS/gIBSCBSMono/...) | | `TCIBS` | `gIBSCBS` | Per-item: vBC, gIBSUF, gIBSMun, vIBS, gCBS | | `TIS` | `IS` | Per-item: CSTIS, cClassTribIS, vBCIS, pIS, vIS | -| `TIBSCBSMonoTot` | `IBSCBSTot` | Totais: vBCIBSCBS, gIBS, gCBS, gMono, gEstornoCred | -| `TISTot` | `ISTot` | Totais: vIS | -| `TDif` | `gDif` | Diferimento (pDif, vDif) | -| `TDevTrib` | `gDevTrib` | Devolucao de tributo (vDevTrib) | -| `TRed` | `gRed` | Reducao de aliquota (pRedAliq, pAliqEfet) | -| `TCredPres` | `gCredPresOper` | Credito presumido (pCredPres, vCredPres/vCredPresCondSus) | +| `TIBSCBSMonoTot` | `IBSCBSTot` | Totals: vBCIBSCBS, gIBS, gCBS, gMono, gEstornoCred | +| `TISTot` | `ISTot` | Totals: vIS | +| `TDif` | `gDif` | Deferral (pDif, vDif) | +| `TDevTrib` | `gDevTrib` | Tax refund (vDevTrib) | +| `TRed` | `gRed` | Rate reduction (pRedAliq, pAliqEfet) | +| `TCredPres` | `gCredPresOper` | Presumed credit (pCredPres, vCredPres/vCredPresCondSus) | -## Notas importantes +## Important notes -- Os nomes das tags XML seguem estritamente os tipos XSD do PL 010b (`DFeTiposBasicos_v1.00.xsd`): +- XML tag names strictly follow the PL 010b XSD types (`DFeTiposBasicos_v1.00.xsd`): - Per-item: `TTribNFe` (`IBSCBS`), `TCIBS` (`gIBSCBS`), `TIS` (`IS`) - - Totais: `TIBSCBSMonoTot` (`IBSCBSTot`), `TISTot` (`ISTot`) -- O calculo dos valores (base de calculo, aliquota, valor) deve ser feito pela aplicacao consumidora. PyNFe apenas serializa os valores informados. -- Durante a fase de transicao (2026-2033), os impostos legados e os novos coexistem no mesmo XML. -- Aliquotas de teste para 2026: CBS=0,9%, IBS-UF=0,1%, IBS-Mun=0%. -- Campos de diferimento (`vDif`), devolucao de tributos (`vDevTrib`) e credito presumido (`vCredPres`, `vCredPresCondSus`) sao emitidos como "0.00" nos totais. Suporte completo para esses campos sera adicionado conforme necessidade. + - Totals: `TIBSCBSMonoTot` (`IBSCBSTot`), `TISTot` (`ISTot`) +- Computing the amounts (tax base, rate, amount) is the consuming application's job. PyNFe only serializes the provided values. +- During the transition phase (2026-2033), legacy and new taxes coexist in the same XML. +- Test rates for 2026: CBS=0.9%, IBS-UF=0.1%, IBS-Mun=0%. +- Deferral (`vDif`), tax refund (`vDevTrib`) and presumed credit (`vCredPres`, `vCredPresCondSus`) fields are emitted as "0.00" in the totals. Full support for these fields will be added as needed. diff --git a/docs/serializacao_map.md b/docs/serializacao_map.md index f4594710..ab490e9f 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` (2881 lines) XML serialization of NF-e, NFC-e, NFS-e and MDF-e documents into SEFAZ-compliant XML format. @@ -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-2309 | NFC-e QR Code generation | +| `SerializacaoNfse` | 2312-2378 | NFS-e serialization (Betha/Ginfes) | +| `SerializacaoQrcodeMDFe` | 2381-2404 | MDF-e QR Code generation | +| `SerializacaoMDFe` | 2407-2881 | 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,21 @@ Abstract base for all serializers. Stores `_fonte_dados`, `_ambiente` (1=prod, 2 --- -## `SerializacaoQrcode` — Lines 2102-2206 +## `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 2209-2275 +## `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 2278-2301 +## `SerializacaoQrcodeMDFe` — Lines 2381-2404 Generates MDF-e QR Code URL using SVRS endpoint. -## `SerializacaoMDFe` — Lines 2304-2771 +## `SerializacaoMDFe` — Lines 2407-2881 ### Methods | Method | Lines | Purpose | diff --git a/docs/webservices_map.md b/docs/webservices_map.md index 5ab7bc9e..395d8009 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` (684 lines) SEFAZ webservice endpoint URLs organized by document type, state, and environment. @@ -6,11 +6,14 @@ 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 + `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 @@ -22,42 +25,69 @@ 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` — 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, 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 8-295 +### NFC-e (`NFCE`) — Lines 49-363 | 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 | 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 297-471 +### NF-e (`NFE`) — Lines 412-583 | 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 | - -### NFS-e (`NFSE`) — Lines 473-499 +| `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 586-611 | Key | Lines | Description | |-----|-------|-------------| -| `BETHA` | 476-486 | Betha provider (HTTP WSDL) | -| `GINFES` | 488-498 | Ginfes provider (HTTPS WSDL) | +| `BETHA` | 588-598 | Betha provider (HTTP WSDL) | +| `GINFES` | 600-610 | Ginfes provider (HTTPS WSDL) | -### MDF-e (`MDFE`) — Lines 501-516 -Only `SVRS` — single authorizer for all states. +### MDF-e (`MDFE`) — Lines 614-628 +Only `SVRS` (616-627) — single authorizer for all states. -### CT-e (`CTE`) — Lines 518-572 +### CT-e (`CTE`) — Lines 630-684 | 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` | 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)` | 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/entidades/notafiscal.py b/pynfe/entidades/notafiscal.py index 7047138e..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 @@ -1046,6 +1085,23 @@ 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 + # vTotIBSMonoItem / vTotCBSMonoItem = item-level mono totals (TDec1302RTC). Per + # NT 2025.002-RTC schema TMonofasia, these are REQUIRED siblings of + # 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) is_c_class_trib = str() # cClassTribIS 6-digit 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 cb463000..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 +from pynfe.utils.webservices import MDFE, NFCE, qrcode_host, url_consulta_chave class Serializacao(object): @@ -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,116 @@ 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 (type TMonofasia do + DFeTiposBasicos_v1.00.xsd, sequencia gMonoPadrao -> gMonoReten -> + 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): + + + + TDec1104RTC (4 casas) + TDec_0302_04RTC (4 casas) + TDec_0302_04RTC (4 casas) + TDec1302RTC (2 casas) + TDec1302RTC (2 casas) + + TDec1302RTC (2 casas) + TDec1302RTC (2 casas) + + + IMPORTANT: + - 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") + etree.SubElement(gmono_padrao, "qBCMono").text = "{:.4f}".format( + produto_servico.ibscbs_q_bc_mono or 0 + ) + etree.SubElement(gmono_padrao, "adRemIBS").text = "{:.4f}".format( + produto_servico.ibscbs_ad_rem_ibs or 0 + ) + etree.SubElement(gmono_padrao, "adRemCBS").text = "{:.4f}".format( + produto_servico.ibscbs_ad_rem_cbs or 0 + ) + 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 + ) + # 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 . @@ -1799,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") @@ -1839,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 @@ -2165,50 +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": - 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"] - # 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"] - # 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"] - # 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 + caminho_qr = NFCE[uf]["QR"] if tpamb == "1" else NFCE[uf]["QR_HOMOLOGACAO"] + qrcode = qrcode_host(uf, producao=tpamb == "1") + caminho_qr + url + # AC, AP, BA, ES, GO, MS, PA, PE, RR, SC, SE, SP 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"] + 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()) @@ -2448,7 +2533,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") @@ -2480,6 +2567,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/pynfe/utils/webservices.py b/pynfe/utils/webservices.py index fc3360a4..d2bdce86 100644 --- a/pynfe/utils/webservices.py +++ b/pynfe/utils/webservices.py @@ -1,5 +1,46 @@ """ @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_*``. + +``qrCode`` vs ``urlChave`` +------------------------- +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``). +- ````: 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. 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/ @@ -34,8 +75,13 @@ "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.", }, "RR": { "STATUS": "", @@ -155,6 +201,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", @@ -209,6 +259,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", @@ -220,6 +272,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.", }, @@ -273,9 +329,20 @@ "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", + # 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", + # 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?", @@ -295,6 +362,50 @@ }, } + +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. + + 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" + if dados.get(chave_qr): + return dados[chave_qr] + 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/requirements-dev.txt b/requirements-dev.txt index 841f2a05..b5382dab 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,2 +1,2 @@ pytest -ruff \ No newline at end of file +ruff==0.12.5 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..ea1fadc0 --- /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 new file mode 100644 index 00000000..fe6531bf --- /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 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"]) + 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_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() diff --git a/tests/test_nfce_qrcode_go.py b/tests/test_nfce_qrcode_go.py new file mode 100644 index 00000000..d09ecf82 --- /dev/null +++ b/tests/test_nfce_qrcode_go.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python +# *-* encoding: utf8 *-* +"""Regression tests for the Goias (GO) NFC-e ```` host. + +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 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 + +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, _ = self._gerar(TP_AMB_PRODUCAO) + self.assertTrue( + qrcode.startswith(QRCODE_HOST_PRODUCAO), + f"qrCode de producao deve comecar com {QRCODE_HOST_PRODUCAO}, veio: {qrcode}", + ) + # Garante que nao reverteu para o subdominio rejeitado (rejeicao 395). + self.assertNotIn("https://nfe.sefaz.go.gov.br", qrcode) + + def test_qrcode_homologacao_usa_host_nfewebhomolog(self): + 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.assertNotIn("https://homolog.sefaz.go.gov.br", qrcode) + + +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..a8a30c02 --- /dev/null +++ b/tests/test_nfce_qrcode_hosts_por_uf.py @@ -0,0 +1,109 @@ +#!/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. + +``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 + +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?", + # 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?", + "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() 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() diff --git a/tests/test_nfe_serializacao_reforma_tributaria.py b/tests/test_nfe_serializacao_reforma_tributaria.py index ee042b8a..4ce522f7 100644 --- a/tests/test_nfe_serializacao_reforma_tributaria.py +++ b/tests/test_nfe_serializacao_reforma_tributaria.py @@ -667,6 +667,474 @@ 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 . + + DEV-1954: also asserts vTotIBSMonoItem / vTotCBSMonoItem siblings of + (required by schema TMonofasia). + """ + 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"), + # 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) + + 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 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:gMonoPadrao/ns:qBCMono", namespaces=self.ns)[0].text + self.assertEqual(q_bc_mono, "18.0000") + + 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:gMonoPadrao/ns:vIBSMono", namespaces=self.ns)[0].text + self.assertEqual(v_ibs_mono, "0.00") + + 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:gMonoPadrao/ns:vCBSMono", namespaces=self.ns)[0].text + self.assertEqual(v_cbs_mono, "0.00") + + # 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", "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. + + 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) + + 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"), + # 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) + + xml = self._serializar_e_assinar() + + # Values live under / per NT 2025.002-RTC + self.assertEqual( + xml.xpath("//ns:gMonoPadrao/ns:qBCMono", namespaces=self.ns)[0].text, "100.0000" + ) + self.assertEqual( + xml.xpath("//ns:gMonoPadrao/ns:adRemIBS", namespaces=self.ns)[0].text, "0.1500" + ) + self.assertEqual( + xml.xpath("//ns:gMonoPadrao/ns:vIBSMono", namespaces=self.ns)[0].text, "15.00" + ) + self.assertEqual( + xml.xpath("//ns:gMonoPadrao/ns:adRemCBS", namespaces=self.ns)[0].text, "0.8500" + ) + 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 + 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") + + # ------------------------------------------------------------------ + # 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 # ------------------------------------------------------------------