ecuafact 1.0.0b1__py3-none-any.whl
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.
Potentially problematic release.
This version of ecuafact might be problematic. Click here for more details.
- ecuafact/__init__.py +97 -0
- ecuafact/client.py +436 -0
- ecuafact/contribuyente.py +31 -0
- ecuafact/errors.py +26 -0
- ecuafact/models.py +534 -0
- ecuafact/options.py +65 -0
- ecuafact/webhooks.py +51 -0
- ecuafact-1.0.0b1.dist-info/METADATA +109 -0
- ecuafact-1.0.0b1.dist-info/RECORD +12 -0
- ecuafact-1.0.0b1.dist-info/WHEEL +5 -0
- ecuafact-1.0.0b1.dist-info/licenses/LICENSE +21 -0
- ecuafact-1.0.0b1.dist-info/top_level.txt +1 -0
ecuafact/__init__.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Cliente oficial del API publico Ecuafact (contrato v1)."""
|
|
2
|
+
|
|
3
|
+
from .client import EcuafactClient
|
|
4
|
+
from .contribuyente import EcuafactContribuyente
|
|
5
|
+
from .errors import EcuafactApiException, EcuafactSdkException
|
|
6
|
+
from .models import (
|
|
7
|
+
Admission,
|
|
8
|
+
ApiError,
|
|
9
|
+
BusquedaContribuyente,
|
|
10
|
+
CampoAdicional,
|
|
11
|
+
ClaveAccesoDecodificada,
|
|
12
|
+
Comprobante,
|
|
13
|
+
ComprobanteRequest,
|
|
14
|
+
Compensacion,
|
|
15
|
+
Contexto,
|
|
16
|
+
Contribuyente,
|
|
17
|
+
ContribuyenteConsulta,
|
|
18
|
+
Detalle,
|
|
19
|
+
Destinatario,
|
|
20
|
+
DestinoSustitutiva,
|
|
21
|
+
DocsSustento,
|
|
22
|
+
EmisionResultado,
|
|
23
|
+
EstadoTributario,
|
|
24
|
+
EstablecimientoConsulta,
|
|
25
|
+
EstablecimientosContribuyente,
|
|
26
|
+
Impuesto,
|
|
27
|
+
ImpuestoDocSustento,
|
|
28
|
+
InfoDocumento,
|
|
29
|
+
InfoSustitutivaGuia,
|
|
30
|
+
InfoTributaria,
|
|
31
|
+
ListadoRequest,
|
|
32
|
+
MaquinaFiscal,
|
|
33
|
+
Motivo,
|
|
34
|
+
Operation,
|
|
35
|
+
Pago,
|
|
36
|
+
PaginaComprobantes,
|
|
37
|
+
QuotaBucket,
|
|
38
|
+
RepresentanteLegalConsulta,
|
|
39
|
+
RetencionFacturaLinea,
|
|
40
|
+
RetencionLinea,
|
|
41
|
+
ReembolsoDetalle,
|
|
42
|
+
RubroTercero,
|
|
43
|
+
TipoNegociable,
|
|
44
|
+
ValidacionIdentificacion,
|
|
45
|
+
)
|
|
46
|
+
from .options import EcuafactClientOptions, ResolvedOptions, resolve_options
|
|
47
|
+
from .webhooks import sign, verify
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"EcuafactClient",
|
|
51
|
+
"EcuafactContribuyente",
|
|
52
|
+
"EcuafactApiException",
|
|
53
|
+
"EcuafactSdkException",
|
|
54
|
+
"EcuafactClientOptions",
|
|
55
|
+
"ResolvedOptions",
|
|
56
|
+
"resolve_options",
|
|
57
|
+
"sign",
|
|
58
|
+
"verify",
|
|
59
|
+
"Admission",
|
|
60
|
+
"ApiError",
|
|
61
|
+
"BusquedaContribuyente",
|
|
62
|
+
"CampoAdicional",
|
|
63
|
+
"ClaveAccesoDecodificada",
|
|
64
|
+
"Comprobante",
|
|
65
|
+
"ComprobanteRequest",
|
|
66
|
+
"Compensacion",
|
|
67
|
+
"Contexto",
|
|
68
|
+
"Contribuyente",
|
|
69
|
+
"ContribuyenteConsulta",
|
|
70
|
+
"Detalle",
|
|
71
|
+
"Destinatario",
|
|
72
|
+
"DestinoSustitutiva",
|
|
73
|
+
"DocsSustento",
|
|
74
|
+
"EmisionResultado",
|
|
75
|
+
"EstadoTributario",
|
|
76
|
+
"EstablecimientoConsulta",
|
|
77
|
+
"EstablecimientosContribuyente",
|
|
78
|
+
"Impuesto",
|
|
79
|
+
"ImpuestoDocSustento",
|
|
80
|
+
"InfoDocumento",
|
|
81
|
+
"InfoSustitutivaGuia",
|
|
82
|
+
"InfoTributaria",
|
|
83
|
+
"ListadoRequest",
|
|
84
|
+
"MaquinaFiscal",
|
|
85
|
+
"Motivo",
|
|
86
|
+
"Operation",
|
|
87
|
+
"Pago",
|
|
88
|
+
"PaginaComprobantes",
|
|
89
|
+
"QuotaBucket",
|
|
90
|
+
"RepresentanteLegalConsulta",
|
|
91
|
+
"RetencionFacturaLinea",
|
|
92
|
+
"RetencionLinea",
|
|
93
|
+
"ReembolsoDetalle",
|
|
94
|
+
"RubroTercero",
|
|
95
|
+
"TipoNegociable",
|
|
96
|
+
"ValidacionIdentificacion",
|
|
97
|
+
]
|
ecuafact/client.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""Cliente HTTP del API publico Ecuafact para una credencial de integracion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as _datetime
|
|
6
|
+
import json
|
|
7
|
+
import random
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from email.utils import parsedate_to_datetime
|
|
11
|
+
from typing import TypeVar
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from pydantic import TypeAdapter, ValidationError
|
|
16
|
+
|
|
17
|
+
from .errors import EcuafactApiException, EcuafactSdkException
|
|
18
|
+
from .models import (
|
|
19
|
+
Admission,
|
|
20
|
+
ApiError,
|
|
21
|
+
BusquedaContribuyente,
|
|
22
|
+
ClaveAccesoDecodificada,
|
|
23
|
+
ComprobanteRequest,
|
|
24
|
+
Contexto,
|
|
25
|
+
ContribuyenteConsulta,
|
|
26
|
+
EmisionResultado,
|
|
27
|
+
EstablecimientosContribuyente,
|
|
28
|
+
ListadoRequest,
|
|
29
|
+
Operation,
|
|
30
|
+
PaginaComprobantes,
|
|
31
|
+
QuotaBucket,
|
|
32
|
+
ValidacionIdentificacion,
|
|
33
|
+
ArchivoComprobante,
|
|
34
|
+
PerfilEmisor,
|
|
35
|
+
PerfilEmisorUpdate,
|
|
36
|
+
CorreoEnviado,
|
|
37
|
+
CatalogoItem,
|
|
38
|
+
)
|
|
39
|
+
from .options import EcuafactClientOptions, ResolvedOptions, resolve_options
|
|
40
|
+
|
|
41
|
+
TRANSIENT_STATUS = {408, 425, 429, 500, 502, 503, 504}
|
|
42
|
+
T = TypeVar("T")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EcuafactClient:
|
|
46
|
+
"""Cliente del API publico Ecuafact para una credencial de integracion."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, options: EcuafactClientOptions) -> None:
|
|
49
|
+
self.options: ResolvedOptions = resolve_options(options)
|
|
50
|
+
self._http = httpx.Client(
|
|
51
|
+
base_url=self.options.base_address,
|
|
52
|
+
timeout=self.options.timeout,
|
|
53
|
+
headers={
|
|
54
|
+
"X-Api-Key": self.options.api_key,
|
|
55
|
+
"Accept": "application/json",
|
|
56
|
+
"User-Agent": self.options.user_agent,
|
|
57
|
+
},
|
|
58
|
+
transport=self.options.transport,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# -- ciclo de vida ------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def close(self) -> None:
|
|
64
|
+
self._http.close()
|
|
65
|
+
|
|
66
|
+
def __enter__(self) -> "EcuafactClient":
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def __exit__(self, *_exc: object) -> None:
|
|
70
|
+
self.close()
|
|
71
|
+
|
|
72
|
+
# -- API ----------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def para(self, identificacion: str) -> "EcuafactContribuyente":
|
|
75
|
+
from .contribuyente import EcuafactContribuyente
|
|
76
|
+
|
|
77
|
+
return EcuafactContribuyente(self, identificacion)
|
|
78
|
+
|
|
79
|
+
def emitir(
|
|
80
|
+
self, comprobante: ComprobanteRequest, idempotency_key: str | None = None
|
|
81
|
+
) -> EmisionResultado:
|
|
82
|
+
return self.emitir_en(self._require_identificacion(), comprobante, idempotency_key)
|
|
83
|
+
|
|
84
|
+
def emitir_en(
|
|
85
|
+
self,
|
|
86
|
+
identificacion: str,
|
|
87
|
+
comprobante: ComprobanteRequest,
|
|
88
|
+
idempotency_key: str | None = None,
|
|
89
|
+
) -> EmisionResultado:
|
|
90
|
+
key = idempotency_key if idempotency_key else uuid.uuid4().hex
|
|
91
|
+
_validate_idempotency_key(key)
|
|
92
|
+
path = f"v1/contribuyentes/{_q(identificacion)}/comprobantes"
|
|
93
|
+
admission, correlation = self._send_meta("POST", path, comprobante, key, True, Admission)
|
|
94
|
+
return EmisionResultado(admission, key, correlation)
|
|
95
|
+
|
|
96
|
+
def get_operacion(self, id_operacion: str) -> Operation:
|
|
97
|
+
return self._send("GET", f"v1/operaciones/{id_operacion}", None, None, False, Operation)
|
|
98
|
+
|
|
99
|
+
def consultar_operacion(self, id_operacion: str) -> Operation:
|
|
100
|
+
"""Igual que get_operacion. El API no expone un POST de consulta."""
|
|
101
|
+
return self.get_operacion(id_operacion)
|
|
102
|
+
|
|
103
|
+
def get_contexto(self) -> Contexto:
|
|
104
|
+
return self._send("GET", "v1/contexto", None, None, False, Contexto)
|
|
105
|
+
|
|
106
|
+
def descargar_ride(self, identificacion: str, clave_acceso: str) -> "ArchivoComprobante":
|
|
107
|
+
"""Descarga el RIDE (PDF) de un comprobante emitido."""
|
|
108
|
+
return self._descargar(identificacion, clave_acceso, "ride")
|
|
109
|
+
|
|
110
|
+
def descargar_xml(self, identificacion: str, clave_acceso: str) -> "ArchivoComprobante":
|
|
111
|
+
"""Descarga el XML. Revise provisional."""
|
|
112
|
+
return self._descargar(identificacion, clave_acceso, "xml")
|
|
113
|
+
|
|
114
|
+
def get_perfil(self, identificacion: str) -> "PerfilEmisor":
|
|
115
|
+
"""Lee el perfil del emisor."""
|
|
116
|
+
return self._send("GET", self._ruta_perfil(identificacion), None, None, False, PerfilEmisor)
|
|
117
|
+
|
|
118
|
+
def enviar_correo(self, identificacion: str, clave_acceso: str, destinatario: str) -> CorreoEnviado:
|
|
119
|
+
"""Reenvia el correo de un comprobante autorizado."""
|
|
120
|
+
path = f"v1/contribuyentes/{_q(identificacion)}/comprobantes/{_q(clave_acceso)}/correo"
|
|
121
|
+
response = self._http.request("POST", path, json={"destinatario": destinatario})
|
|
122
|
+
if response.status_code >= 400:
|
|
123
|
+
raise _build_api_exception(response.status_code, response.text, None, None)
|
|
124
|
+
datos = response.json().get("datos")
|
|
125
|
+
return CorreoEnviado.model_validate(datos)
|
|
126
|
+
|
|
127
|
+
def listar_catalogo(self, nombre: str) -> list[CatalogoItem]:
|
|
128
|
+
"""Lee un catalogo publicado."""
|
|
129
|
+
return self._send("GET", f"v1/catalogos/{_q(nombre)}", None, None, False, CatalogoItem, list_result=True)
|
|
130
|
+
|
|
131
|
+
def actualizar_perfil(self, identificacion: str, cambios: PerfilEmisorUpdate) -> PerfilEmisor:
|
|
132
|
+
"""Actualiza los campos editables del perfil."""
|
|
133
|
+
response = self._http.request(
|
|
134
|
+
"PUT",
|
|
135
|
+
self._ruta_perfil(identificacion),
|
|
136
|
+
json=cambios.model_dump(by_alias=True, exclude_none=True),
|
|
137
|
+
)
|
|
138
|
+
if response.status_code >= 400:
|
|
139
|
+
raise _build_api_exception(response.status_code, response.text, None, None)
|
|
140
|
+
datos = response.json().get("datos")
|
|
141
|
+
return PerfilEmisor.model_validate(datos)
|
|
142
|
+
|
|
143
|
+
def _ruta_perfil(self, identificacion: str) -> str:
|
|
144
|
+
return f"v1/contribuyentes/{_q(identificacion)}/perfil"
|
|
145
|
+
|
|
146
|
+
def _descargar(self, identificacion: str, clave_acceso: str, formato: str) -> ArchivoComprobante:
|
|
147
|
+
path = (
|
|
148
|
+
f"v1/contribuyentes/{_q(identificacion)}"
|
|
149
|
+
f"/comprobantes/{_q(clave_acceso)}/{formato}"
|
|
150
|
+
)
|
|
151
|
+
max_attempts = max(1, self.options.max_attempts) if self.options.retry_transient_failures else 1
|
|
152
|
+
response = None
|
|
153
|
+
for attempt in range(1, max_attempts + 1):
|
|
154
|
+
try:
|
|
155
|
+
response = self._http.request("GET", path)
|
|
156
|
+
except httpx.HTTPError as exc:
|
|
157
|
+
if attempt < max_attempts:
|
|
158
|
+
_delay(self._compute_delay_ms(attempt, None))
|
|
159
|
+
continue
|
|
160
|
+
raise EcuafactSdkException("No hay conexion con el API.") from exc
|
|
161
|
+
if (
|
|
162
|
+
response.status_code >= 400
|
|
163
|
+
and response.status_code in TRANSIENT_STATUS
|
|
164
|
+
and attempt < max_attempts
|
|
165
|
+
):
|
|
166
|
+
_delay(self._compute_delay_ms(attempt, response.headers.get("Retry-After")))
|
|
167
|
+
continue
|
|
168
|
+
break
|
|
169
|
+
if response is None:
|
|
170
|
+
raise EcuafactSdkException("No fue posible completar la solicitud con el API.")
|
|
171
|
+
if response.status_code >= 400:
|
|
172
|
+
raise _build_api_exception(
|
|
173
|
+
response.status_code,
|
|
174
|
+
response.text,
|
|
175
|
+
response.headers.get("X-Correlation-Id"),
|
|
176
|
+
None,
|
|
177
|
+
)
|
|
178
|
+
tipo = response.headers.get("Content-Type", "application/octet-stream").split(";")[0]
|
|
179
|
+
marca = response.headers.get("X-Contenido", "")
|
|
180
|
+
return ArchivoComprobante(response.content, tipo, marca.lower() == "provisional")
|
|
181
|
+
|
|
182
|
+
def get_consumo(self) -> QuotaBucket:
|
|
183
|
+
return self._send("GET", "v1/consumo", None, None, True, QuotaBucket)
|
|
184
|
+
|
|
185
|
+
def listar_emitidos(self, filtros: ListadoRequest | None = None) -> PaginaComprobantes:
|
|
186
|
+
return self._listar(self._require_identificacion(), False, filtros)
|
|
187
|
+
|
|
188
|
+
def listar_recibidos(self, filtros: ListadoRequest | None = None) -> PaginaComprobantes:
|
|
189
|
+
return self._listar(self._require_identificacion(), True, filtros)
|
|
190
|
+
|
|
191
|
+
def listar_emitidos_en(
|
|
192
|
+
self, identificacion: str, filtros: ListadoRequest | None = None
|
|
193
|
+
) -> PaginaComprobantes:
|
|
194
|
+
return self._listar(identificacion, False, filtros)
|
|
195
|
+
|
|
196
|
+
def listar_recibidos_en(
|
|
197
|
+
self, identificacion: str, filtros: ListadoRequest | None = None
|
|
198
|
+
) -> PaginaComprobantes:
|
|
199
|
+
return self._listar(identificacion, True, filtros)
|
|
200
|
+
|
|
201
|
+
def consultar_contribuyente(
|
|
202
|
+
self, identificacion: str, fusionar: bool = True, fuente: str | None = None
|
|
203
|
+
) -> ContribuyenteConsulta:
|
|
204
|
+
if not identificacion or not identificacion.strip():
|
|
205
|
+
raise EcuafactSdkException("La identificacion es obligatoria.")
|
|
206
|
+
flag = "true" if fusionar else "false"
|
|
207
|
+
path = f"v1/consultas/contribuyentes/{_q(identificacion)}?fusionar={flag}"
|
|
208
|
+
if fuente and fuente.strip():
|
|
209
|
+
path += f"&fuente={_q(fuente)}"
|
|
210
|
+
return self._send("GET", path, None, None, True, ContribuyenteConsulta)
|
|
211
|
+
|
|
212
|
+
def buscar_contribuyentes(
|
|
213
|
+
self,
|
|
214
|
+
apellidos: str,
|
|
215
|
+
nombres: str | None = None,
|
|
216
|
+
clase: str | None = None,
|
|
217
|
+
max: int | None = None,
|
|
218
|
+
) -> list[BusquedaContribuyente]:
|
|
219
|
+
if not apellidos or not apellidos.strip():
|
|
220
|
+
raise EcuafactSdkException("apellidos es obligatorio.")
|
|
221
|
+
parts = [f"apellidos={_q(apellidos)}"]
|
|
222
|
+
if nombres and nombres.strip():
|
|
223
|
+
parts.append(f"nombres={_q(nombres)}")
|
|
224
|
+
if clase and clase.strip():
|
|
225
|
+
parts.append(f"clase={_q(clase)}")
|
|
226
|
+
if max is not None:
|
|
227
|
+
parts.append(f"max={max}")
|
|
228
|
+
path = "v1/consultas/contribuyentes?" + "&".join(parts)
|
|
229
|
+
return self._send_list("GET", path, BusquedaContribuyente)
|
|
230
|
+
|
|
231
|
+
def listar_establecimientos(self, ruc: str, filtro: str | None = None) -> EstablecimientosContribuyente:
|
|
232
|
+
if not ruc or not ruc.strip():
|
|
233
|
+
raise EcuafactSdkException("El RUC es obligatorio.")
|
|
234
|
+
path = f"v1/consultas/establecimientos/{_q(ruc)}"
|
|
235
|
+
if filtro and filtro.strip():
|
|
236
|
+
path += f"?filtro={_q(filtro)}"
|
|
237
|
+
return self._send("GET", path, None, None, True, EstablecimientosContribuyente)
|
|
238
|
+
|
|
239
|
+
def validar_identificacion(self, numero: str) -> ValidacionIdentificacion:
|
|
240
|
+
if not numero or not numero.strip():
|
|
241
|
+
raise EcuafactSdkException("El numero a validar es obligatorio.")
|
|
242
|
+
path = f"v1/consultas/identificacion/validar/{_q(numero)}"
|
|
243
|
+
return self._send("GET", path, None, None, True, ValidacionIdentificacion)
|
|
244
|
+
|
|
245
|
+
def decodificar_clave_acceso(self, clave_acceso: str) -> ClaveAccesoDecodificada:
|
|
246
|
+
if not clave_acceso or not clave_acceso.strip():
|
|
247
|
+
raise EcuafactSdkException("La clave de acceso es obligatoria.")
|
|
248
|
+
path = f"v1/consultas/comprobantes/decodificar/{_q(clave_acceso)}"
|
|
249
|
+
return self._send("GET", path, None, None, True, ClaveAccesoDecodificada)
|
|
250
|
+
|
|
251
|
+
# -- interno ------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def _listar(
|
|
254
|
+
self,
|
|
255
|
+
identificacion: str,
|
|
256
|
+
recibidos: bool,
|
|
257
|
+
filtros: ListadoRequest | None,
|
|
258
|
+
) -> PaginaComprobantes:
|
|
259
|
+
direccion = "recibidos" if recibidos else "emitidos"
|
|
260
|
+
path = (
|
|
261
|
+
f"v1/contribuyentes/{_q(identificacion)}"
|
|
262
|
+
f"/comprobantes/{direccion}{_build_query(filtros)}"
|
|
263
|
+
)
|
|
264
|
+
return self._send("GET", path, None, None, False, PaginaComprobantes)
|
|
265
|
+
|
|
266
|
+
def _send_list(self, method: str, path: str, model: type[T]) -> list[T]:
|
|
267
|
+
return self._send(method, path, None, None, True, model, list_result=True)
|
|
268
|
+
|
|
269
|
+
def _send(
|
|
270
|
+
self,
|
|
271
|
+
method: str,
|
|
272
|
+
path: str,
|
|
273
|
+
body: ComprobanteRequest | None,
|
|
274
|
+
idempotency_key: str | None,
|
|
275
|
+
flat: bool,
|
|
276
|
+
model: type[T],
|
|
277
|
+
list_result: bool = False,
|
|
278
|
+
) -> T:
|
|
279
|
+
data, _ = self._send_meta(method, path, body, idempotency_key, flat, model, list_result)
|
|
280
|
+
return data
|
|
281
|
+
|
|
282
|
+
def _send_meta(
|
|
283
|
+
self,
|
|
284
|
+
method: str,
|
|
285
|
+
path: str,
|
|
286
|
+
body: ComprobanteRequest | None,
|
|
287
|
+
idempotency_key: str | None,
|
|
288
|
+
flat: bool,
|
|
289
|
+
model: type[T],
|
|
290
|
+
list_result: bool = False,
|
|
291
|
+
) -> tuple[T, str | None]:
|
|
292
|
+
# Solo se reintentan solicitudes idempotentes: GET, o POST/PUT con Idempotency-Key.
|
|
293
|
+
retryable = method == "GET" or (
|
|
294
|
+
idempotency_key is not None and method in ("POST", "PUT")
|
|
295
|
+
)
|
|
296
|
+
max_attempts = (
|
|
297
|
+
max(1, self.options.max_attempts)
|
|
298
|
+
if self.options.retry_transient_failures and retryable
|
|
299
|
+
else 1
|
|
300
|
+
)
|
|
301
|
+
headers: dict[str, str] = {}
|
|
302
|
+
if idempotency_key:
|
|
303
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
304
|
+
payload = body.model_dump(by_alias=True, exclude_none=True) if body is not None else None
|
|
305
|
+
|
|
306
|
+
for attempt in range(1, max_attempts + 1):
|
|
307
|
+
try:
|
|
308
|
+
response = self._http.request(method, path, json=payload, headers=headers)
|
|
309
|
+
except httpx.HTTPError as exc:
|
|
310
|
+
if attempt < max_attempts:
|
|
311
|
+
_delay(self._compute_delay_ms(attempt, None))
|
|
312
|
+
continue
|
|
313
|
+
raise EcuafactSdkException(
|
|
314
|
+
"No hay conexion con el API. Si estaba emitiendo, conserve la misma Idempotency-Key y reintente."
|
|
315
|
+
) from exc
|
|
316
|
+
|
|
317
|
+
correlation = response.headers.get("X-Correlation-Id")
|
|
318
|
+
if response.status_code >= 400:
|
|
319
|
+
if response.status_code in TRANSIENT_STATUS and attempt < max_attempts:
|
|
320
|
+
_delay(self._compute_delay_ms(attempt, response.headers.get("Retry-After")))
|
|
321
|
+
continue
|
|
322
|
+
raise _build_api_exception(response.status_code, response.text, correlation, idempotency_key)
|
|
323
|
+
|
|
324
|
+
return _parse(response.text, flat, model, list_result), correlation
|
|
325
|
+
|
|
326
|
+
raise EcuafactSdkException("No fue posible completar la solicitud con el API.")
|
|
327
|
+
|
|
328
|
+
def _compute_delay_ms(self, attempt: int, retry_after: str | None) -> float:
|
|
329
|
+
millis = (200.0 * attempt) + random.uniform(0, 50.0 * attempt)
|
|
330
|
+
if retry_after and self.options.respect_retry_after:
|
|
331
|
+
value = _parse_retry_after_ms(retry_after)
|
|
332
|
+
if value is not None and value > millis:
|
|
333
|
+
millis = value
|
|
334
|
+
cap = self.options.max_retry_delay_ms
|
|
335
|
+
if cap and cap > 0 and millis > cap:
|
|
336
|
+
millis = cap
|
|
337
|
+
return millis
|
|
338
|
+
|
|
339
|
+
def _require_identificacion(self) -> str:
|
|
340
|
+
value = self.options.identificacion
|
|
341
|
+
if not value or not value.strip():
|
|
342
|
+
raise EcuafactSdkException(
|
|
343
|
+
"Falta el RUC por defecto (identificacion). Configurelo para integraciones de un solo RUC o use "
|
|
344
|
+
"emitir_en(ruc, ...) o client.para(ruc)."
|
|
345
|
+
)
|
|
346
|
+
return value
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _q(value: str) -> str:
|
|
350
|
+
return quote(value, safe="")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _build_query(filtros: ListadoRequest | None) -> str:
|
|
354
|
+
if filtros is None:
|
|
355
|
+
return ""
|
|
356
|
+
parts: list[str] = []
|
|
357
|
+
for name, value in (
|
|
358
|
+
("desde", filtros.desde),
|
|
359
|
+
("hasta", filtros.hasta),
|
|
360
|
+
("codDoc", filtros.cod_doc),
|
|
361
|
+
("buscar", filtros.buscar),
|
|
362
|
+
("pagina", filtros.pagina),
|
|
363
|
+
("tamanoPagina", filtros.tamano_pagina),
|
|
364
|
+
):
|
|
365
|
+
if value is None or value == "":
|
|
366
|
+
continue
|
|
367
|
+
parts.append(f"{name}={_q(str(value))}")
|
|
368
|
+
return "?" + "&".join(parts) if parts else ""
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _parse(text: str, flat: bool, model: type[T], list_result: bool = False) -> T:
|
|
372
|
+
if text is None or text == "":
|
|
373
|
+
raise EcuafactSdkException("El API no devolvio datos.")
|
|
374
|
+
try:
|
|
375
|
+
data = json.loads(text)
|
|
376
|
+
except ValueError as exc:
|
|
377
|
+
raise EcuafactSdkException("Respuesta del API no reconocida.") from exc
|
|
378
|
+
if not flat:
|
|
379
|
+
data = data.get("datos") if isinstance(data, dict) else None
|
|
380
|
+
if data is None:
|
|
381
|
+
raise EcuafactSdkException("El API no devolvio datos.")
|
|
382
|
+
try:
|
|
383
|
+
if list_result:
|
|
384
|
+
return TypeAdapter(list[model]).validate_python(data)
|
|
385
|
+
return model.model_validate(data)
|
|
386
|
+
except ValidationError as exc:
|
|
387
|
+
raise EcuafactSdkException("Respuesta del API no reconocida.") from exc
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _build_api_exception(
|
|
391
|
+
status: int, text: str, correlation: str | None, idempotency_key: str | None
|
|
392
|
+
) -> EcuafactApiException:
|
|
393
|
+
error: ApiError | None = None
|
|
394
|
+
if text and text.strip():
|
|
395
|
+
try:
|
|
396
|
+
error = ApiError.model_validate_json(text)
|
|
397
|
+
except ValidationError:
|
|
398
|
+
error = None
|
|
399
|
+
return EcuafactApiException(
|
|
400
|
+
error.codigo if error else None,
|
|
401
|
+
error.mensaje if error else None,
|
|
402
|
+
status,
|
|
403
|
+
correlation,
|
|
404
|
+
(error.errores or []) if error else [],
|
|
405
|
+
idempotency_key,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _validate_idempotency_key(key: str) -> None:
|
|
410
|
+
if len(key) > 128:
|
|
411
|
+
raise EcuafactSdkException("Idempotency-Key no puede exceder 128 caracteres.")
|
|
412
|
+
if any(ord(character) > 127 for character in key):
|
|
413
|
+
raise EcuafactSdkException("Idempotency-Key debe ser ASCII.")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _delay(millis: float) -> None:
|
|
417
|
+
if millis > 0:
|
|
418
|
+
time.sleep(millis / 1000.0)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _parse_retry_after_ms(value: str) -> float | None:
|
|
422
|
+
text = value.strip()
|
|
423
|
+
if not text:
|
|
424
|
+
return None
|
|
425
|
+
if text.isdigit():
|
|
426
|
+
return float(text) * 1000.0
|
|
427
|
+
try:
|
|
428
|
+
when = parsedate_to_datetime(text)
|
|
429
|
+
except (TypeError, ValueError):
|
|
430
|
+
return None
|
|
431
|
+
if when is None:
|
|
432
|
+
return None
|
|
433
|
+
if when.tzinfo is None:
|
|
434
|
+
when = when.replace(tzinfo=_datetime.timezone.utc)
|
|
435
|
+
delta = (when - _datetime.datetime.now(_datetime.timezone.utc)).total_seconds() * 1000.0
|
|
436
|
+
return delta if delta > 0 else None
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Vista de emision y consulta fijada a un RUC (multi-RUC)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from .errors import EcuafactSdkException
|
|
8
|
+
from .models import EmisionResultado, ListadoRequest, PaginaComprobantes, QuotaBucket
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .client import EcuafactClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EcuafactContribuyente:
|
|
15
|
+
def __init__(self, client: "EcuafactClient", identificacion: str) -> None:
|
|
16
|
+
if not identificacion or not identificacion.strip():
|
|
17
|
+
raise EcuafactSdkException("El RUC es obligatorio.")
|
|
18
|
+
self._client = client
|
|
19
|
+
self.identificacion = identificacion
|
|
20
|
+
|
|
21
|
+
def emitir(self, comprobante, idempotency_key: str | None = None) -> EmisionResultado:
|
|
22
|
+
return self._client.emitir_en(self.identificacion, comprobante, idempotency_key)
|
|
23
|
+
|
|
24
|
+
def listar_emitidos(self, filtros: ListadoRequest | None = None) -> PaginaComprobantes:
|
|
25
|
+
return self._client.listar_emitidos_en(self.identificacion, filtros)
|
|
26
|
+
|
|
27
|
+
def listar_recibidos(self, filtros: ListadoRequest | None = None) -> PaginaComprobantes:
|
|
28
|
+
return self._client.listar_recibidos_en(self.identificacion, filtros)
|
|
29
|
+
|
|
30
|
+
def get_consumo(self) -> QuotaBucket:
|
|
31
|
+
return self._client.get_consumo()
|
ecuafact/errors.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Error local del SDK (configuracion, transporte o respuesta no reconocida)."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EcuafactSdkException(Exception):
|
|
5
|
+
"""Error local del SDK."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EcuafactApiException(EcuafactSdkException):
|
|
9
|
+
"""Error devuelto por el API con el contrato publico (plano)."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
codigo: str | None,
|
|
14
|
+
mensaje: str | None,
|
|
15
|
+
estado_http: int,
|
|
16
|
+
id_seguimiento: str | None,
|
|
17
|
+
errores: list[str],
|
|
18
|
+
idempotency_key: str | None,
|
|
19
|
+
) -> None:
|
|
20
|
+
super().__init__(mensaje or f"El API devolvio el estado HTTP {estado_http}.")
|
|
21
|
+
self.codigo = codigo
|
|
22
|
+
self.mensaje = mensaje
|
|
23
|
+
self.estado_http = estado_http
|
|
24
|
+
self.id_seguimiento = id_seguimiento
|
|
25
|
+
self.errores = errores
|
|
26
|
+
self.idempotency_key = idempotency_key
|