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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: digifact-sdk
3
- Version: 3.0.0
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 | `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 |
@@ -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 | `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 |
@@ -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 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,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
- 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})
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 ("1", "1")
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="1",
686
- escenario="1",
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 without 'petroleo_amount' are treated
1061
- as regular IVA-only items. Both types may coexist in the same invoice.
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 "petroleo_amount" in item:
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
- resolved_frases = resolve_fuel_frases(frases, tf, es)
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] = {