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.
- emissaoja-0.1.0/.gitignore +7 -0
- emissaoja-0.1.0/CHANGELOG.md +12 -0
- emissaoja-0.1.0/LICENSE +21 -0
- emissaoja-0.1.0/PKG-INFO +228 -0
- emissaoja-0.1.0/README.md +196 -0
- emissaoja-0.1.0/pyproject.toml +71 -0
- emissaoja-0.1.0/src/emissaoja/__init__.py +301 -0
- emissaoja-0.1.0/src/emissaoja/py.typed +0 -0
- emissaoja-0.1.0/tests/test_cliente.py +105 -0
- emissaoja-0.1.0/tests/test_integracao.py +32 -0
- emissaoja-0.1.0/tests/test_transporte.py +48 -0
|
@@ -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+.
|
emissaoja-0.1.0/LICENSE
ADDED
|
@@ -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.
|
emissaoja-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/emissaoja/) [](https://github.com/Easymovel/emissaoja-py/actions/workflows/ci.yml) [](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
|
+
[](https://pypi.org/project/emissaoja/) [](https://github.com/Easymovel/emissaoja-py/actions/workflows/ci.yml) [](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()
|