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.
- {digifact_sdk-2.3.0 → digifact_sdk-3.0.0}/PKG-INFO +39 -23
- {digifact_sdk-2.3.0 → digifact_sdk-3.0.0}/README.md +38 -22
- {digifact_sdk-2.3.0 → digifact_sdk-3.0.0}/digifact_sdk/__init__.py +1 -1
- {digifact_sdk-2.3.0 → digifact_sdk-3.0.0}/digifact_sdk/builder.py +3 -20
- {digifact_sdk-2.3.0 → digifact_sdk-3.0.0}/digifact_sdk/client.py +110 -29
- {digifact_sdk-2.3.0 → digifact_sdk-3.0.0}/digifact_sdk.egg-info/PKG-INFO +39 -23
- {digifact_sdk-2.3.0 → digifact_sdk-3.0.0}/pyproject.toml +1 -1
- {digifact_sdk-2.3.0 → digifact_sdk-3.0.0}/tests/test_integration.py +80 -72
- {digifact_sdk-2.3.0 → digifact_sdk-3.0.0}/digifact_sdk/exceptions.py +0 -0
- {digifact_sdk-2.3.0 → digifact_sdk-3.0.0}/digifact_sdk/tax.py +0 -0
- {digifact_sdk-2.3.0 → digifact_sdk-3.0.0}/digifact_sdk.egg-info/SOURCES.txt +0 -0
- {digifact_sdk-2.3.0 → digifact_sdk-3.0.0}/digifact_sdk.egg-info/dependency_links.txt +0 -0
- {digifact_sdk-2.3.0 → digifact_sdk-3.0.0}/digifact_sdk.egg-info/requires.txt +0 -0
- {digifact_sdk-2.3.0 → digifact_sdk-3.0.0}/digifact_sdk.egg-info/top_level.txt +0 -0
- {digifact_sdk-2.3.0 → digifact_sdk-3.0.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: digifact-sdk
|
|
3
|
-
Version:
|
|
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
|
|
278
|
+
### Subsidio combustible
|
|
264
279
|
|
|
265
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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 (
|
|
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
|
|
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"
|
|
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
|
|
254
|
+
### Subsidio combustible
|
|
240
255
|
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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 (
|
|
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
|
|
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"
|
|
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
|
|
|
@@ -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
|
-
# ──
|
|
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
|
|
1204
|
+
resolved_frases = resolve_fuel_frases(frases, tf, es)
|
|
1222
1205
|
|
|
1223
|
-
# All frases
|
|
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"
|
|
236
|
-
- "77454820"
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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``.
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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:
|
|
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
|
|
278
|
+
### Subsidio combustible
|
|
264
279
|
|
|
265
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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 (
|
|
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
|
|
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"
|
|
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
|
|
|
@@ -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[]
|
|
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
|
|
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
|
|
324
|
-
from digifact_sdk.builder import
|
|
325
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
360
|
+
def test_empty_frases_raises(self):
|
|
342
361
|
from digifact_sdk.builder import resolve_fuel_frases
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
self.
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|