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.
- {digifact_sdk-2.2.3 → digifact_sdk-2.3.0}/PKG-INFO +65 -16
- {digifact_sdk-2.2.3 → digifact_sdk-2.3.0}/README.md +64 -15
- {digifact_sdk-2.2.3 → digifact_sdk-2.3.0}/digifact_sdk/builder.py +109 -6
- {digifact_sdk-2.2.3 → digifact_sdk-2.3.0}/digifact_sdk/client.py +86 -7
- {digifact_sdk-2.2.3 → digifact_sdk-2.3.0}/digifact_sdk.egg-info/PKG-INFO +65 -16
- {digifact_sdk-2.2.3 → digifact_sdk-2.3.0}/pyproject.toml +1 -1
- {digifact_sdk-2.2.3 → digifact_sdk-2.3.0}/tests/test_integration.py +140 -1
- {digifact_sdk-2.2.3 → digifact_sdk-2.3.0}/digifact_sdk/__init__.py +0 -0
- {digifact_sdk-2.2.3 → digifact_sdk-2.3.0}/digifact_sdk/exceptions.py +0 -0
- {digifact_sdk-2.2.3 → digifact_sdk-2.3.0}/digifact_sdk/tax.py +0 -0
- {digifact_sdk-2.2.3 → digifact_sdk-2.3.0}/digifact_sdk.egg-info/SOURCES.txt +0 -0
- {digifact_sdk-2.2.3 → digifact_sdk-2.3.0}/digifact_sdk.egg-info/dependency_links.txt +0 -0
- {digifact_sdk-2.2.3 → digifact_sdk-2.3.0}/digifact_sdk.egg-info/requires.txt +0 -0
- {digifact_sdk-2.2.3 → digifact_sdk-2.3.0}/digifact_sdk.egg-info/top_level.txt +0 -0
- {digifact_sdk-2.2.3 → digifact_sdk-2.3.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: digifact-sdk
|
|
3
|
-
Version: 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,29 +35,33 @@ pip install digifact-sdk
|
|
|
35
35
|
O desde el código fuente:
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
pip install -e
|
|
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
|
-
| `
|
|
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
|
|
56
|
-
| `
|
|
57
|
-
| `
|
|
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
|
-
| `
|
|
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
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
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
|
-
| `
|
|
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
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
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
|
-
| `
|
|
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
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
536
|
-
escenario=
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
| `
|
|
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
|
|
56
|
-
| `
|
|
57
|
-
| `
|
|
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
|
-
| `
|
|
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
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
|
@@ -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=
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|