facturalo-pe 1.0.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,6 @@
1
+ dist/
2
+ *.egg-info/
3
+ __pycache__/
4
+ .venv/
5
+ .pytest_cache/
6
+ .env
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Consultia Digital S.A.C. (Facturalo)
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,130 @@
1
+ Metadata-Version: 2.5
2
+ Name: facturalo-pe
3
+ Version: 1.0.0
4
+ Summary: SDK oficial de Facturalo para Python: emite facturas, boletas, notas y guías de remisión ante SUNAT, consulta RUC/DNI, idempotencia y verifica webhooks.
5
+ Project-URL: Homepage, https://www.facturalo.pe
6
+ Project-URL: Documentation, https://docs.facturalo.pe
7
+ Project-URL: Source, https://github.com/facturalo-pe/facturalo-python
8
+ Project-URL: Issues, https://github.com/facturalo-pe/facturalo-python/issues
9
+ Author-email: Facturalo <contacto@facturalo.pe>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: boleta,factura,facturacion electronica,facturalo,guia de remision,peru,sunat
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Natural Language :: Spanish
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.9
21
+ Requires-Dist: httpx<1,>=0.25
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Facturalo para Python
27
+
28
+ SDK oficial de [Facturalo](https://www.facturalo.pe) para Python: emite facturas, boletas, notas de crédito y débito y guías de remisión ante SUNAT, consulta RUC, DNI y tipo de cambio, trabaja el SIRE y verifica los webhooks. Cliente delgado sobre la [API REST v3](https://docs.facturalo.pe), con la misma superficie que los SDKs de [Node.js](https://www.npmjs.com/package/@facturalo-pe/node) y [PHP](https://packagist.org/packages/facturalo-pe/facturalo-php).
29
+
30
+ ```bash
31
+ pip install facturalo-pe
32
+ ```
33
+
34
+ Requiere Python 3.9 o superior.
35
+
36
+ ## Tu primera factura
37
+
38
+ ```python
39
+ import os
40
+ from facturalo import FacturaloClient
41
+
42
+ facturalo = FacturaloClient(os.environ["FACTURALO_TOKEN"]) # sk_test_... (demo) o sk_live_...
43
+
44
+ envio = facturalo.documentos.enviar(
45
+ {
46
+ "sunatTransaccion": "0101",
47
+ "tipoComprobante": "01",
48
+ "serie": "F001",
49
+ "correlativo": "1",
50
+ "fechaEmision": "2026-11-03",
51
+ "moneda": "PEN",
52
+ "formaPago": "CONTADO",
53
+ "cliente": {
54
+ "tipoDocumento": "6",
55
+ "numeroDocumento": "20000000001",
56
+ "denominacion": "EMPRESA S.A.C.",
57
+ "direccion": "AV. LIMA 123 - LIMA - LIMA - LIMA",
58
+ },
59
+ "items": [
60
+ {"unidadMedida": "NIU", "descripcion": "SERVICIO DE CONSULTORÍA", "cantidad": 1, "precioUnitario": 118, "tipoIgv": "10"},
61
+ ],
62
+ },
63
+ idempotency_key="pedido-1001",
64
+ )
65
+
66
+ print(envio["estado"]) # ACEPTADA o RECHAZADA
67
+ print(envio["enlaces"]["pdf"]) # PDF A4; también pdfTicket, xml y cdr
68
+ ```
69
+
70
+ El token decide el entorno: `sk_test_` envía a SUNAT Beta (demo) y `sk_live_` a producción. Un rechazo de SUNAT no es una excepción: vuelve con `estado == "RECHAZADA"` y el motivo en `envio["sunat"]`.
71
+
72
+ ## Recursos
73
+
74
+ | Cliente | Token | Recursos |
75
+ |---|---|---|
76
+ | `FacturaloClient` | `sk_test_...` / `sk_live_...` | `documentos` (enviar, generar_xml, consultar, buscar, listar, anular, reenviar, eventos, eliminar, url_archivo, descargar), `guias` (enviar, consultar, consultar_ticket, eventos, listar, descargar), `consultas` (ruc, dni, tipo_cambio, placa, soat, licencia…), `webhooks` (listar, reenviar), `sire.ventas` / `sire.compras` |
77
+ | `FacturaloCuentaClient` | `user_sk_...` | `empresas` (listar, obtener, verificar_ruc, crear, actualizar, webhook, regenerar_token…), `establecimientos`, `consultas` |
78
+
79
+ ```python
80
+ facturalo.consultas.ruc("20100006538")
81
+ facturalo.consultas.tipo_cambio("2026-10-30")
82
+ facturalo.documentos.anular("01", "F001", "1", motivo="Error en los datos del cliente")
83
+ pdf = facturalo.documentos.descargar(envio["documento"]["id"], "pdf") # bytes
84
+ ```
85
+
86
+ ## Errores
87
+
88
+ Todas heredan de `FacturaloError`. Los errores HTTP son `FacturaloApiError` con `status`, `errors` y `body`:
89
+
90
+ | Excepción | Cuándo |
91
+ |---|---|
92
+ | `FacturaloValidationError` | 400 / 422: datos inválidos (revisa `e.errors`) |
93
+ | `FacturaloAuthenticationError` | 401: token inválido o revocado |
94
+ | `FacturaloPermissionError` | 403 |
95
+ | `FacturaloNotFoundError` | 404 |
96
+ | `FacturaloRateLimitError` | 429 |
97
+ | `FacturaloConnectionError` | Red, DNS o timeout |
98
+
99
+ ## Reintentos e idempotencia
100
+
101
+ - Timeout por defecto de 90 s: el envío síncrono espera la respuesta de SUNAT.
102
+ - Los `GET` se reintentan ante 429, 502, 503, 504 o fallos de conexión.
103
+ - Los envíos solo se reintentan si llevan `idempotency_key`: el servidor deduplica y nunca se emite dos veces el mismo comprobante.
104
+
105
+ ## Webhooks
106
+
107
+ ```python
108
+ from facturalo import construir_evento, FacturaloSignatureVerificationError
109
+
110
+ # Django: request.body · Flask: request.get_data() · FastAPI: await request.body()
111
+ try:
112
+ evento = construir_evento(cuerpo_crudo, request.headers.get("X-Facturalo-Firma"), os.environ["FACTURALO_WEBHOOK_SECRET"])
113
+ except FacturaloSignatureVerificationError:
114
+ ... # responde 400
115
+
116
+ if evento["evento"] == "documento.sunat.respuesta":
117
+ ...
118
+ ```
119
+
120
+ La firma es `HMAC-SHA256(secret, cuerpo crudo)` en hexadecimal y se compara en tiempo constante.
121
+
122
+ ## Desarrollo
123
+
124
+ ```bash
125
+ python -m venv .venv && . .venv/bin/activate
126
+ pip install -e ".[dev]"
127
+ pytest
128
+ ```
129
+
130
+ Documentación completa: [docs.facturalo.pe](https://docs.facturalo.pe). Licencia MIT · © Consultia Digital S.A.C. (Facturalo).
@@ -0,0 +1,105 @@
1
+ # Facturalo para Python
2
+
3
+ SDK oficial de [Facturalo](https://www.facturalo.pe) para Python: emite facturas, boletas, notas de crédito y débito y guías de remisión ante SUNAT, consulta RUC, DNI y tipo de cambio, trabaja el SIRE y verifica los webhooks. Cliente delgado sobre la [API REST v3](https://docs.facturalo.pe), con la misma superficie que los SDKs de [Node.js](https://www.npmjs.com/package/@facturalo-pe/node) y [PHP](https://packagist.org/packages/facturalo-pe/facturalo-php).
4
+
5
+ ```bash
6
+ pip install facturalo-pe
7
+ ```
8
+
9
+ Requiere Python 3.9 o superior.
10
+
11
+ ## Tu primera factura
12
+
13
+ ```python
14
+ import os
15
+ from facturalo import FacturaloClient
16
+
17
+ facturalo = FacturaloClient(os.environ["FACTURALO_TOKEN"]) # sk_test_... (demo) o sk_live_...
18
+
19
+ envio = facturalo.documentos.enviar(
20
+ {
21
+ "sunatTransaccion": "0101",
22
+ "tipoComprobante": "01",
23
+ "serie": "F001",
24
+ "correlativo": "1",
25
+ "fechaEmision": "2026-11-03",
26
+ "moneda": "PEN",
27
+ "formaPago": "CONTADO",
28
+ "cliente": {
29
+ "tipoDocumento": "6",
30
+ "numeroDocumento": "20000000001",
31
+ "denominacion": "EMPRESA S.A.C.",
32
+ "direccion": "AV. LIMA 123 - LIMA - LIMA - LIMA",
33
+ },
34
+ "items": [
35
+ {"unidadMedida": "NIU", "descripcion": "SERVICIO DE CONSULTORÍA", "cantidad": 1, "precioUnitario": 118, "tipoIgv": "10"},
36
+ ],
37
+ },
38
+ idempotency_key="pedido-1001",
39
+ )
40
+
41
+ print(envio["estado"]) # ACEPTADA o RECHAZADA
42
+ print(envio["enlaces"]["pdf"]) # PDF A4; también pdfTicket, xml y cdr
43
+ ```
44
+
45
+ El token decide el entorno: `sk_test_` envía a SUNAT Beta (demo) y `sk_live_` a producción. Un rechazo de SUNAT no es una excepción: vuelve con `estado == "RECHAZADA"` y el motivo en `envio["sunat"]`.
46
+
47
+ ## Recursos
48
+
49
+ | Cliente | Token | Recursos |
50
+ |---|---|---|
51
+ | `FacturaloClient` | `sk_test_...` / `sk_live_...` | `documentos` (enviar, generar_xml, consultar, buscar, listar, anular, reenviar, eventos, eliminar, url_archivo, descargar), `guias` (enviar, consultar, consultar_ticket, eventos, listar, descargar), `consultas` (ruc, dni, tipo_cambio, placa, soat, licencia…), `webhooks` (listar, reenviar), `sire.ventas` / `sire.compras` |
52
+ | `FacturaloCuentaClient` | `user_sk_...` | `empresas` (listar, obtener, verificar_ruc, crear, actualizar, webhook, regenerar_token…), `establecimientos`, `consultas` |
53
+
54
+ ```python
55
+ facturalo.consultas.ruc("20100006538")
56
+ facturalo.consultas.tipo_cambio("2026-10-30")
57
+ facturalo.documentos.anular("01", "F001", "1", motivo="Error en los datos del cliente")
58
+ pdf = facturalo.documentos.descargar(envio["documento"]["id"], "pdf") # bytes
59
+ ```
60
+
61
+ ## Errores
62
+
63
+ Todas heredan de `FacturaloError`. Los errores HTTP son `FacturaloApiError` con `status`, `errors` y `body`:
64
+
65
+ | Excepción | Cuándo |
66
+ |---|---|
67
+ | `FacturaloValidationError` | 400 / 422: datos inválidos (revisa `e.errors`) |
68
+ | `FacturaloAuthenticationError` | 401: token inválido o revocado |
69
+ | `FacturaloPermissionError` | 403 |
70
+ | `FacturaloNotFoundError` | 404 |
71
+ | `FacturaloRateLimitError` | 429 |
72
+ | `FacturaloConnectionError` | Red, DNS o timeout |
73
+
74
+ ## Reintentos e idempotencia
75
+
76
+ - Timeout por defecto de 90 s: el envío síncrono espera la respuesta de SUNAT.
77
+ - Los `GET` se reintentan ante 429, 502, 503, 504 o fallos de conexión.
78
+ - Los envíos solo se reintentan si llevan `idempotency_key`: el servidor deduplica y nunca se emite dos veces el mismo comprobante.
79
+
80
+ ## Webhooks
81
+
82
+ ```python
83
+ from facturalo import construir_evento, FacturaloSignatureVerificationError
84
+
85
+ # Django: request.body · Flask: request.get_data() · FastAPI: await request.body()
86
+ try:
87
+ evento = construir_evento(cuerpo_crudo, request.headers.get("X-Facturalo-Firma"), os.environ["FACTURALO_WEBHOOK_SECRET"])
88
+ except FacturaloSignatureVerificationError:
89
+ ... # responde 400
90
+
91
+ if evento["evento"] == "documento.sunat.respuesta":
92
+ ...
93
+ ```
94
+
95
+ La firma es `HMAC-SHA256(secret, cuerpo crudo)` en hexadecimal y se compara en tiempo constante.
96
+
97
+ ## Desarrollo
98
+
99
+ ```bash
100
+ python -m venv .venv && . .venv/bin/activate
101
+ pip install -e ".[dev]"
102
+ pytest
103
+ ```
104
+
105
+ Documentación completa: [docs.facturalo.pe](https://docs.facturalo.pe). Licencia MIT · © Consultia Digital S.A.C. (Facturalo).
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "facturalo-pe"
7
+ version = "1.0.0"
8
+ description = "SDK oficial de Facturalo para Python: emite facturas, boletas, notas y guías de remisión ante SUNAT, consulta RUC/DNI, idempotencia y verifica webhooks."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [{ name = "Facturalo", email = "contacto@facturalo.pe" }]
14
+ keywords = ["facturalo", "sunat", "facturacion electronica", "factura", "boleta", "guia de remision", "peru"]
15
+ classifiers = [
16
+ "Development Status :: 5 - Production/Stable",
17
+ "Intended Audience :: Developers",
18
+ "Natural Language :: Spanish",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Office/Business :: Financial :: Accounting",
22
+ "Typing :: Typed",
23
+ ]
24
+ dependencies = ["httpx>=0.25,<1"]
25
+
26
+ [project.optional-dependencies]
27
+ dev = ["pytest>=8"]
28
+
29
+ [project.urls]
30
+ Homepage = "https://www.facturalo.pe"
31
+ Documentation = "https://docs.facturalo.pe"
32
+ Source = "https://github.com/facturalo-pe/facturalo-python"
33
+ Issues = "https://github.com/facturalo-pe/facturalo-python/issues"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/facturalo"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
@@ -0,0 +1,39 @@
1
+ """SDK oficial de Facturalo para Python: facturación electrónica SUNAT por API.
2
+
3
+ from facturalo import FacturaloClient
4
+
5
+ facturalo = FacturaloClient(os.environ["FACTURALO_TOKEN"])
6
+ envio = facturalo.documentos.enviar(documento, idempotency_key="pedido-1001")
7
+ """
8
+
9
+ from .client import FacturaloClient, FacturaloCuentaClient
10
+ from .errors import (
11
+ FacturaloApiError,
12
+ FacturaloAuthenticationError,
13
+ FacturaloConnectionError,
14
+ FacturaloError,
15
+ FacturaloNotFoundError,
16
+ FacturaloPermissionError,
17
+ FacturaloRateLimitError,
18
+ FacturaloSignatureVerificationError,
19
+ FacturaloValidationError,
20
+ )
21
+ from .webhook import construir_evento, verificar_firma
22
+
23
+ __version__ = "1.0.0"
24
+
25
+ __all__ = [
26
+ "FacturaloClient",
27
+ "FacturaloCuentaClient",
28
+ "FacturaloError",
29
+ "FacturaloApiError",
30
+ "FacturaloAuthenticationError",
31
+ "FacturaloConnectionError",
32
+ "FacturaloNotFoundError",
33
+ "FacturaloPermissionError",
34
+ "FacturaloRateLimitError",
35
+ "FacturaloSignatureVerificationError",
36
+ "FacturaloValidationError",
37
+ "construir_evento",
38
+ "verificar_firma",
39
+ ]
@@ -0,0 +1,127 @@
1
+ """Transporte HTTP compartido por FacturaloClient y FacturaloCuentaClient. Uso interno."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any, Dict, Mapping, Optional
7
+
8
+ import httpx
9
+
10
+ from .errors import FacturaloApiError, FacturaloConnectionError
11
+
12
+ DEFAULT_BASE_URL = "https://api.facturalo.pe/api/v3"
13
+ #: El envío síncrono espera la respuesta de SUNAT: puede tardar varios segundos.
14
+ DEFAULT_TIMEOUT_SECONDS = 90.0
15
+ _MAX_RETRIES = 2
16
+ _RETRYABLE_STATUS = {429, 502, 503, 504}
17
+
18
+ Json = Dict[str, Any]
19
+
20
+
21
+ class HttpClient:
22
+ def __init__(
23
+ self,
24
+ token: str,
25
+ base_url: Optional[str] = None,
26
+ http_client: Optional[httpx.Client] = None,
27
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
28
+ ) -> None:
29
+ self._token = token
30
+ self._base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
31
+ self._http = http_client or httpx.Client(timeout=timeout)
32
+ self._timeout = timeout
33
+
34
+ def request(
35
+ self,
36
+ method: str,
37
+ path: str,
38
+ body: Optional[Json] = None,
39
+ query: Optional[Mapping[str, Any]] = None,
40
+ headers: Optional[Mapping[str, str]] = None,
41
+ ) -> Any:
42
+ """Devuelve `data` del sobre `{ success, message, data }`: un dict, o una lista en los listados."""
43
+ envelope = self._send(method, path, body, query or {}, dict(headers or {}))
44
+ data = envelope.get("data")
45
+ return data if isinstance(data, (dict, list)) else {}
46
+
47
+ def file_url(self, path: str) -> str:
48
+ return self._base_url + path
49
+
50
+ def download(self, url: str) -> bytes:
51
+ """Descarga binaria (PDF, XML, CDR). Las URLs de archivos son públicas: no envía el token."""
52
+ try:
53
+ response = self._http.get(url, timeout=self._timeout)
54
+ except httpx.HTTPError as e:
55
+ raise FacturaloConnectionError("No se pudo conectar con la API de Facturalo.") from e
56
+ if not 200 <= response.status_code < 300:
57
+ raise FacturaloApiError.for_status(response.status_code, f"No se pudo descargar {url}")
58
+ return response.content
59
+
60
+ def _send(
61
+ self,
62
+ method: str,
63
+ path: str,
64
+ body: Optional[Json],
65
+ query: Mapping[str, Any],
66
+ extra_headers: Dict[str, str],
67
+ ) -> Json:
68
+ headers = {
69
+ # Facturalo no usa el esquema Bearer: el token va tal cual.
70
+ "Authorization": self._token,
71
+ "Accept": "application/json",
72
+ **extra_headers,
73
+ }
74
+ params = {
75
+ k: ("true" if v else "false") if isinstance(v, bool) else v
76
+ for k, v in query.items()
77
+ if v is not None and v != ""
78
+ }
79
+
80
+ # GET siempre. POST solo con Idempotency-Key (el servidor deduplica): nunca se emite dos veces.
81
+ can_retry = method == "GET" or "Idempotency-Key" in extra_headers
82
+ attempts = _MAX_RETRIES + 1 if can_retry else 1
83
+
84
+ last_error: Optional[Exception] = None
85
+ for attempt in range(attempts):
86
+ if attempt > 0:
87
+ time.sleep((2**attempt) * 0.2)
88
+ try:
89
+ response = self._http.request(
90
+ method,
91
+ self._base_url + path,
92
+ json=body,
93
+ params=params or None,
94
+ headers=headers,
95
+ timeout=self._timeout,
96
+ )
97
+ except (httpx.ConnectError, httpx.ConnectTimeout) as e:
98
+ last_error = FacturaloConnectionError("No se pudo conectar con la API de Facturalo.")
99
+ last_error.__cause__ = e
100
+ if can_retry:
101
+ continue
102
+ raise last_error from e
103
+ except httpx.HTTPError as e:
104
+ raise FacturaloConnectionError("No se pudo conectar con la API de Facturalo.") from e
105
+
106
+ try:
107
+ decoded = response.json()
108
+ except ValueError:
109
+ decoded = {}
110
+ if not isinstance(decoded, dict):
111
+ decoded = {}
112
+
113
+ if 200 <= response.status_code < 300:
114
+ return decoded
115
+
116
+ message = decoded.get("message") if isinstance(decoded.get("message"), str) else response.reason_phrase
117
+ errors = decoded.get("errors") if isinstance(decoded.get("errors"), dict) else {}
118
+ error = FacturaloApiError.for_status(response.status_code, message, errors, decoded or None)
119
+ if can_retry and response.status_code in _RETRYABLE_STATUS and attempt < _MAX_RETRIES:
120
+ last_error = error
121
+ continue
122
+ raise error
123
+
124
+ raise last_error or FacturaloConnectionError("No se pudo conectar con la API de Facturalo.")
125
+
126
+ def close(self) -> None:
127
+ self._http.close()
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ import httpx
6
+
7
+ from ._http import DEFAULT_TIMEOUT_SECONDS, HttpClient
8
+ from .resources import Consultas, Documentos, Empresas, Establecimientos, Guias, Sire, Webhooks
9
+
10
+
11
+ class FacturaloClient:
12
+ """Cliente con el token de una empresa (sk_test_... demo / sk_live_... producción).
13
+
14
+ Emite y consulta comprobantes y guías, consulta RUC/DNI, revisa los webhooks enviados y trabaja el SIRE::
15
+
16
+ facturalo = FacturaloClient(os.environ["FACTURALO_TOKEN"])
17
+ envio = facturalo.documentos.enviar({...})
18
+ """
19
+
20
+ _PREFIJOS = ("sk_test_", "sk_live_")
21
+
22
+ def __init__(
23
+ self,
24
+ token: str,
25
+ *,
26
+ base_url: Optional[str] = None,
27
+ http_client: Optional[httpx.Client] = None,
28
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
29
+ ) -> None:
30
+ if not token.startswith(self._PREFIJOS):
31
+ raise ValueError(
32
+ "FacturaloClient espera el token de una empresa (sk_test_... / sk_live_...). "
33
+ "Para el token de usuario (user_sk_...) usa FacturaloCuentaClient."
34
+ )
35
+ self._http = HttpClient(token, base_url, http_client, timeout)
36
+ #: "demo" o "prod", según el token.
37
+ self.entorno = "prod" if token.startswith("sk_live_") else "demo"
38
+ self.documentos = Documentos(self._http)
39
+ self.guias = Guias(self._http)
40
+ self.consultas = Consultas(self._http)
41
+ self.webhooks = Webhooks(self._http)
42
+ self.sire = Sire(self._http)
43
+
44
+ def close(self) -> None:
45
+ self._http.close()
46
+
47
+ def __enter__(self) -> "FacturaloClient":
48
+ return self
49
+
50
+ def __exit__(self, *_: object) -> None:
51
+ self.close()
52
+
53
+
54
+ class FacturaloCuentaClient:
55
+ """Cliente con el token de usuario (user_sk_...): administra empresas, sus tokens, webhooks y establecimientos.
56
+
57
+ Útil para software que da de alta las empresas de sus clientes.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ token: str,
63
+ *,
64
+ base_url: Optional[str] = None,
65
+ http_client: Optional[httpx.Client] = None,
66
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
67
+ ) -> None:
68
+ if not token.startswith("user_sk_"):
69
+ raise ValueError(
70
+ "FacturaloCuentaClient espera el token de usuario (user_sk_...). "
71
+ "Para emitir comprobantes usa FacturaloClient con el token de la empresa."
72
+ )
73
+ self._http = HttpClient(token, base_url, http_client, timeout)
74
+ self.empresas = Empresas(self._http)
75
+ self.establecimientos = Establecimientos(self._http)
76
+ self.consultas = Consultas(self._http)
77
+
78
+ def close(self) -> None:
79
+ self._http.close()
80
+
81
+ def __enter__(self) -> "FacturaloCuentaClient":
82
+ return self
83
+
84
+ def __exit__(self, *_: object) -> None:
85
+ self.close()
@@ -0,0 +1,70 @@
1
+ """Errores del SDK. Misma jerarquía que los SDKs de Node y PHP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional
6
+
7
+
8
+ class FacturaloError(Exception):
9
+ """Base de todos los errores del SDK."""
10
+
11
+
12
+ class FacturaloConnectionError(FacturaloError):
13
+ """No se pudo conectar con la API (red, DNS, timeout)."""
14
+
15
+
16
+ class FacturaloSignatureVerificationError(FacturaloError):
17
+ """La firma de un webhook falta o no coincide."""
18
+
19
+
20
+ class FacturaloApiError(FacturaloError):
21
+ """La API respondió con un error HTTP. `status`, `errors` (validación) y `body` (respuesta completa)."""
22
+
23
+ def __init__(
24
+ self,
25
+ message: str,
26
+ status: int,
27
+ errors: Optional[Dict[str, Any]] = None,
28
+ body: Optional[Dict[str, Any]] = None,
29
+ ) -> None:
30
+ super().__init__(message)
31
+ self.status = status
32
+ self.errors = errors or {}
33
+ self.body = body
34
+
35
+ @staticmethod
36
+ def for_status(
37
+ status: int,
38
+ message: str,
39
+ errors: Optional[Dict[str, Any]] = None,
40
+ body: Optional[Dict[str, Any]] = None,
41
+ ) -> "FacturaloApiError":
42
+ cls = {
43
+ 401: FacturaloAuthenticationError,
44
+ 403: FacturaloPermissionError,
45
+ 404: FacturaloNotFoundError,
46
+ 400: FacturaloValidationError,
47
+ 422: FacturaloValidationError,
48
+ 429: FacturaloRateLimitError,
49
+ }.get(status, FacturaloApiError)
50
+ return cls(message, status, errors, body)
51
+
52
+
53
+ class FacturaloAuthenticationError(FacturaloApiError):
54
+ """401: token ausente, inválido o revocado."""
55
+
56
+
57
+ class FacturaloPermissionError(FacturaloApiError):
58
+ """403: el token no tiene permiso para esta operación."""
59
+
60
+
61
+ class FacturaloNotFoundError(FacturaloApiError):
62
+ """404: el recurso no existe (o es de otra empresa)."""
63
+
64
+
65
+ class FacturaloValidationError(FacturaloApiError):
66
+ """400 / 422: la petición no pasó validación o la operación no procede. Revisa `errors`."""
67
+
68
+
69
+ class FacturaloRateLimitError(FacturaloApiError):
70
+ """429: demasiadas peticiones."""
File without changes
@@ -0,0 +1,11 @@
1
+ """Recursos de la API: una clase por recurso, igual que en los SDKs de Node y PHP."""
2
+
3
+ from .consultas import Consultas
4
+ from .documentos import Documentos
5
+ from .empresas import Empresas
6
+ from .establecimientos import Establecimientos
7
+ from .guias import Guias
8
+ from .sire import LibroSire, Sire
9
+ from .webhooks import Webhooks
10
+
11
+ __all__ = ["Consultas", "Documentos", "Empresas", "Establecimientos", "Guias", "LibroSire", "Sire", "Webhooks"]
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Optional
4
+ from urllib.parse import quote
5
+
6
+ from .._http import HttpClient
7
+
8
+
9
+ class Consultas:
10
+ """Consultas de RUC, DNI, tipo de cambio, placa, SOAT y licencia."""
11
+
12
+ def __init__(self, http: HttpClient) -> None:
13
+ self._http = http
14
+
15
+ def dni(self, dni: str) -> Dict[str, Any]:
16
+ return self._http.request("GET", f"/consulta/dni/{quote(dni, safe='')}")
17
+
18
+ def ruc(self, ruc: str) -> Dict[str, Any]:
19
+ return self._http.request("GET", f"/consulta/ruc/{quote(ruc, safe='')}")
20
+
21
+ def ruc_establecimientos(self, ruc: str) -> Any:
22
+ return self._http.request("GET", f"/consulta/ruc/{quote(ruc, safe='')}/anexos")
23
+
24
+ def ruc_representantes(self, ruc: str) -> Any:
25
+ return self._http.request("GET", f"/consulta/ruc/{quote(ruc, safe='')}/representantes")
26
+
27
+ def tipo_cambio(self, fecha: Optional[str] = None) -> Dict[str, Any]:
28
+ """Tipo de cambio SUNAT de una fecha (AAAA-MM-DD); sin fecha, el de hoy."""
29
+ return self._http.request("GET", "/consulta/tipo-cambio", query={"fecha": fecha})
30
+
31
+ def placa(self, placa: str) -> Dict[str, Any]:
32
+ return self._http.request("GET", f"/consulta/placa/{quote(placa, safe='')}")
33
+
34
+ def soat(self, placa: str) -> Dict[str, Any]:
35
+ return self._http.request("GET", f"/consulta/placa/{quote(placa, safe='')}/soat")
36
+
37
+ def licencia(self, dni: str) -> Dict[str, Any]:
38
+ return self._http.request("GET", f"/consulta/licencia/{quote(dni, safe='')}")
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Optional
4
+ from urllib.parse import quote
5
+
6
+ from .._http import HttpClient
7
+
8
+ _EXTENSION = {"pdf": ".pdf", "ticket": ".ticket.pdf", "xml": ".xml", "cdr": ".cdr"}
9
+
10
+
11
+ def _id(valor: str) -> str:
12
+ return quote(valor, safe="")
13
+
14
+
15
+ class Documentos:
16
+ """Facturas, boletas y notas de crédito y débito."""
17
+
18
+ def __init__(self, http: HttpClient) -> None:
19
+ self._http = http
20
+
21
+ def enviar(
22
+ self,
23
+ documento: Dict[str, Any],
24
+ *,
25
+ webhook: bool = False,
26
+ establecimiento: Optional[str] = None,
27
+ idempotency_key: Optional[str] = None,
28
+ ) -> Dict[str, Any]:
29
+ """Emite el comprobante y espera la respuesta de SUNAT (o responde EN_PROCESO si `webhook=True`).
30
+
31
+ Un rechazo de SUNAT no es una excepción: vuelve con `estado == "RECHAZADA"` y el motivo en `sunat`.
32
+ Con `idempotency_key`, reintentar la misma petición no duplica el comprobante.
33
+ """
34
+ body: Dict[str, Any] = {"webhook": webhook, "documento": documento}
35
+ if establecimiento is not None:
36
+ body["establecimiento"] = establecimiento
37
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
38
+ return self._http.request("POST", "/documentos/enviar", body, headers=headers)
39
+
40
+ def generar_xml(self, documento: Dict[str, Any], *, establecimiento: Optional[str] = None) -> Dict[str, Any]:
41
+ """Genera el XML UBL 2.1 sin enviarlo a SUNAT."""
42
+ body: Dict[str, Any] = {"documento": documento}
43
+ if establecimiento is not None:
44
+ body["establecimiento"] = establecimiento
45
+ return self._http.request("POST", "/documentos/xml", body)
46
+
47
+ def consultar(self, id: str) -> Dict[str, Any]:
48
+ return self._http.request("GET", f"/documentos/{_id(id)}")
49
+
50
+ def buscar(self, tipo_comprobante: str, serie: str, correlativo: str) -> Dict[str, Any]:
51
+ return self._http.request(
52
+ "GET",
53
+ "/documentos/buscar",
54
+ query={"tipo_documento": tipo_comprobante, "serie": serie, "correlativo": correlativo},
55
+ )
56
+
57
+ def listar(
58
+ self,
59
+ *,
60
+ pagina: Optional[int] = None,
61
+ limite: Optional[int] = None,
62
+ fecha_inicio: Optional[str] = None,
63
+ fecha_fin: Optional[str] = None,
64
+ tipo: Optional[str] = None,
65
+ estado: Optional[str] = None,
66
+ buscar: Optional[str] = None,
67
+ establecimiento: Optional[str] = None,
68
+ ) -> Any:
69
+ return self._http.request(
70
+ "GET",
71
+ "/empresas/documentos",
72
+ query={
73
+ "page": pagina,
74
+ "limite": limite,
75
+ "fecha_inicio": fecha_inicio,
76
+ "fecha_fin": fecha_fin,
77
+ "tipo": tipo,
78
+ "estado": estado,
79
+ "buscar": buscar,
80
+ "establecimiento": establecimiento,
81
+ },
82
+ )
83
+
84
+ def anular(self, tipo_comprobante: str, serie: str, correlativo: str, motivo: str) -> Dict[str, Any]:
85
+ """Comunicación de baja (facturas) o resumen diario (boletas), dentro del plazo de SUNAT."""
86
+ return self._http.request(
87
+ "POST",
88
+ "/documentos/anular",
89
+ {
90
+ "documento": {
91
+ "tipoComprobante": tipo_comprobante,
92
+ "serie": serie,
93
+ "correlativo": correlativo,
94
+ "motivo": motivo,
95
+ }
96
+ },
97
+ )
98
+
99
+ def reenviar(self, id: str) -> Dict[str, Any]:
100
+ return self._http.request("POST", f"/documentos/{_id(id)}/reenviar")
101
+
102
+ def eventos(self, id: str) -> Any:
103
+ return self._http.request("GET", f"/documentos/{_id(id)}/eventos")
104
+
105
+ def eliminar(self, id: str) -> None:
106
+ self._http.request("DELETE", f"/documentos/{_id(id)}")
107
+
108
+ def url_archivo(self, id: str, formato: str = "pdf") -> str:
109
+ """URL pública del PDF (A4), ticket, XML o CDR."""
110
+ if formato not in _EXTENSION:
111
+ raise ValueError("Formato no válido. Usa pdf, ticket, xml o cdr.")
112
+ return self._http.file_url(f"/documentos/{_id(id)}{_EXTENSION[formato]}")
113
+
114
+ def descargar(self, id: str, formato: str = "pdf") -> bytes:
115
+ return self._http.download(self.url_archivo(id, formato))
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, List, Optional
4
+ from urllib.parse import quote
5
+
6
+ from .._http import HttpClient
7
+
8
+
9
+ def _id(valor: str) -> str:
10
+ return quote(valor, safe="")
11
+
12
+
13
+ class Empresas:
14
+ """Empresas de la cuenta (token de usuario user_sk_...)."""
15
+
16
+ def __init__(self, http: HttpClient) -> None:
17
+ self._http = http
18
+
19
+ def listar(self) -> Any:
20
+ return self._http.request("GET", "/empresas")
21
+
22
+ def obtener(self, id: str) -> Dict[str, Any]:
23
+ return self._http.request("GET", f"/empresas/{_id(id)}").get("empresa", {})
24
+
25
+ def verificar_ruc(self, ruc: str) -> Dict[str, Any]:
26
+ return self._http.request("GET", f"/empresas/verificar-ruc/{_id(ruc)}")
27
+
28
+ def crear(self, empresa: Dict[str, Any]) -> Dict[str, Any]:
29
+ return self._http.request("POST", "/empresas", {"empresa": empresa}).get("empresa", {})
30
+
31
+ def actualizar(self, id: str, empresa: Dict[str, Any]) -> Dict[str, Any]:
32
+ return self._http.request("PATCH", f"/empresas/{_id(id)}", {"empresa": empresa}).get("empresa", {})
33
+
34
+ def actualizar_webhook(self, id: str, webhook: Dict[str, Any]) -> Dict[str, Any]:
35
+ return self._http.request("PATCH", f"/empresas/{_id(id)}/webhook", {"webhook": webhook}).get("empresa", {})
36
+
37
+ def eventos_webhook(self, id: str) -> Any:
38
+ return self._http.request("GET", f"/empresas/{_id(id)}/webhook/eventos")
39
+
40
+ def actualizar_eventos_webhook(self, id: str, entorno: str, eventos: List[str]) -> Any:
41
+ return self._http.request(
42
+ "PATCH", f"/empresas/{_id(id)}/webhook/eventos", {"entorno": entorno, "eventos": eventos}
43
+ )
44
+
45
+ def regenerar_token(self, id: str, entorno: str) -> Dict[str, Any]:
46
+ return self._http.request("POST", f"/empresas/{_id(id)}/regenerar-token", {"entorno": entorno}).get(
47
+ "empresa", {}
48
+ )
49
+
50
+ def regenerar_secret_webhook(self, id: str, entorno: str) -> Dict[str, Any]:
51
+ return self._http.request(
52
+ "POST", f"/empresas/{_id(id)}/webhook/regenerar-secret", {"entorno": entorno}
53
+ ).get("empresa", {})
54
+
55
+ def certificado(self, id: str) -> Optional[Dict[str, Any]]:
56
+ certificado = self._http.request("GET", f"/empresas/{_id(id)}/certificado").get("certificado")
57
+ return certificado if isinstance(certificado, dict) else None
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict
4
+ from urllib.parse import quote
5
+
6
+ from .._http import HttpClient
7
+
8
+
9
+ class Establecimientos:
10
+ """Establecimientos (sucursales) de una empresa."""
11
+
12
+ def __init__(self, http: HttpClient) -> None:
13
+ self._http = http
14
+
15
+ def _base(self, empresa_id: str) -> str:
16
+ return f"/empresas/{quote(empresa_id, safe='')}/establecimientos"
17
+
18
+ def listar(self, empresa_id: str) -> Any:
19
+ return self._http.request("GET", self._base(empresa_id))
20
+
21
+ def obtener(self, empresa_id: str, id: str) -> Dict[str, Any]:
22
+ return self._http.request("GET", f"{self._base(empresa_id)}/{quote(id, safe='')}").get("establecimiento", {})
23
+
24
+ def crear(self, empresa_id: str, establecimiento: Dict[str, Any]) -> Dict[str, Any]:
25
+ return self._http.request("POST", self._base(empresa_id), {"establecimiento": establecimiento}).get(
26
+ "establecimiento", {}
27
+ )
28
+
29
+ def actualizar(self, empresa_id: str, id: str, establecimiento: Dict[str, Any]) -> Dict[str, Any]:
30
+ return self._http.request(
31
+ "PATCH", f"{self._base(empresa_id)}/{quote(id, safe='')}", {"establecimiento": establecimiento}
32
+ ).get("establecimiento", {})
33
+
34
+ def eliminar(self, empresa_id: str, id: str) -> None:
35
+ self._http.request("DELETE", f"{self._base(empresa_id)}/{quote(id, safe='')}")
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Optional
4
+ from urllib.parse import quote
5
+
6
+ from .._http import HttpClient
7
+
8
+ _FORMATOS = ("pdf", "xml", "cdr")
9
+
10
+
11
+ class Guias:
12
+ """Guías de remisión remitente (09) y transportista (31)."""
13
+
14
+ def __init__(self, http: HttpClient) -> None:
15
+ self._http = http
16
+
17
+ def enviar(
18
+ self, guia: Dict[str, Any], *, webhook: bool = False, idempotency_key: Optional[str] = None
19
+ ) -> Dict[str, Any]:
20
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
21
+ return self._http.request("POST", "/guias/enviar", {"webhook": webhook, "guia": guia}, headers=headers)
22
+
23
+ def consultar(self, id: str) -> Dict[str, Any]:
24
+ return self._http.request("GET", f"/guias/{quote(id, safe='')}")
25
+
26
+ def consultar_ticket(self, ticket: str) -> Dict[str, Any]:
27
+ return self._http.request("POST", "/guias/ticket", {"ticket": ticket})
28
+
29
+ def eventos(self, id: str) -> Any:
30
+ return self._http.request("GET", f"/guias/{quote(id, safe='')}/eventos")
31
+
32
+ def listar(
33
+ self,
34
+ *,
35
+ pagina: Optional[int] = None,
36
+ limite: Optional[int] = None,
37
+ fecha_inicio: Optional[str] = None,
38
+ fecha_fin: Optional[str] = None,
39
+ estado: Optional[str] = None,
40
+ buscar: Optional[str] = None,
41
+ ) -> Any:
42
+ return self._http.request(
43
+ "GET",
44
+ "/empresas/guias",
45
+ query={
46
+ "page": pagina,
47
+ "limite": limite,
48
+ "fecha_inicio": fecha_inicio,
49
+ "fecha_fin": fecha_fin,
50
+ "estado": estado,
51
+ "buscar": buscar,
52
+ },
53
+ )
54
+
55
+ def url_archivo(self, id: str, formato: str = "pdf") -> str:
56
+ if formato not in _FORMATOS:
57
+ raise ValueError("Formato no válido. Usa pdf, xml o cdr.")
58
+ return self._http.file_url(f"/guias/{quote(id, safe='')}.{formato}")
59
+
60
+ def descargar(self, id: str, formato: str = "pdf") -> bytes:
61
+ return self._http.download(self.url_archivo(id, formato))
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Mapping, Optional
4
+ from urllib.parse import quote
5
+
6
+ from .._http import HttpClient
7
+
8
+
9
+ class LibroSire:
10
+ """Registro de Ventas (RVIE) o de Compras (RCE) del SIRE."""
11
+
12
+ def __init__(self, http: HttpClient, libro: str) -> None:
13
+ self._http = http
14
+ self._libro = libro
15
+
16
+ def _path(self, periodo: str, accion: str) -> str:
17
+ return f"/sire/{self._libro}/{quote(periodo, safe='')}/{accion}"
18
+
19
+ def periodos(self) -> Any:
20
+ return self._http.request("GET", f"/sire/{self._libro}/periodos")
21
+
22
+ def resumen(self, periodo: str, query: Optional[Mapping[str, Any]] = None) -> Any:
23
+ return self._http.request("GET", self._path(periodo, "resumen"), query=query)
24
+
25
+ def resumen_comprobantes(self, periodo: str, query: Optional[Mapping[str, Any]] = None) -> Any:
26
+ return self._http.request("GET", self._path(periodo, "resumen-comprobantes"), query=query)
27
+
28
+ def comprobantes(self, periodo: str, query: Optional[Mapping[str, Any]] = None) -> Any:
29
+ return self._http.request("GET", self._path(periodo, "comprobantes"), query=query)
30
+
31
+ def inconsistencias(self, periodo: str, query: Optional[Mapping[str, Any]] = None) -> Any:
32
+ return self._http.request("GET", self._path(periodo, "inconsistencias"), query=query)
33
+
34
+ def inconsistencia_por_comprobante(self, periodo: str, query: Mapping[str, Any]) -> Any:
35
+ return self._http.request("GET", self._path(periodo, "inconsistencia-por-comprobante"), query=query)
36
+
37
+ def aceptar_propuesta(self, periodo: str, body: Optional[Dict[str, Any]] = None) -> Any:
38
+ return self._http.request("POST", self._path(periodo, "aceptar-propuesta"), body or {})
39
+
40
+ def exportar(self, periodo: str, query: Optional[Mapping[str, Any]] = None) -> Any:
41
+ return self._http.request("GET", self._path(periodo, "exportar"), query=query)
42
+
43
+ def descargar_ticket(self, num_ticket: str) -> Any:
44
+ return self._http.request("GET", f"/sire/{self._libro}/tickets/{quote(num_ticket, safe='')}/descargar")
45
+
46
+ def preliminares(self, periodo: str, query: Optional[Mapping[str, Any]] = None) -> Any:
47
+ return self._http.request("GET", self._path(periodo, "preliminares"), query=query)
48
+
49
+ def eliminar_preliminar(self, periodo: str, body: Dict[str, Any]) -> Any:
50
+ return self._http.request("PUT", self._path(periodo, "eliminar-preliminar"), body)
51
+
52
+ def eliminar_preliminar_registrado(self, periodo: str, body: Dict[str, Any]) -> Any:
53
+ return self._http.request("PUT", self._path(periodo, "eliminar-preliminar-registrado"), body)
54
+
55
+ def conciliar(self, periodo: str, body: Optional[Dict[str, Any]] = None) -> Any:
56
+ return self._http.request("POST", self._path(periodo, "conciliar"), body or {})
57
+
58
+
59
+ class Sire:
60
+ """SIRE: `sire.ventas` (RVIE) y `sire.compras` (RCE)."""
61
+
62
+ def __init__(self, http: HttpClient) -> None:
63
+ self._http = http
64
+ self.ventas = LibroSire(http, "ventas")
65
+ self.compras = LibroSire(http, "compras")
66
+
67
+ def conciliacion(self, conciliacion_id: str) -> Any:
68
+ return self._http.request("GET", f"/sire/conciliaciones/{quote(conciliacion_id, safe='')}")
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Optional
4
+ from urllib.parse import quote
5
+
6
+ from .._http import HttpClient
7
+
8
+
9
+ class Webhooks:
10
+ """Webhooks enviados por Facturalo a tu servidor."""
11
+
12
+ def __init__(self, http: HttpClient) -> None:
13
+ self._http = http
14
+
15
+ def listar(
16
+ self,
17
+ *,
18
+ pagina: Optional[int] = None,
19
+ limite: Optional[int] = None,
20
+ estado: Optional[str] = None,
21
+ evento: Optional[str] = None,
22
+ ) -> Any:
23
+ return self._http.request(
24
+ "GET", "/webhooks", query={"page": pagina, "limite": limite, "estado": estado, "evento": evento}
25
+ )
26
+
27
+ def reenviar(self, evento_id: str) -> Dict[str, Any]:
28
+ return self._http.request("POST", f"/webhooks/{quote(evento_id, safe='')}/reenviar")
@@ -0,0 +1,43 @@
1
+ """Verificación de webhooks: la firma es HMAC-SHA256(secret, cuerpo crudo) en hexadecimal, en X-Facturalo-Firma."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ from typing import Any, Dict, Optional, Union
9
+
10
+ from .errors import FacturaloSignatureVerificationError
11
+
12
+ Payload = Union[str, bytes]
13
+
14
+
15
+ def verificar_firma(payload: Payload, firma: Optional[str], secret: str) -> None:
16
+ """Recalcula la firma y la compara en tiempo constante con el header X-Facturalo-Firma.
17
+
18
+ Usa el cuerpo crudo tal como llegó (`request.body` en Django, `await request.body()` en FastAPI,
19
+ `request.get_data()` en Flask), nunca el JSON ya decodificado.
20
+
21
+ :param payload: Cuerpo crudo del request.
22
+ :param firma: Valor del header X-Facturalo-Firma.
23
+ :param secret: El secreto del webhook de la empresa (whsec_...), distinto por entorno.
24
+ :raises FacturaloSignatureVerificationError: si falta la firma o no coincide.
25
+ """
26
+ if firma is None or not firma.strip():
27
+ raise FacturaloSignatureVerificationError("Falta el header X-Facturalo-Firma.")
28
+ cuerpo = payload.encode("utf-8") if isinstance(payload, str) else payload
29
+ esperada = hmac.new(secret.encode("utf-8"), cuerpo, hashlib.sha256).hexdigest()
30
+ if not hmac.compare_digest(esperada, firma.strip().lower()):
31
+ raise FacturaloSignatureVerificationError("La firma del webhook no coincide.")
32
+
33
+
34
+ def construir_evento(payload: Payload, firma: Optional[str], secret: str) -> Dict[str, Any]:
35
+ """Verifica la firma y devuelve el evento decodificado (id, evento, timestamp, entorno, documento, sunat...)."""
36
+ verificar_firma(payload, firma, secret)
37
+ try:
38
+ evento = json.loads(payload)
39
+ except ValueError as e:
40
+ raise FacturaloSignatureVerificationError("El cuerpo del webhook no es JSON válido.") from e
41
+ if not isinstance(evento, dict):
42
+ raise FacturaloSignatureVerificationError("El cuerpo del webhook no es JSON válido.")
43
+ return evento
@@ -0,0 +1,109 @@
1
+ import json
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from facturalo import (
7
+ FacturaloClient,
8
+ FacturaloCuentaClient,
9
+ FacturaloNotFoundError,
10
+ FacturaloValidationError,
11
+ )
12
+
13
+
14
+ def cliente(handler, token="sk_test_abc"):
15
+ return FacturaloClient(token, http_client=httpx.Client(transport=httpx.MockTransport(handler)))
16
+
17
+
18
+ def ok(data, status=200):
19
+ return httpx.Response(status, json={"success": True, "message": "ok", "data": data})
20
+
21
+
22
+ def test_token_de_empresa_obligatorio():
23
+ with pytest.raises(ValueError):
24
+ FacturaloClient("user_sk_abc")
25
+ with pytest.raises(ValueError):
26
+ FacturaloCuentaClient("sk_test_abc")
27
+
28
+
29
+ def test_entorno_segun_token():
30
+ assert FacturaloClient("sk_test_x").entorno == "demo"
31
+ assert FacturaloClient("sk_live_x").entorno == "prod"
32
+
33
+
34
+ def test_enviar_documento_sin_bearer_y_con_idempotencia():
35
+ visto = {}
36
+
37
+ def handler(request: httpx.Request):
38
+ visto["auth"] = request.headers["Authorization"]
39
+ visto["idem"] = request.headers.get("Idempotency-Key")
40
+ visto["url"] = str(request.url)
41
+ visto["body"] = json.loads(request.content)
42
+ return ok({"estado": "ACEPTADA", "enlaces": {"pdf": "https://x/doc.pdf"}})
43
+
44
+ r = cliente(handler).documentos.enviar({"serie": "F001"}, idempotency_key="pedido-1")
45
+ assert r["estado"] == "ACEPTADA"
46
+ assert visto["auth"] == "sk_test_abc"
47
+ assert visto["idem"] == "pedido-1"
48
+ assert visto["url"] == "https://api.facturalo.pe/api/v3/documentos/enviar"
49
+ assert visto["body"] == {"webhook": False, "documento": {"serie": "F001"}}
50
+
51
+
52
+ def test_rechazo_sunat_no_es_excepcion():
53
+ r = cliente(lambda req: ok({"estado": "RECHAZADA", "sunat": {"codigo": "2800"}})).documentos.enviar({})
54
+ assert r["estado"] == "RECHAZADA"
55
+
56
+
57
+ def test_errores_mapean_por_status():
58
+ def handler(request):
59
+ return httpx.Response(422, json={"success": False, "message": "Datos inválidos", "errors": {"serie": ["requerido"]}})
60
+
61
+ with pytest.raises(FacturaloValidationError) as e:
62
+ cliente(handler).documentos.enviar({})
63
+ assert e.value.status == 422
64
+ assert e.value.errors == {"serie": ["requerido"]}
65
+
66
+ with pytest.raises(FacturaloNotFoundError):
67
+ cliente(lambda req: httpx.Response(404, json={"message": "No existe"})).documentos.consultar("x")
68
+
69
+
70
+ def test_post_sin_idempotencia_no_se_reintenta():
71
+ llamadas = []
72
+
73
+ def handler(request):
74
+ llamadas.append(1)
75
+ return httpx.Response(503, json={"message": "caído"})
76
+
77
+ with pytest.raises(Exception):
78
+ cliente(handler).documentos.enviar({})
79
+ assert len(llamadas) == 1
80
+
81
+
82
+ def test_get_se_reintenta(monkeypatch):
83
+ monkeypatch.setattr("facturalo._http.time.sleep", lambda s: None)
84
+ llamadas = []
85
+
86
+ def handler(request):
87
+ llamadas.append(1)
88
+ return httpx.Response(503) if len(llamadas) < 3 else ok({"compra": 3.45})
89
+
90
+ assert cliente(handler).consultas.tipo_cambio("2026-10-30") == {"compra": 3.45}
91
+ assert len(llamadas) == 3
92
+
93
+
94
+ def test_query_sin_vacios():
95
+ visto = {}
96
+
97
+ def handler(request):
98
+ visto["params"] = dict(request.url.params)
99
+ return ok([])
100
+
101
+ cliente(handler).documentos.listar(pagina=2, estado="ACEPTADA")
102
+ assert visto["params"] == {"page": "2", "estado": "ACEPTADA"}
103
+
104
+
105
+ def test_url_archivo():
106
+ c = cliente(lambda req: ok({}))
107
+ assert c.documentos.url_archivo("abc", "ticket") == "https://api.facturalo.pe/api/v3/documentos/abc.ticket.pdf"
108
+ with pytest.raises(ValueError):
109
+ c.documentos.url_archivo("abc", "zip")
@@ -0,0 +1,22 @@
1
+ import hashlib
2
+ import hmac
3
+
4
+ import pytest
5
+
6
+ from facturalo import FacturaloSignatureVerificationError, construir_evento, verificar_firma
7
+
8
+ SECRET = "whsec_prueba"
9
+ CUERPO = b'{"id":"evt_1","evento":"documento.sunat.respuesta"}'
10
+ FIRMA = hmac.new(SECRET.encode(), CUERPO, hashlib.sha256).hexdigest()
11
+
12
+
13
+ def test_firma_valida():
14
+ assert construir_evento(CUERPO, FIRMA, SECRET)["evento"] == "documento.sunat.respuesta"
15
+ verificar_firma(CUERPO.decode(), FIRMA.upper(), SECRET)
16
+
17
+
18
+ def test_firma_invalida_o_ausente():
19
+ with pytest.raises(FacturaloSignatureVerificationError):
20
+ verificar_firma(CUERPO, "00" * 32, SECRET)
21
+ with pytest.raises(FacturaloSignatureVerificationError):
22
+ verificar_firma(CUERPO, None, SECRET)