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.
Files changed (41) hide show
  1. libredte_lib_sdk-0.1.0/.github/workflows/ci.yml +67 -0
  2. libredte_lib_sdk-0.1.0/.gitignore +15 -0
  3. libredte_lib_sdk-0.1.0/CLAUDE.md +482 -0
  4. libredte_lib_sdk-0.1.0/COPYING +661 -0
  5. libredte_lib_sdk-0.1.0/Makefile +42 -0
  6. libredte_lib_sdk-0.1.0/PKG-INFO +878 -0
  7. libredte_lib_sdk-0.1.0/README.rst +198 -0
  8. libredte_lib_sdk-0.1.0/libredte_lib_sdk/__init__.py +75 -0
  9. libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/__init__.py +31 -0
  10. libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/common.py +29 -0
  11. libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/document/__init__.py +39 -0
  12. libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/document/builder.py +68 -0
  13. libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/document/dispatcher.py +49 -0
  14. libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/document/models.py +169 -0
  15. libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/document/renderer.py +60 -0
  16. libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/enums.py +18 -0
  17. libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/identifier/__init__.py +27 -0
  18. libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/identifier/caf_faker.py +49 -0
  19. libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/identifier/caf_loader.py +31 -0
  20. libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/identifier/caf_validator.py +36 -0
  21. libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/identifier/models.py +37 -0
  22. libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/integration/__init__.py +17 -0
  23. libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/integration/models.py +46 -0
  24. libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/integration/sii_dte.py +83 -0
  25. libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/trading_parties/__init__.py +21 -0
  26. libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/trading_parties/mandatario_manager.py +45 -0
  27. libredte_lib_sdk-0.1.0/libredte_lib_sdk/billing/trading_parties/models.py +38 -0
  28. libredte_lib_sdk-0.1.0/libredte_lib_sdk/client.py +184 -0
  29. libredte_lib_sdk-0.1.0/libredte_lib_sdk/exceptions.py +117 -0
  30. libredte_lib_sdk-0.1.0/libredte_lib_sdk/py.typed +0 -0
  31. libredte_lib_sdk-0.1.0/libredte_lib_sdk/sdk.py +79 -0
  32. libredte_lib_sdk-0.1.0/pyproject.toml +103 -0
  33. libredte_lib_sdk-0.1.0/tests/__init__.py +0 -0
  34. libredte_lib_sdk-0.1.0/tests/conftest.py +25 -0
  35. libredte_lib_sdk-0.1.0/tests/integration/__init__.py +0 -0
  36. libredte_lib_sdk-0.1.0/tests/integration/conftest.py +79 -0
  37. libredte_lib_sdk-0.1.0/tests/integration/test_caf.py +68 -0
  38. libredte_lib_sdk-0.1.0/tests/integration/test_document_rendering.py +148 -0
  39. libredte_lib_sdk-0.1.0/tests/test_client.py +184 -0
  40. libredte_lib_sdk-0.1.0/tests/test_models.py +187 -0
  41. 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,15 @@
1
+ # Python.
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .venv/
6
+ venv/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ dist/
10
+ build/
11
+ .coverage
12
+ var/
13
+
14
+ # Archivos específicos del sistema operativo.
15
+ .DS_Store
@@ -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.