digifact-sdk 2.2.3__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.3
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,29 +35,33 @@ 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
41
  ## Configuración del cliente (`DigifactClient(...)`)
42
42
 
43
+ Ordenados de más usados a menos usados.
44
+
43
45
  | Parámetro | Tipo | Por defecto | Descripción |
44
46
  |-----------|------|-------------|-------------|
45
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. |
46
48
  | `username` | `str` | **requerido** | Usuario corto de Digifact (la parte después de `GT.<NIT>.`, p. ej. `"FELUSER"`). |
47
49
  | `password` | `str` | `""` | Contraseña de la cuenta. **Requerido** si no se provee `token`. |
48
- | `environment` | `str` | `"test"` | `"test"` o `"production"`. |
49
50
  | `token` | `str` | `""` | Bearer token preobtenido. Si se provee, se omite el login. |
50
- | `seller_name` | `str` | `""` | Nombre comercial del emisor. Si está vacío, se consulta en SAT vía `lookup_nit()`. |
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()`. |
51
53
  | `seller_address` | `str` | `""` | Dirección del emisor. Si está vacía, se consulta en SAT. |
52
- | `afiliacion_iva` | `str` | `"GEN"` | Afiliación IVA del RTU: `"GEN"`, `"PEQ"` o `"EXE"`. |
53
- | `tipo_personeria` | `str` | `"1"` | Código de `TipoPersoneria` del RTU (usado en RDON). |
54
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 del establecimiento. Se escribe en `Seller.BranchInfo.Name`. |
56
- | `tipo_frase` | `str \| None` | `None` | Sobreescritura global de `TipoFrase`. Ver [frases](#configuración-de-frases-tipofrase--codigoescenario). |
57
- | `escenario` | `str \| None` | `None` | Sobreescritura global de `CodigoEscenario`. |
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). |
58
62
  | `timeout` | `int` | `120` | Timeout HTTP en segundos. |
59
63
  | `session` | `requests.Session \| None` | `None` | Sesión HTTP personalizada (útil para tests). |
60
- | `petroleo_rates` | `dict[str, float] \| None` | `None` | Mapa código PETROLEO → tarifa por unidad. Usado por `fuel_invoice()`. |
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. |
61
65
 
62
66
  ## Inicio rápido
63
67
 
@@ -256,13 +260,36 @@ result = client.fuel_invoice(
256
260
  | `petroleo_amount` | `float\|Decimal` | — | Impuesto PETROLEO por unidad; omitir para ítems sólo-IVA |
257
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. |
258
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
+
259
286
  ## Configuración de frases (TipoFrase / CodigoEscenario)
260
287
 
261
288
  Todo DTE (excepto FESP) debe llevar un par `TipoFrase` + `CodigoEscenario`. El
262
289
  SDK elige valores por defecto adecuados, por lo que **no** hace falta
263
290
  configurar nada en el caso común.
264
291
 
265
- **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.
266
293
 
267
294
  **Tabla de valores por defecto:**
268
295
 
@@ -277,10 +304,30 @@ configurar nada en el caso común.
277
304
  | FACT / FCAM / NCRE / NDEB | PEQ | `2` | `1` | |
278
305
  | FACT / FCAM / NCRE / NDEB | EXE | `4` | `1` | Exento |
279
306
 
280
- Tanto `tipo_frase` como `escenario` se pueden sobreescribir de forma
281
- independiente — por llamada (como argumentos con nombre) o globalmente al
282
- construir el cliente. Cuando se omiten, cada uno cae al global del
283
- 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.
284
331
 
285
332
  ```python
286
333
  # Sobreescritura por llamada (uno o ambos)
@@ -311,7 +358,7 @@ Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `
311
358
  |--------|-------|-------------|
312
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`. |
313
360
  | `cca_invoice()` | `cca_invoice(buyer, items, cobros, *, tipo_frase=None, escenario=None)` | FACT con complemento CCA. |
314
- | `fuel_invoice()` | `fuel_invoice(buyer, items, *, tipo_frase=None, escenario=None)` | FACT con complemento combustible (IVA + PETROLEO). |
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. |
315
362
  | `credit_note()` | `credit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de crédito (NCRE). |
316
363
  | `debit_note()` | `debit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de débito (NDEB). |
317
364
  | `credit_note_total()` | `credit_note_total(auth_number, issue_datetime, reason="...", reference="")` | Nota de crédito total. Devuelve `dict`. |
@@ -362,6 +409,8 @@ python -m pytest tests/ -v
362
409
  | `DIGIFACT_TAXID` | NIT del emisor (p. ej. `12345678`) |
363
410
  | `DIGIFACT_USERNAME` | Usuario (p. ej. `FELUSER`) |
364
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 |
365
414
 
366
415
  ## Manejo de errores
367
416
 
@@ -11,29 +11,33 @@ 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
17
  ## Configuración del cliente (`DigifactClient(...)`)
18
18
 
19
+ Ordenados de más usados a menos usados.
20
+
19
21
  | Parámetro | Tipo | Por defecto | Descripción |
20
22
  |-----------|------|-------------|-------------|
21
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. |
22
24
  | `username` | `str` | **requerido** | Usuario corto de Digifact (la parte después de `GT.<NIT>.`, p. ej. `"FELUSER"`). |
23
25
  | `password` | `str` | `""` | Contraseña de la cuenta. **Requerido** si no se provee `token`. |
24
- | `environment` | `str` | `"test"` | `"test"` o `"production"`. |
25
26
  | `token` | `str` | `""` | Bearer token preobtenido. Si se provee, se omite el login. |
26
- | `seller_name` | `str` | `""` | Nombre comercial del emisor. Si está vacío, se consulta en SAT vía `lookup_nit()`. |
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()`. |
27
29
  | `seller_address` | `str` | `""` | Dirección del emisor. Si está vacía, se consulta en SAT. |
28
- | `afiliacion_iva` | `str` | `"GEN"` | Afiliación IVA del RTU: `"GEN"`, `"PEQ"` o `"EXE"`. |
29
- | `tipo_personeria` | `str` | `"1"` | Código de `TipoPersoneria` del RTU (usado en RDON). |
30
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 del establecimiento. Se escribe en `Seller.BranchInfo.Name`. |
32
- | `tipo_frase` | `str \| None` | `None` | Sobreescritura global de `TipoFrase`. Ver [frases](#configuración-de-frases-tipofrase--codigoescenario). |
33
- | `escenario` | `str \| None` | `None` | Sobreescritura global de `CodigoEscenario`. |
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). |
34
38
  | `timeout` | `int` | `120` | Timeout HTTP en segundos. |
35
39
  | `session` | `requests.Session \| None` | `None` | Sesión HTTP personalizada (útil para tests). |
36
- | `petroleo_rates` | `dict[str, float] \| None` | `None` | Mapa código PETROLEO → tarifa por unidad. Usado por `fuel_invoice()`. |
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. |
37
41
 
38
42
  ## Inicio rápido
39
43
 
@@ -232,13 +236,36 @@ result = client.fuel_invoice(
232
236
  | `petroleo_amount` | `float\|Decimal` | — | Impuesto PETROLEO por unidad; omitir para ítems sólo-IVA |
233
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. |
234
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
+
235
262
  ## Configuración de frases (TipoFrase / CodigoEscenario)
236
263
 
237
264
  Todo DTE (excepto FESP) debe llevar un par `TipoFrase` + `CodigoEscenario`. El
238
265
  SDK elige valores por defecto adecuados, por lo que **no** hace falta
239
266
  configurar nada en el caso común.
240
267
 
241
- **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.
242
269
 
243
270
  **Tabla de valores por defecto:**
244
271
 
@@ -253,10 +280,30 @@ configurar nada en el caso común.
253
280
  | FACT / FCAM / NCRE / NDEB | PEQ | `2` | `1` | |
254
281
  | FACT / FCAM / NCRE / NDEB | EXE | `4` | `1` | Exento |
255
282
 
256
- Tanto `tipo_frase` como `escenario` se pueden sobreescribir de forma
257
- independiente — por llamada (como argumentos con nombre) o globalmente al
258
- construir el cliente. Cuando se omiten, cada uno cae al global del
259
- 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.
260
307
 
261
308
  ```python
262
309
  # Sobreescritura por llamada (uno o ambos)
@@ -287,7 +334,7 @@ Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `
287
334
  |--------|-------|-------------|
288
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`. |
289
336
  | `cca_invoice()` | `cca_invoice(buyer, items, cobros, *, tipo_frase=None, escenario=None)` | FACT con complemento CCA. |
290
- | `fuel_invoice()` | `fuel_invoice(buyer, items, *, tipo_frase=None, escenario=None)` | FACT con complemento combustible (IVA + PETROLEO). |
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. |
291
338
  | `credit_note()` | `credit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de crédito (NCRE). |
292
339
  | `debit_note()` | `debit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de débito (NDEB). |
293
340
  | `credit_note_total()` | `credit_note_total(auth_number, issue_datetime, reason="...", reference="")` | Nota de crédito total. Devuelve `dict`. |
@@ -338,6 +385,8 @@ python -m pytest tests/ -v
338
385
  | `DIGIFACT_TAXID` | NIT del emisor (p. ej. `12345678`) |
339
386
  | `DIGIFACT_USERNAME` | Usuario (p. ej. `FELUSER`) |
340
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 |
341
390
 
342
391
  ## Manejo de errores
343
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
@@ -127,6 +127,8 @@ class DigifactClient:
127
127
  tipo_personeria: str = "1",
128
128
  tipo_frase: str | None = None,
129
129
  escenario: str | None = None,
130
+ frases: list[dict] | None = None,
131
+ auto_fuel_subsidy_frases: bool | None = None,
130
132
  branch_code: str = "1",
131
133
  branch_name: str = "ESTABLECIMIENTO PRINCIPAL",
132
134
  timeout: int = 120,
@@ -140,6 +142,10 @@ class DigifactClient:
140
142
  raise ValueError("username is required")
141
143
  if not token and not password:
142
144
  raise ValueError("password or token is required")
145
+ if frases is not None and (tipo_frase is not None or escenario is not None):
146
+ raise ValueError(
147
+ "frases and tipo_frase/escenario are mutually exclusive; use one or the other"
148
+ )
143
149
 
144
150
  self.padded_taxid = pad_taxid(taxid)
145
151
  self.username = username
@@ -149,6 +155,8 @@ class DigifactClient:
149
155
  self.tipo_personeria = tipo_personeria
150
156
  self.tipo_frase = tipo_frase
151
157
  self.escenario = escenario
158
+ self.frases = frases
159
+ self.auto_fuel_subsidy_frases = auto_fuel_subsidy_frases
152
160
  self.branch_code = branch_code
153
161
  self.branch_name = branch_name
154
162
  self.timeout = timeout
@@ -369,7 +377,12 @@ class DigifactClient:
369
377
  """
370
378
  seller_name, seller_address = self._get_seller_info()
371
379
  buyer_dict = self._resolve_buyer(buyer)
372
- tf, es = self._resolve_frase(doc_type, tipo_frase, escenario)
380
+ if self.frases is not None and tipo_frase is None and escenario is None:
381
+ eff_frases = self.frases
382
+ tf = es = None
383
+ else:
384
+ eff_frases = None
385
+ tf, es = self._resolve_frase(doc_type, tipo_frase, escenario)
373
386
 
374
387
  if doc_type == "FCAM":
375
388
  if not payment_terms:
@@ -386,6 +399,7 @@ class DigifactClient:
386
399
  observaciones=observaciones,
387
400
  tipo_frase=tf,
388
401
  escenario=es,
402
+ frases=eff_frases,
389
403
  )
390
404
  elif doc_type == "FESP":
391
405
  payload = build_fesp(
@@ -406,6 +420,7 @@ class DigifactClient:
406
420
  afiliacion=self.afiliacion_iva,
407
421
  amount_str=amount_str,
408
422
  observaciones=observaciones,
423
+ frases=eff_frases,
409
424
  )
410
425
  elif doc_type == "RDON":
411
426
  tp = tipo_personeria or self.tipo_personeria
@@ -419,6 +434,7 @@ class DigifactClient:
419
434
  afiliacion=self.afiliacion_iva,
420
435
  amount_str=amount_str,
421
436
  observaciones=observaciones,
437
+ frases=eff_frases,
422
438
  )
423
439
  elif doc_type == "FPEQ":
424
440
  payload = build_fpeq(
@@ -431,6 +447,7 @@ class DigifactClient:
431
447
  observaciones=observaciones,
432
448
  tipo_frase=tf,
433
449
  escenario=es,
450
+ frases=eff_frases,
434
451
  )
435
452
  elif doc_type == "RECI":
436
453
  payload = build_reci(
@@ -442,6 +459,7 @@ class DigifactClient:
442
459
  afiliacion=self.afiliacion_iva,
443
460
  amount_str=amount_str,
444
461
  observaciones=observaciones,
462
+ frases=eff_frases,
445
463
  )
446
464
  else:
447
465
  # FACT (default), FACT CUI
@@ -455,6 +473,7 @@ class DigifactClient:
455
473
  afiliacion=self.afiliacion_iva,
456
474
  tipo_frase=tf,
457
475
  escenario=es,
476
+ frases=eff_frases,
458
477
  amount_str=amount_str,
459
478
  observaciones=observaciones,
460
479
  )
@@ -474,7 +493,12 @@ class DigifactClient:
474
493
  """Emit a CCA (Cobro por Cuenta Ajena) FACT+CCA complemento."""
475
494
  seller_name, seller_address = self._get_seller_info()
476
495
  buyer_dict = self._resolve_buyer(buyer)
477
- tf, es = self._resolve_frase("FACT", tipo_frase, escenario)
496
+ if self.frases is not None and tipo_frase is None and escenario is None:
497
+ eff_frases = self.frases
498
+ tf = es = None
499
+ else:
500
+ eff_frases = None
501
+ tf, es = self._resolve_frase("FACT", tipo_frase, escenario)
478
502
  payload = build_cca(
479
503
  self.taxid,
480
504
  seller_name,
@@ -485,6 +509,7 @@ class DigifactClient:
485
509
  afiliacion=self.afiliacion_iva,
486
510
  tipo_frase=tf,
487
511
  escenario=es,
512
+ frases=eff_frases,
488
513
  )
489
514
  data = self._certify(payload)
490
515
  return self._parse_result(data)
@@ -496,6 +521,8 @@ class DigifactClient:
496
521
  *,
497
522
  tipo_frase: str | None = None,
498
523
  escenario: str | None = None,
524
+ frases: list[dict] | None = None,
525
+ auto_fuel_subsidy_frases: bool | None = None,
499
526
  ) -> DteResult:
500
527
  """Emit a combustible (fuel) FACT invoice.
501
528
 
@@ -511,6 +538,15 @@ class DigifactClient:
511
538
  ``"4"`` DIESEL (default ``"1"``).
512
539
 
513
540
  Items without ``petroleo_amount`` are treated as regular IVA-only items.
541
+ frases:
542
+ Explicit list of ``{"tipo_frase": ..., "escenario": ...}`` dicts.
543
+ Mutually exclusive with ``tipo_frase``/``escenario``. When set,
544
+ auto-injection of subsidy frases (9/18, 9/19) is suppressed.
545
+ auto_fuel_subsidy_frases:
546
+ Override the client-level or default auto-injection behaviour.
547
+ ``None`` (default) falls back to the constructor setting, then
548
+ ``True``. Set to ``False`` (or ``DIGIFACT_DISABLE_AUTO_FUEL_SUBSIDY_FRASES=1``)
549
+ to disable auto-injection.
514
550
 
515
551
  Example::
516
552
 
@@ -521,10 +557,39 @@ class DigifactClient:
521
557
  "type": "Bien"},
522
558
  ])
523
559
  """
560
+ import os
561
+ from .exceptions import DigifactValidationError
562
+
563
+ if frases is not None and (tipo_frase is not None or escenario is not None):
564
+ raise DigifactValidationError(
565
+ "frases and tipo_frase/escenario are mutually exclusive; use one or the other"
566
+ )
567
+
568
+ # Resolve effective frases: per-call → constructor → legacy tipo_frase/escenario
569
+ eff_frases: list[dict] | None
570
+ eff_tf: str | None
571
+ eff_es: str | None
572
+ if frases is not None:
573
+ eff_frases, eff_tf, eff_es = frases, None, None
574
+ elif self.frases is not None:
575
+ eff_frases, eff_tf, eff_es = self.frases, None, None
576
+ else:
577
+ eff_frases = None
578
+ eff_tf, eff_es = self._resolve_frase("FACT", tipo_frase, escenario)
579
+
580
+ # Resolve auto_enabled
581
+ if auto_fuel_subsidy_frases is not None:
582
+ auto_enabled = auto_fuel_subsidy_frases
583
+ elif self.auto_fuel_subsidy_frases is not None:
584
+ auto_enabled = self.auto_fuel_subsidy_frases
585
+ else:
586
+ auto_enabled = True
587
+ if os.environ.get("DIGIFACT_DISABLE_AUTO_FUEL_SUBSIDY_FRASES", "") == "1":
588
+ auto_enabled = False
589
+
524
590
  seller_name, seller_address = self._get_seller_info()
525
591
  buyer_dict = self._resolve_buyer(buyer)
526
592
  resolved = self._apply_petroleo_rates(items)
527
- tf, es = self._resolve_frase("FACT", tipo_frase, escenario)
528
593
  payload = build_fact_combustible(
529
594
  self.taxid,
530
595
  seller_name,
@@ -532,8 +597,10 @@ class DigifactClient:
532
597
  buyer_dict,
533
598
  resolved,
534
599
  afiliacion=self.afiliacion_iva,
535
- tipo_frase=tf,
536
- escenario=es,
600
+ tipo_frase=eff_tf,
601
+ escenario=eff_es,
602
+ frases=eff_frases,
603
+ auto_fuel_subsidy_frases=auto_enabled,
537
604
  )
538
605
  data = self._certify(payload)
539
606
  return self._parse_result(data)
@@ -579,7 +646,12 @@ class DigifactClient:
579
646
  """
580
647
  seller_name, seller_address = self._get_seller_info()
581
648
  buyer_dict = self._resolve_buyer(buyer)
582
- tf, es = self._resolve_frase("NCRE", tipo_frase, escenario)
649
+ if self.frases is not None and tipo_frase is None and escenario is None:
650
+ eff_frases = self.frases
651
+ tf = es = None
652
+ else:
653
+ eff_frases = None
654
+ tf, es = self._resolve_frase("NCRE", tipo_frase, escenario)
583
655
  payload = build_ncre(
584
656
  self.taxid,
585
657
  seller_name,
@@ -591,6 +663,7 @@ class DigifactClient:
591
663
  afiliacion=self.afiliacion_iva,
592
664
  tipo_frase=tf,
593
665
  escenario=es,
666
+ frases=eff_frases,
594
667
  )
595
668
  data = self._certify(payload)
596
669
  return self._parse_result(data)
@@ -616,7 +689,12 @@ class DigifactClient:
616
689
  """
617
690
  seller_name, seller_address = self._get_seller_info()
618
691
  buyer_dict = self._resolve_buyer(buyer)
619
- tf, es = self._resolve_frase("NDEB", tipo_frase, escenario)
692
+ if self.frases is not None and tipo_frase is None and escenario is None:
693
+ eff_frases = self.frases
694
+ tf = es = None
695
+ else:
696
+ eff_frases = None
697
+ tf, es = self._resolve_frase("NDEB", tipo_frase, escenario)
620
698
  payload = build_ndeb(
621
699
  self.taxid,
622
700
  seller_name,
@@ -628,6 +706,7 @@ class DigifactClient:
628
706
  afiliacion=self.afiliacion_iva,
629
707
  tipo_frase=tf,
630
708
  escenario=es,
709
+ frases=eff_frases,
631
710
  )
632
711
  data = self._certify(payload)
633
712
  return self._parse_result(data)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: digifact-sdk
3
- Version: 2.2.3
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,29 +35,33 @@ 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
41
  ## Configuración del cliente (`DigifactClient(...)`)
42
42
 
43
+ Ordenados de más usados a menos usados.
44
+
43
45
  | Parámetro | Tipo | Por defecto | Descripción |
44
46
  |-----------|------|-------------|-------------|
45
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. |
46
48
  | `username` | `str` | **requerido** | Usuario corto de Digifact (la parte después de `GT.<NIT>.`, p. ej. `"FELUSER"`). |
47
49
  | `password` | `str` | `""` | Contraseña de la cuenta. **Requerido** si no se provee `token`. |
48
- | `environment` | `str` | `"test"` | `"test"` o `"production"`. |
49
50
  | `token` | `str` | `""` | Bearer token preobtenido. Si se provee, se omite el login. |
50
- | `seller_name` | `str` | `""` | Nombre comercial del emisor. Si está vacío, se consulta en SAT vía `lookup_nit()`. |
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()`. |
51
53
  | `seller_address` | `str` | `""` | Dirección del emisor. Si está vacía, se consulta en SAT. |
52
- | `afiliacion_iva` | `str` | `"GEN"` | Afiliación IVA del RTU: `"GEN"`, `"PEQ"` o `"EXE"`. |
53
- | `tipo_personeria` | `str` | `"1"` | Código de `TipoPersoneria` del RTU (usado en RDON). |
54
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 del establecimiento. Se escribe en `Seller.BranchInfo.Name`. |
56
- | `tipo_frase` | `str \| None` | `None` | Sobreescritura global de `TipoFrase`. Ver [frases](#configuración-de-frases-tipofrase--codigoescenario). |
57
- | `escenario` | `str \| None` | `None` | Sobreescritura global de `CodigoEscenario`. |
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). |
58
62
  | `timeout` | `int` | `120` | Timeout HTTP en segundos. |
59
63
  | `session` | `requests.Session \| None` | `None` | Sesión HTTP personalizada (útil para tests). |
60
- | `petroleo_rates` | `dict[str, float] \| None` | `None` | Mapa código PETROLEO → tarifa por unidad. Usado por `fuel_invoice()`. |
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. |
61
65
 
62
66
  ## Inicio rápido
63
67
 
@@ -256,13 +260,36 @@ result = client.fuel_invoice(
256
260
  | `petroleo_amount` | `float\|Decimal` | — | Impuesto PETROLEO por unidad; omitir para ítems sólo-IVA |
257
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. |
258
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
+
259
286
  ## Configuración de frases (TipoFrase / CodigoEscenario)
260
287
 
261
288
  Todo DTE (excepto FESP) debe llevar un par `TipoFrase` + `CodigoEscenario`. El
262
289
  SDK elige valores por defecto adecuados, por lo que **no** hace falta
263
290
  configurar nada en el caso común.
264
291
 
265
- **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.
266
293
 
267
294
  **Tabla de valores por defecto:**
268
295
 
@@ -277,10 +304,30 @@ configurar nada en el caso común.
277
304
  | FACT / FCAM / NCRE / NDEB | PEQ | `2` | `1` | |
278
305
  | FACT / FCAM / NCRE / NDEB | EXE | `4` | `1` | Exento |
279
306
 
280
- Tanto `tipo_frase` como `escenario` se pueden sobreescribir de forma
281
- independiente — por llamada (como argumentos con nombre) o globalmente al
282
- construir el cliente. Cuando se omiten, cada uno cae al global del
283
- 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.
284
331
 
285
332
  ```python
286
333
  # Sobreescritura por llamada (uno o ambos)
@@ -311,7 +358,7 @@ Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `
311
358
  |--------|-------|-------------|
312
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`. |
313
360
  | `cca_invoice()` | `cca_invoice(buyer, items, cobros, *, tipo_frase=None, escenario=None)` | FACT con complemento CCA. |
314
- | `fuel_invoice()` | `fuel_invoice(buyer, items, *, tipo_frase=None, escenario=None)` | FACT con complemento combustible (IVA + PETROLEO). |
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. |
315
362
  | `credit_note()` | `credit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de crédito (NCRE). |
316
363
  | `debit_note()` | `debit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de débito (NDEB). |
317
364
  | `credit_note_total()` | `credit_note_total(auth_number, issue_datetime, reason="...", reference="")` | Nota de crédito total. Devuelve `dict`. |
@@ -362,6 +409,8 @@ python -m pytest tests/ -v
362
409
  | `DIGIFACT_TAXID` | NIT del emisor (p. ej. `12345678`) |
363
410
  | `DIGIFACT_USERNAME` | Usuario (p. ej. `FELUSER`) |
364
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 |
365
414
 
366
415
  ## Manejo de errores
367
416
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "digifact-sdk"
7
- version = "2.2.3"
7
+ version = "2.3.0"
8
8
  description = "Python SDK for Digifact FEL Guatemala e-invoicing API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -27,6 +27,7 @@ except ImportError:
27
27
  TAXID = os.environ.get("DIGIFACT_TAXID", "")
28
28
  USERNAME = os.environ.get("DIGIFACT_USERNAME", "")
29
29
  PASSWORD = os.environ.get("DIGIFACT_PASSWORD", "")
30
+ ENV = os.environ.get("DIGIFACT_ENVIRONMENT", "test").lower()
30
31
 
31
32
  SKIP_REASON = "Set DIGIFACT_TAXID, DIGIFACT_USERNAME, DIGIFACT_PASSWORD to run integration tests"
32
33
  SKIP = not (TAXID and USERNAME and PASSWORD)
@@ -40,7 +41,7 @@ if not SKIP:
40
41
  taxid=TAXID,
41
42
  username=USERNAME,
42
43
  password=PASSWORD,
43
- environment="test",
44
+ environment=ENV,
44
45
  )
45
46
  else:
46
47
  _CLIENT = None # type: ignore[assignment]
@@ -288,6 +289,127 @@ class TestBuilderUnit(unittest.TestCase):
288
289
  self.assertIn("PETROLEO", total_descs)
289
290
 
290
291
 
292
+ class TestFuelFrases(unittest.TestCase):
293
+ """Unit tests for frases[] / auto-fuel-subsidy-injection logic."""
294
+
295
+ def _buyer(self):
296
+ return {"TaxID": "CF", "Name": "CONSUMIDOR FINAL",
297
+ "AddressInfo": {"Address": "CIUDAD", "City": "01010",
298
+ "District": "GUATEMALA", "State": "GUATEMALA", "Country": "GT"}}
299
+
300
+ def _items(self):
301
+ return [{"description": "SUPER", "qty": 1, "price": 35.00,
302
+ "petroleo_amount": 4.70, "petroleo_code": "1", "type": "Bien"}]
303
+
304
+ def _additionl_frases(self, payload):
305
+ ai = payload["Seller"].get("AdditionlInfo", [])
306
+ frases = []
307
+ i = 0
308
+ while i + 1 < len(ai):
309
+ frases.append((ai[i]["Value"], ai[i + 1]["Value"]))
310
+ i += 2
311
+ return frases
312
+
313
+ def test_non_fuel_invoice_no_subsidy_frases(self):
314
+ from digifact_sdk.builder import build_fact
315
+ buyer = self._buyer()
316
+ payload = build_fact("12345678", "TEST", "CALLE", buyer=buyer,
317
+ items=[{"description": "X", "qty": 1, "price": 100.0}])
318
+ frases = self._additionl_frases(payload)
319
+ self.assertEqual(len(frases), 1, "Regular invoice must have only the base frase")
320
+ self.assertNotIn(("9", "18"), frases)
321
+ self.assertNotIn(("9", "19"), frases)
322
+
323
+ def test_fuel_within_window_auto_inject(self):
324
+ from digifact_sdk.builder import build_fact_combustible, resolve_fuel_frases
325
+ # Use resolve_fuel_frases directly with a date known to be in window
326
+ result = resolve_fuel_frases(None, "1", "1", "2026-06-01T10:00:00", auto_enabled=True)
327
+ tf_es = [(f["tipo_frase"], f["escenario"]) for f in result]
328
+ self.assertIn(("1", "1"), tf_es, "Base frase must be present")
329
+ self.assertIn(("9", "18"), tf_es, "Escenario 18 must be auto-injected")
330
+ self.assertIn(("9", "19"), tf_es, "Escenario 19 must be auto-injected")
331
+ self.assertEqual(len(result), 3)
332
+
333
+ def test_fuel_custom_frases_no_auto_inject(self):
334
+ from digifact_sdk.builder import resolve_fuel_frases
335
+ custom = [{"tipo_frase": "2", "escenario": "1"}]
336
+ result = resolve_fuel_frases(custom, None, None, "2026-06-01T10:00:00", auto_enabled=True)
337
+ self.assertEqual(len(result), 1)
338
+ self.assertEqual(result[0]["tipo_frase"], "2")
339
+ self.assertNotIn(("9", "18"), [(f["tipo_frase"], f["escenario"]) for f in result])
340
+
341
+ def test_fuel_auto_disabled_no_inject(self):
342
+ from digifact_sdk.builder import resolve_fuel_frases
343
+ result = resolve_fuel_frases(None, "1", "1", "2026-06-01T10:00:00", auto_enabled=False)
344
+ tf_es = [(f["tipo_frase"], f["escenario"]) for f in result]
345
+ self.assertEqual(len(result), 1)
346
+ self.assertNotIn(("9", "18"), tf_es)
347
+ self.assertNotIn(("9", "19"), tf_es)
348
+
349
+ def test_deduplication(self):
350
+ from digifact_sdk.builder import resolve_fuel_frases
351
+ dupes = [{"tipo_frase": "9", "escenario": "18"},
352
+ {"tipo_frase": "9", "escenario": "18"},
353
+ {"tipo_frase": "9", "escenario": "19"}]
354
+ result = resolve_fuel_frases(dupes, None, None, "2026-06-01T10:00:00", auto_enabled=True)
355
+ self.assertEqual(len(result), 2)
356
+
357
+ def test_outside_window_no_inject(self):
358
+ from digifact_sdk.builder import resolve_fuel_frases
359
+ # Date before window
360
+ result_before = resolve_fuel_frases(None, "1", "1", "2026-04-26T10:00:00", auto_enabled=True)
361
+ self.assertEqual(len(result_before), 1)
362
+ # Date after window
363
+ result_after = resolve_fuel_frases(None, "1", "1", "2026-07-27T10:00:00", auto_enabled=True)
364
+ self.assertEqual(len(result_after), 1)
365
+
366
+ def test_build_fact_combustible_seller_has_additionl_info(self):
367
+ from digifact_sdk.builder import build_fact_combustible
368
+ payload = build_fact_combustible(
369
+ "12345678", "SELLER", "ADDR",
370
+ buyer=self._buyer(), items=self._items(),
371
+ auto_fuel_subsidy_frases=False,
372
+ )
373
+ ai = payload["Seller"].get("AdditionlInfo", [])
374
+ self.assertTrue(len(ai) >= 2, "Seller.AdditionlInfo must be present for fuel invoice")
375
+ self.assertEqual(ai[0]["Name"], "TipoFrase", "first AdditionlInfo entry must be TipoFrase")
376
+ self.assertEqual(ai[0]["Value"], "1")
377
+ self.assertEqual(ai[0]["Data"], "1")
378
+ self.assertEqual(ai[1]["Name"], "Escenario")
379
+ self.assertEqual(ai[1]["Data"], "1")
380
+
381
+ def test_build_fact_combustible_subsidy_frases_in_additionl_info(self):
382
+ from digifact_sdk.builder import build_fact_combustible
383
+ payload = build_fact_combustible(
384
+ "12345678", "SELLER", "ADDR",
385
+ buyer=self._buyer(), items=self._items(),
386
+ tipo_frase="1", escenario="1", auto_fuel_subsidy_frases=True,
387
+ )
388
+ ai = payload["Seller"].get("AdditionlInfo", [])
389
+ pairs = [(ai[i]["Value"], ai[i+1]["Value"]) for i in range(0, len(ai) - 1, 2)]
390
+ tipo_frases = [tf for tf, _ in pairs]
391
+ self.assertIn("1", tipo_frases, "TipoFrase=1 must be in AdditionlInfo")
392
+ # Within subsidy window: 9/18 and 9/19 must also be present
393
+ if len(pairs) > 1:
394
+ self.assertIn(("9", "18"), pairs, "9/18 must be in AdditionlInfo when within subsidy window")
395
+ self.assertIn(("9", "19"), pairs, "9/19 must be in AdditionlInfo when within subsidy window")
396
+ # Data must be the 1-based frase group index so the API creates separate <dte:Frase> elements
397
+ for idx, entry in enumerate(ai):
398
+ expected_data = str(idx // 2 + 1)
399
+ self.assertEqual(entry["Data"], expected_data, f"ai[{idx}]['Data'] must equal frase group index")
400
+
401
+ def test_mutual_exclusivity_raises(self):
402
+ from digifact_sdk.builder import build_fact_combustible
403
+ from digifact_sdk.exceptions import DigifactValidationError
404
+ with self.assertRaises(DigifactValidationError):
405
+ build_fact_combustible(
406
+ "12345678", "TEST", "CALLE",
407
+ buyer=self._buyer(), items=self._items(),
408
+ frases=[{"tipo_frase": "1", "escenario": "1"}],
409
+ tipo_frase="1",
410
+ )
411
+
412
+
291
413
  # ── Integration tests (shared client, 1 login total) ─────────────────────────
292
414
 
293
415
  @unittest.skipIf(SKIP, SKIP_REASON)
@@ -547,6 +669,10 @@ class TestGetDte(unittest.TestCase):
547
669
  @unittest.skipIf(SKIP, SKIP_REASON)
548
670
  class TestFuelInvoice(unittest.TestCase):
549
671
  def test_fuel_invoice_cf_mixed_items(self):
672
+ import base64, re as _re
673
+ is_production = ENV == "production"
674
+ # Test env does not support TipoFrase=9 (subsidio) — disable auto-injection there.
675
+ # On production, the default (True) applies and subsidio frases are included automatically.
550
676
  result = _client().fuel_invoice(
551
677
  "CF",
552
678
  [
@@ -556,10 +682,23 @@ class TestFuelInvoice(unittest.TestCase):
556
682
  "petroleo_amount": 4.60, "petroleo_code": "2", "type": "Bien"},
557
683
  {"description": "FILTRO DE ACEITE", "qty": 1, "price": 45.00, "type": "Bien"},
558
684
  ],
685
+ auto_fuel_subsidy_frases=is_production,
559
686
  )
560
687
  self.assertTrue(result.auth_number, "Expected auth_number")
561
688
  print(f"\n FACT Combustible auth: {result.auth_number}")
562
689
 
690
+ if is_production:
691
+ # Verify subsidio frases appear in the certified XML
692
+ dte = _client().get_dte(result.auth_number, "XML")
693
+ b64 = (dte.get("RESPONSE") or [{}])[0].get("ResponseData1") or dte.get("responseData1", "")
694
+ self.assertTrue(b64, "ResponseData1 must be present")
695
+ xml = base64.b64decode(b64).decode("utf-8", errors="replace")
696
+ frases = _re.findall(r'TipoFrase="\d+" CodigoEscenario="\d+"', xml)
697
+ print(f" Frases en XML: {', '.join(frases)}")
698
+ self.assertIn('TipoFrase="9"', xml, "TipoFrase=9 must appear in certified XML")
699
+ self.assertIn('CodigoEscenario="18"', xml, "CodigoEscenario=18 must appear in certified XML")
700
+ self.assertIn('CodigoEscenario="19"', xml, "CodigoEscenario=19 must appear in certified XML")
701
+
563
702
 
564
703
  if __name__ == "__main__":
565
704
  unittest.main()
File without changes