vektra-erp 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Vektra Digital Solutions S.A.S.
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,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: vektra-erp
3
+ Version: 1.0.0
4
+ Summary: SDK de Python de la API v1 de Vektra ERP (facturación electrónica con ARCA), generado desde el OpenAPI.
5
+ License: MIT
6
+ Project-URL: Homepage, https://vektra.digital/api-factura-electronica-arca
7
+ Keywords: vektra,erp,arca,afip,factura-electronica,api,sdk
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Natural Language :: Spanish
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Dynamic: license-file
18
+
19
+ # SDK de Python — API v1 de Vektra ERP
20
+
21
+ Cliente de la API v1 generado desde el OpenAPI (`/api/v1/openapi.json`), con type hints (`TypedDict` para los cuerpos). **Sin dependencias**: usa `urllib` de la biblioteca estándar. Python 3.9 o más.
22
+
23
+ > **Estado:** vive en el repositorio (`sdk/python/`); todavía no está publicado en PyPI. Para usarlo, copiá la carpeta `vektra_erp/` a tu proyecto o instalalo desde el repo (`pip install ./sdk/python`).
24
+ > `tipos.py` y `operaciones.py` son **generados**: no se editan, se regeneran con `npm run sdk:generar` (desde la raíz del repo). El resto (`cliente.py`, `errores.py`) es código a mano.
25
+
26
+ ## Empezar
27
+
28
+ ```python
29
+ import os
30
+ from vektra_erp import ClienteVektra
31
+
32
+ vektra = ClienteVektra(
33
+ clave=os.environ["VEKTRA_CLAVE"], # erp_... (producción) o erp_test_... (sandbox)
34
+ base_url="https://tuempresa.erp.vektra.digital", # la dirección de tu instalación
35
+ )
36
+ ```
37
+
38
+ Producción y sandbox usan **la misma dirección**; lo que cambia es la clave. Con una clave `erp_test_...` (`vektra.es_sandbox`) las facturas son simuladas: nada llega a ARCA ni a tu contabilidad.
39
+
40
+ Opciones: `max_reintentos` (429; 2 por defecto), `espera_maxima` (60 s), `timeout` (30 s).
41
+
42
+ ## Emitir una factura (con idempotencia)
43
+
44
+ ```python
45
+ from vektra_erp import nueva_idempotency_key
46
+
47
+ # Guardá la clave junto a tu pedido: si se corta, repetís con la MISMA clave y no se factura dos veces.
48
+ idempotency_key = nueva_idempotency_key()
49
+
50
+ factura = vektra.emitir_factura(
51
+ {
52
+ "referencia": "pedido-1001",
53
+ "cliente": {"documento": "30715974823", "razonSocial": "Cliente de ejemplo S.A.", "condicionIva": 1},
54
+ "puntoVenta": 1,
55
+ "renglones": [{"descripcion": "Servicio mensual", "cantidad": 1, "precioUnitario": 1000, "iva": 21}],
56
+ },
57
+ idempotency_key=idempotency_key,
58
+ )
59
+ ```
60
+
61
+ Repetir el pedido con la misma `Idempotency-Key` y el mismo cuerpo devuelve la misma factura; con otro cuerpo falla con `clave_reutilizada`.
62
+
63
+ ## Errores
64
+
65
+ Todo error de la API se lanza como `ErrorApi` (formato estable `{error, code, campo, request_id}`); un corte de red, como `ErrorDeRed`. Ambas heredan de `ErrorVektra`.
66
+
67
+ ```python
68
+ from vektra_erp import ErrorApi, ErrorDeRed
69
+
70
+ try:
71
+ vektra.emitir_factura(cuerpo, idempotency_key=idempotency_key)
72
+ except ErrorApi as e:
73
+ print(e.status, e.code, e.message, e.campo, e.request_id) # 422 validacion «...» renglones.0.iva req_...
74
+ except ErrorDeRed:
75
+ # No sabés si el servidor llegó a procesar el pedido: repetí con la misma idempotency_key.
76
+ raise
77
+ ```
78
+
79
+ ### 422 con `pendiente: true` (ARCA no confirmó)
80
+
81
+ Si ARCA no contesta a tiempo, la API responde 422 `pendiente_arca` con `pendiente: true`: el comprobante **puede haberse autorizado igual**. No lo repitas con otra clave ni sin `referencia`: usá la misma `idempotency_key` o consultá la factura.
82
+
83
+ ```python
84
+ try:
85
+ vektra.emitir_factura(cuerpo, idempotency_key=idempotency_key)
86
+ except ErrorApi as e:
87
+ if e.pendiente:
88
+ # e.datos trae el comprobante tal como quedó (con su id); e.request_id sirve para soporte.
89
+ quedo = vektra.listar_facturas(referencia="pedido-1001")
90
+ else:
91
+ raise
92
+ ```
93
+
94
+ ## Reintentos ante 429
95
+
96
+ Un 429 (`limite_pedidos`) se reintenta solo, esperando los segundos de `Retry-After`. Si pide esperar más de `espera_maxima`, o se agotan los reintentos, se lanza `ErrorApi` (`e.retry_after` trae los segundos). El 503 `servicio_no_disponible` no se reintenta solo.
97
+
98
+ ## Más ejemplos
99
+
100
+ ```python
101
+ pagina = vektra.listar_facturas(desde="2026-01-01", hasta="2026-01-31", estado="autorizado", limit=50)
102
+ pdf = vektra.descargar_pdf_factura(factura["id"]) # bytes
103
+ ```
104
+
105
+ Los métodos van en snake_case (`listar_clientes`, `crear_punto_venta`, `obtener_cotizacion`...). Los parámetros de ruta son posicionales, el cuerpo va después y los filtros de consulta, por nombre. Los objetos anidados sin nombre en el OpenAPI se tipan como `Dict[str, Any]`.
106
+
107
+ ## Pruebas
108
+
109
+ ```bash
110
+ cd sdk/python && python3 -m unittest discover -s tests -t .
111
+ ```
112
+
113
+ `src/app/api/v1/sdk.test.ts` (Vitest) falla si lo generado no coincide con el OpenAPI actual y corre estas pruebas si hay `python3`.
@@ -0,0 +1,95 @@
1
+ # SDK de Python — API v1 de Vektra ERP
2
+
3
+ Cliente de la API v1 generado desde el OpenAPI (`/api/v1/openapi.json`), con type hints (`TypedDict` para los cuerpos). **Sin dependencias**: usa `urllib` de la biblioteca estándar. Python 3.9 o más.
4
+
5
+ > **Estado:** vive en el repositorio (`sdk/python/`); todavía no está publicado en PyPI. Para usarlo, copiá la carpeta `vektra_erp/` a tu proyecto o instalalo desde el repo (`pip install ./sdk/python`).
6
+ > `tipos.py` y `operaciones.py` son **generados**: no se editan, se regeneran con `npm run sdk:generar` (desde la raíz del repo). El resto (`cliente.py`, `errores.py`) es código a mano.
7
+
8
+ ## Empezar
9
+
10
+ ```python
11
+ import os
12
+ from vektra_erp import ClienteVektra
13
+
14
+ vektra = ClienteVektra(
15
+ clave=os.environ["VEKTRA_CLAVE"], # erp_... (producción) o erp_test_... (sandbox)
16
+ base_url="https://tuempresa.erp.vektra.digital", # la dirección de tu instalación
17
+ )
18
+ ```
19
+
20
+ Producción y sandbox usan **la misma dirección**; lo que cambia es la clave. Con una clave `erp_test_...` (`vektra.es_sandbox`) las facturas son simuladas: nada llega a ARCA ni a tu contabilidad.
21
+
22
+ Opciones: `max_reintentos` (429; 2 por defecto), `espera_maxima` (60 s), `timeout` (30 s).
23
+
24
+ ## Emitir una factura (con idempotencia)
25
+
26
+ ```python
27
+ from vektra_erp import nueva_idempotency_key
28
+
29
+ # Guardá la clave junto a tu pedido: si se corta, repetís con la MISMA clave y no se factura dos veces.
30
+ idempotency_key = nueva_idempotency_key()
31
+
32
+ factura = vektra.emitir_factura(
33
+ {
34
+ "referencia": "pedido-1001",
35
+ "cliente": {"documento": "30715974823", "razonSocial": "Cliente de ejemplo S.A.", "condicionIva": 1},
36
+ "puntoVenta": 1,
37
+ "renglones": [{"descripcion": "Servicio mensual", "cantidad": 1, "precioUnitario": 1000, "iva": 21}],
38
+ },
39
+ idempotency_key=idempotency_key,
40
+ )
41
+ ```
42
+
43
+ Repetir el pedido con la misma `Idempotency-Key` y el mismo cuerpo devuelve la misma factura; con otro cuerpo falla con `clave_reutilizada`.
44
+
45
+ ## Errores
46
+
47
+ Todo error de la API se lanza como `ErrorApi` (formato estable `{error, code, campo, request_id}`); un corte de red, como `ErrorDeRed`. Ambas heredan de `ErrorVektra`.
48
+
49
+ ```python
50
+ from vektra_erp import ErrorApi, ErrorDeRed
51
+
52
+ try:
53
+ vektra.emitir_factura(cuerpo, idempotency_key=idempotency_key)
54
+ except ErrorApi as e:
55
+ print(e.status, e.code, e.message, e.campo, e.request_id) # 422 validacion «...» renglones.0.iva req_...
56
+ except ErrorDeRed:
57
+ # No sabés si el servidor llegó a procesar el pedido: repetí con la misma idempotency_key.
58
+ raise
59
+ ```
60
+
61
+ ### 422 con `pendiente: true` (ARCA no confirmó)
62
+
63
+ Si ARCA no contesta a tiempo, la API responde 422 `pendiente_arca` con `pendiente: true`: el comprobante **puede haberse autorizado igual**. No lo repitas con otra clave ni sin `referencia`: usá la misma `idempotency_key` o consultá la factura.
64
+
65
+ ```python
66
+ try:
67
+ vektra.emitir_factura(cuerpo, idempotency_key=idempotency_key)
68
+ except ErrorApi as e:
69
+ if e.pendiente:
70
+ # e.datos trae el comprobante tal como quedó (con su id); e.request_id sirve para soporte.
71
+ quedo = vektra.listar_facturas(referencia="pedido-1001")
72
+ else:
73
+ raise
74
+ ```
75
+
76
+ ## Reintentos ante 429
77
+
78
+ Un 429 (`limite_pedidos`) se reintenta solo, esperando los segundos de `Retry-After`. Si pide esperar más de `espera_maxima`, o se agotan los reintentos, se lanza `ErrorApi` (`e.retry_after` trae los segundos). El 503 `servicio_no_disponible` no se reintenta solo.
79
+
80
+ ## Más ejemplos
81
+
82
+ ```python
83
+ pagina = vektra.listar_facturas(desde="2026-01-01", hasta="2026-01-31", estado="autorizado", limit=50)
84
+ pdf = vektra.descargar_pdf_factura(factura["id"]) # bytes
85
+ ```
86
+
87
+ Los métodos van en snake_case (`listar_clientes`, `crear_punto_venta`, `obtener_cotizacion`...). Los parámetros de ruta son posicionales, el cuerpo va después y los filtros de consulta, por nombre. Los objetos anidados sin nombre en el OpenAPI se tipan como `Dict[str, Any]`.
88
+
89
+ ## Pruebas
90
+
91
+ ```bash
92
+ cd sdk/python && python3 -m unittest discover -s tests -t .
93
+ ```
94
+
95
+ `src/app/api/v1/sdk.test.ts` (Vitest) falla si lo generado no coincide con el OpenAPI actual y corre estas pruebas si hay `python3`.
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "vektra-erp"
7
+ version = "1.0.0"
8
+ description = "SDK de Python de la API v1 de Vektra ERP (facturación electrónica con ARCA), generado desde el OpenAPI."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ dependencies = []
13
+ keywords = ["vektra", "erp", "arca", "afip", "factura-electronica", "api", "sdk"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Natural Language :: Spanish",
20
+ "Typing :: Typed",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://vektra.digital/api-factura-electronica-arca"
25
+
26
+ [tool.setuptools]
27
+ packages = ["vektra_erp"]
28
+
29
+ [tool.setuptools.package-data]
30
+ vektra_erp = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,78 @@
1
+ """Pruebas del SDK de Python con un transporte simulado (sin red). Se corren con: python -m unittest discover -s tests"""
2
+
3
+ import json
4
+ import unittest
5
+
6
+ from vektra_erp import ClienteVektra, ErrorApi
7
+
8
+
9
+ def respuesta(status, cuerpo=None, **cabeceras):
10
+ datos = b"" if cuerpo is None else json.dumps(cuerpo).encode("utf-8")
11
+ return status, {k.lower().replace("_", "-"): str(v) for k, v in cabeceras.items()}, datos
12
+
13
+
14
+ class Simulado:
15
+ def __init__(self, *respuestas):
16
+ self.respuestas = list(respuestas)
17
+ self.pedidos = []
18
+
19
+ def __call__(self, metodo, url, cabeceras, cuerpo, timeout):
20
+ self.pedidos.append((metodo, url, cabeceras, cuerpo))
21
+ return self.respuestas.pop(0)
22
+
23
+
24
+ def cliente(transporte, **kw):
25
+ return ClienteVektra("erp_abc_secreto", "https://erp.ejemplo.com/", transporte=transporte, dormir=lambda s: None, **kw)
26
+
27
+
28
+ class PruebasCliente(unittest.TestCase):
29
+ def test_emite_una_factura_con_idempotency_key(self):
30
+ t = Simulado(respuesta(201, {"id": "f1", "estado": "autorizado"}))
31
+ f = cliente(t).emitir_factura({"cliente": {"documento": "30715974823"}, "renglones": []}, idempotency_key="k-1")
32
+ self.assertEqual(f["id"], "f1")
33
+ metodo, url, cab, cuerpo = t.pedidos[0]
34
+ self.assertEqual((metodo, url), ("POST", "https://erp.ejemplo.com/api/v1/facturas"))
35
+ self.assertEqual(cab["Authorization"], "Bearer erp_abc_secreto")
36
+ self.assertEqual(cab["Idempotency-Key"], "k-1")
37
+ self.assertEqual(json.loads(cuerpo)["cliente"]["documento"], "30715974823")
38
+
39
+ def test_consulta_y_ruta(self):
40
+ t = Simulado(respuesta(200, {"paging": {}, "resultados": []}), respuesta(200, {"id": "x"}))
41
+ c = cliente(t)
42
+ c.listar_facturas(punto_venta=2, estado="autorizado", cae=None)
43
+ c.obtener_factura("a b")
44
+ self.assertEqual(t.pedidos[0][1], "https://erp.ejemplo.com/api/v1/facturas?estado=autorizado&puntoVenta=2")
45
+ self.assertTrue(t.pedidos[1][1].endswith("/api/v1/facturas/a%20b"))
46
+
47
+ def test_422_pendiente(self):
48
+ cuerpo = {"error": "ARCA no confirmó", "code": "pendiente_arca", "request_id": "req_1", "pendiente": True, "id": "f9"}
49
+ with self.assertRaises(ErrorApi) as ctx:
50
+ cliente(Simulado(respuesta(422, cuerpo))).emitir_factura({"cliente": {}, "renglones": []})
51
+ e = ctx.exception
52
+ self.assertEqual((e.status, e.code, e.request_id, e.pendiente), (422, "pendiente_arca", "req_1", True))
53
+ self.assertEqual(e.datos["id"], "f9")
54
+
55
+ def test_reintenta_el_429_con_retry_after(self):
56
+ esperas = []
57
+ t = Simulado(respuesta(429, {"error": "x", "code": "limite_pedidos"}, Retry_After=3), respuesta(200, {"paging": {}}))
58
+ c = ClienteVektra("erp_abc_x", "https://e.com", transporte=t, dormir=esperas.append)
59
+ c.listar_facturas()
60
+ self.assertEqual((len(t.pedidos), esperas), (2, [3.0]))
61
+
62
+ def test_429_sin_reintentos_restantes_falla(self):
63
+ t = Simulado(*[respuesta(429, {"error": "x", "code": "limite_pedidos"}, Retry_After=1)] * 3)
64
+ with self.assertRaises(ErrorApi) as ctx:
65
+ cliente(t).listar_facturas()
66
+ self.assertEqual((ctx.exception.code, len(t.pedidos)), ("limite_pedidos", 3))
67
+
68
+ def test_sandbox(self):
69
+ self.assertTrue(ClienteVektra("erp_test_abc_x", "https://e.com").es_sandbox)
70
+ self.assertFalse(ClienteVektra("erp_abc_x", "https://e.com").es_sandbox)
71
+
72
+ def test_pdf_devuelve_bytes(self):
73
+ t = Simulado((200, {}, b"%PDF-1.4"))
74
+ self.assertEqual(cliente(t).descargar_pdf_factura("f1"), b"%PDF-1.4")
75
+
76
+
77
+ if __name__ == "__main__":
78
+ unittest.main()
@@ -0,0 +1,7 @@
1
+ """SDK de Python de la API v1 de Vektra ERP (generado desde el OpenAPI)."""
2
+
3
+ from . import tipos
4
+ from .cliente import ClienteVektra, nueva_idempotency_key
5
+ from .errores import ErrorApi, ErrorDeRed, ErrorVektra
6
+
7
+ __all__ = ["ClienteVektra", "ErrorApi", "ErrorDeRed", "ErrorVektra", "nueva_idempotency_key", "tipos"]
@@ -0,0 +1,160 @@
1
+ """Cliente HTTP de la API v1 de Vektra ERP, sobre ``urllib`` (sin dependencias)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ import uuid
11
+ from email.utils import parsedate_to_datetime
12
+ from typing import Any, Callable, Dict, Optional, Tuple
13
+
14
+ from .errores import ErrorApi, ErrorDeRed
15
+ from .operaciones import OperacionesApi
16
+
17
+ PREFIJO_SANDBOX = "erp_test_"
18
+
19
+ #: ``(status, cabeceras, cuerpo)``; las cabeceras con las claves en minúscula.
20
+ Respuesta = Tuple[int, Dict[str, str], bytes]
21
+ #: ``(metodo, url, cabeceras, cuerpo, timeout) -> Respuesta``. Se puede reemplazar (pruebas, proxies).
22
+ Transporte = Callable[[str, str, Dict[str, str], Optional[bytes], Optional[float]], Respuesta]
23
+
24
+
25
+ def nueva_idempotency_key() -> str:
26
+ """Una ``Idempotency-Key`` nueva (UUID v4). Guardala junto al pedido para poder repetirlo sin duplicarlo."""
27
+ return str(uuid.uuid4())
28
+
29
+
30
+ def _transporte_urllib(
31
+ metodo: str, url: str, cabeceras: Dict[str, str], cuerpo: Optional[bytes], timeout: Optional[float]
32
+ ) -> Respuesta:
33
+ pedido = urllib.request.Request(url, data=cuerpo, headers=cabeceras, method=metodo)
34
+ try:
35
+ with urllib.request.urlopen(pedido, timeout=timeout) as r: # noqa: S310 (la URL la arma el cliente con http/https)
36
+ return r.status, {k.lower(): v for k, v in r.headers.items()}, r.read()
37
+ except urllib.error.HTTPError as e:
38
+ return e.code, {k.lower(): v for k, v in e.headers.items()}, e.read()
39
+ except (urllib.error.URLError, TimeoutError, OSError) as e:
40
+ raise ErrorDeRed(f"No se pudo completar el pedido {metodo} {url}: {e}") from e
41
+
42
+
43
+ def _leer_retry_after(valor: Optional[str]) -> Optional[float]:
44
+ """Segundos de un ``Retry-After`` (número o fecha HTTP), o ``None`` si no vino o no se entiende."""
45
+ if not valor:
46
+ return None
47
+ try:
48
+ segundos = float(valor)
49
+ return segundos if segundos >= 0 else None
50
+ except ValueError:
51
+ pass
52
+ try:
53
+ return max(0.0, parsedate_to_datetime(valor).timestamp() - time.time())
54
+ except (TypeError, ValueError):
55
+ return None
56
+
57
+
58
+ class ClienteVektra(OperacionesApi):
59
+ """Cliente de la API v1. Los métodos (``emitir_factura``, ``listar_clientes``...) salen del OpenAPI.
60
+
61
+ Los errores de la API se lanzan como :class:`ErrorApi`; un 429 se reintenta solo, esperando lo que pide ``Retry-After``.
62
+
63
+ :param clave: la clave de la API (``erp_...`` de producción, ``erp_test_...`` de sandbox).
64
+ :param base_url: la dirección de tu instalación. Producción y sandbox usan la misma.
65
+ :param max_reintentos: reintentos ante 429 (por defecto 2; 0 los desactiva).
66
+ :param espera_maxima: tope de la espera entre reintentos, en segundos; si el servidor pide más, falla con el 429.
67
+ :param timeout: tiempo máximo de cada pedido, en segundos (``None``: sin límite).
68
+ :param transporte: reemplaza el envío HTTP (pruebas).
69
+ :param dormir: reemplaza la espera (pruebas).
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ clave: str,
75
+ base_url: str,
76
+ *,
77
+ max_reintentos: int = 2,
78
+ espera_maxima: float = 60.0,
79
+ timeout: Optional[float] = 30.0,
80
+ transporte: Optional[Transporte] = None,
81
+ dormir: Callable[[float], None] = time.sleep,
82
+ ) -> None:
83
+ if not clave:
84
+ raise ValueError("Falta la clave de la API (erp_... o erp_test_...).")
85
+ if not base_url.startswith(("http://", "https://")):
86
+ raise ValueError("base_url tiene que empezar con http:// o https://.")
87
+ self._clave = clave
88
+ self.base_url = base_url.rstrip("/")
89
+ #: La clave es de prueba: las facturas son simuladas, nada llega a ARCA ni a la contabilidad.
90
+ self.es_sandbox = clave.startswith(PREFIJO_SANDBOX)
91
+ self._max_reintentos = max_reintentos
92
+ self._espera_maxima = espera_maxima
93
+ self._timeout = timeout
94
+ self._transporte = transporte or _transporte_urllib
95
+ self._dormir = dormir
96
+
97
+ def _url(self, ruta: str, en_ruta: Optional[Dict[str, Any]], consulta: Optional[Dict[str, Any]]) -> str:
98
+ valores = en_ruta or {}
99
+ for nombre, valor in valores.items():
100
+ if valor is None:
101
+ raise ValueError(f"Falta el parámetro de ruta «{nombre}».")
102
+ ruta = ruta.replace("{" + nombre + "}", urllib.parse.quote(str(valor), safe=""))
103
+ pares = []
104
+ for k, v in (consulta or {}).items():
105
+ for x in v if isinstance(v, (list, tuple)) else [v]:
106
+ if x is not None:
107
+ pares.append((k, "true" if x is True else "false" if x is False else str(x)))
108
+ texto = urllib.parse.urlencode(pares)
109
+ return f"{self.base_url}{ruta}{'?' + texto if texto else ''}"
110
+
111
+ def _pedir(
112
+ self,
113
+ metodo: str,
114
+ ruta: str,
115
+ *,
116
+ ruta_params: Optional[Dict[str, Any]] = None,
117
+ consulta: Optional[Dict[str, Any]] = None,
118
+ cabecera: Optional[Dict[str, Any]] = None,
119
+ cuerpo: Any = None,
120
+ respuesta: str = "json",
121
+ idempotency_key: Optional[str] = None,
122
+ ) -> Any:
123
+ cabeceras = {
124
+ "Authorization": f"Bearer {self._clave}",
125
+ "Accept": "*/*" if respuesta == "binario" else "application/json",
126
+ "User-Agent": "vektra-erp-python/1.0",
127
+ }
128
+ for k, v in (cabecera or {}).items():
129
+ if v is not None:
130
+ cabeceras[k] = str(v)
131
+ datos = None if cuerpo is None else json.dumps(cuerpo).encode("utf-8")
132
+ if datos is not None:
133
+ cabeceras["Content-Type"] = "application/json"
134
+ if idempotency_key:
135
+ cabeceras["Idempotency-Key"] = idempotency_key
136
+ url = self._url(ruta, ruta_params, consulta)
137
+
138
+ intento = 0
139
+ while True:
140
+ status, recibidas, bytes_ = self._transporte(metodo, url, cabeceras, datos, self._timeout)
141
+ request_id = recibidas.get("x-request-id")
142
+ retry_after = _leer_retry_after(recibidas.get("retry-after"))
143
+ if status == 429 and intento < self._max_reintentos:
144
+ espera = retry_after if retry_after is not None else float(2**intento)
145
+ if espera <= self._espera_maxima:
146
+ self._dormir(espera)
147
+ intento += 1
148
+ continue
149
+ if status >= 400:
150
+ texto = bytes_.decode("utf-8", errors="replace")
151
+ try:
152
+ cuerpo_error: Any = json.loads(texto) if texto else {}
153
+ except ValueError:
154
+ cuerpo_error = texto
155
+ raise ErrorApi.desde_respuesta(status, cuerpo_error, request_id, retry_after)
156
+ if respuesta == "binario":
157
+ return bytes_
158
+ if respuesta == "vacio" or status == 204 or not bytes_:
159
+ return None
160
+ return json.loads(bytes_.decode("utf-8"))
@@ -0,0 +1,83 @@
1
+ """Errores del SDK.
2
+
3
+ La API responde todo error como ``{ error, code, campo?, request_id }`` (más los datos que la respuesta ya llevaba, por ejemplo la
4
+ factura que quedó pendiente). También se entiende la forma anidada ``{ error: { code, message, field, request_id } }``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Dict, Optional
10
+
11
+
12
+ class ErrorVektra(Exception):
13
+ """Base de todos los errores del SDK."""
14
+
15
+
16
+ class ErrorDeRed(ErrorVektra):
17
+ """No hubo respuesta: corte de red, tiempo agotado o servidor inalcanzable."""
18
+
19
+
20
+ class ErrorApi(ErrorVektra):
21
+ """La API contestó con un error (HTTP 4xx o 5xx)."""
22
+
23
+ def __init__(
24
+ self,
25
+ status: int,
26
+ code: str,
27
+ message: str,
28
+ campo: Optional[str] = None,
29
+ request_id: Optional[str] = None,
30
+ retry_after: Optional[float] = None,
31
+ datos: Optional[Dict[str, Any]] = None,
32
+ ) -> None:
33
+ super().__init__(message)
34
+ self.status = status
35
+ #: Código estable para programar contra él (``validacion``, ``limite_pedidos``...).
36
+ self.code = code
37
+ self.message = message
38
+ #: El campo del pedido que falló (``cliente.documento``), si corresponde.
39
+ self.campo = campo
40
+ #: El ``X-Request-Id`` del pedido: mandalo a soporte.
41
+ self.request_id = request_id
42
+ #: Segundos que pidió esperar el servidor (``Retry-After``), si mandó.
43
+ self.retry_after = retry_after
44
+ #: El cuerpo completo de la respuesta (con la factura en un 422 ``pendiente_arca``).
45
+ self.datos: Dict[str, Any] = datos or {}
46
+
47
+ @property
48
+ def pendiente(self) -> bool:
49
+ """ARCA no confirmó el comprobante (422 ``pendiente_arca``): puede haberse autorizado igual.
50
+
51
+ No lo repitas sin la misma ``referencia`` o ``Idempotency-Key``; ``datos`` trae el comprobante tal como quedó.
52
+ """
53
+ return self.datos.get("pendiente") is True
54
+
55
+ def __str__(self) -> str:
56
+ extra = f" (campo {self.campo})" if self.campo else ""
57
+ return f"[{self.status} {self.code}] {self.message}{extra}"
58
+
59
+ @classmethod
60
+ def desde_respuesta(
61
+ cls, status: int, cuerpo: Any, request_id: Optional[str] = None, retry_after: Optional[float] = None
62
+ ) -> "ErrorApi":
63
+ """Arma el error desde el cuerpo ya leído de una respuesta no exitosa."""
64
+ datos = cuerpo if isinstance(cuerpo, dict) else {}
65
+ anidado = datos.get("error") if isinstance(datos.get("error"), dict) else None
66
+
67
+ def texto(valor: Any) -> Optional[str]:
68
+ return valor if isinstance(valor, str) and valor else None
69
+
70
+ mensaje = (
71
+ texto(anidado.get("message") if anidado else datos.get("error"))
72
+ or (cuerpo if isinstance(cuerpo, str) and cuerpo else None)
73
+ or f"HTTP {status}"
74
+ )
75
+ return cls(
76
+ status=status,
77
+ code=texto(anidado.get("code") if anidado else datos.get("code")) or "error_interno",
78
+ message=mensaje,
79
+ campo=texto(anidado.get("field") if anidado else datos.get("campo")),
80
+ request_id=texto(anidado.get("request_id") if anidado else datos.get("request_id")) or request_id,
81
+ retry_after=retry_after,
82
+ datos=datos,
83
+ )