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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ 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()