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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: digifact-sdk
3
- Version: 3.1.0
3
+ Version: 3.2.0
4
4
  Summary: Python SDK for Digifact FEL Guatemala e-invoicing API
5
5
  Author-email: AALONZOLU <noreply@github.com>
6
6
  License: MIT
@@ -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.1.0"
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(self, payload: dict) -> dict:
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
- resp = self._session.post(
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
- try:
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(payload)
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(payload)
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(payload)
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(payload)
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(payload)
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._session.post(
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
- try:
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._session.post(
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
- try:
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._session.get(
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
- try:
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._session.get(
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
- try:
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._session.get(
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.raise_for_status()
883
- except requests.HTTPError as exc:
995
+ data = resp.json()
996
+ except ValueError as exc:
884
997
  raise DigifactApiError(
885
- f"GetDteInfo HTTP error: {exc}", raw=_try_json(resp)
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
- return resp.json()
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._session.get(
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
- try:
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
- code_int = int(code)
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__(self, message: str, code: int | None = None, raw: dict | None = None) -> None:
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.1.0
3
+ Version: 3.2.0
4
4
  Summary: Python SDK for Digifact FEL Guatemala e-invoicing API
5
5
  Author-email: AALONZOLU <noreply@github.com>
6
6
  License: MIT
@@ -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
  )
@@ -10,4 +10,5 @@ digifact_sdk.egg-info/SOURCES.txt
10
10
  digifact_sdk.egg-info/dependency_links.txt
11
11
  digifact_sdk.egg-info/requires.txt
12
12
  digifact_sdk.egg-info/top_level.txt
13
- tests/test_integration.py
13
+ tests/test_integration.py
14
+ tests/test_resilience.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "digifact-sdk"
7
- version = "3.1.0"
7
+ version = "3.2.0"
8
8
  description = "Python SDK for Digifact FEL Guatemala e-invoicing API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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