libredte-lib-sdk 0.1.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.
- libredte_lib_sdk-0.1.0/.github/workflows/ci.yml +67 -0
- libredte_lib_sdk-0.1.0/.gitignore +15 -0
- libredte_lib_sdk-0.1.0/CLAUDE.md +482 -0
- libredte_lib_sdk-0.1.0/COPYING +661 -0
- libredte_lib_sdk-0.1.0/Makefile +42 -0
- libredte_lib_sdk-0.1.0/PKG-INFO +878 -0
- libredte_lib_sdk-0.1.0/README.rst +198 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/__init__.py +75 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/__init__.py +31 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/common.py +29 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/document/__init__.py +39 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/document/builder.py +68 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/document/dispatcher.py +49 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/document/models.py +169 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/document/renderer.py +60 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/enums.py +18 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/identifier/__init__.py +27 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/identifier/caf_faker.py +49 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/identifier/caf_loader.py +31 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/identifier/caf_validator.py +36 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/identifier/models.py +37 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/integration/__init__.py +17 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/integration/models.py +46 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/integration/sii_dte.py +83 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/trading_parties/__init__.py +21 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/trading_parties/mandatario_manager.py +45 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/trading_parties/models.py +38 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/client.py +184 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/exceptions.py +117 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/py.typed +0 -0
- libredte_lib_sdk-0.1.0/libredte_lib_sdk/sdk.py +79 -0
- libredte_lib_sdk-0.1.0/pyproject.toml +103 -0
- libredte_lib_sdk-0.1.0/tests/__init__.py +0 -0
- libredte_lib_sdk-0.1.0/tests/conftest.py +25 -0
- libredte_lib_sdk-0.1.0/tests/integration/__init__.py +0 -0
- libredte_lib_sdk-0.1.0/tests/integration/conftest.py +79 -0
- libredte_lib_sdk-0.1.0/tests/integration/test_caf.py +68 -0
- libredte_lib_sdk-0.1.0/tests/integration/test_document_rendering.py +148 -0
- libredte_lib_sdk-0.1.0/tests/test_client.py +184 -0
- libredte_lib_sdk-0.1.0/tests/test_models.py +187 -0
- libredte_lib_sdk-0.1.0/tests/test_services.py +428 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
push:
|
|
6
|
+
branches:
|
|
7
|
+
- main
|
|
8
|
+
pull_request:
|
|
9
|
+
branches:
|
|
10
|
+
- main
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
tests:
|
|
14
|
+
name: Tests
|
|
15
|
+
timeout-minutes: 10
|
|
16
|
+
runs-on: ${{ matrix.os }}
|
|
17
|
+
strategy:
|
|
18
|
+
matrix:
|
|
19
|
+
os: [ubuntu-latest]
|
|
20
|
+
python-version: ['3.14']
|
|
21
|
+
|
|
22
|
+
steps:
|
|
23
|
+
- name: Check out repository
|
|
24
|
+
uses: actions/checkout@v4
|
|
25
|
+
|
|
26
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
27
|
+
uses: actions/setup-python@v5
|
|
28
|
+
with:
|
|
29
|
+
python-version: ${{ matrix.python-version }}
|
|
30
|
+
|
|
31
|
+
- name: Display Python version
|
|
32
|
+
run: python --version
|
|
33
|
+
|
|
34
|
+
- name: Install dependencies
|
|
35
|
+
run: |
|
|
36
|
+
python -m pip install --upgrade pip
|
|
37
|
+
pip install -e '.[dev]'
|
|
38
|
+
|
|
39
|
+
- name: Run Ruff (lint)
|
|
40
|
+
run: ruff check .
|
|
41
|
+
|
|
42
|
+
- name: Run Ruff (format)
|
|
43
|
+
run: ruff format --check .
|
|
44
|
+
|
|
45
|
+
# No corre los tests `live` (marker `live`, excluidos por defecto vía
|
|
46
|
+
# `addopts` en pyproject.toml): golpean la API real de LibreDTE y/o
|
|
47
|
+
# un ambiente de desarrollo local, no algo apto para CI.
|
|
48
|
+
- name: Run pytest
|
|
49
|
+
run: |
|
|
50
|
+
mkdir -p var
|
|
51
|
+
pytest -v \
|
|
52
|
+
--junitxml=var/tests-results.xml \
|
|
53
|
+
--cov=libredte_lib_sdk \
|
|
54
|
+
--cov-report=xml:var/tests-coverage.xml
|
|
55
|
+
|
|
56
|
+
- name: Upload pytest result report
|
|
57
|
+
if: failure()
|
|
58
|
+
uses: actions/upload-artifact@v4
|
|
59
|
+
with:
|
|
60
|
+
name: tests-results-python_${{ matrix.python-version }}.xml
|
|
61
|
+
path: var/tests-results.xml
|
|
62
|
+
|
|
63
|
+
- name: Upload Coverage Report
|
|
64
|
+
uses: actions/upload-artifact@v4
|
|
65
|
+
with:
|
|
66
|
+
name: tests-coverage-python_${{ matrix.python-version }}.xml
|
|
67
|
+
path: var/tests-coverage.xml
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
# `libredte_lib_sdk` — plan y bitácora
|
|
2
|
+
|
|
3
|
+
> Este archivo es la fuente de verdad del diseño. Si una sesión de chat se
|
|
4
|
+
> corta, léelo primero: acá está el porqué de cada decisión, no solo el qué.
|
|
5
|
+
|
|
6
|
+
## Objetivo
|
|
7
|
+
|
|
8
|
+
SDK Python (`libredte_lib_sdk`) que orquesta la **API** de LibreDTE Lib
|
|
9
|
+
(por defecto `https://core.libredte.cl/api`, spec en
|
|
10
|
+
`/api/openapi-docs.json`) para cubrir 5 operaciones de negocio sobre DTE
|
|
11
|
+
chilenos:
|
|
12
|
+
|
|
13
|
+
1. Emitir el **borrador** de un DTE.
|
|
14
|
+
2. Generar el DTE real, **timbrado y firmado**.
|
|
15
|
+
3. **Enviar** el DTE al SII.
|
|
16
|
+
4. Obtener el **estado** de un envío al SII por su **Track ID**.
|
|
17
|
+
5. Generar el **PDF** (o HTML) de un DTE (borrador o emitido).
|
|
18
|
+
|
|
19
|
+
Más 4 operaciones de soporte para el CAF y el certificado — 2 para poder
|
|
20
|
+
probar los 5 flujos de punta a punta sin credenciales reales, y 2 para
|
|
21
|
+
poder usar un CAF **real** en producción (no solo ficticio):
|
|
22
|
+
|
|
23
|
+
6. Generar un **CAF ficticio** (`billing.identifier.caf_faker`).
|
|
24
|
+
7. Generar un **certificado digital ficticio** (`billing.trading_parties
|
|
25
|
+
.mandatario_manager::createFakeCertificate`).
|
|
26
|
+
8. **Cargar un CAF real** desde su XML (`billing.identifier.caf_loader`).
|
|
27
|
+
9. **Validar un CAF** — vigencia, firma (`billing.identifier
|
|
28
|
+
.caf_validator`).
|
|
29
|
+
|
|
30
|
+
Este SDK es la capa de "lógica de negocio / orquestación DTE" para un
|
|
31
|
+
ecosistema donde una app Django, más adelante, será *solo* vista + auth +
|
|
32
|
+
persistencia. Toda la lógica de facturación electrónica vive acá. Por lo
|
|
33
|
+
tanto: **API estable, servicios explícitos, sin acoplar a Django ni a
|
|
34
|
+
ningún framework web**.
|
|
35
|
+
|
|
36
|
+
### Relación con `libredte-lib-core-bridge-python`
|
|
37
|
+
|
|
38
|
+
Referencia de diseño (no dependencia):
|
|
39
|
+
`/Users/delaf/dev/docker-python3.14-caddy-server/sites/_bridge-libredte/libredte-lib-core-bridge-python`.
|
|
40
|
+
|
|
41
|
+
Ese paquete llama al *mismo* código PHP (`libredte-lib-core`) pero vía
|
|
42
|
+
`phpy` (embebido, dentro de un contenedor especial). Acá se llama al
|
|
43
|
+
*mismo* código, pero vía HTTP a la API pública. Por eso la convención de
|
|
44
|
+
nombres de operación es idéntica: `paquete.componente.worker::operacion`
|
|
45
|
+
(ej. `billing.document.builder::build`), y la API la expone 1:1 como
|
|
46
|
+
`POST /{paquete}/{componente}/{worker}/{operacion}`.
|
|
47
|
+
|
|
48
|
+
**No es una API REST**, aunque el proyecto la llamó así al principio —
|
|
49
|
+
corregido a pedido del usuario. Todo pasa por `POST`, sin distinción de
|
|
50
|
+
verbos HTTP por semántica (crear/leer/validar todas usan `POST`), y las
|
|
51
|
+
rutas son RPC (`paquete/componente/worker/operacion`, un identificador
|
|
52
|
+
de operación, no un recurso). Es más precisa llamarla "API HTTP" o "API
|
|
53
|
+
RPC sobre HTTP" — así se la llama en el resto de este archivo, en el
|
|
54
|
+
código y en `README.rst`.
|
|
55
|
+
|
|
56
|
+
## `core.libredte.cl` vs `pro.libredte.cl`
|
|
57
|
+
|
|
58
|
+
El SDK apunta por defecto a **`core.libredte.cl`** (decisión explícita
|
|
59
|
+
del usuario — hubo una pasada previa apuntando a `pro.libredte.cl` por
|
|
60
|
+
defecto, revertida). Ambas exponen el mismo contrato (mismo
|
|
61
|
+
`openapi-docs.json`, mismos paths `billing/*`, mismo formato de
|
|
62
|
+
respuesta); se diferencian en el límite de solicitudes:
|
|
63
|
+
`core.libredte.cl` limita a 50 cada 24 horas (header
|
|
64
|
+
`x-ratelimit-limit`), `pro.libredte.cl` a ~3600/hora — y trae además
|
|
65
|
+
paquetes adicionales (`human_resources`) fuera de alcance de este SDK.
|
|
66
|
+
|
|
67
|
+
`LIBREDTE_LIB_SDK_BASE_URL`/`base_url` está documentado como opción pública
|
|
68
|
+
en `README.rst` — cambiar el default en el futuro es una decisión de
|
|
69
|
+
producto, no algo a inferir de este archivo.
|
|
70
|
+
|
|
71
|
+
Sigue siendo configurable (`LibreDTE(base_url=...)` o env var
|
|
72
|
+
`LIBREDTE_LIB_SDK_BASE_URL`) por si hace falta apuntar a `core` u otro
|
|
73
|
+
ambiente. `ApiClient` controla el caso de límite excedido: si la API
|
|
74
|
+
responde `429`, se levanta `LibreDteRateLimitError` (subclase de
|
|
75
|
+
`LibreDteApiError`) con `.retry_after`/`.limit`/`.remaining` leídos de los
|
|
76
|
+
headers `Retry-After`/`X-RateLimit-Limit`/`X-RateLimit-Remaining`. No se
|
|
77
|
+
pudo reproducir un `429` real en vivo (habría implicado agotar la cuota
|
|
78
|
+
real de un ambiente compartido, sin ningún beneficio de diseño) — la
|
|
79
|
+
implementación sigue el status code HTTP estándar más los headers que la
|
|
80
|
+
propia API ya expone de forma consistente en toda respuesta.
|
|
81
|
+
|
|
82
|
+
## Decisiones de diseño (YAGNI / KISS / SRP)
|
|
83
|
+
|
|
84
|
+
- **No reimplementar el esquema DTE del SII en Python.** Los datos de
|
|
85
|
+
`Encabezado`/`Detalle`/etc. (`parsedData`) se pasan como `dict` tal cual
|
|
86
|
+
el formato SII — la validación real la hace `libredte-lib-core` en el
|
|
87
|
+
servidor.
|
|
88
|
+
- **DTO solo donde el SDK aporta valor**: contenedores tipados + conversión
|
|
89
|
+
de/hacia el payload JSON de la API (nombres de campo, base64 de XML).
|
|
90
|
+
- **Un servicio por worker de la API**, no por operación — mismo criterio
|
|
91
|
+
de SRP que usa la API misma.
|
|
92
|
+
- **La estructura de paquetes Python espeja la de la API**:
|
|
93
|
+
`billing.{document,identifier,trading_parties,integration}`, cada
|
|
94
|
+
componente con un `*Component` que agrupa sus servicios por worker (ver
|
|
95
|
+
"Estructura del módulo" abajo). Esto se decidió explícitamente en esta
|
|
96
|
+
segunda pasada (con autorización para crear submódulos): con la API
|
|
97
|
+
exponiendo más de 40 operaciones a través de 7 componentes (y otro
|
|
98
|
+
paquete completo, `human_resources`, en `pro.libredte.cl`), un único
|
|
99
|
+
módulo plano ya no escala — un paquete/componente nuevo es un
|
|
100
|
+
directorio nuevo, no tocar nada existente.
|
|
101
|
+
- **Solo síncrono**, sin `pydantic`, cliente HTTP genérico y delgado,
|
|
102
|
+
errores tipados sin sobre-modelar por código HTTP — mismos argumentos
|
|
103
|
+
que en la primera pasada, sin cambios.
|
|
104
|
+
- **`httpx`** como única dependencia de runtime.
|
|
105
|
+
|
|
106
|
+
## Estructura del módulo
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
libredte_lib_sdk/
|
|
110
|
+
├── __init__.py # exports públicos (re-exporta todo lo de billing/*)
|
|
111
|
+
├── py.typed
|
|
112
|
+
├── client.py # ApiClient: operation_id -> URL, JSON/binario, rate limit
|
|
113
|
+
├── exceptions.py # LibreDteSdkError, LibreDteApiError (+RateLimit), ConnectionError
|
|
114
|
+
├── sdk.py # LibreDTE: fachada única -> sdk.billing.*
|
|
115
|
+
└── billing/ # paquete "billing" de la API
|
|
116
|
+
├── __init__.py # BillingPackage: agrupa los componentes
|
|
117
|
+
├── common.py # XmlPayloadMixin (xml_bytes/xml compartido)
|
|
118
|
+
├── enums.py # SiiEnvironment
|
|
119
|
+
├── document/ # componente "document"
|
|
120
|
+
│ ├── __init__.py # DocumentComponent
|
|
121
|
+
│ ├── models.py # AutorizacionDte, Emisor, Document, Envelope, RenderResult, Rendering
|
|
122
|
+
│ ├── builder.py # DocumentBuilderService (build_draft/build_signed)
|
|
123
|
+
│ ├── dispatcher.py # DocumentDispatcherService (create -> sobre EnvioDTE)
|
|
124
|
+
│ └── renderer.py # DocumentRendererService (render -> RenderResult)
|
|
125
|
+
├── identifier/ # componente "identifier"
|
|
126
|
+
│ ├── __init__.py # IdentifierComponent
|
|
127
|
+
│ ├── models.py # Caf
|
|
128
|
+
│ ├── caf_faker.py # CafFakerService (create, ficticio)
|
|
129
|
+
│ ├── caf_loader.py # CafLoaderService (load, real)
|
|
130
|
+
│ └── caf_validator.py # CafValidatorService (validate)
|
|
131
|
+
├── trading_parties/ # componente "trading_parties"
|
|
132
|
+
│ ├── __init__.py # TradingPartiesComponent
|
|
133
|
+
│ ├── models.py # Certificate
|
|
134
|
+
│ └── mandatario_manager.py # MandatarioManagerService (create_fake_certificate)
|
|
135
|
+
└── integration/ # componente "integration"
|
|
136
|
+
├── __init__.py # IntegrationComponent
|
|
137
|
+
├── models.py # SendResult, SiiStatus
|
|
138
|
+
└── sii_dte.py # SiiDteService (send/check_status)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Extensible por diseño: agregar un worker nuevo de un componente ya
|
|
142
|
+
soportado es un archivo nuevo + una línea en el `__init__.py` de ese
|
|
143
|
+
componente. Agregar un componente nuevo (o el paquete `human_resources`
|
|
144
|
+
completo) es un subpaquete nuevo + una línea en `billing/__init__.py` (o
|
|
145
|
+
en `sdk.py` para un paquete nuevo) — nada existente se toca.
|
|
146
|
+
|
|
147
|
+
Los 5 flujos de negocio pedidos, más los 2 fakers, mapeados:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
sdk = LibreDTE()
|
|
151
|
+
b = sdk.billing
|
|
152
|
+
|
|
153
|
+
# Fakers (solo para pruebas) o CAF real (producción):
|
|
154
|
+
caf = b.identifier.caf_faker.create(emisor_dict, codigo_documento=33)
|
|
155
|
+
# caf = b.identifier.caf_loader.load(xml_base64_subido_por_el_usuario)
|
|
156
|
+
# b.identifier.caf_validator.validate(caf.xml_base64) # opcional, valida antes de usarlo
|
|
157
|
+
certificate = b.trading_parties.mandatario_manager.create_fake_certificate(
|
|
158
|
+
mandatario_dict,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# 1. Borrador.
|
|
162
|
+
borrador = b.document.builder.build_draft(parsed_data)
|
|
163
|
+
|
|
164
|
+
# 2. Timbrado y firmado.
|
|
165
|
+
documento = b.document.builder.build_signed(
|
|
166
|
+
parsed_data, caf_xml=caf.xml_base64, certificate=certificate,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# 3. Enviar al SII (arma el sobre EnvioDTE y luego lo envía).
|
|
170
|
+
sobre = b.document.dispatcher.create(
|
|
171
|
+
documento.xml_base64, certificate=certificate, emisor=emisor_dto,
|
|
172
|
+
)
|
|
173
|
+
envio = b.integration.sii_dte.send(
|
|
174
|
+
sobre.xml_base64, certificate=certificate, company_rut=rut,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# 4. Estado por Track ID.
|
|
178
|
+
estado = b.integration.sii_dte.check_status(
|
|
179
|
+
envio.track_id, certificate=certificate, company_rut=rut,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# 5. HTML o PDF — misma forma de respuesta para ambos (ver más abajo).
|
|
183
|
+
html = b.document.renderer.render(documento.xml_base64, format='html').first
|
|
184
|
+
pdf = b.document.renderer.render(documento.xml_base64, format='pdf').first
|
|
185
|
+
pdf_bytes = pdf.content_bytes
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Verificación en vivo (contra `pro.libredte.cl`, con datos ficticios)
|
|
189
|
+
|
|
190
|
+
Toda la cadena se ejecutó de punta a punta usando el SDK real (no
|
|
191
|
+
curl/urllib) contra la API real, sin mocks:
|
|
192
|
+
|
|
193
|
+
| Paso | Resultado |
|
|
194
|
+
|---|---|
|
|
195
|
+
| `caf_faker.create(...)` | OK — CAF ficticio real, XML válido. |
|
|
196
|
+
| `mandatario_manager.create_fake_certificate(...)` | OK — certificado autofirmado real (CA de prueba "Derafu Test Certificate Authority"). |
|
|
197
|
+
| `builder.build_draft(...)` | OK — borrador, `is_timbrado == False`. |
|
|
198
|
+
| `builder.build_signed(...)` (con el CAF y certificado ficticios) | OK — documento con TED real, `is_timbrado == True`. |
|
|
199
|
+
| `dispatcher.create(...)` | OK — sobre `EnvioDTE` real y válido. |
|
|
200
|
+
| `sii_dte.send(...)` | **Falla, como es esperable**: el SII real (ambiente de certificación) rechaza el certificado ficticio con `AuthenticateException` ("XML Inválido, elemento «Certificate» no existe") — el SII realmente recibió y evaluó la solicitud, y la rechazó por autenticación, no por un error del SDK. Se propaga como `LibreDteApiError` normal, con `.php_class` identificando la excepción real. |
|
|
201
|
+
| `sii_dte.check_status(...)` | Mismo rechazo por autenticación (con un Track ID inventado, ya que no hay uno real posible sin autenticación exitosa). |
|
|
202
|
+
| `renderer.render(..., format='html')` | OK — HTML real del documento, con timbre. |
|
|
203
|
+
| `renderer.render(..., format='pdf')` | **OK** (ver "Render de PDF: bug y fix" abajo) — PDF real, válido, verificado con `file` (`PDF document, version 1.4, 1 pages`). Requiere una API con el fix del render desplegado (ver esa sección: hoy solo confirmado contra un ambiente de desarrollo local, no contra `pro.libredte.cl` pública). |
|
|
204
|
+
|
|
205
|
+
Esto confirma: el diseño de servicios/DTO es correcto, y el manejo de
|
|
206
|
+
errores de la API funciona (tanto para rechazos de negocio como de
|
|
207
|
+
autenticación real del SII).
|
|
208
|
+
|
|
209
|
+
Durante la primera verificación (contra el shape viejo de `render`) se
|
|
210
|
+
encontró y corrigió un bug real del SDK: `DocumentRendererService
|
|
211
|
+
.render()` asumía que cualquier respuesta no-bytes podía convertirse con
|
|
212
|
+
`bytes(result)` — pero para formatos de texto (ej. `html`, en el shape
|
|
213
|
+
viejo) la API devolvía un `string` dentro del sobre JSON, y `bytes(str)`
|
|
214
|
+
sin encoding explícito lanza `TypeError`. Quedó resuelto de raíz al
|
|
215
|
+
migrar al shape nuevo (`render()` ahora siempre decodifica base64 desde
|
|
216
|
+
`Rendering.content_base64`, sin ninguna rama `str`/`bytes` especial) —
|
|
217
|
+
ver "Render de PDF: bug y fix" abajo.
|
|
218
|
+
|
|
219
|
+
## Render de PDF: bug y fix
|
|
220
|
+
|
|
221
|
+
### El bug (histórico)
|
|
222
|
+
|
|
223
|
+
`POST /billing/document/renderer/render` con `options.renderer.format` en
|
|
224
|
+
cualquier formato **binario** (`pdf`) devolvía siempre `500`:
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
Derafu\Http\Response::asText(): Argument #1 ($data) must be of type
|
|
228
|
+
string, array given, called in
|
|
229
|
+
.../vendor/derafu/http/src/Middleware/ResponseNormalizerMiddleware.php
|
|
230
|
+
on line 108
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Reproducible en `core.libredte.cl` y `pro.libredte.cl`, con cualquier
|
|
234
|
+
documento/opciones/headers — no era nada influenciable desde el cliente
|
|
235
|
+
(se descartó exhaustivamente: tipo de documento, timbrado o no, claves
|
|
236
|
+
extra en `options.renderer`, headers `Accept`). El motivo: la API
|
|
237
|
+
devolvía el binario como una respuesta HTTP cruda, fuera del sobre JSON
|
|
238
|
+
`{meta, data}` habitual — y esa rama especial de la capa HTTP
|
|
239
|
+
(`derafu/http`, `ResponseNormalizerMiddleware`) estaba rota.
|
|
240
|
+
`format='html'` (que sí iba dentro del sobre JSON normal, como `string`)
|
|
241
|
+
funcionaba bien — la pista de que el problema era justo esa rama binaria
|
|
242
|
+
"cruda", no el renderer de DTE en sí.
|
|
243
|
+
|
|
244
|
+
### El fix
|
|
245
|
+
|
|
246
|
+
La recomendación (dada en esta misma sesión, antes de que el usuario
|
|
247
|
+
mostrara el fix): que `render` **siempre** devuelva su resultado dentro
|
|
248
|
+
del sobre JSON de siempre, en base64 — el mismo patrón que ya usa el
|
|
249
|
+
resto de la API (XML de documentos, sobres, CAF) — en vez de una rama
|
|
250
|
+
especial para contenido binario. Es exactamente lo que se implementó, con
|
|
251
|
+
un plus: soporte para **múltiples archivos por llamada** (copias
|
|
252
|
+
tributarias/cedibles). Shape nuevo, confirmado en vivo:
|
|
253
|
+
|
|
254
|
+
```json
|
|
255
|
+
{
|
|
256
|
+
"meta": {"timestamp": ..., "data_type": "...RenderResult"},
|
|
257
|
+
"data": {
|
|
258
|
+
"renderings": [
|
|
259
|
+
{
|
|
260
|
+
"content": "<base64>",
|
|
261
|
+
"mimeType": "application/pdf",
|
|
262
|
+
"filename": "LibreDTE_76192083-9_T033F000000000001_tributaria.pdf",
|
|
263
|
+
"label": "tributaria",
|
|
264
|
+
"copies": 1,
|
|
265
|
+
"copyNumber": 1
|
|
266
|
+
}
|
|
267
|
+
]
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
**Verificado en vivo, con el SDK real, contra un ambiente de desarrollo
|
|
273
|
+
local (`http://localhost:9000/api`)**: `render(..., format='pdf').first
|
|
274
|
+
.content_bytes` produce un PDF real y válido — confirmado con `file`
|
|
275
|
+
(`PDF document, version 1.4, 1 pages`), generado a partir de un documento
|
|
276
|
+
timbrado y firmado con CAF y certificado ficticios (`caf_faker`/
|
|
277
|
+
`mandatario_manager.create_fake_certificate`).
|
|
278
|
+
|
|
279
|
+
### Copias múltiples (`renderings`)
|
|
280
|
+
|
|
281
|
+
Agregado en una segunda vuelta sobre el mismo endpoint: `bag.options
|
|
282
|
+
.renderer.renderings` (`dict[str, int]`, ej. `{'tributaria': 2,
|
|
283
|
+
'cedible': 1}`) pide una o más presentaciones, y una o más copias de
|
|
284
|
+
cada una, en la misma llamada. Sin esa clave, la API genera una única
|
|
285
|
+
copia `'tributaria'` (default). `Rendering` ahora trae `label` (qué
|
|
286
|
+
presentación es), `copies` (cuántas se pidieron de ese label) y
|
|
287
|
+
`copy_number` (cuál de esas copias es esta, 1-indexado) — necesarios
|
|
288
|
+
para poder distinguir entre varios ítems de `renderings` cuando se pide
|
|
289
|
+
más de uno. `RenderResult.by_label(label)` filtra por presentación.
|
|
290
|
+
|
|
291
|
+
Reglas de negocio confirmadas en vivo contra `localhost:9000` (worker
|
|
292
|
+
`billing.document.renderer`, mismo endpoint, sin cambios de ruta):
|
|
293
|
+
|
|
294
|
+
- Presentación inexistente (ej. `'noexiste'`) → `LibreDteApiError` 500,
|
|
295
|
+
`RendererException`, `detail` contiene `"no existe"`.
|
|
296
|
+
- Presentación que la API no puede generar para ese tipo de documento
|
|
297
|
+
(ej. `'cedible'` — copia cedible/acuse de recibo — en una boleta, que
|
|
298
|
+
no lo admite) **se omite en silencio** si se pidió junto con al menos
|
|
299
|
+
una que sí se pudo generar: `renderings={'tributaria': 1, 'cedible':
|
|
300
|
+
1}` en una boleta devuelve solo la `'tributaria'`, sin error.
|
|
301
|
+
- Si **ninguna** de las presentaciones pedidas se pudo generar (ej.
|
|
302
|
+
pedir *solo* `'cedible'` para una boleta) → `LibreDteApiError` 500,
|
|
303
|
+
`RendererException`, `detail` distinto ("no fue posible generar
|
|
304
|
+
ninguna...").
|
|
305
|
+
|
|
306
|
+
El SDK no modela `'tributaria'`/`'cedible'` como un enum cerrado: son
|
|
307
|
+
strings que la propia API valida (y da un error claro si no reconoce
|
|
308
|
+
uno) — hardcodear los valores conocidos hoy sería asumir que no van a
|
|
309
|
+
agregar un tercero mañana.
|
|
310
|
+
|
|
311
|
+
### Ojo: esto puede no estar desplegado en la API pública todavía
|
|
312
|
+
|
|
313
|
+
Al momento de escribir esto, este contrato (`renderings` incluido) se
|
|
314
|
+
confirmó únicamente en el ambiente de desarrollo local del usuario
|
|
315
|
+
(`http://localhost:9000/api`) — `pro.libredte.cl`/`core.libredte.cl` (la
|
|
316
|
+
API pública) seguían con el shape viejo (y el 500 para `pdf`) la última
|
|
317
|
+
vez que se probaron. El SDK **solo** soporta el shape nuevo
|
|
318
|
+
(`data.renderings[]`) — no hay compatibilidad con el shape viejo. Si la
|
|
319
|
+
API contra la que apunta `LibreDTE()` (default: `core.libredte.cl`)
|
|
320
|
+
todavía no tiene el fix desplegado, `render()` va a fallar
|
|
321
|
+
(`TypeError`/`KeyError`, según qué tan viejo sea el shape que devuelva)
|
|
322
|
+
— no es un bug del SDK, es esa API sin actualizar todavía.
|
|
323
|
+
|
|
324
|
+
### Cómo probar el render localmente
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
make install-dev
|
|
328
|
+
|
|
329
|
+
# Contra la API pública (default: core.libredte.cl):
|
|
330
|
+
.venv/bin/pytest -m live -v tests/integration/test_document_rendering.py
|
|
331
|
+
|
|
332
|
+
# Contra un ambiente propio con el fix ya desplegado:
|
|
333
|
+
LIBREDTE_LIB_SDK_BASE_URL=http://localhost:9000/api .venv/bin/pytest \
|
|
334
|
+
-m live -v tests/integration/test_document_rendering.py
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
8 tests, todos verdes contra `localhost:9000` (confirmado en esta
|
|
338
|
+
sesión): formato HTML, formato PDF, default (una copia tributaria),
|
|
339
|
+
varias presentaciones en una llamada, varias copias de una misma
|
|
340
|
+
presentación, omisión silenciosa de una presentación no soportada,
|
|
341
|
+
presentación inexistente, y "ninguna presentación pudo generarse".
|
|
342
|
+
|
|
343
|
+
## Otros supuestos no verificados en vivo
|
|
344
|
+
|
|
345
|
+
- **`SendResult.track_id`** y **`SiiStatus.status`**: la respuesta
|
|
346
|
+
exitosa real de `sendXmlDocument`/`checkXmlDocumentSentStatus` nunca se
|
|
347
|
+
pudo observar (el SII rechaza la autenticación antes de llegar a un
|
|
348
|
+
200, con cualquier certificado que no sea uno real emitido para un RUT
|
|
349
|
+
real — ver tabla de verificación arriba). Los nombres de campo
|
|
350
|
+
(`trackId`/`track_id`, `status`/`estado`/`state`) siguen siendo el
|
|
351
|
+
mejor supuesto según la convención de nombres del resto de la API, con
|
|
352
|
+
fallback defensivo y `.raw` como fuente de verdad completa. Esto solo
|
|
353
|
+
se puede cerrar con un certificado real emitido para un RUT real con
|
|
354
|
+
folios autorizados de verdad — fuera de alcance de este SDK/sesión.
|
|
355
|
+
- `environment`: `0` = certificación, `1` = producción — convención
|
|
356
|
+
estándar del ecosistema LibreDTE, coherente con lo observado (se probó
|
|
357
|
+
contra `environment: 0` y efectivamente habló con el SII de
|
|
358
|
+
certificación).
|
|
359
|
+
|
|
360
|
+
## Cobertura de la API: qué está mapeado y qué falta
|
|
361
|
+
|
|
362
|
+
`http://localhost:9000/api/openapi-docs.json` expone hoy 39 operaciones
|
|
363
|
+
(todas bajo el paquete `billing`; no se detectó `human_resources` en este
|
|
364
|
+
ambiente local, a diferencia de `pro.libredte.cl`). El SDK mapea 9:
|
|
365
|
+
|
|
366
|
+
| Worker (componente) | Operación | ¿En el SDK? |
|
|
367
|
+
|---|---|---|
|
|
368
|
+
| `document.builder` | `build` | ✅ `DocumentBuilderService.build_draft`/`build_signed` |
|
|
369
|
+
| `document.dispatcher` | `create` | ✅ `DocumentDispatcherService.create` |
|
|
370
|
+
| `document.renderer` | `render` | ✅ `DocumentRendererService.render` |
|
|
371
|
+
| `identifier.caf_faker` | `create` | ✅ `CafFakerService.create` |
|
|
372
|
+
| `identifier.caf_loader` | `load` | ✅ `CafLoaderService.load` |
|
|
373
|
+
| `identifier.caf_validator` | `validate` | ✅ `CafValidatorService.validate` |
|
|
374
|
+
| `trading_parties.mandatario_manager` | `createFakeCertificate` | ✅ `MandatarioManagerService.create_fake_certificate` |
|
|
375
|
+
| `integration.sii_dte` | `sendXmlDocument` | ✅ `SiiDteService.send` |
|
|
376
|
+
| `integration.sii_dte` | `checkXmlDocumentSentStatus` | ✅ `SiiDteService.check_status` |
|
|
377
|
+
|
|
378
|
+
`caf_loader`/`caf_validator` se agregaron en esta misma sesión (decisión
|
|
379
|
+
del usuario: cerrar la única brecha real dentro de lo ya acordado antes
|
|
380
|
+
de pasar a la app Django — ver "Estado"). Verificado en vivo el flujo
|
|
381
|
+
completo con un CAF **no ficticio** en el sentido de "cargado, no
|
|
382
|
+
generado" (`caf_faker::create` → `caf_loader::load` de ese mismo XML →
|
|
383
|
+
`caf_validator::validate` → `build_signed()`), simulando cómo lo haría
|
|
384
|
+
la app Django con un CAF real subido por el usuario.
|
|
385
|
+
|
|
386
|
+
Detalle no obvio del contrato, confirmado en vivo: `caf_validator
|
|
387
|
+
::validate` espera el **XML en base64** (el mismo dato que
|
|
388
|
+
`caf_loader::load`), no la entidad `Caf` completa como `dict` — pasar el
|
|
389
|
+
`dict` da 422 ("requires string data"), aunque el parámetro de la
|
|
390
|
+
operación se llame `caf` y no `xml`.
|
|
391
|
+
|
|
392
|
+
Las 30 restantes, agrupadas por lo que aportarían a una app de
|
|
393
|
+
facturación (emisión), con mi recomendación de prioridad:
|
|
394
|
+
|
|
395
|
+
**Expanden a un caso de uso legítimo pero nuevo (no estaba en el pedido
|
|
396
|
+
original) — evaluaría agregar recién cuando la app Django los necesite,
|
|
397
|
+
no antes:**
|
|
398
|
+
|
|
399
|
+
| Worker | Operaciones | Qué habilitan |
|
|
400
|
+
|---|---|---|
|
|
401
|
+
| `document.validator` | `validate`, `validateSchema`, `validateSignature` | Validar los datos de un documento *antes* de construirlo — feedback de UI más temprano/claro que esperar el error de `builder.build`. |
|
|
402
|
+
| `document.dispatcher` | `loadXml`, `validate`, `validateSchema`, `validateSignature` | Cargar/validar un sobre `EnvioDTE` ya existente (ej. reprocesar uno guardado). |
|
|
403
|
+
| `document.loader` | `loadXml` | Cargar un documento individual desde su XML (ej. reconstruir datos desde un DTE ya emitido). |
|
|
404
|
+
| `trading_parties.mandatario_manager` | `createFromCertificate` | Extraer RUT/nombre/email desde un certificado **real** subido por el usuario — útil para mostrar "certificado de Juan Pérez, vence en X días" en la futura UI. |
|
|
405
|
+
| `integration.sii_dte` | `validateDocument`, `validateDocumentSignature` | Confirmar que un documento ya enviado quedó aceptado en el SII (reconciliación), independiente de `checkXmlDocumentSentStatus`. |
|
|
406
|
+
| `integration.sii_dte` | `requestXmlDocumentSentStatusByEmail` | Pedirle al SII que mande el estado por correo — alternativa a consultar, uso más bien excepcional. |
|
|
407
|
+
|
|
408
|
+
**Son un dominio de negocio distinto — "recibir" documentos de
|
|
409
|
+
proveedores (compras) y libros contables mensuales, no "emitir" (ventas)
|
|
410
|
+
— agregaría solo si la app Django va a cubrir eso, es una decisión de
|
|
411
|
+
alcance, no una omisión:**
|
|
412
|
+
|
|
413
|
+
| Componente | Qué es |
|
|
414
|
+
|---|---|
|
|
415
|
+
| `integration.sii_rcv` (`checkDocumentAssignability`, `getDocumentSiiReceptionDate`, `listDocumentEvents`, `submitDocumentAcceptance`) | Registro de Compra y Venta del SII: aceptar/reclamar un DTE recibido de un proveedor, consultar su historial — esto es el lado "recibo facturas de otros", no "emito facturas". |
|
|
416
|
+
| `exchange.*` (`document_response`, `receiver`, `sender`) | Intercambio de documentos entre partes (acuses de recibo, envío/recepción P2P) — mismo dominio de "recepción" que lo anterior. |
|
|
417
|
+
| `book.*` (`builder`, `loader`, `validator`) | Libros de Ventas/Compras/Boletas/Guías y Resumen de Ventas Diarias — obligación periódica del SII, capacidad grande y separada de emitir un DTE puntual. |
|
|
418
|
+
| `ownership_transfer.aec` + `integration.sii_rtc` | Cesión electrónica de créditos (factoring) — nicho, solo si la app va a soportar factoring. |
|
|
419
|
+
|
|
420
|
+
## Estado
|
|
421
|
+
|
|
422
|
+
- [x] Investigación del spec + requests reales contra ambas APIs
|
|
423
|
+
(`core`/`pro`).
|
|
424
|
+
- [x] `CLAUDE.md` (este archivo).
|
|
425
|
+
- [x] Paquete reorganizado en `billing/{document,identifier,
|
|
426
|
+
trading_parties,integration}`, con un `*Component`/`*Service` por
|
|
427
|
+
worker de la API.
|
|
428
|
+
- [x] Fakers agregados como servicios del SDK (`caf_faker`,
|
|
429
|
+
`mandatario_manager.create_fake_certificate`).
|
|
430
|
+
- [x] Manejo de rate limit (`LibreDteRateLimitError` en `429`).
|
|
431
|
+
- [x] Tests unitarios (mock de HTTP con `respx`, sin red real): 35 tests,
|
|
432
|
+
todos verdes (`make test`).
|
|
433
|
+
- [x] Tests de integración en vivo, marcados `live`, excluidos por
|
|
434
|
+
defecto: 12 tests, todos verdes (`make test-live`, ambiente propio).
|
|
435
|
+
- [x] Verificación end-to-end contra la API real de los 5 flujos + los 2
|
|
436
|
+
fakers, con el SDK real (no curl) — ver tabla arriba.
|
|
437
|
+
- [x] Render de PDF: bug reportado, fix diseñado en conjunto, verificado
|
|
438
|
+
en vivo (PDF real y válido) contra el ambiente de desarrollo del
|
|
439
|
+
usuario. Ver "Render de PDF: bug y fix" arriba.
|
|
440
|
+
- [x] Copias múltiples (`renderings`): agregado por la API, soportado en
|
|
441
|
+
el SDK (`render(..., renderings={...})`, `Rendering.label/copies
|
|
442
|
+
/copy_number`, `RenderResult.by_label()`), con los 4 casos de
|
|
443
|
+
negocio (varias presentaciones, varias copias, omisión silenciosa,
|
|
444
|
+
presentación inexistente, ninguna generable) verificados en vivo.
|
|
445
|
+
- [ ] **El fix/las funcionalidades de render todavía no están
|
|
446
|
+
desplegadas en la API pública** (`pro.libredte.cl`/
|
|
447
|
+
`core.libredte.cl`) — solo confirmado contra
|
|
448
|
+
`http://localhost:9000/api`. `LibreDTE()` sin `base_url` explícito
|
|
449
|
+
seguirá fallando en `render()` hasta que se despliegue ahí. No es
|
|
450
|
+
un TODO del SDK — es un TODO de seguimiento del despliegue.
|
|
451
|
+
- [ ] Cerrar los supuestos de `SendResult`/`SiiStatus` con un certificado
|
|
452
|
+
real (fuera de alcance de este SDK/sesión).
|
|
453
|
+
- [x] **`identifier.caf_loader::load` + `identifier.caf_validator
|
|
454
|
+
::validate`**: agregados (decisión del usuario, tras evaluar el
|
|
455
|
+
mapeo completo de cobertura de la API — ver esa sección arriba).
|
|
456
|
+
Cierra la única brecha real que quedaba dentro de los 5 flujos ya
|
|
457
|
+
acordados: ahora sí se puede timbrar con un CAF **real** (cargado,
|
|
458
|
+
no ficticio) en producción. Verificado en vivo de punta a punta
|
|
459
|
+
(`caf_faker` → `caf_loader.load` de ese XML → `caf_validator
|
|
460
|
+
.validate` → `build_signed()` con ese CAF cargado).
|
|
461
|
+
- [ ] **Siguiente paso decidido**: pasar a la app Django, en su propio
|
|
462
|
+
repo. El resto de las 30 operaciones que faltan (ver "Cobertura de
|
|
463
|
+
la API") queda para agregar cuando la app Django las necesite de
|
|
464
|
+
verdad, no antes — decisión explícita del usuario, no un olvido.
|
|
465
|
+
|
|
466
|
+
## Desarrollo
|
|
467
|
+
|
|
468
|
+
```bash
|
|
469
|
+
make install-dev
|
|
470
|
+
make check # ruff (lint + format --check) + tests unitarios (offline)
|
|
471
|
+
make test-live # tests de integración reales contra la API (red)
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
## CI
|
|
475
|
+
|
|
476
|
+
`.github/workflows/ci.yml` — `workflow_dispatch` + `push`/`pull_request`
|
|
477
|
+
sobre `main`, un solo job (`ruff check`, `ruff format --check`, `pytest`
|
|
478
|
+
con reporte JUnit + cobertura como artifacts), Python 3.14. Mismo patrón
|
|
479
|
+
que `derafu/derafu-backbone` (PHP), adaptado a Python/`pyproject.toml`.
|
|
480
|
+
Corre **solo** los tests unitarios (offline, sin red) — los `live`
|
|
481
|
+
(marker `live`) quedan fuera por diseño: dependen de la API real y/o de
|
|
482
|
+
un ambiente de desarrollo local (`localhost:9000`) que no existe en CI.
|