emissaoja 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ .env
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-11-05
4
+
5
+ Primeira versão pública do cliente oficial da API do emissaoja para Python.
6
+
7
+ - Recursos `empresa`, `tomadores`, `servicos`, `notas`, `emissoes`, `operacoes`, `cnpj` e `status()`.
8
+ - `notas.emitir` exige `referencia` (vira o cabeçalho `Idempotency-Key`) e valida o formato antes de chamar a API.
9
+ - `notas.aguardar`, `empresa.aguardar_credencial` e `operacoes.aguardar` respeitam `proxima_consulta_ms`.
10
+ - Erros da API viram `EmissaojaErro` com `status`, `code`, `extras` e `request_id`; timeout e falha de conexão viram
11
+ `tempo_esgotado` e `rede`.
12
+ - Constantes `BASE_PRODUCAO` e `BASE_SANDBOX`. Só biblioteca padrão, Python 3.9+.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BINOBO LTDA
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,228 @@
1
+ Metadata-Version: 2.5
2
+ Name: emissaoja
3
+ Version: 0.1.0
4
+ Summary: API de NFS-e nacional para Python: emita nota fiscal de serviço eletrônica no Emissor Nacional sem certificado digital (MEI e Simples Nacional). PDF, XML, tomadores. Cliente oficial do emissaoja.
5
+ Project-URL: Homepage, https://emissaoja.com.br
6
+ Project-URL: Documentation, https://emissaoja.com.br/docs/
7
+ Project-URL: Source, https://github.com/Easymovel/emissaoja-py
8
+ Project-URL: Changelog, https://github.com/Easymovel/emissaoja-py/blob/main/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/Easymovel/emissaoja-py/issues
10
+ Author: BINOBO LTDA
11
+ License: MIT
12
+ License-File: LICENSE
13
+ Keywords: api nfse,brasil,brazil,danfse,emissaoja,emissor nacional,emitir nfse,invoice,iss,mei,nfs-e,nfse,nfse nacional,nota fiscal,nota fiscal de servico,nota fiscal eletronica,sem certificado digital,simples nacional
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Natural Language :: Portuguese (Brazilian)
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.9
22
+ Classifier: Programming Language :: Python :: 3.10
23
+ Classifier: Programming Language :: Python :: 3.11
24
+ Classifier: Programming Language :: Python :: 3.12
25
+ Classifier: Programming Language :: Python :: 3.13
26
+ Classifier: Programming Language :: Python :: 3.14
27
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
28
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
29
+ Classifier: Typing :: Typed
30
+ Requires-Python: >=3.9
31
+ Description-Content-Type: text/markdown
32
+
33
+ # emissaoja: API de NFS-e nacional para Python
34
+
35
+ [![PyPI](https://img.shields.io/pypi/v/emissaoja)](https://pypi.org/project/emissaoja/) [![CI](https://github.com/Easymovel/emissaoja-py/actions/workflows/ci.yml/badge.svg)](https://github.com/Easymovel/emissaoja-py/actions/workflows/ci.yml) [![Licença MIT](https://img.shields.io/badge/licen%C3%A7a-MIT-blue)](LICENSE)
36
+
37
+ Cliente oficial da **[API do emissaoja](https://emissaoja.com.br/docs/)** para emitir **NFS-e (Nota Fiscal de Serviço eletrônica) no Emissor Nacional** (nfse.gov.br) a partir do seu sistema em Python, **sem certificado digital**: o emissaoja entra no portal com o CNPJ e a senha da própria empresa. Feito para **MEI e empresas do Simples Nacional**. Cadastre tomadores pelo CNPJ (nome e endereço vêm da Receita Federal), emita, acompanhe a autorização, baixe o **PDF (DANFSe) e o XML** e cancele notas.
38
+
39
+ - **Só biblioteca padrão** (urllib), Python 3.9+, com `py.typed`.
40
+ - **Idempotência embutida:** toda emissão leva uma `referencia` sua, que vira o cabeçalho `Idempotency-Key`. Repetir a mesma referência nunca emite duas notas.
41
+ - `notas.aguardar()` acompanha o pedido até `autorizada`, `recusada` ou `incerta`, no ritmo que a API indica (`proxima_consulta_ms`).
42
+ - Erros com `code` estável (`nota_bloqueada`, `limite_notas`, `possivel_duplicada`…) e o que falta resolver em `extras["bloqueios"]`.
43
+ - **Sandbox grátis** com Emissor Nacional simulado: teste o fluxo inteiro sem efeito fiscal.
44
+
45
+ ## Instalação
46
+
47
+ ```bash
48
+ pip install emissaoja
49
+ ```
50
+
51
+ Crie a conta com o Google em [emissaoja.com.br](https://emissaoja.com.br/?origem=lib-pypi) e gere a chave em **Conta → API** (ela aparece uma vez; prefixo `ej_live_`). Guarde a chave no servidor:
52
+
53
+ ```bash
54
+ export EMISSAOJA_KEY=ej_live_...
55
+ ```
56
+
57
+ ## Teste no sandbox em 2 minutos
58
+
59
+ O sandbox (`https://sandbox.emissaoja.com.br`) é o mesmo emissaoja com um **Emissor Nacional simulado**: nada ali existe no fisco e nada é cobrado. Entre com o Google, crie uma chave `ej_test_` em Conta → API e rode:
60
+
61
+ ```python
62
+ from emissaoja import BASE_SANDBOX, Emissaoja
63
+
64
+ ej = Emissaoja(base_url=BASE_SANDBOX) # lê a chave de EMISSAOJA_KEY; ou Emissaoja("ej_test_...", base_url=...)
65
+
66
+ # 1. Conectar a empresa (uma vez). 11111111000191 é um MEI de exemplo pronto para emitir; qualquer senha serve.
67
+ ej.empresa.conectar("11111111000191", "senha-de-teste")
68
+ ej.empresa.aguardar_credencial()
69
+
70
+ # 2. Tomador pelo CNPJ: nome e endereço vêm da Receita Federal
71
+ tomador = ej.tomadores.criar("12345678000195")["tomador"]
72
+
73
+ # 3. Emitir (prévia + emissão num passo). A referência é o seu id da nota.
74
+ pedido = ej.notas.emitir(
75
+ "pedido-2026-11-0001",
76
+ tomador["tipo"],
77
+ tomador["id"],
78
+ 1500,
79
+ descricao="Consultoria em marketing digital - novembro/2026",
80
+ )["pedido"]
81
+
82
+ # 4. Acompanhar até autorizada | recusada | incerta
83
+ final = ej.notas.aguardar(pedido["id"])["pedido"]
84
+ print(final["estado"], final.get("numero"), final.get("chave_acesso"))
85
+
86
+ # 5. PDF (DANFSe) e XML
87
+ if final["estado"] == "autorizada":
88
+ pdf = ej.notas.pdf(final["nota"]["origem"], final["nota"]["id"]) # bytes
89
+ xml = ej.notas.xml(final["nota"]["origem"], final["nota"]["id"]) # str
90
+ ```
91
+
92
+ Valores com significado no sandbox: `13.13` gera uma nota **incerta** que `ej.emissoes.conferir(id)` resolve como autorizada, `77.77` uma incerta que não saiu, `66.66` uma **recusada** pelo fisco e `99.99` um **bloqueio** já na prévia (`422 nota_bloqueada`). Outras empresas de exemplo: `22222222000191` (ME que pede a alíquota do Simples antes de emitir), `33333333000191` (MEI sem notas anteriores) e `44444444000191` (bloqueada). A senha `errada` simula senha recusada pelo portal. Exemplo completo em [`exemplos/emitir_nfse_sandbox.py`](exemplos/emitir_nfse_sandbox.py).
93
+
94
+ ## Em produção
95
+
96
+ `Emissaoja()` usa `https://emissaoja.com.br` e lê `EMISSAOJA_KEY`. A chave `ej_live_` vale para a empresa e o plano da conta que a criou; chaves `ej_test_` só valem no sandbox.
97
+
98
+ > **A chave é secreta.** Use o cliente só no servidor (Django, Flask, FastAPI, filas, scripts). Quem tiver a chave emite notas em nome da empresa.
99
+
100
+ Com Django ou FastAPI, crie um cliente por processo e reutilize (ele não guarda estado entre chamadas):
101
+
102
+ ```python
103
+ # settings.py / config.py
104
+ from emissaoja import Emissaoja
105
+
106
+ EMISSAOJA = Emissaoja() # chave de EMISSAOJA_KEY, lida na primeira chamada
107
+ ```
108
+
109
+ ## Empresa: conectar ao Emissor Nacional
110
+
111
+ ```python
112
+ ej.empresa.conectar("11222333000181", "senha-do-emissor-nacional") # 202, verificação em segundo plano
113
+ tentativa = ej.empresa.aguardar_credencial()["tentativa"]
114
+ # tentativa["proximo_passo"]: configurando | pronto | corrigir_senha | tentar_mais_tarde | redefinir_no_portal | suporte
115
+
116
+ empresa = ej.empresa.obter() # empresa["onboarding"]["estado"]: configurando → perguntas → pronto
117
+ if empresa["onboarding"]["estado"] == "perguntas":
118
+ print(ej.empresa.onboarding()) # o que o cadastro fiscal não respondeu sozinho
119
+ ej.empresa.fiscal(aliquota_simples=6) # só com o valor que a empresa informou
120
+ ```
121
+
122
+ A senha é conferida no portal e nunca volta em resposta. O portal bloqueia a conta da empresa depois de algumas senhas erradas: a API limita a 3 tentativas erradas por CNPJ em 24 h.
123
+
124
+ ## Tomadores e serviços
125
+
126
+ ```python
127
+ ej.tomadores.criar("52998224725", nome="Maria da Silva") # CPF precisa do nome
128
+ ej.tomadores.listar(busca="alfa", ordem="recentes")
129
+ ej.tomadores.buscar("12.345.678")
130
+
131
+ itens = ej.servicos.catalogo("consultoria")["itens"] # código nacional (cTribNac) por texto
132
+ ej.servicos.criar(descricao="Consultoria", c_trib_nac=itens[0]["c_trib_nac"], trib_issqn="tributavel", retem_iss=False)
133
+ ej.servicos.listar() # emitir sem servico_id usa o principal
134
+ ```
135
+
136
+ ## Notas
137
+
138
+ ```python
139
+ notas = ej.notas.listar(periodo="mes_atual", situacao="emitidas")["notas"]
140
+ operacao = ej.notas.cancelar("local", nota_id, "1", justificativa="Valor digitado errado.")["operacao"]
141
+ ej.operacoes.aguardar(operacao["id"]) # processando → concluida | falhou
142
+ ```
143
+
144
+ Nota não se altera: para corrigir, cancele e emita outra. Nota autorizada e depois cancelada conta no plano; recusada não conta.
145
+
146
+ ## Idempotência e nota em dobro
147
+
148
+ - Use uma `referencia` por nota que você quer emitir (o id do seu pedido serve; 8 a 64 caracteres: letras, números, `_` e `-`). Repetir a mesma devolve o mesmo `pedido` com `repetido: True`.
149
+ - `503 fila_indisponivel`: repita **com a mesma referência**.
150
+ - `409 possivel_duplicada`: já existe nota igual (tomador, valor e competência) no mesmo dia. Se for mesmo outra nota, repita com `permitir_repetida_de=e.extras["nota_igual_recente"]["pedido_id"]`.
151
+ - `409 emissao_repetida_recente`: mesma nota há menos de 2 minutos. Se for intencional, repita com `confirmo_repeticao=True`.
152
+ - A biblioteca não repete chamadas sozinha: emitir tem efeito fiscal, e quem decide repetir é você.
153
+
154
+ ## Erros
155
+
156
+ ```python
157
+ from emissaoja import EmissaojaErro
158
+
159
+ try:
160
+ ej.notas.emitir("pedido-0002", "pj", tomador_id, 100)
161
+ except EmissaojaErro as e:
162
+ if e.code == "nota_bloqueada":
163
+ print(e.extras["bloqueios"]) # [{"code", "mensagem", "acao"}]
164
+ elif e.code == "limite_notas":
165
+ print("limite do plano no mês")
166
+ else:
167
+ print(e.status, e.code, e, e.request_id)
168
+ ```
169
+
170
+ | HTTP | `code` | O que fazer |
171
+ | --- | --- | --- |
172
+ | 400 | `validation_error`, `valor_invalido`, `cnpj_invalido`, `chave_idempotencia_invalida` | Corrigir o campo |
173
+ | 401 | `chave_invalida` | Conferir a chave (ou o ambiente: `ej_test_` só no sandbox) |
174
+ | 402 | `limite_notas` | Limite do plano no mês |
175
+ | 404 | `tomador_nao_encontrado`, `servico_nao_encontrado`, `nota_nao_encontrada`, `pedido_nao_encontrado` | Conferir o id |
176
+ | 409 | `sem_empresa` | Conectar a empresa (`empresa.conectar`) |
177
+ | 409 | `aceite_pendente` | Termos ou Política mudaram: entre no [app](https://emissaoja.com.br/app) e aceite a versão nova; a chave volta a funcionar |
178
+ | 409 | `possivel_duplicada`, `emissao_repetida_recente`, `incerta_pendente` | Ver "Idempotência e nota em dobro" |
179
+ | 422 | `nota_bloqueada` | Resolver `extras["bloqueios"]` |
180
+ | 429 | `rate_limited` | Esperar `Retry-After` |
181
+ | 503 | `motor_indisponivel`, `fila_indisponivel`, `receita_indisponivel` | Tentar de novo (com a mesma referência) |
182
+ | 0 | `rede`, `tempo_esgotado` | Falha de rede ou tempo esgotado no seu lado |
183
+
184
+ Limites: 60 chamadas por minuto por chave, 10 emissões por minuto e 120 por minuto em buscas e catálogo.
185
+
186
+ ## Referência
187
+
188
+ | Recurso | Métodos |
189
+ | --- | --- |
190
+ | `empresa` | `obter`, `conectar`, `credencial`, `aguardar_credencial`, `onboarding`, `fiscal`, `reler_fisco` |
191
+ | `tomadores` | `listar`, `buscar`, `criar`, `obter`, `atualizar` |
192
+ | `servicos` | `listar`, `criar`, `atualizar`, `desativar`, `catalogo` |
193
+ | `notas` | `emitir`, `aguardar`, `listar`, `pdf`, `xml`, `cancelar` |
194
+ | `emissoes` | `obter`, `listar`, `conferir` |
195
+ | `operacoes` | `obter`, `aguardar` |
196
+ | `cnpj` | `consultar` (dados da Receita Federal) |
197
+ | — | `status()` (sem chave) |
198
+
199
+ `Emissaoja(chave=None, base_url=BASE_PRODUCAO, timeout=30.0)`. Referência completa da API: [emissaoja.com.br/docs](https://emissaoja.com.br/docs/) e [OpenAPI 3.1](https://emissaoja.com.br/v1/openapi.json).
200
+
201
+ ## Perguntas frequentes
202
+
203
+ **Precisa de certificado digital?** Não. O emissaoja entra no Emissor Nacional com o CNPJ e a senha do portal da própria empresa, o acesso que o portal oferece a MEI e empresas do Simples Nacional.
204
+
205
+ **Funciona para MEI?** Sim. O público é MEI, ME e EPP optantes pelo Simples Nacional, em um município que emite pelo Emissor Nacional.
206
+
207
+ **Tem ambiente de testes?** Sim, o sandbox em `https://sandbox.emissaoja.com.br`, com o Emissor Nacional simulado e empresas de exemplo.
208
+
209
+ **Quanto custa?** O plano Grátis emite 1 nota por mês; os planos pagos estão em [emissaoja.com.br/#precos](https://emissaoja.com.br/#precos). O sandbox é grátis. Nota recusada não conta.
210
+
211
+ **Tem webhook?** Ainda não. Acompanhe o pedido com `notas.aguardar()` ou `emissoes.obter()`, que trazem `proxima_consulta_ms`.
212
+
213
+ ## Outras formas de integrar
214
+
215
+ - Node.js/TypeScript: [`npm install emissaoja`](https://github.com/Easymovel/emissaoja-js) · PHP/Laravel: [`composer require emissaoja/emissaoja`](https://github.com/Easymovel/emissaoja-php) · .NET: [`dotnet add package Emissaoja`](https://github.com/Easymovel/emissaoja-dotnet) · Go: [`go get emissaoja.com.br/go`](https://github.com/Easymovel/emissaoja-go) · Ruby: [`gem install emissaoja`](https://github.com/Easymovel/emissaoja-ruby)
216
+ - n8n: [`n8n-nodes-emissaoja`](https://github.com/Easymovel/n8n-nodes-emissaoja) · WooCommerce: [emissaoja NFS-e para WooCommerce](https://github.com/Easymovel/emissaoja-woocommerce)
217
+ - Assistentes de IA (Claude, ChatGPT, Cursor, VS Code): servidor MCP em `https://emissaoja.com.br/mcp` ([emissaoja-mcp](https://github.com/Easymovel/emissaoja-mcp))
218
+ - Documentação para IAs: [llms.txt](https://emissaoja.com.br/llms.txt) · [docs.md](https://emissaoja.com.br/docs.md)
219
+
220
+ ## In English
221
+
222
+ Official Python client for the [emissaoja API](https://emissaoja.com.br/docs/): issue Brazilian **NFS-e** (electronic service invoices) through the national **Emissor Nacional** without a digital certificate, for MEI and Simples Nacional companies. Standard library only, typed, idempotent issuing, PDF and XML download, and a free sandbox with a simulated tax authority. The API and the docs are in Portuguese.
223
+
224
+ ---
225
+
226
+ Suporte: [suporte@emissaoja.com.br](mailto:suporte@emissaoja.com.br). Licença MIT, © 2026 BINOBO LTDA.
227
+
228
+ O emissaoja é um serviço privado e não tem vínculo com a Receita Federal nem com o Emissor Nacional.
@@ -0,0 +1,196 @@
1
+ # emissaoja: API de NFS-e nacional para Python
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/emissaoja)](https://pypi.org/project/emissaoja/) [![CI](https://github.com/Easymovel/emissaoja-py/actions/workflows/ci.yml/badge.svg)](https://github.com/Easymovel/emissaoja-py/actions/workflows/ci.yml) [![Licença MIT](https://img.shields.io/badge/licen%C3%A7a-MIT-blue)](LICENSE)
4
+
5
+ Cliente oficial da **[API do emissaoja](https://emissaoja.com.br/docs/)** para emitir **NFS-e (Nota Fiscal de Serviço eletrônica) no Emissor Nacional** (nfse.gov.br) a partir do seu sistema em Python, **sem certificado digital**: o emissaoja entra no portal com o CNPJ e a senha da própria empresa. Feito para **MEI e empresas do Simples Nacional**. Cadastre tomadores pelo CNPJ (nome e endereço vêm da Receita Federal), emita, acompanhe a autorização, baixe o **PDF (DANFSe) e o XML** e cancele notas.
6
+
7
+ - **Só biblioteca padrão** (urllib), Python 3.9+, com `py.typed`.
8
+ - **Idempotência embutida:** toda emissão leva uma `referencia` sua, que vira o cabeçalho `Idempotency-Key`. Repetir a mesma referência nunca emite duas notas.
9
+ - `notas.aguardar()` acompanha o pedido até `autorizada`, `recusada` ou `incerta`, no ritmo que a API indica (`proxima_consulta_ms`).
10
+ - Erros com `code` estável (`nota_bloqueada`, `limite_notas`, `possivel_duplicada`…) e o que falta resolver em `extras["bloqueios"]`.
11
+ - **Sandbox grátis** com Emissor Nacional simulado: teste o fluxo inteiro sem efeito fiscal.
12
+
13
+ ## Instalação
14
+
15
+ ```bash
16
+ pip install emissaoja
17
+ ```
18
+
19
+ Crie a conta com o Google em [emissaoja.com.br](https://emissaoja.com.br/?origem=lib-pypi) e gere a chave em **Conta → API** (ela aparece uma vez; prefixo `ej_live_`). Guarde a chave no servidor:
20
+
21
+ ```bash
22
+ export EMISSAOJA_KEY=ej_live_...
23
+ ```
24
+
25
+ ## Teste no sandbox em 2 minutos
26
+
27
+ O sandbox (`https://sandbox.emissaoja.com.br`) é o mesmo emissaoja com um **Emissor Nacional simulado**: nada ali existe no fisco e nada é cobrado. Entre com o Google, crie uma chave `ej_test_` em Conta → API e rode:
28
+
29
+ ```python
30
+ from emissaoja import BASE_SANDBOX, Emissaoja
31
+
32
+ ej = Emissaoja(base_url=BASE_SANDBOX) # lê a chave de EMISSAOJA_KEY; ou Emissaoja("ej_test_...", base_url=...)
33
+
34
+ # 1. Conectar a empresa (uma vez). 11111111000191 é um MEI de exemplo pronto para emitir; qualquer senha serve.
35
+ ej.empresa.conectar("11111111000191", "senha-de-teste")
36
+ ej.empresa.aguardar_credencial()
37
+
38
+ # 2. Tomador pelo CNPJ: nome e endereço vêm da Receita Federal
39
+ tomador = ej.tomadores.criar("12345678000195")["tomador"]
40
+
41
+ # 3. Emitir (prévia + emissão num passo). A referência é o seu id da nota.
42
+ pedido = ej.notas.emitir(
43
+ "pedido-2026-11-0001",
44
+ tomador["tipo"],
45
+ tomador["id"],
46
+ 1500,
47
+ descricao="Consultoria em marketing digital - novembro/2026",
48
+ )["pedido"]
49
+
50
+ # 4. Acompanhar até autorizada | recusada | incerta
51
+ final = ej.notas.aguardar(pedido["id"])["pedido"]
52
+ print(final["estado"], final.get("numero"), final.get("chave_acesso"))
53
+
54
+ # 5. PDF (DANFSe) e XML
55
+ if final["estado"] == "autorizada":
56
+ pdf = ej.notas.pdf(final["nota"]["origem"], final["nota"]["id"]) # bytes
57
+ xml = ej.notas.xml(final["nota"]["origem"], final["nota"]["id"]) # str
58
+ ```
59
+
60
+ Valores com significado no sandbox: `13.13` gera uma nota **incerta** que `ej.emissoes.conferir(id)` resolve como autorizada, `77.77` uma incerta que não saiu, `66.66` uma **recusada** pelo fisco e `99.99` um **bloqueio** já na prévia (`422 nota_bloqueada`). Outras empresas de exemplo: `22222222000191` (ME que pede a alíquota do Simples antes de emitir), `33333333000191` (MEI sem notas anteriores) e `44444444000191` (bloqueada). A senha `errada` simula senha recusada pelo portal. Exemplo completo em [`exemplos/emitir_nfse_sandbox.py`](exemplos/emitir_nfse_sandbox.py).
61
+
62
+ ## Em produção
63
+
64
+ `Emissaoja()` usa `https://emissaoja.com.br` e lê `EMISSAOJA_KEY`. A chave `ej_live_` vale para a empresa e o plano da conta que a criou; chaves `ej_test_` só valem no sandbox.
65
+
66
+ > **A chave é secreta.** Use o cliente só no servidor (Django, Flask, FastAPI, filas, scripts). Quem tiver a chave emite notas em nome da empresa.
67
+
68
+ Com Django ou FastAPI, crie um cliente por processo e reutilize (ele não guarda estado entre chamadas):
69
+
70
+ ```python
71
+ # settings.py / config.py
72
+ from emissaoja import Emissaoja
73
+
74
+ EMISSAOJA = Emissaoja() # chave de EMISSAOJA_KEY, lida na primeira chamada
75
+ ```
76
+
77
+ ## Empresa: conectar ao Emissor Nacional
78
+
79
+ ```python
80
+ ej.empresa.conectar("11222333000181", "senha-do-emissor-nacional") # 202, verificação em segundo plano
81
+ tentativa = ej.empresa.aguardar_credencial()["tentativa"]
82
+ # tentativa["proximo_passo"]: configurando | pronto | corrigir_senha | tentar_mais_tarde | redefinir_no_portal | suporte
83
+
84
+ empresa = ej.empresa.obter() # empresa["onboarding"]["estado"]: configurando → perguntas → pronto
85
+ if empresa["onboarding"]["estado"] == "perguntas":
86
+ print(ej.empresa.onboarding()) # o que o cadastro fiscal não respondeu sozinho
87
+ ej.empresa.fiscal(aliquota_simples=6) # só com o valor que a empresa informou
88
+ ```
89
+
90
+ A senha é conferida no portal e nunca volta em resposta. O portal bloqueia a conta da empresa depois de algumas senhas erradas: a API limita a 3 tentativas erradas por CNPJ em 24 h.
91
+
92
+ ## Tomadores e serviços
93
+
94
+ ```python
95
+ ej.tomadores.criar("52998224725", nome="Maria da Silva") # CPF precisa do nome
96
+ ej.tomadores.listar(busca="alfa", ordem="recentes")
97
+ ej.tomadores.buscar("12.345.678")
98
+
99
+ itens = ej.servicos.catalogo("consultoria")["itens"] # código nacional (cTribNac) por texto
100
+ ej.servicos.criar(descricao="Consultoria", c_trib_nac=itens[0]["c_trib_nac"], trib_issqn="tributavel", retem_iss=False)
101
+ ej.servicos.listar() # emitir sem servico_id usa o principal
102
+ ```
103
+
104
+ ## Notas
105
+
106
+ ```python
107
+ notas = ej.notas.listar(periodo="mes_atual", situacao="emitidas")["notas"]
108
+ operacao = ej.notas.cancelar("local", nota_id, "1", justificativa="Valor digitado errado.")["operacao"]
109
+ ej.operacoes.aguardar(operacao["id"]) # processando → concluida | falhou
110
+ ```
111
+
112
+ Nota não se altera: para corrigir, cancele e emita outra. Nota autorizada e depois cancelada conta no plano; recusada não conta.
113
+
114
+ ## Idempotência e nota em dobro
115
+
116
+ - Use uma `referencia` por nota que você quer emitir (o id do seu pedido serve; 8 a 64 caracteres: letras, números, `_` e `-`). Repetir a mesma devolve o mesmo `pedido` com `repetido: True`.
117
+ - `503 fila_indisponivel`: repita **com a mesma referência**.
118
+ - `409 possivel_duplicada`: já existe nota igual (tomador, valor e competência) no mesmo dia. Se for mesmo outra nota, repita com `permitir_repetida_de=e.extras["nota_igual_recente"]["pedido_id"]`.
119
+ - `409 emissao_repetida_recente`: mesma nota há menos de 2 minutos. Se for intencional, repita com `confirmo_repeticao=True`.
120
+ - A biblioteca não repete chamadas sozinha: emitir tem efeito fiscal, e quem decide repetir é você.
121
+
122
+ ## Erros
123
+
124
+ ```python
125
+ from emissaoja import EmissaojaErro
126
+
127
+ try:
128
+ ej.notas.emitir("pedido-0002", "pj", tomador_id, 100)
129
+ except EmissaojaErro as e:
130
+ if e.code == "nota_bloqueada":
131
+ print(e.extras["bloqueios"]) # [{"code", "mensagem", "acao"}]
132
+ elif e.code == "limite_notas":
133
+ print("limite do plano no mês")
134
+ else:
135
+ print(e.status, e.code, e, e.request_id)
136
+ ```
137
+
138
+ | HTTP | `code` | O que fazer |
139
+ | --- | --- | --- |
140
+ | 400 | `validation_error`, `valor_invalido`, `cnpj_invalido`, `chave_idempotencia_invalida` | Corrigir o campo |
141
+ | 401 | `chave_invalida` | Conferir a chave (ou o ambiente: `ej_test_` só no sandbox) |
142
+ | 402 | `limite_notas` | Limite do plano no mês |
143
+ | 404 | `tomador_nao_encontrado`, `servico_nao_encontrado`, `nota_nao_encontrada`, `pedido_nao_encontrado` | Conferir o id |
144
+ | 409 | `sem_empresa` | Conectar a empresa (`empresa.conectar`) |
145
+ | 409 | `aceite_pendente` | Termos ou Política mudaram: entre no [app](https://emissaoja.com.br/app) e aceite a versão nova; a chave volta a funcionar |
146
+ | 409 | `possivel_duplicada`, `emissao_repetida_recente`, `incerta_pendente` | Ver "Idempotência e nota em dobro" |
147
+ | 422 | `nota_bloqueada` | Resolver `extras["bloqueios"]` |
148
+ | 429 | `rate_limited` | Esperar `Retry-After` |
149
+ | 503 | `motor_indisponivel`, `fila_indisponivel`, `receita_indisponivel` | Tentar de novo (com a mesma referência) |
150
+ | 0 | `rede`, `tempo_esgotado` | Falha de rede ou tempo esgotado no seu lado |
151
+
152
+ Limites: 60 chamadas por minuto por chave, 10 emissões por minuto e 120 por minuto em buscas e catálogo.
153
+
154
+ ## Referência
155
+
156
+ | Recurso | Métodos |
157
+ | --- | --- |
158
+ | `empresa` | `obter`, `conectar`, `credencial`, `aguardar_credencial`, `onboarding`, `fiscal`, `reler_fisco` |
159
+ | `tomadores` | `listar`, `buscar`, `criar`, `obter`, `atualizar` |
160
+ | `servicos` | `listar`, `criar`, `atualizar`, `desativar`, `catalogo` |
161
+ | `notas` | `emitir`, `aguardar`, `listar`, `pdf`, `xml`, `cancelar` |
162
+ | `emissoes` | `obter`, `listar`, `conferir` |
163
+ | `operacoes` | `obter`, `aguardar` |
164
+ | `cnpj` | `consultar` (dados da Receita Federal) |
165
+ | — | `status()` (sem chave) |
166
+
167
+ `Emissaoja(chave=None, base_url=BASE_PRODUCAO, timeout=30.0)`. Referência completa da API: [emissaoja.com.br/docs](https://emissaoja.com.br/docs/) e [OpenAPI 3.1](https://emissaoja.com.br/v1/openapi.json).
168
+
169
+ ## Perguntas frequentes
170
+
171
+ **Precisa de certificado digital?** Não. O emissaoja entra no Emissor Nacional com o CNPJ e a senha do portal da própria empresa, o acesso que o portal oferece a MEI e empresas do Simples Nacional.
172
+
173
+ **Funciona para MEI?** Sim. O público é MEI, ME e EPP optantes pelo Simples Nacional, em um município que emite pelo Emissor Nacional.
174
+
175
+ **Tem ambiente de testes?** Sim, o sandbox em `https://sandbox.emissaoja.com.br`, com o Emissor Nacional simulado e empresas de exemplo.
176
+
177
+ **Quanto custa?** O plano Grátis emite 1 nota por mês; os planos pagos estão em [emissaoja.com.br/#precos](https://emissaoja.com.br/#precos). O sandbox é grátis. Nota recusada não conta.
178
+
179
+ **Tem webhook?** Ainda não. Acompanhe o pedido com `notas.aguardar()` ou `emissoes.obter()`, que trazem `proxima_consulta_ms`.
180
+
181
+ ## Outras formas de integrar
182
+
183
+ - Node.js/TypeScript: [`npm install emissaoja`](https://github.com/Easymovel/emissaoja-js) · PHP/Laravel: [`composer require emissaoja/emissaoja`](https://github.com/Easymovel/emissaoja-php) · .NET: [`dotnet add package Emissaoja`](https://github.com/Easymovel/emissaoja-dotnet) · Go: [`go get emissaoja.com.br/go`](https://github.com/Easymovel/emissaoja-go) · Ruby: [`gem install emissaoja`](https://github.com/Easymovel/emissaoja-ruby)
184
+ - n8n: [`n8n-nodes-emissaoja`](https://github.com/Easymovel/n8n-nodes-emissaoja) · WooCommerce: [emissaoja NFS-e para WooCommerce](https://github.com/Easymovel/emissaoja-woocommerce)
185
+ - Assistentes de IA (Claude, ChatGPT, Cursor, VS Code): servidor MCP em `https://emissaoja.com.br/mcp` ([emissaoja-mcp](https://github.com/Easymovel/emissaoja-mcp))
186
+ - Documentação para IAs: [llms.txt](https://emissaoja.com.br/llms.txt) · [docs.md](https://emissaoja.com.br/docs.md)
187
+
188
+ ## In English
189
+
190
+ Official Python client for the [emissaoja API](https://emissaoja.com.br/docs/): issue Brazilian **NFS-e** (electronic service invoices) through the national **Emissor Nacional** without a digital certificate, for MEI and Simples Nacional companies. Standard library only, typed, idempotent issuing, PDF and XML download, and a free sandbox with a simulated tax authority. The API and the docs are in Portuguese.
191
+
192
+ ---
193
+
194
+ Suporte: [suporte@emissaoja.com.br](mailto:suporte@emissaoja.com.br). Licença MIT, © 2026 BINOBO LTDA.
195
+
196
+ O emissaoja é um serviço privado e não tem vínculo com a Receita Federal nem com o Emissor Nacional.
@@ -0,0 +1,71 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "emissaoja"
7
+ version = "0.1.0"
8
+ description = "API de NFS-e nacional para Python: emita nota fiscal de serviço eletrônica no Emissor Nacional sem certificado digital (MEI e Simples Nacional). PDF, XML, tomadores. Cliente oficial do emissaoja."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "BINOBO LTDA" }]
13
+ keywords = [
14
+ "nfse",
15
+ "nfs-e",
16
+ "nfse nacional",
17
+ "api nfse",
18
+ "emitir nfse",
19
+ "nota fiscal",
20
+ "nota fiscal de servico",
21
+ "nota fiscal eletronica",
22
+ "emissor nacional",
23
+ "danfse",
24
+ "sem certificado digital",
25
+ "mei",
26
+ "simples nacional",
27
+ "iss",
28
+ "brasil",
29
+ "brazil",
30
+ "invoice",
31
+ "emissaoja",
32
+ ]
33
+ classifiers = [
34
+ "Development Status :: 4 - Beta",
35
+ "Intended Audience :: Developers",
36
+ "License :: OSI Approved :: MIT License",
37
+ "Natural Language :: Portuguese (Brazilian)",
38
+ "Operating System :: OS Independent",
39
+ "Programming Language :: Python :: 3",
40
+ "Programming Language :: Python :: 3 :: Only",
41
+ "Programming Language :: Python :: 3.9",
42
+ "Programming Language :: Python :: 3.10",
43
+ "Programming Language :: Python :: 3.11",
44
+ "Programming Language :: Python :: 3.12",
45
+ "Programming Language :: Python :: 3.13",
46
+ "Programming Language :: Python :: 3.14",
47
+ "Topic :: Office/Business :: Financial :: Accounting",
48
+ "Topic :: Software Development :: Libraries :: Python Modules",
49
+ "Typing :: Typed",
50
+ ]
51
+ dependencies = []
52
+
53
+ [project.urls]
54
+ Homepage = "https://emissaoja.com.br"
55
+ Documentation = "https://emissaoja.com.br/docs/"
56
+ Source = "https://github.com/Easymovel/emissaoja-py"
57
+ Changelog = "https://github.com/Easymovel/emissaoja-py/blob/main/CHANGELOG.md"
58
+ Issues = "https://github.com/Easymovel/emissaoja-py/issues"
59
+
60
+ [tool.hatch.build.targets.wheel]
61
+ packages = ["src/emissaoja"]
62
+
63
+ [tool.hatch.build.targets.sdist]
64
+ include = [
65
+ "/src/emissaoja",
66
+ "/tests",
67
+ "/README.md",
68
+ "/CHANGELOG.md",
69
+ "/LICENSE",
70
+ "/pyproject.toml",
71
+ ]
@@ -0,0 +1,301 @@
1
+ """emissaoja: cliente oficial da API de NFS-e do emissaoja (https://emissaoja.com.br/docs/).
2
+
3
+ Emite Nota Fiscal de Serviço eletrônica (NFS-e) no Emissor Nacional (nfse.gov.br) com o CNPJ e a senha do
4
+ portal, sem certificado digital, para MEI e empresas do Simples Nacional.
5
+
6
+ Só biblioteca padrão (urllib). Mesma forma do cliente JavaScript: ``Emissaoja(chave)`` e os
7
+ recursos ``empresa``, ``tomadores``, ``servicos``, ``notas``, ``emissoes``, ``operacoes`` e ``cnpj``.
8
+ Erros da API viram ``EmissaojaErro`` com ``code`` (a lista fechada da API), ``status`` e ``extras``.
9
+
10
+ Exemplo no sandbox (Emissor Nacional simulado)::
11
+
12
+ from emissaoja import Emissaoja, BASE_SANDBOX
13
+
14
+ ej = Emissaoja(base_url=BASE_SANDBOX) # chave em EMISSAOJA_KEY (ej_test_...)
15
+ tomador = ej.tomadores.criar("12345678000195")["tomador"]
16
+ pedido = ej.notas.emitir("pedido-0001", tomador["tipo"], tomador["id"], 150, descricao="Consultoria")["pedido"]
17
+ final = ej.notas.aguardar(pedido["id"])["pedido"]
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import http.client
23
+ import json
24
+ import os
25
+ import re
26
+ import socket
27
+ import time
28
+ import urllib.error
29
+ import urllib.parse
30
+ import urllib.request
31
+ from typing import Any, Callable, Dict, Optional
32
+
33
+ __all__ = ["Emissaoja", "EmissaojaErro", "BASE_PRODUCAO", "BASE_SANDBOX", "__version__"]
34
+ __version__ = "0.1.0"
35
+
36
+ BASE_PRODUCAO = "https://emissaoja.com.br"
37
+ BASE_SANDBOX = "https://sandbox.emissaoja.com.br"
38
+ _BASE_PADRAO = BASE_PRODUCAO
39
+ _TERMINAIS = {"autorizada", "recusada", "incerta"}
40
+ _RE_REFERENCIA = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
41
+
42
+
43
+ class EmissaojaErro(Exception):
44
+ """Erro da API (``status`` HTTP, ``code`` da lista fechada, ``extras`` com bloqueios etc.)."""
45
+
46
+ def __init__(self, status: int, corpo: Optional[dict] = None, request_id: Optional[str] = None):
47
+ c = corpo if isinstance(corpo, dict) else {}
48
+ super().__init__(c.get("message") or f"HTTP {status}")
49
+ self.status = status
50
+ self.code: str = c.get("code") or "erro_desconhecido"
51
+ self.extras: Dict[str, Any] = {k: v for k, v in c.items() if k not in ("status", "message", "code")}
52
+ self.request_id = request_id
53
+
54
+
55
+ # Transporte: (metodo, url, cabecalhos, corpo_bytes, timeout) -> (status, cabecalhos, bytes). Injetável nos testes.
56
+ Transporte = Callable[[str, str, Dict[str, str], Optional[bytes], float], "tuple[int, Dict[str, str], bytes]"]
57
+
58
+
59
+ def _transporte_urllib(metodo: str, url: str, cabecalhos: Dict[str, str], corpo: Optional[bytes], timeout: float):
60
+ req = urllib.request.Request(url, data=corpo, method=metodo, headers=cabecalhos)
61
+ try:
62
+ with urllib.request.urlopen(req, timeout=timeout) as res: # noqa: S310 (URL é a da API)
63
+ return res.status, {k.lower(): v for k, v in res.headers.items()}, res.read()
64
+ except urllib.error.HTTPError as e:
65
+ cabecalhos_erro = {k.lower(): v for k, v in e.headers.items()} if e.headers is not None else {}
66
+ return e.code, cabecalhos_erro, e.read()
67
+ except urllib.error.URLError as e:
68
+ if isinstance(e.reason, (TimeoutError, socket.timeout)):
69
+ raise EmissaojaErro(0, {"code": "tempo_esgotado", "message": "A API não respondeu a tempo."}) from e
70
+ raise EmissaojaErro(0, {"code": "rede", "message": str(e.reason)}) from e
71
+ except (TimeoutError, socket.timeout) as e: # no 3.9, socket.timeout ainda não é TimeoutError
72
+ raise EmissaojaErro(0, {"code": "tempo_esgotado", "message": "A API não respondeu a tempo."}) from e
73
+ except (OSError, http.client.HTTPException) as e:
74
+ raise EmissaojaErro(0, {"code": "rede", "message": str(e) or e.__class__.__name__}) from e
75
+
76
+
77
+ class _Recurso:
78
+ def __init__(self, cliente: "Emissaoja"):
79
+ self._c = cliente
80
+
81
+
82
+ class _Empresa(_Recurso):
83
+ def obter(self) -> dict:
84
+ return self._c._json("GET", "/v1/empresa")
85
+
86
+ def conectar(self, cnpj: str, senha: str, ciente_ultima_tentativa: bool = False) -> dict:
87
+ """``PUT /v1/empresa/credencial``: CNPJ e senha do Emissor Nacional → ``{"tentativa": …}``."""
88
+ return self._c._json("PUT", "/v1/empresa/credencial", {"cnpj": cnpj, "senha": senha, "ciente_ultima_tentativa": bool(ciente_ultima_tentativa)})
89
+
90
+ def credencial(self) -> dict:
91
+ return self._c._json("GET", "/v1/empresa/credencial")
92
+
93
+ def aguardar_credencial(self, intervalo: float = 2.0, timeout: float = 120.0) -> dict:
94
+ """Consulta até ``tentativa.proximo_passo`` sair de ``aguardar``."""
95
+ fim = time.monotonic() + timeout
96
+ while True:
97
+ r = self.credencial()
98
+ t = r.get("tentativa")
99
+ if not t or t.get("proximo_passo") != "aguardar":
100
+ return r
101
+ if time.monotonic() > fim:
102
+ raise EmissaojaErro(0, {"code": "tempo_esgotado", "message": "A verificação da senha não terminou a tempo."})
103
+ self._c._dormir(intervalo)
104
+
105
+ def onboarding(self) -> dict:
106
+ return self._c._json("GET", "/v1/empresa/onboarding")
107
+
108
+ def fiscal(self, aliquota_simples: Optional[float] = None, reg_ap_trib_sn: Optional[int] = None) -> dict:
109
+ corpo: Dict[str, Any] = {}
110
+ if aliquota_simples is not None:
111
+ corpo["aliquota_simples"] = aliquota_simples
112
+ if reg_ap_trib_sn is not None:
113
+ corpo["reg_ap_trib_sn"] = reg_ap_trib_sn
114
+ return self._c._json("PUT", "/v1/empresa/fiscal", corpo)
115
+
116
+ def reler_fisco(self) -> dict:
117
+ return self._c._json("POST", "/v1/empresa/reler-fisco")
118
+
119
+
120
+ class _Tomadores(_Recurso):
121
+ def listar(self, busca: Optional[str] = None, pagina: int = 1, limite: Optional[int] = None, ordem: Optional[str] = None) -> dict:
122
+ return self._c._json("GET", "/v1/tomadores", query={"busca": busca, "pagina": pagina, "limite": limite, "ordem": ordem})
123
+
124
+ def buscar(self, q: str) -> dict:
125
+ return self._c._json("GET", "/v1/tomadores/busca", query={"q": q})
126
+
127
+ def criar(self, documento: str, nome: Optional[str] = None) -> dict:
128
+ corpo: Dict[str, Any] = {"documento": documento}
129
+ if nome:
130
+ corpo["nome"] = nome
131
+ return self._c._json("POST", "/v1/tomadores", corpo)
132
+
133
+ def obter(self, tipo: str, id: str) -> dict:
134
+ return self._c._json("GET", f"/v1/tomadores/{_q(tipo)}/{_q(id)}")
135
+
136
+ def atualizar(self, tipo: str, id: str, **campos: Any) -> dict:
137
+ return self._c._json("PUT", f"/v1/tomadores/{_q(tipo)}/{_q(id)}", campos)
138
+
139
+
140
+ class _Servicos(_Recurso):
141
+ def listar(self, ativos: str = "1") -> dict:
142
+ return self._c._json("GET", "/v1/servicos", query={"ativos": ativos})
143
+
144
+ def criar(self, **campos: Any) -> dict:
145
+ return self._c._json("POST", "/v1/servicos", campos)
146
+
147
+ def atualizar(self, id: str, **campos: Any) -> dict:
148
+ return self._c._json("PUT", f"/v1/servicos/{_q(id)}", campos)
149
+
150
+ def desativar(self, id: str) -> dict:
151
+ return self._c._json("DELETE", f"/v1/servicos/{_q(id)}")
152
+
153
+ def catalogo(self, q: str, limite: Optional[int] = None) -> dict:
154
+ return self._c._json("GET", "/v1/catalogo/servicos", query={"q": q, "limite": limite})
155
+
156
+
157
+ class _Notas(_Recurso):
158
+ def emitir(self, referencia: str, tomador_tipo: str, tomador_id: str, valor: float, **campos: Any) -> dict:
159
+ """``POST /v1/notas`` com ``Idempotency-Key`` = ``referencia`` (um id seu por nota).
160
+
161
+ Devolve ``{"pedido": …, "previa": …}`` (202) ou ``{"pedido": …, "repetido": True}`` (mesma referência).
162
+ """
163
+ if not isinstance(referencia, str) or not _RE_REFERENCIA.match(referencia):
164
+ raise EmissaojaErro(0, {"code": "chave_idempotencia_invalida", "message": "Informe `referencia` (8 a 64 caracteres: letras, números, _ e -), única por nota."})
165
+ corpo = {"tomador_tipo": tomador_tipo, "tomador_id": tomador_id, "valor": valor, **{k: v for k, v in campos.items() if v is not None}}
166
+ return self._c._json("POST", "/v1/notas", corpo, cabecalhos={"Idempotency-Key": referencia})
167
+
168
+ def aguardar(self, pedido_id: str, timeout: float = 180.0, intervalo_minimo: float = 1.0) -> dict:
169
+ """Consulta o pedido até ``autorizada``, ``recusada`` ou ``incerta``, respeitando ``proxima_consulta_ms``."""
170
+ fim = time.monotonic() + timeout
171
+ while True:
172
+ r = self._c.emissoes.obter(pedido_id)
173
+ p = r.get("pedido") or {}
174
+ if p.get("estado") in _TERMINAIS:
175
+ return r
176
+ if time.monotonic() > fim:
177
+ raise EmissaojaErro(0, {"code": "tempo_esgotado", "message": "O pedido não terminou a tempo; consulte de novo mais tarde.", "pedido_id": pedido_id})
178
+ self._c._dormir(max(intervalo_minimo, (p.get("proxima_consulta_ms") or 3000) / 1000))
179
+
180
+ def listar(self, **query: Any) -> dict:
181
+ return self._c._json("GET", "/v1/notas", query=query)
182
+
183
+ def pdf(self, origem: str, id: str) -> bytes:
184
+ return self._c._bytes(f"/v1/notas/{_q(origem)}/{_q(id)}/pdf")
185
+
186
+ def xml(self, origem: str, id: str) -> str:
187
+ return self._c._bytes(f"/v1/notas/{_q(origem)}/{_q(id)}/xml").decode("utf-8")
188
+
189
+ def cancelar(self, origem: str, id: str, motivo: str, justificativa: Optional[str] = None) -> dict:
190
+ corpo: Dict[str, Any] = {"motivo": str(motivo)}
191
+ if justificativa:
192
+ corpo["justificativa"] = justificativa
193
+ return self._c._json("POST", f"/v1/notas/{_q(origem)}/{_q(id)}/cancelar", corpo)
194
+
195
+
196
+ class _Emissoes(_Recurso):
197
+ def obter(self, id: str) -> dict:
198
+ return self._c._json("GET", f"/v1/emissoes/{_q(id)}")
199
+
200
+ def listar(self, estados: Optional[str] = None) -> dict:
201
+ return self._c._json("GET", "/v1/emissoes", query={"estados": estados})
202
+
203
+ def conferir(self, id: str) -> dict:
204
+ return self._c._json("POST", f"/v1/emissoes/{_q(id)}/conferir")
205
+
206
+
207
+ class _Operacoes(_Recurso):
208
+ def obter(self, id: str) -> dict:
209
+ return self._c._json("GET", f"/v1/operacoes/{_q(id)}")
210
+
211
+ def aguardar(self, id: str, intervalo: float = 2.0, timeout: float = 180.0) -> dict:
212
+ fim = time.monotonic() + timeout
213
+ while True:
214
+ r = self.obter(id)
215
+ if (r.get("operacao") or {}).get("estado") != "processando":
216
+ return r
217
+ if time.monotonic() > fim:
218
+ raise EmissaojaErro(0, {"code": "tempo_esgotado", "message": "A operação não terminou a tempo."})
219
+ self._c._dormir(intervalo)
220
+
221
+
222
+ class _Cnpj(_Recurso):
223
+ def consultar(self, cnpj: str) -> dict:
224
+ return self._c._json("GET", f"/v1/cnpj/{re.sub(r'[^0-9]', '', str(cnpj))}")
225
+
226
+
227
+ class Emissaoja:
228
+ """``Emissaoja(chave)``; ``chave`` omitida lê ``EMISSAOJA_KEY`` na primeira chamada."""
229
+
230
+ def __init__(
231
+ self,
232
+ chave: Optional[str] = None,
233
+ base_url: str = _BASE_PADRAO,
234
+ timeout: float = 30.0,
235
+ transporte: Optional[Transporte] = None,
236
+ dormir: Optional[Callable[[float], None]] = None,
237
+ ):
238
+ self._chave = chave
239
+ self.base_url = base_url.rstrip("/")
240
+ self.timeout = timeout
241
+ self._transporte = transporte or _transporte_urllib
242
+ self._dormir: Callable[[float], None] = dormir or time.sleep
243
+ self.empresa = _Empresa(self)
244
+ self.tomadores = _Tomadores(self)
245
+ self.servicos = _Servicos(self)
246
+ self.notas = _Notas(self)
247
+ self.emissoes = _Emissoes(self)
248
+ self.operacoes = _Operacoes(self)
249
+ self.cnpj = _Cnpj(self)
250
+
251
+ @property
252
+ def chave(self) -> str:
253
+ if self._chave is None:
254
+ env = os.environ.get("EMISSAOJA_KEY")
255
+ if not env:
256
+ raise EmissaojaErro(0, {"code": "chave_invalida", "message": "Informe a chave: Emissaoja(chave) ou a variável EMISSAOJA_KEY."})
257
+ self._chave = env
258
+ return self._chave
259
+
260
+ def status(self) -> dict:
261
+ return self._json("GET", "/v1/status", sem_chave=True)
262
+
263
+ # ---- transporte ----
264
+ def _req(self, metodo: str, caminho: str, corpo: Any = None, query: Optional[dict] = None, cabecalhos: Optional[dict] = None, sem_chave: bool = False):
265
+ url = self.base_url + caminho
266
+ q = {k: v for k, v in (query or {}).items() if v is not None and v != ""}
267
+ if q:
268
+ url += "?" + urllib.parse.urlencode(q)
269
+ h = {"Accept": "application/json", "User-Agent": f"emissaoja-py/{__version__}", **(cabecalhos or {})}
270
+ if not sem_chave:
271
+ h["Authorization"] = f"Bearer {self.chave}"
272
+ dados = None
273
+ if corpo is not None:
274
+ h["Content-Type"] = "application/json"
275
+ dados = json.dumps(corpo).encode("utf-8")
276
+ return self._transporte(metodo, url, h, dados, self.timeout)
277
+
278
+ def _json(self, metodo: str, caminho: str, corpo: Any = None, query: Optional[dict] = None, cabecalhos: Optional[dict] = None, sem_chave: bool = False) -> dict:
279
+ status, h, bytes_ = self._req(metodo, caminho, corpo, query, cabecalhos, sem_chave)
280
+ texto = bytes_.decode("utf-8") if bytes_ else ""
281
+ try:
282
+ dados = json.loads(texto) if texto else None
283
+ except ValueError:
284
+ dados = None
285
+ if status >= 400:
286
+ raise EmissaojaErro(status, dados if isinstance(dados, dict) else {"message": texto[:200] or f"HTTP {status}"}, h.get("x-request-id"))
287
+ return dados if isinstance(dados, dict) else {}
288
+
289
+ def _bytes(self, caminho: str) -> bytes:
290
+ status, h, bytes_ = self._req("GET", caminho)
291
+ if status >= 400:
292
+ try:
293
+ dados = json.loads(bytes_.decode("utf-8"))
294
+ except ValueError:
295
+ dados = None
296
+ raise EmissaojaErro(status, dados if isinstance(dados, dict) else None, h.get("x-request-id"))
297
+ return bytes_
298
+
299
+
300
+ def _q(v: Any) -> str:
301
+ return urllib.parse.quote(str(v), safe="")
File without changes
@@ -0,0 +1,105 @@
1
+ """Testes do cliente com um transporte falso (sem rede). Rodar: python -m unittest discover -s tests -v"""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ import unittest
7
+
8
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
9
+ from emissaoja import Emissaoja, EmissaojaErro # noqa: E402
10
+
11
+
12
+ class Transporte:
13
+ """Guarda as chamadas e responde pelo caminho."""
14
+
15
+ def __init__(self):
16
+ self.chamadas = []
17
+ self.respostas = {}
18
+
19
+ def __call__(self, metodo, url, cabecalhos, corpo, timeout):
20
+ self.chamadas.append((metodo, url, cabecalhos, corpo))
21
+ caminho = url.split("emissaoja.test", 1)[1].split("?")[0]
22
+ status, dados = self.respostas.get((metodo, caminho), (404, {"status": "ERROR", "code": "not_found", "message": "Rota não encontrada."}))
23
+ if isinstance(dados, (bytes, bytearray)):
24
+ return status, {"content-type": "application/pdf"}, bytes(dados)
25
+ return status, {"content-type": "application/json", "x-request-id": "r_teste"}, json.dumps(dados).encode("utf-8")
26
+
27
+
28
+ class ClienteTest(unittest.TestCase):
29
+ def setUp(self):
30
+ self.t = Transporte()
31
+ self.ej = Emissaoja("ej_live_" + "a" * 43, base_url="https://emissaoja.test", transporte=self.t)
32
+
33
+ def test_cabecalhos_e_status_sem_chave(self):
34
+ self.t.respostas[("GET", "/v1/status")] = (200, {"ok": True, "motor": "ok"})
35
+ self.assertEqual(self.ej.status()["motor"], "ok")
36
+ _, _, h, _ = self.t.chamadas[0]
37
+ self.assertNotIn("Authorization", h)
38
+ self.t.respostas[("GET", "/v1/empresa")] = (200, {"empresa": None})
39
+ self.ej.empresa.obter()
40
+ self.assertEqual(self.t.chamadas[1][2]["Authorization"], "Bearer ej_live_" + "a" * 43)
41
+ self.assertTrue(self.t.chamadas[1][2]["User-Agent"].startswith("emissaoja-py/"))
42
+
43
+ def test_emitir_exige_referencia_e_manda_idempotency_key(self):
44
+ with self.assertRaises(EmissaojaErro) as cm:
45
+ self.ej.notas.emitir("curta", "pj", "id", 10)
46
+ self.assertEqual(cm.exception.code, "chave_idempotencia_invalida")
47
+ self.t.respostas[("POST", "/v1/notas")] = (202, {"pedido": {"id": "p1", "estado": "na_fila"}})
48
+ r = self.ej.notas.emitir("pedido-0001", "pj", "11111111-1111-1111-1111-111111111111", 150.0, descricao="x", competencia=None)
49
+ self.assertEqual(r["pedido"]["id"], "p1")
50
+ metodo, url, h, corpo = self.t.chamadas[-1]
51
+ self.assertEqual(h["Idempotency-Key"], "pedido-0001")
52
+ self.assertEqual(json.loads(corpo), {"tomador_tipo": "pj", "tomador_id": "11111111-1111-1111-1111-111111111111", "valor": 150.0, "descricao": "x"})
53
+
54
+ def test_erro_da_api_vira_excecao_com_code_e_extras(self):
55
+ self.t.respostas[("POST", "/v1/notas")] = (422, {"status": "ERROR", "code": "nota_bloqueada", "message": "Antes de emitir, resolva:", "bloqueios": [{"code": "tomador_incompleto", "mensagem": "…", "acao": "completar_tomador"}]})
56
+ with self.assertRaises(EmissaojaErro) as cm:
57
+ self.ej.notas.emitir("pedido-0002", "pj", "11111111-1111-1111-1111-111111111111", 10)
58
+ e = cm.exception
59
+ self.assertEqual((e.status, e.code, e.request_id), (422, "nota_bloqueada", "r_teste"))
60
+ self.assertEqual(e.extras["bloqueios"][0]["acao"], "completar_tomador")
61
+
62
+ def test_aguardar_respeita_proxima_consulta(self):
63
+ estados = iter([{"estado": "emitindo", "proxima_consulta_ms": 1}, {"estado": "autorizada", "numero": "7"}])
64
+ self.t.respostas[("GET", "/v1/emissoes/p1")] = (200, {})
65
+ original = self.t.__call__
66
+
67
+ def responder(metodo, url, h, corpo, timeout):
68
+ status, hh, _ = original(metodo, url, h, corpo, timeout)
69
+ return 200, hh, json.dumps({"pedido": next(estados)}).encode()
70
+
71
+ self.ej._transporte = responder
72
+ r = self.ej.notas.aguardar("p1", intervalo_minimo=0.001)
73
+ self.assertEqual(r["pedido"]["numero"], "7")
74
+
75
+ def test_query_e_bytes(self):
76
+ self.t.respostas[("GET", "/v1/tomadores")] = (200, {"tomadores": [], "total": 0})
77
+ self.ej.tomadores.listar(busca="alfa", pagina=2)
78
+ self.assertIn("busca=alfa&pagina=2", self.t.chamadas[-1][1])
79
+ self.t.respostas[("GET", "/v1/notas/local/abc/pdf")] = (200, b"%PDF-1.4 teste")
80
+ self.assertTrue(self.ej.notas.pdf("local", "abc").startswith(b"%PDF"))
81
+ self.t.respostas[("GET", "/v1/cnpj/11222333000181")] = (200, {"cnpj": "11222333000181"})
82
+ self.assertEqual(self.ej.cnpj.consultar("11.222.333/0001-81")["cnpj"], "11222333000181")
83
+
84
+ def test_dormir_injetavel_recebe_proxima_consulta(self):
85
+ estados = iter([
86
+ {"estado": "na_fila", "proxima_consulta_ms": 2500},
87
+ {"estado": "emitindo", "proxima_consulta_ms": 10},
88
+ {"estado": "recusada", "motivo_code": "E0001"},
89
+ ])
90
+ esperas = []
91
+ ej = Emissaoja("ej_live_" + "a" * 43, base_url="https://emissaoja.test", transporte=lambda m, u, h, c, t: (200, {}, json.dumps({"pedido": next(estados)}).encode()), dormir=esperas.append)
92
+ r = ej.notas.aguardar("p2")
93
+ self.assertEqual(r["pedido"]["estado"], "recusada")
94
+ self.assertEqual(esperas, [2.5, 1.0])
95
+
96
+ def test_sem_chave_le_env(self):
97
+ os.environ.pop("EMISSAOJA_KEY", None)
98
+ ej = Emissaoja(base_url="https://emissaoja.test", transporte=self.t)
99
+ with self.assertRaises(EmissaojaErro) as cm:
100
+ ej.empresa.obter()
101
+ self.assertEqual(cm.exception.code, "chave_invalida")
102
+
103
+
104
+ if __name__ == "__main__":
105
+ unittest.main()
@@ -0,0 +1,32 @@
1
+ """Integração com o sandbox público (Emissor Nacional simulado). Só roda com EMISSAOJA_TEST_KEY (chave ej_test_)."""
2
+
3
+ import os
4
+ import random
5
+ import sys
6
+ import time
7
+ import unittest
8
+
9
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
10
+ from emissaoja import BASE_SANDBOX, Emissaoja # noqa: E402
11
+
12
+ CHAVE = os.environ.get("EMISSAOJA_TEST_KEY")
13
+
14
+
15
+ @unittest.skipUnless(CHAVE, "defina EMISSAOJA_TEST_KEY para rodar")
16
+ class SandboxTest(unittest.TestCase):
17
+ def test_fluxo_no_sandbox(self):
18
+ ej = Emissaoja(CHAVE, base_url=BASE_SANDBOX)
19
+ self.assertEqual(ej.status()["motor"], "simulado")
20
+ if ej.empresa.obter()["credencial"]["estado"] != "ativa":
21
+ ej.empresa.conectar("11111111000191", "qualquer")
22
+ ej.empresa.aguardar_credencial(timeout=60)
23
+ tomador = ej.tomadores.criar("12345678000195")["tomador"]
24
+ pedido = ej.notas.emitir(f"sdk-py-{int(time.time())}", tomador["tipo"], tomador["id"], random.randint(10001, 19999) / 100, descricao="Teste do cliente Python")["pedido"]
25
+ final = ej.notas.aguardar(pedido["id"], timeout=120)["pedido"]
26
+ self.assertIn(final["estado"], ("autorizada", "recusada", "incerta"))
27
+ if final["estado"] == "autorizada":
28
+ self.assertTrue(ej.notas.pdf(final["nota"]["origem"], final["nota"]["id"]).startswith(b"%PDF"))
29
+
30
+
31
+ if __name__ == "__main__":
32
+ unittest.main()
@@ -0,0 +1,48 @@
1
+ """Transporte urllib: timeouts e falhas de conexão viram EmissaojaErro (sem rede: urlopen é trocado)."""
2
+
3
+ import os
4
+ import socket
5
+ import sys
6
+ import unittest
7
+ import urllib.error
8
+ from unittest import mock
9
+
10
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
11
+ import emissaoja # noqa: E402
12
+ from emissaoja import BASE_PRODUCAO, BASE_SANDBOX, Emissaoja, EmissaojaErro # noqa: E402
13
+
14
+
15
+ class TransporteUrllibTest(unittest.TestCase):
16
+ def setUp(self):
17
+ self.ej = Emissaoja("ej_test_" + "b" * 43, base_url=BASE_SANDBOX)
18
+
19
+ def _codigo(self, excecao):
20
+ with mock.patch("urllib.request.urlopen", side_effect=excecao):
21
+ with self.assertRaises(EmissaojaErro) as cm:
22
+ self.ej.empresa.obter()
23
+ self.assertEqual(cm.exception.status, 0)
24
+ return cm.exception.code
25
+
26
+ def test_timeout_de_leitura(self):
27
+ self.assertEqual(self._codigo(socket.timeout("timed out")), "tempo_esgotado")
28
+ self.assertEqual(self._codigo(TimeoutError("timed out")), "tempo_esgotado")
29
+
30
+ def test_timeout_na_conexao(self):
31
+ self.assertEqual(self._codigo(urllib.error.URLError(socket.timeout("timed out"))), "tempo_esgotado")
32
+
33
+ def test_falhas_de_rede(self):
34
+ self.assertEqual(self._codigo(urllib.error.URLError("Name or service not known")), "rede")
35
+ self.assertEqual(self._codigo(ConnectionResetError("reset")), "rede")
36
+
37
+
38
+ class ConstantesTest(unittest.TestCase):
39
+ def test_bases_e_versao(self):
40
+ self.assertEqual(BASE_PRODUCAO, "https://emissaoja.com.br")
41
+ self.assertEqual(BASE_SANDBOX, "https://sandbox.emissaoja.com.br")
42
+ self.assertEqual(Emissaoja("x" * 10).base_url, BASE_PRODUCAO)
43
+ self.assertEqual(Emissaoja("x" * 10, base_url=BASE_SANDBOX + "/").base_url, BASE_SANDBOX)
44
+ self.assertRegex(emissaoja.__version__, r"^\d+\.\d+\.\d+$")
45
+
46
+
47
+ if __name__ == "__main__":
48
+ unittest.main()