digifact-sdk 2.2.3__tar.gz → 3.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: digifact-sdk
3
- Version: 2.2.3
3
+ Version: 3.0.0
4
4
  Summary: Python SDK for Digifact FEL Guatemala e-invoicing API
5
5
  Author-email: AALONZOLU <noreply@github.com>
6
6
  License: MIT
@@ -35,29 +35,32 @@ pip install digifact-sdk
35
35
  O desde el código fuente:
36
36
 
37
37
  ```bash
38
- pip install -e sdk/python/
38
+ pip install -e python/
39
39
  ```
40
40
 
41
41
  ## Configuración del cliente (`DigifactClient(...)`)
42
42
 
43
+ Ordenados de más usados a menos usados.
44
+
43
45
  | Parámetro | Tipo | Por defecto | Descripción |
44
46
  |-----------|------|-------------|-------------|
45
47
  | `taxid` | `str` | **requerido** | NIT del emisor. Acepta dígitos o con separadores (`"12345678"`, `"1234567-8"`); los no-dígitos se eliminan. Se rellena internamente a 12 caracteres. |
46
48
  | `username` | `str` | **requerido** | Usuario corto de Digifact (la parte después de `GT.<NIT>.`, p. ej. `"FELUSER"`). |
47
49
  | `password` | `str` | `""` | Contraseña de la cuenta. **Requerido** si no se provee `token`. |
48
- | `environment` | `str` | `"test"` | `"test"` o `"production"`. |
49
50
  | `token` | `str` | `""` | Bearer token preobtenido. Si se provee, se omite el login. |
50
- | `seller_name` | `str` | `""` | Nombre comercial del emisor. Si está vacío, se consulta en SAT vía `lookup_nit()`. |
51
+ | `environment` | `str` | `"test"` | `"test"` o `"production"`. |
52
+ | `seller_name` | `str` | `""` | Nombre del emisor. Para NIT individual es el nombre de la persona; para S.A. / S.E. es la razón social de la entidad. Si está vacío, se consulta en SAT vía `lookup_nit()`. |
51
53
  | `seller_address` | `str` | `""` | Dirección del emisor. Si está vacía, se consulta en SAT. |
52
- | `afiliacion_iva` | `str` | `"GEN"` | Afiliación IVA del RTU: `"GEN"`, `"PEQ"` o `"EXE"`. |
53
- | `tipo_personeria` | `str` | `"1"` | Código de `TipoPersoneria` del RTU (usado en RDON). |
54
54
  | `branch_code` | `str` | `"1"` | **Código del establecimiento** del RTU. Un NIT puede tener varios establecimientos (1, 2, 3…). Se escribe en `Seller.BranchInfo.Code`. |
55
- | `branch_name` | `str` | `"ESTABLECIMIENTO PRINCIPAL"` | Nombre comercial del establecimiento. Se escribe en `Seller.BranchInfo.Name`. |
56
- | `tipo_frase` | `str \| None` | `None` | Sobreescritura global de `TipoFrase`. Ver [frases](#configuración-de-frases-tipofrase--codigoescenario). |
57
- | `escenario` | `str \| None` | `None` | Sobreescritura global de `CodigoEscenario`. |
55
+ | `branch_name` | `str` | `"ESTABLECIMIENTO PRINCIPAL"` | **Nombre comercial** de la sucursal — el mismo que aparece en la patente de comercio. Se escribe en `Seller.BranchInfo.Name`. |
56
+ | `afiliacion_iva` | `str` | `"GEN"` | Afiliación IVA del RTU: `"GEN"`, `"PEQ"` o `"EXE"`. |
57
+ | `tipo_frase` | `str \| None` | `None` | Sobreescritura global de `TipoFrase` (legacy). **Mutuamente exclusivo con `frases`**. Ver [frases](#configuración-de-frases-tipofrase--codigoescenario). |
58
+ | `escenario` | `str \| None` | `None` | Sobreescritura global de `CodigoEscenario` (legacy). **Mutuamente exclusivo con `frases`**. |
59
+ | `frases` | `list[dict] \| None` | `None` | Lista de frases `{"tipo_frase": ..., "escenario": ...}`. Reemplaza a `tipo_frase`/`escenario`. **Mutuamente exclusivo** con ellos. Ver [frases](#configuración-de-frases-tipofrase--codigoescenario). |
60
+ | `petroleo_rates` | `dict[str, float] \| None` | `None` | Mapa código PETROLEO → tarifa por unidad. Usado sólo por `fuel_invoice()` (gasolineras). |
58
61
  | `timeout` | `int` | `120` | Timeout HTTP en segundos. |
59
62
  | `session` | `requests.Session \| None` | `None` | Sesión HTTP personalizada (útil para tests). |
60
- | `petroleo_rates` | `dict[str, float] \| None` | `None` | Mapa código PETROLEO → tarifa por unidad. Usado por `fuel_invoice()`. |
63
+ | `tipo_personeria` | `str` | `"1"` | Código de `TipoPersoneria` del RTU. **Sólo aplica a RDON** (Recibo por Donación); ignóralo en los demás documentos. |
61
64
 
62
65
  ## Inicio rápido
63
66
 
@@ -87,7 +90,19 @@ result = client.invoice(
87
90
  ]
88
91
  )
89
92
 
90
- # FACT a receptor con CUI
93
+ # FACT a receptor con CUI (DPI) — el nombre se consulta en SAT automáticamente
94
+ result = client.invoice(
95
+ buyer={"taxid": "1234567890123", "type": "CUI"},
96
+ items=[{"description": "Producto", "qty": 2, "price": 50.00}]
97
+ )
98
+
99
+ # Equivalente: un string de 13 dígitos se resuelve como CUI
100
+ result = client.invoice(
101
+ buyer="1234567890123",
102
+ items=[{"description": "Producto", "qty": 2, "price": 50.00}]
103
+ )
104
+
105
+ # Con nombre explícito no se consulta SAT
91
106
  result = client.invoice(
92
107
  buyer={"taxid": "3730617490101", "type": "CUI", "name": "Juan Pérez"},
93
108
  items=[{"description": "Producto", "qty": 2, "price": 50.00}]
@@ -156,6 +171,10 @@ result = client.credit_note_total(
156
171
  info = client.lookup_nit("12345678")
157
172
  print(info["name"])
158
173
 
174
+ # Consulta de CUI (DPI) — SAT devuelve el nombre como "APELLIDOS, NOMBRES"
175
+ info = client.lookup_cui("1234567890123")
176
+ print(info["name"]) # "PEREZ LOPEZ, JUAN CARLOS"
177
+
159
178
  # Obtener DTE
160
179
  doc = client.get_dte("XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX")
161
180
  ```
@@ -256,13 +275,37 @@ result = client.fuel_invoice(
256
275
  | `petroleo_amount` | `float\|Decimal` | — | Impuesto PETROLEO por unidad; omitir para ítems sólo-IVA |
257
276
  | `petroleo_code` | `str` | `"1"` | `"1"`=SUPER, `"2"`=REGULAR, `"4"`=DIESEL. Obligatorio cuando se omite `petroleo_amount` y `petroleo_rates` está configurado; lanza `DigifactValidationError` si el código no está en el diccionario de tarifas. |
258
277
 
278
+ ### Subsidio combustible
279
+
280
+ El subsidio a la gasolina y al diésel **finalizó el jueves 2 de julio de 2026 a las 24:00**, antes de lo
281
+ previsto: el presupuesto de Q2 mil millones (Decreto 11-2026, reglamentado por el Acuerdo Gubernativo
282
+ 64-2026) se agotó por la demanda. Por eso el SDK **nunca** envía las frases `TipoFrase=9, Escenario=18` ni
283
+ `TipoFrase=9, Escenario=19` por su cuenta — no hay fecha de corte que valga para todos.
284
+
285
+ Las estaciones con inventario adquirido bajo el subsidio deben mantener el precio rebajado hasta agotar ese
286
+ producto, sujeto a verificación. Mientras te quede ese inventario, manda las frases explícitamente con
287
+ `frases`; al agotarlo, deja de mandarlas:
288
+
289
+ ```python
290
+ result = client.fuel_invoice("CF", items, frases=[
291
+ {"tipo_frase": "1", "escenario": "1"}, # frase base
292
+ {"tipo_frase": "9", "escenario": "18"},
293
+ {"tipo_frase": "9", "escenario": "19"},
294
+ ])
295
+ ```
296
+
297
+ > **Nota:** la leyenda del subsidio en la representación gráfica la genera Digifact a partir de las frases
298
+ > del XML certificado, no este SDK. Al dejar de mandar las frases, deja de imprimirse sola.
299
+
300
+ > **Nota:** `frases` y `tipo_frase`/`escenario` son **mutuamente exclusivos** — pasarlos juntos lanza `DigifactValidationError`.
301
+
259
302
  ## Configuración de frases (TipoFrase / CodigoEscenario)
260
303
 
261
304
  Todo DTE (excepto FESP) debe llevar un par `TipoFrase` + `CodigoEscenario`. El
262
305
  SDK elige valores por defecto adecuados, por lo que **no** hace falta
263
306
  configurar nada en el caso común.
264
307
 
265
- **Orden de precedencia:** argumentos por llamada → globales del constructor (`tipo_frase` / `escenario`) → tabla de valores por defecto.
308
+ **Orden de precedencia:** argumentos por llamada → globales del constructor → tabla de valores por defecto.
266
309
 
267
310
  **Tabla de valores por defecto:**
268
311
 
@@ -277,10 +320,30 @@ configurar nada en el caso común.
277
320
  | FACT / FCAM / NCRE / NDEB | PEQ | `2` | `1` | |
278
321
  | FACT / FCAM / NCRE / NDEB | EXE | `4` | `1` | Exento |
279
322
 
280
- Tanto `tipo_frase` como `escenario` se pueden sobreescribir de forma
281
- independiente — por llamada (como argumentos con nombre) o globalmente al
282
- construir el cliente. Cuando se omiten, cada uno cae al global del
283
- constructor y luego a la tabla de valores por defecto.
323
+ ### Nueva API: `frases[]` (múltiples frases)
324
+
325
+ Usa `frases` cuando necesitas enviar **más de un par** TipoFrase/Escenario (p. ej. listados en resoluciones SAT). Es **mutuamente exclusivo** con `tipo_frase`/`escenario`.
326
+
327
+ ```python
328
+ # Lista explícita de frases (reemplaza a tipo_frase/escenario)
329
+ client.fuel_invoice("CF", items, frases=[
330
+ {"tipo_frase": "1", "escenario": "1"},
331
+ {"tipo_frase": "9", "escenario": "18"},
332
+ {"tipo_frase": "9", "escenario": "19"},
333
+ ])
334
+
335
+ # Globalmente en el constructor
336
+ client = DigifactClient(
337
+ taxid="12345678", username="FELUSER", password="...",
338
+ frases=[{"tipo_frase": "1", "escenario": "2"}], # ISR régimen opcional simplificado
339
+ )
340
+ ```
341
+
342
+ > **Regla:** `frases` y `tipo_frase`/`escenario` son **mutuamente exclusivos** en cada nivel (por llamada y en el constructor). Combinarlos lanza `DigifactValidationError`.
343
+
344
+ ### API legacy: `tipo_frase` / `escenario`
345
+
346
+ Ambos parámetros siguen funcionando exactamente como antes para integraciones existentes.
284
347
 
285
348
  ```python
286
349
  # Sobreescritura por llamada (uno o ambos)
@@ -311,18 +374,19 @@ Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `
311
374
  |--------|-------|-------------|
312
375
  | `invoice()` | `invoice(buyer, items, *, doc_type="FACT", payment_terms=None, amount_str="", observaciones="-", tipo_personeria=None, tipo_frase=None, escenario=None)` | Emite FACT, FCAM, FESP, FPEQ, NABN, RDON o RECI según `doc_type`. |
313
376
  | `cca_invoice()` | `cca_invoice(buyer, items, cobros, *, tipo_frase=None, escenario=None)` | FACT con complemento CCA. |
314
- | `fuel_invoice()` | `fuel_invoice(buyer, items, *, tipo_frase=None, escenario=None)` | FACT con complemento combustible (IVA + PETROLEO). |
377
+ | `fuel_invoice()` | `fuel_invoice(buyer, items, *, tipo_frase=None, escenario=None, frases=None)` | FACT con complemento combustible (IVA + PETROLEO). |
315
378
  | `credit_note()` | `credit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de crédito (NCRE). |
316
379
  | `debit_note()` | `debit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de débito (NDEB). |
317
380
  | `credit_note_total()` | `credit_note_total(auth_number, issue_datetime, reason="...", reference="")` | Nota de crédito total. Devuelve `dict`. |
318
381
  | `cancel()` | `cancel(auth_number, receiver_id, issue_datetime, reason="Anulación")` | Anula un DTE. Devuelve `dict`. |
319
382
  | `lookup_nit()` | `lookup_nit(nit)` | Consulta SAT. Devuelve `{"nit","name","address","city","district","state"}`. |
383
+ | `lookup_cui()` | `lookup_cui(cui)` | Consulta SAT por CUI (DPI). Devuelve `{"cui","name","status"}`. El nombre viene como `"APELLIDOS, NOMBRES"`, tal como lo registra SAT. |
320
384
  | `get_dte()` | `get_dte(auth_number, fmt="JSON")` | Recupera el DTE (`"JSON"`, `"XML"`, `"HTML"`, `"PDF"`). |
321
385
  | `get_dte_info()` | `get_dte_info(auth_number)` | Metadatos del DTE. |
322
386
 
323
387
  ### Parámetros comunes
324
388
 
325
- - **`buyer`**: `"CF"` (consumidor final), un NIT string (`"12345678"` — se consulta el nombre), un dict CUI (`{"type":"CUI","taxid":...,"name":...}`) o un dict NIT explícito (`{"taxid","name","address","city","district","state","country","email"}`).
389
+ - **`buyer`**: `"CF"` (consumidor final), un NIT string (`"12345678"` — se consulta el nombre), un CUI string de 13 dígitos (`"1234567890123"` — se consulta el nombre), un dict CUI (`{"type":"CUI","taxid":...}` — se consulta el nombre; con `"name"` explícito no se consulta) o un dict NIT explícito (`{"taxid","name","address","city","district","state","country","email"}`).
326
390
  - **`items`**: lista de dicts con `description` (str, req), `price` (float/Decimal, req), `qty` (1), `type` (`"Servicio"`/`"Bien"`), `unit_of_measure` (`"UNI"`), `discount` (opcional).
327
391
  - **`origin`** (NCRE/NDEB): `{"auth_number": ..., "date": "YYYY-MM-DD", "series": ..., "number": ...}`.
328
392
 
@@ -362,6 +426,7 @@ python -m pytest tests/ -v
362
426
  | `DIGIFACT_TAXID` | NIT del emisor (p. ej. `12345678`) |
363
427
  | `DIGIFACT_USERNAME` | Usuario (p. ej. `FELUSER`) |
364
428
  | `DIGIFACT_PASSWORD` | Contraseña de la cuenta |
429
+ | `DIGIFACT_ENVIRONMENT` | `test` (default) o `production` |
365
430
 
366
431
  ## Manejo de errores
367
432
 
@@ -11,29 +11,32 @@ pip install digifact-sdk
11
11
  O desde el código fuente:
12
12
 
13
13
  ```bash
14
- pip install -e sdk/python/
14
+ pip install -e python/
15
15
  ```
16
16
 
17
17
  ## Configuración del cliente (`DigifactClient(...)`)
18
18
 
19
+ Ordenados de más usados a menos usados.
20
+
19
21
  | Parámetro | Tipo | Por defecto | Descripción |
20
22
  |-----------|------|-------------|-------------|
21
23
  | `taxid` | `str` | **requerido** | NIT del emisor. Acepta dígitos o con separadores (`"12345678"`, `"1234567-8"`); los no-dígitos se eliminan. Se rellena internamente a 12 caracteres. |
22
24
  | `username` | `str` | **requerido** | Usuario corto de Digifact (la parte después de `GT.<NIT>.`, p. ej. `"FELUSER"`). |
23
25
  | `password` | `str` | `""` | Contraseña de la cuenta. **Requerido** si no se provee `token`. |
24
- | `environment` | `str` | `"test"` | `"test"` o `"production"`. |
25
26
  | `token` | `str` | `""` | Bearer token preobtenido. Si se provee, se omite el login. |
26
- | `seller_name` | `str` | `""` | Nombre comercial del emisor. Si está vacío, se consulta en SAT vía `lookup_nit()`. |
27
+ | `environment` | `str` | `"test"` | `"test"` o `"production"`. |
28
+ | `seller_name` | `str` | `""` | Nombre del emisor. Para NIT individual es el nombre de la persona; para S.A. / S.E. es la razón social de la entidad. Si está vacío, se consulta en SAT vía `lookup_nit()`. |
27
29
  | `seller_address` | `str` | `""` | Dirección del emisor. Si está vacía, se consulta en SAT. |
28
- | `afiliacion_iva` | `str` | `"GEN"` | Afiliación IVA del RTU: `"GEN"`, `"PEQ"` o `"EXE"`. |
29
- | `tipo_personeria` | `str` | `"1"` | Código de `TipoPersoneria` del RTU (usado en RDON). |
30
30
  | `branch_code` | `str` | `"1"` | **Código del establecimiento** del RTU. Un NIT puede tener varios establecimientos (1, 2, 3…). Se escribe en `Seller.BranchInfo.Code`. |
31
- | `branch_name` | `str` | `"ESTABLECIMIENTO PRINCIPAL"` | Nombre comercial del establecimiento. Se escribe en `Seller.BranchInfo.Name`. |
32
- | `tipo_frase` | `str \| None` | `None` | Sobreescritura global de `TipoFrase`. Ver [frases](#configuración-de-frases-tipofrase--codigoescenario). |
33
- | `escenario` | `str \| None` | `None` | Sobreescritura global de `CodigoEscenario`. |
31
+ | `branch_name` | `str` | `"ESTABLECIMIENTO PRINCIPAL"` | **Nombre comercial** de la sucursal — el mismo que aparece en la patente de comercio. Se escribe en `Seller.BranchInfo.Name`. |
32
+ | `afiliacion_iva` | `str` | `"GEN"` | Afiliación IVA del RTU: `"GEN"`, `"PEQ"` o `"EXE"`. |
33
+ | `tipo_frase` | `str \| None` | `None` | Sobreescritura global de `TipoFrase` (legacy). **Mutuamente exclusivo con `frases`**. Ver [frases](#configuración-de-frases-tipofrase--codigoescenario). |
34
+ | `escenario` | `str \| None` | `None` | Sobreescritura global de `CodigoEscenario` (legacy). **Mutuamente exclusivo con `frases`**. |
35
+ | `frases` | `list[dict] \| None` | `None` | Lista de frases `{"tipo_frase": ..., "escenario": ...}`. Reemplaza a `tipo_frase`/`escenario`. **Mutuamente exclusivo** con ellos. Ver [frases](#configuración-de-frases-tipofrase--codigoescenario). |
36
+ | `petroleo_rates` | `dict[str, float] \| None` | `None` | Mapa código PETROLEO → tarifa por unidad. Usado sólo por `fuel_invoice()` (gasolineras). |
34
37
  | `timeout` | `int` | `120` | Timeout HTTP en segundos. |
35
38
  | `session` | `requests.Session \| None` | `None` | Sesión HTTP personalizada (útil para tests). |
36
- | `petroleo_rates` | `dict[str, float] \| None` | `None` | Mapa código PETROLEO → tarifa por unidad. Usado por `fuel_invoice()`. |
39
+ | `tipo_personeria` | `str` | `"1"` | Código de `TipoPersoneria` del RTU. **Sólo aplica a RDON** (Recibo por Donación); ignóralo en los demás documentos. |
37
40
 
38
41
  ## Inicio rápido
39
42
 
@@ -63,7 +66,19 @@ result = client.invoice(
63
66
  ]
64
67
  )
65
68
 
66
- # FACT a receptor con CUI
69
+ # FACT a receptor con CUI (DPI) — el nombre se consulta en SAT automáticamente
70
+ result = client.invoice(
71
+ buyer={"taxid": "1234567890123", "type": "CUI"},
72
+ items=[{"description": "Producto", "qty": 2, "price": 50.00}]
73
+ )
74
+
75
+ # Equivalente: un string de 13 dígitos se resuelve como CUI
76
+ result = client.invoice(
77
+ buyer="1234567890123",
78
+ items=[{"description": "Producto", "qty": 2, "price": 50.00}]
79
+ )
80
+
81
+ # Con nombre explícito no se consulta SAT
67
82
  result = client.invoice(
68
83
  buyer={"taxid": "3730617490101", "type": "CUI", "name": "Juan Pérez"},
69
84
  items=[{"description": "Producto", "qty": 2, "price": 50.00}]
@@ -132,6 +147,10 @@ result = client.credit_note_total(
132
147
  info = client.lookup_nit("12345678")
133
148
  print(info["name"])
134
149
 
150
+ # Consulta de CUI (DPI) — SAT devuelve el nombre como "APELLIDOS, NOMBRES"
151
+ info = client.lookup_cui("1234567890123")
152
+ print(info["name"]) # "PEREZ LOPEZ, JUAN CARLOS"
153
+
135
154
  # Obtener DTE
136
155
  doc = client.get_dte("XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX")
137
156
  ```
@@ -232,13 +251,37 @@ result = client.fuel_invoice(
232
251
  | `petroleo_amount` | `float\|Decimal` | — | Impuesto PETROLEO por unidad; omitir para ítems sólo-IVA |
233
252
  | `petroleo_code` | `str` | `"1"` | `"1"`=SUPER, `"2"`=REGULAR, `"4"`=DIESEL. Obligatorio cuando se omite `petroleo_amount` y `petroleo_rates` está configurado; lanza `DigifactValidationError` si el código no está en el diccionario de tarifas. |
234
253
 
254
+ ### Subsidio combustible
255
+
256
+ El subsidio a la gasolina y al diésel **finalizó el jueves 2 de julio de 2026 a las 24:00**, antes de lo
257
+ previsto: el presupuesto de Q2 mil millones (Decreto 11-2026, reglamentado por el Acuerdo Gubernativo
258
+ 64-2026) se agotó por la demanda. Por eso el SDK **nunca** envía las frases `TipoFrase=9, Escenario=18` ni
259
+ `TipoFrase=9, Escenario=19` por su cuenta — no hay fecha de corte que valga para todos.
260
+
261
+ Las estaciones con inventario adquirido bajo el subsidio deben mantener el precio rebajado hasta agotar ese
262
+ producto, sujeto a verificación. Mientras te quede ese inventario, manda las frases explícitamente con
263
+ `frases`; al agotarlo, deja de mandarlas:
264
+
265
+ ```python
266
+ result = client.fuel_invoice("CF", items, frases=[
267
+ {"tipo_frase": "1", "escenario": "1"}, # frase base
268
+ {"tipo_frase": "9", "escenario": "18"},
269
+ {"tipo_frase": "9", "escenario": "19"},
270
+ ])
271
+ ```
272
+
273
+ > **Nota:** la leyenda del subsidio en la representación gráfica la genera Digifact a partir de las frases
274
+ > del XML certificado, no este SDK. Al dejar de mandar las frases, deja de imprimirse sola.
275
+
276
+ > **Nota:** `frases` y `tipo_frase`/`escenario` son **mutuamente exclusivos** — pasarlos juntos lanza `DigifactValidationError`.
277
+
235
278
  ## Configuración de frases (TipoFrase / CodigoEscenario)
236
279
 
237
280
  Todo DTE (excepto FESP) debe llevar un par `TipoFrase` + `CodigoEscenario`. El
238
281
  SDK elige valores por defecto adecuados, por lo que **no** hace falta
239
282
  configurar nada en el caso común.
240
283
 
241
- **Orden de precedencia:** argumentos por llamada → globales del constructor (`tipo_frase` / `escenario`) → tabla de valores por defecto.
284
+ **Orden de precedencia:** argumentos por llamada → globales del constructor → tabla de valores por defecto.
242
285
 
243
286
  **Tabla de valores por defecto:**
244
287
 
@@ -253,10 +296,30 @@ configurar nada en el caso común.
253
296
  | FACT / FCAM / NCRE / NDEB | PEQ | `2` | `1` | |
254
297
  | FACT / FCAM / NCRE / NDEB | EXE | `4` | `1` | Exento |
255
298
 
256
- Tanto `tipo_frase` como `escenario` se pueden sobreescribir de forma
257
- independiente — por llamada (como argumentos con nombre) o globalmente al
258
- construir el cliente. Cuando se omiten, cada uno cae al global del
259
- constructor y luego a la tabla de valores por defecto.
299
+ ### Nueva API: `frases[]` (múltiples frases)
300
+
301
+ Usa `frases` cuando necesitas enviar **más de un par** TipoFrase/Escenario (p. ej. listados en resoluciones SAT). Es **mutuamente exclusivo** con `tipo_frase`/`escenario`.
302
+
303
+ ```python
304
+ # Lista explícita de frases (reemplaza a tipo_frase/escenario)
305
+ client.fuel_invoice("CF", items, frases=[
306
+ {"tipo_frase": "1", "escenario": "1"},
307
+ {"tipo_frase": "9", "escenario": "18"},
308
+ {"tipo_frase": "9", "escenario": "19"},
309
+ ])
310
+
311
+ # Globalmente en el constructor
312
+ client = DigifactClient(
313
+ taxid="12345678", username="FELUSER", password="...",
314
+ frases=[{"tipo_frase": "1", "escenario": "2"}], # ISR régimen opcional simplificado
315
+ )
316
+ ```
317
+
318
+ > **Regla:** `frases` y `tipo_frase`/`escenario` son **mutuamente exclusivos** en cada nivel (por llamada y en el constructor). Combinarlos lanza `DigifactValidationError`.
319
+
320
+ ### API legacy: `tipo_frase` / `escenario`
321
+
322
+ Ambos parámetros siguen funcionando exactamente como antes para integraciones existentes.
260
323
 
261
324
  ```python
262
325
  # Sobreescritura por llamada (uno o ambos)
@@ -287,18 +350,19 @@ Todos los métodos de emisión devuelven `DteResult` con `result.auth_number`, `
287
350
  |--------|-------|-------------|
288
351
  | `invoice()` | `invoice(buyer, items, *, doc_type="FACT", payment_terms=None, amount_str="", observaciones="-", tipo_personeria=None, tipo_frase=None, escenario=None)` | Emite FACT, FCAM, FESP, FPEQ, NABN, RDON o RECI según `doc_type`. |
289
352
  | `cca_invoice()` | `cca_invoice(buyer, items, cobros, *, tipo_frase=None, escenario=None)` | FACT con complemento CCA. |
290
- | `fuel_invoice()` | `fuel_invoice(buyer, items, *, tipo_frase=None, escenario=None)` | FACT con complemento combustible (IVA + PETROLEO). |
353
+ | `fuel_invoice()` | `fuel_invoice(buyer, items, *, tipo_frase=None, escenario=None, frases=None)` | FACT con complemento combustible (IVA + PETROLEO). |
291
354
  | `credit_note()` | `credit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de crédito (NCRE). |
292
355
  | `debit_note()` | `debit_note(buyer, items, origin, reason, *, tipo_frase=None, escenario=None)` | Nota de débito (NDEB). |
293
356
  | `credit_note_total()` | `credit_note_total(auth_number, issue_datetime, reason="...", reference="")` | Nota de crédito total. Devuelve `dict`. |
294
357
  | `cancel()` | `cancel(auth_number, receiver_id, issue_datetime, reason="Anulación")` | Anula un DTE. Devuelve `dict`. |
295
358
  | `lookup_nit()` | `lookup_nit(nit)` | Consulta SAT. Devuelve `{"nit","name","address","city","district","state"}`. |
359
+ | `lookup_cui()` | `lookup_cui(cui)` | Consulta SAT por CUI (DPI). Devuelve `{"cui","name","status"}`. El nombre viene como `"APELLIDOS, NOMBRES"`, tal como lo registra SAT. |
296
360
  | `get_dte()` | `get_dte(auth_number, fmt="JSON")` | Recupera el DTE (`"JSON"`, `"XML"`, `"HTML"`, `"PDF"`). |
297
361
  | `get_dte_info()` | `get_dte_info(auth_number)` | Metadatos del DTE. |
298
362
 
299
363
  ### Parámetros comunes
300
364
 
301
- - **`buyer`**: `"CF"` (consumidor final), un NIT string (`"12345678"` — se consulta el nombre), un dict CUI (`{"type":"CUI","taxid":...,"name":...}`) o un dict NIT explícito (`{"taxid","name","address","city","district","state","country","email"}`).
365
+ - **`buyer`**: `"CF"` (consumidor final), un NIT string (`"12345678"` — se consulta el nombre), un CUI string de 13 dígitos (`"1234567890123"` — se consulta el nombre), un dict CUI (`{"type":"CUI","taxid":...}` — se consulta el nombre; con `"name"` explícito no se consulta) o un dict NIT explícito (`{"taxid","name","address","city","district","state","country","email"}`).
302
366
  - **`items`**: lista de dicts con `description` (str, req), `price` (float/Decimal, req), `qty` (1), `type` (`"Servicio"`/`"Bien"`), `unit_of_measure` (`"UNI"`), `discount` (opcional).
303
367
  - **`origin`** (NCRE/NDEB): `{"auth_number": ..., "date": "YYYY-MM-DD", "series": ..., "number": ...}`.
304
368
 
@@ -338,6 +402,7 @@ python -m pytest tests/ -v
338
402
  | `DIGIFACT_TAXID` | NIT del emisor (p. ej. `12345678`) |
339
403
  | `DIGIFACT_USERNAME` | Usuario (p. ej. `FELUSER`) |
340
404
  | `DIGIFACT_PASSWORD` | Contraseña de la cuenta |
405
+ | `DIGIFACT_ENVIRONMENT` | `test` (default) o `production` |
341
406
 
342
407
  ## Manejo de errores
343
408
 
@@ -8,7 +8,7 @@ from .exceptions import (
8
8
  DigifactValidationError,
9
9
  )
10
10
 
11
- __version__ = "2.0.0"
11
+ __version__ = "3.0.0"
12
12
  __all__ = [
13
13
  "DigifactClient",
14
14
  "DteResult",
@@ -27,6 +27,54 @@ def _adenda_code(doc_type: str) -> str:
27
27
  return ADENDA_CODE_ALT if doc_type in _ALT_ADENDA_TYPES else ADENDA_CODE_STD
28
28
 
29
29
 
30
+ # ── Frases ───────────────────────────────────────────────────────────────────
31
+
32
+
33
+ def _uniq_frases(frases: list[dict]) -> list[dict]:
34
+ seen: set[str] = set()
35
+ out: list[dict] = []
36
+ for f in frases:
37
+ key = f"{f['tipo_frase']}:{f['escenario']}"
38
+ if key not in seen:
39
+ seen.add(key)
40
+ out.append(f)
41
+ return out
42
+
43
+
44
+ def _build_additionl_info_from_frases(frases: list[dict]) -> list[dict]:
45
+ """Build Seller.AdditionlInfo entries from a frases list.
46
+
47
+ Each frase gets a 1-based Data index so Digifact's NUC XSLT groups them into
48
+ separate <dte:Frase> elements. Confirmed via live API: Digifact counts the frases
49
+ correctly with this format (error becomes a NIT-authorization check, not XSLT failure).
50
+ """
51
+ out: list[dict] = []
52
+ for i, f in enumerate(frases, start=1):
53
+ out.append({"Name": "TipoFrase", "Data": str(i), "Value": str(f["tipo_frase"])})
54
+ out.append({"Name": "Escenario", "Data": str(i), "Value": str(f["escenario"])})
55
+ return out
56
+
57
+
58
+ def resolve_fuel_frases(
59
+ frases: list[dict] | None,
60
+ tipo_frase: str | None,
61
+ escenario: str | None,
62
+ ) -> list[dict]:
63
+ """Return the final deduplicated frases list for a fuel invoice.
64
+
65
+ Called only after mutual-exclusivity validation (frases vs tipo_frase/escenario).
66
+ """
67
+ if frases is not None:
68
+ if not frases:
69
+ from .exceptions import DigifactValidationError
70
+ raise DigifactValidationError("frases must contain at least one TipoFrase/Escenario pair")
71
+ return _uniq_frases(frases)
72
+ result: list[dict] = []
73
+ if tipo_frase is not None and escenario is not None:
74
+ result.append({"tipo_frase": tipo_frase, "escenario": escenario})
75
+ return _uniq_frases(result)
76
+
77
+
30
78
  def default_frase(doc_type: str, afiliacion: str = "GEN") -> tuple[str, str] | None:
31
79
  """Return default (TipoFrase, CodigoEscenario) for a (doc_type, afiliacion) combo.
32
80
 
@@ -68,6 +116,7 @@ def _build_seller(
68
116
  afiliacion: str = "GEN",
69
117
  tipo_frase: str | None = "1",
70
118
  escenario: str | None = "1",
119
+ frases: list[dict] | None = None,
71
120
  branch_code: str = "1",
72
121
  branch_name: str = "ESTABLECIMIENTO PRINCIPAL",
73
122
  city: str = "01010",
@@ -96,8 +145,12 @@ def _build_seller(
96
145
  }
97
146
  if email:
98
147
  seller["Contact"] = {"EmailList": {"Email": [email]}}
99
- # AdditionlInfo — intentional typo in API spec
100
- if tipo_frase is not None and escenario is not None:
148
+ # AdditionlInfo — intentional typo in API spec.
149
+ # Digifact's XSLT groups entries by 1-based Data index into separate <dte:Frase> elements.
150
+ if frases is not None:
151
+ if frases:
152
+ seller["AdditionlInfo"] = _build_additionl_info_from_frases(frases)
153
+ elif tipo_frase is not None and escenario is not None:
101
154
  seller["AdditionlInfo"] = [
102
155
  {"Name": "TipoFrase", "Data": "1", "Value": tipo_frase},
103
156
  {"Name": "Escenario", "Data": "1", "Value": escenario},
@@ -306,6 +359,7 @@ def build_fact(
306
359
  afiliacion: str = "GEN",
307
360
  tipo_frase: str | None = None,
308
361
  escenario: str | None = None,
362
+ frases: list[dict] | None = None,
309
363
  amount_str: str = "",
310
364
  observaciones: str = "-",
311
365
  extra_header: dict | None = None,
@@ -329,6 +383,7 @@ def build_fact(
329
383
  afiliacion=afiliacion,
330
384
  tipo_frase=tf,
331
385
  escenario=es,
386
+ frases=frases,
332
387
  email=seller_email,
333
388
  )
334
389
 
@@ -371,6 +426,7 @@ def build_fcam(
371
426
  seller_email: str | None = None,
372
427
  tipo_frase: str | None = None,
373
428
  escenario: str | None = None,
429
+ frases: list[dict] | None = None,
374
430
  ) -> dict:
375
431
  """Build a FCAM (Factura Cambiaria) DTE payload.
376
432
 
@@ -391,6 +447,7 @@ def build_fcam(
391
447
  afiliacion=afiliacion,
392
448
  tipo_frase=tf,
393
449
  escenario=es,
450
+ frases=frases,
394
451
  email=seller_email,
395
452
  )
396
453
 
@@ -443,6 +500,7 @@ def build_ndeb(
443
500
  seller_email: str | None = None,
444
501
  tipo_frase: str | None = None,
445
502
  escenario: str | None = None,
503
+ frases: list[dict] | None = None,
446
504
  ) -> dict:
447
505
  """Build a NDEB (Nota de Débito) DTE payload.
448
506
 
@@ -462,6 +520,7 @@ def build_ndeb(
462
520
  afiliacion=afiliacion,
463
521
  tipo_frase=tf,
464
522
  escenario=es,
523
+ frases=frases,
465
524
  email=seller_email,
466
525
  )
467
526
 
@@ -527,6 +586,7 @@ def build_ncre(
527
586
  seller_email: str | None = None,
528
587
  tipo_frase: str | None = None,
529
588
  escenario: str | None = None,
589
+ frases: list[dict] | None = None,
530
590
  ) -> dict:
531
591
  """Build a NCRE (Nota de Crédito) DTE payload.
532
592
 
@@ -546,6 +606,7 @@ def build_ncre(
546
606
  afiliacion=afiliacion,
547
607
  tipo_frase=tf,
548
608
  escenario=es,
609
+ frases=frases,
549
610
  email=seller_email,
550
611
  )
551
612
 
@@ -609,6 +670,7 @@ def build_nabn(
609
670
  amount_str: str = "",
610
671
  observaciones: str = "-",
611
672
  seller_email: str | None = None,
673
+ frases: list[dict] | None = None,
612
674
  ) -> dict:
613
675
  """Build a NABN (Nota de Abono) DTE payload — no IVA."""
614
676
  issue_dt, _, _ = gt_now()
@@ -622,6 +684,7 @@ def build_nabn(
622
684
  afiliacion=afiliacion,
623
685
  tipo_frase="1",
624
686
  escenario="1",
687
+ frases=frases,
625
688
  email=seller_email,
626
689
  )
627
690
 
@@ -718,6 +781,7 @@ def build_rdon(
718
781
  amount_str: str = "",
719
782
  observaciones: str = "-",
720
783
  seller_email: str | None = None,
784
+ frases: list[dict] | None = None,
721
785
  ) -> dict:
722
786
  """Build a RDON (Recibo por Donación) DTE payload — no IVA."""
723
787
  issue_dt, _, _ = gt_now()
@@ -730,6 +794,7 @@ def build_rdon(
730
794
  afiliacion=afiliacion,
731
795
  tipo_frase="4",
732
796
  escenario="4",
797
+ frases=frases,
733
798
  email=seller_email,
734
799
  )
735
800
 
@@ -769,6 +834,7 @@ def build_fpeq(
769
834
  seller_email: str | None = None,
770
835
  tipo_frase: str | None = None,
771
836
  escenario: str | None = None,
837
+ frases: list[dict] | None = None,
772
838
  ) -> dict:
773
839
  """Build a FPEQ (Factura Pequeño Contribuyente) DTE payload — no IVA, AfiliacionIVA=PEQ."""
774
840
  issue_dt, _, _ = gt_now()
@@ -785,6 +851,7 @@ def build_fpeq(
785
851
  afiliacion="PEQ",
786
852
  tipo_frase=tf,
787
853
  escenario=es,
854
+ frases=frases,
788
855
  email=seller_email,
789
856
  )
790
857
 
@@ -819,6 +886,7 @@ def build_reci(
819
886
  student_id: str = "000000000",
820
887
  academic_unit: str = "01",
821
888
  seller_email: str | None = None,
889
+ frases: list[dict] | None = None,
822
890
  ) -> dict:
823
891
  """Build a RECI (Recibo) DTE payload — no IVA, universidad ADENDA."""
824
892
  issue_dt, _, _ = gt_now()
@@ -831,6 +899,7 @@ def build_reci(
831
899
  afiliacion=afiliacion,
832
900
  tipo_frase="4",
833
901
  escenario="5",
902
+ frases=frases,
834
903
  email=seller_email,
835
904
  )
836
905
 
@@ -873,6 +942,7 @@ def build_cca(
873
942
  seller_email: str | None = None,
874
943
  tipo_frase: str | None = None,
875
944
  escenario: str | None = None,
945
+ frases: list[dict] | None = None,
876
946
  ) -> dict:
877
947
  """Build a CCA (Cobro por Cuenta Ajena) FACT+CCA complemento payload.
878
948
 
@@ -902,6 +972,7 @@ def build_cca(
902
972
  afiliacion=afiliacion,
903
973
  tipo_frase=tf,
904
974
  escenario=es,
975
+ frases=frases,
905
976
  email=seller_email,
906
977
  )
907
978
 
@@ -1095,6 +1166,7 @@ def build_fact_combustible(
1095
1166
  afiliacion: str = "GEN",
1096
1167
  tipo_frase: str | None = None,
1097
1168
  escenario: str | None = None,
1169
+ frases: list[dict] | None = None,
1098
1170
  seller_email: str | None = None,
1099
1171
  ) -> dict:
1100
1172
  """Build a FACT payload for combustible (fuel) invoices.
@@ -1104,6 +1176,8 @@ def build_fact_combustible(
1104
1176
  "4"=DIESEL; default "1"). Items without ``petroleo_amount`` are treated as
1105
1177
  regular IVA-only items and may coexist in the same invoice.
1106
1178
 
1179
+ ``frases`` and ``tipo_frase``/``escenario`` are mutually exclusive.
1180
+
1107
1181
  Example fuel item::
1108
1182
 
1109
1183
  {"description": "GASOLINA SUPER", "qty": 1, "price": 30.30,
@@ -1113,6 +1187,12 @@ def build_fact_combustible(
1113
1187
 
1114
1188
  {"description": "FILTRO DE ACEITE", "qty": 1, "price": 45.00, "type": "Bien"}
1115
1189
  """
1190
+ from .exceptions import DigifactValidationError
1191
+ if frases is not None and (tipo_frase is not None or escenario is not None):
1192
+ raise DigifactValidationError(
1193
+ "frases and tipo_frase/escenario are mutually exclusive; use one or the other"
1194
+ )
1195
+
1116
1196
  issue_dt, _, _ = gt_now()
1117
1197
 
1118
1198
  line_items, grand_total, total_iva, total_petroleo = _build_fuel_items(items)
@@ -1121,13 +1201,16 @@ def build_fact_combustible(
1121
1201
  tf = tipo_frase if tipo_frase is not None else def_tf
1122
1202
  es = escenario if escenario is not None else def_es
1123
1203
 
1204
+ resolved_frases = resolve_fuel_frases(frases, tf, es)
1205
+
1206
+ # All frases go into Seller.AdditionlInfo as repeated pairs —
1207
+ # that is the field the API reads to generate <dte:Frases> in the certified XML.
1124
1208
  seller = _build_seller(
1125
1209
  taxid,
1126
1210
  seller_name,
1127
1211
  seller_address,
1128
1212
  afiliacion=afiliacion,
1129
- tipo_frase=tf,
1130
- escenario=es,
1213
+ frases=resolved_frases,
1131
1214
  email=seller_email,
1132
1215
  )
1133
1216
 
@@ -1135,12 +1218,14 @@ def build_fact_combustible(
1135
1218
  if float(total_petroleo) > 0.0:
1136
1219
  total_tax.append({"Description": "PETROLEO", "Amount": total_petroleo})
1137
1220
 
1138
- return {
1221
+ combustible_payload: dict[str, Any] = {
1139
1222
  "Version": "1.00",
1140
1223
  "CountryCode": "GT",
1141
1224
  "Header": {"DocType": "FACT", "IssuedDateTime": issue_dt, "Currency": "GTQ"},
1142
1225
  "Seller": seller,
1143
1226
  "Buyer": buyer,
1227
+ }
1228
+ combustible_payload.update({
1144
1229
  "ThirdParties": None,
1145
1230
  "Items": line_items,
1146
1231
  "Totals": {
@@ -1148,4 +1233,5 @@ def build_fact_combustible(
1148
1233
  "GrandTotal": {"InvoiceTotal": grand_total},
1149
1234
  },
1150
1235
  "AdditionalDocumentInfo": _build_fuel_adenda(),
1151
- }
1236
+ })
1237
+ return combustible_payload