nodda-client 1.2.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.
- nodda_client-1.2.0/LICENSE +21 -0
- nodda_client-1.2.0/PKG-INFO +106 -0
- nodda_client-1.2.0/README.md +89 -0
- nodda_client-1.2.0/pyproject.toml +30 -0
- nodda_client-1.2.0/setup.cfg +4 -0
- nodda_client-1.2.0/src/nodda_client/__init__.py +18 -0
- nodda_client-1.2.0/src/nodda_client/client.py +229 -0
- nodda_client-1.2.0/src/nodda_client/exceptions.py +26 -0
- nodda_client-1.2.0/src/nodda_client/http_transport.py +91 -0
- nodda_client-1.2.0/src/nodda_client.egg-info/PKG-INFO +106 -0
- nodda_client-1.2.0/src/nodda_client.egg-info/SOURCES.txt +12 -0
- nodda_client-1.2.0/src/nodda_client.egg-info/dependency_links.txt +1 -0
- nodda_client-1.2.0/src/nodda_client.egg-info/top_level.txt +1 -0
- nodda_client-1.2.0/tests/test_nodda_client.py +283 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nodda
|
|
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,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nodda-client
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Cliente oficial de la API de Nodda para emitir y consultar facturas VERI*FACTU (AEAT).
|
|
5
|
+
Author-email: Nodda <soporte@nodda.es>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Documentacion, https://app.nodda.es/developers/introduccion
|
|
8
|
+
Project-URL: Repositorio, https://gitlab.com/Bitfx/nodda-facturacion/nodda-cliente
|
|
9
|
+
Keywords: verifactu,aeat,facturacion,factura,nodda
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# nodda-client (Python)
|
|
19
|
+
|
|
20
|
+
Cliente oficial de la API de [Nodda](https://nodda.es) para emitir y consultar facturas **VERI\*FACTU** de la Agencia Tributaria (AEAT) desde tu propio programa (ERP, tienda online, TPV…).
|
|
21
|
+
|
|
22
|
+
Nodda se encarga de la firma con tu certificado, del XML, de la cadena de huellas y del envío a la AEAT: tú solo mandas los datos de la factura en JSON.
|
|
23
|
+
|
|
24
|
+
## Instalación
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install nodda-client
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Requisitos: Python 3.8 o superior. Sin dependencias (solo la librería estándar).
|
|
31
|
+
|
|
32
|
+
## Antes de empezar
|
|
33
|
+
|
|
34
|
+
1. Crea tu cuenta en el portal de Nodda y da de alta tu empresa (NIF y certificado digital).
|
|
35
|
+
2. Genera tu **clave API** en el portal → *API keys*.
|
|
36
|
+
3. Usa la dirección del entorno con su clave:
|
|
37
|
+
|
|
38
|
+
| Entorno | Dirección | Uso |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| Pruebas | `https://sandbox.nodda.es` | Envía al entorno de pruebas de la AEAT, sin validez fiscal |
|
|
41
|
+
| Producción | `https://api.nodda.es` | Facturas reales |
|
|
42
|
+
|
|
43
|
+
La clave de pruebas no sirve en producción, ni al revés.
|
|
44
|
+
|
|
45
|
+
## Uso
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import os
|
|
49
|
+
from nodda_client import NoddaClient
|
|
50
|
+
|
|
51
|
+
nodda = NoddaClient("https://sandbox.nodda.es", os.environ["NODDA_API_KEY"])
|
|
52
|
+
|
|
53
|
+
r = nodda.crear_factura({
|
|
54
|
+
"series": "2026", "issueDate": "2026-11-03", "invoiceType": "F1",
|
|
55
|
+
"description": "Servicios de consultoría", "taxRegimeKey": "01", "operationQualification": "S1",
|
|
56
|
+
"taxBase": "100.00", "taxRate": "21", "taxAmount": "21.00", "totalAmount": "121.00",
|
|
57
|
+
"recipients": [{"nif": "B12345674", "name": "Cliente Demo SL"}],
|
|
58
|
+
})
|
|
59
|
+
# r["status"] == "queued": la API responde al momento; la AEAT, unos segundos después.
|
|
60
|
+
|
|
61
|
+
estado = nodda.consultar_estado(r["id"]) # queued, sent, accepted_with_errors o permanently_failed
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Si tu clave da acceso a varias empresas (NIF), indica cuál en el parámetro `nif` de cada método. Puedes asociar la factura a un centro de coste enviando `costCenterId` en los datos.
|
|
65
|
+
|
|
66
|
+
## Métodos
|
|
67
|
+
|
|
68
|
+
| Método | Endpoint |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `crear_factura(datos, nif=None)` | `POST /v1/invoices` |
|
|
71
|
+
| `consultar_estado(id, nif=None)` | `GET /v1/invoices/{id}` |
|
|
72
|
+
| `listar_facturas(filtros=None, nif=None)` | `GET /v1/invoices` |
|
|
73
|
+
| `cancelar_factura(id, datos=None, nif=None)` | `POST /v1/invoices/{id}/cancel` |
|
|
74
|
+
| `reintentar_factura(id, tipo=None, nif=None)` | `POST /v1/invoices/{id}/retry` |
|
|
75
|
+
| `obtener_url_qr(id)` / `obtener_imagen_qr(id)` | `GET /v1/invoices/{id}/qr` |
|
|
76
|
+
| `estado_sistema(nif=None)` | `GET /v1/status` |
|
|
77
|
+
| `obtener_perfil()` | `GET /v1/client` |
|
|
78
|
+
| `configurar_webhook(url, nif=None)` / `quitar_webhook(nif=None)` | `PUT` / `DELETE /v1/webhooks` |
|
|
79
|
+
| `NoddaClient.verificar_firma_webhook(cuerpo, firma, secreto)` | Comprueba la firma de un aviso |
|
|
80
|
+
|
|
81
|
+
## Avisos (webhooks)
|
|
82
|
+
|
|
83
|
+
En lugar de consultar el estado de cada factura, Nodda puede avisar a tu servidor cuando la AEAT responde. Configura la URL con `configurar_webhook(url)` (la respuesta trae el `secreto` **una sola vez**: guárdalo) y, en tu endpoint, comprueba la firma antes de confiar en el aviso:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
# cuerpo: bytes tal cual llegan; firma: cabecera X-Nodda-Signature
|
|
87
|
+
if not NoddaClient.verificar_firma_webhook(cuerpo, firma, os.environ["NODDA_WEBHOOK_SECRET"]):
|
|
88
|
+
return 401
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
El cuerpo trae `id` (número de factura), `status` (`sent`, `accepted_with_errors` o `permanently_failed`), `razon`, `eventId` y los datos del acuse de la AEAT. Responde con un código 2xx; si no, Nodda lo reintenta.
|
|
92
|
+
|
|
93
|
+
## Errores
|
|
94
|
+
|
|
95
|
+
Si la API responde con un error, se lanza `NoddaApiException` con el código HTTP y un código estable (por ejemplo `invalid_request`, `business_rule_violation`, `certificate_not_configured`). Si no hubo respuesta (fallo de red), se lanza `NoddaException`: puedes reintentar la misma petición.
|
|
96
|
+
|
|
97
|
+
## Documentación
|
|
98
|
+
|
|
99
|
+
Guía completa, campos de cada petición y códigos de error: <https://app.nodda.es/developers/introduccion>
|
|
100
|
+
Referencia OpenAPI: <https://api.nodda.es/openapi.json>
|
|
101
|
+
|
|
102
|
+
Soporte: soporte@nodda.es
|
|
103
|
+
|
|
104
|
+
## Licencia
|
|
105
|
+
|
|
106
|
+
MIT
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# nodda-client (Python)
|
|
2
|
+
|
|
3
|
+
Cliente oficial de la API de [Nodda](https://nodda.es) para emitir y consultar facturas **VERI\*FACTU** de la Agencia Tributaria (AEAT) desde tu propio programa (ERP, tienda online, TPV…).
|
|
4
|
+
|
|
5
|
+
Nodda se encarga de la firma con tu certificado, del XML, de la cadena de huellas y del envío a la AEAT: tú solo mandas los datos de la factura en JSON.
|
|
6
|
+
|
|
7
|
+
## Instalación
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install nodda-client
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requisitos: Python 3.8 o superior. Sin dependencias (solo la librería estándar).
|
|
14
|
+
|
|
15
|
+
## Antes de empezar
|
|
16
|
+
|
|
17
|
+
1. Crea tu cuenta en el portal de Nodda y da de alta tu empresa (NIF y certificado digital).
|
|
18
|
+
2. Genera tu **clave API** en el portal → *API keys*.
|
|
19
|
+
3. Usa la dirección del entorno con su clave:
|
|
20
|
+
|
|
21
|
+
| Entorno | Dirección | Uso |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| Pruebas | `https://sandbox.nodda.es` | Envía al entorno de pruebas de la AEAT, sin validez fiscal |
|
|
24
|
+
| Producción | `https://api.nodda.es` | Facturas reales |
|
|
25
|
+
|
|
26
|
+
La clave de pruebas no sirve en producción, ni al revés.
|
|
27
|
+
|
|
28
|
+
## Uso
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import os
|
|
32
|
+
from nodda_client import NoddaClient
|
|
33
|
+
|
|
34
|
+
nodda = NoddaClient("https://sandbox.nodda.es", os.environ["NODDA_API_KEY"])
|
|
35
|
+
|
|
36
|
+
r = nodda.crear_factura({
|
|
37
|
+
"series": "2026", "issueDate": "2026-11-03", "invoiceType": "F1",
|
|
38
|
+
"description": "Servicios de consultoría", "taxRegimeKey": "01", "operationQualification": "S1",
|
|
39
|
+
"taxBase": "100.00", "taxRate": "21", "taxAmount": "21.00", "totalAmount": "121.00",
|
|
40
|
+
"recipients": [{"nif": "B12345674", "name": "Cliente Demo SL"}],
|
|
41
|
+
})
|
|
42
|
+
# r["status"] == "queued": la API responde al momento; la AEAT, unos segundos después.
|
|
43
|
+
|
|
44
|
+
estado = nodda.consultar_estado(r["id"]) # queued, sent, accepted_with_errors o permanently_failed
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Si tu clave da acceso a varias empresas (NIF), indica cuál en el parámetro `nif` de cada método. Puedes asociar la factura a un centro de coste enviando `costCenterId` en los datos.
|
|
48
|
+
|
|
49
|
+
## Métodos
|
|
50
|
+
|
|
51
|
+
| Método | Endpoint |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `crear_factura(datos, nif=None)` | `POST /v1/invoices` |
|
|
54
|
+
| `consultar_estado(id, nif=None)` | `GET /v1/invoices/{id}` |
|
|
55
|
+
| `listar_facturas(filtros=None, nif=None)` | `GET /v1/invoices` |
|
|
56
|
+
| `cancelar_factura(id, datos=None, nif=None)` | `POST /v1/invoices/{id}/cancel` |
|
|
57
|
+
| `reintentar_factura(id, tipo=None, nif=None)` | `POST /v1/invoices/{id}/retry` |
|
|
58
|
+
| `obtener_url_qr(id)` / `obtener_imagen_qr(id)` | `GET /v1/invoices/{id}/qr` |
|
|
59
|
+
| `estado_sistema(nif=None)` | `GET /v1/status` |
|
|
60
|
+
| `obtener_perfil()` | `GET /v1/client` |
|
|
61
|
+
| `configurar_webhook(url, nif=None)` / `quitar_webhook(nif=None)` | `PUT` / `DELETE /v1/webhooks` |
|
|
62
|
+
| `NoddaClient.verificar_firma_webhook(cuerpo, firma, secreto)` | Comprueba la firma de un aviso |
|
|
63
|
+
|
|
64
|
+
## Avisos (webhooks)
|
|
65
|
+
|
|
66
|
+
En lugar de consultar el estado de cada factura, Nodda puede avisar a tu servidor cuando la AEAT responde. Configura la URL con `configurar_webhook(url)` (la respuesta trae el `secreto` **una sola vez**: guárdalo) y, en tu endpoint, comprueba la firma antes de confiar en el aviso:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
# cuerpo: bytes tal cual llegan; firma: cabecera X-Nodda-Signature
|
|
70
|
+
if not NoddaClient.verificar_firma_webhook(cuerpo, firma, os.environ["NODDA_WEBHOOK_SECRET"]):
|
|
71
|
+
return 401
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
El cuerpo trae `id` (número de factura), `status` (`sent`, `accepted_with_errors` o `permanently_failed`), `razon`, `eventId` y los datos del acuse de la AEAT. Responde con un código 2xx; si no, Nodda lo reintenta.
|
|
75
|
+
|
|
76
|
+
## Errores
|
|
77
|
+
|
|
78
|
+
Si la API responde con un error, se lanza `NoddaApiException` con el código HTTP y un código estable (por ejemplo `invalid_request`, `business_rule_violation`, `certificate_not_configured`). Si no hubo respuesta (fallo de red), se lanza `NoddaException`: puedes reintentar la misma petición.
|
|
79
|
+
|
|
80
|
+
## Documentación
|
|
81
|
+
|
|
82
|
+
Guía completa, campos de cada petición y códigos de error: <https://app.nodda.es/developers/introduccion>
|
|
83
|
+
Referencia OpenAPI: <https://api.nodda.es/openapi.json>
|
|
84
|
+
|
|
85
|
+
Soporte: soporte@nodda.es
|
|
86
|
+
|
|
87
|
+
## Licencia
|
|
88
|
+
|
|
89
|
+
MIT
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nodda-client"
|
|
7
|
+
version = "1.2.0"
|
|
8
|
+
description = "Cliente oficial de la API de Nodda para emitir y consultar facturas VERI*FACTU (AEAT)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Nodda", email = "soporte@nodda.es" }]
|
|
13
|
+
keywords = ["verifactu", "aeat", "facturacion", "factura", "nodda"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Documentacion = "https://app.nodda.es/developers/introduccion"
|
|
24
|
+
Repositorio = "https://gitlab.com/Bitfx/nodda-facturacion/nodda-cliente"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.package-dir]
|
|
30
|
+
"" = "src"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""SDK publico de Nodda -- capa 3. Cliente HTTP delgado, sin conocimiento
|
|
2
|
+
de AEAT, certificados, ni logica de negocio.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .client import NoddaClient
|
|
6
|
+
from .exceptions import NoddaApiException, NoddaException
|
|
7
|
+
from .http_transport import HttpTransport, RespuestaHttp, UrllibHttpTransport
|
|
8
|
+
|
|
9
|
+
__version__ = "1.0.0"
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"NoddaClient",
|
|
13
|
+
"NoddaException",
|
|
14
|
+
"NoddaApiException",
|
|
15
|
+
"HttpTransport",
|
|
16
|
+
"RespuestaHttp",
|
|
17
|
+
"UrllibHttpTransport",
|
|
18
|
+
]
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Cliente del SDK publico de Nodda (capa 3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import secrets
|
|
9
|
+
from urllib.parse import quote, urlencode
|
|
10
|
+
|
|
11
|
+
from .exceptions import NoddaApiException, NoddaException
|
|
12
|
+
from .http_transport import HttpTransport, RespuestaHttp, UrllibHttpTransport
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NoddaClient:
|
|
16
|
+
"""SDK publico de Nodda -- capa 3 de la arquitectura::
|
|
17
|
+
|
|
18
|
+
Este SDK -> API propia -> Core Engine -> AEAT
|
|
19
|
+
|
|
20
|
+
A proposito NO sabe nada de AEAT, certificados, XML, ni hash -- solo
|
|
21
|
+
habla HTTP/JSON con la API propia. Cualquier logica de negocio
|
|
22
|
+
(validaciones, reglas de VERI*FACTU) vive del lado de la API/Core
|
|
23
|
+
Engine, nunca aqui.
|
|
24
|
+
|
|
25
|
+
Multi-NIF: un token puede autorizar varios NIF ("1 cuenta principal,
|
|
26
|
+
varias empresas"). Por eso cada metodo que habla con una factura
|
|
27
|
+
especifica acepta un ``nif`` OPCIONAL -- si tu token solo tiene 1 NIF
|
|
28
|
+
autorizado, puedes seguir sin pasarlo.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, base_url: str, api_key: str, http: HttpTransport | None = None) -> None:
|
|
32
|
+
self._base_url = base_url
|
|
33
|
+
self._api_key = api_key
|
|
34
|
+
self._http: HttpTransport = http or UrllibHttpTransport()
|
|
35
|
+
self._ultimo_correlation_id: str | None = None
|
|
36
|
+
self._ultimo_cupo_restante: int | None = None
|
|
37
|
+
|
|
38
|
+
def crear_factura(self, datos: dict, nif: str | None = None) -> dict:
|
|
39
|
+
"""POST /v1/invoices -- registra una factura. Siempre devuelve de
|
|
40
|
+
inmediato con status "queued"; usa consultar_estado() despues para
|
|
41
|
+
saber el resultado real.
|
|
42
|
+
"""
|
|
43
|
+
if nif is not None:
|
|
44
|
+
datos = {**datos, "nif": nif}
|
|
45
|
+
return self._peticion("POST", "/v1/invoices", datos)
|
|
46
|
+
|
|
47
|
+
def consultar_estado(self, id: str, nif: str | None = None) -> dict:
|
|
48
|
+
"""GET /v1/invoices/{id} -- estado real de una factura ya emitida."""
|
|
49
|
+
return self._peticion("GET", f"/v1/invoices/{id}{self._query_nif(nif)}")
|
|
50
|
+
|
|
51
|
+
def cancelar_factura(self, id: str, datos: dict | None = None, nif: str | None = None) -> dict:
|
|
52
|
+
"""POST /v1/invoices/{id}/cancel -- anula una factura. VERI*FACTU no
|
|
53
|
+
permite borrar, solo anular con un registro nuevo.
|
|
54
|
+
"""
|
|
55
|
+
datos = dict(datos or {})
|
|
56
|
+
if nif is not None:
|
|
57
|
+
datos["nif"] = nif
|
|
58
|
+
return self._peticion("POST", f"/v1/invoices/{id}/cancel", datos)
|
|
59
|
+
|
|
60
|
+
def obtener_url_qr(self, id: str, nif: str | None = None) -> str:
|
|
61
|
+
"""GET /v1/invoices/{id}/qr?format=url -- la URL del codigo QR."""
|
|
62
|
+
query = f"?format=url&nif={quote(nif)}" if nif is not None else "?format=url"
|
|
63
|
+
respuesta = self._peticion("GET", f"/v1/invoices/{id}/qr{query}")
|
|
64
|
+
return str(respuesta["url"])
|
|
65
|
+
|
|
66
|
+
def obtener_imagen_qr(self, id: str, nif: str | None = None) -> bytes:
|
|
67
|
+
"""GET /v1/invoices/{id}/qr -- la imagen PNG del codigo QR, en
|
|
68
|
+
binario crudo (bytes, listo para guardar a archivo o incrustar en
|
|
69
|
+
un PDF).
|
|
70
|
+
"""
|
|
71
|
+
respuesta = self._http.request(
|
|
72
|
+
"GET",
|
|
73
|
+
self._url_completa(f"/v1/invoices/{id}/qr{self._query_nif(nif)}"),
|
|
74
|
+
self._headers(),
|
|
75
|
+
)
|
|
76
|
+
if respuesta.status >= 400:
|
|
77
|
+
self._lanzar_error_desde_json(respuesta)
|
|
78
|
+
return respuesta.body
|
|
79
|
+
|
|
80
|
+
def estado_sistema(self, nif: str | None = None) -> dict:
|
|
81
|
+
"""GET /v1/status -- si se puede enviar ahora o hay que esperar
|
|
82
|
+
(control de flujo de AEAT). El tiempo de espera es por certificado,
|
|
83
|
+
no por token.
|
|
84
|
+
"""
|
|
85
|
+
return self._peticion("GET", f"/v1/status{self._query_nif(nif)}")
|
|
86
|
+
|
|
87
|
+
def listar_facturas(self, filtros: dict | None = None, nif: str | None = None) -> dict:
|
|
88
|
+
"""GET /v1/invoices -- lista las facturas propias, con filtros
|
|
89
|
+
opcionales (status, type, from, to, limit, offset).
|
|
90
|
+
"""
|
|
91
|
+
filtros = dict(filtros or {})
|
|
92
|
+
if nif is not None:
|
|
93
|
+
filtros["nif"] = nif
|
|
94
|
+
query = f"?{urlencode(filtros)}" if filtros else ""
|
|
95
|
+
return self._peticion("GET", f"/v1/invoices{query}")
|
|
96
|
+
|
|
97
|
+
def reintentar_factura(self, id: str, tipo: str | None = None, nif: str | None = None) -> dict:
|
|
98
|
+
"""POST /v1/invoices/{id}/retry -- fuerza un reintento manual, sin
|
|
99
|
+
esperar el ciclo automatico. ``tipo`` es 'alta' o 'anulacion'.
|
|
100
|
+
"""
|
|
101
|
+
parametros: dict[str, str] = {}
|
|
102
|
+
if tipo is not None:
|
|
103
|
+
parametros["type"] = tipo
|
|
104
|
+
if nif is not None:
|
|
105
|
+
parametros["nif"] = nif
|
|
106
|
+
query = f"?{urlencode(parametros)}" if parametros else ""
|
|
107
|
+
return self._peticion("POST", f"/v1/invoices/{id}/retry{query}")
|
|
108
|
+
|
|
109
|
+
def obtener_perfil(self) -> dict:
|
|
110
|
+
"""GET /v1/client -- los NIF autorizados para este token."""
|
|
111
|
+
return self._peticion("GET", "/v1/client")
|
|
112
|
+
|
|
113
|
+
def configurar_webhook(self, url: str, nif: str | None = None) -> dict:
|
|
114
|
+
"""PUT /v1/webhooks -- configura la URL (https) a la que Nodda avisa cuando la AEAT responde.
|
|
115
|
+
La respuesta trae el ``secreto`` de firma UNA sola vez: guardalo para verificar_firma_webhook()."""
|
|
116
|
+
return self._peticion("PUT", f"/v1/webhooks{self._query_nif(nif)}", {"url": url})
|
|
117
|
+
|
|
118
|
+
def quitar_webhook(self, nif: str | None = None) -> dict:
|
|
119
|
+
"""DELETE /v1/webhooks -- deja de enviar avisos."""
|
|
120
|
+
return self._peticion("DELETE", f"/v1/webhooks{self._query_nif(nif)}")
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def verificar_firma_webhook(cuerpo: bytes | str, firma: str | None, secreto: str) -> bool:
|
|
124
|
+
"""Comprueba que un aviso viene de Nodda: X-Nodda-Signature es el HMAC-SHA256 (hex) del cuerpo EXACTO
|
|
125
|
+
recibido, con tu secreto. Usa el cuerpo sin decodificar."""
|
|
126
|
+
if not firma or not secreto:
|
|
127
|
+
return False
|
|
128
|
+
datos = cuerpo.encode("utf-8") if isinstance(cuerpo, str) else cuerpo
|
|
129
|
+
esperada = hmac.new(secreto.encode("utf-8"), datos, hashlib.sha256).hexdigest()
|
|
130
|
+
return hmac.compare_digest(esperada, firma.strip().lower())
|
|
131
|
+
|
|
132
|
+
def ultimo_correlation_id(self) -> str | None:
|
|
133
|
+
"""El X-Correlation-Id enviado en la ultima peticion, o None."""
|
|
134
|
+
return self._ultimo_correlation_id
|
|
135
|
+
|
|
136
|
+
def ultimo_cupo_restante(self) -> int | None:
|
|
137
|
+
"""Cuantas facturas gratuitas le quedan al cliente segun el ultimo
|
|
138
|
+
crear_factura() -- None si el NIF no esta sujeto a cupo (plan de
|
|
139
|
+
pago) o si el servidor no mando el encabezado. No hace su propia
|
|
140
|
+
peticion -- solo lee el encabezado de la ULTIMA respuesta.
|
|
141
|
+
"""
|
|
142
|
+
return self._ultimo_cupo_restante
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def registrar_cuenta(
|
|
146
|
+
base_url: str,
|
|
147
|
+
email: str,
|
|
148
|
+
nif: str,
|
|
149
|
+
nombre_emisor: str,
|
|
150
|
+
telefono: str | None = None,
|
|
151
|
+
http: HttpTransport | None = None,
|
|
152
|
+
) -> dict:
|
|
153
|
+
"""POST /v1/cuentas -- registro publico, SIN necesitar una API Key
|
|
154
|
+
previa (por eso es estatico y no manda Authorization). Devuelve una
|
|
155
|
+
API Key nueva -- guardala de inmediato, el servidor no la vuelve a
|
|
156
|
+
mostrar.
|
|
157
|
+
"""
|
|
158
|
+
http = http or UrllibHttpTransport()
|
|
159
|
+
datos = {"email": email, "nif": nif, "nombreEmisor": nombre_emisor}
|
|
160
|
+
if telefono is not None:
|
|
161
|
+
datos["telefono"] = telefono
|
|
162
|
+
|
|
163
|
+
respuesta = http.request(
|
|
164
|
+
"POST",
|
|
165
|
+
f"{base_url.rstrip('/')}/v1/cuentas",
|
|
166
|
+
{"Content-Type": "application/json"},
|
|
167
|
+
json.dumps(datos),
|
|
168
|
+
)
|
|
169
|
+
if respuesta.status >= 400:
|
|
170
|
+
NoddaClient._lanzar_error_estatico(respuesta)
|
|
171
|
+
decodificado = NoddaClient._intentar_json(respuesta.body)
|
|
172
|
+
if decodificado is None:
|
|
173
|
+
raise NoddaException(
|
|
174
|
+
f"La API devolvio una respuesta que no es JSON valido: {respuesta.body.decode('utf-8', 'replace')}"
|
|
175
|
+
)
|
|
176
|
+
return decodificado
|
|
177
|
+
|
|
178
|
+
# -- internos -----------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _query_nif(nif: str | None) -> str:
|
|
182
|
+
return f"?nif={quote(nif)}" if nif is not None else ""
|
|
183
|
+
|
|
184
|
+
def _peticion(self, metodo: str, ruta: str, datos: dict | None = None) -> dict:
|
|
185
|
+
cuerpo = json.dumps(datos) if datos is not None else None
|
|
186
|
+
respuesta = self._http.request(metodo, self._url_completa(ruta), self._headers(), cuerpo)
|
|
187
|
+
|
|
188
|
+
if respuesta.status >= 400:
|
|
189
|
+
self._lanzar_error_desde_json(respuesta)
|
|
190
|
+
|
|
191
|
+
valor = respuesta.headers.get("X-Nodda-Quota-Remaining")
|
|
192
|
+
self._ultimo_cupo_restante = int(valor) if valor is not None else None
|
|
193
|
+
|
|
194
|
+
decodificado = self._intentar_json(respuesta.body)
|
|
195
|
+
if decodificado is None:
|
|
196
|
+
raise NoddaException(
|
|
197
|
+
f"La API devolvio una respuesta que no es JSON valido: {respuesta.body.decode('utf-8', 'replace')}"
|
|
198
|
+
)
|
|
199
|
+
return decodificado
|
|
200
|
+
|
|
201
|
+
def _lanzar_error_desde_json(self, respuesta: RespuestaHttp) -> None:
|
|
202
|
+
self._lanzar_error_estatico(respuesta)
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def _lanzar_error_estatico(respuesta: RespuestaHttp) -> None:
|
|
206
|
+
decodificado = NoddaClient._intentar_json(respuesta.body) or {}
|
|
207
|
+
error = decodificado.get("error", {}) if isinstance(decodificado, dict) else {}
|
|
208
|
+
mensaje = error.get("message") or f"La API respondio con el codigo HTTP {respuesta.status}."
|
|
209
|
+
codigo = error.get("code")
|
|
210
|
+
raise NoddaApiException(mensaje, respuesta.status, codigo)
|
|
211
|
+
|
|
212
|
+
def _url_completa(self, ruta: str) -> str:
|
|
213
|
+
return self._base_url.rstrip("/") + ruta
|
|
214
|
+
|
|
215
|
+
def _headers(self) -> dict[str, str]:
|
|
216
|
+
self._ultimo_correlation_id = secrets.token_hex(16)
|
|
217
|
+
return {
|
|
218
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
219
|
+
"Content-Type": "application/json",
|
|
220
|
+
"X-Correlation-Id": self._ultimo_correlation_id,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
@staticmethod
|
|
224
|
+
def _intentar_json(body: bytes):
|
|
225
|
+
try:
|
|
226
|
+
valor = json.loads(body.decode("utf-8"))
|
|
227
|
+
except (ValueError, UnicodeDecodeError):
|
|
228
|
+
return None
|
|
229
|
+
return valor if isinstance(valor, (dict, list)) else None
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Excepciones del SDK publico de Nodda."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NoddaException(Exception):
|
|
7
|
+
"""Excepcion generica de comunicacion (red, timeout).
|
|
8
|
+
|
|
9
|
+
Para errores que la API devuelve con codigo HTTP de error (422, 404,
|
|
10
|
+
etc.), ver NoddaApiException -- son cosas distintas: esta es "no se
|
|
11
|
+
pudo ni hablar con la API", la otra es "la API respondio, pero con un
|
|
12
|
+
error".
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NoddaApiException(NoddaException):
|
|
17
|
+
"""Se lanza cuando la API de Nodda SI respondio, pero con un codigo de
|
|
18
|
+
error (422 = dato invalido o regla de negocio, 404 = no encontrado,
|
|
19
|
+
500 = error interno). Distinta de NoddaException (esa es cuando ni
|
|
20
|
+
siquiera hubo respuesta -- problema de red).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, mensaje: str, status_code: int, codigo_error: str | None) -> None:
|
|
24
|
+
super().__init__(mensaje)
|
|
25
|
+
self.status_code = status_code
|
|
26
|
+
self.codigo_error = codigo_error
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Transporte HTTP del SDK: interfaz + implementacion real con urllib."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from .exceptions import NoddaException
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class RespuestaHttp:
|
|
15
|
+
"""Respuesta cruda del transporte.
|
|
16
|
+
|
|
17
|
+
body son bytes (texto JSON o imagen PNG); headers tiene el nombre del
|
|
18
|
+
encabezado en Title-Case.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
status: int
|
|
22
|
+
body: bytes
|
|
23
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HttpTransport(Protocol):
|
|
27
|
+
"""Abstrae el transporte HTTP -- misma razon que HttpClientInterface en
|
|
28
|
+
el SDK de PHP: sin esto no se puede probar NoddaClient con un doble de
|
|
29
|
+
prueba en vez de la conexion real. La implementacion real es
|
|
30
|
+
UrllibHttpTransport.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def request(
|
|
34
|
+
self,
|
|
35
|
+
metodo: str,
|
|
36
|
+
url: str,
|
|
37
|
+
headers: dict[str, str] | None = None,
|
|
38
|
+
cuerpo: str | None = None,
|
|
39
|
+
) -> RespuestaHttp:
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class UrllibHttpTransport:
|
|
44
|
+
"""Implementacion real usando urllib de la biblioteca estandar -- sin
|
|
45
|
+
requests ni ninguna otra libreria HTTP, mismo criterio que el SDK de
|
|
46
|
+
PHP (curl crudo, sin Guzzle): mantener el SDK publico con el minimo de
|
|
47
|
+
dependencias posible, ya que lo va a instalar gente externa.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
_TIEMPO_LIMITE = 30
|
|
51
|
+
|
|
52
|
+
def request(
|
|
53
|
+
self,
|
|
54
|
+
metodo: str,
|
|
55
|
+
url: str,
|
|
56
|
+
headers: dict[str, str] | None = None,
|
|
57
|
+
cuerpo: str | None = None,
|
|
58
|
+
) -> RespuestaHttp:
|
|
59
|
+
headers = headers or {}
|
|
60
|
+
datos = cuerpo.encode("utf-8") if cuerpo is not None else None
|
|
61
|
+
peticion = urllib.request.Request(url=url, data=datos, headers=headers, method=metodo.upper())
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
with urllib.request.urlopen(peticion, timeout=self._TIEMPO_LIMITE) as respuesta:
|
|
65
|
+
return RespuestaHttp(
|
|
66
|
+
status=respuesta.status,
|
|
67
|
+
body=respuesta.read(),
|
|
68
|
+
headers=self._normalizar(respuesta.headers.items()),
|
|
69
|
+
)
|
|
70
|
+
except urllib.error.HTTPError as e:
|
|
71
|
+
# La API respondio con un codigo de error (>= 400); no es un
|
|
72
|
+
# fallo de red -- se devuelve como respuesta normal para que
|
|
73
|
+
# NoddaClient lo traduzca a NoddaApiException.
|
|
74
|
+
return RespuestaHttp(
|
|
75
|
+
status=e.code,
|
|
76
|
+
body=e.read(),
|
|
77
|
+
headers=self._normalizar(e.headers.items() if e.headers else []),
|
|
78
|
+
)
|
|
79
|
+
except urllib.error.URLError as e:
|
|
80
|
+
raise NoddaException(f"Fallo de comunicacion con la API de Nodda: {e.reason}") from e
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _normalizar(items) -> dict[str, str]:
|
|
84
|
+
# HTTP/2 manda los nombres en minusculas; se normalizan a Title-Case
|
|
85
|
+
# para que el resto del codigo pueda buscar por un nombre fijo
|
|
86
|
+
# (p. ej. "X-Nodda-Quota-Remaining").
|
|
87
|
+
encabezados: dict[str, str] = {}
|
|
88
|
+
for nombre, valor in items:
|
|
89
|
+
titulo = "-".join(p[:1].upper() + p[1:] for p in nombre.split("-"))
|
|
90
|
+
encabezados[titulo] = valor
|
|
91
|
+
return encabezados
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nodda-client
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Cliente oficial de la API de Nodda para emitir y consultar facturas VERI*FACTU (AEAT).
|
|
5
|
+
Author-email: Nodda <soporte@nodda.es>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Documentacion, https://app.nodda.es/developers/introduccion
|
|
8
|
+
Project-URL: Repositorio, https://gitlab.com/Bitfx/nodda-facturacion/nodda-cliente
|
|
9
|
+
Keywords: verifactu,aeat,facturacion,factura,nodda
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# nodda-client (Python)
|
|
19
|
+
|
|
20
|
+
Cliente oficial de la API de [Nodda](https://nodda.es) para emitir y consultar facturas **VERI\*FACTU** de la Agencia Tributaria (AEAT) desde tu propio programa (ERP, tienda online, TPV…).
|
|
21
|
+
|
|
22
|
+
Nodda se encarga de la firma con tu certificado, del XML, de la cadena de huellas y del envío a la AEAT: tú solo mandas los datos de la factura en JSON.
|
|
23
|
+
|
|
24
|
+
## Instalación
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install nodda-client
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Requisitos: Python 3.8 o superior. Sin dependencias (solo la librería estándar).
|
|
31
|
+
|
|
32
|
+
## Antes de empezar
|
|
33
|
+
|
|
34
|
+
1. Crea tu cuenta en el portal de Nodda y da de alta tu empresa (NIF y certificado digital).
|
|
35
|
+
2. Genera tu **clave API** en el portal → *API keys*.
|
|
36
|
+
3. Usa la dirección del entorno con su clave:
|
|
37
|
+
|
|
38
|
+
| Entorno | Dirección | Uso |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| Pruebas | `https://sandbox.nodda.es` | Envía al entorno de pruebas de la AEAT, sin validez fiscal |
|
|
41
|
+
| Producción | `https://api.nodda.es` | Facturas reales |
|
|
42
|
+
|
|
43
|
+
La clave de pruebas no sirve en producción, ni al revés.
|
|
44
|
+
|
|
45
|
+
## Uso
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import os
|
|
49
|
+
from nodda_client import NoddaClient
|
|
50
|
+
|
|
51
|
+
nodda = NoddaClient("https://sandbox.nodda.es", os.environ["NODDA_API_KEY"])
|
|
52
|
+
|
|
53
|
+
r = nodda.crear_factura({
|
|
54
|
+
"series": "2026", "issueDate": "2026-11-03", "invoiceType": "F1",
|
|
55
|
+
"description": "Servicios de consultoría", "taxRegimeKey": "01", "operationQualification": "S1",
|
|
56
|
+
"taxBase": "100.00", "taxRate": "21", "taxAmount": "21.00", "totalAmount": "121.00",
|
|
57
|
+
"recipients": [{"nif": "B12345674", "name": "Cliente Demo SL"}],
|
|
58
|
+
})
|
|
59
|
+
# r["status"] == "queued": la API responde al momento; la AEAT, unos segundos después.
|
|
60
|
+
|
|
61
|
+
estado = nodda.consultar_estado(r["id"]) # queued, sent, accepted_with_errors o permanently_failed
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Si tu clave da acceso a varias empresas (NIF), indica cuál en el parámetro `nif` de cada método. Puedes asociar la factura a un centro de coste enviando `costCenterId` en los datos.
|
|
65
|
+
|
|
66
|
+
## Métodos
|
|
67
|
+
|
|
68
|
+
| Método | Endpoint |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `crear_factura(datos, nif=None)` | `POST /v1/invoices` |
|
|
71
|
+
| `consultar_estado(id, nif=None)` | `GET /v1/invoices/{id}` |
|
|
72
|
+
| `listar_facturas(filtros=None, nif=None)` | `GET /v1/invoices` |
|
|
73
|
+
| `cancelar_factura(id, datos=None, nif=None)` | `POST /v1/invoices/{id}/cancel` |
|
|
74
|
+
| `reintentar_factura(id, tipo=None, nif=None)` | `POST /v1/invoices/{id}/retry` |
|
|
75
|
+
| `obtener_url_qr(id)` / `obtener_imagen_qr(id)` | `GET /v1/invoices/{id}/qr` |
|
|
76
|
+
| `estado_sistema(nif=None)` | `GET /v1/status` |
|
|
77
|
+
| `obtener_perfil()` | `GET /v1/client` |
|
|
78
|
+
| `configurar_webhook(url, nif=None)` / `quitar_webhook(nif=None)` | `PUT` / `DELETE /v1/webhooks` |
|
|
79
|
+
| `NoddaClient.verificar_firma_webhook(cuerpo, firma, secreto)` | Comprueba la firma de un aviso |
|
|
80
|
+
|
|
81
|
+
## Avisos (webhooks)
|
|
82
|
+
|
|
83
|
+
En lugar de consultar el estado de cada factura, Nodda puede avisar a tu servidor cuando la AEAT responde. Configura la URL con `configurar_webhook(url)` (la respuesta trae el `secreto` **una sola vez**: guárdalo) y, en tu endpoint, comprueba la firma antes de confiar en el aviso:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
# cuerpo: bytes tal cual llegan; firma: cabecera X-Nodda-Signature
|
|
87
|
+
if not NoddaClient.verificar_firma_webhook(cuerpo, firma, os.environ["NODDA_WEBHOOK_SECRET"]):
|
|
88
|
+
return 401
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
El cuerpo trae `id` (número de factura), `status` (`sent`, `accepted_with_errors` o `permanently_failed`), `razon`, `eventId` y los datos del acuse de la AEAT. Responde con un código 2xx; si no, Nodda lo reintenta.
|
|
92
|
+
|
|
93
|
+
## Errores
|
|
94
|
+
|
|
95
|
+
Si la API responde con un error, se lanza `NoddaApiException` con el código HTTP y un código estable (por ejemplo `invalid_request`, `business_rule_violation`, `certificate_not_configured`). Si no hubo respuesta (fallo de red), se lanza `NoddaException`: puedes reintentar la misma petición.
|
|
96
|
+
|
|
97
|
+
## Documentación
|
|
98
|
+
|
|
99
|
+
Guía completa, campos de cada petición y códigos de error: <https://app.nodda.es/developers/introduccion>
|
|
100
|
+
Referencia OpenAPI: <https://api.nodda.es/openapi.json>
|
|
101
|
+
|
|
102
|
+
Soporte: soporte@nodda.es
|
|
103
|
+
|
|
104
|
+
## Licencia
|
|
105
|
+
|
|
106
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/nodda_client/__init__.py
|
|
5
|
+
src/nodda_client/client.py
|
|
6
|
+
src/nodda_client/exceptions.py
|
|
7
|
+
src/nodda_client/http_transport.py
|
|
8
|
+
src/nodda_client.egg-info/PKG-INFO
|
|
9
|
+
src/nodda_client.egg-info/SOURCES.txt
|
|
10
|
+
src/nodda_client.egg-info/dependency_links.txt
|
|
11
|
+
src/nodda_client.egg-info/top_level.txt
|
|
12
|
+
tests/test_nodda_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nodda_client
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import unittest
|
|
3
|
+
|
|
4
|
+
from nodda_client import NoddaApiException, NoddaClient
|
|
5
|
+
from tests.fake_http_transport import FakeHttpTransport
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NoddaClientTest(unittest.TestCase):
|
|
9
|
+
def test_crear_factura_manda_metodo_y_ruta_correctos(self):
|
|
10
|
+
fake = FakeHttpTransport.que_responde(202, '{"id":"A-0001","status":"queued"}')
|
|
11
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY_DE_PRUEBA", fake)
|
|
12
|
+
|
|
13
|
+
resultado = client.crear_factura({"series": "A", "number": "0001"})
|
|
14
|
+
|
|
15
|
+
self.assertEqual("A-0001", resultado["id"])
|
|
16
|
+
self.assertEqual("queued", resultado["status"])
|
|
17
|
+
self.assertEqual(1, len(fake.peticiones_recibidas))
|
|
18
|
+
metodo, url, headers, cuerpo = fake.peticiones_recibidas[0]
|
|
19
|
+
self.assertEqual("POST", metodo)
|
|
20
|
+
self.assertEqual("https://api.nodda.test/v1/invoices", url)
|
|
21
|
+
self.assertEqual("Bearer API_KEY_DE_PRUEBA", headers["Authorization"])
|
|
22
|
+
self.assertEqual({"series": "A", "number": "0001"}, json.loads(cuerpo))
|
|
23
|
+
|
|
24
|
+
def test_consultar_estado_manda_get_a_la_ruta_correcta(self):
|
|
25
|
+
fake = FakeHttpTransport.que_responde(200, '{"id":"A-0001","status":"sent"}')
|
|
26
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
27
|
+
|
|
28
|
+
resultado = client.consultar_estado("A-0001")
|
|
29
|
+
|
|
30
|
+
self.assertEqual("sent", resultado["status"])
|
|
31
|
+
metodo, url, _, _ = fake.peticiones_recibidas[0]
|
|
32
|
+
self.assertEqual("GET", metodo)
|
|
33
|
+
self.assertEqual("https://api.nodda.test/v1/invoices/A-0001", url)
|
|
34
|
+
|
|
35
|
+
def test_cancelar_factura_manda_post_a_la_ruta_de_cancel(self):
|
|
36
|
+
fake = FakeHttpTransport.que_responde(202, '{"id":"A-0001","status":"queued"}')
|
|
37
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
38
|
+
|
|
39
|
+
client.cancelar_factura("A-0001", {"generatedBy": "E"})
|
|
40
|
+
|
|
41
|
+
metodo, url, _, _ = fake.peticiones_recibidas[0]
|
|
42
|
+
self.assertEqual("POST", metodo)
|
|
43
|
+
self.assertEqual("https://api.nodda.test/v1/invoices/A-0001/cancel", url)
|
|
44
|
+
|
|
45
|
+
def test_obtener_url_qr_pide_formato_url_y_devuelve_solo_la_url(self):
|
|
46
|
+
fake = FakeHttpTransport.que_responde(
|
|
47
|
+
200, '{"url":"https://www2.agenciatributaria.es/wlpl/TIKE-CONT/ValidarQR?nif=B12345678"}'
|
|
48
|
+
)
|
|
49
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
50
|
+
|
|
51
|
+
url = client.obtener_url_qr("A-0001")
|
|
52
|
+
|
|
53
|
+
self.assertEqual("https://www2.agenciatributaria.es/wlpl/TIKE-CONT/ValidarQR?nif=B12345678", url)
|
|
54
|
+
_, url_pedida, _, _ = fake.peticiones_recibidas[0]
|
|
55
|
+
self.assertIn("format=url", url_pedida)
|
|
56
|
+
|
|
57
|
+
def test_obtener_imagen_qr_devuelve_el_binario_crudo_sin_decodificar(self):
|
|
58
|
+
bytes_falsos = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3])
|
|
59
|
+
fake = FakeHttpTransport.que_responde(200, bytes_falsos)
|
|
60
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
61
|
+
|
|
62
|
+
imagen = client.obtener_imagen_qr("A-0001")
|
|
63
|
+
|
|
64
|
+
self.assertIsInstance(imagen, bytes)
|
|
65
|
+
self.assertEqual(bytes_falsos, imagen)
|
|
66
|
+
|
|
67
|
+
def test_obtener_imagen_qr_con_error_lanza_nodda_api_exception(self):
|
|
68
|
+
fake = FakeHttpTransport.que_responde(404, '{"error":{"code":"not_found","message":"No existe esa factura"}}')
|
|
69
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
70
|
+
|
|
71
|
+
with self.assertRaises(NoddaApiException) as ctx:
|
|
72
|
+
client.obtener_imagen_qr("NUNCA-EXISTIO")
|
|
73
|
+
self.assertEqual(404, ctx.exception.status_code)
|
|
74
|
+
self.assertEqual("not_found", ctx.exception.codigo_error)
|
|
75
|
+
|
|
76
|
+
def test_estado_sistema_manda_get_a_status(self):
|
|
77
|
+
fake = FakeHttpTransport.que_responde(200, '{"canSendNow":true,"nextAllowedSendAt":null}')
|
|
78
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
79
|
+
|
|
80
|
+
resultado = client.estado_sistema()
|
|
81
|
+
|
|
82
|
+
self.assertTrue(resultado["canSendNow"])
|
|
83
|
+
_, url, _, _ = fake.peticiones_recibidas[0]
|
|
84
|
+
self.assertEqual("https://api.nodda.test/v1/status", url)
|
|
85
|
+
|
|
86
|
+
def test_listar_facturas_sin_filtros_manda_get_sin_query(self):
|
|
87
|
+
fake = FakeHttpTransport.que_responde(200, '{"invoices":[],"pagination":{"total":0,"limit":20,"offset":0}}')
|
|
88
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
89
|
+
|
|
90
|
+
client.listar_facturas()
|
|
91
|
+
|
|
92
|
+
metodo, url, _, _ = fake.peticiones_recibidas[0]
|
|
93
|
+
self.assertEqual("GET", metodo)
|
|
94
|
+
self.assertEqual("https://api.nodda.test/v1/invoices", url)
|
|
95
|
+
|
|
96
|
+
def test_listar_facturas_con_filtros_los_incluye_en_la_query(self):
|
|
97
|
+
fake = FakeHttpTransport.que_responde(200, '{"invoices":[],"pagination":{"total":0,"limit":20,"offset":0}}')
|
|
98
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
99
|
+
|
|
100
|
+
client.listar_facturas({"status": "permanently_failed", "limit": "5"})
|
|
101
|
+
|
|
102
|
+
_, url, _, _ = fake.peticiones_recibidas[0]
|
|
103
|
+
self.assertIn("status=permanently_failed", url)
|
|
104
|
+
self.assertIn("limit=5", url)
|
|
105
|
+
|
|
106
|
+
def test_reintentar_factura_sin_tipo_manda_post_sin_query(self):
|
|
107
|
+
fake = FakeHttpTransport.que_responde(202, '{"id":"A-0001","status":"queued"}')
|
|
108
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
109
|
+
|
|
110
|
+
client.reintentar_factura("A-0001")
|
|
111
|
+
|
|
112
|
+
metodo, url, _, _ = fake.peticiones_recibidas[0]
|
|
113
|
+
self.assertEqual("POST", metodo)
|
|
114
|
+
self.assertEqual("https://api.nodda.test/v1/invoices/A-0001/retry", url)
|
|
115
|
+
|
|
116
|
+
def test_reintentar_factura_con_tipo_lo_incluye_en_la_query(self):
|
|
117
|
+
fake = FakeHttpTransport.que_responde(202, '{"id":"A-0001","status":"queued"}')
|
|
118
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
119
|
+
|
|
120
|
+
client.reintentar_factura("A-0001", "anulacion")
|
|
121
|
+
|
|
122
|
+
_, url, _, _ = fake.peticiones_recibidas[0]
|
|
123
|
+
self.assertEqual("https://api.nodda.test/v1/invoices/A-0001/retry?type=anulacion", url)
|
|
124
|
+
|
|
125
|
+
def test_obtener_perfil_manda_get_a_la_ruta_correcta(self):
|
|
126
|
+
fake = FakeHttpTransport.que_responde(200, '{"nifs":[{"nif":"B12345678","nombreEmisor":"Empresa Demo SL"}]}')
|
|
127
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
128
|
+
|
|
129
|
+
resultado = client.obtener_perfil()
|
|
130
|
+
|
|
131
|
+
self.assertEqual("B12345678", resultado["nifs"][0]["nif"])
|
|
132
|
+
metodo, url, _, _ = fake.peticiones_recibidas[0]
|
|
133
|
+
self.assertEqual("GET", metodo)
|
|
134
|
+
self.assertEqual("https://api.nodda.test/v1/client", url)
|
|
135
|
+
|
|
136
|
+
def test_crear_factura_con_nif_lo_incluye_en_el_cuerpo(self):
|
|
137
|
+
fake = FakeHttpTransport.que_responde(202, '{"id":"A-0001","status":"queued"}')
|
|
138
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
139
|
+
|
|
140
|
+
client.crear_factura({"series": "A", "number": "0001"}, "B22222222")
|
|
141
|
+
|
|
142
|
+
_, _, _, cuerpo = fake.peticiones_recibidas[0]
|
|
143
|
+
self.assertEqual({"series": "A", "number": "0001", "nif": "B22222222"}, json.loads(cuerpo))
|
|
144
|
+
|
|
145
|
+
def test_crear_factura_sin_nif_no_lo_incluye_en_el_cuerpo(self):
|
|
146
|
+
fake = FakeHttpTransport.que_responde(202, '{"id":"A-0001","status":"queued"}')
|
|
147
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
148
|
+
|
|
149
|
+
client.crear_factura({"series": "A", "number": "0001"})
|
|
150
|
+
|
|
151
|
+
_, _, _, cuerpo = fake.peticiones_recibidas[0]
|
|
152
|
+
self.assertNotIn("nif", cuerpo)
|
|
153
|
+
|
|
154
|
+
def test_listar_facturas_con_nif_lo_incluye_en_la_url(self):
|
|
155
|
+
fake = FakeHttpTransport.que_responde(200, '{"invoices":[],"pagination":{"total":0,"limit":20,"offset":0}}')
|
|
156
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
157
|
+
|
|
158
|
+
client.listar_facturas({"status": "permanently_failed"}, "B33333333")
|
|
159
|
+
|
|
160
|
+
_, url, _, _ = fake.peticiones_recibidas[0]
|
|
161
|
+
self.assertIn("nif=B33333333", url)
|
|
162
|
+
self.assertIn("status=permanently_failed", url)
|
|
163
|
+
|
|
164
|
+
def test_estado_sistema_con_nif_lo_incluye_en_la_url(self):
|
|
165
|
+
fake = FakeHttpTransport.que_responde(200, '{"canSendNow":true,"nextAllowedSendAt":null}')
|
|
166
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
167
|
+
|
|
168
|
+
client.estado_sistema("B11111111")
|
|
169
|
+
|
|
170
|
+
_, url, _, _ = fake.peticiones_recibidas[0]
|
|
171
|
+
self.assertEqual("https://api.nodda.test/v1/status?nif=B11111111", url)
|
|
172
|
+
|
|
173
|
+
def test_un_error_de_la_api_lanza_nodda_api_exception_con_el_detalle(self):
|
|
174
|
+
fake = FakeHttpTransport.que_responde(422, '{"error":{"code":"business_rule_violation","message":"Fecha invalida"}}')
|
|
175
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
176
|
+
|
|
177
|
+
with self.assertRaises(NoddaApiException) as ctx:
|
|
178
|
+
client.crear_factura({"series": "A"})
|
|
179
|
+
self.assertEqual(422, ctx.exception.status_code)
|
|
180
|
+
self.assertEqual("business_rule_violation", ctx.exception.codigo_error)
|
|
181
|
+
self.assertEqual("Fecha invalida", str(ctx.exception))
|
|
182
|
+
|
|
183
|
+
def test_un_error_sin_cuerpo_json_valido_no_rompe_el_manejo_de_errores(self):
|
|
184
|
+
fake = FakeHttpTransport.que_responde(500, "esto no es json")
|
|
185
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
186
|
+
|
|
187
|
+
with self.assertRaises(NoddaApiException) as ctx:
|
|
188
|
+
client.consultar_estado("A-0001")
|
|
189
|
+
self.assertEqual(500, ctx.exception.status_code)
|
|
190
|
+
self.assertIsNone(ctx.exception.codigo_error)
|
|
191
|
+
|
|
192
|
+
def test_la_api_key_viaja_en_el_header_authorization(self):
|
|
193
|
+
fake = FakeHttpTransport.que_responde(200, '{"canSendNow":true,"nextAllowedSendAt":null}')
|
|
194
|
+
client = NoddaClient("https://api.nodda.test", "MI_API_KEY_SECRETA", fake)
|
|
195
|
+
|
|
196
|
+
client.estado_sistema()
|
|
197
|
+
|
|
198
|
+
_, _, headers, _ = fake.peticiones_recibidas[0]
|
|
199
|
+
self.assertEqual("Bearer MI_API_KEY_SECRETA", headers["Authorization"])
|
|
200
|
+
|
|
201
|
+
def test_cada_peticion_manda_un_correlation_id_distinto(self):
|
|
202
|
+
fake = FakeHttpTransport.que_responde(200, '{"canSendNow":true,"nextAllowedSendAt":null}')
|
|
203
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
204
|
+
|
|
205
|
+
client.estado_sistema()
|
|
206
|
+
_, _, primeros, _ = fake.peticiones_recibidas[0]
|
|
207
|
+
client.estado_sistema()
|
|
208
|
+
_, _, segundos, _ = fake.peticiones_recibidas[1]
|
|
209
|
+
|
|
210
|
+
self.assertIn("X-Correlation-Id", primeros)
|
|
211
|
+
self.assertNotEqual(primeros["X-Correlation-Id"], segundos["X-Correlation-Id"])
|
|
212
|
+
|
|
213
|
+
def test_ultimo_correlation_id_coincide_con_el_mandado(self):
|
|
214
|
+
fake = FakeHttpTransport.que_responde(200, '{"canSendNow":true,"nextAllowedSendAt":null}')
|
|
215
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
216
|
+
|
|
217
|
+
client.estado_sistema()
|
|
218
|
+
_, _, headers, _ = fake.peticiones_recibidas[0]
|
|
219
|
+
|
|
220
|
+
self.assertEqual(headers["X-Correlation-Id"], client.ultimo_correlation_id())
|
|
221
|
+
|
|
222
|
+
def test_ultimo_cupo_restante_lee_el_encabezado(self):
|
|
223
|
+
fake = FakeHttpTransport.que_responde_con_encabezados(
|
|
224
|
+
202, '{"id":"A-0001","status":"queued"}', {"X-Nodda-Quota-Remaining": "7"}
|
|
225
|
+
)
|
|
226
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
227
|
+
|
|
228
|
+
client.crear_factura({"series": "A", "number": "0001"})
|
|
229
|
+
|
|
230
|
+
self.assertEqual(7, client.ultimo_cupo_restante())
|
|
231
|
+
|
|
232
|
+
def test_ultimo_cupo_restante_es_none_cuando_no_viene_el_encabezado(self):
|
|
233
|
+
fake = FakeHttpTransport.que_responde(202, '{"id":"A-0001","status":"queued"}')
|
|
234
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
235
|
+
|
|
236
|
+
client.crear_factura({"series": "A", "number": "0001"})
|
|
237
|
+
|
|
238
|
+
self.assertIsNone(client.ultimo_cupo_restante())
|
|
239
|
+
|
|
240
|
+
def test_registrar_cuenta_manda_post_a_cuentas_sin_authorization(self):
|
|
241
|
+
fake = FakeHttpTransport.que_responde(201, '{"cuentaId":"c1","nifId":"n1","apiKey":"KEY_NUEVA","aviso":"guardala"}')
|
|
242
|
+
|
|
243
|
+
resultado = NoddaClient.registrar_cuenta(
|
|
244
|
+
"https://api.nodda.test", "a@b.com", "B12345674", "Empresa SL", None, fake
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
self.assertEqual("KEY_NUEVA", resultado["apiKey"])
|
|
248
|
+
metodo, url, headers, _ = fake.peticiones_recibidas[0]
|
|
249
|
+
self.assertEqual("POST", metodo)
|
|
250
|
+
self.assertEqual("https://api.nodda.test/v1/cuentas", url)
|
|
251
|
+
self.assertNotIn("Authorization", headers)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_configurar_webhook_manda_put_con_la_url(self):
|
|
256
|
+
fake = FakeHttpTransport.que_responde(200, '{"nif":"B1","url":"https://x.es/h","secreto":"abc"}')
|
|
257
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
258
|
+
r = client.configurar_webhook("https://x.es/h", "B1")
|
|
259
|
+
self.assertEqual("abc", r["secreto"])
|
|
260
|
+
metodo, url, _, cuerpo = fake.peticiones_recibidas[0]
|
|
261
|
+
self.assertEqual("PUT", metodo)
|
|
262
|
+
self.assertEqual("https://api.nodda.test/v1/webhooks?nif=B1", url)
|
|
263
|
+
self.assertEqual({"url": "https://x.es/h"}, json.loads(cuerpo))
|
|
264
|
+
|
|
265
|
+
def test_quitar_webhook_manda_delete(self):
|
|
266
|
+
fake = FakeHttpTransport.que_responde(200, '{"nif":"B1","eliminado":true}')
|
|
267
|
+
client = NoddaClient("https://api.nodda.test", "API_KEY", fake)
|
|
268
|
+
client.quitar_webhook()
|
|
269
|
+
metodo, url, _, _ = fake.peticiones_recibidas[0]
|
|
270
|
+
self.assertEqual("DELETE", metodo)
|
|
271
|
+
self.assertEqual("https://api.nodda.test/v1/webhooks", url)
|
|
272
|
+
|
|
273
|
+
def test_verificar_firma_webhook(self):
|
|
274
|
+
cuerpo = '{"id":"2026-0001","status":"sent"}'
|
|
275
|
+
self.assertTrue(NoddaClient.verificar_firma_webhook(cuerpo, "17c2185e12eeb103aecf6c824b4719e2808bd202f743ad5c6109051803261fc8", "s3cr3t"))
|
|
276
|
+
self.assertTrue(NoddaClient.verificar_firma_webhook(cuerpo.encode(), "17c2185e12eeb103aecf6c824b4719e2808bd202f743ad5c6109051803261fc8".upper(), "s3cr3t"))
|
|
277
|
+
self.assertFalse(NoddaClient.verificar_firma_webhook(cuerpo + " ", "17c2185e12eeb103aecf6c824b4719e2808bd202f743ad5c6109051803261fc8", "s3cr3t"))
|
|
278
|
+
self.assertFalse(NoddaClient.verificar_firma_webhook(cuerpo, "17c2185e12eeb103aecf6c824b4719e2808bd202f743ad5c6109051803261fc8", "otro"))
|
|
279
|
+
self.assertFalse(NoddaClient.verificar_firma_webhook(cuerpo, None, "s3cr3t"))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
if __name__ == "__main__":
|
|
283
|
+
unittest.main()
|