Metadata-Version: 2.4
Name: ecuafact
Version: 1.0.0b4
Summary: Cliente oficial en Python del API publico de facturacion electronica Ecuafact (contrato v1).
Author: Ecuanexus
License-Expression: MIT
Project-URL: Documentation, https://docsapi.ecuafact.com/v1/guias/sdk-python
Project-URL: Homepage, https://docsapi.ecuafact.com/v1/guias/sdk-python
Keywords: ecuafact,sri,facturacion-electronica,ecuador,api,sdk
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx<1,>=0.27
Requires-Dist: pydantic<3,>=2.6
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Dynamic: license-file

# ecuafact (Python)

Cliente oficial en Python del API publico de facturacion electronica Ecuafact (contrato v1). Cubre
autenticacion, idempotencia, reintentos configurables, importes exactos (`Decimal`), espera del
resultado, paginacion, logo del perfil y webhooks tipados. Requiere Python 3.10+. Incluye `py.typed`.

## Instalacion

```bash
pip install "ecuafact>=1.0.0b4"
```

## Uso

```python
from decimal import Decimal
from ecuafact import ComprobanteRequest, EcuafactClient, EcuafactClientOptions, InfoDocumento, InfoTributaria

# Sin opciones, el cliente lee ECUAFACT_BASE_URL y ECUAFACT_API_KEY.
client = EcuafactClient(EcuafactClientOptions(
    base_address="https://staging-api.mynexusapi.com/",  # sandbox; produccion: https://api.mynexusapi.com/
    identificacion="0123456789",  # valor `identificacion` de GET /v1/contexto (solo integraciones de un RUC)
))

resultado = client.emitir(ComprobanteRequest(
    origen_referencia="MiERP",
    referencia_externa="FACTURA-2026-0001",
    info_tributaria=InfoTributaria(ruc="0123456789001", cod_doc="01", estab="002", pto_emi="001"),
    info=InfoDocumento(total_sin_impuestos=Decimal("10.00"), importe_total=Decimal("11.50")),
    # detalles=..., pagos=...
))
print(resultado.admission.id_operacion, resultado.idempotency_key)  # 202: operacion admitida

operacion = client.esperar_resultado(resultado.admission.id_operacion, intervalo=5, maximo=120)
if operacion.es_autorizado:
    print("Autorizado", operacion.clave_acceso)
elif not operacion.es_final:
    print("Sigue en proceso; consulta de nuevo mas tarde")
```

`identificacion` en las rutas es siempre el valor `identificacion` de `GET /v1/contexto` (por ejemplo
`0123456789`), no el RUC de 13 digitos. El comprobante no lleva `secuencial`: la plataforma lo asigna.

### Multi-RUC

```python
ruc = client.para("0123456789")
ruc.emitir(comprobante)
for comprobante in ruc.iterar_emitidos():          # recorre todas las paginas
    print(comprobante.clave_acceso, comprobante.total)
ruc.descargar_ride(clave_acceso)
ruc.descargar_xml(clave_acceso)
ruc.enviar_correo(clave_acceso, "cliente@ejemplo.com")
ruc.get_perfil()
ruc.actualizar_perfil(PerfilEmisorUpdate(telefono="0999999999"))
ruc.actualizar_logo(open("logo.png", "rb").read(), "image/png", "logo.png")
```

## Importes

Los importes, cantidades, precios y tarifas son `decimal.Decimal`. Puedes pasar `int`, `float`,
`Decimal` o `str` (`"12.50"`); el SDK los envia como numero JSON exacto (`12.50`, no `"12.50"`). Un
`float` se toma con 15 cifras significativas, asi que `0.1 + 0.2` se envia como `0.3`. Las respuestas
(`Comprobante.total`, `CatalogoItem.tarifa`) se leen como `Decimal` sin perder precision.

## Idempotencia

`idempotency_key` es opcional: si no se envia, el SDK la genera, la reutiliza en los reintentos y la
devuelve en `resultado.idempotency_key` (y en el error, como `idempotency_key`). Al reintentar a nivel
de aplicacion **reutiliza la clave devuelta**. Solo admite letras, digitos y `- _ . : /`, maximo 128
caracteres; una clave invalida falla antes de enviar. La correlacion queda en
`resultado.correlation_id` (o `id_seguimiento` en el error).

## Reintentos

Se reintentan las solicitudes idempotentes (GET, emision con `Idempotency-Key`, logo) ante fallos de
conexion y los estados de `retry_status_codes` (por defecto `408, 425, 500, 502, 503, 504`).

- `429` con codigo `501` (cupo agotado): nunca se reintenta.
- `429` con codigo `104` (limite de solicitudes): solo con `retry_rate_limited=True`, respetando
  `Retry-After` y `max_retry_delay_ms`.
- `enviar_correo` y `actualizar_perfil` no se reintentan.

## Opciones

| Opcion | Default | Descripcion |
|---|---|---|
| `base_address` | `ECUAFACT_BASE_URL` | URL absoluta http/https del despliegue, sin `/v1` |
| `api_key` | `ECUAFACT_API_KEY` | Credencial `X-Api-Key`; no admite espacios al inicio/fin ni caracteres de control; nunca aparece en `repr` |
| `identificacion` | `None` | Contribuyente por defecto (valor de `GET /v1/contexto`) |
| `timeout` | `30.0` | Tiempo maximo de cada intento, incluida la lectura del cuerpo (segundos) o `httpx.Timeout` |
| `connect_timeout` | `None` | Tiempo maximo para conectar (segundos); `None` = `timeout` |
| `total_timeout` | `None` | Tope de toda la llamada, incluidos reintentos (segundos) |
| `user_agent` | `Ecuafact.Sdk/1.0` | User-Agent (se envia en cada solicitud) |
| `additional_info` | `None` | Se envia como `X-Additional-Info` en cada solicitud; ASCII imprimible, sin espacios al inicio/fin, maximo 256; `None` o vacio no la envia; nunca aparece en `repr` |
| `retry_transient_failures` | `True` | `False` desactiva todos los reintentos |
| `max_attempts` | `3` | Intentos por solicitud (minimo 1) |
| `retry_status_codes` | `408, 425, 500, 502, 503, 504` | Estados HTTP reintentables |
| `retry_rate_limited` | `False` | Reintenta `429`/`104` |
| `retry_backoff` | `"linear"` | `"linear"` (base x intento) o `"exponential"` (base x 2^(intento-1)) |
| `retry_base_delay_ms` | `200` | Espera base entre reintentos |
| `retry_jitter_ms` | `50` | Variacion aleatoria maxima de la espera |
| `respect_retry_after` | `True` | Respeta `Retry-After` |
| `max_retry_delay_ms` | `60000` | Espera maxima entre reintentos (`0` = sin tope) |
| `http_client` | `None` | `httpx.Client` propio (proxy, TLS, pool); el SDK no lo cierra |
| `proxy` | `None` | Proxy del cliente interno (`"http://proxy:3128"`) |
| `verify` | `None` | TLS del cliente interno: `True`/`False`, ruta a bundle CA o `ssl.SSLContext` |
| `transport` | `None` | Transporte httpx propio (por ejemplo `httpx.MockTransport` en pruebas) |

Una opcion invalida lanza `EcuafactConfigurationException` al crear el cliente.

## Opciones por llamada

Cada metodo que llama al API acepta `opciones=RequestOptions(...)` como ultimo argumento (keyword).
Solo afecta a esa llamada; el resto usa la configuracion del cliente.

| Campo | Default | Descripcion |
|---|---|---|
| `timeout` | `None` (usa `options.timeout`) | Tiempo maximo de cada intento (segundos o `httpx.Timeout`) |
| `total_timeout` | `None` (usa `options.total_timeout`) | Tope de toda la llamada, incluidos reintentos y esperas (segundos) |
| `correlation_id` | `None` | Se envia como `X-Correlation-Id`: de 1 a 64 caracteres `A-Z a-z 0-9 . _ -` |
| `additional_info` | `None` (usa `options.additional_info`) | Se envia como `X-Additional-Info` solo en esta llamada; `None` usa el del cliente y `""` no la envia en esta llamada |
| `reintentos` | `True` | `False` desactiva los reintentos solo en esa llamada |

```python
from ecuafact import RequestOptions

opciones = RequestOptions(timeout=10, total_timeout=30, correlation_id="pedido-457", reintentos=True)
resultado = client.emitir(comprobante, "FACTURA-2026-0001", opciones=opciones)
print(resultado.correlation_id)  # pedido-457 (el que devuelve el API)

operacion = client.get_operacion(resultado.admission.id_operacion, opciones=RequestOptions(reintentos=False))
ruc = client.para("0123456789")
ruc.descargar_ride(clave_acceso, opciones=RequestOptions(timeout=60))
```

- Un `correlation_id` o un tiempo invalido lanza `EcuafactConfigurationException` al crear `RequestOptions`.
- La correlacion queda en `resultado.correlation_id` y en `id_seguimiento` de cualquier excepcion del SDK
  (tambien en `EcuafactTimeoutException` y `EcuafactConnectionException`). Si el API no devuelve
  `X-Correlation-Id`, se expone el que enviaste.
- `reintentos=True` no reactiva reintentos que el cliente desactivo (`retry_transient_failures=False`),
  ni reintenta solicitudes no idempotentes (`enviar_correo`, `actualizar_perfil`).
- En `esperar_resultado` e `iterar_emitidos`, las opciones se aplican a cada consulta o pagina.
- El SDK Python no tiene cancelacion: acota la llamada con `timeout` y `total_timeout`.

## Constructores de comprobantes

Opcionales. Arman el mismo `ComprobanteRequest` para factura (`01`), liquidacion de compra (`03`),
nota de credito (`04`), nota de debito (`05`), guia de remision (`06`) y retencion (`07`).

```python
from ecuafact import ComprobanteBuilder, Iva, calcular_totales, validar_comprobante

factura = (
    ComprobanteBuilder.factura("0123456789001", "002", "001")
    .referencias("MiERP", "FACTURA-2026-0001")
    .fecha_emision("07/10/2026")
    .comprador("05", "0123456789", "CONTRIBUYENTE DE PRUEBAS", "DIRECCION DE PRUEBAS")
    .linea("SERV-001", "CONSULTORIA", 3, "12.50", "2.50", Iva.TARIFA_15)
    .linea("REP-002", "REPUESTO", 3, "1.0112", 0, Iva.TARIFA_15)
    .linea("LIB-003", "LIBRO", 2, "4.35", 0, Iva.TARIFA_0)
    .moneda("DOLAR")
    .pago("01", "20.00")
    .pago("19")                 # sin total: recibe lo que falta (32.44)
    .calcular_totales()
)
errores = factura.validar()     # [] si todo cuadra
client.emitir(factura.construir(), "FACTURA-2026-0001")
```

- **Tus valores mandan.** Todo importe que asignes (en `linea(...)`, `detalle(Detalle(...))`,
  `info(importe_total=...)`, `pago(forma, total)`, un `Impuesto` con `valor`) se respeta.
  `calcular_totales()` solo rellena lo vacio (`None`).
- **Calculo** (reglas del API): subtotal de linea `round(cantidad x precio - descuento, 2)`; IVA sobre la
  base sin redondear mas el ICE de la linea; cabecera `totalConImpuestos` por `codigo` +
  `codigoPorcentaje` (IVA primero, luego ICE, en orden de aparicion) sumando las lineas; `importeTotal` =
  subtotal + IVA + ICE + propina; descuento vacio = 0; `propina` 0 en factura; `moneda` `DOLAR` en 01, 03
  y 04 si falta; un pago sin `total` recibe lo que falta. Nota de debito: IVA = suma del IVA redondeado de
  cada motivo. Retencion: `valorImpuesto`, `totalSinImpuestos` (suma de bases), `importeTotal`,
  `valorRetenido` y el pago; `pagoLocExt` `01` por defecto.
- **Precision**: `Precision(importes=2, cantidades=6, redondeo="HALF_UP")`. Modos: `HALF_UP`, `HALF_DOWN`,
  `HALF_EVEN`, `UP`, `DOWN`, `CEILING`, `FLOOR` (o la constante de `decimal`). Pasa la tuya como
  `ComprobanteBuilder.factura(ruc, estab, pto_emi, precision=Precision(...))`. Otra precision puede
  producir importes que el API rechaza con 422.
- **`validar()`** devuelve una lista de `Inconsistencia(campo, mensaje, esperado, actual)`
  (`str(i)` da `"info.importeTotal: no coincide con ... (esperado 52.44, actual 52.45)"`); no lanza ni
  bloquea `emitir`.
- **Sin constructor**: `calcular_totales(request, precision=None)` devuelve una copia con los importes vacios
  calculados y `validar_comprobante(request, precision=None)` devuelve las inconsistencias; el tipo sale de
  `infoTributaria.codDoc`.
- Tarifas: `Iva.TARIFA_0`, `Iva.TARIFA_5`, `Iva.TARIFA_8`, `Iva.TARIFA_15` (codigo `4`), `Iva.NO_OBJETO`, `Iva.EXENTO`,
  `Tarifa("4", 15)` o la tupla `("4", 15)`. ICE: `linea(..., ice=("3011", 10))`.

| Tipo | Metodos propios |
|---|---|
| `factura` | `comprador`, `linea`, `detalle`, `propina`, `moneda`, `pago` |
| `liquidacion_compra` | `proveedor`, `linea`, `detalle`, `moneda`, `pago` |
| `nota_credito` | `comprador`, `documento_modificado`, `motivo`, `linea` (`codigoInterno`), `detalle`, `moneda` |
| `nota_debito` | `comprador`, `documento_modificado`, `motivo(razon, valor)`, `iva`, `moneda`, `pago` |
| `guia_remision` | `transportista`, `traslado`, `destinatario`, `linea(codigo, descripcion, cantidad)` |
| `retencion` | `sujeto_retenido`, `periodo_fiscal`, `parte_relacionada`, `documento_sustento`, `impuesto_sustento`, `retencion`, `pago` |

Comunes: `referencias`, `secuencial`, `fecha_emision`, `info(**campos)`, `info_adicional`,
`calcular_totales`, `validar`, `construir`.

## Errores

| Excepcion | Cuando | Que hacer |
|---|---|---|
| `EcuafactConnectionException` | Sin conexion o respuesta cortada | Reintenta con la misma `Idempotency-Key` |
| `EcuafactTimeoutException` | Tiempo del intento o `total_timeout` agotado | Reintenta con la misma `Idempotency-Key` o consulta la operacion |
| `EcuafactConfigurationException` | Opciones invalidas | Corrige la configuracion |
| `EcuafactWebhookException` | Firma (`motivo` `firma_invalida`) o cuerpo (`motivo` `cuerpo_invalido`) de webhook invalidos | Responde 401 o 400 segun `motivo` |
| `EcuafactAuthException` | 401 / 403 | Revisa la API Key, el alcance o el plan |
| `EcuafactValidationException` | 400 / 422 | Corrige el comprobante segun `errores` |
| `EcuafactNotFoundException` | 404 | Revisa el identificador |
| `EcuafactConflictException` | 409 | Duplicado o clave reutilizada con otro contenido |
| `EcuafactRateLimitException` | 429 / `104` | Espera `retry_after` |
| `EcuafactQuotaException` | 429 / `501` | Cupo agotado: no reintentes |
| `EcuafactServerException` | 5xx | Reintenta mas tarde con la misma clave |

Todas heredan de `EcuafactSdkException`; las del API, de `EcuafactApiException`, con `codigo`,
`mensaje`, `errores`, `estado_http`, `id_seguimiento`, `idempotency_key`, `retry_after`
(`timedelta` o `None`) y `es_reintentable`. Toda `EcuafactSdkException` tiene `id_seguimiento` (correlacion
de la llamada, o `None`).

## Webhooks

```python
from ecuafact import TipoEventoWebhook, construir_evento, EcuafactWebhookException

try:
    evento = construir_evento([secreto_actual, secreto_anterior], cabecera, await request.body())
except EcuafactWebhookException as error:
    return Response(status_code=401 if error.es_firma_invalida else 400)
if evento.event_type is TipoEventoWebhook.DOCUMENTO_AUTORIZADO:
    marcar_autorizado(evento.id_operacion, evento.clave_acceso)
```

`verify(secretos, cabecera, cuerpo)` acepta el cuerpo como `bytes` o `str`, uno o varios secretos y
tolerancia por defecto de 300 s. Nunca lanza: una cabecera malformada devuelve `False`.

## Enums

`operacion.estado_operacion` (`EstadoOperacion`), `operacion.estado_autorizacion_tipado`
(`EstadoAutorizacion`), `operacion.tipo_comprobante` (`TipoComprobante`), `operacion.es_final` y
`operacion.es_autorizado`. Un valor nuevo del API se lee como `DESCONOCIDO`; los campos de texto
originales se conservan.

## Fechas de filtros

`ListadoRequest(desde=..., hasta=...)` acepta `"yyyy-MM-dd"` (se envia tal cual), `datetime.date` o
`datetime.datetime`. Son fechas de calendario de Ecuador: un `datetime` con zona se convierte a
America/Guayaquil (UTC-5) antes de tomar la fecha.

## Cambios en 1.0.0b4

- Dependencias acotadas a `httpx>=0.27,<1` y `pydantic>=2.6,<3`. Con `pip install --pre`, pip podia
  instalar `httpx 1.0.dev`, que no es compatible, y el SDK fallaba al importarse.
- Incluye los cambios de 1.0.0b3, que no conviene usar: instala `ecuafact>=1.0.0b4`.

## Cambios en 1.0.0b3

- Opcion `additional_info` para enviar la cabecera `X-Additional-Info` (texto del integrador que el API registra en
  sus logs para soporte; ASCII imprimible, sin espacios al inicio o al final, maximo 256 caracteres), en el
  cliente y por llamada. No aparece en errores ni en la representacion de las opciones.
- **Cambio de comportamiento:** el tiempo maximo por intento por defecto (`timeout`) baja de 100 a 30 segundos.
  Si tus llamadas tardan mas, fija el valor de forma explicita.

## Documentacion

- [Guia del SDK Python](https://docsapi.ecuafact.com/v1/guias/sdk-python)

## Licencia

MIT.
