digifact-sdk 3.0.0__tar.gz → 3.2.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-3.0.0 → digifact_sdk-3.2.0}/PKG-INFO +67 -4
- {digifact_sdk-3.0.0 → digifact_sdk-3.2.0}/README.md +66 -3
- digifact_sdk-3.2.0/digifact_sdk/__init__.py +36 -0
- {digifact_sdk-3.0.0 → digifact_sdk-3.2.0}/digifact_sdk/builder.py +140 -14
- {digifact_sdk-3.0.0 → digifact_sdk-3.2.0}/digifact_sdk/client.py +219 -66
- {digifact_sdk-3.0.0 → digifact_sdk-3.2.0}/digifact_sdk/exceptions.py +18 -1
- {digifact_sdk-3.0.0 → digifact_sdk-3.2.0}/digifact_sdk.egg-info/PKG-INFO +67 -4
- {digifact_sdk-3.0.0 → digifact_sdk-3.2.0}/digifact_sdk.egg-info/SOURCES.txt +2 -1
- {digifact_sdk-3.0.0 → digifact_sdk-3.2.0}/pyproject.toml +1 -1
- {digifact_sdk-3.0.0 → digifact_sdk-3.2.0}/tests/test_integration.py +123 -27
- digifact_sdk-3.2.0/tests/test_resilience.py +241 -0
- digifact_sdk-3.0.0/digifact_sdk/__init__.py +0 -20
- {digifact_sdk-3.0.0 → digifact_sdk-3.2.0}/digifact_sdk/tax.py +0 -0
- {digifact_sdk-3.0.0 → digifact_sdk-3.2.0}/digifact_sdk.egg-info/dependency_links.txt +0 -0
- {digifact_sdk-3.0.0 → digifact_sdk-3.2.0}/digifact_sdk.egg-info/requires.txt +0 -0
- {digifact_sdk-3.0.0 → digifact_sdk-3.2.0}/digifact_sdk.egg-info/top_level.txt +0 -0
- {digifact_sdk-3.0.0 → digifact_sdk-3.2.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: digifact-sdk
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.2.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
|
|
@@ -275,6 +275,32 @@ result = client.fuel_invoice(
|
|
|
275
275
|
| `petroleo_amount` | `float\|Decimal` | — | Impuesto PETROLEO por unidad; omitir para ítems sólo-IVA |
|
|
276
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. |
|
|
277
277
|
|
|
278
|
+
### Exención temporal (Decreto 22-2026)
|
|
279
|
+
|
|
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`.
|
|
285
|
+
|
|
286
|
+
```python
|
|
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
|
+
]
|
|
294
|
+
result = client.fuel_invoice("CF", items)
|
|
295
|
+
|
|
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).
|
|
303
|
+
|
|
278
304
|
### Subsidio combustible
|
|
279
305
|
|
|
280
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
|
|
@@ -315,7 +341,7 @@ configurar nada en el caso común.
|
|
|
315
341
|
| FPEQ | PEQ | `2` | `1` | Pequeño contribuyente |
|
|
316
342
|
| RDON | cualquiera | `4` | `4` | Donaciones |
|
|
317
343
|
| RECI | cualquiera | `4` | `5` | Recibos (universidades) |
|
|
318
|
-
| NABN | cualquiera | `
|
|
344
|
+
| NABN | cualquiera | `9` | `17` | Abonos |
|
|
319
345
|
| FACT / FCAM / NCRE / NDEB | **GEN** | `1` | `1` | Por defecto: ISR **régimen sobre utilidades trimestrales** |
|
|
320
346
|
| FACT / FCAM / NCRE / NDEB | PEQ | `2` | `1` | |
|
|
321
347
|
| FACT / FCAM / NCRE / NDEB | EXE | `4` | `1` | Exento |
|
|
@@ -368,7 +394,9 @@ del RTU en el portal de SAT.
|
|
|
368
394
|
|
|
369
395
|
## Referencia de métodos
|
|
370
396
|
|
|
371
|
-
Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `series`, `number`, `issue_datetime`, `raw`.
|
|
397
|
+
Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `series`, `number`, `issue_datetime`, `enrolled_timestamp`, `additional_info` (acuse de la SAT) y `raw`.
|
|
398
|
+
|
|
399
|
+
`invoice()`, `cca_invoice()`, `fuel_invoice()`, `credit_note()` y `debit_note()` aceptan además `internal_reference` y `validate_internal_reference` (ver [Emisión sin duplicados](#emisión-sin-duplicados-referencia-interna)).
|
|
372
400
|
|
|
373
401
|
| Método | Firma | Descripción |
|
|
374
402
|
|--------|-------|-------------|
|
|
@@ -383,6 +411,7 @@ Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `
|
|
|
383
411
|
| `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. |
|
|
384
412
|
| `get_dte()` | `get_dte(auth_number, fmt="JSON")` | Recupera el DTE (`"JSON"`, `"XML"`, `"HTML"`, `"PDF"`). |
|
|
385
413
|
| `get_dte_info()` | `get_dte_info(auth_number)` | Metadatos del DTE. |
|
|
414
|
+
| `find_by_internal_reference()` | `find_by_internal_reference(reference, issued_on)` | DTE certificados con esa referencia interna ese día (`SHARED_GETDTEINFO_BY_INTERNALID`). Devuelve `list[dict]`, vacía si no hay. |
|
|
386
415
|
|
|
387
416
|
### Parámetros comunes
|
|
388
417
|
|
|
@@ -390,6 +419,39 @@ Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `
|
|
|
390
419
|
- **`items`**: lista de dicts con `description` (str, req), `price` (float/Decimal, req), `qty` (1), `type` (`"Servicio"`/`"Bien"`), `unit_of_measure` (`"UNI"`), `discount` (opcional).
|
|
391
420
|
- **`origin`** (NCRE/NDEB): `{"auth_number": ..., "date": "YYYY-MM-DD", "series": ..., "number": ...}`.
|
|
392
421
|
|
|
422
|
+
## Emisión sin duplicados (referencia interna)
|
|
423
|
+
|
|
424
|
+
Digifact guarda una referencia interna por DTE (el `Code` de la ADENDA). Con `validate_internal_reference=True`
|
|
425
|
+
rechaza un segundo DTE con la misma referencia, así que un envío que se quedó sin respuesta se puede repetir sin
|
|
426
|
+
emitir dos veces:
|
|
427
|
+
|
|
428
|
+
```python
|
|
429
|
+
import uuid
|
|
430
|
+
from digifact_sdk import DigifactDuplicateReferenceError
|
|
431
|
+
|
|
432
|
+
referencia = str(uuid.uuid4()) # un GUID; Digifact también acepta referencias más cortas
|
|
433
|
+
try:
|
|
434
|
+
result = client.invoice("CF", items, internal_reference=referencia, validate_internal_reference=True)
|
|
435
|
+
except DigifactDuplicateReferenceError:
|
|
436
|
+
# ya se había enviado: el DTE pudo quedar certificado
|
|
437
|
+
filas = client.find_by_internal_reference(referencia, issued_on="2026-11-03")
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
* Sin `internal_reference` el payload no cambia: se envía la referencia fija de siempre con `NO_VALIDAR`.
|
|
441
|
+
* NCRE, NDEB, FCAM, FESP y CCA reciben una ADENDA mínima con la referencia, junto a su complemento.
|
|
442
|
+
* `SHARED_GETDTEINFO_BY_INTERNALID` y el error `9022` no están en la documentación de Digifact para
|
|
443
|
+
Guatemala, pero se verificaron en el ambiente de pruebas el 3 de noviembre de 2026: el duplicado llega con
|
|
444
|
+
`code` 3000 y `[9022]` en la descripción, y la consulta encuentra el DTE sin prefijo en `DATA2`.
|
|
445
|
+
|
|
446
|
+
## Token y reintentos
|
|
447
|
+
|
|
448
|
+
* Si Digifact responde `401` (token vencido) y el cliente tiene `password`, el SDK inicia sesión otra vez y repite la
|
|
449
|
+
petición una sola vez. Con solo `token`, lanza `DigifactAuthError`.
|
|
450
|
+
* `client.token` devuelve el token vigente, para guardarlo en un caché compartido y pasarlo como `token=` a otros
|
|
451
|
+
procesos.
|
|
452
|
+
* Los errores HTTP traen `exc.code` (el estado HTTP) y `exc.retryable`: `True` para 5xx, `False` para 4xx (`400`,
|
|
453
|
+
`406` documento inválido, `415` tipo de contenido).
|
|
454
|
+
|
|
393
455
|
## Establecimiento (sucursal)
|
|
394
456
|
|
|
395
457
|
Cada NIT puede tener varios establecimientos registrados en el RTU. Configúralos al crear el cliente:
|
|
@@ -434,7 +496,8 @@ python -m pytest tests/ -v
|
|
|
434
496
|
from digifact_sdk import (
|
|
435
497
|
DigifactError, # base
|
|
436
498
|
DigifactAuthError, # fallo de autenticación
|
|
437
|
-
DigifactApiError, # error HTTP / de API
|
|
499
|
+
DigifactApiError, # error HTTP / de API (.retryable)
|
|
500
|
+
DigifactDuplicateReferenceError, # referencia interna ya enviada (9022)
|
|
438
501
|
DigifactValidationError, # rechazo de SAT
|
|
439
502
|
DigifactNitNotFoundError, # NIT no encontrado
|
|
440
503
|
)
|
|
@@ -251,6 +251,32 @@ result = client.fuel_invoice(
|
|
|
251
251
|
| `petroleo_amount` | `float\|Decimal` | — | Impuesto PETROLEO por unidad; omitir para ítems sólo-IVA |
|
|
252
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. |
|
|
253
253
|
|
|
254
|
+
### Exención temporal (Decreto 22-2026)
|
|
255
|
+
|
|
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`.
|
|
261
|
+
|
|
262
|
+
```python
|
|
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
|
+
]
|
|
270
|
+
result = client.fuel_invoice("CF", items)
|
|
271
|
+
|
|
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).
|
|
279
|
+
|
|
254
280
|
### Subsidio combustible
|
|
255
281
|
|
|
256
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
|
|
@@ -291,7 +317,7 @@ configurar nada en el caso común.
|
|
|
291
317
|
| FPEQ | PEQ | `2` | `1` | Pequeño contribuyente |
|
|
292
318
|
| RDON | cualquiera | `4` | `4` | Donaciones |
|
|
293
319
|
| RECI | cualquiera | `4` | `5` | Recibos (universidades) |
|
|
294
|
-
| NABN | cualquiera | `
|
|
320
|
+
| NABN | cualquiera | `9` | `17` | Abonos |
|
|
295
321
|
| FACT / FCAM / NCRE / NDEB | **GEN** | `1` | `1` | Por defecto: ISR **régimen sobre utilidades trimestrales** |
|
|
296
322
|
| FACT / FCAM / NCRE / NDEB | PEQ | `2` | `1` | |
|
|
297
323
|
| FACT / FCAM / NCRE / NDEB | EXE | `4` | `1` | Exento |
|
|
@@ -344,7 +370,9 @@ del RTU en el portal de SAT.
|
|
|
344
370
|
|
|
345
371
|
## Referencia de métodos
|
|
346
372
|
|
|
347
|
-
Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `series`, `number`, `issue_datetime`, `raw`.
|
|
373
|
+
Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `series`, `number`, `issue_datetime`, `enrolled_timestamp`, `additional_info` (acuse de la SAT) y `raw`.
|
|
374
|
+
|
|
375
|
+
`invoice()`, `cca_invoice()`, `fuel_invoice()`, `credit_note()` y `debit_note()` aceptan además `internal_reference` y `validate_internal_reference` (ver [Emisión sin duplicados](#emisión-sin-duplicados-referencia-interna)).
|
|
348
376
|
|
|
349
377
|
| Método | Firma | Descripción |
|
|
350
378
|
|--------|-------|-------------|
|
|
@@ -359,6 +387,7 @@ Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `
|
|
|
359
387
|
| `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. |
|
|
360
388
|
| `get_dte()` | `get_dte(auth_number, fmt="JSON")` | Recupera el DTE (`"JSON"`, `"XML"`, `"HTML"`, `"PDF"`). |
|
|
361
389
|
| `get_dte_info()` | `get_dte_info(auth_number)` | Metadatos del DTE. |
|
|
390
|
+
| `find_by_internal_reference()` | `find_by_internal_reference(reference, issued_on)` | DTE certificados con esa referencia interna ese día (`SHARED_GETDTEINFO_BY_INTERNALID`). Devuelve `list[dict]`, vacía si no hay. |
|
|
362
391
|
|
|
363
392
|
### Parámetros comunes
|
|
364
393
|
|
|
@@ -366,6 +395,39 @@ Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `
|
|
|
366
395
|
- **`items`**: lista de dicts con `description` (str, req), `price` (float/Decimal, req), `qty` (1), `type` (`"Servicio"`/`"Bien"`), `unit_of_measure` (`"UNI"`), `discount` (opcional).
|
|
367
396
|
- **`origin`** (NCRE/NDEB): `{"auth_number": ..., "date": "YYYY-MM-DD", "series": ..., "number": ...}`.
|
|
368
397
|
|
|
398
|
+
## Emisión sin duplicados (referencia interna)
|
|
399
|
+
|
|
400
|
+
Digifact guarda una referencia interna por DTE (el `Code` de la ADENDA). Con `validate_internal_reference=True`
|
|
401
|
+
rechaza un segundo DTE con la misma referencia, así que un envío que se quedó sin respuesta se puede repetir sin
|
|
402
|
+
emitir dos veces:
|
|
403
|
+
|
|
404
|
+
```python
|
|
405
|
+
import uuid
|
|
406
|
+
from digifact_sdk import DigifactDuplicateReferenceError
|
|
407
|
+
|
|
408
|
+
referencia = str(uuid.uuid4()) # un GUID; Digifact también acepta referencias más cortas
|
|
409
|
+
try:
|
|
410
|
+
result = client.invoice("CF", items, internal_reference=referencia, validate_internal_reference=True)
|
|
411
|
+
except DigifactDuplicateReferenceError:
|
|
412
|
+
# ya se había enviado: el DTE pudo quedar certificado
|
|
413
|
+
filas = client.find_by_internal_reference(referencia, issued_on="2026-11-03")
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
* Sin `internal_reference` el payload no cambia: se envía la referencia fija de siempre con `NO_VALIDAR`.
|
|
417
|
+
* NCRE, NDEB, FCAM, FESP y CCA reciben una ADENDA mínima con la referencia, junto a su complemento.
|
|
418
|
+
* `SHARED_GETDTEINFO_BY_INTERNALID` y el error `9022` no están en la documentación de Digifact para
|
|
419
|
+
Guatemala, pero se verificaron en el ambiente de pruebas el 3 de noviembre de 2026: el duplicado llega con
|
|
420
|
+
`code` 3000 y `[9022]` en la descripción, y la consulta encuentra el DTE sin prefijo en `DATA2`.
|
|
421
|
+
|
|
422
|
+
## Token y reintentos
|
|
423
|
+
|
|
424
|
+
* Si Digifact responde `401` (token vencido) y el cliente tiene `password`, el SDK inicia sesión otra vez y repite la
|
|
425
|
+
petición una sola vez. Con solo `token`, lanza `DigifactAuthError`.
|
|
426
|
+
* `client.token` devuelve el token vigente, para guardarlo en un caché compartido y pasarlo como `token=` a otros
|
|
427
|
+
procesos.
|
|
428
|
+
* Los errores HTTP traen `exc.code` (el estado HTTP) y `exc.retryable`: `True` para 5xx, `False` para 4xx (`400`,
|
|
429
|
+
`406` documento inválido, `415` tipo de contenido).
|
|
430
|
+
|
|
369
431
|
## Establecimiento (sucursal)
|
|
370
432
|
|
|
371
433
|
Cada NIT puede tener varios establecimientos registrados en el RTU. Configúralos al crear el cliente:
|
|
@@ -410,7 +472,8 @@ python -m pytest tests/ -v
|
|
|
410
472
|
from digifact_sdk import (
|
|
411
473
|
DigifactError, # base
|
|
412
474
|
DigifactAuthError, # fallo de autenticación
|
|
413
|
-
DigifactApiError, # error HTTP / de API
|
|
475
|
+
DigifactApiError, # error HTTP / de API (.retryable)
|
|
476
|
+
DigifactDuplicateReferenceError, # referencia interna ya enviada (9022)
|
|
414
477
|
DigifactValidationError, # rechazo de SAT
|
|
415
478
|
DigifactNitNotFoundError, # NIT no encontrado
|
|
416
479
|
)
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
)
|
|
10
|
+
from .client import DigifactClient, DteResult
|
|
11
|
+
from .exceptions import (
|
|
12
|
+
DigifactApiError,
|
|
13
|
+
DigifactAuthError,
|
|
14
|
+
DigifactDuplicateReferenceError,
|
|
15
|
+
DigifactError,
|
|
16
|
+
DigifactNitNotFoundError,
|
|
17
|
+
DigifactValidationError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__version__ = "3.2.0"
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DigifactClient",
|
|
23
|
+
"DteResult",
|
|
24
|
+
"DigifactError",
|
|
25
|
+
"DigifactAuthError",
|
|
26
|
+
"DigifactApiError",
|
|
27
|
+
"DigifactDuplicateReferenceError",
|
|
28
|
+
"DigifactValidationError",
|
|
29
|
+
"DigifactNitNotFoundError",
|
|
30
|
+
"FuelExemption",
|
|
31
|
+
"fuel_exemption",
|
|
32
|
+
"FUEL_EXEMPT_SUPER",
|
|
33
|
+
"FUEL_EXEMPT_REGULAR_IMPORTER",
|
|
34
|
+
"FUEL_EXEMPT_DIESEL",
|
|
35
|
+
"FUEL_EXEMPT_REGULAR_ETHANOL",
|
|
36
|
+
]
|
|
@@ -7,7 +7,8 @@ Key intentional API typos preserved from the SAT/Digifact spec:
|
|
|
7
7
|
"""
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
from
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from decimal import ROUND_HALF_UP, Decimal
|
|
11
12
|
from typing import Any
|
|
12
13
|
|
|
13
14
|
from .tax import LineCalc, FuelLineCalc, InvoiceTotals, fmt, gt_now
|
|
@@ -27,6 +28,93 @@ 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
|
+
VALIDATE_REFERENCE = "VALIDAR"
|
|
32
|
+
SKIP_REFERENCE_VALIDATION = "NO_VALIDAR"
|
|
33
|
+
_VALIDATION_FLAG = "VALIDAR_REFERENCIA_INTERNA"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def apply_internal_reference(payload: dict, reference: str, *, validate: bool) -> dict:
|
|
37
|
+
"""Set the Digifact internal reference (ADENDA ``Code``) of a built payload.
|
|
38
|
+
|
|
39
|
+
With ``validate=True`` Digifact rejects a second DTE with the same
|
|
40
|
+
reference (error 9022), which makes retries safe. DTE types whose builder
|
|
41
|
+
carries no ADENDA (NCRE, NDEB, FCAM, FESP, CCA) get a minimal one.
|
|
42
|
+
"""
|
|
43
|
+
flag = {"Name": _VALIDATION_FLAG, "Data": None, "Value": VALIDATE_REFERENCE if validate else SKIP_REFERENCE_VALIDATION}
|
|
44
|
+
document_info = payload.get("AdditionalDocumentInfo") or {}
|
|
45
|
+
entries = document_info.setdefault("AdditionalInfo", [])
|
|
46
|
+
adenda = next((entry for entry in entries if entry.get("Type") == "ADENDA"), None)
|
|
47
|
+
if adenda is None:
|
|
48
|
+
entries.append({"Code": reference, "Type": "ADENDA", "AditionalInfo": [flag]})
|
|
49
|
+
else:
|
|
50
|
+
adenda["Code"] = reference
|
|
51
|
+
others = [info for info in adenda.get("AditionalInfo") or [] if info.get("Name") != _VALIDATION_FLAG]
|
|
52
|
+
adenda["AditionalInfo"] = [flag, *others]
|
|
53
|
+
payload["AdditionalDocumentInfo"] = document_info
|
|
54
|
+
return payload
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ── Exención temporal de combustibles (Decreto 22-2026) ──────────────────────
|
|
58
|
+
FUEL_EXEMPT_SUPER = "18"
|
|
59
|
+
FUEL_EXEMPT_REGULAR_IMPORTER = "19"
|
|
60
|
+
FUEL_EXEMPT_DIESEL = "20"
|
|
61
|
+
FUEL_EXEMPT_REGULAR_ETHANOL = "21"
|
|
62
|
+
|
|
63
|
+
_FUEL_EXEMPT_IDP_RATES = {
|
|
64
|
+
FUEL_EXEMPT_SUPER: Decimal("4.70"),
|
|
65
|
+
FUEL_EXEMPT_REGULAR_IMPORTER: Decimal("4.60"),
|
|
66
|
+
FUEL_EXEMPT_DIESEL: Decimal("1.30"),
|
|
67
|
+
FUEL_EXEMPT_REGULAR_ETHANOL: Decimal("4.14"),
|
|
68
|
+
}
|
|
69
|
+
_FUEL_EXEMPTION_FRASES = [
|
|
70
|
+
{"tipo_frase": "9", "escenario": "23"},
|
|
71
|
+
{"tipo_frase": "4", "escenario": "38"},
|
|
72
|
+
]
|
|
73
|
+
_IVA_EXEMPT_CODE = "2"
|
|
74
|
+
_IVA_RATE = Decimal("0.12")
|
|
75
|
+
_CENTS = Decimal("0.01")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_exempt_fuel_item(item: dict) -> bool:
|
|
79
|
+
return str(item.get("petroleo_code", "")) in _FUEL_EXEMPT_IDP_RATES
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class FuelExemption:
|
|
84
|
+
"""Taxes a fuel sale would have paid without the Decreto 22-2026 exemption."""
|
|
85
|
+
|
|
86
|
+
iva: Decimal
|
|
87
|
+
idp: Decimal
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def leyendas(self) -> list[str]:
|
|
91
|
+
if not self.iva and not self.idp:
|
|
92
|
+
return []
|
|
93
|
+
return [
|
|
94
|
+
f"Monto de exención temporal de IDP aplicada: Q {fmt(self.idp, 2)}, según Decreto Número 22-2026",
|
|
95
|
+
f"Monto de exención temporal de IVA aplicada: Q {fmt(self.iva, 2)}, según Decreto Número 22-2026",
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def fuel_exemption(items: list[dict]) -> FuelExemption:
|
|
100
|
+
"""Return the exempted IVA and IDP for the exempt fuel items in ``items``.
|
|
101
|
+
|
|
102
|
+
``qty`` must be in gallons: the IDP rates are per gallon.
|
|
103
|
+
"""
|
|
104
|
+
exempt_total = Decimal("0")
|
|
105
|
+
idp = Decimal("0")
|
|
106
|
+
for item in items:
|
|
107
|
+
if not is_exempt_fuel_item(item):
|
|
108
|
+
continue
|
|
109
|
+
qty = Decimal(str(item.get("qty", 1)))
|
|
110
|
+
exempt_total += qty * Decimal(str(item["price"]))
|
|
111
|
+
idp += qty * _FUEL_EXEMPT_IDP_RATES[str(item["petroleo_code"])]
|
|
112
|
+
return FuelExemption(
|
|
113
|
+
iva=(exempt_total * _IVA_RATE).quantize(_CENTS, rounding=ROUND_HALF_UP),
|
|
114
|
+
idp=idp.quantize(_CENTS, rounding=ROUND_HALF_UP),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
30
118
|
# ── Frases ───────────────────────────────────────────────────────────────────
|
|
31
119
|
|
|
32
120
|
|
|
@@ -59,19 +147,25 @@ def resolve_fuel_frases(
|
|
|
59
147
|
frases: list[dict] | None,
|
|
60
148
|
tipo_frase: str | None,
|
|
61
149
|
escenario: str | None,
|
|
150
|
+
*,
|
|
151
|
+
exempt: bool = False,
|
|
62
152
|
) -> list[dict]:
|
|
63
153
|
"""Return the final deduplicated frases list for a fuel invoice.
|
|
64
154
|
|
|
65
155
|
Called only after mutual-exclusivity validation (frases vs tipo_frase/escenario).
|
|
156
|
+
``exempt`` appends the Decreto 22-2026 frases, mandatory with exempt fuel codes.
|
|
66
157
|
"""
|
|
67
158
|
if frases is not None:
|
|
68
159
|
if not frases:
|
|
69
160
|
from .exceptions import DigifactValidationError
|
|
70
161
|
raise DigifactValidationError("frases must contain at least one TipoFrase/Escenario pair")
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
162
|
+
result = list(frases)
|
|
163
|
+
else:
|
|
164
|
+
result = []
|
|
165
|
+
if tipo_frase is not None and escenario is not None:
|
|
166
|
+
result.append({"tipo_frase": tipo_frase, "escenario": escenario})
|
|
167
|
+
if exempt:
|
|
168
|
+
result.extend(_FUEL_EXEMPTION_FRASES)
|
|
75
169
|
return _uniq_frases(result)
|
|
76
170
|
|
|
77
171
|
|
|
@@ -91,7 +185,7 @@ def default_frase(doc_type: str, afiliacion: str = "GEN") -> tuple[str, str] | N
|
|
|
91
185
|
if doc_type == "RECI":
|
|
92
186
|
return ("4", "5")
|
|
93
187
|
if doc_type == "NABN":
|
|
94
|
-
return ("
|
|
188
|
+
return ("9", "17")
|
|
95
189
|
# FACT, NCRE, NDEB, FCAM, FACT+CCA, FACT+combustible
|
|
96
190
|
if afi == "PEQ":
|
|
97
191
|
return ("2", "1")
|
|
@@ -667,6 +761,8 @@ def build_nabn(
|
|
|
667
761
|
items: list[dict],
|
|
668
762
|
*,
|
|
669
763
|
afiliacion: str = "GEN",
|
|
764
|
+
tipo_frase: str | None = "9",
|
|
765
|
+
escenario: str | None = "17",
|
|
670
766
|
amount_str: str = "",
|
|
671
767
|
observaciones: str = "-",
|
|
672
768
|
seller_email: str | None = None,
|
|
@@ -676,14 +772,13 @@ def build_nabn(
|
|
|
676
772
|
issue_dt, _, _ = gt_now()
|
|
677
773
|
line_items, totals = _build_items(items, taxable=False)
|
|
678
774
|
|
|
679
|
-
# NABN: uses TipoFrase=1, Escenario=1 in seller (matches smoke runner)
|
|
680
775
|
seller = _build_seller(
|
|
681
776
|
taxid,
|
|
682
777
|
seller_name,
|
|
683
778
|
seller_address,
|
|
684
779
|
afiliacion=afiliacion,
|
|
685
|
-
tipo_frase=
|
|
686
|
-
escenario=
|
|
780
|
+
tipo_frase=tipo_frase,
|
|
781
|
+
escenario=escenario,
|
|
687
782
|
frases=frases,
|
|
688
783
|
email=seller_email,
|
|
689
784
|
)
|
|
@@ -1057,8 +1152,9 @@ def _build_fuel_items(
|
|
|
1057
1152
|
"""Build Items list for a combustible (fuel) invoice.
|
|
1058
1153
|
|
|
1059
1154
|
Items with 'petroleo_amount' are treated as fuel items and receive two
|
|
1060
|
-
Tax entries (IVA + PETROLEO). Items
|
|
1061
|
-
|
|
1155
|
+
Tax entries (IVA + PETROLEO). Items whose 'petroleo_code' is an exempt
|
|
1156
|
+
code (18-21) carry both taxes at zero. Items without 'petroleo_amount' are
|
|
1157
|
+
treated as regular IVA-only items. All types may coexist in the same invoice.
|
|
1062
1158
|
|
|
1063
1159
|
Returns (line_items, grand_total_str, total_iva_str, total_petroleo_str).
|
|
1064
1160
|
"""
|
|
@@ -1083,7 +1179,31 @@ def _build_fuel_items(
|
|
|
1083
1179
|
"Discounts": None,
|
|
1084
1180
|
}
|
|
1085
1181
|
|
|
1086
|
-
if
|
|
1182
|
+
if is_exempt_fuel_item(item):
|
|
1183
|
+
lc = LineCalc(qty, price, taxable=False)
|
|
1184
|
+
|
|
1185
|
+
built["Qty"] = lc.f_qty()
|
|
1186
|
+
built["Price"] = lc.f_price()
|
|
1187
|
+
built["Taxes"] = {
|
|
1188
|
+
"Tax": [
|
|
1189
|
+
{
|
|
1190
|
+
"Code": _IVA_EXEMPT_CODE,
|
|
1191
|
+
"Description": "IVA",
|
|
1192
|
+
"TaxableAmount": lc.f_line_total(),
|
|
1193
|
+
"Amount": lc.f_iva(),
|
|
1194
|
+
},
|
|
1195
|
+
{
|
|
1196
|
+
"Code": str(item["petroleo_code"]),
|
|
1197
|
+
"Description": "PETROLEO",
|
|
1198
|
+
"TaxableAmount": lc.f_qty(),
|
|
1199
|
+
"Amount": fmt(0),
|
|
1200
|
+
},
|
|
1201
|
+
]
|
|
1202
|
+
}
|
|
1203
|
+
built["Totals"] = {"TotalItem": lc.f_line_total()}
|
|
1204
|
+
|
|
1205
|
+
grand_total += lc.line_total
|
|
1206
|
+
elif "petroleo_amount" in item:
|
|
1087
1207
|
petrol_per_unit = Decimal(str(item["petroleo_amount"]))
|
|
1088
1208
|
petrol_code = str(item.get("petroleo_code", "1"))
|
|
1089
1209
|
|
|
@@ -1176,6 +1296,11 @@ def build_fact_combustible(
|
|
|
1176
1296
|
"4"=DIESEL; default "1"). Items without ``petroleo_amount`` are treated as
|
|
1177
1297
|
regular IVA-only items and may coexist in the same invoice.
|
|
1178
1298
|
|
|
1299
|
+
Fuel sold under the Decreto 22-2026 exemption uses the exempt codes instead
|
|
1300
|
+
("18"=SUPER, "21"=REGULAR with ethanol, "20"=DIESEL, "19"=REGULAR for
|
|
1301
|
+
importers): ``price`` is the pump price without IVA or IDP, no
|
|
1302
|
+
``petroleo_amount`` is needed, and frases 9/23 and 4/38 are added.
|
|
1303
|
+
|
|
1179
1304
|
``frases`` and ``tipo_frase``/``escenario`` are mutually exclusive.
|
|
1180
1305
|
|
|
1181
1306
|
Example fuel item::
|
|
@@ -1201,7 +1326,8 @@ def build_fact_combustible(
|
|
|
1201
1326
|
tf = tipo_frase if tipo_frase is not None else def_tf
|
|
1202
1327
|
es = escenario if escenario is not None else def_es
|
|
1203
1328
|
|
|
1204
|
-
|
|
1329
|
+
has_exempt_fuel = any(is_exempt_fuel_item(item) for item in items)
|
|
1330
|
+
resolved_frases = resolve_fuel_frases(frases, tf, es, exempt=has_exempt_fuel)
|
|
1205
1331
|
|
|
1206
1332
|
# All frases go into Seller.AdditionlInfo as repeated pairs —
|
|
1207
1333
|
# that is the field the API reads to generate <dte:Frases> in the certified XML.
|
|
@@ -1215,7 +1341,7 @@ def build_fact_combustible(
|
|
|
1215
1341
|
)
|
|
1216
1342
|
|
|
1217
1343
|
total_tax: list[dict] = [{"Description": "IVA", "Amount": total_iva}]
|
|
1218
|
-
if float(total_petroleo) > 0.0:
|
|
1344
|
+
if float(total_petroleo) > 0.0 or has_exempt_fuel:
|
|
1219
1345
|
total_tax.append({"Description": "PETROLEO", "Amount": total_petroleo})
|
|
1220
1346
|
|
|
1221
1347
|
combustible_payload: dict[str, Any] = {
|