digifact-sdk 2.3.0__tar.gz → 3.0.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.0.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,28 @@ 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
+ ### Subsidio combustible
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
+ El subsidio a la gasolina y al diésel **finalizó el jueves 2 de julio de 2026 a las 24:00**, antes de lo
281
+ previsto: el presupuesto de Q2 mil millones (Decreto 11-2026, reglamentado por el Acuerdo Gubernativo
282
+ 64-2026) se agotó por la demanda. Por eso el SDK **nunca** envía las frases `TipoFrase=9, Escenario=18` ni
283
+ `TipoFrase=9, Escenario=19` por su cuenta — no hay fecha de corte que valga para todos.
266
284
 
267
- ```python
268
- # Sin cambios — el SDK agrega 9/18 y 9/19 automáticamente durante el subsidio
269
- result = client.fuel_invoice("CF", items)
270
-
271
- # Deshabilitarlo por llamada
272
- result = client.fuel_invoice("CF", items, auto_fuel_subsidy_frases=False)
273
-
274
- # Deshabilitarlo globalmente al construir el cliente
275
- client = DigifactClient(..., auto_fuel_subsidy_frases=False)
285
+ Las estaciones con inventario adquirido bajo el subsidio deben mantener el precio rebajado hasta agotar ese
286
+ producto, sujeto a verificación. Mientras te quede ese inventario, manda las frases explícitamente con
287
+ `frases`; al agotarlo, deja de mandarlas:
276
288
 
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)
279
-
280
- # Frases completamente personalizadas (deshabilita auto-inyección)
281
- result = client.fuel_invoice("CF", items, frases=[{"tipo_frase": "1", "escenario": "1"}])
289
+ ```python
290
+ result = client.fuel_invoice("CF", items, frases=[
291
+ {"tipo_frase": "1", "escenario": "1"}, # frase base
292
+ {"tipo_frase": "9", "escenario": "18"},
293
+ {"tipo_frase": "9", "escenario": "19"},
294
+ ])
282
295
  ```
283
296
 
297
+ > **Nota:** la leyenda del subsidio en la representación gráfica la genera Digifact a partir de las frases
298
+ > del XML certificado, no este SDK. Al dejar de mandar las frases, deja de imprimirse sola.
299
+
284
300
  > **Nota:** `frases` y `tipo_frase`/`escenario` son **mutuamente exclusivos** — pasarlos juntos lanza `DigifactValidationError`.
285
301
 
286
302
  ## Configuración de frases (TipoFrase / CodigoEscenario)
@@ -309,7 +325,7 @@ configurar nada en el caso común.
309
325
  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
326
 
311
327
  ```python
312
- # Lista explícita de frases (deshabilita tipo_frase/escenario y auto-inyección)
328
+ # Lista explícita de frases (reemplaza a tipo_frase/escenario)
313
329
  client.fuel_invoice("CF", items, frases=[
314
330
  {"tipo_frase": "1", "escenario": "1"},
315
331
  {"tipo_frase": "9", "escenario": "18"},
@@ -358,18 +374,19 @@ Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `
358
374
  |--------|-------|-------------|
359
375
  | `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
376
  | `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. |
377
+ | `fuel_invoice()` | `fuel_invoice(buyer, items, *, tipo_frase=None, escenario=None, frases=None)` | FACT con complemento combustible (IVA + PETROLEO). |
362
378
  | `credit_note()` | `credit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de crédito (NCRE). |
363
379
  | `debit_note()` | `debit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de débito (NDEB). |
364
380
  | `credit_note_total()` | `credit_note_total(auth_number, issue_datetime, reason="...", reference="")` | Nota de crédito total. Devuelve `dict`. |
365
381
  | `cancel()` | `cancel(auth_number, receiver_id, issue_datetime, reason="Anulación")` | Anula un DTE. Devuelve `dict`. |
366
382
  | `lookup_nit()` | `lookup_nit(nit)` | Consulta SAT. Devuelve `{"nit","name","address","city","district","state"}`. |
383
+ | `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
384
  | `get_dte()` | `get_dte(auth_number, fmt="JSON")` | Recupera el DTE (`"JSON"`, `"XML"`, `"HTML"`, `"PDF"`). |
368
385
  | `get_dte_info()` | `get_dte_info(auth_number)` | Metadatos del DTE. |
369
386
 
370
387
  ### Parámetros comunes
371
388
 
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"}`).
389
+ - **`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
390
  - **`items`**: lista de dicts con `description` (str, req), `price` (float/Decimal, req), `qty` (1), `type` (`"Servicio"`/`"Bien"`), `unit_of_measure` (`"UNI"`), `discount` (opcional).
374
391
  - **`origin`** (NCRE/NDEB): `{"auth_number": ..., "date": "YYYY-MM-DD", "series": ..., "number": ...}`.
375
392
 
@@ -410,7 +427,6 @@ python -m pytest tests/ -v
410
427
  | `DIGIFACT_USERNAME` | Usuario (p. ej. `FELUSER`) |
411
428
  | `DIGIFACT_PASSWORD` | Contraseña de la cuenta |
412
429
  | `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
430
 
415
431
  ## Manejo de errores
416
432
 
@@ -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,28 @@ 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
+ ### Subsidio combustible
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
+ El subsidio a la gasolina y al diésel **finalizó el jueves 2 de julio de 2026 a las 24:00**, antes de lo
257
+ previsto: el presupuesto de Q2 mil millones (Decreto 11-2026, reglamentado por el Acuerdo Gubernativo
258
+ 64-2026) se agotó por la demanda. Por eso el SDK **nunca** envía las frases `TipoFrase=9, Escenario=18` ni
259
+ `TipoFrase=9, Escenario=19` por su cuenta — no hay fecha de corte que valga para todos.
242
260
 
243
- ```python
244
- # Sin cambios — el SDK agrega 9/18 y 9/19 automáticamente durante el subsidio
245
- result = client.fuel_invoice("CF", items)
246
-
247
- # Deshabilitarlo por llamada
248
- result = client.fuel_invoice("CF", items, auto_fuel_subsidy_frases=False)
249
-
250
- # Deshabilitarlo globalmente al construir el cliente
251
- client = DigifactClient(..., auto_fuel_subsidy_frases=False)
261
+ Las estaciones con inventario adquirido bajo el subsidio deben mantener el precio rebajado hasta agotar ese
262
+ producto, sujeto a verificación. Mientras te quede ese inventario, manda las frases explícitamente con
263
+ `frases`; al agotarlo, deja de mandarlas:
252
264
 
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)
255
-
256
- # Frases completamente personalizadas (deshabilita auto-inyección)
257
- result = client.fuel_invoice("CF", items, frases=[{"tipo_frase": "1", "escenario": "1"}])
265
+ ```python
266
+ result = client.fuel_invoice("CF", items, frases=[
267
+ {"tipo_frase": "1", "escenario": "1"}, # frase base
268
+ {"tipo_frase": "9", "escenario": "18"},
269
+ {"tipo_frase": "9", "escenario": "19"},
270
+ ])
258
271
  ```
259
272
 
273
+ > **Nota:** la leyenda del subsidio en la representación gráfica la genera Digifact a partir de las frases
274
+ > del XML certificado, no este SDK. Al dejar de mandar las frases, deja de imprimirse sola.
275
+
260
276
  > **Nota:** `frases` y `tipo_frase`/`escenario` son **mutuamente exclusivos** — pasarlos juntos lanza `DigifactValidationError`.
261
277
 
262
278
  ## Configuración de frases (TipoFrase / CodigoEscenario)
@@ -285,7 +301,7 @@ configurar nada en el caso común.
285
301
  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
302
 
287
303
  ```python
288
- # Lista explícita de frases (deshabilita tipo_frase/escenario y auto-inyección)
304
+ # Lista explícita de frases (reemplaza a tipo_frase/escenario)
289
305
  client.fuel_invoice("CF", items, frases=[
290
306
  {"tipo_frase": "1", "escenario": "1"},
291
307
  {"tipo_frase": "9", "escenario": "18"},
@@ -334,18 +350,19 @@ Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `
334
350
  |--------|-------|-------------|
335
351
  | `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
352
  | `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. |
353
+ | `fuel_invoice()` | `fuel_invoice(buyer, items, *, tipo_frase=None, escenario=None, frases=None)` | FACT con complemento combustible (IVA + PETROLEO). |
338
354
  | `credit_note()` | `credit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de crédito (NCRE). |
339
355
  | `debit_note()` | `debit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de débito (NDEB). |
340
356
  | `credit_note_total()` | `credit_note_total(auth_number, issue_datetime, reason="...", reference="")` | Nota de crédito total. Devuelve `dict`. |
341
357
  | `cancel()` | `cancel(auth_number, receiver_id, issue_datetime, reason="Anulación")` | Anula un DTE. Devuelve `dict`. |
342
358
  | `lookup_nit()` | `lookup_nit(nit)` | Consulta SAT. Devuelve `{"nit","name","address","city","district","state"}`. |
359
+ | `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
360
  | `get_dte()` | `get_dte(auth_number, fmt="JSON")` | Recupera el DTE (`"JSON"`, `"XML"`, `"HTML"`, `"PDF"`). |
344
361
  | `get_dte_info()` | `get_dte_info(auth_number)` | Metadatos del DTE. |
345
362
 
346
363
  ### Parámetros comunes
347
364
 
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"}`).
365
+ - **`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
366
  - **`items`**: lista de dicts con `description` (str, req), `price` (float/Decimal, req), `qty` (1), `type` (`"Servicio"`/`"Bien"`), `unit_of_measure` (`"UNI"`), `discount` (opcional).
350
367
  - **`origin`** (NCRE/NDEB): `{"auth_number": ..., "date": "YYYY-MM-DD", "series": ..., "number": ...}`.
351
368
 
@@ -386,7 +403,6 @@ python -m pytest tests/ -v
386
403
  | `DIGIFACT_USERNAME` | Usuario (p. ej. `FELUSER`) |
387
404
  | `DIGIFACT_PASSWORD` | Contraseña de la cuenta |
388
405
  | `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
406
 
391
407
  ## Manejo de errores
392
408
 
@@ -8,7 +8,7 @@ from .exceptions import (
8
8
  DigifactValidationError,
9
9
  )
10
10
 
11
- __version__ = "2.0.0"
11
+ __version__ = "3.0.0"
12
12
  __all__ = [
13
13
  "DigifactClient",
14
14
  "DteResult",
@@ -7,7 +7,6 @@ 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
10
  from decimal import Decimal
12
11
  from typing import Any
13
12
 
@@ -28,9 +27,7 @@ def _adenda_code(doc_type: str) -> str:
28
27
  return ADENDA_CODE_ALT if doc_type in _ALT_ADENDA_TYPES else ADENDA_CODE_STD
29
28
 
30
29
 
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
30
+ # ── Frases ───────────────────────────────────────────────────────────────────
34
31
 
35
32
 
36
33
  def _uniq_frases(frases: list[dict]) -> list[dict]:
@@ -58,20 +55,10 @@ def _build_additionl_info_from_frases(frases: list[dict]) -> list[dict]:
58
55
  return out
59
56
 
60
57
 
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
58
  def resolve_fuel_frases(
70
59
  frases: list[dict] | None,
71
60
  tipo_frase: str | None,
72
61
  escenario: str | None,
73
- issue_dt_iso: str,
74
- auto_enabled: bool = True,
75
62
  ) -> list[dict]:
76
63
  """Return the final deduplicated frases list for a fuel invoice.
77
64
 
@@ -85,9 +72,6 @@ def resolve_fuel_frases(
85
72
  result: list[dict] = []
86
73
  if tipo_frase is not None and escenario is not None:
87
74
  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"})
91
75
  return _uniq_frases(result)
92
76
 
93
77
 
@@ -1183,7 +1167,6 @@ def build_fact_combustible(
1183
1167
  tipo_frase: str | None = None,
1184
1168
  escenario: str | None = None,
1185
1169
  frases: list[dict] | None = None,
1186
- auto_fuel_subsidy_frases: bool = True,
1187
1170
  seller_email: str | None = None,
1188
1171
  ) -> dict:
1189
1172
  """Build a FACT payload for combustible (fuel) invoices.
@@ -1218,9 +1201,9 @@ def build_fact_combustible(
1218
1201
  tf = tipo_frase if tipo_frase is not None else def_tf
1219
1202
  es = escenario if escenario is not None else def_es
1220
1203
 
1221
- resolved_frases = resolve_fuel_frases(frases, tf, es, issue_dt, auto_fuel_subsidy_frases)
1204
+ resolved_frases = resolve_fuel_frases(frases, tf, es)
1222
1205
 
1223
- # All frases (base + any subsidy) go into Seller.AdditionlInfo as repeated pairs —
1206
+ # All frases go into Seller.AdditionlInfo as repeated pairs —
1224
1207
  # that is the field the API reads to generate <dte:Frases> in the certified XML.
1225
1208
  seller = _build_seller(
1226
1209
  taxid,
@@ -40,6 +40,9 @@ _BASE_URLS = {
40
40
  "production": "https://nucgt.digifact.com/gt.com.apinuc/api",
41
41
  }
42
42
 
43
+ # Digits in a Guatemalan CUI (DPI); a NIT never reaches this length.
44
+ CUI_LENGTH = 13
45
+
43
46
 
44
47
  @dataclass
45
48
  class DteResult:
@@ -128,7 +131,6 @@ class DigifactClient:
128
131
  tipo_frase: str | None = None,
129
132
  escenario: str | None = None,
130
133
  frases: list[dict] | None = None,
131
- auto_fuel_subsidy_frases: bool | None = None,
132
134
  branch_code: str = "1",
133
135
  branch_name: str = "ESTABLECIMIENTO PRINCIPAL",
134
136
  timeout: int = 120,
@@ -156,7 +158,6 @@ class DigifactClient:
156
158
  self.tipo_frase = tipo_frase
157
159
  self.escenario = escenario
158
160
  self.frases = frases
159
- self.auto_fuel_subsidy_frases = auto_fuel_subsidy_frases
160
161
  self.branch_code = branch_code
161
162
  self.branch_name = branch_name
162
163
  self.timeout = timeout
@@ -171,6 +172,7 @@ class DigifactClient:
171
172
  self._seller_name: str = seller_name
172
173
  self._seller_address: str = seller_address
173
174
  self._nit_cache: dict[str, dict] = {}
175
+ self._cui_cache: dict[str, dict] = {}
174
176
  self.petroleo_rates: dict[str, float] = dict(petroleo_rates) if petroleo_rates else {}
175
177
 
176
178
  self._session = session or requests.Session()
@@ -232,17 +234,25 @@ class DigifactClient:
232
234
  def _resolve_buyer(self, buyer: str | dict) -> dict:
233
235
  """Resolve a buyer spec to a buyer dict.
234
236
 
235
- - "CF" → Consumidor Final
236
- - "77454820" → NIT lookup → buyer dict
237
+ - "CF" → Consumidor Final
238
+ - "77454820" → NIT lookup → buyer dict
239
+ - "1234567890123" → CUI lookup → buyer dict (13 digits)
240
+ - {"taxid": ..., "type": "CUI"} → CUI buyer, name resolved via lookup
237
241
  - {"taxid": ..., "type": "CUI", "name": ...} → CUI buyer
238
242
  - {"taxid": ..., "name": ..., ...} → explicit buyer dict
239
243
  """
240
244
  if isinstance(buyer, str):
241
245
  if buyer.upper() == "CF":
242
246
  return _build_buyer_cf()
243
- # Numeric string → NIT
244
247
  digits = re.sub(r"\D", "", buyer)
245
248
  if digits:
249
+ # A CUI is always 13 digits; a NIT never reaches that length.
250
+ if len(digits) == CUI_LENGTH:
251
+ info = self.lookup_cui(digits)
252
+ return _build_buyer_cui(
253
+ taxid=digits,
254
+ name=info.get("name") or digits,
255
+ )
246
256
  info = self.lookup_nit(digits)
247
257
  return _build_buyer_nit(
248
258
  nit=digits,
@@ -258,10 +268,12 @@ class DigifactClient:
258
268
  if isinstance(buyer, dict):
259
269
  buyer_type = buyer.get("type", "").upper()
260
270
  if buyer_type == "CUI":
261
- return _build_buyer_cui(
262
- taxid=str(buyer["taxid"]),
263
- name=buyer["name"],
264
- )
271
+ cui = str(buyer["taxid"])
272
+ name = buyer.get("name") or ""
273
+ if not name:
274
+ digits = re.sub(r"\D", "", cui)
275
+ name = self.lookup_cui(digits).get("name") or digits
276
+ return _build_buyer_cui(taxid=cui, name=name)
265
277
  # Explicit dict — build NIT buyer
266
278
  return _build_buyer_nit(
267
279
  nit=str(buyer["taxid"]),
@@ -522,7 +534,6 @@ class DigifactClient:
522
534
  tipo_frase: str | None = None,
523
535
  escenario: str | None = None,
524
536
  frases: list[dict] | None = None,
525
- auto_fuel_subsidy_frases: bool | None = None,
526
537
  ) -> DteResult:
527
538
  """Emit a combustible (fuel) FACT invoice.
528
539
 
@@ -540,13 +551,15 @@ class DigifactClient:
540
551
  Items without ``petroleo_amount`` are treated as regular IVA-only items.
541
552
  frases:
542
553
  Explicit list of ``{"tipo_frase": ..., "escenario": ...}`` dicts.
543
- Mutually exclusive with ``tipo_frase``/``escenario``. When set,
544
- auto-injection of subsidy frases (9/18, 9/19) is suppressed.
545
- auto_fuel_subsidy_frases:
546
- Override the client-level or default auto-injection behaviour.
547
- ``None`` (default) falls back to the constructor setting, then
548
- ``True``. Set to ``False`` (or ``DIGIFACT_DISABLE_AUTO_FUEL_SUBSIDY_FRASES=1``)
549
- to disable auto-injection.
554
+ Mutually exclusive with ``tipo_frase``/``escenario``.
555
+
556
+ The SAT fuel subsidy has ended, so nothing subsidy-related is ever
557
+ sent on its own. If you are still dispatching inventory bought under
558
+ the subsidy scheme, pass the frases yourself::
559
+
560
+ frases=[{"tipo_frase": "1", "escenario": "1"},
561
+ {"tipo_frase": "9", "escenario": "18"},
562
+ {"tipo_frase": "9", "escenario": "19"}]
550
563
 
551
564
  Example::
552
565
 
@@ -557,7 +570,6 @@ class DigifactClient:
557
570
  "type": "Bien"},
558
571
  ])
559
572
  """
560
- import os
561
573
  from .exceptions import DigifactValidationError
562
574
 
563
575
  if frases is not None and (tipo_frase is not None or escenario is not None):
@@ -577,16 +589,6 @@ class DigifactClient:
577
589
  eff_frases = None
578
590
  eff_tf, eff_es = self._resolve_frase("FACT", tipo_frase, escenario)
579
591
 
580
- # Resolve auto_enabled
581
- if auto_fuel_subsidy_frases is not None:
582
- auto_enabled = auto_fuel_subsidy_frases
583
- elif self.auto_fuel_subsidy_frases is not None:
584
- auto_enabled = self.auto_fuel_subsidy_frases
585
- else:
586
- auto_enabled = True
587
- if os.environ.get("DIGIFACT_DISABLE_AUTO_FUEL_SUBSIDY_FRASES", "") == "1":
588
- auto_enabled = False
589
-
590
592
  seller_name, seller_address = self._get_seller_info()
591
593
  buyer_dict = self._resolve_buyer(buyer)
592
594
  resolved = self._apply_petroleo_rates(items)
@@ -600,7 +602,6 @@ class DigifactClient:
600
602
  tipo_frase=eff_tf,
601
603
  escenario=eff_es,
602
604
  frases=eff_frases,
603
- auto_fuel_subsidy_frases=auto_enabled,
604
605
  )
605
606
  data = self._certify(payload)
606
607
  return self._parse_result(data)
@@ -818,6 +819,42 @@ class DigifactClient:
818
819
  self._nit_cache[digits] = normalized
819
820
  return normalized
820
821
 
822
+ def lookup_cui(self, cui: str) -> dict:
823
+ """Look up a CUI (DPI) via SHARED_GETINFOCUI.
824
+
825
+ Returns a normalized dict with keys: cui, name, status.
826
+ Result is cached per CUI.
827
+ """
828
+ digits = re.sub(r"\D", "", cui)
829
+ if digits in self._cui_cache:
830
+ return self._cui_cache[digits]
831
+
832
+ resp = self._session.get(
833
+ f"{self.base_url}/Shared",
834
+ params={
835
+ "COUNTRY": "GT",
836
+ "TAXID": self.padded_taxid,
837
+ "DATA1": "SHARED_GETINFOCUI",
838
+ "DATA2": f"CUI|{digits}",
839
+ "USERNAME": self.username,
840
+ },
841
+ headers={"Authorization": self._login()},
842
+ timeout=self.timeout,
843
+ )
844
+ try:
845
+ resp.raise_for_status()
846
+ except requests.HTTPError as exc:
847
+ raise DigifactApiError(
848
+ f"CUI lookup HTTP error: {exc}", raw=_try_json(resp)
849
+ ) from exc
850
+
851
+ data = resp.json()
852
+ normalized = _parse_cui_response(digits, data)
853
+ if not normalized.get("name"):
854
+ raise DigifactNitNotFoundError(f"CUI {cui!r} not found or returned empty name")
855
+ self._cui_cache[digits] = normalized
856
+ return normalized
857
+
821
858
  def get_dte_info(self, auth_number: str) -> dict:
822
859
  """Get DTE info via SHARED_GETDTEINFO."""
823
860
  resp = self._session.get(
@@ -947,3 +984,47 @@ def _parse_nit_response(nit: str, data: Any) -> dict:
947
984
  return _parse_nit_response(nit, data[0])
948
985
  return {"nit": nit, "name": "", "address": "CIUDAD", "city": "01010",
949
986
  "district": "GUATEMALA", "state": "GUATEMALA"}
987
+
988
+
989
+ def _normalize_cui_name(name: str) -> str:
990
+ """Tidy spacing only — SAT's "APELLIDOS, NOMBRES" string is kept verbatim otherwise.
991
+
992
+ The comma is deliberately preserved: it is how SAT delimits surnames from given
993
+ names, and commas already reach the DTE unharmed via NIT names such as
994
+ "A3 CLOUD TECHNOLOGIES, SOCIEDAD DE EMPRENDIMIENTO". Reordering is not attempted
995
+ either — at least one real production record arrives with its two sides
996
+ swapped at the source, so no rule could straighten every case.
997
+ """
998
+ return re.sub(r"\s+", " ", re.sub(r"\s*,\s*", ", ", name)).strip()
999
+
1000
+
1001
+ def _parse_cui_response(cui: str, data: Any) -> dict:
1002
+ """Normalize CUI lookup response to a standard dict.
1003
+
1004
+ The API returns: {"REQUEST_DATA": [...], "RESPONSE": [{"PAIS": "GT", "CUI": ...,
1005
+ "NOMBRE": "NOMBRES , APELLIDOS", "STATUS": "A"}]}
1006
+ """
1007
+ empty = {"cui": cui, "name": "", "status": ""}
1008
+
1009
+ # Unwrap envelope: {"REQUEST_DATA": [...], "RESPONSE": [...]}
1010
+ if isinstance(data, dict) and "RESPONSE" in data:
1011
+ response_list = data["RESPONSE"]
1012
+ if isinstance(response_list, list) and response_list:
1013
+ return _parse_cui_response(cui, response_list[0])
1014
+ return empty
1015
+
1016
+ if isinstance(data, dict):
1017
+ # Direct row dict — NOMBRE arrives as "NOMBRES , APELLIDOS" (RENAP format)
1018
+ name = (
1019
+ data.get("NOMBRE") or data.get("nombre")
1020
+ or data.get("Name") or data.get("name") or ""
1021
+ )
1022
+ status = data.get("STATUS") or data.get("status") or ""
1023
+ return {
1024
+ "cui": cui,
1025
+ "name": _normalize_cui_name(name),
1026
+ "status": status.strip(),
1027
+ }
1028
+ if isinstance(data, list) and data:
1029
+ return _parse_cui_response(cui, data[0])
1030
+ return empty
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: digifact-sdk
3
- Version: 2.3.0
3
+ Version: 3.0.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,28 @@ 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
+ ### Subsidio combustible
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
+ El subsidio a la gasolina y al diésel **finalizó el jueves 2 de julio de 2026 a las 24:00**, antes de lo
281
+ previsto: el presupuesto de Q2 mil millones (Decreto 11-2026, reglamentado por el Acuerdo Gubernativo
282
+ 64-2026) se agotó por la demanda. Por eso el SDK **nunca** envía las frases `TipoFrase=9, Escenario=18` ni
283
+ `TipoFrase=9, Escenario=19` por su cuenta — no hay fecha de corte que valga para todos.
266
284
 
267
- ```python
268
- # Sin cambios — el SDK agrega 9/18 y 9/19 automáticamente durante el subsidio
269
- result = client.fuel_invoice("CF", items)
270
-
271
- # Deshabilitarlo por llamada
272
- result = client.fuel_invoice("CF", items, auto_fuel_subsidy_frases=False)
273
-
274
- # Deshabilitarlo globalmente al construir el cliente
275
- client = DigifactClient(..., auto_fuel_subsidy_frases=False)
285
+ Las estaciones con inventario adquirido bajo el subsidio deben mantener el precio rebajado hasta agotar ese
286
+ producto, sujeto a verificación. Mientras te quede ese inventario, manda las frases explícitamente con
287
+ `frases`; al agotarlo, deja de mandarlas:
276
288
 
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)
279
-
280
- # Frases completamente personalizadas (deshabilita auto-inyección)
281
- result = client.fuel_invoice("CF", items, frases=[{"tipo_frase": "1", "escenario": "1"}])
289
+ ```python
290
+ result = client.fuel_invoice("CF", items, frases=[
291
+ {"tipo_frase": "1", "escenario": "1"}, # frase base
292
+ {"tipo_frase": "9", "escenario": "18"},
293
+ {"tipo_frase": "9", "escenario": "19"},
294
+ ])
282
295
  ```
283
296
 
297
+ > **Nota:** la leyenda del subsidio en la representación gráfica la genera Digifact a partir de las frases
298
+ > del XML certificado, no este SDK. Al dejar de mandar las frases, deja de imprimirse sola.
299
+
284
300
  > **Nota:** `frases` y `tipo_frase`/`escenario` son **mutuamente exclusivos** — pasarlos juntos lanza `DigifactValidationError`.
285
301
 
286
302
  ## Configuración de frases (TipoFrase / CodigoEscenario)
@@ -309,7 +325,7 @@ configurar nada en el caso común.
309
325
  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
326
 
311
327
  ```python
312
- # Lista explícita de frases (deshabilita tipo_frase/escenario y auto-inyección)
328
+ # Lista explícita de frases (reemplaza a tipo_frase/escenario)
313
329
  client.fuel_invoice("CF", items, frases=[
314
330
  {"tipo_frase": "1", "escenario": "1"},
315
331
  {"tipo_frase": "9", "escenario": "18"},
@@ -358,18 +374,19 @@ Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `
358
374
  |--------|-------|-------------|
359
375
  | `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
376
  | `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. |
377
+ | `fuel_invoice()` | `fuel_invoice(buyer, items, *, tipo_frase=None, escenario=None, frases=None)` | FACT con complemento combustible (IVA + PETROLEO). |
362
378
  | `credit_note()` | `credit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de crédito (NCRE). |
363
379
  | `debit_note()` | `debit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de débito (NDEB). |
364
380
  | `credit_note_total()` | `credit_note_total(auth_number, issue_datetime, reason="...", reference="")` | Nota de crédito total. Devuelve `dict`. |
365
381
  | `cancel()` | `cancel(auth_number, receiver_id, issue_datetime, reason="Anulación")` | Anula un DTE. Devuelve `dict`. |
366
382
  | `lookup_nit()` | `lookup_nit(nit)` | Consulta SAT. Devuelve `{"nit","name","address","city","district","state"}`. |
383
+ | `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
384
  | `get_dte()` | `get_dte(auth_number, fmt="JSON")` | Recupera el DTE (`"JSON"`, `"XML"`, `"HTML"`, `"PDF"`). |
368
385
  | `get_dte_info()` | `get_dte_info(auth_number)` | Metadatos del DTE. |
369
386
 
370
387
  ### Parámetros comunes
371
388
 
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"}`).
389
+ - **`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
390
  - **`items`**: lista de dicts con `description` (str, req), `price` (float/Decimal, req), `qty` (1), `type` (`"Servicio"`/`"Bien"`), `unit_of_measure` (`"UNI"`), `discount` (opcional).
374
391
  - **`origin`** (NCRE/NDEB): `{"auth_number": ..., "date": "YYYY-MM-DD", "series": ..., "number": ...}`.
375
392
 
@@ -410,7 +427,6 @@ python -m pytest tests/ -v
410
427
  | `DIGIFACT_USERNAME` | Usuario (p. ej. `FELUSER`) |
411
428
  | `DIGIFACT_PASSWORD` | Contraseña de la cuenta |
412
429
  | `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
430
 
415
431
  ## Manejo de errores
416
432
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "digifact-sdk"
7
- version = "2.3.0"
7
+ version = "3.0.0"
8
8
  description = "Python SDK for Digifact FEL Guatemala e-invoicing API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -52,6 +52,32 @@ def _client() -> "DigifactClient":
52
52
  return _CLIENT # type: ignore[return-value]
53
53
 
54
54
 
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
+ )
65
+
66
+ CANCEL_SAT_SKIP = (
67
+ "Upstream: SAT's anulación transmission is failing (Codigo 9019, 'Error al "
68
+ "transmitir anulación a SAT'). Certification is unaffected."
69
+ )
70
+
71
+
72
+ def _is_nabn_frase_rule(exc: Exception) -> bool:
73
+ return "FEL_RCP112" in str(exc)
74
+
75
+
76
+ def _is_sat_cancel_outage(exc: Exception) -> bool:
77
+ raw = getattr(exc, "raw", None) or {}
78
+ return str(raw.get("Codigo")) == "9019" or "9019" in str(exc)
79
+
80
+
55
81
  # ── Unit tests (no credentials required) ─────────────────────────────────────
56
82
 
57
83
  class TestTaxCalcUnit(unittest.TestCase):
@@ -290,7 +316,7 @@ class TestBuilderUnit(unittest.TestCase):
290
316
 
291
317
 
292
318
  class TestFuelFrases(unittest.TestCase):
293
- """Unit tests for frases[] / auto-fuel-subsidy-injection logic."""
319
+ """Unit tests for frases[] resolution on fuel invoices."""
294
320
 
295
321
  def _buyer(self):
296
322
  return {"TaxID": "CF", "Name": "CONSUMIDOR FINAL",
@@ -310,65 +336,46 @@ class TestFuelFrases(unittest.TestCase):
310
336
  i += 2
311
337
  return frases
312
338
 
313
- def test_non_fuel_invoice_no_subsidy_frases(self):
339
+ def test_non_fuel_invoice_has_base_frase_only(self):
314
340
  from digifact_sdk.builder import build_fact
315
341
  buyer = self._buyer()
316
342
  payload = build_fact("12345678", "TEST", "CALLE", buyer=buyer,
317
343
  items=[{"description": "X", "qty": 1, "price": 100.0}])
318
344
  frases = self._additionl_frases(payload)
319
345
  self.assertEqual(len(frases), 1, "Regular invoice must have only the base frase")
320
- self.assertNotIn(("9", "18"), frases)
321
- self.assertNotIn(("9", "19"), frases)
322
346
 
323
- def test_fuel_within_window_auto_inject(self):
324
- from digifact_sdk.builder import build_fact_combustible, resolve_fuel_frases
325
- # Use resolve_fuel_frases directly with a date known to be in window
326
- result = resolve_fuel_frases(None, "1", "1", "2026-06-01T10:00:00", auto_enabled=True)
347
+ def test_fuel_base_frase_only(self):
348
+ from digifact_sdk.builder import resolve_fuel_frases
349
+ result = resolve_fuel_frases(None, "1", "1")
327
350
  tf_es = [(f["tipo_frase"], f["escenario"]) for f in result]
328
- self.assertIn(("1", "1"), tf_es, "Base frase must be present")
329
- self.assertIn(("9", "18"), tf_es, "Escenario 18 must be auto-injected")
330
- self.assertIn(("9", "19"), tf_es, "Escenario 19 must be auto-injected")
331
- self.assertEqual(len(result), 3)
351
+ self.assertEqual(tf_es, [("1", "1")], "Nothing may be added to the base frase")
332
352
 
333
- def test_fuel_custom_frases_no_auto_inject(self):
353
+ def test_fuel_explicit_frases_replace_base(self):
334
354
  from digifact_sdk.builder import resolve_fuel_frases
335
355
  custom = [{"tipo_frase": "2", "escenario": "1"}]
336
- result = resolve_fuel_frases(custom, None, None, "2026-06-01T10:00:00", auto_enabled=True)
356
+ result = resolve_fuel_frases(custom, None, None)
337
357
  self.assertEqual(len(result), 1)
338
358
  self.assertEqual(result[0]["tipo_frase"], "2")
339
- self.assertNotIn(("9", "18"), [(f["tipo_frase"], f["escenario"]) for f in result])
340
359
 
341
- def test_fuel_auto_disabled_no_inject(self):
360
+ def test_empty_frases_raises(self):
342
361
  from digifact_sdk.builder import resolve_fuel_frases
343
- result = resolve_fuel_frases(None, "1", "1", "2026-06-01T10:00:00", auto_enabled=False)
344
- tf_es = [(f["tipo_frase"], f["escenario"]) for f in result]
345
- self.assertEqual(len(result), 1)
346
- self.assertNotIn(("9", "18"), tf_es)
347
- self.assertNotIn(("9", "19"), tf_es)
362
+ from digifact_sdk.exceptions import DigifactValidationError
363
+ with self.assertRaises(DigifactValidationError):
364
+ resolve_fuel_frases([], None, None)
348
365
 
349
366
  def test_deduplication(self):
350
367
  from digifact_sdk.builder import resolve_fuel_frases
351
368
  dupes = [{"tipo_frase": "9", "escenario": "18"},
352
369
  {"tipo_frase": "9", "escenario": "18"},
353
370
  {"tipo_frase": "9", "escenario": "19"}]
354
- result = resolve_fuel_frases(dupes, None, None, "2026-06-01T10:00:00", auto_enabled=True)
371
+ result = resolve_fuel_frases(dupes, None, None)
355
372
  self.assertEqual(len(result), 2)
356
373
 
357
- def test_outside_window_no_inject(self):
358
- from digifact_sdk.builder import resolve_fuel_frases
359
- # Date before window
360
- result_before = resolve_fuel_frases(None, "1", "1", "2026-04-26T10:00:00", auto_enabled=True)
361
- self.assertEqual(len(result_before), 1)
362
- # Date after window
363
- result_after = resolve_fuel_frases(None, "1", "1", "2026-07-27T10:00:00", auto_enabled=True)
364
- self.assertEqual(len(result_after), 1)
365
-
366
374
  def test_build_fact_combustible_seller_has_additionl_info(self):
367
375
  from digifact_sdk.builder import build_fact_combustible
368
376
  payload = build_fact_combustible(
369
377
  "12345678", "SELLER", "ADDR",
370
378
  buyer=self._buyer(), items=self._items(),
371
- auto_fuel_subsidy_frases=False,
372
379
  )
373
380
  ai = payload["Seller"].get("AdditionlInfo", [])
374
381
  self.assertTrue(len(ai) >= 2, "Seller.AdditionlInfo must be present for fuel invoice")
@@ -378,25 +385,33 @@ class TestFuelFrases(unittest.TestCase):
378
385
  self.assertEqual(ai[1]["Name"], "Escenario")
379
386
  self.assertEqual(ai[1]["Data"], "1")
380
387
 
381
- def test_build_fact_combustible_subsidy_frases_in_additionl_info(self):
388
+ def test_build_fact_combustible_sends_only_base_frase(self):
389
+ from digifact_sdk.builder import build_fact_combustible
390
+ payload = build_fact_combustible(
391
+ "12345678", "SELLER", "ADDR",
392
+ buyer=self._buyer(), items=self._items(),
393
+ tipo_frase="1", escenario="1",
394
+ )
395
+ pairs = self._additionl_frases(payload)
396
+ self.assertEqual(pairs, [("1", "1")], "Fuel invoices must not carry frases nobody asked for")
397
+
398
+ def test_build_fact_combustible_explicit_subsidy_frases(self):
399
+ """Emitters still dispatching subsidised stock pass 9/18 + 9/19 themselves."""
382
400
  from digifact_sdk.builder import build_fact_combustible
383
401
  payload = build_fact_combustible(
384
402
  "12345678", "SELLER", "ADDR",
385
403
  buyer=self._buyer(), items=self._items(),
386
- tipo_frase="1", escenario="1", auto_fuel_subsidy_frases=True,
404
+ frases=[{"tipo_frase": "1", "escenario": "1"},
405
+ {"tipo_frase": "9", "escenario": "18"},
406
+ {"tipo_frase": "9", "escenario": "19"}],
387
407
  )
388
408
  ai = payload["Seller"].get("AdditionlInfo", [])
389
409
  pairs = [(ai[i]["Value"], ai[i+1]["Value"]) for i in range(0, len(ai) - 1, 2)]
390
- tipo_frases = [tf for tf, _ in pairs]
391
- self.assertIn("1", tipo_frases, "TipoFrase=1 must be in AdditionlInfo")
392
- # Within subsidy window: 9/18 and 9/19 must also be present
393
- if len(pairs) > 1:
394
- self.assertIn(("9", "18"), pairs, "9/18 must be in AdditionlInfo when within subsidy window")
395
- self.assertIn(("9", "19"), pairs, "9/19 must be in AdditionlInfo when within subsidy window")
396
- # Data must be the 1-based frase group index so the API creates separate <dte:Frase> elements
397
- for idx, entry in enumerate(ai):
398
- expected_data = str(idx // 2 + 1)
399
- self.assertEqual(entry["Data"], expected_data, f"ai[{idx}]['Data'] must equal frase group index")
410
+ self.assertEqual(pairs, [("1", "1"), ("9", "18"), ("9", "19")])
411
+ # Data must be the 1-based frase group index so the API creates separate <dte:Frase> elements
412
+ for idx, entry in enumerate(ai):
413
+ expected_data = str(idx // 2 + 1)
414
+ self.assertEqual(entry["Data"], expected_data, f"ai[{idx}]['Data'] must equal frase group index")
400
415
 
401
416
  def test_mutual_exclusivity_raises(self):
402
417
  from digifact_sdk.builder import build_fact_combustible
@@ -551,11 +566,16 @@ class TestNDEBandNCRE(unittest.TestCase):
551
566
  @unittest.skipIf(SKIP, SKIP_REASON)
552
567
  class TestNABN(unittest.TestCase):
553
568
  def test_nabn(self):
554
- result = _client().invoice(
555
- "77454820",
556
- [{"description": "RETENEDOR BLANCO", "qty": 1, "price": 100.00, "type": "Bien"}],
557
- doc_type="NABN",
558
- )
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
559
579
  self.assertTrue(result.auth_number)
560
580
  print(f"\n NABN auth: {result.auth_number}")
561
581
 
@@ -628,12 +648,17 @@ class TestCancelAndNcredTotal(unittest.TestCase):
628
648
  def test_cancel(self):
629
649
  if not self._fact_cf:
630
650
  self.skipTest(getattr(self, "_skip_reason", "FACT CF not available"))
631
- result = _client().cancel(
632
- self._fact_cf.auth_number,
633
- receiver_id="CF",
634
- issue_datetime=self._fact_cf.issue_datetime,
635
- reason="Anulación de prueba automática",
636
- )
651
+ try:
652
+ result = _client().cancel(
653
+ self._fact_cf.auth_number,
654
+ receiver_id="CF",
655
+ issue_datetime=self._fact_cf.issue_datetime,
656
+ reason="Anulación de prueba automática",
657
+ )
658
+ except DigifactError as exc:
659
+ if _is_sat_cancel_outage(exc):
660
+ self.skipTest(CANCEL_SAT_SKIP)
661
+ raise
637
662
  self.assertIsInstance(result, dict)
638
663
  print(f"\n cancel Codigo: {result.get('Codigo')}")
639
664
 
@@ -669,10 +694,6 @@ class TestGetDte(unittest.TestCase):
669
694
  @unittest.skipIf(SKIP, SKIP_REASON)
670
695
  class TestFuelInvoice(unittest.TestCase):
671
696
  def test_fuel_invoice_cf_mixed_items(self):
672
- import base64, re as _re
673
- is_production = ENV == "production"
674
- # Test env does not support TipoFrase=9 (subsidio) — disable auto-injection there.
675
- # On production, the default (True) applies and subsidio frases are included automatically.
676
697
  result = _client().fuel_invoice(
677
698
  "CF",
678
699
  [
@@ -682,23 +703,10 @@ class TestFuelInvoice(unittest.TestCase):
682
703
  "petroleo_amount": 4.60, "petroleo_code": "2", "type": "Bien"},
683
704
  {"description": "FILTRO DE ACEITE", "qty": 1, "price": 45.00, "type": "Bien"},
684
705
  ],
685
- auto_fuel_subsidy_frases=is_production,
686
706
  )
687
707
  self.assertTrue(result.auth_number, "Expected auth_number")
688
708
  print(f"\n FACT Combustible auth: {result.auth_number}")
689
709
 
690
- if is_production:
691
- # Verify subsidio frases appear in the certified XML
692
- dte = _client().get_dte(result.auth_number, "XML")
693
- b64 = (dte.get("RESPONSE") or [{}])[0].get("ResponseData1") or dte.get("responseData1", "")
694
- self.assertTrue(b64, "ResponseData1 must be present")
695
- xml = base64.b64decode(b64).decode("utf-8", errors="replace")
696
- frases = _re.findall(r'TipoFrase="\d+" CodigoEscenario="\d+"', xml)
697
- print(f" Frases en XML: {', '.join(frases)}")
698
- self.assertIn('TipoFrase="9"', xml, "TipoFrase=9 must appear in certified XML")
699
- self.assertIn('CodigoEscenario="18"', xml, "CodigoEscenario=18 must appear in certified XML")
700
- self.assertIn('CodigoEscenario="19"', xml, "CodigoEscenario=19 must appear in certified XML")
701
-
702
710
 
703
711
  if __name__ == "__main__":
704
712
  unittest.main()
File without changes