digifact-sdk 2.0.1__py3-none-any.whl
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/__init__.py +20 -0
- digifact_sdk/builder.py +911 -0
- digifact_sdk/client.py +704 -0
- digifact_sdk/exceptions.py +76 -0
- digifact_sdk/tax.py +133 -0
- digifact_sdk-2.0.1.dist-info/METADATA +207 -0
- digifact_sdk-2.0.1.dist-info/RECORD +9 -0
- digifact_sdk-2.0.1.dist-info/WHEEL +5 -0
- digifact_sdk-2.0.1.dist-info/top_level.txt +1 -0
digifact_sdk/client.py
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
"""DigifactClient — main entry point for the Digifact FEL Guatemala SDK."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from .builder import (
|
|
12
|
+
build_cca,
|
|
13
|
+
build_fact,
|
|
14
|
+
build_fcam,
|
|
15
|
+
build_fesp,
|
|
16
|
+
build_fpeq,
|
|
17
|
+
build_nabn,
|
|
18
|
+
build_ncre,
|
|
19
|
+
build_ndeb,
|
|
20
|
+
build_rdon,
|
|
21
|
+
build_reci,
|
|
22
|
+
_build_buyer_cf,
|
|
23
|
+
_build_buyer_nit,
|
|
24
|
+
_build_buyer_cui,
|
|
25
|
+
)
|
|
26
|
+
from .exceptions import (
|
|
27
|
+
DigifactApiError,
|
|
28
|
+
DigifactAuthError,
|
|
29
|
+
DigifactError,
|
|
30
|
+
DigifactNitNotFoundError,
|
|
31
|
+
DigifactValidationError,
|
|
32
|
+
classify_error,
|
|
33
|
+
)
|
|
34
|
+
from .tax import gt_now, pad_taxid
|
|
35
|
+
|
|
36
|
+
_BASE_URLS = {
|
|
37
|
+
"test": "https://testnucgt.digifact.com/api",
|
|
38
|
+
"production": "https://nucgt.digifact.com/api",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class DteResult:
|
|
44
|
+
"""Result of a DTE emission."""
|
|
45
|
+
|
|
46
|
+
auth_number: str
|
|
47
|
+
series: str
|
|
48
|
+
number: str
|
|
49
|
+
issue_datetime: str
|
|
50
|
+
raw: dict = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def auth_number_upper(self) -> str:
|
|
54
|
+
return self.auth_number.upper()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DigifactClient:
|
|
58
|
+
"""High-level client for the Digifact FEL Guatemala API.
|
|
59
|
+
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
taxid:
|
|
63
|
+
Fiscal ID of the issuer (digits only; will be padded to 12 chars).
|
|
64
|
+
username:
|
|
65
|
+
Short username, e.g. "FELUSER".
|
|
66
|
+
password:
|
|
67
|
+
Account password. Either password or token must be provided.
|
|
68
|
+
environment:
|
|
69
|
+
"test" (default) or "production".
|
|
70
|
+
token:
|
|
71
|
+
Pre-obtained bearer token. If provided, login is skipped.
|
|
72
|
+
seller_name:
|
|
73
|
+
Override the seller name (default: fetched from SAT NIT lookup).
|
|
74
|
+
seller_address:
|
|
75
|
+
Override the seller address.
|
|
76
|
+
afiliacion_iva:
|
|
77
|
+
IVA affiliation code ("GEN", "PEQ", "EXE"). Default "GEN".
|
|
78
|
+
tipo_personeria:
|
|
79
|
+
Required for RDON; e.g. "719".
|
|
80
|
+
timeout:
|
|
81
|
+
HTTP request timeout in seconds (default 120).
|
|
82
|
+
session:
|
|
83
|
+
Optional ``requests.Session`` to use (useful for testing / mocking).
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
taxid: str,
|
|
89
|
+
username: str,
|
|
90
|
+
password: str = "",
|
|
91
|
+
*,
|
|
92
|
+
environment: str = "test",
|
|
93
|
+
token: str = "",
|
|
94
|
+
seller_name: str = "",
|
|
95
|
+
seller_address: str = "",
|
|
96
|
+
afiliacion_iva: str = "GEN",
|
|
97
|
+
tipo_personeria: str = "1",
|
|
98
|
+
timeout: int = 120,
|
|
99
|
+
session: requests.Session | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
self.taxid = re.sub(r"\D", "", taxid) # digits only
|
|
102
|
+
if not self.taxid:
|
|
103
|
+
raise ValueError("taxid must contain at least one digit")
|
|
104
|
+
if not username:
|
|
105
|
+
raise ValueError("username is required")
|
|
106
|
+
if not token and not password:
|
|
107
|
+
raise ValueError("password or token is required")
|
|
108
|
+
|
|
109
|
+
self.padded_taxid = pad_taxid(taxid)
|
|
110
|
+
self.username = username
|
|
111
|
+
self.password = password
|
|
112
|
+
self.environment = environment
|
|
113
|
+
self.afiliacion_iva = afiliacion_iva
|
|
114
|
+
self.tipo_personeria = tipo_personeria
|
|
115
|
+
self.timeout = timeout
|
|
116
|
+
|
|
117
|
+
base = _BASE_URLS.get(environment)
|
|
118
|
+
if base is None:
|
|
119
|
+
raise ValueError(f"environment must be 'test' or 'production', got {environment!r}")
|
|
120
|
+
self.base_url = base.rstrip("/")
|
|
121
|
+
|
|
122
|
+
self._full_username = f"GT.{self.padded_taxid}.{username}"
|
|
123
|
+
self._token: str = token
|
|
124
|
+
self._seller_name: str = seller_name
|
|
125
|
+
self._seller_address: str = seller_address
|
|
126
|
+
self._nit_cache: dict[str, dict] = {}
|
|
127
|
+
|
|
128
|
+
self._session = session or requests.Session()
|
|
129
|
+
|
|
130
|
+
def __repr__(self) -> str:
|
|
131
|
+
env = self.environment
|
|
132
|
+
return f"DigifactClient(taxid={self.taxid!r}, username={self.username!r}, environment={env!r})"
|
|
133
|
+
|
|
134
|
+
# ── Authentication ────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
def _login(self) -> str:
|
|
137
|
+
if self._token:
|
|
138
|
+
return self._token
|
|
139
|
+
if not self.password:
|
|
140
|
+
raise DigifactAuthError("password or token is required")
|
|
141
|
+
resp = self._session.post(
|
|
142
|
+
f"{self.base_url}/login/get_token",
|
|
143
|
+
json={"Username": self._full_username, "Password": self.password},
|
|
144
|
+
timeout=self.timeout,
|
|
145
|
+
)
|
|
146
|
+
try:
|
|
147
|
+
resp.raise_for_status()
|
|
148
|
+
except requests.HTTPError as exc:
|
|
149
|
+
raise DigifactAuthError(f"Login HTTP error: {exc}", raw=_try_json(resp)) from exc
|
|
150
|
+
data = resp.json()
|
|
151
|
+
tok = data.get("Token") or data.get("token")
|
|
152
|
+
if not tok:
|
|
153
|
+
raise DigifactAuthError("Login succeeded but response contained no token", raw=data)
|
|
154
|
+
self._token = tok
|
|
155
|
+
return self._token
|
|
156
|
+
|
|
157
|
+
def _headers(self) -> dict:
|
|
158
|
+
return {"Authorization": self._login(), "Content-Type": "application/json"}
|
|
159
|
+
|
|
160
|
+
# ── Seller info ───────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
def _get_seller_info(self) -> tuple[str, str]:
|
|
163
|
+
"""Fetch seller name and address, with cache. Returns (name, address)."""
|
|
164
|
+
if self._seller_name and self._seller_address:
|
|
165
|
+
return self._seller_name, self._seller_address
|
|
166
|
+
try:
|
|
167
|
+
info = self.lookup_nit(self.taxid)
|
|
168
|
+
name = info.get("name") or info.get("NOMBRE") or "EMISOR"
|
|
169
|
+
address = info.get("address") or info.get("Direccion") or "CIUDAD"
|
|
170
|
+
if not self._seller_name:
|
|
171
|
+
self._seller_name = name
|
|
172
|
+
if not self._seller_address:
|
|
173
|
+
self._seller_address = address
|
|
174
|
+
except (DigifactError, requests.RequestException):
|
|
175
|
+
# NIT lookup failed — use safe fallbacks so the invoice can still proceed
|
|
176
|
+
if not self._seller_name:
|
|
177
|
+
self._seller_name = f"EMISOR {self.taxid}"
|
|
178
|
+
if not self._seller_address:
|
|
179
|
+
self._seller_address = "CIUDAD"
|
|
180
|
+
return self._seller_name, self._seller_address
|
|
181
|
+
|
|
182
|
+
# ── Buyer resolution ──────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
def _resolve_buyer(self, buyer: str | dict) -> dict:
|
|
185
|
+
"""Resolve a buyer spec to a buyer dict.
|
|
186
|
+
|
|
187
|
+
- "CF" → Consumidor Final
|
|
188
|
+
- "77454820" → NIT lookup → buyer dict
|
|
189
|
+
- {"taxid": ..., "type": "CUI", "name": ...} → CUI buyer
|
|
190
|
+
- {"taxid": ..., "name": ..., ...} → explicit buyer dict
|
|
191
|
+
"""
|
|
192
|
+
if isinstance(buyer, str):
|
|
193
|
+
if buyer.upper() == "CF":
|
|
194
|
+
return _build_buyer_cf()
|
|
195
|
+
# Numeric string → NIT
|
|
196
|
+
digits = re.sub(r"\D", "", buyer)
|
|
197
|
+
if digits:
|
|
198
|
+
info = self.lookup_nit(digits)
|
|
199
|
+
return _build_buyer_nit(
|
|
200
|
+
nit=digits,
|
|
201
|
+
name=info.get("name") or info.get("NOMBRE") or digits,
|
|
202
|
+
address=info.get("address") or info.get("Direccion") or "CIUDAD",
|
|
203
|
+
city=info.get("city") or "01010",
|
|
204
|
+
district=info.get("district") or "GUATEMALA",
|
|
205
|
+
state=info.get("state") or "GUATEMALA",
|
|
206
|
+
country="GT",
|
|
207
|
+
)
|
|
208
|
+
raise DigifactError(f"Cannot resolve buyer: {buyer!r}")
|
|
209
|
+
|
|
210
|
+
if isinstance(buyer, dict):
|
|
211
|
+
buyer_type = buyer.get("type", "").upper()
|
|
212
|
+
if buyer_type == "CUI":
|
|
213
|
+
return _build_buyer_cui(
|
|
214
|
+
taxid=str(buyer["taxid"]),
|
|
215
|
+
name=buyer["name"],
|
|
216
|
+
)
|
|
217
|
+
# Explicit dict — build NIT buyer
|
|
218
|
+
return _build_buyer_nit(
|
|
219
|
+
nit=str(buyer["taxid"]),
|
|
220
|
+
name=buyer["name"],
|
|
221
|
+
address=buyer.get("address", "CIUDAD"),
|
|
222
|
+
city=buyer.get("city", "01010"),
|
|
223
|
+
district=buyer.get("district", "GUATEMALA"),
|
|
224
|
+
state=buyer.get("state", "GUATEMALA"),
|
|
225
|
+
country=buyer.get("country", "GT"),
|
|
226
|
+
email=buyer.get("email"),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
raise DigifactError(f"buyer must be a string or dict, got {type(buyer)}")
|
|
230
|
+
|
|
231
|
+
# ── Raw API calls ─────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
def _certify(self, payload: dict) -> dict:
|
|
234
|
+
resp = self._session.post(
|
|
235
|
+
f"{self.base_url}/v2/transform/nuc_json",
|
|
236
|
+
params={
|
|
237
|
+
"TAXID": self.padded_taxid,
|
|
238
|
+
"FORMAT": "XML|HTML|PDF",
|
|
239
|
+
"USERNAME": self.username,
|
|
240
|
+
},
|
|
241
|
+
headers=self._headers(),
|
|
242
|
+
json=payload,
|
|
243
|
+
timeout=self.timeout,
|
|
244
|
+
)
|
|
245
|
+
try:
|
|
246
|
+
resp.raise_for_status()
|
|
247
|
+
except requests.HTTPError as exc:
|
|
248
|
+
raise DigifactApiError(f"Certify HTTP error: {exc}", raw=_try_json(resp)) from exc
|
|
249
|
+
data = resp.json()
|
|
250
|
+
return _check_response(data)
|
|
251
|
+
|
|
252
|
+
def _parse_result(self, data: dict) -> DteResult:
|
|
253
|
+
auth = data.get("authNumber") or data.get("Autorizacion") or ""
|
|
254
|
+
series = data.get("batch") or data.get("Serie") or ""
|
|
255
|
+
number = str(data.get("serial") or data.get("Numero") or "")
|
|
256
|
+
ts_raw = data.get("issuedTimeStamp") or data.get("FechaEmision") or ""
|
|
257
|
+
issue_dt = ts_raw.replace("T", " ") if "T" in ts_raw else ts_raw
|
|
258
|
+
return DteResult(
|
|
259
|
+
auth_number=auth,
|
|
260
|
+
series=series,
|
|
261
|
+
number=number,
|
|
262
|
+
issue_datetime=issue_dt,
|
|
263
|
+
raw=data,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# ── Public DTE methods ────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
def invoice(
|
|
269
|
+
self,
|
|
270
|
+
buyer: str | dict,
|
|
271
|
+
items: list[dict],
|
|
272
|
+
*,
|
|
273
|
+
doc_type: str = "FACT",
|
|
274
|
+
payment_terms: list[dict] | None = None,
|
|
275
|
+
amount_str: str = "",
|
|
276
|
+
observaciones: str = "-",
|
|
277
|
+
tipo_personeria: str | None = None,
|
|
278
|
+
) -> DteResult:
|
|
279
|
+
"""Emit a DTE invoice (FACT, FCAM, NABN, FESP, RDON, FPEQ, RECI, CCA).
|
|
280
|
+
|
|
281
|
+
Parameters
|
|
282
|
+
----------
|
|
283
|
+
buyer:
|
|
284
|
+
"CF", NIT string, CUI dict, or full buyer dict.
|
|
285
|
+
items:
|
|
286
|
+
List of item dicts with keys: description, qty, price, type, unit_of_measure.
|
|
287
|
+
doc_type:
|
|
288
|
+
DTE type. Default "FACT".
|
|
289
|
+
payment_terms:
|
|
290
|
+
For FCAM: list of {"date": "YYYY-MM-DD", "amount": float}.
|
|
291
|
+
amount_str:
|
|
292
|
+
Human-readable total amount string for ADENDA (e.g. "CIEN QUETZALES CON 00/100").
|
|
293
|
+
observaciones:
|
|
294
|
+
Observations text for ADENDA.
|
|
295
|
+
tipo_personeria:
|
|
296
|
+
Required for RDON.
|
|
297
|
+
"""
|
|
298
|
+
seller_name, seller_address = self._get_seller_info()
|
|
299
|
+
buyer_dict = self._resolve_buyer(buyer)
|
|
300
|
+
|
|
301
|
+
if doc_type == "FCAM":
|
|
302
|
+
if not payment_terms:
|
|
303
|
+
raise DigifactValidationError("payment_terms is required for FCAM")
|
|
304
|
+
payload = build_fcam(
|
|
305
|
+
self.taxid,
|
|
306
|
+
seller_name,
|
|
307
|
+
seller_address,
|
|
308
|
+
buyer_dict,
|
|
309
|
+
items,
|
|
310
|
+
payment_terms,
|
|
311
|
+
afiliacion=self.afiliacion_iva,
|
|
312
|
+
amount_str=amount_str,
|
|
313
|
+
observaciones=observaciones,
|
|
314
|
+
)
|
|
315
|
+
elif doc_type == "FESP":
|
|
316
|
+
payload = build_fesp(
|
|
317
|
+
self.taxid,
|
|
318
|
+
seller_name,
|
|
319
|
+
seller_address,
|
|
320
|
+
buyer_dict,
|
|
321
|
+
items,
|
|
322
|
+
afiliacion=self.afiliacion_iva,
|
|
323
|
+
)
|
|
324
|
+
elif doc_type == "NABN":
|
|
325
|
+
payload = build_nabn(
|
|
326
|
+
self.taxid,
|
|
327
|
+
seller_name,
|
|
328
|
+
seller_address,
|
|
329
|
+
buyer_dict,
|
|
330
|
+
items,
|
|
331
|
+
afiliacion=self.afiliacion_iva,
|
|
332
|
+
amount_str=amount_str,
|
|
333
|
+
observaciones=observaciones,
|
|
334
|
+
)
|
|
335
|
+
elif doc_type == "RDON":
|
|
336
|
+
tp = tipo_personeria or self.tipo_personeria
|
|
337
|
+
payload = build_rdon(
|
|
338
|
+
self.taxid,
|
|
339
|
+
seller_name,
|
|
340
|
+
seller_address,
|
|
341
|
+
buyer_dict,
|
|
342
|
+
items,
|
|
343
|
+
tp,
|
|
344
|
+
afiliacion=self.afiliacion_iva,
|
|
345
|
+
amount_str=amount_str,
|
|
346
|
+
observaciones=observaciones,
|
|
347
|
+
)
|
|
348
|
+
elif doc_type == "FPEQ":
|
|
349
|
+
payload = build_fpeq(
|
|
350
|
+
self.taxid,
|
|
351
|
+
seller_name,
|
|
352
|
+
seller_address,
|
|
353
|
+
buyer_dict,
|
|
354
|
+
items,
|
|
355
|
+
amount_str=amount_str,
|
|
356
|
+
observaciones=observaciones,
|
|
357
|
+
)
|
|
358
|
+
elif doc_type == "RECI":
|
|
359
|
+
payload = build_reci(
|
|
360
|
+
self.taxid,
|
|
361
|
+
seller_name,
|
|
362
|
+
seller_address,
|
|
363
|
+
buyer_dict,
|
|
364
|
+
items,
|
|
365
|
+
afiliacion=self.afiliacion_iva,
|
|
366
|
+
amount_str=amount_str,
|
|
367
|
+
observaciones=observaciones,
|
|
368
|
+
)
|
|
369
|
+
else:
|
|
370
|
+
# FACT (default), FACT CUI
|
|
371
|
+
payload = build_fact(
|
|
372
|
+
self.taxid,
|
|
373
|
+
seller_name,
|
|
374
|
+
seller_address,
|
|
375
|
+
buyer_dict,
|
|
376
|
+
items,
|
|
377
|
+
doc_type=doc_type,
|
|
378
|
+
afiliacion=self.afiliacion_iva,
|
|
379
|
+
amount_str=amount_str,
|
|
380
|
+
observaciones=observaciones,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
data = self._certify(payload)
|
|
384
|
+
return self._parse_result(data)
|
|
385
|
+
|
|
386
|
+
def cca_invoice(
|
|
387
|
+
self,
|
|
388
|
+
buyer: str | dict,
|
|
389
|
+
items: list[dict],
|
|
390
|
+
cobros: list[dict],
|
|
391
|
+
) -> DteResult:
|
|
392
|
+
"""Emit a CCA (Cobro por Cuenta Ajena) FACT+CCA complemento."""
|
|
393
|
+
seller_name, seller_address = self._get_seller_info()
|
|
394
|
+
buyer_dict = self._resolve_buyer(buyer)
|
|
395
|
+
payload = build_cca(
|
|
396
|
+
self.taxid,
|
|
397
|
+
seller_name,
|
|
398
|
+
seller_address,
|
|
399
|
+
buyer_dict,
|
|
400
|
+
items,
|
|
401
|
+
cobros,
|
|
402
|
+
afiliacion=self.afiliacion_iva,
|
|
403
|
+
)
|
|
404
|
+
data = self._certify(payload)
|
|
405
|
+
return self._parse_result(data)
|
|
406
|
+
|
|
407
|
+
def credit_note(
|
|
408
|
+
self,
|
|
409
|
+
buyer: str | dict,
|
|
410
|
+
items: list[dict],
|
|
411
|
+
origin: dict,
|
|
412
|
+
reason: str,
|
|
413
|
+
) -> DteResult:
|
|
414
|
+
"""Emit a NCRE (Nota de Crédito).
|
|
415
|
+
|
|
416
|
+
Parameters
|
|
417
|
+
----------
|
|
418
|
+
origin:
|
|
419
|
+
{"auth_number": str, "date": "YYYY-MM-DD", "series": str, "number": str|int}
|
|
420
|
+
reason:
|
|
421
|
+
Reason for the credit note.
|
|
422
|
+
"""
|
|
423
|
+
seller_name, seller_address = self._get_seller_info()
|
|
424
|
+
buyer_dict = self._resolve_buyer(buyer)
|
|
425
|
+
payload = build_ncre(
|
|
426
|
+
self.taxid,
|
|
427
|
+
seller_name,
|
|
428
|
+
seller_address,
|
|
429
|
+
buyer_dict,
|
|
430
|
+
items,
|
|
431
|
+
origin,
|
|
432
|
+
reason,
|
|
433
|
+
afiliacion=self.afiliacion_iva,
|
|
434
|
+
)
|
|
435
|
+
data = self._certify(payload)
|
|
436
|
+
return self._parse_result(data)
|
|
437
|
+
|
|
438
|
+
def debit_note(
|
|
439
|
+
self,
|
|
440
|
+
buyer: str | dict,
|
|
441
|
+
items: list[dict],
|
|
442
|
+
origin: dict,
|
|
443
|
+
reason: str,
|
|
444
|
+
) -> DteResult:
|
|
445
|
+
"""Emit a NDEB (Nota de Débito).
|
|
446
|
+
|
|
447
|
+
Parameters
|
|
448
|
+
----------
|
|
449
|
+
origin:
|
|
450
|
+
{"auth_number": str, "date": "YYYY-MM-DD", "series": str, "number": str|int}
|
|
451
|
+
reason:
|
|
452
|
+
Reason for the debit note.
|
|
453
|
+
"""
|
|
454
|
+
seller_name, seller_address = self._get_seller_info()
|
|
455
|
+
buyer_dict = self._resolve_buyer(buyer)
|
|
456
|
+
payload = build_ndeb(
|
|
457
|
+
self.taxid,
|
|
458
|
+
seller_name,
|
|
459
|
+
seller_address,
|
|
460
|
+
buyer_dict,
|
|
461
|
+
items,
|
|
462
|
+
origin,
|
|
463
|
+
reason,
|
|
464
|
+
afiliacion=self.afiliacion_iva,
|
|
465
|
+
)
|
|
466
|
+
data = self._certify(payload)
|
|
467
|
+
return self._parse_result(data)
|
|
468
|
+
|
|
469
|
+
def cancel(
|
|
470
|
+
self,
|
|
471
|
+
auth_number: str,
|
|
472
|
+
receiver_id: str,
|
|
473
|
+
issue_datetime: str,
|
|
474
|
+
reason: str = "Anulación",
|
|
475
|
+
) -> dict:
|
|
476
|
+
"""Cancel a DTE.
|
|
477
|
+
|
|
478
|
+
Parameters
|
|
479
|
+
----------
|
|
480
|
+
issue_datetime:
|
|
481
|
+
Format "YYYY-MM-DD HH:MM:SS"
|
|
482
|
+
"""
|
|
483
|
+
resp = self._session.post(
|
|
484
|
+
f"{self.base_url}/CancelFelGT",
|
|
485
|
+
headers=self._headers(),
|
|
486
|
+
json={
|
|
487
|
+
"Taxid": self.taxid,
|
|
488
|
+
"Autorizacion": auth_number,
|
|
489
|
+
"IdReceptor": receiver_id,
|
|
490
|
+
"FechaEmisionDocumentoAnular": issue_datetime,
|
|
491
|
+
"MotivoAnulacion": reason,
|
|
492
|
+
"Username": self.username,
|
|
493
|
+
},
|
|
494
|
+
timeout=self.timeout,
|
|
495
|
+
)
|
|
496
|
+
try:
|
|
497
|
+
resp.raise_for_status()
|
|
498
|
+
except requests.HTTPError as exc:
|
|
499
|
+
raise DigifactApiError(f"Cancel HTTP error: {exc}", raw=_try_json(resp)) from exc
|
|
500
|
+
return resp.json()
|
|
501
|
+
|
|
502
|
+
def credit_note_total(
|
|
503
|
+
self,
|
|
504
|
+
auth_number: str,
|
|
505
|
+
issue_datetime: str,
|
|
506
|
+
reason: str = "Nota de crédito total",
|
|
507
|
+
reference: str = "",
|
|
508
|
+
) -> dict:
|
|
509
|
+
"""Create a total credit note via /cert/ncredtotal.
|
|
510
|
+
|
|
511
|
+
Parameters
|
|
512
|
+
----------
|
|
513
|
+
issue_datetime:
|
|
514
|
+
Format "YYYY-MM-DD HH:MM:SS"
|
|
515
|
+
"""
|
|
516
|
+
resp = self._session.post(
|
|
517
|
+
f"{self.base_url}/cert/ncredtotal",
|
|
518
|
+
headers=self._headers(),
|
|
519
|
+
json={
|
|
520
|
+
"Staxid": self.taxid,
|
|
521
|
+
"Authnumber": auth_number,
|
|
522
|
+
"FechaEmision": issue_datetime,
|
|
523
|
+
"MotivoAjuste": reason,
|
|
524
|
+
"ReferenciaInterna": reference,
|
|
525
|
+
"Formatos": "xml|html|pdf",
|
|
526
|
+
"Username": self.username,
|
|
527
|
+
},
|
|
528
|
+
timeout=self.timeout,
|
|
529
|
+
)
|
|
530
|
+
try:
|
|
531
|
+
resp.raise_for_status()
|
|
532
|
+
except requests.HTTPError as exc:
|
|
533
|
+
raise DigifactApiError(
|
|
534
|
+
f"NcredTotal HTTP error: {exc}", raw=_try_json(resp)
|
|
535
|
+
) from exc
|
|
536
|
+
return resp.json()
|
|
537
|
+
|
|
538
|
+
# ── Query methods ─────────────────────────────────────────────────────────
|
|
539
|
+
|
|
540
|
+
def lookup_nit(self, nit: str) -> dict:
|
|
541
|
+
"""Look up a NIT via SHARED_GETINFONITcom.
|
|
542
|
+
|
|
543
|
+
Returns a normalized dict with keys: name, address, city, district, state.
|
|
544
|
+
Result is cached per NIT.
|
|
545
|
+
"""
|
|
546
|
+
digits = re.sub(r"\D", "", nit)
|
|
547
|
+
if digits in self._nit_cache:
|
|
548
|
+
return self._nit_cache[digits]
|
|
549
|
+
|
|
550
|
+
resp = self._session.get(
|
|
551
|
+
f"{self.base_url}/Shared",
|
|
552
|
+
params={
|
|
553
|
+
"COUNTRY": "GT",
|
|
554
|
+
"TAXID": self.padded_taxid,
|
|
555
|
+
"DATA1": "SHARED_GETINFONITcom",
|
|
556
|
+
"DATA2": f"NIT|{digits}",
|
|
557
|
+
"USERNAME": self.username,
|
|
558
|
+
},
|
|
559
|
+
headers={"Authorization": self._login()},
|
|
560
|
+
timeout=self.timeout,
|
|
561
|
+
)
|
|
562
|
+
try:
|
|
563
|
+
resp.raise_for_status()
|
|
564
|
+
except requests.HTTPError as exc:
|
|
565
|
+
raise DigifactApiError(
|
|
566
|
+
f"NIT lookup HTTP error: {exc}", raw=_try_json(resp)
|
|
567
|
+
) from exc
|
|
568
|
+
|
|
569
|
+
data = resp.json()
|
|
570
|
+
normalized = _parse_nit_response(digits, data)
|
|
571
|
+
if not normalized.get("name"):
|
|
572
|
+
raise DigifactNitNotFoundError(f"NIT {nit!r} not found or returned empty name")
|
|
573
|
+
self._nit_cache[digits] = normalized
|
|
574
|
+
return normalized
|
|
575
|
+
|
|
576
|
+
def get_dte_info(self, auth_number: str) -> dict:
|
|
577
|
+
"""Get DTE info via SHARED_GETDTEINFO."""
|
|
578
|
+
resp = self._session.get(
|
|
579
|
+
f"{self.base_url}/Shared",
|
|
580
|
+
params={
|
|
581
|
+
"COUNTRY": "GT",
|
|
582
|
+
"TAXID": self.padded_taxid,
|
|
583
|
+
"DATA1": "SHARED_GETDTEINFO",
|
|
584
|
+
"DATA2": f"AUTHNUMBER|{auth_number}",
|
|
585
|
+
"USERNAME": self.username,
|
|
586
|
+
},
|
|
587
|
+
headers={"Authorization": self._login()},
|
|
588
|
+
timeout=self.timeout,
|
|
589
|
+
)
|
|
590
|
+
try:
|
|
591
|
+
resp.raise_for_status()
|
|
592
|
+
except requests.HTTPError as exc:
|
|
593
|
+
raise DigifactApiError(
|
|
594
|
+
f"GetDteInfo HTTP error: {exc}", raw=_try_json(resp)
|
|
595
|
+
) from exc
|
|
596
|
+
return resp.json()
|
|
597
|
+
|
|
598
|
+
def get_dte(self, auth_number: str, fmt: str = "JSON") -> dict:
|
|
599
|
+
"""Retrieve a DTE document via GET /GetDocument."""
|
|
600
|
+
resp = self._session.get(
|
|
601
|
+
f"{self.base_url}/GetDocument",
|
|
602
|
+
params={
|
|
603
|
+
"AUTHNUMBER": auth_number,
|
|
604
|
+
"TAXID": self.padded_taxid,
|
|
605
|
+
"FORMAT": fmt,
|
|
606
|
+
"USERNAME": self.username,
|
|
607
|
+
},
|
|
608
|
+
headers={"Authorization": self._login()},
|
|
609
|
+
timeout=self.timeout,
|
|
610
|
+
)
|
|
611
|
+
try:
|
|
612
|
+
resp.raise_for_status()
|
|
613
|
+
except requests.HTTPError as exc:
|
|
614
|
+
raise DigifactApiError(
|
|
615
|
+
f"GetDocument HTTP error: {exc}", raw=_try_json(resp)
|
|
616
|
+
) from exc
|
|
617
|
+
return resp.json()
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
# ── Internal helpers ──────────────────────────────────────────────────────────
|
|
621
|
+
|
|
622
|
+
def _try_json(resp: requests.Response) -> dict:
|
|
623
|
+
try:
|
|
624
|
+
return resp.json()
|
|
625
|
+
except Exception:
|
|
626
|
+
return {"_text": resp.text}
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _check_response(data: dict) -> dict:
|
|
630
|
+
"""Raise DigifactValidationError if the API response indicates failure."""
|
|
631
|
+
code = data.get("code")
|
|
632
|
+
if code is None:
|
|
633
|
+
# No code field — check for auth_number / authNumber
|
|
634
|
+
auth = data.get("authNumber") or data.get("Autorizacion")
|
|
635
|
+
if auth:
|
|
636
|
+
return data
|
|
637
|
+
# Unknown format — return as-is (let caller decide)
|
|
638
|
+
return data
|
|
639
|
+
|
|
640
|
+
code_int = int(code)
|
|
641
|
+
if code_int == 1:
|
|
642
|
+
return data
|
|
643
|
+
if code_int == 0:
|
|
644
|
+
msg = data.get("description") or data.get("message") or str(data)
|
|
645
|
+
hint = classify_error(msg)
|
|
646
|
+
full_msg = f"DTE rejected (code=0): {msg}"
|
|
647
|
+
if hint:
|
|
648
|
+
full_msg += f"\n\nHint: {hint}"
|
|
649
|
+
raise DigifactValidationError(full_msg, code=code_int, raw=data)
|
|
650
|
+
|
|
651
|
+
# code == 3000 or other warn codes — still raise so caller is aware
|
|
652
|
+
msg = data.get("description") or data.get("message") or str(data)
|
|
653
|
+
hint = classify_error(msg)
|
|
654
|
+
full_msg = f"API warning (code={code_int}): {msg}"
|
|
655
|
+
if hint:
|
|
656
|
+
full_msg += f"\n\nHint: {hint}"
|
|
657
|
+
raise DigifactApiError(full_msg, code=code_int, raw=data)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _parse_nit_response(nit: str, data: Any) -> dict:
|
|
661
|
+
"""Normalize NIT lookup response to a standard dict.
|
|
662
|
+
|
|
663
|
+
The API returns: {"REQUEST_DATA": [...], "RESPONSE": [{"NOMBRE": ..., "Direccion": ...,
|
|
664
|
+
"MUNICIPIO": ..., "DEPARTAMENTO": ...}]}
|
|
665
|
+
"""
|
|
666
|
+
# Unwrap envelope: {"RESPONSE": [...]} or {"REQUEST_DATA": [...], "RESPONSE": [...]}
|
|
667
|
+
if isinstance(data, dict) and "RESPONSE" in data:
|
|
668
|
+
response_list = data["RESPONSE"]
|
|
669
|
+
if isinstance(response_list, list) and response_list:
|
|
670
|
+
return _parse_nit_response(nit, response_list[0])
|
|
671
|
+
return {"nit": nit, "name": "", "address": "CIUDAD", "city": "01010",
|
|
672
|
+
"district": "GUATEMALA", "state": "GUATEMALA"}
|
|
673
|
+
|
|
674
|
+
if isinstance(data, dict):
|
|
675
|
+
# Direct row dict with API keys
|
|
676
|
+
name = (
|
|
677
|
+
data.get("NOMBRE") or data.get("nombre")
|
|
678
|
+
or data.get("Name") or data.get("name") or ""
|
|
679
|
+
)
|
|
680
|
+
address = (
|
|
681
|
+
data.get("Direccion") or data.get("direccion")
|
|
682
|
+
or data.get("Address") or data.get("address") or "CIUDAD"
|
|
683
|
+
)
|
|
684
|
+
# MUNICIPIO and DEPARTAMENTO are uppercase in the real API response
|
|
685
|
+
district = (
|
|
686
|
+
data.get("MUNICIPIO") or data.get("Municipio")
|
|
687
|
+
or data.get("municipio") or data.get("district") or "GUATEMALA"
|
|
688
|
+
)
|
|
689
|
+
state = (
|
|
690
|
+
data.get("DEPARTAMENTO") or data.get("Departamento")
|
|
691
|
+
or data.get("departamento") or data.get("state") or "GUATEMALA"
|
|
692
|
+
)
|
|
693
|
+
return {
|
|
694
|
+
"nit": nit,
|
|
695
|
+
"name": name.strip(),
|
|
696
|
+
"address": address.strip(),
|
|
697
|
+
"city": "01010",
|
|
698
|
+
"district": district.strip(),
|
|
699
|
+
"state": state.strip(),
|
|
700
|
+
}
|
|
701
|
+
if isinstance(data, list) and data:
|
|
702
|
+
return _parse_nit_response(nit, data[0])
|
|
703
|
+
return {"nit": nit, "name": "", "address": "CIUDAD", "city": "01010",
|
|
704
|
+
"district": "GUATEMALA", "state": "GUATEMALA"}
|