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..4648241e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,115 +1,44 @@ -# 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/ +## Architecture and invariants -# Run specific test -pytest tests/test_nfe_serializacao_geral.py - -# 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 - -- 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` +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. 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..2312ecb5 100644 --- a/docs/reforma_tributaria.md +++ b/docs/reforma_tributaria.md @@ -37,8 +37,43 @@ A implementacao cobre: - `vNF` **NAO inclui** IBS/CBS (proibido em 2025-2026) - `finNFe=5` (Nota de Debito) e `finNFe=6` (Nota de Credito) - Campos de entidade para IS (Imposto Seletivo) — **armazenados mas nao serializados** ate o schema suportar (2027) +- Tributacao monofasica (`gIBSCBSMono`) para CST 620 — combustiveis e demais produtos sujeitos ao regime monofasico de IBS/CBS -**Nao inclui** (ainda): Split Payment, cashback, eventos de apuracao assistida, Grupo VB (total do item), Grupo VC (referenciamento de DF-e), Grupo BB (antecipacao de pagamento), tributacao monofasica (`gIBSCBSMono`), diferimento per-item (`gDif`), devolucao de tributos per-item (`gDevTrib`), reducao de aliquota per-item (`gRed`), estorno de credito (`gEstornoCred`), credito presumido per-item (`gCredPresOper`, `gCredPresIBSZFM`). +**Nao inclui** (ainda): Split Payment, cashback, eventos de apuracao assistida, Grupo VB (total do item), Grupo VC (referenciamento de DF-e), Grupo BB (antecipacao de pagamento), diferimento per-item (`gDif`), devolucao de tributos per-item (`gDevTrib`), reducao de aliquota per-item (`gRed`), estorno de credito (`gEstornoCred`), credito presumido per-item (`gCredPresOper`, `gCredPresIBSZFM`). + +### Tributacao monofasica — `gIBSCBSMono` + +Para produtos com CST 620 (combustiveis, etc.) o grupo emitido dentro de `` e `` ao inves de ``. Conforme o schema oficial (`DFeTiposBasicos_v1.00.xsd`, type `TMonofasia`), os cinco campos monofasicos vivem sob o wrapper obrigatorio `` e na ordem definida pelo schema. Alem disso, `` e `` sao SIBLINGS de `` (NAO filhos) e ambos sao OBRIGATORIOS por schema (sem `minOccurs=0`): + +```xml + + + 18.0000 + 0.1000 + 0.0000 + 1.80 + 0.00 + + 1.80 + 0.00 + +``` + +| Campo | Tipo | Descricao | +|-------|------|-----------| +| `qBCMono` | TDec1104RTC | Quantidade tributada na base monofasica | +| `adRemIBS` | TDec_0302_04RTC | Aliquota ad rem IBS (valor em BRL por unidade) | +| `adRemCBS` | TDec_0302_04RTC | Aliquota ad rem CBS (valor em BRL por unidade) | +| `vIBSMono` | TDec1302RTC | Valor IBS monofasico | +| `vCBSMono` | TDec1302RTC | Valor CBS monofasico | +| `vTotIBSMonoItem` | TDec1302RTC | Total IBS monofasico do item (sibling de `gMonoPadrao`) | +| `vTotCBSMonoItem` | TDec1302RTC | Total CBS monofasico do item (sibling de `gMonoPadrao`) | + +Atributos na entidade `NotaFiscalProduto`: `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`. + +Para itens single-line (sem retencao / retencao anterior / diferimento), `vTotIBSMonoItem == vIBSMono` e `vTotCBSMonoItem == vCBSMono`. Quando os atributos nao sao informados pelo caller, o serializador emite `0.00` (default seguro durante o Teste de Carga 2026 com ad rem zerados). + +Durante o Teste de Carga 2026 os ad rem ainda nao foram publicados pela SEFAZ, entao os valores podem ser zerados — o grupo `gIBSCBSMono` ainda sera emitido corretamente. ## CSTs disponiveis @@ -300,13 +335,22 @@ Os totais ficam em um grupo **separado** de ``, como irmao dentro de `< 0.00 0.00 - + + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + 0.00 + ``` > Os subgrupos `gIBS` e `gCBS` sao opcionais (`minOccurs="0"`) — emitidos apenas quando ha valores. Os campos `vDif`, `vDevTrib`, `vCredPres` e `vCredPresCondSus` sao obrigatorios dentro de cada subgrupo (emitidos como "0.00" quando nao utilizados). +> +> O subgrupo `gMono` e opcional, mas obrigatorio sempre que algum item da NF-e carregar `` (CST 620, etc.). Quando emitido, **todos os seis filhos** sao obrigatorios (`vIBSMono`, `vCBSMono`, `vIBSMonoReten`, `vCBSMonoReten`, `vIBSMonoRet`, `vCBSMonoRet`). Omitir `` em uma NF-e com items monofasicos faz a SEFAZ rejeitar com `cStat 1119 - "Total de IBS e CBS nao informado"`. Os totais `Reten`/`Ret` ainda nao sao acumulados a nivel de item (PyNFe ainda so emite ``), entao serao "0.00" ate que `` / `` / `` sejam suportados a nivel de item. ### Cabecalho — `cMunFGIBS` no `` @@ -338,7 +382,7 @@ Esses CSTs geram apenas `` e ``, sem ``. - **`cClassTrib`**: Emitido quando informado (campo obrigatorio na pratica) - **`cMunFGIBS`**: Emitido no `` apenas quando informado -- **``**: Tipo `TIBSCBSMonoTot`. Omitido se todos os totais forem zero. Quando emitido, `vBCIBSCBS` e obrigatorio como primeiro filho; `gIBS` e `gCBS` sao opcionais +- **``**: Tipo `TIBSCBSMonoTot`. Omitido se todos os totais forem zero E nenhum item carregar ``. Quando emitido, `vBCIBSCBS` e obrigatorio como primeiro filho; `gIBS`, `gCBS` e `gMono` sao opcionais (mas `gMono` e obrigatorio sempre que houver items monofasicos) - **``**: Tipo `TTribNFe`. Omitido completamente se `ibscbs_cst` nao for informado - **IS (``)**: Tipo `TIS`. **Nao emitido no XML** — serializacao desabilitada ate 2027 - **``**: Tipo `TISTot`. **Nao emitido** — sera irmao de `` (antes dele no schema) diff --git a/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 # ------------------------------------------------------------------