digifact-sdk 2.3.0__tar.gz → 3.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {digifact_sdk-2.3.0 → digifact_sdk-3.1.0}/PKG-INFO +61 -19
- {digifact_sdk-2.3.0 → digifact_sdk-3.1.0}/README.md +60 -18
- {digifact_sdk-2.3.0 → digifact_sdk-3.1.0}/digifact_sdk/__init__.py +15 -1
- {digifact_sdk-2.3.0 → digifact_sdk-3.1.0}/digifact_sdk/builder.py +116 -33
- {digifact_sdk-2.3.0 → digifact_sdk-3.1.0}/digifact_sdk/client.py +120 -30
- {digifact_sdk-2.3.0 → digifact_sdk-3.1.0}/digifact_sdk.egg-info/PKG-INFO +61 -19
- {digifact_sdk-2.3.0 → digifact_sdk-3.1.0}/pyproject.toml +1 -1
- {digifact_sdk-2.3.0 → digifact_sdk-3.1.0}/tests/test_integration.py +175 -71
- {digifact_sdk-2.3.0 → digifact_sdk-3.1.0}/digifact_sdk/exceptions.py +0 -0
- {digifact_sdk-2.3.0 → digifact_sdk-3.1.0}/digifact_sdk/tax.py +0 -0
- {digifact_sdk-2.3.0 → digifact_sdk-3.1.0}/digifact_sdk.egg-info/SOURCES.txt +0 -0
- {digifact_sdk-2.3.0 → digifact_sdk-3.1.0}/digifact_sdk.egg-info/dependency_links.txt +0 -0
- {digifact_sdk-2.3.0 → digifact_sdk-3.1.0}/digifact_sdk.egg-info/requires.txt +0 -0
- {digifact_sdk-2.3.0 → digifact_sdk-3.1.0}/digifact_sdk.egg-info/top_level.txt +0 -0
- {digifact_sdk-2.3.0 → digifact_sdk-3.1.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: digifact-sdk
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.1.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
|
|
@@ -57,7 +57,6 @@ Ordenados de más usados a menos usados.
|
|
|
57
57
|
| `tipo_frase` | `str \| None` | `None` | Sobreescritura global de `TipoFrase` (legacy). **Mutuamente exclusivo con `frases`**. Ver [frases](#configuración-de-frases-tipofrase--codigoescenario). |
|
|
58
58
|
| `escenario` | `str \| None` | `None` | Sobreescritura global de `CodigoEscenario` (legacy). **Mutuamente exclusivo con `frases`**. |
|
|
59
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
60
|
| `petroleo_rates` | `dict[str, float] \| None` | `None` | Mapa código PETROLEO → tarifa por unidad. Usado sólo por `fuel_invoice()` (gasolineras). |
|
|
62
61
|
| `timeout` | `int` | `120` | Timeout HTTP en segundos. |
|
|
63
62
|
| `session` | `requests.Session \| None` | `None` | Sesión HTTP personalizada (útil para tests). |
|
|
@@ -91,7 +90,19 @@ result = client.invoice(
|
|
|
91
90
|
]
|
|
92
91
|
)
|
|
93
92
|
|
|
94
|
-
# FACT a receptor con CUI
|
|
93
|
+
# FACT a receptor con CUI (DPI) — el nombre se consulta en SAT automáticamente
|
|
94
|
+
result = client.invoice(
|
|
95
|
+
buyer={"taxid": "1234567890123", "type": "CUI"},
|
|
96
|
+
items=[{"description": "Producto", "qty": 2, "price": 50.00}]
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Equivalente: un string de 13 dígitos se resuelve como CUI
|
|
100
|
+
result = client.invoice(
|
|
101
|
+
buyer="1234567890123",
|
|
102
|
+
items=[{"description": "Producto", "qty": 2, "price": 50.00}]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Con nombre explícito no se consulta SAT
|
|
95
106
|
result = client.invoice(
|
|
96
107
|
buyer={"taxid": "3730617490101", "type": "CUI", "name": "Juan Pérez"},
|
|
97
108
|
items=[{"description": "Producto", "qty": 2, "price": 50.00}]
|
|
@@ -160,6 +171,10 @@ result = client.credit_note_total(
|
|
|
160
171
|
info = client.lookup_nit("12345678")
|
|
161
172
|
print(info["name"])
|
|
162
173
|
|
|
174
|
+
# Consulta de CUI (DPI) — SAT devuelve el nombre como "APELLIDOS, NOMBRES"
|
|
175
|
+
info = client.lookup_cui("1234567890123")
|
|
176
|
+
print(info["name"]) # "PEREZ LOPEZ, JUAN CARLOS"
|
|
177
|
+
|
|
163
178
|
# Obtener DTE
|
|
164
179
|
doc = client.get_dte("XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX")
|
|
165
180
|
```
|
|
@@ -260,27 +275,54 @@ result = client.fuel_invoice(
|
|
|
260
275
|
| `petroleo_amount` | `float\|Decimal` | — | Impuesto PETROLEO por unidad; omitir para ítems sólo-IVA |
|
|
261
276
|
| `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. |
|
|
262
277
|
|
|
263
|
-
###
|
|
278
|
+
### Exención temporal (Decreto 22-2026)
|
|
264
279
|
|
|
265
|
-
|
|
280
|
+
Del 1 de octubre al 31 de diciembre de 2026, y después hasta agotar el inventario comprado exento, la
|
|
281
|
+
gasolina superior, la regular y el diésel se facturan sin IVA ni IDP. Se activa por ítem con el código de
|
|
282
|
+
unidad gravable exento: `"18"` superior, `"21"` regular con etanol, `"20"` diésel y `"19"` regular sin
|
|
283
|
+
etanol (sólo importadores). `price` es el precio de bomba, ya sin impuestos, y `qty` va en galones. El SDK
|
|
284
|
+
envía ambos impuestos en cero y agrega las frases `9/23` y `4/38`; no hace falta `petroleo_amount` ni `petroleo_rates`.
|
|
266
285
|
|
|
267
286
|
```python
|
|
268
|
-
|
|
287
|
+
from digifact_sdk import fuel_exemption
|
|
288
|
+
|
|
289
|
+
items = [
|
|
290
|
+
{"description": "GASOLINA SUPER", "qty": 10, "price": 30.00, "petroleo_code": "18", "unit_of_measure": "GAL"},
|
|
291
|
+
{"description": "GASOLINA REGULAR", "qty": 5, "price": 28.00, "petroleo_code": "21", "unit_of_measure": "GAL"},
|
|
292
|
+
{"description": "DIESEL", "qty": 20, "price": 27.00, "petroleo_code": "20", "unit_of_measure": "GAL"},
|
|
293
|
+
]
|
|
269
294
|
result = client.fuel_invoice("CF", items)
|
|
270
295
|
|
|
271
|
-
#
|
|
272
|
-
|
|
296
|
+
# Montos y leyendas obligatorias para un ticket propio (el PDF de Digifact ya las imprime)
|
|
297
|
+
exencion = fuel_exemption(items)
|
|
298
|
+
print(exencion.idp, exencion.iva) # 93.70 117.60
|
|
299
|
+
print(exencion.leyendas)
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Los detalles y las advertencias están en el [README principal](../README.md#exención-temporal-de-combustibles-decreto-22-2026).
|
|
273
303
|
|
|
274
|
-
|
|
275
|
-
client = DigifactClient(..., auto_fuel_subsidy_frases=False)
|
|
304
|
+
### Subsidio combustible
|
|
276
305
|
|
|
277
|
-
|
|
278
|
-
|
|
306
|
+
El subsidio a la gasolina y al diésel **finalizó el jueves 2 de julio de 2026 a las 24:00**, antes de lo
|
|
307
|
+
previsto: el presupuesto de Q2 mil millones (Decreto 11-2026, reglamentado por el Acuerdo Gubernativo
|
|
308
|
+
64-2026) se agotó por la demanda. Por eso el SDK **nunca** envía las frases `TipoFrase=9, Escenario=18` ni
|
|
309
|
+
`TipoFrase=9, Escenario=19` por su cuenta — no hay fecha de corte que valga para todos.
|
|
279
310
|
|
|
280
|
-
|
|
281
|
-
|
|
311
|
+
Las estaciones con inventario adquirido bajo el subsidio deben mantener el precio rebajado hasta agotar ese
|
|
312
|
+
producto, sujeto a verificación. Mientras te quede ese inventario, manda las frases explícitamente con
|
|
313
|
+
`frases`; al agotarlo, deja de mandarlas:
|
|
314
|
+
|
|
315
|
+
```python
|
|
316
|
+
result = client.fuel_invoice("CF", items, frases=[
|
|
317
|
+
{"tipo_frase": "1", "escenario": "1"}, # frase base
|
|
318
|
+
{"tipo_frase": "9", "escenario": "18"},
|
|
319
|
+
{"tipo_frase": "9", "escenario": "19"},
|
|
320
|
+
])
|
|
282
321
|
```
|
|
283
322
|
|
|
323
|
+
> **Nota:** la leyenda del subsidio en la representación gráfica la genera Digifact a partir de las frases
|
|
324
|
+
> del XML certificado, no este SDK. Al dejar de mandar las frases, deja de imprimirse sola.
|
|
325
|
+
|
|
284
326
|
> **Nota:** `frases` y `tipo_frase`/`escenario` son **mutuamente exclusivos** — pasarlos juntos lanza `DigifactValidationError`.
|
|
285
327
|
|
|
286
328
|
## Configuración de frases (TipoFrase / CodigoEscenario)
|
|
@@ -299,7 +341,7 @@ configurar nada en el caso común.
|
|
|
299
341
|
| FPEQ | PEQ | `2` | `1` | Pequeño contribuyente |
|
|
300
342
|
| RDON | cualquiera | `4` | `4` | Donaciones |
|
|
301
343
|
| RECI | cualquiera | `4` | `5` | Recibos (universidades) |
|
|
302
|
-
| NABN | cualquiera | `
|
|
344
|
+
| NABN | cualquiera | `9` | `17` | Abonos |
|
|
303
345
|
| FACT / FCAM / NCRE / NDEB | **GEN** | `1` | `1` | Por defecto: ISR **régimen sobre utilidades trimestrales** |
|
|
304
346
|
| FACT / FCAM / NCRE / NDEB | PEQ | `2` | `1` | |
|
|
305
347
|
| FACT / FCAM / NCRE / NDEB | EXE | `4` | `1` | Exento |
|
|
@@ -309,7 +351,7 @@ configurar nada en el caso común.
|
|
|
309
351
|
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
352
|
|
|
311
353
|
```python
|
|
312
|
-
# Lista explícita de frases (
|
|
354
|
+
# Lista explícita de frases (reemplaza a tipo_frase/escenario)
|
|
313
355
|
client.fuel_invoice("CF", items, frases=[
|
|
314
356
|
{"tipo_frase": "1", "escenario": "1"},
|
|
315
357
|
{"tipo_frase": "9", "escenario": "18"},
|
|
@@ -358,18 +400,19 @@ Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `
|
|
|
358
400
|
|--------|-------|-------------|
|
|
359
401
|
| `invoice()` | `invoice(buyer, items, *, doc_type="FACT", payment_terms=None, amount_str="", observaciones="-", tipo_personeria=None, tipo_frase=None, escenario=None)` | Emite FACT, FCAM, FESP, FPEQ, NABN, RDON o RECI según `doc_type`. |
|
|
360
402
|
| `cca_invoice()` | `cca_invoice(buyer, items, cobros, *, tipo_frase=None, escenario=None)` | FACT con complemento CCA. |
|
|
361
|
-
| `fuel_invoice()` | `fuel_invoice(buyer, items, *, tipo_frase=None, escenario=None, frases=None
|
|
403
|
+
| `fuel_invoice()` | `fuel_invoice(buyer, items, *, tipo_frase=None, escenario=None, frases=None)` | FACT con complemento combustible (IVA + PETROLEO). |
|
|
362
404
|
| `credit_note()` | `credit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de crédito (NCRE). |
|
|
363
405
|
| `debit_note()` | `debit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de débito (NDEB). |
|
|
364
406
|
| `credit_note_total()` | `credit_note_total(auth_number, issue_datetime, reason="...", reference="")` | Nota de crédito total. Devuelve `dict`. |
|
|
365
407
|
| `cancel()` | `cancel(auth_number, receiver_id, issue_datetime, reason="Anulación")` | Anula un DTE. Devuelve `dict`. |
|
|
366
408
|
| `lookup_nit()` | `lookup_nit(nit)` | Consulta SAT. Devuelve `{"nit","name","address","city","district","state"}`. |
|
|
409
|
+
| `lookup_cui()` | `lookup_cui(cui)` | Consulta SAT por CUI (DPI). Devuelve `{"cui","name","status"}`. El nombre viene como `"APELLIDOS, NOMBRES"`, tal como lo registra SAT. |
|
|
367
410
|
| `get_dte()` | `get_dte(auth_number, fmt="JSON")` | Recupera el DTE (`"JSON"`, `"XML"`, `"HTML"`, `"PDF"`). |
|
|
368
411
|
| `get_dte_info()` | `get_dte_info(auth_number)` | Metadatos del DTE. |
|
|
369
412
|
|
|
370
413
|
### Parámetros comunes
|
|
371
414
|
|
|
372
|
-
- **`buyer`**: `"CF"` (consumidor final), un NIT string (`"12345678"` — se consulta el nombre), un dict CUI (`{"type":"CUI","taxid"
|
|
415
|
+
- **`buyer`**: `"CF"` (consumidor final), un NIT string (`"12345678"` — se consulta el nombre), un CUI string de 13 dígitos (`"1234567890123"` — se consulta el nombre), un dict CUI (`{"type":"CUI","taxid":...}` — se consulta el nombre; con `"name"` explícito no se consulta) o un dict NIT explícito (`{"taxid","name","address","city","district","state","country","email"}`).
|
|
373
416
|
- **`items`**: lista de dicts con `description` (str, req), `price` (float/Decimal, req), `qty` (1), `type` (`"Servicio"`/`"Bien"`), `unit_of_measure` (`"UNI"`), `discount` (opcional).
|
|
374
417
|
- **`origin`** (NCRE/NDEB): `{"auth_number": ..., "date": "YYYY-MM-DD", "series": ..., "number": ...}`.
|
|
375
418
|
|
|
@@ -410,7 +453,6 @@ python -m pytest tests/ -v
|
|
|
410
453
|
| `DIGIFACT_USERNAME` | Usuario (p. ej. `FELUSER`) |
|
|
411
454
|
| `DIGIFACT_PASSWORD` | Contraseña de la cuenta |
|
|
412
455
|
| `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 |
|
|
414
456
|
|
|
415
457
|
## Manejo de errores
|
|
416
458
|
|
|
@@ -33,7 +33,6 @@ Ordenados de más usados a menos usados.
|
|
|
33
33
|
| `tipo_frase` | `str \| None` | `None` | Sobreescritura global de `TipoFrase` (legacy). **Mutuamente exclusivo con `frases`**. Ver [frases](#configuración-de-frases-tipofrase--codigoescenario). |
|
|
34
34
|
| `escenario` | `str \| None` | `None` | Sobreescritura global de `CodigoEscenario` (legacy). **Mutuamente exclusivo con `frases`**. |
|
|
35
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
36
|
| `petroleo_rates` | `dict[str, float] \| None` | `None` | Mapa código PETROLEO → tarifa por unidad. Usado sólo por `fuel_invoice()` (gasolineras). |
|
|
38
37
|
| `timeout` | `int` | `120` | Timeout HTTP en segundos. |
|
|
39
38
|
| `session` | `requests.Session \| None` | `None` | Sesión HTTP personalizada (útil para tests). |
|
|
@@ -67,7 +66,19 @@ result = client.invoice(
|
|
|
67
66
|
]
|
|
68
67
|
)
|
|
69
68
|
|
|
70
|
-
# FACT a receptor con CUI
|
|
69
|
+
# FACT a receptor con CUI (DPI) — el nombre se consulta en SAT automáticamente
|
|
70
|
+
result = client.invoice(
|
|
71
|
+
buyer={"taxid": "1234567890123", "type": "CUI"},
|
|
72
|
+
items=[{"description": "Producto", "qty": 2, "price": 50.00}]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Equivalente: un string de 13 dígitos se resuelve como CUI
|
|
76
|
+
result = client.invoice(
|
|
77
|
+
buyer="1234567890123",
|
|
78
|
+
items=[{"description": "Producto", "qty": 2, "price": 50.00}]
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Con nombre explícito no se consulta SAT
|
|
71
82
|
result = client.invoice(
|
|
72
83
|
buyer={"taxid": "3730617490101", "type": "CUI", "name": "Juan Pérez"},
|
|
73
84
|
items=[{"description": "Producto", "qty": 2, "price": 50.00}]
|
|
@@ -136,6 +147,10 @@ result = client.credit_note_total(
|
|
|
136
147
|
info = client.lookup_nit("12345678")
|
|
137
148
|
print(info["name"])
|
|
138
149
|
|
|
150
|
+
# Consulta de CUI (DPI) — SAT devuelve el nombre como "APELLIDOS, NOMBRES"
|
|
151
|
+
info = client.lookup_cui("1234567890123")
|
|
152
|
+
print(info["name"]) # "PEREZ LOPEZ, JUAN CARLOS"
|
|
153
|
+
|
|
139
154
|
# Obtener DTE
|
|
140
155
|
doc = client.get_dte("XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX")
|
|
141
156
|
```
|
|
@@ -236,27 +251,54 @@ result = client.fuel_invoice(
|
|
|
236
251
|
| `petroleo_amount` | `float\|Decimal` | — | Impuesto PETROLEO por unidad; omitir para ítems sólo-IVA |
|
|
237
252
|
| `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. |
|
|
238
253
|
|
|
239
|
-
###
|
|
254
|
+
### Exención temporal (Decreto 22-2026)
|
|
240
255
|
|
|
241
|
-
|
|
256
|
+
Del 1 de octubre al 31 de diciembre de 2026, y después hasta agotar el inventario comprado exento, la
|
|
257
|
+
gasolina superior, la regular y el diésel se facturan sin IVA ni IDP. Se activa por ítem con el código de
|
|
258
|
+
unidad gravable exento: `"18"` superior, `"21"` regular con etanol, `"20"` diésel y `"19"` regular sin
|
|
259
|
+
etanol (sólo importadores). `price` es el precio de bomba, ya sin impuestos, y `qty` va en galones. El SDK
|
|
260
|
+
envía ambos impuestos en cero y agrega las frases `9/23` y `4/38`; no hace falta `petroleo_amount` ni `petroleo_rates`.
|
|
242
261
|
|
|
243
262
|
```python
|
|
244
|
-
|
|
263
|
+
from digifact_sdk import fuel_exemption
|
|
264
|
+
|
|
265
|
+
items = [
|
|
266
|
+
{"description": "GASOLINA SUPER", "qty": 10, "price": 30.00, "petroleo_code": "18", "unit_of_measure": "GAL"},
|
|
267
|
+
{"description": "GASOLINA REGULAR", "qty": 5, "price": 28.00, "petroleo_code": "21", "unit_of_measure": "GAL"},
|
|
268
|
+
{"description": "DIESEL", "qty": 20, "price": 27.00, "petroleo_code": "20", "unit_of_measure": "GAL"},
|
|
269
|
+
]
|
|
245
270
|
result = client.fuel_invoice("CF", items)
|
|
246
271
|
|
|
247
|
-
#
|
|
248
|
-
|
|
272
|
+
# Montos y leyendas obligatorias para un ticket propio (el PDF de Digifact ya las imprime)
|
|
273
|
+
exencion = fuel_exemption(items)
|
|
274
|
+
print(exencion.idp, exencion.iva) # 93.70 117.60
|
|
275
|
+
print(exencion.leyendas)
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Los detalles y las advertencias están en el [README principal](../README.md#exención-temporal-de-combustibles-decreto-22-2026).
|
|
249
279
|
|
|
250
|
-
|
|
251
|
-
client = DigifactClient(..., auto_fuel_subsidy_frases=False)
|
|
280
|
+
### Subsidio combustible
|
|
252
281
|
|
|
253
|
-
|
|
254
|
-
|
|
282
|
+
El subsidio a la gasolina y al diésel **finalizó el jueves 2 de julio de 2026 a las 24:00**, antes de lo
|
|
283
|
+
previsto: el presupuesto de Q2 mil millones (Decreto 11-2026, reglamentado por el Acuerdo Gubernativo
|
|
284
|
+
64-2026) se agotó por la demanda. Por eso el SDK **nunca** envía las frases `TipoFrase=9, Escenario=18` ni
|
|
285
|
+
`TipoFrase=9, Escenario=19` por su cuenta — no hay fecha de corte que valga para todos.
|
|
255
286
|
|
|
256
|
-
|
|
257
|
-
|
|
287
|
+
Las estaciones con inventario adquirido bajo el subsidio deben mantener el precio rebajado hasta agotar ese
|
|
288
|
+
producto, sujeto a verificación. Mientras te quede ese inventario, manda las frases explícitamente con
|
|
289
|
+
`frases`; al agotarlo, deja de mandarlas:
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
result = client.fuel_invoice("CF", items, frases=[
|
|
293
|
+
{"tipo_frase": "1", "escenario": "1"}, # frase base
|
|
294
|
+
{"tipo_frase": "9", "escenario": "18"},
|
|
295
|
+
{"tipo_frase": "9", "escenario": "19"},
|
|
296
|
+
])
|
|
258
297
|
```
|
|
259
298
|
|
|
299
|
+
> **Nota:** la leyenda del subsidio en la representación gráfica la genera Digifact a partir de las frases
|
|
300
|
+
> del XML certificado, no este SDK. Al dejar de mandar las frases, deja de imprimirse sola.
|
|
301
|
+
|
|
260
302
|
> **Nota:** `frases` y `tipo_frase`/`escenario` son **mutuamente exclusivos** — pasarlos juntos lanza `DigifactValidationError`.
|
|
261
303
|
|
|
262
304
|
## Configuración de frases (TipoFrase / CodigoEscenario)
|
|
@@ -275,7 +317,7 @@ configurar nada en el caso común.
|
|
|
275
317
|
| FPEQ | PEQ | `2` | `1` | Pequeño contribuyente |
|
|
276
318
|
| RDON | cualquiera | `4` | `4` | Donaciones |
|
|
277
319
|
| RECI | cualquiera | `4` | `5` | Recibos (universidades) |
|
|
278
|
-
| NABN | cualquiera | `
|
|
320
|
+
| NABN | cualquiera | `9` | `17` | Abonos |
|
|
279
321
|
| FACT / FCAM / NCRE / NDEB | **GEN** | `1` | `1` | Por defecto: ISR **régimen sobre utilidades trimestrales** |
|
|
280
322
|
| FACT / FCAM / NCRE / NDEB | PEQ | `2` | `1` | |
|
|
281
323
|
| FACT / FCAM / NCRE / NDEB | EXE | `4` | `1` | Exento |
|
|
@@ -285,7 +327,7 @@ configurar nada en el caso común.
|
|
|
285
327
|
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
328
|
|
|
287
329
|
```python
|
|
288
|
-
# Lista explícita de frases (
|
|
330
|
+
# Lista explícita de frases (reemplaza a tipo_frase/escenario)
|
|
289
331
|
client.fuel_invoice("CF", items, frases=[
|
|
290
332
|
{"tipo_frase": "1", "escenario": "1"},
|
|
291
333
|
{"tipo_frase": "9", "escenario": "18"},
|
|
@@ -334,18 +376,19 @@ Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `
|
|
|
334
376
|
|--------|-------|-------------|
|
|
335
377
|
| `invoice()` | `invoice(buyer, items, *, doc_type="FACT", payment_terms=None, amount_str="", observaciones="-", tipo_personeria=None, tipo_frase=None, escenario=None)` | Emite FACT, FCAM, FESP, FPEQ, NABN, RDON o RECI según `doc_type`. |
|
|
336
378
|
| `cca_invoice()` | `cca_invoice(buyer, items, cobros, *, tipo_frase=None, escenario=None)` | FACT con complemento CCA. |
|
|
337
|
-
| `fuel_invoice()` | `fuel_invoice(buyer, items, *, tipo_frase=None, escenario=None, frases=None
|
|
379
|
+
| `fuel_invoice()` | `fuel_invoice(buyer, items, *, tipo_frase=None, escenario=None, frases=None)` | FACT con complemento combustible (IVA + PETROLEO). |
|
|
338
380
|
| `credit_note()` | `credit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de crédito (NCRE). |
|
|
339
381
|
| `debit_note()` | `debit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de débito (NDEB). |
|
|
340
382
|
| `credit_note_total()` | `credit_note_total(auth_number, issue_datetime, reason="...", reference="")` | Nota de crédito total. Devuelve `dict`. |
|
|
341
383
|
| `cancel()` | `cancel(auth_number, receiver_id, issue_datetime, reason="Anulación")` | Anula un DTE. Devuelve `dict`. |
|
|
342
384
|
| `lookup_nit()` | `lookup_nit(nit)` | Consulta SAT. Devuelve `{"nit","name","address","city","district","state"}`. |
|
|
385
|
+
| `lookup_cui()` | `lookup_cui(cui)` | Consulta SAT por CUI (DPI). Devuelve `{"cui","name","status"}`. El nombre viene como `"APELLIDOS, NOMBRES"`, tal como lo registra SAT. |
|
|
343
386
|
| `get_dte()` | `get_dte(auth_number, fmt="JSON")` | Recupera el DTE (`"JSON"`, `"XML"`, `"HTML"`, `"PDF"`). |
|
|
344
387
|
| `get_dte_info()` | `get_dte_info(auth_number)` | Metadatos del DTE. |
|
|
345
388
|
|
|
346
389
|
### Parámetros comunes
|
|
347
390
|
|
|
348
|
-
- **`buyer`**: `"CF"` (consumidor final), un NIT string (`"12345678"` — se consulta el nombre), un dict CUI (`{"type":"CUI","taxid"
|
|
391
|
+
- **`buyer`**: `"CF"` (consumidor final), un NIT string (`"12345678"` — se consulta el nombre), un CUI string de 13 dígitos (`"1234567890123"` — se consulta el nombre), un dict CUI (`{"type":"CUI","taxid":...}` — se consulta el nombre; con `"name"` explícito no se consulta) o un dict NIT explícito (`{"taxid","name","address","city","district","state","country","email"}`).
|
|
349
392
|
- **`items`**: lista de dicts con `description` (str, req), `price` (float/Decimal, req), `qty` (1), `type` (`"Servicio"`/`"Bien"`), `unit_of_measure` (`"UNI"`), `discount` (opcional).
|
|
350
393
|
- **`origin`** (NCRE/NDEB): `{"auth_number": ..., "date": "YYYY-MM-DD", "series": ..., "number": ...}`.
|
|
351
394
|
|
|
@@ -386,7 +429,6 @@ python -m pytest tests/ -v
|
|
|
386
429
|
| `DIGIFACT_USERNAME` | Usuario (p. ej. `FELUSER`) |
|
|
387
430
|
| `DIGIFACT_PASSWORD` | Contraseña de la cuenta |
|
|
388
431
|
| `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 |
|
|
390
432
|
|
|
391
433
|
## Manejo de errores
|
|
392
434
|
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
"""Digifact FEL Guatemala SDK — Python package."""
|
|
2
|
+
from .builder import (
|
|
3
|
+
FUEL_EXEMPT_DIESEL,
|
|
4
|
+
FUEL_EXEMPT_REGULAR_ETHANOL,
|
|
5
|
+
FUEL_EXEMPT_REGULAR_IMPORTER,
|
|
6
|
+
FUEL_EXEMPT_SUPER,
|
|
7
|
+
FuelExemption,
|
|
8
|
+
fuel_exemption,
|
|
9
|
+
)
|
|
2
10
|
from .client import DigifactClient, DteResult
|
|
3
11
|
from .exceptions import (
|
|
4
12
|
DigifactApiError,
|
|
@@ -8,7 +16,7 @@ from .exceptions import (
|
|
|
8
16
|
DigifactValidationError,
|
|
9
17
|
)
|
|
10
18
|
|
|
11
|
-
__version__ = "
|
|
19
|
+
__version__ = "3.1.0"
|
|
12
20
|
__all__ = [
|
|
13
21
|
"DigifactClient",
|
|
14
22
|
"DteResult",
|
|
@@ -17,4 +25,10 @@ __all__ = [
|
|
|
17
25
|
"DigifactApiError",
|
|
18
26
|
"DigifactValidationError",
|
|
19
27
|
"DigifactNitNotFoundError",
|
|
28
|
+
"FuelExemption",
|
|
29
|
+
"fuel_exemption",
|
|
30
|
+
"FUEL_EXEMPT_SUPER",
|
|
31
|
+
"FUEL_EXEMPT_REGULAR_IMPORTER",
|
|
32
|
+
"FUEL_EXEMPT_DIESEL",
|
|
33
|
+
"FUEL_EXEMPT_REGULAR_ETHANOL",
|
|
20
34
|
]
|
|
@@ -7,8 +7,8 @@ Key intentional API typos preserved from the SAT/Digifact spec:
|
|
|
7
7
|
"""
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
from decimal import Decimal
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from decimal import ROUND_HALF_UP, Decimal
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
14
|
from .tax import LineCalc, FuelLineCalc, InvoiceTotals, fmt, gt_now
|
|
@@ -28,9 +28,68 @@ def _adenda_code(doc_type: str) -> str:
|
|
|
28
28
|
return ADENDA_CODE_ALT if doc_type in _ALT_ADENDA_TYPES else ADENDA_CODE_STD
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
# ──
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
# ── Exención temporal de combustibles (Decreto 22-2026) ──────────────────────
|
|
32
|
+
FUEL_EXEMPT_SUPER = "18"
|
|
33
|
+
FUEL_EXEMPT_REGULAR_IMPORTER = "19"
|
|
34
|
+
FUEL_EXEMPT_DIESEL = "20"
|
|
35
|
+
FUEL_EXEMPT_REGULAR_ETHANOL = "21"
|
|
36
|
+
|
|
37
|
+
_FUEL_EXEMPT_IDP_RATES = {
|
|
38
|
+
FUEL_EXEMPT_SUPER: Decimal("4.70"),
|
|
39
|
+
FUEL_EXEMPT_REGULAR_IMPORTER: Decimal("4.60"),
|
|
40
|
+
FUEL_EXEMPT_DIESEL: Decimal("1.30"),
|
|
41
|
+
FUEL_EXEMPT_REGULAR_ETHANOL: Decimal("4.14"),
|
|
42
|
+
}
|
|
43
|
+
_FUEL_EXEMPTION_FRASES = [
|
|
44
|
+
{"tipo_frase": "9", "escenario": "23"},
|
|
45
|
+
{"tipo_frase": "4", "escenario": "38"},
|
|
46
|
+
]
|
|
47
|
+
_IVA_EXEMPT_CODE = "2"
|
|
48
|
+
_IVA_RATE = Decimal("0.12")
|
|
49
|
+
_CENTS = Decimal("0.01")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_exempt_fuel_item(item: dict) -> bool:
|
|
53
|
+
return str(item.get("petroleo_code", "")) in _FUEL_EXEMPT_IDP_RATES
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class FuelExemption:
|
|
58
|
+
"""Taxes a fuel sale would have paid without the Decreto 22-2026 exemption."""
|
|
59
|
+
|
|
60
|
+
iva: Decimal
|
|
61
|
+
idp: Decimal
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def leyendas(self) -> list[str]:
|
|
65
|
+
if not self.iva and not self.idp:
|
|
66
|
+
return []
|
|
67
|
+
return [
|
|
68
|
+
f"Monto de exención temporal de IDP aplicada: Q {fmt(self.idp, 2)}, según Decreto Número 22-2026",
|
|
69
|
+
f"Monto de exención temporal de IVA aplicada: Q {fmt(self.iva, 2)}, según Decreto Número 22-2026",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def fuel_exemption(items: list[dict]) -> FuelExemption:
|
|
74
|
+
"""Return the exempted IVA and IDP for the exempt fuel items in ``items``.
|
|
75
|
+
|
|
76
|
+
``qty`` must be in gallons: the IDP rates are per gallon.
|
|
77
|
+
"""
|
|
78
|
+
exempt_total = Decimal("0")
|
|
79
|
+
idp = Decimal("0")
|
|
80
|
+
for item in items:
|
|
81
|
+
if not is_exempt_fuel_item(item):
|
|
82
|
+
continue
|
|
83
|
+
qty = Decimal(str(item.get("qty", 1)))
|
|
84
|
+
exempt_total += qty * Decimal(str(item["price"]))
|
|
85
|
+
idp += qty * _FUEL_EXEMPT_IDP_RATES[str(item["petroleo_code"])]
|
|
86
|
+
return FuelExemption(
|
|
87
|
+
iva=(exempt_total * _IVA_RATE).quantize(_CENTS, rounding=ROUND_HALF_UP),
|
|
88
|
+
idp=idp.quantize(_CENTS, rounding=ROUND_HALF_UP),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── Frases ───────────────────────────────────────────────────────────────────
|
|
34
93
|
|
|
35
94
|
|
|
36
95
|
def _uniq_frases(frases: list[dict]) -> list[dict]:
|
|
@@ -58,36 +117,29 @@ def _build_additionl_info_from_frases(frases: list[dict]) -> list[dict]:
|
|
|
58
117
|
return out
|
|
59
118
|
|
|
60
119
|
|
|
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
120
|
def resolve_fuel_frases(
|
|
70
121
|
frases: list[dict] | None,
|
|
71
122
|
tipo_frase: str | None,
|
|
72
123
|
escenario: str | None,
|
|
73
|
-
|
|
74
|
-
|
|
124
|
+
*,
|
|
125
|
+
exempt: bool = False,
|
|
75
126
|
) -> list[dict]:
|
|
76
127
|
"""Return the final deduplicated frases list for a fuel invoice.
|
|
77
128
|
|
|
78
129
|
Called only after mutual-exclusivity validation (frases vs tipo_frase/escenario).
|
|
130
|
+
``exempt`` appends the Decreto 22-2026 frases, mandatory with exempt fuel codes.
|
|
79
131
|
"""
|
|
80
132
|
if frases is not None:
|
|
81
133
|
if not frases:
|
|
82
134
|
from .exceptions import DigifactValidationError
|
|
83
135
|
raise DigifactValidationError("frases must contain at least one TipoFrase/Escenario pair")
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
result.
|
|
136
|
+
result = list(frases)
|
|
137
|
+
else:
|
|
138
|
+
result = []
|
|
139
|
+
if tipo_frase is not None and escenario is not None:
|
|
140
|
+
result.append({"tipo_frase": tipo_frase, "escenario": escenario})
|
|
141
|
+
if exempt:
|
|
142
|
+
result.extend(_FUEL_EXEMPTION_FRASES)
|
|
91
143
|
return _uniq_frases(result)
|
|
92
144
|
|
|
93
145
|
|
|
@@ -107,7 +159,7 @@ def default_frase(doc_type: str, afiliacion: str = "GEN") -> tuple[str, str] | N
|
|
|
107
159
|
if doc_type == "RECI":
|
|
108
160
|
return ("4", "5")
|
|
109
161
|
if doc_type == "NABN":
|
|
110
|
-
return ("
|
|
162
|
+
return ("9", "17")
|
|
111
163
|
# FACT, NCRE, NDEB, FCAM, FACT+CCA, FACT+combustible
|
|
112
164
|
if afi == "PEQ":
|
|
113
165
|
return ("2", "1")
|
|
@@ -683,6 +735,8 @@ def build_nabn(
|
|
|
683
735
|
items: list[dict],
|
|
684
736
|
*,
|
|
685
737
|
afiliacion: str = "GEN",
|
|
738
|
+
tipo_frase: str | None = "9",
|
|
739
|
+
escenario: str | None = "17",
|
|
686
740
|
amount_str: str = "",
|
|
687
741
|
observaciones: str = "-",
|
|
688
742
|
seller_email: str | None = None,
|
|
@@ -692,14 +746,13 @@ def build_nabn(
|
|
|
692
746
|
issue_dt, _, _ = gt_now()
|
|
693
747
|
line_items, totals = _build_items(items, taxable=False)
|
|
694
748
|
|
|
695
|
-
# NABN: uses TipoFrase=1, Escenario=1 in seller (matches smoke runner)
|
|
696
749
|
seller = _build_seller(
|
|
697
750
|
taxid,
|
|
698
751
|
seller_name,
|
|
699
752
|
seller_address,
|
|
700
753
|
afiliacion=afiliacion,
|
|
701
|
-
tipo_frase=
|
|
702
|
-
escenario=
|
|
754
|
+
tipo_frase=tipo_frase,
|
|
755
|
+
escenario=escenario,
|
|
703
756
|
frases=frases,
|
|
704
757
|
email=seller_email,
|
|
705
758
|
)
|
|
@@ -1073,8 +1126,9 @@ def _build_fuel_items(
|
|
|
1073
1126
|
"""Build Items list for a combustible (fuel) invoice.
|
|
1074
1127
|
|
|
1075
1128
|
Items with 'petroleo_amount' are treated as fuel items and receive two
|
|
1076
|
-
Tax entries (IVA + PETROLEO). Items
|
|
1077
|
-
|
|
1129
|
+
Tax entries (IVA + PETROLEO). Items whose 'petroleo_code' is an exempt
|
|
1130
|
+
code (18-21) carry both taxes at zero. Items without 'petroleo_amount' are
|
|
1131
|
+
treated as regular IVA-only items. All types may coexist in the same invoice.
|
|
1078
1132
|
|
|
1079
1133
|
Returns (line_items, grand_total_str, total_iva_str, total_petroleo_str).
|
|
1080
1134
|
"""
|
|
@@ -1099,7 +1153,31 @@ def _build_fuel_items(
|
|
|
1099
1153
|
"Discounts": None,
|
|
1100
1154
|
}
|
|
1101
1155
|
|
|
1102
|
-
if
|
|
1156
|
+
if is_exempt_fuel_item(item):
|
|
1157
|
+
lc = LineCalc(qty, price, taxable=False)
|
|
1158
|
+
|
|
1159
|
+
built["Qty"] = lc.f_qty()
|
|
1160
|
+
built["Price"] = lc.f_price()
|
|
1161
|
+
built["Taxes"] = {
|
|
1162
|
+
"Tax": [
|
|
1163
|
+
{
|
|
1164
|
+
"Code": _IVA_EXEMPT_CODE,
|
|
1165
|
+
"Description": "IVA",
|
|
1166
|
+
"TaxableAmount": lc.f_line_total(),
|
|
1167
|
+
"Amount": lc.f_iva(),
|
|
1168
|
+
},
|
|
1169
|
+
{
|
|
1170
|
+
"Code": str(item["petroleo_code"]),
|
|
1171
|
+
"Description": "PETROLEO",
|
|
1172
|
+
"TaxableAmount": lc.f_qty(),
|
|
1173
|
+
"Amount": fmt(0),
|
|
1174
|
+
},
|
|
1175
|
+
]
|
|
1176
|
+
}
|
|
1177
|
+
built["Totals"] = {"TotalItem": lc.f_line_total()}
|
|
1178
|
+
|
|
1179
|
+
grand_total += lc.line_total
|
|
1180
|
+
elif "petroleo_amount" in item:
|
|
1103
1181
|
petrol_per_unit = Decimal(str(item["petroleo_amount"]))
|
|
1104
1182
|
petrol_code = str(item.get("petroleo_code", "1"))
|
|
1105
1183
|
|
|
@@ -1183,7 +1261,6 @@ def build_fact_combustible(
|
|
|
1183
1261
|
tipo_frase: str | None = None,
|
|
1184
1262
|
escenario: str | None = None,
|
|
1185
1263
|
frases: list[dict] | None = None,
|
|
1186
|
-
auto_fuel_subsidy_frases: bool = True,
|
|
1187
1264
|
seller_email: str | None = None,
|
|
1188
1265
|
) -> dict:
|
|
1189
1266
|
"""Build a FACT payload for combustible (fuel) invoices.
|
|
@@ -1193,6 +1270,11 @@ def build_fact_combustible(
|
|
|
1193
1270
|
"4"=DIESEL; default "1"). Items without ``petroleo_amount`` are treated as
|
|
1194
1271
|
regular IVA-only items and may coexist in the same invoice.
|
|
1195
1272
|
|
|
1273
|
+
Fuel sold under the Decreto 22-2026 exemption uses the exempt codes instead
|
|
1274
|
+
("18"=SUPER, "21"=REGULAR with ethanol, "20"=DIESEL, "19"=REGULAR for
|
|
1275
|
+
importers): ``price`` is the pump price without IVA or IDP, no
|
|
1276
|
+
``petroleo_amount`` is needed, and frases 9/23 and 4/38 are added.
|
|
1277
|
+
|
|
1196
1278
|
``frases`` and ``tipo_frase``/``escenario`` are mutually exclusive.
|
|
1197
1279
|
|
|
1198
1280
|
Example fuel item::
|
|
@@ -1218,9 +1300,10 @@ def build_fact_combustible(
|
|
|
1218
1300
|
tf = tipo_frase if tipo_frase is not None else def_tf
|
|
1219
1301
|
es = escenario if escenario is not None else def_es
|
|
1220
1302
|
|
|
1221
|
-
|
|
1303
|
+
has_exempt_fuel = any(is_exempt_fuel_item(item) for item in items)
|
|
1304
|
+
resolved_frases = resolve_fuel_frases(frases, tf, es, exempt=has_exempt_fuel)
|
|
1222
1305
|
|
|
1223
|
-
# All frases
|
|
1306
|
+
# All frases go into Seller.AdditionlInfo as repeated pairs —
|
|
1224
1307
|
# that is the field the API reads to generate <dte:Frases> in the certified XML.
|
|
1225
1308
|
seller = _build_seller(
|
|
1226
1309
|
taxid,
|
|
@@ -1232,7 +1315,7 @@ def build_fact_combustible(
|
|
|
1232
1315
|
)
|
|
1233
1316
|
|
|
1234
1317
|
total_tax: list[dict] = [{"Description": "IVA", "Amount": total_iva}]
|
|
1235
|
-
if float(total_petroleo) > 0.0:
|
|
1318
|
+
if float(total_petroleo) > 0.0 or has_exempt_fuel:
|
|
1236
1319
|
total_tax.append({"Description": "PETROLEO", "Amount": total_petroleo})
|
|
1237
1320
|
|
|
1238
1321
|
combustible_payload: dict[str, Any] = {
|