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/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"}