digifact-sdk 3.1.0__tar.gz → 3.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {digifact_sdk-3.1.0 → digifact_sdk-3.2.0}/PKG-INFO +40 -3
- {digifact_sdk-3.1.0 → digifact_sdk-3.2.0}/README.md +39 -2
- {digifact_sdk-3.1.0 → digifact_sdk-3.2.0}/digifact_sdk/__init__.py +3 -1
- {digifact_sdk-3.1.0 → digifact_sdk-3.2.0}/digifact_sdk/builder.py +26 -0
- {digifact_sdk-3.1.0 → digifact_sdk-3.2.0}/digifact_sdk/client.py +209 -65
- {digifact_sdk-3.1.0 → digifact_sdk-3.2.0}/digifact_sdk/exceptions.py +18 -1
- {digifact_sdk-3.1.0 → digifact_sdk-3.2.0}/digifact_sdk.egg-info/PKG-INFO +40 -3
- {digifact_sdk-3.1.0 → digifact_sdk-3.2.0}/digifact_sdk.egg-info/SOURCES.txt +2 -1
- {digifact_sdk-3.1.0 → digifact_sdk-3.2.0}/pyproject.toml +1 -1
- digifact_sdk-3.2.0/tests/test_resilience.py +241 -0
- {digifact_sdk-3.1.0 → digifact_sdk-3.2.0}/digifact_sdk/tax.py +0 -0
- {digifact_sdk-3.1.0 → digifact_sdk-3.2.0}/digifact_sdk.egg-info/dependency_links.txt +0 -0
- {digifact_sdk-3.1.0 → digifact_sdk-3.2.0}/digifact_sdk.egg-info/requires.txt +0 -0
- {digifact_sdk-3.1.0 → digifact_sdk-3.2.0}/digifact_sdk.egg-info/top_level.txt +0 -0
- {digifact_sdk-3.1.0 → digifact_sdk-3.2.0}/setup.cfg +0 -0
- {digifact_sdk-3.1.0 → digifact_sdk-3.2.0}/tests/test_integration.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: digifact-sdk
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.2.0
|
|
4
4
|
Summary: Python SDK for Digifact FEL Guatemala e-invoicing API
|
|
5
5
|
Author-email: AALONZOLU <noreply@github.com>
|
|
6
6
|
License: MIT
|
|
@@ -394,7 +394,9 @@ del RTU en el portal de SAT.
|
|
|
394
394
|
|
|
395
395
|
## Referencia de métodos
|
|
396
396
|
|
|
397
|
-
Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `series`, `number`, `issue_datetime`, `raw`.
|
|
397
|
+
Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `series`, `number`, `issue_datetime`, `enrolled_timestamp`, `additional_info` (acuse de la SAT) y `raw`.
|
|
398
|
+
|
|
399
|
+
`invoice()`, `cca_invoice()`, `fuel_invoice()`, `credit_note()` y `debit_note()` aceptan además `internal_reference` y `validate_internal_reference` (ver [Emisión sin duplicados](#emisión-sin-duplicados-referencia-interna)).
|
|
398
400
|
|
|
399
401
|
| Método | Firma | Descripción |
|
|
400
402
|
|--------|-------|-------------|
|
|
@@ -409,6 +411,7 @@ Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `
|
|
|
409
411
|
| `lookup_cui()` | `lookup_cui(cui)` | Consulta SAT por CUI (DPI). Devuelve `{"cui","name","status"}`. El nombre viene como `"APELLIDOS, NOMBRES"`, tal como lo registra SAT. |
|
|
410
412
|
| `get_dte()` | `get_dte(auth_number, fmt="JSON")` | Recupera el DTE (`"JSON"`, `"XML"`, `"HTML"`, `"PDF"`). |
|
|
411
413
|
| `get_dte_info()` | `get_dte_info(auth_number)` | Metadatos del DTE. |
|
|
414
|
+
| `find_by_internal_reference()` | `find_by_internal_reference(reference, issued_on)` | DTE certificados con esa referencia interna ese día (`SHARED_GETDTEINFO_BY_INTERNALID`). Devuelve `list[dict]`, vacía si no hay. |
|
|
412
415
|
|
|
413
416
|
### Parámetros comunes
|
|
414
417
|
|
|
@@ -416,6 +419,39 @@ Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `
|
|
|
416
419
|
- **`items`**: lista de dicts con `description` (str, req), `price` (float/Decimal, req), `qty` (1), `type` (`"Servicio"`/`"Bien"`), `unit_of_measure` (`"UNI"`), `discount` (opcional).
|
|
417
420
|
- **`origin`** (NCRE/NDEB): `{"auth_number": ..., "date": "YYYY-MM-DD", "series": ..., "number": ...}`.
|
|
418
421
|
|
|
422
|
+
## Emisión sin duplicados (referencia interna)
|
|
423
|
+
|
|
424
|
+
Digifact guarda una referencia interna por DTE (el `Code` de la ADENDA). Con `validate_internal_reference=True`
|
|
425
|
+
rechaza un segundo DTE con la misma referencia, así que un envío que se quedó sin respuesta se puede repetir sin
|
|
426
|
+
emitir dos veces:
|
|
427
|
+
|
|
428
|
+
```python
|
|
429
|
+
import uuid
|
|
430
|
+
from digifact_sdk import DigifactDuplicateReferenceError
|
|
431
|
+
|
|
432
|
+
referencia = str(uuid.uuid4()) # un GUID; Digifact también acepta referencias más cortas
|
|
433
|
+
try:
|
|
434
|
+
result = client.invoice("CF", items, internal_reference=referencia, validate_internal_reference=True)
|
|
435
|
+
except DigifactDuplicateReferenceError:
|
|
436
|
+
# ya se había enviado: el DTE pudo quedar certificado
|
|
437
|
+
filas = client.find_by_internal_reference(referencia, issued_on="2026-11-03")
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
* Sin `internal_reference` el payload no cambia: se envía la referencia fija de siempre con `NO_VALIDAR`.
|
|
441
|
+
* NCRE, NDEB, FCAM, FESP y CCA reciben una ADENDA mínima con la referencia, junto a su complemento.
|
|
442
|
+
* `SHARED_GETDTEINFO_BY_INTERNALID` y el error `9022` no están en la documentación de Digifact para
|
|
443
|
+
Guatemala, pero se verificaron en el ambiente de pruebas el 3 de noviembre de 2026: el duplicado llega con
|
|
444
|
+
`code` 3000 y `[9022]` en la descripción, y la consulta encuentra el DTE sin prefijo en `DATA2`.
|
|
445
|
+
|
|
446
|
+
## Token y reintentos
|
|
447
|
+
|
|
448
|
+
* Si Digifact responde `401` (token vencido) y el cliente tiene `password`, el SDK inicia sesión otra vez y repite la
|
|
449
|
+
petición una sola vez. Con solo `token`, lanza `DigifactAuthError`.
|
|
450
|
+
* `client.token` devuelve el token vigente, para guardarlo en un caché compartido y pasarlo como `token=` a otros
|
|
451
|
+
procesos.
|
|
452
|
+
* Los errores HTTP traen `exc.code` (el estado HTTP) y `exc.retryable`: `True` para 5xx, `False` para 4xx (`400`,
|
|
453
|
+
`406` documento inválido, `415` tipo de contenido).
|
|
454
|
+
|
|
419
455
|
## Establecimiento (sucursal)
|
|
420
456
|
|
|
421
457
|
Cada NIT puede tener varios establecimientos registrados en el RTU. Configúralos al crear el cliente:
|
|
@@ -460,7 +496,8 @@ python -m pytest tests/ -v
|
|
|
460
496
|
from digifact_sdk import (
|
|
461
497
|
DigifactError, # base
|
|
462
498
|
DigifactAuthError, # fallo de autenticación
|
|
463
|
-
DigifactApiError, # error HTTP / de API
|
|
499
|
+
DigifactApiError, # error HTTP / de API (.retryable)
|
|
500
|
+
DigifactDuplicateReferenceError, # referencia interna ya enviada (9022)
|
|
464
501
|
DigifactValidationError, # rechazo de SAT
|
|
465
502
|
DigifactNitNotFoundError, # NIT no encontrado
|
|
466
503
|
)
|
|
@@ -370,7 +370,9 @@ del RTU en el portal de SAT.
|
|
|
370
370
|
|
|
371
371
|
## Referencia de métodos
|
|
372
372
|
|
|
373
|
-
Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `series`, `number`, `issue_datetime`, `raw`.
|
|
373
|
+
Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `series`, `number`, `issue_datetime`, `enrolled_timestamp`, `additional_info` (acuse de la SAT) y `raw`.
|
|
374
|
+
|
|
375
|
+
`invoice()`, `cca_invoice()`, `fuel_invoice()`, `credit_note()` y `debit_note()` aceptan además `internal_reference` y `validate_internal_reference` (ver [Emisión sin duplicados](#emisión-sin-duplicados-referencia-interna)).
|
|
374
376
|
|
|
375
377
|
| Método | Firma | Descripción |
|
|
376
378
|
|--------|-------|-------------|
|
|
@@ -385,6 +387,7 @@ Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `
|
|
|
385
387
|
| `lookup_cui()` | `lookup_cui(cui)` | Consulta SAT por CUI (DPI). Devuelve `{"cui","name","status"}`. El nombre viene como `"APELLIDOS, NOMBRES"`, tal como lo registra SAT. |
|
|
386
388
|
| `get_dte()` | `get_dte(auth_number, fmt="JSON")` | Recupera el DTE (`"JSON"`, `"XML"`, `"HTML"`, `"PDF"`). |
|
|
387
389
|
| `get_dte_info()` | `get_dte_info(auth_number)` | Metadatos del DTE. |
|
|
390
|
+
| `find_by_internal_reference()` | `find_by_internal_reference(reference, issued_on)` | DTE certificados con esa referencia interna ese día (`SHARED_GETDTEINFO_BY_INTERNALID`). Devuelve `list[dict]`, vacía si no hay. |
|
|
388
391
|
|
|
389
392
|
### Parámetros comunes
|
|
390
393
|
|
|
@@ -392,6 +395,39 @@ Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `
|
|
|
392
395
|
- **`items`**: lista de dicts con `description` (str, req), `price` (float/Decimal, req), `qty` (1), `type` (`"Servicio"`/`"Bien"`), `unit_of_measure` (`"UNI"`), `discount` (opcional).
|
|
393
396
|
- **`origin`** (NCRE/NDEB): `{"auth_number": ..., "date": "YYYY-MM-DD", "series": ..., "number": ...}`.
|
|
394
397
|
|
|
398
|
+
## Emisión sin duplicados (referencia interna)
|
|
399
|
+
|
|
400
|
+
Digifact guarda una referencia interna por DTE (el `Code` de la ADENDA). Con `validate_internal_reference=True`
|
|
401
|
+
rechaza un segundo DTE con la misma referencia, así que un envío que se quedó sin respuesta se puede repetir sin
|
|
402
|
+
emitir dos veces:
|
|
403
|
+
|
|
404
|
+
```python
|
|
405
|
+
import uuid
|
|
406
|
+
from digifact_sdk import DigifactDuplicateReferenceError
|
|
407
|
+
|
|
408
|
+
referencia = str(uuid.uuid4()) # un GUID; Digifact también acepta referencias más cortas
|
|
409
|
+
try:
|
|
410
|
+
result = client.invoice("CF", items, internal_reference=referencia, validate_internal_reference=True)
|
|
411
|
+
except DigifactDuplicateReferenceError:
|
|
412
|
+
# ya se había enviado: el DTE pudo quedar certificado
|
|
413
|
+
filas = client.find_by_internal_reference(referencia, issued_on="2026-11-03")
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
* Sin `internal_reference` el payload no cambia: se envía la referencia fija de siempre con `NO_VALIDAR`.
|
|
417
|
+
* NCRE, NDEB, FCAM, FESP y CCA reciben una ADENDA mínima con la referencia, junto a su complemento.
|
|
418
|
+
* `SHARED_GETDTEINFO_BY_INTERNALID` y el error `9022` no están en la documentación de Digifact para
|
|
419
|
+
Guatemala, pero se verificaron en el ambiente de pruebas el 3 de noviembre de 2026: el duplicado llega con
|
|
420
|
+
`code` 3000 y `[9022]` en la descripción, y la consulta encuentra el DTE sin prefijo en `DATA2`.
|
|
421
|
+
|
|
422
|
+
## Token y reintentos
|
|
423
|
+
|
|
424
|
+
* Si Digifact responde `401` (token vencido) y el cliente tiene `password`, el SDK inicia sesión otra vez y repite la
|
|
425
|
+
petición una sola vez. Con solo `token`, lanza `DigifactAuthError`.
|
|
426
|
+
* `client.token` devuelve el token vigente, para guardarlo en un caché compartido y pasarlo como `token=` a otros
|
|
427
|
+
procesos.
|
|
428
|
+
* Los errores HTTP traen `exc.code` (el estado HTTP) y `exc.retryable`: `True` para 5xx, `False` para 4xx (`400`,
|
|
429
|
+
`406` documento inválido, `415` tipo de contenido).
|
|
430
|
+
|
|
395
431
|
## Establecimiento (sucursal)
|
|
396
432
|
|
|
397
433
|
Cada NIT puede tener varios establecimientos registrados en el RTU. Configúralos al crear el cliente:
|
|
@@ -436,7 +472,8 @@ python -m pytest tests/ -v
|
|
|
436
472
|
from digifact_sdk import (
|
|
437
473
|
DigifactError, # base
|
|
438
474
|
DigifactAuthError, # fallo de autenticación
|
|
439
|
-
DigifactApiError, # error HTTP / de API
|
|
475
|
+
DigifactApiError, # error HTTP / de API (.retryable)
|
|
476
|
+
DigifactDuplicateReferenceError, # referencia interna ya enviada (9022)
|
|
440
477
|
DigifactValidationError, # rechazo de SAT
|
|
441
478
|
DigifactNitNotFoundError, # NIT no encontrado
|
|
442
479
|
)
|
|
@@ -11,18 +11,20 @@ from .client import DigifactClient, DteResult
|
|
|
11
11
|
from .exceptions import (
|
|
12
12
|
DigifactApiError,
|
|
13
13
|
DigifactAuthError,
|
|
14
|
+
DigifactDuplicateReferenceError,
|
|
14
15
|
DigifactError,
|
|
15
16
|
DigifactNitNotFoundError,
|
|
16
17
|
DigifactValidationError,
|
|
17
18
|
)
|
|
18
19
|
|
|
19
|
-
__version__ = "3.
|
|
20
|
+
__version__ = "3.2.0"
|
|
20
21
|
__all__ = [
|
|
21
22
|
"DigifactClient",
|
|
22
23
|
"DteResult",
|
|
23
24
|
"DigifactError",
|
|
24
25
|
"DigifactAuthError",
|
|
25
26
|
"DigifactApiError",
|
|
27
|
+
"DigifactDuplicateReferenceError",
|
|
26
28
|
"DigifactValidationError",
|
|
27
29
|
"DigifactNitNotFoundError",
|
|
28
30
|
"FuelExemption",
|
|
@@ -28,6 +28,32 @@ def _adenda_code(doc_type: str) -> str:
|
|
|
28
28
|
return ADENDA_CODE_ALT if doc_type in _ALT_ADENDA_TYPES else ADENDA_CODE_STD
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
VALIDATE_REFERENCE = "VALIDAR"
|
|
32
|
+
SKIP_REFERENCE_VALIDATION = "NO_VALIDAR"
|
|
33
|
+
_VALIDATION_FLAG = "VALIDAR_REFERENCIA_INTERNA"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def apply_internal_reference(payload: dict, reference: str, *, validate: bool) -> dict:
|
|
37
|
+
"""Set the Digifact internal reference (ADENDA ``Code``) of a built payload.
|
|
38
|
+
|
|
39
|
+
With ``validate=True`` Digifact rejects a second DTE with the same
|
|
40
|
+
reference (error 9022), which makes retries safe. DTE types whose builder
|
|
41
|
+
carries no ADENDA (NCRE, NDEB, FCAM, FESP, CCA) get a minimal one.
|
|
42
|
+
"""
|
|
43
|
+
flag = {"Name": _VALIDATION_FLAG, "Data": None, "Value": VALIDATE_REFERENCE if validate else SKIP_REFERENCE_VALIDATION}
|
|
44
|
+
document_info = payload.get("AdditionalDocumentInfo") or {}
|
|
45
|
+
entries = document_info.setdefault("AdditionalInfo", [])
|
|
46
|
+
adenda = next((entry for entry in entries if entry.get("Type") == "ADENDA"), None)
|
|
47
|
+
if adenda is None:
|
|
48
|
+
entries.append({"Code": reference, "Type": "ADENDA", "AditionalInfo": [flag]})
|
|
49
|
+
else:
|
|
50
|
+
adenda["Code"] = reference
|
|
51
|
+
others = [info for info in adenda.get("AditionalInfo") or [] if info.get("Name") != _VALIDATION_FLAG]
|
|
52
|
+
adenda["AditionalInfo"] = [flag, *others]
|
|
53
|
+
payload["AdditionalDocumentInfo"] = document_info
|
|
54
|
+
return payload
|
|
55
|
+
|
|
56
|
+
|
|
31
57
|
# ── Exención temporal de combustibles (Decreto 22-2026) ──────────────────────
|
|
32
58
|
FUEL_EXEMPT_SUPER = "18"
|
|
33
59
|
FUEL_EXEMPT_REGULAR_IMPORTER = "19"
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import re
|
|
5
5
|
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import date
|
|
6
7
|
from decimal import Decimal
|
|
7
8
|
from typing import Any
|
|
8
9
|
|
|
@@ -18,6 +19,7 @@ from .builder import (
|
|
|
18
19
|
build_nabn,
|
|
19
20
|
build_ncre,
|
|
20
21
|
build_ndeb,
|
|
22
|
+
apply_internal_reference,
|
|
21
23
|
build_rdon,
|
|
22
24
|
build_reci,
|
|
23
25
|
default_frase,
|
|
@@ -29,6 +31,7 @@ from .builder import (
|
|
|
29
31
|
from .exceptions import (
|
|
30
32
|
DigifactApiError,
|
|
31
33
|
DigifactAuthError,
|
|
34
|
+
DigifactDuplicateReferenceError,
|
|
32
35
|
DigifactError,
|
|
33
36
|
DigifactNitNotFoundError,
|
|
34
37
|
DigifactValidationError,
|
|
@@ -44,6 +47,11 @@ _BASE_URLS = {
|
|
|
44
47
|
# Digits in a Guatemalan CUI (DPI); a NIT never reaches this length.
|
|
45
48
|
CUI_LENGTH = 13
|
|
46
49
|
|
|
50
|
+
HTTP_UNAUTHORIZED = 401
|
|
51
|
+
DUPLICATE_REFERENCE_CODE = 9022
|
|
52
|
+
DUPLICATE_REFERENCE_GLOBAL_CODE = "BUS-003"
|
|
53
|
+
DUPLICATE_REFERENCE_MESSAGE = "ENVIADA A CERTIFICAR"
|
|
54
|
+
|
|
47
55
|
|
|
48
56
|
@dataclass
|
|
49
57
|
class DteResult:
|
|
@@ -54,6 +62,8 @@ class DteResult:
|
|
|
54
62
|
number: str
|
|
55
63
|
issue_datetime: str
|
|
56
64
|
raw: dict = field(default_factory=dict)
|
|
65
|
+
enrolled_timestamp: str = ""
|
|
66
|
+
additional_info: dict = field(default_factory=dict)
|
|
57
67
|
|
|
58
68
|
@property
|
|
59
69
|
def auth_number_upper(self) -> str:
|
|
@@ -182,6 +192,11 @@ class DigifactClient:
|
|
|
182
192
|
env = self.environment
|
|
183
193
|
return f"DigifactClient(taxid={self.taxid!r}, username={self.username!r}, environment={env!r})"
|
|
184
194
|
|
|
195
|
+
@property
|
|
196
|
+
def token(self) -> str:
|
|
197
|
+
"""Current bearer token ("" before the first login), to cache it across processes."""
|
|
198
|
+
return self._token
|
|
199
|
+
|
|
185
200
|
# ── Authentication ────────────────────────────────────────────────────────
|
|
186
201
|
|
|
187
202
|
def _login(self) -> str:
|
|
@@ -208,6 +223,22 @@ class DigifactClient:
|
|
|
208
223
|
def _headers(self) -> dict:
|
|
209
224
|
return {"Authorization": self._login(), "Content-Type": "application/json"}
|
|
210
225
|
|
|
226
|
+
def _send(self, verb: str, url: str, *, json_content: bool = True, **kwargs: Any) -> requests.Response:
|
|
227
|
+
"""Send an authenticated request, logging in again once if the token expired (401)."""
|
|
228
|
+
send = getattr(self._session, verb)
|
|
229
|
+
resp = send(url, headers=self._auth_headers(json_content), timeout=self.timeout, **kwargs)
|
|
230
|
+
if resp.status_code == HTTP_UNAUTHORIZED and self.password:
|
|
231
|
+
self._token = ""
|
|
232
|
+
resp = send(url, headers=self._auth_headers(json_content), timeout=self.timeout, **kwargs)
|
|
233
|
+
if resp.status_code == HTTP_UNAUTHORIZED:
|
|
234
|
+
raise DigifactAuthError(
|
|
235
|
+
"Digifact rejected the token (401)", code=HTTP_UNAUTHORIZED, raw=_try_json(resp)
|
|
236
|
+
)
|
|
237
|
+
return resp
|
|
238
|
+
|
|
239
|
+
def _auth_headers(self, json_content: bool) -> dict:
|
|
240
|
+
return self._headers() if json_content else {"Authorization": self._login()}
|
|
241
|
+
|
|
211
242
|
# ── Seller info ───────────────────────────────────────────────────────────
|
|
212
243
|
|
|
213
244
|
def _get_seller_info(self) -> tuple[str, str]:
|
|
@@ -302,23 +333,31 @@ class DigifactClient:
|
|
|
302
333
|
branch["Name"] = self.branch_name
|
|
303
334
|
return payload
|
|
304
335
|
|
|
305
|
-
def _certify(
|
|
336
|
+
def _certify(
|
|
337
|
+
self,
|
|
338
|
+
payload: dict,
|
|
339
|
+
*,
|
|
340
|
+
internal_reference: str | None = None,
|
|
341
|
+
validate_internal_reference: bool = False,
|
|
342
|
+
) -> dict:
|
|
306
343
|
payload = self._apply_branch_info(payload)
|
|
307
|
-
|
|
344
|
+
if internal_reference:
|
|
345
|
+
payload = apply_internal_reference(
|
|
346
|
+
payload, internal_reference, validate=validate_internal_reference
|
|
347
|
+
)
|
|
348
|
+
elif validate_internal_reference:
|
|
349
|
+
raise ValueError("validate_internal_reference requires internal_reference")
|
|
350
|
+
resp = self._send(
|
|
351
|
+
"post",
|
|
308
352
|
f"{self.base_url}/v2/transform/nuc_json",
|
|
309
353
|
params={
|
|
310
354
|
"TAXID": self.padded_taxid,
|
|
311
355
|
"FORMAT": "XML|HTML|PDF",
|
|
312
356
|
"USERNAME": self.username,
|
|
313
357
|
},
|
|
314
|
-
headers=self._headers(),
|
|
315
358
|
json=payload,
|
|
316
|
-
timeout=self.timeout,
|
|
317
359
|
)
|
|
318
|
-
|
|
319
|
-
resp.raise_for_status()
|
|
320
|
-
except requests.HTTPError as exc:
|
|
321
|
-
raise DigifactApiError(f"Certify HTTP error: {exc}", raw=_try_json(resp)) from exc
|
|
360
|
+
_raise_for_status(resp, "Certify")
|
|
322
361
|
data = resp.json()
|
|
323
362
|
return _check_response(data)
|
|
324
363
|
|
|
@@ -328,12 +367,15 @@ class DigifactClient:
|
|
|
328
367
|
number = str(data.get("serial") or data.get("Numero") or "")
|
|
329
368
|
ts_raw = data.get("issuedTimeStamp") or data.get("FechaEmision") or ""
|
|
330
369
|
issue_dt = ts_raw.replace("T", " ") if "T" in ts_raw else ts_raw
|
|
370
|
+
additional = data.get("additionalInfo")
|
|
331
371
|
return DteResult(
|
|
332
372
|
auth_number=auth,
|
|
333
373
|
series=series,
|
|
334
374
|
number=number,
|
|
335
375
|
issue_datetime=issue_dt,
|
|
336
376
|
raw=data,
|
|
377
|
+
enrolled_timestamp=data.get("enrolledTimeStamp") or "",
|
|
378
|
+
additional_info=additional if isinstance(additional, dict) else {},
|
|
337
379
|
)
|
|
338
380
|
|
|
339
381
|
# ── Public DTE methods ────────────────────────────────────────────────────
|
|
@@ -368,6 +410,8 @@ class DigifactClient:
|
|
|
368
410
|
tipo_personeria: str | None = None,
|
|
369
411
|
tipo_frase: str | None = None,
|
|
370
412
|
escenario: str | None = None,
|
|
413
|
+
internal_reference: str | None = None,
|
|
414
|
+
validate_internal_reference: bool = False,
|
|
371
415
|
) -> DteResult:
|
|
372
416
|
"""Emit a DTE invoice (FACT, FCAM, NABN, FESP, RDON, FPEQ, RECI, CCA).
|
|
373
417
|
|
|
@@ -387,6 +431,13 @@ class DigifactClient:
|
|
|
387
431
|
Observations text for ADENDA.
|
|
388
432
|
tipo_personeria:
|
|
389
433
|
Required for RDON.
|
|
434
|
+
internal_reference:
|
|
435
|
+
Digifact internal reference (ADENDA ``Code``). Digifact suggests a
|
|
436
|
+
GUID, but shorter references are accepted. Can be queried with :meth:`find_by_internal_reference`.
|
|
437
|
+
validate_internal_reference:
|
|
438
|
+
When True, Digifact rejects a second DTE with the same
|
|
439
|
+
``internal_reference`` (:class:`DigifactDuplicateReferenceError`), so a
|
|
440
|
+
timed-out request can be retried without issuing twice.
|
|
390
441
|
"""
|
|
391
442
|
seller_name, seller_address = self._get_seller_info()
|
|
392
443
|
buyer_dict = self._resolve_buyer(buyer)
|
|
@@ -493,7 +544,11 @@ class DigifactClient:
|
|
|
493
544
|
observaciones=observaciones,
|
|
494
545
|
)
|
|
495
546
|
|
|
496
|
-
data = self._certify(
|
|
547
|
+
data = self._certify(
|
|
548
|
+
payload,
|
|
549
|
+
internal_reference=internal_reference,
|
|
550
|
+
validate_internal_reference=validate_internal_reference,
|
|
551
|
+
)
|
|
497
552
|
return self._parse_result(data)
|
|
498
553
|
|
|
499
554
|
def cca_invoice(
|
|
@@ -504,8 +559,13 @@ class DigifactClient:
|
|
|
504
559
|
*,
|
|
505
560
|
tipo_frase: str | None = None,
|
|
506
561
|
escenario: str | None = None,
|
|
562
|
+
internal_reference: str | None = None,
|
|
563
|
+
validate_internal_reference: bool = False,
|
|
507
564
|
) -> DteResult:
|
|
508
|
-
"""Emit a CCA (Cobro por Cuenta Ajena) FACT+CCA complemento.
|
|
565
|
+
"""Emit a CCA (Cobro por Cuenta Ajena) FACT+CCA complemento.
|
|
566
|
+
|
|
567
|
+
``internal_reference`` and ``validate_internal_reference`` work as in :meth:`invoice`.
|
|
568
|
+
"""
|
|
509
569
|
seller_name, seller_address = self._get_seller_info()
|
|
510
570
|
buyer_dict = self._resolve_buyer(buyer)
|
|
511
571
|
if self.frases is not None and tipo_frase is None and escenario is None:
|
|
@@ -526,7 +586,11 @@ class DigifactClient:
|
|
|
526
586
|
escenario=es,
|
|
527
587
|
frases=eff_frases,
|
|
528
588
|
)
|
|
529
|
-
data = self._certify(
|
|
589
|
+
data = self._certify(
|
|
590
|
+
payload,
|
|
591
|
+
internal_reference=internal_reference,
|
|
592
|
+
validate_internal_reference=validate_internal_reference,
|
|
593
|
+
)
|
|
530
594
|
return self._parse_result(data)
|
|
531
595
|
|
|
532
596
|
def fuel_invoice(
|
|
@@ -537,6 +601,8 @@ class DigifactClient:
|
|
|
537
601
|
tipo_frase: str | None = None,
|
|
538
602
|
escenario: str | None = None,
|
|
539
603
|
frases: list[dict] | None = None,
|
|
604
|
+
internal_reference: str | None = None,
|
|
605
|
+
validate_internal_reference: bool = False,
|
|
540
606
|
) -> DteResult:
|
|
541
607
|
"""Emit a combustible (fuel) FACT invoice.
|
|
542
608
|
|
|
@@ -569,6 +635,13 @@ class DigifactClient:
|
|
|
569
635
|
frases=[{"tipo_frase": "1", "escenario": "1"},
|
|
570
636
|
{"tipo_frase": "9", "escenario": "18"},
|
|
571
637
|
{"tipo_frase": "9", "escenario": "19"}]
|
|
638
|
+
internal_reference:
|
|
639
|
+
Digifact internal reference (ADENDA ``Code``). Digifact suggests a
|
|
640
|
+
GUID, but shorter references are accepted. Can be queried with :meth:`find_by_internal_reference`.
|
|
641
|
+
validate_internal_reference:
|
|
642
|
+
When True, Digifact rejects a second DTE with the same
|
|
643
|
+
``internal_reference`` (:class:`DigifactDuplicateReferenceError`), so a
|
|
644
|
+
timed-out request can be retried without issuing twice.
|
|
572
645
|
|
|
573
646
|
Example::
|
|
574
647
|
|
|
@@ -612,7 +685,11 @@ class DigifactClient:
|
|
|
612
685
|
escenario=eff_es,
|
|
613
686
|
frases=eff_frases,
|
|
614
687
|
)
|
|
615
|
-
data = self._certify(
|
|
688
|
+
data = self._certify(
|
|
689
|
+
payload,
|
|
690
|
+
internal_reference=internal_reference,
|
|
691
|
+
validate_internal_reference=validate_internal_reference,
|
|
692
|
+
)
|
|
616
693
|
return self._parse_result(data)
|
|
617
694
|
|
|
618
695
|
def _apply_petroleo_rates(self, items: list[dict]) -> list[dict]:
|
|
@@ -644,6 +721,8 @@ class DigifactClient:
|
|
|
644
721
|
*,
|
|
645
722
|
tipo_frase: str | None = None,
|
|
646
723
|
escenario: str | None = None,
|
|
724
|
+
internal_reference: str | None = None,
|
|
725
|
+
validate_internal_reference: bool = False,
|
|
647
726
|
) -> DteResult:
|
|
648
727
|
"""Emit a NCRE (Nota de Crédito).
|
|
649
728
|
|
|
@@ -653,6 +732,13 @@ class DigifactClient:
|
|
|
653
732
|
{"auth_number": str, "date": "YYYY-MM-DD", "series": str, "number": str|int}
|
|
654
733
|
reason:
|
|
655
734
|
Reason for the credit note.
|
|
735
|
+
internal_reference:
|
|
736
|
+
Digifact internal reference (ADENDA ``Code``). Digifact suggests a
|
|
737
|
+
GUID, but shorter references are accepted. Can be queried with :meth:`find_by_internal_reference`.
|
|
738
|
+
validate_internal_reference:
|
|
739
|
+
When True, Digifact rejects a second DTE with the same
|
|
740
|
+
``internal_reference`` (:class:`DigifactDuplicateReferenceError`), so a
|
|
741
|
+
timed-out request can be retried without issuing twice.
|
|
656
742
|
"""
|
|
657
743
|
seller_name, seller_address = self._get_seller_info()
|
|
658
744
|
buyer_dict = self._resolve_buyer(buyer)
|
|
@@ -675,7 +761,11 @@ class DigifactClient:
|
|
|
675
761
|
escenario=es,
|
|
676
762
|
frases=eff_frases,
|
|
677
763
|
)
|
|
678
|
-
data = self._certify(
|
|
764
|
+
data = self._certify(
|
|
765
|
+
payload,
|
|
766
|
+
internal_reference=internal_reference,
|
|
767
|
+
validate_internal_reference=validate_internal_reference,
|
|
768
|
+
)
|
|
679
769
|
return self._parse_result(data)
|
|
680
770
|
|
|
681
771
|
def debit_note(
|
|
@@ -687,6 +777,8 @@ class DigifactClient:
|
|
|
687
777
|
*,
|
|
688
778
|
tipo_frase: str | None = None,
|
|
689
779
|
escenario: str | None = None,
|
|
780
|
+
internal_reference: str | None = None,
|
|
781
|
+
validate_internal_reference: bool = False,
|
|
690
782
|
) -> DteResult:
|
|
691
783
|
"""Emit a NDEB (Nota de Débito).
|
|
692
784
|
|
|
@@ -696,6 +788,13 @@ class DigifactClient:
|
|
|
696
788
|
{"auth_number": str, "date": "YYYY-MM-DD", "series": str, "number": str|int}
|
|
697
789
|
reason:
|
|
698
790
|
Reason for the debit note.
|
|
791
|
+
internal_reference:
|
|
792
|
+
Digifact internal reference (ADENDA ``Code``). Digifact suggests a
|
|
793
|
+
GUID, but shorter references are accepted. Can be queried with :meth:`find_by_internal_reference`.
|
|
794
|
+
validate_internal_reference:
|
|
795
|
+
When True, Digifact rejects a second DTE with the same
|
|
796
|
+
``internal_reference`` (:class:`DigifactDuplicateReferenceError`), so a
|
|
797
|
+
timed-out request can be retried without issuing twice.
|
|
699
798
|
"""
|
|
700
799
|
seller_name, seller_address = self._get_seller_info()
|
|
701
800
|
buyer_dict = self._resolve_buyer(buyer)
|
|
@@ -718,7 +817,11 @@ class DigifactClient:
|
|
|
718
817
|
escenario=es,
|
|
719
818
|
frases=eff_frases,
|
|
720
819
|
)
|
|
721
|
-
data = self._certify(
|
|
820
|
+
data = self._certify(
|
|
821
|
+
payload,
|
|
822
|
+
internal_reference=internal_reference,
|
|
823
|
+
validate_internal_reference=validate_internal_reference,
|
|
824
|
+
)
|
|
722
825
|
return self._parse_result(data)
|
|
723
826
|
|
|
724
827
|
def cancel(
|
|
@@ -735,9 +838,9 @@ class DigifactClient:
|
|
|
735
838
|
issue_datetime:
|
|
736
839
|
Format "YYYY-MM-DD HH:MM:SS"
|
|
737
840
|
"""
|
|
738
|
-
resp = self.
|
|
841
|
+
resp = self._send(
|
|
842
|
+
"post",
|
|
739
843
|
f"{self.base_url}/CancelFelGT",
|
|
740
|
-
headers=self._headers(),
|
|
741
844
|
json={
|
|
742
845
|
"Taxid": self.taxid,
|
|
743
846
|
"Autorizacion": auth_number,
|
|
@@ -746,12 +849,8 @@ class DigifactClient:
|
|
|
746
849
|
"MotivoAnulacion": reason,
|
|
747
850
|
"Username": self.username,
|
|
748
851
|
},
|
|
749
|
-
timeout=self.timeout,
|
|
750
852
|
)
|
|
751
|
-
|
|
752
|
-
resp.raise_for_status()
|
|
753
|
-
except requests.HTTPError as exc:
|
|
754
|
-
raise DigifactApiError(f"Cancel HTTP error: {exc}", raw=_try_json(resp)) from exc
|
|
853
|
+
_raise_for_status(resp, "Cancel")
|
|
755
854
|
return resp.json()
|
|
756
855
|
|
|
757
856
|
def credit_note_total(
|
|
@@ -768,9 +867,9 @@ class DigifactClient:
|
|
|
768
867
|
issue_datetime:
|
|
769
868
|
Format "YYYY-MM-DD HH:MM:SS"
|
|
770
869
|
"""
|
|
771
|
-
resp = self.
|
|
870
|
+
resp = self._send(
|
|
871
|
+
"post",
|
|
772
872
|
f"{self.base_url}/cert/ncredtotal",
|
|
773
|
-
headers=self._headers(),
|
|
774
873
|
json={
|
|
775
874
|
"Staxid": self.taxid,
|
|
776
875
|
"Authnumber": auth_number,
|
|
@@ -780,14 +879,8 @@ class DigifactClient:
|
|
|
780
879
|
"Formatos": "xml|html|pdf",
|
|
781
880
|
"Username": self.username,
|
|
782
881
|
},
|
|
783
|
-
timeout=self.timeout,
|
|
784
882
|
)
|
|
785
|
-
|
|
786
|
-
resp.raise_for_status()
|
|
787
|
-
except requests.HTTPError as exc:
|
|
788
|
-
raise DigifactApiError(
|
|
789
|
-
f"NcredTotal HTTP error: {exc}", raw=_try_json(resp)
|
|
790
|
-
) from exc
|
|
883
|
+
_raise_for_status(resp, "NcredTotal")
|
|
791
884
|
return resp.json()
|
|
792
885
|
|
|
793
886
|
# ── Query methods ─────────────────────────────────────────────────────────
|
|
@@ -802,8 +895,10 @@ class DigifactClient:
|
|
|
802
895
|
if digits in self._nit_cache:
|
|
803
896
|
return self._nit_cache[digits]
|
|
804
897
|
|
|
805
|
-
resp = self.
|
|
898
|
+
resp = self._send(
|
|
899
|
+
"get",
|
|
806
900
|
f"{self.base_url}/Shared",
|
|
901
|
+
json_content=False,
|
|
807
902
|
params={
|
|
808
903
|
"COUNTRY": "GT",
|
|
809
904
|
"TAXID": self.padded_taxid,
|
|
@@ -811,15 +906,8 @@ class DigifactClient:
|
|
|
811
906
|
"DATA2": f"NIT|{digits}",
|
|
812
907
|
"USERNAME": self.username,
|
|
813
908
|
},
|
|
814
|
-
headers={"Authorization": self._login()},
|
|
815
|
-
timeout=self.timeout,
|
|
816
909
|
)
|
|
817
|
-
|
|
818
|
-
resp.raise_for_status()
|
|
819
|
-
except requests.HTTPError as exc:
|
|
820
|
-
raise DigifactApiError(
|
|
821
|
-
f"NIT lookup HTTP error: {exc}", raw=_try_json(resp)
|
|
822
|
-
) from exc
|
|
910
|
+
_raise_for_status(resp, "NIT lookup")
|
|
823
911
|
|
|
824
912
|
data = resp.json()
|
|
825
913
|
normalized = _parse_nit_response(digits, data)
|
|
@@ -838,8 +926,10 @@ class DigifactClient:
|
|
|
838
926
|
if digits in self._cui_cache:
|
|
839
927
|
return self._cui_cache[digits]
|
|
840
928
|
|
|
841
|
-
resp = self.
|
|
929
|
+
resp = self._send(
|
|
930
|
+
"get",
|
|
842
931
|
f"{self.base_url}/Shared",
|
|
932
|
+
json_content=False,
|
|
843
933
|
params={
|
|
844
934
|
"COUNTRY": "GT",
|
|
845
935
|
"TAXID": self.padded_taxid,
|
|
@@ -847,15 +937,8 @@ class DigifactClient:
|
|
|
847
937
|
"DATA2": f"CUI|{digits}",
|
|
848
938
|
"USERNAME": self.username,
|
|
849
939
|
},
|
|
850
|
-
headers={"Authorization": self._login()},
|
|
851
|
-
timeout=self.timeout,
|
|
852
940
|
)
|
|
853
|
-
|
|
854
|
-
resp.raise_for_status()
|
|
855
|
-
except requests.HTTPError as exc:
|
|
856
|
-
raise DigifactApiError(
|
|
857
|
-
f"CUI lookup HTTP error: {exc}", raw=_try_json(resp)
|
|
858
|
-
) from exc
|
|
941
|
+
_raise_for_status(resp, "CUI lookup")
|
|
859
942
|
|
|
860
943
|
data = resp.json()
|
|
861
944
|
normalized = _parse_cui_response(digits, data)
|
|
@@ -866,8 +949,10 @@ class DigifactClient:
|
|
|
866
949
|
|
|
867
950
|
def get_dte_info(self, auth_number: str) -> dict:
|
|
868
951
|
"""Get DTE info via SHARED_GETDTEINFO."""
|
|
869
|
-
resp = self.
|
|
952
|
+
resp = self._send(
|
|
953
|
+
"get",
|
|
870
954
|
f"{self.base_url}/Shared",
|
|
955
|
+
json_content=False,
|
|
871
956
|
params={
|
|
872
957
|
"COUNTRY": "GT",
|
|
873
958
|
"TAXID": self.padded_taxid,
|
|
@@ -875,41 +960,92 @@ class DigifactClient:
|
|
|
875
960
|
"DATA2": f"AUTHNUMBER|{auth_number}",
|
|
876
961
|
"USERNAME": self.username,
|
|
877
962
|
},
|
|
878
|
-
headers={"Authorization": self._login()},
|
|
879
|
-
timeout=self.timeout,
|
|
880
963
|
)
|
|
964
|
+
_raise_for_status(resp, "GetDteInfo")
|
|
965
|
+
return resp.json()
|
|
966
|
+
|
|
967
|
+
def find_by_internal_reference(self, reference: str, issued_on: date | str) -> list[dict]:
|
|
968
|
+
"""Find the DTEs certified with an internal reference on a given day.
|
|
969
|
+
|
|
970
|
+
Uses ``SHARED_GETDTEINFO_BY_INTERNALID``. Returns the ``RESPONSE`` rows
|
|
971
|
+
(``GUID``, ``SERIE``, ``NUMERO``, ``ESTATUS``…), or an empty list when
|
|
972
|
+
Digifact has no DTE with that reference on that day. A reply that is not
|
|
973
|
+
JSON raises a retryable :class:`DigifactApiError` instead of looking empty.
|
|
974
|
+
|
|
975
|
+
Parameters
|
|
976
|
+
----------
|
|
977
|
+
issued_on:
|
|
978
|
+
Issue date, as a :class:`datetime.date` or "YYYY-MM-DD".
|
|
979
|
+
"""
|
|
980
|
+
day = issued_on.isoformat() if isinstance(issued_on, date) else issued_on
|
|
981
|
+
resp = self._send(
|
|
982
|
+
"get",
|
|
983
|
+
f"{self.base_url}/Shared",
|
|
984
|
+
json_content=False,
|
|
985
|
+
params={
|
|
986
|
+
"COUNTRY": "GT",
|
|
987
|
+
"TAXID": self.padded_taxid,
|
|
988
|
+
"DATA1": "SHARED_GETDTEINFO_BY_INTERNALID",
|
|
989
|
+
"DATA2": f"REFERENCIA_INTERNA|{reference}|ISSUEDDATE|{day}",
|
|
990
|
+
"USERNAME": self.username,
|
|
991
|
+
},
|
|
992
|
+
)
|
|
993
|
+
_raise_for_status(resp, "GetDteInfoByInternalId")
|
|
881
994
|
try:
|
|
882
|
-
resp.
|
|
883
|
-
except
|
|
995
|
+
data = resp.json()
|
|
996
|
+
except ValueError as exc:
|
|
884
997
|
raise DigifactApiError(
|
|
885
|
-
|
|
998
|
+
"GetDteInfoByInternalId returned a response that is not JSON",
|
|
999
|
+
code=resp.status_code,
|
|
1000
|
+
raw={"_text": resp.text},
|
|
1001
|
+
retryable=True,
|
|
886
1002
|
) from exc
|
|
887
|
-
|
|
1003
|
+
rows = data.get("RESPONSE") if isinstance(data, dict) else None
|
|
1004
|
+
return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else []
|
|
888
1005
|
|
|
889
1006
|
def get_dte(self, auth_number: str, fmt: str = "JSON") -> dict:
|
|
890
1007
|
"""Retrieve a DTE document via GET /GetDocument."""
|
|
891
|
-
resp = self.
|
|
1008
|
+
resp = self._send(
|
|
1009
|
+
"get",
|
|
892
1010
|
f"{self.base_url}/GetDocument",
|
|
1011
|
+
json_content=False,
|
|
893
1012
|
params={
|
|
894
1013
|
"AUTHNUMBER": auth_number,
|
|
895
1014
|
"TAXID": self.padded_taxid,
|
|
896
1015
|
"FORMAT": fmt,
|
|
897
1016
|
"USERNAME": self.username,
|
|
898
1017
|
},
|
|
899
|
-
headers={"Authorization": self._login()},
|
|
900
|
-
timeout=self.timeout,
|
|
901
1018
|
)
|
|
902
|
-
|
|
903
|
-
resp.raise_for_status()
|
|
904
|
-
except requests.HTTPError as exc:
|
|
905
|
-
raise DigifactApiError(
|
|
906
|
-
f"GetDocument HTTP error: {exc}", raw=_try_json(resp)
|
|
907
|
-
) from exc
|
|
1019
|
+
_raise_for_status(resp, "GetDocument")
|
|
908
1020
|
return resp.json()
|
|
909
1021
|
|
|
910
1022
|
|
|
911
1023
|
# ── Internal helpers ──────────────────────────────────────────────────────────
|
|
912
1024
|
|
|
1025
|
+
def _raise_for_status(resp: requests.Response, operation: str) -> None:
|
|
1026
|
+
try:
|
|
1027
|
+
resp.raise_for_status()
|
|
1028
|
+
except requests.HTTPError as exc:
|
|
1029
|
+
raise DigifactApiError(
|
|
1030
|
+
f"{operation} HTTP error: {exc}",
|
|
1031
|
+
code=resp.status_code,
|
|
1032
|
+
raw=_try_json(resp),
|
|
1033
|
+
retryable=resp.status_code >= 500,
|
|
1034
|
+
) from exc
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _is_duplicate_reference(data: dict, code: Any) -> bool:
|
|
1038
|
+
if str(code) == str(DUPLICATE_REFERENCE_CODE):
|
|
1039
|
+
return True
|
|
1040
|
+
global_code = str(data.get("codeError") or "").upper()
|
|
1041
|
+
message = str(data.get("description") or data.get("message") or "").upper()
|
|
1042
|
+
return (
|
|
1043
|
+
DUPLICATE_REFERENCE_GLOBAL_CODE in global_code
|
|
1044
|
+
or str(DUPLICATE_REFERENCE_CODE) in message
|
|
1045
|
+
or DUPLICATE_REFERENCE_MESSAGE in message
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
|
|
913
1049
|
def _try_json(resp: requests.Response) -> dict:
|
|
914
1050
|
try:
|
|
915
1051
|
return resp.json()
|
|
@@ -928,7 +1064,15 @@ def _check_response(data: dict) -> dict:
|
|
|
928
1064
|
# Unknown format — return as-is (let caller decide)
|
|
929
1065
|
return data
|
|
930
1066
|
|
|
931
|
-
|
|
1067
|
+
if _is_duplicate_reference(data, code):
|
|
1068
|
+
msg = data.get("description") or data.get("message") or str(data)
|
|
1069
|
+
raise DigifactDuplicateReferenceError(
|
|
1070
|
+
f"Internal reference already sent to certify: {msg}", code=DUPLICATE_REFERENCE_CODE, raw=data
|
|
1071
|
+
)
|
|
1072
|
+
try:
|
|
1073
|
+
code_int = int(code)
|
|
1074
|
+
except (TypeError, ValueError):
|
|
1075
|
+
raise DigifactApiError(f"API returned an unexpected code {code!r}: {data}", raw=data) from None
|
|
932
1076
|
if code_int == 1:
|
|
933
1077
|
return data
|
|
934
1078
|
if code_int == 0:
|
|
@@ -5,10 +5,18 @@ from __future__ import annotations
|
|
|
5
5
|
class DigifactError(Exception):
|
|
6
6
|
"""Base exception for all Digifact SDK errors."""
|
|
7
7
|
|
|
8
|
-
def __init__(
|
|
8
|
+
def __init__(
|
|
9
|
+
self,
|
|
10
|
+
message: str,
|
|
11
|
+
code: int | None = None,
|
|
12
|
+
raw: dict | None = None,
|
|
13
|
+
*,
|
|
14
|
+
retryable: bool = False,
|
|
15
|
+
) -> None:
|
|
9
16
|
super().__init__(message)
|
|
10
17
|
self.code = code
|
|
11
18
|
self.raw = raw or {}
|
|
19
|
+
self.retryable = retryable
|
|
12
20
|
|
|
13
21
|
def __repr__(self) -> str:
|
|
14
22
|
return f"DigifactError(message={str(self)!r}, code={self.code!r})"
|
|
@@ -22,6 +30,15 @@ class DigifactApiError(DigifactError):
|
|
|
22
30
|
"""API returned a non-success response."""
|
|
23
31
|
|
|
24
32
|
|
|
33
|
+
class DigifactDuplicateReferenceError(DigifactApiError):
|
|
34
|
+
"""The internal reference was already sent to certify (Digifact 9022 / BUS-003).
|
|
35
|
+
|
|
36
|
+
Raised only when the DTE was sent with ``validate_internal_reference=True``.
|
|
37
|
+
The original DTE may already be certified: look it up with
|
|
38
|
+
:meth:`DigifactClient.find_by_internal_reference` instead of issuing again.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
25
42
|
class DigifactValidationError(DigifactError):
|
|
26
43
|
"""Payload validation error (SAT or Digifact rejection)."""
|
|
27
44
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: digifact-sdk
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.2.0
|
|
4
4
|
Summary: Python SDK for Digifact FEL Guatemala e-invoicing API
|
|
5
5
|
Author-email: AALONZOLU <noreply@github.com>
|
|
6
6
|
License: MIT
|
|
@@ -394,7 +394,9 @@ del RTU en el portal de SAT.
|
|
|
394
394
|
|
|
395
395
|
## Referencia de métodos
|
|
396
396
|
|
|
397
|
-
Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `series`, `number`, `issue_datetime`, `raw`.
|
|
397
|
+
Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `series`, `number`, `issue_datetime`, `enrolled_timestamp`, `additional_info` (acuse de la SAT) y `raw`.
|
|
398
|
+
|
|
399
|
+
`invoice()`, `cca_invoice()`, `fuel_invoice()`, `credit_note()` y `debit_note()` aceptan además `internal_reference` y `validate_internal_reference` (ver [Emisión sin duplicados](#emisión-sin-duplicados-referencia-interna)).
|
|
398
400
|
|
|
399
401
|
| Método | Firma | Descripción |
|
|
400
402
|
|--------|-------|-------------|
|
|
@@ -409,6 +411,7 @@ Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `
|
|
|
409
411
|
| `lookup_cui()` | `lookup_cui(cui)` | Consulta SAT por CUI (DPI). Devuelve `{"cui","name","status"}`. El nombre viene como `"APELLIDOS, NOMBRES"`, tal como lo registra SAT. |
|
|
410
412
|
| `get_dte()` | `get_dte(auth_number, fmt="JSON")` | Recupera el DTE (`"JSON"`, `"XML"`, `"HTML"`, `"PDF"`). |
|
|
411
413
|
| `get_dte_info()` | `get_dte_info(auth_number)` | Metadatos del DTE. |
|
|
414
|
+
| `find_by_internal_reference()` | `find_by_internal_reference(reference, issued_on)` | DTE certificados con esa referencia interna ese día (`SHARED_GETDTEINFO_BY_INTERNALID`). Devuelve `list[dict]`, vacía si no hay. |
|
|
412
415
|
|
|
413
416
|
### Parámetros comunes
|
|
414
417
|
|
|
@@ -416,6 +419,39 @@ Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `
|
|
|
416
419
|
- **`items`**: lista de dicts con `description` (str, req), `price` (float/Decimal, req), `qty` (1), `type` (`"Servicio"`/`"Bien"`), `unit_of_measure` (`"UNI"`), `discount` (opcional).
|
|
417
420
|
- **`origin`** (NCRE/NDEB): `{"auth_number": ..., "date": "YYYY-MM-DD", "series": ..., "number": ...}`.
|
|
418
421
|
|
|
422
|
+
## Emisión sin duplicados (referencia interna)
|
|
423
|
+
|
|
424
|
+
Digifact guarda una referencia interna por DTE (el `Code` de la ADENDA). Con `validate_internal_reference=True`
|
|
425
|
+
rechaza un segundo DTE con la misma referencia, así que un envío que se quedó sin respuesta se puede repetir sin
|
|
426
|
+
emitir dos veces:
|
|
427
|
+
|
|
428
|
+
```python
|
|
429
|
+
import uuid
|
|
430
|
+
from digifact_sdk import DigifactDuplicateReferenceError
|
|
431
|
+
|
|
432
|
+
referencia = str(uuid.uuid4()) # un GUID; Digifact también acepta referencias más cortas
|
|
433
|
+
try:
|
|
434
|
+
result = client.invoice("CF", items, internal_reference=referencia, validate_internal_reference=True)
|
|
435
|
+
except DigifactDuplicateReferenceError:
|
|
436
|
+
# ya se había enviado: el DTE pudo quedar certificado
|
|
437
|
+
filas = client.find_by_internal_reference(referencia, issued_on="2026-11-03")
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
* Sin `internal_reference` el payload no cambia: se envía la referencia fija de siempre con `NO_VALIDAR`.
|
|
441
|
+
* NCRE, NDEB, FCAM, FESP y CCA reciben una ADENDA mínima con la referencia, junto a su complemento.
|
|
442
|
+
* `SHARED_GETDTEINFO_BY_INTERNALID` y el error `9022` no están en la documentación de Digifact para
|
|
443
|
+
Guatemala, pero se verificaron en el ambiente de pruebas el 3 de noviembre de 2026: el duplicado llega con
|
|
444
|
+
`code` 3000 y `[9022]` en la descripción, y la consulta encuentra el DTE sin prefijo en `DATA2`.
|
|
445
|
+
|
|
446
|
+
## Token y reintentos
|
|
447
|
+
|
|
448
|
+
* Si Digifact responde `401` (token vencido) y el cliente tiene `password`, el SDK inicia sesión otra vez y repite la
|
|
449
|
+
petición una sola vez. Con solo `token`, lanza `DigifactAuthError`.
|
|
450
|
+
* `client.token` devuelve el token vigente, para guardarlo en un caché compartido y pasarlo como `token=` a otros
|
|
451
|
+
procesos.
|
|
452
|
+
* Los errores HTTP traen `exc.code` (el estado HTTP) y `exc.retryable`: `True` para 5xx, `False` para 4xx (`400`,
|
|
453
|
+
`406` documento inválido, `415` tipo de contenido).
|
|
454
|
+
|
|
419
455
|
## Establecimiento (sucursal)
|
|
420
456
|
|
|
421
457
|
Cada NIT puede tener varios establecimientos registrados en el RTU. Configúralos al crear el cliente:
|
|
@@ -460,7 +496,8 @@ python -m pytest tests/ -v
|
|
|
460
496
|
from digifact_sdk import (
|
|
461
497
|
DigifactError, # base
|
|
462
498
|
DigifactAuthError, # fallo de autenticación
|
|
463
|
-
DigifactApiError, # error HTTP / de API
|
|
499
|
+
DigifactApiError, # error HTTP / de API (.retryable)
|
|
500
|
+
DigifactDuplicateReferenceError, # referencia interna ya enviada (9022)
|
|
464
501
|
DigifactValidationError, # rechazo de SAT
|
|
465
502
|
DigifactNitNotFoundError, # NIT no encontrado
|
|
466
503
|
)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Unit tests (no credentials) for internal references, token renewal and error classification."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import unittest
|
|
6
|
+
from collections import defaultdict, deque
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from digifact_sdk import DigifactApiError, DigifactAuthError, DigifactClient, DigifactDuplicateReferenceError
|
|
11
|
+
from digifact_sdk.builder import ADENDA_CODE_STD
|
|
12
|
+
|
|
13
|
+
REFERENCE = "0F8FAD5B-D9CB-469F-A165-70867728950E"
|
|
14
|
+
ITEMS = [{"description": "Servicio", "qty": 1, "price": 100}]
|
|
15
|
+
ORIGIN = {"auth_number": "AAAABBBB-1111-2222-3333-444455556666", "date": "2026-10-01", "series": "AAAABBBB", "number": "1"}
|
|
16
|
+
CERTIFIED = {
|
|
17
|
+
"code": 1,
|
|
18
|
+
"authNumber": "CCCCDDDD-1111-2222-3333-444455556666",
|
|
19
|
+
"batch": "CCCCDDDD",
|
|
20
|
+
"serial": 123,
|
|
21
|
+
"issuedTimeStamp": "2026-11-03T10:00:00",
|
|
22
|
+
"enrolledTimeStamp": "2026-11-03T10:00:02",
|
|
23
|
+
"additionalInfo": {"acuseReciboSAT": "ACEPTADO"},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def response(status: int, body: dict | str) -> requests.Response:
|
|
28
|
+
resp = requests.Response()
|
|
29
|
+
resp.status_code = status
|
|
30
|
+
resp._content = (body if isinstance(body, str) else json.dumps(body)).encode()
|
|
31
|
+
resp.headers["Content-Type"] = "application/json"
|
|
32
|
+
resp.url = "https://testnucgt.digifact.com/api"
|
|
33
|
+
return resp
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FakeSession:
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
self.routes: dict[tuple[str, str], deque[requests.Response]] = defaultdict(deque)
|
|
39
|
+
self.calls: list[tuple[str, str, dict]] = []
|
|
40
|
+
|
|
41
|
+
def on(self, verb: str, path: str, *responses: requests.Response) -> FakeSession:
|
|
42
|
+
self.routes[(verb, path)].extend(responses)
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
def calls_to(self, verb: str, path: str) -> list[dict]:
|
|
46
|
+
return [kwargs for called_verb, url, kwargs in self.calls if called_verb == verb and url.endswith(path)]
|
|
47
|
+
|
|
48
|
+
def post(self, url: str, **kwargs) -> requests.Response:
|
|
49
|
+
return self._respond("post", url, kwargs)
|
|
50
|
+
|
|
51
|
+
def get(self, url: str, **kwargs) -> requests.Response:
|
|
52
|
+
return self._respond("get", url, kwargs)
|
|
53
|
+
|
|
54
|
+
def _respond(self, verb: str, url: str, kwargs: dict) -> requests.Response:
|
|
55
|
+
self.calls.append((verb, url, kwargs))
|
|
56
|
+
for (route_verb, path), queue in self.routes.items():
|
|
57
|
+
if route_verb == verb and url.endswith(path):
|
|
58
|
+
return queue.popleft() if len(queue) > 1 else queue[0]
|
|
59
|
+
raise AssertionError(f"Unexpected {verb.upper()} {url}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def a_client(session: FakeSession, *, password: str = "secreto", token: str = "token-vigente") -> DigifactClient:
|
|
63
|
+
return DigifactClient(
|
|
64
|
+
taxid="12345678",
|
|
65
|
+
username="FELUSER",
|
|
66
|
+
password=password,
|
|
67
|
+
token=token,
|
|
68
|
+
seller_name="EMPRESA DEMO, SOCIEDAD ANONIMA",
|
|
69
|
+
seller_address="CIUDAD",
|
|
70
|
+
session=session,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def adenda_of(payload: dict) -> list[dict]:
|
|
75
|
+
return [entry for entry in payload["AdditionalDocumentInfo"]["AdditionalInfo"] if entry["Type"] == "ADENDA"]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def reference_flag(adenda: dict) -> str:
|
|
79
|
+
return next(info["Value"] for info in adenda["AditionalInfo"] if info["Name"] == "VALIDAR_REFERENCIA_INTERNA")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TestInternalReferenceUnit(unittest.TestCase):
|
|
83
|
+
def setUp(self) -> None:
|
|
84
|
+
self.session = FakeSession().on("post", "/v2/transform/nuc_json", response(200, CERTIFIED))
|
|
85
|
+
self.client = a_client(self.session)
|
|
86
|
+
|
|
87
|
+
def sent_payload(self) -> dict:
|
|
88
|
+
return self.session.calls_to("post", "/v2/transform/nuc_json")[-1]["json"]
|
|
89
|
+
|
|
90
|
+
def test_invoice_carries_the_reference_with_validation(self):
|
|
91
|
+
self.client.invoice("CF", ITEMS, internal_reference=REFERENCE, validate_internal_reference=True)
|
|
92
|
+
|
|
93
|
+
[adenda] = adenda_of(self.sent_payload())
|
|
94
|
+
self.assertEqual(adenda["Code"], REFERENCE)
|
|
95
|
+
self.assertEqual(reference_flag(adenda), "VALIDAR")
|
|
96
|
+
self.assertIn("AditionalData", adenda)
|
|
97
|
+
|
|
98
|
+
def test_reference_without_validation_is_only_stored(self):
|
|
99
|
+
self.client.invoice("CF", ITEMS, internal_reference=REFERENCE)
|
|
100
|
+
|
|
101
|
+
[adenda] = adenda_of(self.sent_payload())
|
|
102
|
+
self.assertEqual(adenda["Code"], REFERENCE)
|
|
103
|
+
self.assertEqual(reference_flag(adenda), "NO_VALIDAR")
|
|
104
|
+
|
|
105
|
+
def test_without_reference_the_payload_is_unchanged(self):
|
|
106
|
+
self.client.invoice("CF", ITEMS)
|
|
107
|
+
|
|
108
|
+
[adenda] = adenda_of(self.sent_payload())
|
|
109
|
+
self.assertEqual(adenda["Code"], ADENDA_CODE_STD)
|
|
110
|
+
self.assertEqual(reference_flag(adenda), "NO_VALIDAR")
|
|
111
|
+
|
|
112
|
+
def test_credit_note_gets_an_adenda_next_to_its_complement(self):
|
|
113
|
+
self.client.credit_note("CF", ITEMS, ORIGIN, "Devolución", internal_reference=REFERENCE,
|
|
114
|
+
validate_internal_reference=True)
|
|
115
|
+
|
|
116
|
+
entries = self.sent_payload()["AdditionalDocumentInfo"]["AdditionalInfo"]
|
|
117
|
+
self.assertEqual([entry["Type"] for entry in entries], ["COMPLEMENTO", "ADENDA"])
|
|
118
|
+
self.assertEqual(entries[1]["Code"], REFERENCE)
|
|
119
|
+
self.assertEqual(reference_flag(entries[1]), "VALIDAR")
|
|
120
|
+
|
|
121
|
+
def test_validation_needs_a_reference(self):
|
|
122
|
+
with self.assertRaises(ValueError):
|
|
123
|
+
self.client.invoice("CF", ITEMS, validate_internal_reference=True)
|
|
124
|
+
|
|
125
|
+
def test_result_exposes_enrollment_and_additional_info(self):
|
|
126
|
+
result = self.client.invoice("CF", ITEMS)
|
|
127
|
+
|
|
128
|
+
self.assertEqual(result.enrolled_timestamp, "2026-11-03T10:00:02")
|
|
129
|
+
self.assertEqual(result.additional_info, {"acuseReciboSAT": "ACEPTADO"})
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class TestDuplicateReferenceUnit(unittest.TestCase):
|
|
133
|
+
def certify_answering(self, body: dict) -> None:
|
|
134
|
+
session = FakeSession().on("post", "/v2/transform/nuc_json", response(200, body))
|
|
135
|
+
a_client(session).invoice("CF", ITEMS, internal_reference=REFERENCE, validate_internal_reference=True)
|
|
136
|
+
|
|
137
|
+
def test_code_9022_is_a_duplicate_reference(self):
|
|
138
|
+
with self.assertRaises(DigifactDuplicateReferenceError) as raised:
|
|
139
|
+
self.certify_answering({"code": 9022, "description": "Referencia interna duplicada"})
|
|
140
|
+
self.assertFalse(raised.exception.retryable)
|
|
141
|
+
|
|
142
|
+
def test_rejection_message_about_an_already_sent_reference_is_a_duplicate(self):
|
|
143
|
+
with self.assertRaises(DigifactDuplicateReferenceError):
|
|
144
|
+
self.certify_answering({"code": 0, "description": "La referencia interna ya ha sido enviada a certificar"})
|
|
145
|
+
|
|
146
|
+
def test_global_error_code_bus_003_is_a_duplicate(self):
|
|
147
|
+
with self.assertRaises(DigifactDuplicateReferenceError):
|
|
148
|
+
self.certify_answering({"code": 0, "codeError": "BUS-003", "description": "Rechazado"})
|
|
149
|
+
|
|
150
|
+
def test_the_response_digifact_actually_sends_is_a_duplicate(self):
|
|
151
|
+
with self.assertRaises(DigifactDuplicateReferenceError):
|
|
152
|
+
self.certify_answering({"code": 3000, "message": "La referencia INTERNA, ya ha sido enviada a certificar",
|
|
153
|
+
"description": "[9022] - El documento bajo la referencia 0F8FAD5B-D9CB-469F-A165-70867728950E ya ha sido registrado bajo el UUID CCCCDDDD-1111-2222-3333-444455556666"})
|
|
154
|
+
|
|
155
|
+
def test_non_numeric_code_is_an_api_error(self):
|
|
156
|
+
with self.assertRaises(DigifactApiError):
|
|
157
|
+
self.certify_answering({"code": "Otro", "description": "?"})
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class TestTokenRenewalUnit(unittest.TestCase):
|
|
161
|
+
def test_expired_token_logs_in_again_and_retries_once(self):
|
|
162
|
+
session = (
|
|
163
|
+
FakeSession()
|
|
164
|
+
.on("post", "/v2/transform/nuc_json", response(401, {}), response(200, CERTIFIED))
|
|
165
|
+
.on("post", "/login/get_token", response(200, {"Token": "token-nuevo"}))
|
|
166
|
+
)
|
|
167
|
+
client = a_client(session, token="token-vencido")
|
|
168
|
+
|
|
169
|
+
result = client.invoice("CF", ITEMS)
|
|
170
|
+
|
|
171
|
+
certify_calls = session.calls_to("post", "/v2/transform/nuc_json")
|
|
172
|
+
self.assertEqual(result.auth_number, CERTIFIED["authNumber"])
|
|
173
|
+
self.assertEqual([call["headers"]["Authorization"] for call in certify_calls], ["token-vencido", "token-nuevo"])
|
|
174
|
+
self.assertEqual(client.token, "token-nuevo")
|
|
175
|
+
|
|
176
|
+
def test_token_only_client_reports_the_expired_token(self):
|
|
177
|
+
session = FakeSession().on("get", "/Shared", response(401, {}))
|
|
178
|
+
client = a_client(session, password="", token="token-vencido")
|
|
179
|
+
|
|
180
|
+
with self.assertRaises(DigifactAuthError):
|
|
181
|
+
client.get_dte_info("CCCCDDDD-1111-2222-3333-444455556666")
|
|
182
|
+
self.assertEqual(session.calls_to("post", "/login/get_token"), [])
|
|
183
|
+
|
|
184
|
+
def test_still_unauthorized_after_login_is_an_auth_error(self):
|
|
185
|
+
session = (
|
|
186
|
+
FakeSession()
|
|
187
|
+
.on("post", "/v2/transform/nuc_json", response(401, {}))
|
|
188
|
+
.on("post", "/login/get_token", response(200, {"Token": "token-nuevo"}))
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
with self.assertRaises(DigifactAuthError):
|
|
192
|
+
a_client(session).invoice("CF", ITEMS)
|
|
193
|
+
self.assertEqual(len(session.calls_to("post", "/v2/transform/nuc_json")), 2)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class TestErrorClassificationUnit(unittest.TestCase):
|
|
197
|
+
def certify_with_status(self, status: int) -> DigifactApiError:
|
|
198
|
+
session = FakeSession().on("post", "/v2/transform/nuc_json", response(status, {"message": "error"}))
|
|
199
|
+
with self.assertRaises(DigifactApiError) as raised:
|
|
200
|
+
a_client(session).invoice("CF", ITEMS)
|
|
201
|
+
return raised.exception
|
|
202
|
+
|
|
203
|
+
def test_server_errors_are_retryable(self):
|
|
204
|
+
error = self.certify_with_status(503)
|
|
205
|
+
|
|
206
|
+
self.assertTrue(error.retryable)
|
|
207
|
+
self.assertEqual(error.code, 503)
|
|
208
|
+
|
|
209
|
+
def test_invalid_documents_are_not_retryable(self):
|
|
210
|
+
for status in (400, 406, 415):
|
|
211
|
+
with self.subTest(status=status):
|
|
212
|
+
self.assertFalse(self.certify_with_status(status).retryable)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class TestFindByInternalReferenceUnit(unittest.TestCase):
|
|
216
|
+
def test_queries_shared_by_internal_reference_and_day(self):
|
|
217
|
+
rows = [{"GUID": "CCCCDDDD-1111-2222-3333-444455556666", "SERIE": "CCCCDDDD", "NUMERO": "123"}]
|
|
218
|
+
session = FakeSession().on("get", "/Shared", response(200, {"RESPONSE": rows}))
|
|
219
|
+
|
|
220
|
+
found = a_client(session).find_by_internal_reference(REFERENCE, "2026-11-03")
|
|
221
|
+
|
|
222
|
+
params = session.calls_to("get", "/Shared")[-1]["params"]
|
|
223
|
+
self.assertEqual(found, rows)
|
|
224
|
+
self.assertEqual(params["DATA1"], "SHARED_GETDTEINFO_BY_INTERNALID")
|
|
225
|
+
self.assertEqual(params["DATA2"], f"REFERENCIA_INTERNA|{REFERENCE}|ISSUEDDATE|2026-11-03")
|
|
226
|
+
|
|
227
|
+
def test_no_dte_with_that_reference_is_an_empty_list(self):
|
|
228
|
+
session = FakeSession().on("get", "/Shared", response(200, {"Respuesta": 0, "RESPONSE": []}))
|
|
229
|
+
|
|
230
|
+
self.assertEqual(a_client(session).find_by_internal_reference(REFERENCE, "2026-11-03"), [])
|
|
231
|
+
|
|
232
|
+
def test_a_reply_that_is_not_json_never_looks_like_an_absent_dte(self):
|
|
233
|
+
session = FakeSession().on("get", "/Shared", response(200, "<html>Mantenimiento</html>"))
|
|
234
|
+
|
|
235
|
+
with self.assertRaises(DigifactApiError) as raised:
|
|
236
|
+
a_client(session).find_by_internal_reference(REFERENCE, "2026-11-03")
|
|
237
|
+
self.assertTrue(raised.exception.retryable)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
if __name__ == "__main__":
|
|
241
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|