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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: digifact-sdk
3
- Version: 3.0.0
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 | `1` | `1` | Abonos |
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 | `1` | `1` | Abonos |
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.0.0"
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 decimal import Decimal
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
- return _uniq_frases(frases)
72
- result: list[dict] = []
73
- if tipo_frase is not None and escenario is not None:
74
- result.append({"tipo_frase": tipo_frase, "escenario": escenario})
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 ("1", "1")
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="1",
686
- escenario="1",
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 without 'petroleo_amount' are treated
1061
- as regular IVA-only items. Both types may coexist in the same invoice.
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 "petroleo_amount" in item:
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
- resolved_frases = resolve_fuel_frases(frases, tf, es)
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.0.0
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 | `1` | `1` | Abonos |
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 |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "digifact-sdk"
7
- version = "3.0.0"
7
+ version = "3.1.0"
8
8
  description = "Python SDK for Digifact FEL Guatemala e-invoicing API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -53,15 +53,8 @@ def _client() -> "DigifactClient":
53
53
 
54
54
 
55
55
  # ── Upstream outages ─────────────────────────────────────────────────────────
56
- # Two Digifact/SAT-side failures currently have no SDK-side workaround. These
57
- # helpers match each one narrowly so that any *other* failure of the same test
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
- try:
570
- result = _client().invoice(
571
- "77454820",
572
- [{"description": "RETENEDOR BLANCO", "qty": 1, "price": 100.00, "type": "Bien"}],
573
- doc_type="NABN",
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": 35.00,
701
- "petroleo_amount": 4.70, "petroleo_code": "1", "type": "Bien"},
702
- {"description": "GASOLINA REGULAR", "qty": 1, "price": 34.00,
703
- "petroleo_amount": 4.60, "petroleo_code": "2", "type": "Bien"},
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