digifact-sdk 2.2.2__tar.gz → 2.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: digifact-sdk
3
- Version: 2.2.2
3
+ Version: 2.3.0
4
4
  Summary: Python SDK for Digifact FEL Guatemala e-invoicing API
5
5
  Author-email: AALONZOLU <noreply@github.com>
6
6
  License: MIT
@@ -35,9 +35,34 @@ pip install digifact-sdk
35
35
  O desde el código fuente:
36
36
 
37
37
  ```bash
38
- pip install -e sdk/python/
38
+ pip install -e python/
39
39
  ```
40
40
 
41
+ ## Configuración del cliente (`DigifactClient(...)`)
42
+
43
+ Ordenados de más usados a menos usados.
44
+
45
+ | Parámetro | Tipo | Por defecto | Descripción |
46
+ |-----------|------|-------------|-------------|
47
+ | `taxid` | `str` | **requerido** | NIT del emisor. Acepta dígitos o con separadores (`"12345678"`, `"1234567-8"`); los no-dígitos se eliminan. Se rellena internamente a 12 caracteres. |
48
+ | `username` | `str` | **requerido** | Usuario corto de Digifact (la parte después de `GT.<NIT>.`, p. ej. `"FELUSER"`). |
49
+ | `password` | `str` | `""` | Contraseña de la cuenta. **Requerido** si no se provee `token`. |
50
+ | `token` | `str` | `""` | Bearer token preobtenido. Si se provee, se omite el login. |
51
+ | `environment` | `str` | `"test"` | `"test"` o `"production"`. |
52
+ | `seller_name` | `str` | `""` | Nombre del emisor. Para NIT individual es el nombre de la persona; para S.A. / S.E. es la razón social de la entidad. Si está vacío, se consulta en SAT vía `lookup_nit()`. |
53
+ | `seller_address` | `str` | `""` | Dirección del emisor. Si está vacía, se consulta en SAT. |
54
+ | `branch_code` | `str` | `"1"` | **Código del establecimiento** del RTU. Un NIT puede tener varios establecimientos (1, 2, 3…). Se escribe en `Seller.BranchInfo.Code`. |
55
+ | `branch_name` | `str` | `"ESTABLECIMIENTO PRINCIPAL"` | **Nombre comercial** de la sucursal — el mismo que aparece en la patente de comercio. Se escribe en `Seller.BranchInfo.Name`. |
56
+ | `afiliacion_iva` | `str` | `"GEN"` | Afiliación IVA del RTU: `"GEN"`, `"PEQ"` o `"EXE"`. |
57
+ | `tipo_frase` | `str \| None` | `None` | Sobreescritura global de `TipoFrase` (legacy). **Mutuamente exclusivo con `frases`**. Ver [frases](#configuración-de-frases-tipofrase--codigoescenario). |
58
+ | `escenario` | `str \| None` | `None` | Sobreescritura global de `CodigoEscenario` (legacy). **Mutuamente exclusivo con `frases`**. |
59
+ | `frases` | `list[dict] \| None` | `None` | Lista de frases `{"tipo_frase": ..., "escenario": ...}`. Reemplaza a `tipo_frase`/`escenario`. **Mutuamente exclusivo** con ellos. Ver [frases](#configuración-de-frases-tipofrase--codigoescenario). |
60
+ | `auto_fuel_subsidy_frases` | `bool \| None` | `None` | Controla la auto-inyección de frases 9/18 y 9/19 en facturas de combustible durante el periodo de subsidio. `None` = usar default (`True`). Ver [subsidio combustible](#subsidio-combustible-frases-automaticas). |
61
+ | `petroleo_rates` | `dict[str, float] \| None` | `None` | Mapa código PETROLEO → tarifa por unidad. Usado sólo por `fuel_invoice()` (gasolineras). |
62
+ | `timeout` | `int` | `120` | Timeout HTTP en segundos. |
63
+ | `session` | `requests.Session \| None` | `None` | Sesión HTTP personalizada (útil para tests). |
64
+ | `tipo_personeria` | `str` | `"1"` | Código de `TipoPersoneria` del RTU. **Sólo aplica a RDON** (Recibo por Donación); ignóralo en los demás documentos. |
65
+
41
66
  ## Inicio rápido
42
67
 
43
68
  ```python
@@ -235,13 +260,36 @@ result = client.fuel_invoice(
235
260
  | `petroleo_amount` | `float\|Decimal` | — | Impuesto PETROLEO por unidad; omitir para ítems sólo-IVA |
236
261
  | `petroleo_code` | `str` | `"1"` | `"1"`=SUPER, `"2"`=REGULAR, `"4"`=DIESEL. Obligatorio cuando se omite `petroleo_amount` y `petroleo_rates` está configurado; lanza `DigifactValidationError` si el código no está en el diccionario de tarifas. |
237
262
 
263
+ ### Subsidio combustible — frases automáticas
264
+
265
+ Durante el **periodo de subsidio de combustibles** (2026-04-27 (incl.) a 2026-07-27 (excl.)), SAT exige incluir frases especiales `TipoFrase=9, Escenario=18` y `TipoFrase=9, Escenario=19` en las facturas de combustible. **El SDK las agrega automáticamente** cuando la fecha de emisión cae dentro de la ventana — no se necesita cambiar ningún código existente.
266
+
267
+ ```python
268
+ # Sin cambios — el SDK agrega 9/18 y 9/19 automáticamente durante el subsidio
269
+ result = client.fuel_invoice("CF", items)
270
+
271
+ # Deshabilitarlo por llamada
272
+ result = client.fuel_invoice("CF", items, auto_fuel_subsidy_frases=False)
273
+
274
+ # Deshabilitarlo globalmente al construir el cliente
275
+ client = DigifactClient(..., auto_fuel_subsidy_frases=False)
276
+
277
+ # Deshabilitarlo sin tocar código (útil si el presupuesto se agota antes de la fecha)
278
+ # DIGIFACT_DISABLE_AUTO_FUEL_SUBSIDY_FRASES=1 (variable de entorno)
279
+
280
+ # Frases completamente personalizadas (deshabilita auto-inyección)
281
+ result = client.fuel_invoice("CF", items, frases=[{"tipo_frase": "1", "escenario": "1"}])
282
+ ```
283
+
284
+ > **Nota:** `frases` y `tipo_frase`/`escenario` son **mutuamente exclusivos** — pasarlos juntos lanza `DigifactValidationError`.
285
+
238
286
  ## Configuración de frases (TipoFrase / CodigoEscenario)
239
287
 
240
288
  Todo DTE (excepto FESP) debe llevar un par `TipoFrase` + `CodigoEscenario`. El
241
289
  SDK elige valores por defecto adecuados, por lo que **no** hace falta
242
290
  configurar nada en el caso común.
243
291
 
244
- **Orden de precedencia:** argumentos por llamada → globales del constructor (`tipo_frase` / `escenario`) → tabla de valores por defecto.
292
+ **Orden de precedencia:** argumentos por llamada → globales del constructor → tabla de valores por defecto.
245
293
 
246
294
  **Tabla de valores por defecto:**
247
295
 
@@ -256,10 +304,30 @@ configurar nada en el caso común.
256
304
  | FACT / FCAM / NCRE / NDEB | PEQ | `2` | `1` | |
257
305
  | FACT / FCAM / NCRE / NDEB | EXE | `4` | `1` | Exento |
258
306
 
259
- Tanto `tipo_frase` como `escenario` se pueden sobreescribir de forma
260
- independiente — por llamada (como argumentos con nombre) o globalmente al
261
- construir el cliente. Cuando se omiten, cada uno cae al global del
262
- constructor y luego a la tabla de valores por defecto.
307
+ ### Nueva API: `frases[]` (múltiples frases)
308
+
309
+ Usa `frases` cuando necesitas enviar **más de un par** TipoFrase/Escenario (p. ej. listados en resoluciones SAT). Es **mutuamente exclusivo** con `tipo_frase`/`escenario`.
310
+
311
+ ```python
312
+ # Lista explícita de frases (deshabilita tipo_frase/escenario y auto-inyección)
313
+ client.fuel_invoice("CF", items, frases=[
314
+ {"tipo_frase": "1", "escenario": "1"},
315
+ {"tipo_frase": "9", "escenario": "18"},
316
+ {"tipo_frase": "9", "escenario": "19"},
317
+ ])
318
+
319
+ # Globalmente en el constructor
320
+ client = DigifactClient(
321
+ taxid="12345678", username="FELUSER", password="...",
322
+ frases=[{"tipo_frase": "1", "escenario": "2"}], # ISR régimen opcional simplificado
323
+ )
324
+ ```
325
+
326
+ > **Regla:** `frases` y `tipo_frase`/`escenario` son **mutuamente exclusivos** en cada nivel (por llamada y en el constructor). Combinarlos lanza `DigifactValidationError`.
327
+
328
+ ### API legacy: `tipo_frase` / `escenario`
329
+
330
+ Ambos parámetros siguen funcionando exactamente como antes para integraciones existentes.
263
331
 
264
332
  ```python
265
333
  # Sobreescritura por llamada (uno o ambos)
@@ -282,6 +350,45 @@ client = DigifactClient(
282
350
  Para descubrir el par correcto en un caso particular, revisa las afiliaciones
283
351
  del RTU en el portal de SAT.
284
352
 
353
+ ## Referencia de métodos
354
+
355
+ Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `series`, `number`, `issue_datetime`, `raw`.
356
+
357
+ | Método | Firma | Descripción |
358
+ |--------|-------|-------------|
359
+ | `invoice()` | `invoice(buyer, items, *, doc_type="FACT", payment_terms=None, amount_str="", observaciones="-", tipo_personeria=None, tipo_frase=None, escenario=None)` | Emite FACT, FCAM, FESP, FPEQ, NABN, RDON o RECI según `doc_type`. |
360
+ | `cca_invoice()` | `cca_invoice(buyer, items, cobros, *, tipo_frase=None, escenario=None)` | FACT con complemento CCA. |
361
+ | `fuel_invoice()` | `fuel_invoice(buyer, items, *, tipo_frase=None, escenario=None, frases=None, auto_fuel_subsidy_frases=None)` | FACT con complemento combustible (IVA + PETROLEO). Auto-inyecta frases 9/18 y 9/19 durante el subsidio. |
362
+ | `credit_note()` | `credit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de crédito (NCRE). |
363
+ | `debit_note()` | `debit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de débito (NDEB). |
364
+ | `credit_note_total()` | `credit_note_total(auth_number, issue_datetime, reason="...", reference="")` | Nota de crédito total. Devuelve `dict`. |
365
+ | `cancel()` | `cancel(auth_number, receiver_id, issue_datetime, reason="Anulación")` | Anula un DTE. Devuelve `dict`. |
366
+ | `lookup_nit()` | `lookup_nit(nit)` | Consulta SAT. Devuelve `{"nit","name","address","city","district","state"}`. |
367
+ | `get_dte()` | `get_dte(auth_number, fmt="JSON")` | Recupera el DTE (`"JSON"`, `"XML"`, `"HTML"`, `"PDF"`). |
368
+ | `get_dte_info()` | `get_dte_info(auth_number)` | Metadatos del DTE. |
369
+
370
+ ### Parámetros comunes
371
+
372
+ - **`buyer`**: `"CF"` (consumidor final), un NIT string (`"12345678"` — se consulta el nombre), un dict CUI (`{"type":"CUI","taxid":...,"name":...}`) o un dict NIT explícito (`{"taxid","name","address","city","district","state","country","email"}`).
373
+ - **`items`**: lista de dicts con `description` (str, req), `price` (float/Decimal, req), `qty` (1), `type` (`"Servicio"`/`"Bien"`), `unit_of_measure` (`"UNI"`), `discount` (opcional).
374
+ - **`origin`** (NCRE/NDEB): `{"auth_number": ..., "date": "YYYY-MM-DD", "series": ..., "number": ...}`.
375
+
376
+ ## Establecimiento (sucursal)
377
+
378
+ Cada NIT puede tener varios establecimientos registrados en el RTU. Configúralos al crear el cliente:
379
+
380
+ ```python
381
+ client = DigifactClient(
382
+ taxid="12345678",
383
+ username="FELUSER",
384
+ password="secret",
385
+ branch_code="2",
386
+ branch_name="SUCURSAL ZONA 10",
387
+ )
388
+ ```
389
+
390
+ Aplican a todos los DTE emitidos por ese cliente. Si se omiten, se usan los defaults `"1"` / `"ESTABLECIMIENTO PRINCIPAL"`.
391
+
285
392
  ## Ejecutar las pruebas
286
393
 
287
394
  ```bash
@@ -302,6 +409,8 @@ python -m pytest tests/ -v
302
409
  | `DIGIFACT_TAXID` | NIT del emisor (p. ej. `12345678`) |
303
410
  | `DIGIFACT_USERNAME` | Usuario (p. ej. `FELUSER`) |
304
411
  | `DIGIFACT_PASSWORD` | Contraseña de la cuenta |
412
+ | `DIGIFACT_ENVIRONMENT` | `test` (default) o `production` |
413
+ | `DIGIFACT_DISABLE_AUTO_FUEL_SUBSIDY_FRASES` | `1` para deshabilitar la auto-inyección de frases 9/18 y 9/19 sin tocar código |
305
414
 
306
415
  ## Manejo de errores
307
416
 
@@ -11,9 +11,34 @@ pip install digifact-sdk
11
11
  O desde el código fuente:
12
12
 
13
13
  ```bash
14
- pip install -e sdk/python/
14
+ pip install -e python/
15
15
  ```
16
16
 
17
+ ## Configuración del cliente (`DigifactClient(...)`)
18
+
19
+ Ordenados de más usados a menos usados.
20
+
21
+ | Parámetro | Tipo | Por defecto | Descripción |
22
+ |-----------|------|-------------|-------------|
23
+ | `taxid` | `str` | **requerido** | NIT del emisor. Acepta dígitos o con separadores (`"12345678"`, `"1234567-8"`); los no-dígitos se eliminan. Se rellena internamente a 12 caracteres. |
24
+ | `username` | `str` | **requerido** | Usuario corto de Digifact (la parte después de `GT.<NIT>.`, p. ej. `"FELUSER"`). |
25
+ | `password` | `str` | `""` | Contraseña de la cuenta. **Requerido** si no se provee `token`. |
26
+ | `token` | `str` | `""` | Bearer token preobtenido. Si se provee, se omite el login. |
27
+ | `environment` | `str` | `"test"` | `"test"` o `"production"`. |
28
+ | `seller_name` | `str` | `""` | Nombre del emisor. Para NIT individual es el nombre de la persona; para S.A. / S.E. es la razón social de la entidad. Si está vacío, se consulta en SAT vía `lookup_nit()`. |
29
+ | `seller_address` | `str` | `""` | Dirección del emisor. Si está vacía, se consulta en SAT. |
30
+ | `branch_code` | `str` | `"1"` | **Código del establecimiento** del RTU. Un NIT puede tener varios establecimientos (1, 2, 3…). Se escribe en `Seller.BranchInfo.Code`. |
31
+ | `branch_name` | `str` | `"ESTABLECIMIENTO PRINCIPAL"` | **Nombre comercial** de la sucursal — el mismo que aparece en la patente de comercio. Se escribe en `Seller.BranchInfo.Name`. |
32
+ | `afiliacion_iva` | `str` | `"GEN"` | Afiliación IVA del RTU: `"GEN"`, `"PEQ"` o `"EXE"`. |
33
+ | `tipo_frase` | `str \| None` | `None` | Sobreescritura global de `TipoFrase` (legacy). **Mutuamente exclusivo con `frases`**. Ver [frases](#configuración-de-frases-tipofrase--codigoescenario). |
34
+ | `escenario` | `str \| None` | `None` | Sobreescritura global de `CodigoEscenario` (legacy). **Mutuamente exclusivo con `frases`**. |
35
+ | `frases` | `list[dict] \| None` | `None` | Lista de frases `{"tipo_frase": ..., "escenario": ...}`. Reemplaza a `tipo_frase`/`escenario`. **Mutuamente exclusivo** con ellos. Ver [frases](#configuración-de-frases-tipofrase--codigoescenario). |
36
+ | `auto_fuel_subsidy_frases` | `bool \| None` | `None` | Controla la auto-inyección de frases 9/18 y 9/19 en facturas de combustible durante el periodo de subsidio. `None` = usar default (`True`). Ver [subsidio combustible](#subsidio-combustible-frases-automaticas). |
37
+ | `petroleo_rates` | `dict[str, float] \| None` | `None` | Mapa código PETROLEO → tarifa por unidad. Usado sólo por `fuel_invoice()` (gasolineras). |
38
+ | `timeout` | `int` | `120` | Timeout HTTP en segundos. |
39
+ | `session` | `requests.Session \| None` | `None` | Sesión HTTP personalizada (útil para tests). |
40
+ | `tipo_personeria` | `str` | `"1"` | Código de `TipoPersoneria` del RTU. **Sólo aplica a RDON** (Recibo por Donación); ignóralo en los demás documentos. |
41
+
17
42
  ## Inicio rápido
18
43
 
19
44
  ```python
@@ -211,13 +236,36 @@ result = client.fuel_invoice(
211
236
  | `petroleo_amount` | `float\|Decimal` | — | Impuesto PETROLEO por unidad; omitir para ítems sólo-IVA |
212
237
  | `petroleo_code` | `str` | `"1"` | `"1"`=SUPER, `"2"`=REGULAR, `"4"`=DIESEL. Obligatorio cuando se omite `petroleo_amount` y `petroleo_rates` está configurado; lanza `DigifactValidationError` si el código no está en el diccionario de tarifas. |
213
238
 
239
+ ### Subsidio combustible — frases automáticas
240
+
241
+ Durante el **periodo de subsidio de combustibles** (2026-04-27 (incl.) a 2026-07-27 (excl.)), SAT exige incluir frases especiales `TipoFrase=9, Escenario=18` y `TipoFrase=9, Escenario=19` en las facturas de combustible. **El SDK las agrega automáticamente** cuando la fecha de emisión cae dentro de la ventana — no se necesita cambiar ningún código existente.
242
+
243
+ ```python
244
+ # Sin cambios — el SDK agrega 9/18 y 9/19 automáticamente durante el subsidio
245
+ result = client.fuel_invoice("CF", items)
246
+
247
+ # Deshabilitarlo por llamada
248
+ result = client.fuel_invoice("CF", items, auto_fuel_subsidy_frases=False)
249
+
250
+ # Deshabilitarlo globalmente al construir el cliente
251
+ client = DigifactClient(..., auto_fuel_subsidy_frases=False)
252
+
253
+ # Deshabilitarlo sin tocar código (útil si el presupuesto se agota antes de la fecha)
254
+ # DIGIFACT_DISABLE_AUTO_FUEL_SUBSIDY_FRASES=1 (variable de entorno)
255
+
256
+ # Frases completamente personalizadas (deshabilita auto-inyección)
257
+ result = client.fuel_invoice("CF", items, frases=[{"tipo_frase": "1", "escenario": "1"}])
258
+ ```
259
+
260
+ > **Nota:** `frases` y `tipo_frase`/`escenario` son **mutuamente exclusivos** — pasarlos juntos lanza `DigifactValidationError`.
261
+
214
262
  ## Configuración de frases (TipoFrase / CodigoEscenario)
215
263
 
216
264
  Todo DTE (excepto FESP) debe llevar un par `TipoFrase` + `CodigoEscenario`. El
217
265
  SDK elige valores por defecto adecuados, por lo que **no** hace falta
218
266
  configurar nada en el caso común.
219
267
 
220
- **Orden de precedencia:** argumentos por llamada → globales del constructor (`tipo_frase` / `escenario`) → tabla de valores por defecto.
268
+ **Orden de precedencia:** argumentos por llamada → globales del constructor → tabla de valores por defecto.
221
269
 
222
270
  **Tabla de valores por defecto:**
223
271
 
@@ -232,10 +280,30 @@ configurar nada en el caso común.
232
280
  | FACT / FCAM / NCRE / NDEB | PEQ | `2` | `1` | |
233
281
  | FACT / FCAM / NCRE / NDEB | EXE | `4` | `1` | Exento |
234
282
 
235
- Tanto `tipo_frase` como `escenario` se pueden sobreescribir de forma
236
- independiente — por llamada (como argumentos con nombre) o globalmente al
237
- construir el cliente. Cuando se omiten, cada uno cae al global del
238
- constructor y luego a la tabla de valores por defecto.
283
+ ### Nueva API: `frases[]` (múltiples frases)
284
+
285
+ Usa `frases` cuando necesitas enviar **más de un par** TipoFrase/Escenario (p. ej. listados en resoluciones SAT). Es **mutuamente exclusivo** con `tipo_frase`/`escenario`.
286
+
287
+ ```python
288
+ # Lista explícita de frases (deshabilita tipo_frase/escenario y auto-inyección)
289
+ client.fuel_invoice("CF", items, frases=[
290
+ {"tipo_frase": "1", "escenario": "1"},
291
+ {"tipo_frase": "9", "escenario": "18"},
292
+ {"tipo_frase": "9", "escenario": "19"},
293
+ ])
294
+
295
+ # Globalmente en el constructor
296
+ client = DigifactClient(
297
+ taxid="12345678", username="FELUSER", password="...",
298
+ frases=[{"tipo_frase": "1", "escenario": "2"}], # ISR régimen opcional simplificado
299
+ )
300
+ ```
301
+
302
+ > **Regla:** `frases` y `tipo_frase`/`escenario` son **mutuamente exclusivos** en cada nivel (por llamada y en el constructor). Combinarlos lanza `DigifactValidationError`.
303
+
304
+ ### API legacy: `tipo_frase` / `escenario`
305
+
306
+ Ambos parámetros siguen funcionando exactamente como antes para integraciones existentes.
239
307
 
240
308
  ```python
241
309
  # Sobreescritura por llamada (uno o ambos)
@@ -258,6 +326,45 @@ client = DigifactClient(
258
326
  Para descubrir el par correcto en un caso particular, revisa las afiliaciones
259
327
  del RTU en el portal de SAT.
260
328
 
329
+ ## Referencia de métodos
330
+
331
+ Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `series`, `number`, `issue_datetime`, `raw`.
332
+
333
+ | Método | Firma | Descripción |
334
+ |--------|-------|-------------|
335
+ | `invoice()` | `invoice(buyer, items, *, doc_type="FACT", payment_terms=None, amount_str="", observaciones="-", tipo_personeria=None, tipo_frase=None, escenario=None)` | Emite FACT, FCAM, FESP, FPEQ, NABN, RDON o RECI según `doc_type`. |
336
+ | `cca_invoice()` | `cca_invoice(buyer, items, cobros, *, tipo_frase=None, escenario=None)` | FACT con complemento CCA. |
337
+ | `fuel_invoice()` | `fuel_invoice(buyer, items, *, tipo_frase=None, escenario=None, frases=None, auto_fuel_subsidy_frases=None)` | FACT con complemento combustible (IVA + PETROLEO). Auto-inyecta frases 9/18 y 9/19 durante el subsidio. |
338
+ | `credit_note()` | `credit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de crédito (NCRE). |
339
+ | `debit_note()` | `debit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de débito (NDEB). |
340
+ | `credit_note_total()` | `credit_note_total(auth_number, issue_datetime, reason="...", reference="")` | Nota de crédito total. Devuelve `dict`. |
341
+ | `cancel()` | `cancel(auth_number, receiver_id, issue_datetime, reason="Anulación")` | Anula un DTE. Devuelve `dict`. |
342
+ | `lookup_nit()` | `lookup_nit(nit)` | Consulta SAT. Devuelve `{"nit","name","address","city","district","state"}`. |
343
+ | `get_dte()` | `get_dte(auth_number, fmt="JSON")` | Recupera el DTE (`"JSON"`, `"XML"`, `"HTML"`, `"PDF"`). |
344
+ | `get_dte_info()` | `get_dte_info(auth_number)` | Metadatos del DTE. |
345
+
346
+ ### Parámetros comunes
347
+
348
+ - **`buyer`**: `"CF"` (consumidor final), un NIT string (`"12345678"` — se consulta el nombre), un dict CUI (`{"type":"CUI","taxid":...,"name":...}`) o un dict NIT explícito (`{"taxid","name","address","city","district","state","country","email"}`).
349
+ - **`items`**: lista de dicts con `description` (str, req), `price` (float/Decimal, req), `qty` (1), `type` (`"Servicio"`/`"Bien"`), `unit_of_measure` (`"UNI"`), `discount` (opcional).
350
+ - **`origin`** (NCRE/NDEB): `{"auth_number": ..., "date": "YYYY-MM-DD", "series": ..., "number": ...}`.
351
+
352
+ ## Establecimiento (sucursal)
353
+
354
+ Cada NIT puede tener varios establecimientos registrados en el RTU. Configúralos al crear el cliente:
355
+
356
+ ```python
357
+ client = DigifactClient(
358
+ taxid="12345678",
359
+ username="FELUSER",
360
+ password="secret",
361
+ branch_code="2",
362
+ branch_name="SUCURSAL ZONA 10",
363
+ )
364
+ ```
365
+
366
+ Aplican a todos los DTE emitidos por ese cliente. Si se omiten, se usan los defaults `"1"` / `"ESTABLECIMIENTO PRINCIPAL"`.
367
+
261
368
  ## Ejecutar las pruebas
262
369
 
263
370
  ```bash
@@ -278,6 +385,8 @@ python -m pytest tests/ -v
278
385
  | `DIGIFACT_TAXID` | NIT del emisor (p. ej. `12345678`) |
279
386
  | `DIGIFACT_USERNAME` | Usuario (p. ej. `FELUSER`) |
280
387
  | `DIGIFACT_PASSWORD` | Contraseña de la cuenta |
388
+ | `DIGIFACT_ENVIRONMENT` | `test` (default) o `production` |
389
+ | `DIGIFACT_DISABLE_AUTO_FUEL_SUBSIDY_FRASES` | `1` para deshabilitar la auto-inyección de frases 9/18 y 9/19 sin tocar código |
281
390
 
282
391
  ## Manejo de errores
283
392
 
@@ -7,6 +7,7 @@ Key intentional API typos preserved from the SAT/Digifact spec:
7
7
  """
8
8
  from __future__ import annotations
9
9
 
10
+ import datetime as _datetime
10
11
  from decimal import Decimal
11
12
  from typing import Any
12
13
 
@@ -27,6 +28,69 @@ def _adenda_code(doc_type: str) -> str:
27
28
  return ADENDA_CODE_ALT if doc_type in _ALT_ADENDA_TYPES else ADENDA_CODE_STD
28
29
 
29
30
 
31
+ # ── Fuel subsidy frases (Tipo 9, Escenarios 18 / 19) ─────────────────────────
32
+ FUEL_SUBSIDY_START = _datetime.date(2026, 4, 27)
33
+ FUEL_SUBSIDY_END = _datetime.date(2026, 7, 27) # exclusive
34
+
35
+
36
+ def _uniq_frases(frases: list[dict]) -> list[dict]:
37
+ seen: set[str] = set()
38
+ out: list[dict] = []
39
+ for f in frases:
40
+ key = f"{f['tipo_frase']}:{f['escenario']}"
41
+ if key not in seen:
42
+ seen.add(key)
43
+ out.append(f)
44
+ return out
45
+
46
+
47
+ def _build_additionl_info_from_frases(frases: list[dict]) -> list[dict]:
48
+ """Build Seller.AdditionlInfo entries from a frases list.
49
+
50
+ Each frase gets a 1-based Data index so Digifact's NUC XSLT groups them into
51
+ separate <dte:Frase> elements. Confirmed via live API: Digifact counts the frases
52
+ correctly with this format (error becomes a NIT-authorization check, not XSLT failure).
53
+ """
54
+ out: list[dict] = []
55
+ for i, f in enumerate(frases, start=1):
56
+ out.append({"Name": "TipoFrase", "Data": str(i), "Value": str(f["tipo_frase"])})
57
+ out.append({"Name": "Escenario", "Data": str(i), "Value": str(f["escenario"])})
58
+ return out
59
+
60
+
61
+ def _within_subsidy_window(issue_dt_iso: str) -> bool:
62
+ try:
63
+ d = _datetime.date.fromisoformat(issue_dt_iso[:10])
64
+ return FUEL_SUBSIDY_START <= d < FUEL_SUBSIDY_END
65
+ except (ValueError, TypeError):
66
+ return False
67
+
68
+
69
+ def resolve_fuel_frases(
70
+ frases: list[dict] | None,
71
+ tipo_frase: str | None,
72
+ escenario: str | None,
73
+ issue_dt_iso: str,
74
+ auto_enabled: bool = True,
75
+ ) -> list[dict]:
76
+ """Return the final deduplicated frases list for a fuel invoice.
77
+
78
+ Called only after mutual-exclusivity validation (frases vs tipo_frase/escenario).
79
+ """
80
+ if frases is not None:
81
+ if not frases:
82
+ from .exceptions import DigifactValidationError
83
+ raise DigifactValidationError("frases must contain at least one TipoFrase/Escenario pair")
84
+ return _uniq_frases(frases)
85
+ result: list[dict] = []
86
+ if tipo_frase is not None and escenario is not None:
87
+ result.append({"tipo_frase": tipo_frase, "escenario": escenario})
88
+ if auto_enabled and _within_subsidy_window(issue_dt_iso):
89
+ result.append({"tipo_frase": "9", "escenario": "18"})
90
+ result.append({"tipo_frase": "9", "escenario": "19"})
91
+ return _uniq_frases(result)
92
+
93
+
30
94
  def default_frase(doc_type: str, afiliacion: str = "GEN") -> tuple[str, str] | None:
31
95
  """Return default (TipoFrase, CodigoEscenario) for a (doc_type, afiliacion) combo.
32
96
 
@@ -68,6 +132,7 @@ def _build_seller(
68
132
  afiliacion: str = "GEN",
69
133
  tipo_frase: str | None = "1",
70
134
  escenario: str | None = "1",
135
+ frases: list[dict] | None = None,
71
136
  branch_code: str = "1",
72
137
  branch_name: str = "ESTABLECIMIENTO PRINCIPAL",
73
138
  city: str = "01010",
@@ -96,8 +161,12 @@ def _build_seller(
96
161
  }
97
162
  if email:
98
163
  seller["Contact"] = {"EmailList": {"Email": [email]}}
99
- # AdditionlInfo — intentional typo in API spec
100
- if tipo_frase is not None and escenario is not None:
164
+ # AdditionlInfo — intentional typo in API spec.
165
+ # Digifact's XSLT groups entries by 1-based Data index into separate <dte:Frase> elements.
166
+ if frases is not None:
167
+ if frases:
168
+ seller["AdditionlInfo"] = _build_additionl_info_from_frases(frases)
169
+ elif tipo_frase is not None and escenario is not None:
101
170
  seller["AdditionlInfo"] = [
102
171
  {"Name": "TipoFrase", "Data": "1", "Value": tipo_frase},
103
172
  {"Name": "Escenario", "Data": "1", "Value": escenario},
@@ -306,6 +375,7 @@ def build_fact(
306
375
  afiliacion: str = "GEN",
307
376
  tipo_frase: str | None = None,
308
377
  escenario: str | None = None,
378
+ frases: list[dict] | None = None,
309
379
  amount_str: str = "",
310
380
  observaciones: str = "-",
311
381
  extra_header: dict | None = None,
@@ -329,6 +399,7 @@ def build_fact(
329
399
  afiliacion=afiliacion,
330
400
  tipo_frase=tf,
331
401
  escenario=es,
402
+ frases=frases,
332
403
  email=seller_email,
333
404
  )
334
405
 
@@ -371,6 +442,7 @@ def build_fcam(
371
442
  seller_email: str | None = None,
372
443
  tipo_frase: str | None = None,
373
444
  escenario: str | None = None,
445
+ frases: list[dict] | None = None,
374
446
  ) -> dict:
375
447
  """Build a FCAM (Factura Cambiaria) DTE payload.
376
448
 
@@ -391,6 +463,7 @@ def build_fcam(
391
463
  afiliacion=afiliacion,
392
464
  tipo_frase=tf,
393
465
  escenario=es,
466
+ frases=frases,
394
467
  email=seller_email,
395
468
  )
396
469
 
@@ -443,6 +516,7 @@ def build_ndeb(
443
516
  seller_email: str | None = None,
444
517
  tipo_frase: str | None = None,
445
518
  escenario: str | None = None,
519
+ frases: list[dict] | None = None,
446
520
  ) -> dict:
447
521
  """Build a NDEB (Nota de Débito) DTE payload.
448
522
 
@@ -462,6 +536,7 @@ def build_ndeb(
462
536
  afiliacion=afiliacion,
463
537
  tipo_frase=tf,
464
538
  escenario=es,
539
+ frases=frases,
465
540
  email=seller_email,
466
541
  )
467
542
 
@@ -527,6 +602,7 @@ def build_ncre(
527
602
  seller_email: str | None = None,
528
603
  tipo_frase: str | None = None,
529
604
  escenario: str | None = None,
605
+ frases: list[dict] | None = None,
530
606
  ) -> dict:
531
607
  """Build a NCRE (Nota de Crédito) DTE payload.
532
608
 
@@ -546,6 +622,7 @@ def build_ncre(
546
622
  afiliacion=afiliacion,
547
623
  tipo_frase=tf,
548
624
  escenario=es,
625
+ frases=frases,
549
626
  email=seller_email,
550
627
  )
551
628
 
@@ -609,6 +686,7 @@ def build_nabn(
609
686
  amount_str: str = "",
610
687
  observaciones: str = "-",
611
688
  seller_email: str | None = None,
689
+ frases: list[dict] | None = None,
612
690
  ) -> dict:
613
691
  """Build a NABN (Nota de Abono) DTE payload — no IVA."""
614
692
  issue_dt, _, _ = gt_now()
@@ -622,6 +700,7 @@ def build_nabn(
622
700
  afiliacion=afiliacion,
623
701
  tipo_frase="1",
624
702
  escenario="1",
703
+ frases=frases,
625
704
  email=seller_email,
626
705
  )
627
706
 
@@ -718,6 +797,7 @@ def build_rdon(
718
797
  amount_str: str = "",
719
798
  observaciones: str = "-",
720
799
  seller_email: str | None = None,
800
+ frases: list[dict] | None = None,
721
801
  ) -> dict:
722
802
  """Build a RDON (Recibo por Donación) DTE payload — no IVA."""
723
803
  issue_dt, _, _ = gt_now()
@@ -730,6 +810,7 @@ def build_rdon(
730
810
  afiliacion=afiliacion,
731
811
  tipo_frase="4",
732
812
  escenario="4",
813
+ frases=frases,
733
814
  email=seller_email,
734
815
  )
735
816
 
@@ -769,6 +850,7 @@ def build_fpeq(
769
850
  seller_email: str | None = None,
770
851
  tipo_frase: str | None = None,
771
852
  escenario: str | None = None,
853
+ frases: list[dict] | None = None,
772
854
  ) -> dict:
773
855
  """Build a FPEQ (Factura Pequeño Contribuyente) DTE payload — no IVA, AfiliacionIVA=PEQ."""
774
856
  issue_dt, _, _ = gt_now()
@@ -785,6 +867,7 @@ def build_fpeq(
785
867
  afiliacion="PEQ",
786
868
  tipo_frase=tf,
787
869
  escenario=es,
870
+ frases=frases,
788
871
  email=seller_email,
789
872
  )
790
873
 
@@ -819,6 +902,7 @@ def build_reci(
819
902
  student_id: str = "000000000",
820
903
  academic_unit: str = "01",
821
904
  seller_email: str | None = None,
905
+ frases: list[dict] | None = None,
822
906
  ) -> dict:
823
907
  """Build a RECI (Recibo) DTE payload — no IVA, universidad ADENDA."""
824
908
  issue_dt, _, _ = gt_now()
@@ -831,6 +915,7 @@ def build_reci(
831
915
  afiliacion=afiliacion,
832
916
  tipo_frase="4",
833
917
  escenario="5",
918
+ frases=frases,
834
919
  email=seller_email,
835
920
  )
836
921
 
@@ -873,6 +958,7 @@ def build_cca(
873
958
  seller_email: str | None = None,
874
959
  tipo_frase: str | None = None,
875
960
  escenario: str | None = None,
961
+ frases: list[dict] | None = None,
876
962
  ) -> dict:
877
963
  """Build a CCA (Cobro por Cuenta Ajena) FACT+CCA complemento payload.
878
964
 
@@ -902,6 +988,7 @@ def build_cca(
902
988
  afiliacion=afiliacion,
903
989
  tipo_frase=tf,
904
990
  escenario=es,
991
+ frases=frases,
905
992
  email=seller_email,
906
993
  )
907
994
 
@@ -1095,6 +1182,8 @@ def build_fact_combustible(
1095
1182
  afiliacion: str = "GEN",
1096
1183
  tipo_frase: str | None = None,
1097
1184
  escenario: str | None = None,
1185
+ frases: list[dict] | None = None,
1186
+ auto_fuel_subsidy_frases: bool = True,
1098
1187
  seller_email: str | None = None,
1099
1188
  ) -> dict:
1100
1189
  """Build a FACT payload for combustible (fuel) invoices.
@@ -1104,6 +1193,8 @@ def build_fact_combustible(
1104
1193
  "4"=DIESEL; default "1"). Items without ``petroleo_amount`` are treated as
1105
1194
  regular IVA-only items and may coexist in the same invoice.
1106
1195
 
1196
+ ``frases`` and ``tipo_frase``/``escenario`` are mutually exclusive.
1197
+
1107
1198
  Example fuel item::
1108
1199
 
1109
1200
  {"description": "GASOLINA SUPER", "qty": 1, "price": 30.30,
@@ -1113,6 +1204,12 @@ def build_fact_combustible(
1113
1204
 
1114
1205
  {"description": "FILTRO DE ACEITE", "qty": 1, "price": 45.00, "type": "Bien"}
1115
1206
  """
1207
+ from .exceptions import DigifactValidationError
1208
+ if frases is not None and (tipo_frase is not None or escenario is not None):
1209
+ raise DigifactValidationError(
1210
+ "frases and tipo_frase/escenario are mutually exclusive; use one or the other"
1211
+ )
1212
+
1116
1213
  issue_dt, _, _ = gt_now()
1117
1214
 
1118
1215
  line_items, grand_total, total_iva, total_petroleo = _build_fuel_items(items)
@@ -1121,13 +1218,16 @@ def build_fact_combustible(
1121
1218
  tf = tipo_frase if tipo_frase is not None else def_tf
1122
1219
  es = escenario if escenario is not None else def_es
1123
1220
 
1221
+ resolved_frases = resolve_fuel_frases(frases, tf, es, issue_dt, auto_fuel_subsidy_frases)
1222
+
1223
+ # All frases (base + any subsidy) go into Seller.AdditionlInfo as repeated pairs —
1224
+ # that is the field the API reads to generate <dte:Frases> in the certified XML.
1124
1225
  seller = _build_seller(
1125
1226
  taxid,
1126
1227
  seller_name,
1127
1228
  seller_address,
1128
1229
  afiliacion=afiliacion,
1129
- tipo_frase=tf,
1130
- escenario=es,
1230
+ frases=resolved_frases,
1131
1231
  email=seller_email,
1132
1232
  )
1133
1233
 
@@ -1135,12 +1235,14 @@ def build_fact_combustible(
1135
1235
  if float(total_petroleo) > 0.0:
1136
1236
  total_tax.append({"Description": "PETROLEO", "Amount": total_petroleo})
1137
1237
 
1138
- return {
1238
+ combustible_payload: dict[str, Any] = {
1139
1239
  "Version": "1.00",
1140
1240
  "CountryCode": "GT",
1141
1241
  "Header": {"DocType": "FACT", "IssuedDateTime": issue_dt, "Currency": "GTQ"},
1142
1242
  "Seller": seller,
1143
1243
  "Buyer": buyer,
1244
+ }
1245
+ combustible_payload.update({
1144
1246
  "ThirdParties": None,
1145
1247
  "Items": line_items,
1146
1248
  "Totals": {
@@ -1148,4 +1250,5 @@ def build_fact_combustible(
1148
1250
  "GrandTotal": {"InvoiceTotal": grand_total},
1149
1251
  },
1150
1252
  "AdditionalDocumentInfo": _build_fuel_adenda(),
1151
- }
1253
+ })
1254
+ return combustible_payload