digifact-sdk 3.0.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-3.0.0 → digifact_sdk-3.1.0}/PKG-INFO +28 -2
- {digifact_sdk-3.0.0 → digifact_sdk-3.1.0}/README.md +27 -1
- {digifact_sdk-3.0.0 → digifact_sdk-3.1.0}/digifact_sdk/__init__.py +15 -1
- {digifact_sdk-3.0.0 → digifact_sdk-3.1.0}/digifact_sdk/builder.py +114 -14
- {digifact_sdk-3.0.0 → digifact_sdk-3.1.0}/digifact_sdk/client.py +10 -1
- {digifact_sdk-3.0.0 → digifact_sdk-3.1.0}/digifact_sdk.egg-info/PKG-INFO +28 -2
- {digifact_sdk-3.0.0 → digifact_sdk-3.1.0}/pyproject.toml +1 -1
- {digifact_sdk-3.0.0 → digifact_sdk-3.1.0}/tests/test_integration.py +123 -27
- {digifact_sdk-3.0.0 → digifact_sdk-3.1.0}/digifact_sdk/exceptions.py +0 -0
- {digifact_sdk-3.0.0 → digifact_sdk-3.1.0}/digifact_sdk/tax.py +0 -0
- {digifact_sdk-3.0.0 → digifact_sdk-3.1.0}/digifact_sdk.egg-info/SOURCES.txt +0 -0
- {digifact_sdk-3.0.0 → digifact_sdk-3.1.0}/digifact_sdk.egg-info/dependency_links.txt +0 -0
- {digifact_sdk-3.0.0 → digifact_sdk-3.1.0}/digifact_sdk.egg-info/requires.txt +0 -0
- {digifact_sdk-3.0.0 → digifact_sdk-3.1.0}/digifact_sdk.egg-info/top_level.txt +0 -0
- {digifact_sdk-3.0.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.
|
|
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
|
|
@@ -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 |
|
|
@@ -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 |
|
|
@@ -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__ = "3.
|
|
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,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,67 @@ 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
|
+
# ── 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
|
+
|
|
30
92
|
# ── Frases ───────────────────────────────────────────────────────────────────
|
|
31
93
|
|
|
32
94
|
|
|
@@ -59,19 +121,25 @@ def resolve_fuel_frases(
|
|
|
59
121
|
frases: list[dict] | None,
|
|
60
122
|
tipo_frase: str | None,
|
|
61
123
|
escenario: str | None,
|
|
124
|
+
*,
|
|
125
|
+
exempt: bool = False,
|
|
62
126
|
) -> list[dict]:
|
|
63
127
|
"""Return the final deduplicated frases list for a fuel invoice.
|
|
64
128
|
|
|
65
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.
|
|
66
131
|
"""
|
|
67
132
|
if frases is not None:
|
|
68
133
|
if not frases:
|
|
69
134
|
from .exceptions import DigifactValidationError
|
|
70
135
|
raise DigifactValidationError("frases must contain at least one TipoFrase/Escenario pair")
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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)
|
|
75
143
|
return _uniq_frases(result)
|
|
76
144
|
|
|
77
145
|
|
|
@@ -91,7 +159,7 @@ def default_frase(doc_type: str, afiliacion: str = "GEN") -> tuple[str, str] | N
|
|
|
91
159
|
if doc_type == "RECI":
|
|
92
160
|
return ("4", "5")
|
|
93
161
|
if doc_type == "NABN":
|
|
94
|
-
return ("
|
|
162
|
+
return ("9", "17")
|
|
95
163
|
# FACT, NCRE, NDEB, FCAM, FACT+CCA, FACT+combustible
|
|
96
164
|
if afi == "PEQ":
|
|
97
165
|
return ("2", "1")
|
|
@@ -667,6 +735,8 @@ def build_nabn(
|
|
|
667
735
|
items: list[dict],
|
|
668
736
|
*,
|
|
669
737
|
afiliacion: str = "GEN",
|
|
738
|
+
tipo_frase: str | None = "9",
|
|
739
|
+
escenario: str | None = "17",
|
|
670
740
|
amount_str: str = "",
|
|
671
741
|
observaciones: str = "-",
|
|
672
742
|
seller_email: str | None = None,
|
|
@@ -676,14 +746,13 @@ def build_nabn(
|
|
|
676
746
|
issue_dt, _, _ = gt_now()
|
|
677
747
|
line_items, totals = _build_items(items, taxable=False)
|
|
678
748
|
|
|
679
|
-
# NABN: uses TipoFrase=1, Escenario=1 in seller (matches smoke runner)
|
|
680
749
|
seller = _build_seller(
|
|
681
750
|
taxid,
|
|
682
751
|
seller_name,
|
|
683
752
|
seller_address,
|
|
684
753
|
afiliacion=afiliacion,
|
|
685
|
-
tipo_frase=
|
|
686
|
-
escenario=
|
|
754
|
+
tipo_frase=tipo_frase,
|
|
755
|
+
escenario=escenario,
|
|
687
756
|
frases=frases,
|
|
688
757
|
email=seller_email,
|
|
689
758
|
)
|
|
@@ -1057,8 +1126,9 @@ def _build_fuel_items(
|
|
|
1057
1126
|
"""Build Items list for a combustible (fuel) invoice.
|
|
1058
1127
|
|
|
1059
1128
|
Items with 'petroleo_amount' are treated as fuel items and receive two
|
|
1060
|
-
Tax entries (IVA + PETROLEO). Items
|
|
1061
|
-
|
|
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.
|
|
1062
1132
|
|
|
1063
1133
|
Returns (line_items, grand_total_str, total_iva_str, total_petroleo_str).
|
|
1064
1134
|
"""
|
|
@@ -1083,7 +1153,31 @@ def _build_fuel_items(
|
|
|
1083
1153
|
"Discounts": None,
|
|
1084
1154
|
}
|
|
1085
1155
|
|
|
1086
|
-
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:
|
|
1087
1181
|
petrol_per_unit = Decimal(str(item["petroleo_amount"]))
|
|
1088
1182
|
petrol_code = str(item.get("petroleo_code", "1"))
|
|
1089
1183
|
|
|
@@ -1176,6 +1270,11 @@ def build_fact_combustible(
|
|
|
1176
1270
|
"4"=DIESEL; default "1"). Items without ``petroleo_amount`` are treated as
|
|
1177
1271
|
regular IVA-only items and may coexist in the same invoice.
|
|
1178
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
|
+
|
|
1179
1278
|
``frases`` and ``tipo_frase``/``escenario`` are mutually exclusive.
|
|
1180
1279
|
|
|
1181
1280
|
Example fuel item::
|
|
@@ -1201,7 +1300,8 @@ def build_fact_combustible(
|
|
|
1201
1300
|
tf = tipo_frase if tipo_frase is not None else def_tf
|
|
1202
1301
|
es = escenario if escenario is not None else def_es
|
|
1203
1302
|
|
|
1204
|
-
|
|
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)
|
|
1205
1305
|
|
|
1206
1306
|
# All frases go into Seller.AdditionlInfo as repeated pairs —
|
|
1207
1307
|
# that is the field the API reads to generate <dte:Frases> in the certified XML.
|
|
@@ -1215,7 +1315,7 @@ def build_fact_combustible(
|
|
|
1215
1315
|
)
|
|
1216
1316
|
|
|
1217
1317
|
total_tax: list[dict] = [{"Description": "IVA", "Amount": total_iva}]
|
|
1218
|
-
if float(total_petroleo) > 0.0:
|
|
1318
|
+
if float(total_petroleo) > 0.0 or has_exempt_fuel:
|
|
1219
1319
|
total_tax.append({"Description": "PETROLEO", "Amount": total_petroleo})
|
|
1220
1320
|
|
|
1221
1321
|
combustible_payload: dict[str, Any] = {
|
|
@@ -21,6 +21,7 @@ from .builder import (
|
|
|
21
21
|
build_rdon,
|
|
22
22
|
build_reci,
|
|
23
23
|
default_frase,
|
|
24
|
+
is_exempt_fuel_item,
|
|
24
25
|
_build_buyer_cf,
|
|
25
26
|
_build_buyer_nit,
|
|
26
27
|
_build_buyer_cui,
|
|
@@ -430,6 +431,8 @@ class DigifactClient:
|
|
|
430
431
|
buyer_dict,
|
|
431
432
|
items,
|
|
432
433
|
afiliacion=self.afiliacion_iva,
|
|
434
|
+
tipo_frase=tf,
|
|
435
|
+
escenario=es,
|
|
433
436
|
amount_str=amount_str,
|
|
434
437
|
observaciones=observaciones,
|
|
435
438
|
frases=eff_frases,
|
|
@@ -549,6 +552,12 @@ class DigifactClient:
|
|
|
549
552
|
``"4"`` DIESEL (default ``"1"``).
|
|
550
553
|
|
|
551
554
|
Items without ``petroleo_amount`` are treated as regular IVA-only items.
|
|
555
|
+
|
|
556
|
+
Fuel sold under the Decreto 22-2026 exemption uses the exempt
|
|
557
|
+
codes instead: ``"18"`` SUPER, ``"21"`` REGULAR with ethanol,
|
|
558
|
+
``"20"`` DIESEL, ``"19"`` REGULAR for importers. ``price`` is the
|
|
559
|
+
pump price without IVA or IDP, ``petroleo_amount`` is not needed,
|
|
560
|
+
and frases 9/23 and 4/38 are added automatically.
|
|
552
561
|
frases:
|
|
553
562
|
Explicit list of ``{"tipo_frase": ..., "escenario": ...}`` dicts.
|
|
554
563
|
Mutually exclusive with ``tipo_frase``/``escenario``.
|
|
@@ -615,7 +624,7 @@ class DigifactClient:
|
|
|
615
624
|
resolved = []
|
|
616
625
|
for item in items:
|
|
617
626
|
code = item.get("petroleo_code")
|
|
618
|
-
if code is not None and "petroleo_amount" not in item:
|
|
627
|
+
if code is not None and "petroleo_amount" not in item and not is_exempt_fuel_item(item):
|
|
619
628
|
rate = self.petroleo_rates.get(str(code)) if self.petroleo_rates else None
|
|
620
629
|
if rate is None:
|
|
621
630
|
raise DigifactValidationError(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: digifact-sdk
|
|
3
|
-
Version: 3.
|
|
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
|
|
@@ -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 |
|
|
@@ -53,15 +53,8 @@ def _client() -> "DigifactClient":
|
|
|
53
53
|
|
|
54
54
|
|
|
55
55
|
# ── Upstream outages ─────────────────────────────────────────────────────────
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
# still fails the run.
|
|
59
|
-
|
|
60
|
-
NABN_FRASE_SKIP = (
|
|
61
|
-
"Upstream: Digifact rejects every NABN with FEL_RCP112 demanding frase "
|
|
62
|
-
"TipoFrase=9/CodigoEscenario=17, including payloads that carry exactly that "
|
|
63
|
-
"frase — the rule is unsatisfiable from the NUC JSON. Pending Digifact support."
|
|
64
|
-
)
|
|
56
|
+
# One SAT-side failure currently has no SDK-side workaround. The helper matches
|
|
57
|
+
# it narrowly so that any *other* failure of the same test still fails the run.
|
|
65
58
|
|
|
66
59
|
CANCEL_SAT_SKIP = (
|
|
67
60
|
"Upstream: SAT's anulación transmission is failing (Codigo 9019, 'Error al "
|
|
@@ -69,10 +62,6 @@ CANCEL_SAT_SKIP = (
|
|
|
69
62
|
)
|
|
70
63
|
|
|
71
64
|
|
|
72
|
-
def _is_nabn_frase_rule(exc: Exception) -> bool:
|
|
73
|
-
return "FEL_RCP112" in str(exc)
|
|
74
|
-
|
|
75
|
-
|
|
76
65
|
def _is_sat_cancel_outage(exc: Exception) -> bool:
|
|
77
66
|
raw = getattr(exc, "raw", None) or {}
|
|
78
67
|
return str(raw.get("Codigo")) == "9019" or "9019" in str(exc)
|
|
@@ -172,6 +161,13 @@ class TestPetroleoRatesAutoFill(unittest.TestCase):
|
|
|
172
161
|
{"description": "DIESEL", "price": 30.70, "petroleo_code": "4"},
|
|
173
162
|
])
|
|
174
163
|
|
|
164
|
+
def test_exempt_code_needs_no_rate(self):
|
|
165
|
+
client = self._make_client()
|
|
166
|
+
resolved = client._apply_petroleo_rates([
|
|
167
|
+
{"description": "DIESEL", "price": 27.00, "petroleo_code": "20"},
|
|
168
|
+
])
|
|
169
|
+
self.assertNotIn("petroleo_amount", resolved[0])
|
|
170
|
+
|
|
175
171
|
|
|
176
172
|
class TestBuilderUnit(unittest.TestCase):
|
|
177
173
|
def setUp(self):
|
|
@@ -206,6 +202,16 @@ class TestBuilderUnit(unittest.TestCase):
|
|
|
206
202
|
self.assertIn("Items", payload)
|
|
207
203
|
self.assertIsNotNone(payload["Items"][0]["Taxes"])
|
|
208
204
|
|
|
205
|
+
def test_nabn_carries_only_frase_9_17(self):
|
|
206
|
+
from digifact_sdk.builder import _build_buyer_cf, build_nabn, default_frase
|
|
207
|
+
self.assertEqual(default_frase("NABN"), ("9", "17"))
|
|
208
|
+
payload = build_nabn("12345678", "TEST", "CALLE", _build_buyer_cf(),
|
|
209
|
+
[{"description": "X", "qty": 1, "price": 100.0}])
|
|
210
|
+
self.assertEqual(payload["Seller"]["AdditionlInfo"], [
|
|
211
|
+
{"Name": "TipoFrase", "Data": "1", "Value": "9"},
|
|
212
|
+
{"Name": "Escenario", "Data": "1", "Value": "17"},
|
|
213
|
+
])
|
|
214
|
+
|
|
209
215
|
def test_fesp_no_additionlinfo(self):
|
|
210
216
|
seller = self._make_seller(doc_type="FESP")
|
|
211
217
|
self.assertNotIn("AdditionlInfo", seller,
|
|
@@ -425,6 +431,101 @@ class TestFuelFrases(unittest.TestCase):
|
|
|
425
431
|
)
|
|
426
432
|
|
|
427
433
|
|
|
434
|
+
class TestFuelExemption(unittest.TestCase):
|
|
435
|
+
"""Decreto 22-2026: exempt fuel codes 18-21."""
|
|
436
|
+
|
|
437
|
+
def _buyer(self):
|
|
438
|
+
return {"TaxID": "CF", "Name": "CONSUMIDOR FINAL",
|
|
439
|
+
"AddressInfo": {"Address": "CIUDAD", "City": "01010",
|
|
440
|
+
"District": "GUATEMALA", "State": "GUATEMALA", "Country": "GT"}}
|
|
441
|
+
|
|
442
|
+
def _exempt_items(self):
|
|
443
|
+
return [
|
|
444
|
+
{"description": "SUPER", "qty": 2.5, "price": 30.00, "petroleo_code": "18"},
|
|
445
|
+
{"description": "REGULAR", "qty": 1, "price": 28.00, "petroleo_code": "21"},
|
|
446
|
+
{"description": "DIESEL", "qty": 1, "price": 27.00, "petroleo_code": "20"},
|
|
447
|
+
]
|
|
448
|
+
|
|
449
|
+
def _build(self, items, **kwargs):
|
|
450
|
+
from digifact_sdk.builder import build_fact_combustible
|
|
451
|
+
return build_fact_combustible("12345678", "SELLER", "ADDR",
|
|
452
|
+
buyer=self._buyer(), items=items, **kwargs)
|
|
453
|
+
|
|
454
|
+
def _frases(self, payload):
|
|
455
|
+
ai = payload["Seller"]["AdditionlInfo"]
|
|
456
|
+
return [(ai[i]["Value"], ai[i + 1]["Value"]) for i in range(0, len(ai), 2)]
|
|
457
|
+
|
|
458
|
+
def test_exempt_item_carries_both_taxes_at_zero(self):
|
|
459
|
+
iva, petroleo = self._build(self._exempt_items())["Items"][0]["Taxes"]["Tax"]
|
|
460
|
+
self.assertEqual(iva, {"Code": "2", "Description": "IVA",
|
|
461
|
+
"TaxableAmount": "75.000000", "Amount": "0.000000"})
|
|
462
|
+
self.assertEqual(petroleo, {"Code": "18", "Description": "PETROLEO",
|
|
463
|
+
"TaxableAmount": "2.500000", "Amount": "0.000000"})
|
|
464
|
+
|
|
465
|
+
def test_exempt_totals_include_petroleo_at_zero(self):
|
|
466
|
+
totals = self._build(self._exempt_items())["Totals"]
|
|
467
|
+
self.assertEqual(totals["TotalTaxes"]["TotalTax"], [
|
|
468
|
+
{"Description": "IVA", "Amount": "0.000000"},
|
|
469
|
+
{"Description": "PETROLEO", "Amount": "0.000000"},
|
|
470
|
+
])
|
|
471
|
+
self.assertEqual(totals["GrandTotal"]["InvoiceTotal"], "130.000000")
|
|
472
|
+
|
|
473
|
+
def test_exemption_frases_follow_the_base_frase(self):
|
|
474
|
+
self.assertEqual(self._frases(self._build(self._exempt_items())),
|
|
475
|
+
[("1", "1"), ("9", "23"), ("4", "38")])
|
|
476
|
+
|
|
477
|
+
def test_exemption_frases_added_to_explicit_frases_once(self):
|
|
478
|
+
payload = self._build(self._exempt_items(), frases=[
|
|
479
|
+
{"tipo_frase": "1", "escenario": "2"},
|
|
480
|
+
{"tipo_frase": "4", "escenario": "38"},
|
|
481
|
+
])
|
|
482
|
+
self.assertEqual(self._frases(payload), [("1", "2"), ("4", "38"), ("9", "23")])
|
|
483
|
+
|
|
484
|
+
def test_taxed_fuel_gets_no_exemption_frases(self):
|
|
485
|
+
payload = self._build([{"description": "SUPER", "qty": 1, "price": 35.00,
|
|
486
|
+
"petroleo_amount": 4.70, "petroleo_code": "1"}])
|
|
487
|
+
self.assertEqual(self._frases(payload), [("1", "1")])
|
|
488
|
+
|
|
489
|
+
def test_exempt_fuel_mixed_with_taxed_item(self):
|
|
490
|
+
payload = self._build(self._exempt_items() + [
|
|
491
|
+
{"description": "FILTRO", "qty": 1, "price": 56.00},
|
|
492
|
+
])
|
|
493
|
+
filtro_tax = payload["Items"][3]["Taxes"]["Tax"]
|
|
494
|
+
self.assertEqual(filtro_tax, [{"Code": "1", "Description": "IVA",
|
|
495
|
+
"TaxableAmount": "50.000000", "Amount": "6.000000"}])
|
|
496
|
+
totals = payload["Totals"]
|
|
497
|
+
self.assertEqual(totals["TotalTaxes"]["TotalTax"], [
|
|
498
|
+
{"Description": "IVA", "Amount": "6.000000"},
|
|
499
|
+
{"Description": "PETROLEO", "Amount": "0.000000"},
|
|
500
|
+
])
|
|
501
|
+
self.assertEqual(totals["GrandTotal"]["InvoiceTotal"], "186.000000")
|
|
502
|
+
|
|
503
|
+
def test_exempt_code_ignores_petroleo_amount(self):
|
|
504
|
+
payload = self._build([{"description": "SUPER", "qty": 1, "price": 30.00,
|
|
505
|
+
"petroleo_code": "18", "petroleo_amount": 4.70}])
|
|
506
|
+
self.assertEqual(payload["Items"][0]["Taxes"]["Tax"][1]["Amount"], "0.000000")
|
|
507
|
+
self.assertEqual(payload["Items"][0]["Totals"]["TotalItem"], "30.000000")
|
|
508
|
+
|
|
509
|
+
def test_fuel_exemption_amounts(self):
|
|
510
|
+
from digifact_sdk import fuel_exemption
|
|
511
|
+
exemption = fuel_exemption(self._exempt_items() + [
|
|
512
|
+
{"description": "FILTRO", "qty": 1, "price": 56.00},
|
|
513
|
+
])
|
|
514
|
+
self.assertEqual(exemption.idp, Decimal("17.19"))
|
|
515
|
+
self.assertEqual(exemption.iva, Decimal("15.60"))
|
|
516
|
+
self.assertEqual(exemption.leyendas, [
|
|
517
|
+
"Monto de exención temporal de IDP aplicada: Q 17.19, según Decreto Número 22-2026",
|
|
518
|
+
"Monto de exención temporal de IVA aplicada: Q 15.60, según Decreto Número 22-2026",
|
|
519
|
+
])
|
|
520
|
+
|
|
521
|
+
def test_fuel_exemption_without_exempt_items(self):
|
|
522
|
+
from digifact_sdk import fuel_exemption
|
|
523
|
+
exemption = fuel_exemption([{"description": "SUPER", "qty": 1, "price": 35.00,
|
|
524
|
+
"petroleo_amount": 4.70, "petroleo_code": "1"}])
|
|
525
|
+
self.assertEqual((exemption.iva, exemption.idp), (Decimal("0.00"), Decimal("0.00")))
|
|
526
|
+
self.assertEqual(exemption.leyendas, [])
|
|
527
|
+
|
|
528
|
+
|
|
428
529
|
# ── Integration tests (shared client, 1 login total) ─────────────────────────
|
|
429
530
|
|
|
430
531
|
@unittest.skipIf(SKIP, SKIP_REASON)
|
|
@@ -566,16 +667,11 @@ class TestNDEBandNCRE(unittest.TestCase):
|
|
|
566
667
|
@unittest.skipIf(SKIP, SKIP_REASON)
|
|
567
668
|
class TestNABN(unittest.TestCase):
|
|
568
669
|
def test_nabn(self):
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
)
|
|
575
|
-
except DigifactError as exc:
|
|
576
|
-
if _is_nabn_frase_rule(exc):
|
|
577
|
-
self.skipTest(NABN_FRASE_SKIP)
|
|
578
|
-
raise
|
|
670
|
+
result = _client().invoice(
|
|
671
|
+
"77454820",
|
|
672
|
+
[{"description": "RETENEDOR BLANCO", "qty": 1, "price": 100.00, "type": "Bien"}],
|
|
673
|
+
doc_type="NABN",
|
|
674
|
+
)
|
|
579
675
|
self.assertTrue(result.auth_number)
|
|
580
676
|
print(f"\n NABN auth: {result.auth_number}")
|
|
581
677
|
|
|
@@ -697,10 +793,10 @@ class TestFuelInvoice(unittest.TestCase):
|
|
|
697
793
|
result = _client().fuel_invoice(
|
|
698
794
|
"CF",
|
|
699
795
|
[
|
|
700
|
-
{"description": "GASOLINA SUPER", "qty": 1, "price":
|
|
701
|
-
"
|
|
702
|
-
{"description": "GASOLINA REGULAR", "qty": 1, "price":
|
|
703
|
-
"
|
|
796
|
+
{"description": "GASOLINA SUPER", "qty": 1, "price": 30.00,
|
|
797
|
+
"petroleo_code": "18", "type": "Bien"},
|
|
798
|
+
{"description": "GASOLINA REGULAR", "qty": 1, "price": 28.00,
|
|
799
|
+
"petroleo_code": "21", "type": "Bien"},
|
|
704
800
|
{"description": "FILTRO DE ACEITE", "qty": 1, "price": 45.00, "type": "Bien"},
|
|
705
801
|
],
|
|
706
802
|
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|