digifact-sdk 2.3.0__tar.gz → 3.1.0__tar.gz

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