Metadata-Version: 2.4
Name: factusmart
Version: 0.1.0
Summary: SDK oficial de Python del API de facturación electrónica de FactuSmart (SUNAT, Perú).
Author: FactuSmart
License: MIT
Project-URL: Documentacion, https://factusmart.pe/api-facturacion-electronica/docs/sdks
Project-URL: Repositorio, https://github.com/factusmart/factusmart-python
Keywords: factusmart,sunat,facturacion electronica,peru,cpe,boleta,factura
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Natural Language :: Spanish
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
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: Topic :: Office/Business :: Financial :: Accounting
Classifier: Typing :: Typed
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# FactuSmart — SDK de Python

SDK oficial del **API de facturación electrónica de FactuSmart** (SUNAT, Perú):
facturas, boletas, notas de crédito/débito, guías de remisión (GRE 2.0) y
anulaciones.

- Python 3.8 o superior.
- **Cero dependencias**: solo librería estándar (`urllib`).
- Con type hints (`py.typed`).
- Documentación completa del API: https://factusmart.pe/api-facturacion-electronica/docs

## Instalar

```bash
pip install factusmart
```

## Uso en 10 líneas

```python
import factusmart

fs = factusmart.Cliente(api_key="TU_API_KEY", base_url="https://TU_URL_BASE/api/v1/issuer", ruc="20123456789")

calc = factusmart.calcular_comprobante([
    {"descripcion": "Servicio de consultoría", "cantidad": 1, "valor_unitario": 100, "afectacion": "10"},
])

r = fs.comprobantes.emitir({
    "codigo_tipo_documento": "01",
    "datos_del_cliente_o_receptor": {"codigo_tipo_documento_identidad": "6", "numero_documento": "20555555555"},
    "items": calc["items"], "totales": calc["totales"],
})

print(r["aceptado_por_sunat"], r["serie_numero"])
```

`base_url` **no es igual para todas las cuentas**: cada cuenta vive en un
servidor distinto. Cópiala de tu panel de integrador (pestaña Configuración,
"URL base de la API").

## Opciones del cliente

| Opción | Obligatoria | Por defecto | Para qué |
|---|---|---|---|
| `api_key` | sí | — | Tu API key (va en el header `X-API-Key`) |
| `base_url` | sí | — | URL base de tu cuenta, sin barra final |
| `ruc` | no | — | RUC emisor por defecto. Se cambia en cada llamada con `ruc=` |
| `timeout` | no | `60` | Segundos por petición (SUNAT a veces tarda) |
| `max_reintentos` | no | `3` | Ver "Reintentos e idempotencia" |
| `verificar_ssl` | no | `True` | `False` **solo** en pruebas locales con certificado propio |

Si falta `api_key` o `base_url`, el cliente lanza `ValueError` al crearse.

## Métodos

Los datos se envían y se reciben **tal cual los define el API** (claves en
español con guion bajo). Todos los métodos que operan sobre un RUC aceptan
`ruc=` para cambiar el del constructor (en GET va como `?ruc=`; en POST va en el
cuerpo, y si tú ya pusiste `ruc` en los datos, se respeta el tuyo).

| Método | Endpoint | Devuelve |
|---|---|---|
| `fs.ping(ruc=None)` | `GET /ping` | dict |
| `fs.cuenta.saldo()` | `GET /account` | dict |
| `fs.series.listar(ruc=None)` | `GET /series` | dict |
| `fs.comprobantes.emitir(datos, ruc=None, idempotency_key=None)` | `POST /documents` | dict |
| `fs.comprobantes.listar(filtros=None, ruc=None)` | `GET /documents` | dict |
| `fs.comprobantes.consultar(external_id, ruc=None)` | `GET /documents/{id}` | dict |
| `fs.comprobantes.descargar(external_id, tipo, ruc=None)` | `GET /documents/{id}/{pdf\|xml\|cdr}` | **bytes** |
| `fs.comprobantes.reenviar(external_id, ruc=None)` | `POST /documents/{id}/reenviar` | dict |
| `fs.comprobantes.consultar_sunat(external_id, ruc=None)` | `POST /documents/{id}/consultar-sunat` | dict |
| `fs.comprobantes.historial(external_id, ruc=None)` | `GET /documents/{id}/historial` | dict |
| `fs.anulaciones.crear(datos, ruc=None)` | `POST /anulaciones` | dict |
| `fs.anulaciones.estado(ticket, ruc=None)` | `GET /anulaciones/{ticket}/status` | dict |
| `fs.guias.emitir(datos, ruc=None)` | `POST /guias` | dict |
| `fs.guias.estado(external_id, ruc=None)` | `GET /guias/{id}/status` | dict |
| `fs.rucs.listar()` | `GET /rucs` | dict |
| `fs.rucs.crear(datos)` | `POST /rucs` | dict |
| `fs.rucs.activar(ruc, activo)` | `PATCH /rucs/{ruc}/activation` | dict |

Filtros de `comprobantes.listar`: `estado`, `desde`, `hasta` (YYYY-MM-DD),
`limite` (1 a 100) y `antes_de` (pasa el `siguiente_cursor` de la página
anterior).

Guardar el PDF:

```python
with open("comprobante.pdf", "wb") as f:
    f.write(fs.comprobantes.descargar(r["external_id"], "pdf"))
```

### Lo que `comprobantes.emitir` completa por ti

Solo si no lo mandas:

- `numero_documento`: `"#"` (FactuSmart asigna el correlativo). Si mandas tu
  propio número, se respeta.
- `fecha_de_emision` y `hora_de_emision`: fecha y hora **de Perú**
  (America/Lima), no la de tu servidor.
- `codigo_tipo_moneda`: `"PEN"`.
- `codigo_tipo_operacion`: `"0101"` en facturas y boletas (en notas no aplica).

`guias.emitir` completa igual `numero_documento`, `fecha_de_emision` y
`hora_de_emision`.

## Calcular IGV y totales

El API espera los importes ya calculados. `calcular_comprobante` lo hace por ti:
recibe cada ítem con `descripcion`, `cantidad`, `valor_unitario` (**sin IGV**) y
`afectacion`, y devuelve `{"items": [...], "totales": {...}}` listo para enviar.

| `afectacion` | Significado | IGV |
|---|---|---|
| `"10"` | Gravado | 18% |
| `"20"` | Exonerado | 0 |
| `"30"` | Inafecto | 0 |
| `"40"` | Exportación | 0 (va en `total_exportacion`) |

Opcionales por ítem: `codigo_interno` (recomendado: el SKU de tu sistema) y
`unidad_de_medida` (por defecto `NIU`; usa `ZZ` para servicios).

Redondea a 2 decimales por ítem con `decimal.Decimal` y ROUND_HALF_UP, así da
**exactamente los mismos números** que los SDK de PHP y Node.

## La regla de oro: revisa `aceptado_por_sunat`

Un HTTP 200 significa que FactuSmart **generó** tu comprobante, no que SUNAT lo
aceptó. SUNAT puede estar caída o rechazarlo: recibirás `estado: "01"` con
`aceptado_por_sunat: False` y el motivo en `sunat["descripcion"]`.

**Eso NO es una excepción.** El SDK te devuelve la respuesta tal cual y tú
decides:

```python
r = fs.comprobantes.emitir(datos)
if not r["aceptado_por_sunat"]:
    print("SUNAT no lo aceptó todavía:", r.get("sunat"), r.get("accion_requerida"))
    # corrige y reintenta gratis con: fs.comprobantes.reenviar(r["external_id"])
```

Mira también `entorno` (`"prueba"` o `"produccion"`) y `tiene_validez_fiscal`.

## Errores

Todas las excepciones están en `factusmart.errores` (y también en `factusmart`)
y heredan de `FactuSmartError`, que trae:

- `status`: código HTTP (`None` si fue un error de red),
- `mensaje`: el mensaje del API, en español,
- `respuesta`: el cuerpo completo de la respuesta.

| HTTP | Excepción |
|---|---|
| 401 | `ErrorDeAutenticacion` |
| 402 | `SinCreditos` |
| 403 | `RucNoAutorizado` |
| 404 | `NoEncontrado` |
| 422 | `ErrorDeValidacion` |
| 429 | `LimiteDePeticiones` (con `segundos_de_espera` si se pudo leer) |
| otros / red | `FactuSmartError` |

```python
from factusmart import errores

try:
    r = fs.comprobantes.emitir(datos)
except errores.ErrorDeValidacion as e:
    print("Datos inválidos:", e.mensaje)   # no se cobró el crédito
except errores.SinCreditos:
    print("Recarga créditos")
except errores.FactuSmartError as e:
    print("Error", e.status, e.mensaje)
```

## Reintentos e idempotencia

- `comprobantes.emitir` **siempre** manda el header `Idempotency-Key`: la tuya
  (`idempotency_key="id-de-tu-venta"`) o un UUID v4 generado. **La misma clave
  se usa en todos los reintentos de esa llamada**, así un reintento nunca emite
  dos veces ni cobra dos veces. Si llamas dos veces con la misma clave,
  recibes la misma respuesta.
- Se reintenta solo ante: error de red/timeout, HTTP 429, 502, 503 y 504, con
  espera creciente (1 s, 2 s, 4 s…), hasta `max_reintentos` veces. Ante un
  límite de peticiones se respeta el header `Retry-After` o el "Espera N
  segundos" del mensaje.
- En `emitir`, el HTTP 409 ("ya hay una emisión en curso con esa
  Idempotency-Key") también se reintenta con la misma clave.
- Se reintentan las consultas (GET) y `rucs.crear` (el API es idempotente por
  RUC).
- **Nunca** se reintentan solas: `anulaciones.crear`, `guias.emitir`,
  `comprobantes.reenviar`, `comprobantes.consultar_sunat` y `rucs.activar`.

## Pruebas (para quien mantiene el SDK)

```bash
python -m unittest discover -s tests -v
```

- `test_unitarios.py` y `test_calculo.py` no usan red.
- `test_contrato.py` lee `openapi.yaml` (variable `FACTUSMART_OPENAPI`) y falla
  si algún `operationId` no tiene método en el SDK.
- `test_integracion.py` va contra el API **local** (`FACTUSMART_TEST_BASE_URL`,
  `FACTUSMART_TEST_API_KEY`, `FACTUSMART_TEST_RUC`; `FACTUSMART_INTEGRACION=0`
  para saltarlas). Gasta 2 créditos por corrida. Se niega a correr contra
  `factusmart.pe`.

## Licencia

MIT. Ver [LICENSE](LICENSE).
