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.
@@ -0,0 +1,911 @@
1
+ """DTE payload builders for all supported Digifact FEL document types.
2
+
3
+ Key intentional API typos preserved from the SAT/Digifact spec:
4
+ - Seller.AdditionlInfo (missing 'a' in Additional)
5
+ - AdditionalDocumentInfo → AdditionalInfo[].AditionalData (missing 'd')
6
+ - AdditionalDocumentInfo → AdditionalInfo[].AditionalInfo (missing 'd')
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from decimal import Decimal
11
+ from typing import Any
12
+
13
+ from .tax import LineCalc, InvoiceTotals, fmt, gt_now
14
+
15
+ # ── No-IVA document types ─────────────────────────────────────────────────────
16
+ NO_IVA_TYPES = {"FPEQ", "NABN", "RDON", "RECI"}
17
+
18
+ # ── ADENDA codes ──────────────────────────────────────────────────────────────
19
+ ADENDA_CODE_STD = "FRONT-263C-444B-89BA-6F87EC1330C0"
20
+ ADENDA_CODE_ALT = "FRONT-67C1-4545-BA1E-AA3C115E18D6"
21
+
22
+ # Types that use the alt ADENDA code
23
+ _ALT_ADENDA_TYPES = {"NABN", "RDON", "RECI"}
24
+
25
+
26
+ def _adenda_code(doc_type: str) -> str:
27
+ return ADENDA_CODE_ALT if doc_type in _ALT_ADENDA_TYPES else ADENDA_CODE_STD
28
+
29
+
30
+ # ─────────────────────────────────────────────────────────────────────────────
31
+ # Internal helpers
32
+ # ─────────────────────────────────────────────────────────────────────────────
33
+
34
+ def _build_seller(
35
+ taxid: str,
36
+ name: str,
37
+ address: str,
38
+ *,
39
+ afiliacion: str = "GEN",
40
+ tipo_frase: str | None = "1",
41
+ escenario: str | None = "1",
42
+ branch_code: str = "1",
43
+ branch_name: str = "ESTABLECIMIENTO PRINCIPAL",
44
+ city: str = "01010",
45
+ district: str = "Guatemala",
46
+ state: str = "Guatemala",
47
+ country: str = "GT",
48
+ email: str | None = None,
49
+ ) -> dict:
50
+ seller: dict[str, Any] = {
51
+ "TaxID": taxid,
52
+ "TaxIDAdditionalInfo": [
53
+ {"Name": "AfiliacionIVA", "Data": None, "Value": afiliacion}
54
+ ],
55
+ "Name": name,
56
+ "BranchInfo": {
57
+ "Code": branch_code,
58
+ "Name": branch_name,
59
+ "AddressInfo": {
60
+ "Address": address,
61
+ "City": city,
62
+ "District": district,
63
+ "State": state,
64
+ "Country": country,
65
+ },
66
+ },
67
+ }
68
+ if email:
69
+ seller["Contact"] = {"EmailList": {"Email": [email]}}
70
+ # AdditionlInfo — intentional typo in API spec
71
+ if tipo_frase is not None and escenario is not None:
72
+ seller["AdditionlInfo"] = [
73
+ {"Name": "TipoFrase", "Data": "1", "Value": tipo_frase},
74
+ {"Name": "Escenario", "Data": "1", "Value": escenario},
75
+ ]
76
+ return seller
77
+
78
+
79
+ def _build_buyer_cf() -> dict:
80
+ return {
81
+ "TaxID": "CF",
82
+ "Name": "CONSUMIDOR FINAL",
83
+ "AddressInfo": {
84
+ "Address": "CIUDAD",
85
+ "City": "01010",
86
+ "District": "GUATEMALA",
87
+ "State": "GUATEMALA",
88
+ "Country": "GT",
89
+ },
90
+ }
91
+
92
+
93
+ def _build_buyer_nit(
94
+ nit: str,
95
+ name: str,
96
+ address: str = "CIUDAD",
97
+ city: str = "01010",
98
+ district: str = "GUATEMALA",
99
+ state: str = "GUATEMALA",
100
+ country: str = "GT",
101
+ email: str | None = None,
102
+ ) -> dict:
103
+ buyer: dict[str, Any] = {
104
+ "TaxID": nit,
105
+ "Name": name,
106
+ "AddressInfo": {
107
+ "Address": address,
108
+ "City": city,
109
+ "District": district,
110
+ "State": state,
111
+ "Country": country,
112
+ },
113
+ }
114
+ if email:
115
+ buyer["Contact"] = {"EmailList": {"Email": [email]}}
116
+ return buyer
117
+
118
+
119
+ def _build_buyer_cui(taxid: str, name: str) -> dict:
120
+ return {
121
+ "TaxID": taxid,
122
+ "TaxIDType": "CUI",
123
+ "Name": name,
124
+ "AddressInfo": {
125
+ "Address": "CIUDAD",
126
+ "City": "01010",
127
+ "District": "GUATEMALA",
128
+ "State": "GUATEMALA",
129
+ "Country": "GT",
130
+ },
131
+ }
132
+
133
+
134
+ def _build_items(
135
+ items: list[dict],
136
+ taxable: bool = True,
137
+ ) -> tuple[list[dict], InvoiceTotals]:
138
+ """Build Items list and InvoiceTotals from a list of item dicts.
139
+
140
+ Each item dict supports:
141
+ description: str
142
+ qty: float | Decimal (default 1)
143
+ price: float | Decimal (unit price, IVA-inclusive)
144
+ type: str (default "Servicio")
145
+ unit_of_measure: str (default "UNI")
146
+ discount: float | None (line-level discount amount)
147
+ """
148
+ line_items = []
149
+ lines: list[LineCalc] = []
150
+
151
+ for i, item in enumerate(items, start=1):
152
+ qty = Decimal(str(item.get("qty", 1)))
153
+ price = Decimal(str(item["price"]))
154
+ item_type = item.get("type", "Servicio")
155
+ uom = item.get("unit_of_measure", "UNI")
156
+ desc = item["description"]
157
+ discount_val = item.get("discount")
158
+ discount = Decimal(str(discount_val)) if discount_val is not None else None
159
+
160
+ lc = LineCalc(qty, price, taxable=taxable, discount=discount)
161
+ lines.append(lc)
162
+
163
+ built: dict[str, Any] = {
164
+ "Number": str(i),
165
+ "Codes": None,
166
+ "Type": item_type,
167
+ "Description": desc,
168
+ "Qty": lc.f_qty(),
169
+ "UnitOfMeasure": uom,
170
+ "Price": lc.f_price(),
171
+ }
172
+
173
+ if discount is not None and discount > 0:
174
+ built["Discounts"] = {"Discount": [{"Amount": lc.f_discount()}]}
175
+ else:
176
+ built["Discounts"] = None
177
+
178
+ if taxable:
179
+ built["Taxes"] = {
180
+ "Tax": [
181
+ {
182
+ "Code": "1",
183
+ "Description": "IVA",
184
+ "TaxableAmount": lc.f_taxable(),
185
+ "Amount": lc.f_iva(),
186
+ }
187
+ ]
188
+ }
189
+ else:
190
+ built["Taxes"] = None
191
+
192
+ built["Totals"] = {"TotalItem": lc.f_line_total()}
193
+ line_items.append(built)
194
+
195
+ totals = InvoiceTotals(lines)
196
+ return line_items, totals
197
+
198
+
199
+ def _build_totals(totals: InvoiceTotals, taxable: bool = True) -> dict:
200
+ result: dict[str, Any] = {}
201
+ if taxable:
202
+ result["TotalTaxes"] = {
203
+ "TotalTax": [{"Description": "IVA", "Amount": totals.f_total_iva()}]
204
+ }
205
+ result["GrandTotal"] = {"InvoiceTotal": totals.f_grand_total()}
206
+ return result
207
+
208
+
209
+ def _adenda_entry(
210
+ index: int,
211
+ ean_code: str = "00001",
212
+ ) -> dict:
213
+ """Build a DetallesAux_Detalle ADENDA entry for a single line item."""
214
+ return {
215
+ "Name": "DetallesAux_Detalle",
216
+ "Info": [
217
+ {"Name": "NumeroLinea", "Data": None, "Value": str(index)},
218
+ {"Name": "Descripcion_Adicional", "Data": None, "Value": "-"},
219
+ {"Name": "CodigoEAN", "Data": None, "Value": ean_code},
220
+ {"Name": "CategoriaAdicional", "Data": None, "Value": "-"},
221
+ ],
222
+ }
223
+
224
+
225
+ def _build_adenda(
226
+ doc_type: str,
227
+ amount_str: str,
228
+ num_items: int,
229
+ observaciones: str = "-",
230
+ extra_aditional_info: list[dict] | None = None,
231
+ ) -> dict:
232
+ """Build the standard ADENDA AdditionalDocumentInfo block."""
233
+ code = _adenda_code(doc_type)
234
+
235
+ data = [
236
+ {
237
+ "Name": "INFORMACION_ADICIONAL",
238
+ "Info": [
239
+ {"Name": "OBSERVACIONES", "Data": None, "Value": observaciones},
240
+ {"Name": "CANTIDAD_LETRAS", "Data": None, "Value": amount_str},
241
+ ],
242
+ }
243
+ ]
244
+ for i in range(1, num_items + 1):
245
+ data.append(_adenda_entry(i))
246
+
247
+ aditional_info = [
248
+ {"Name": "VALIDAR_REFERENCIA_INTERNA", "Data": None, "Value": "NO_VALIDAR"}
249
+ ]
250
+ if extra_aditional_info:
251
+ aditional_info.extend(extra_aditional_info)
252
+
253
+ return {
254
+ "AdditionalInfo": [
255
+ {
256
+ "Code": code,
257
+ "Type": "ADENDA",
258
+ "AditionalData": {"Data": data},
259
+ "AditionalInfo": aditional_info,
260
+ }
261
+ ]
262
+ }
263
+
264
+
265
+ # ─────────────────────────────────────────────────────────────────────────────
266
+ # Public builder functions
267
+ # ─────────────────────────────────────────────────────────────────────────────
268
+
269
+ def build_fact(
270
+ taxid: str,
271
+ seller_name: str,
272
+ seller_address: str,
273
+ buyer: dict,
274
+ items: list[dict],
275
+ *,
276
+ doc_type: str = "FACT",
277
+ afiliacion: str = "GEN",
278
+ tipo_frase: str = "1",
279
+ escenario: str = "1",
280
+ amount_str: str = "",
281
+ observaciones: str = "-",
282
+ extra_header: dict | None = None,
283
+ seller_email: str | None = None,
284
+ ) -> dict:
285
+ """Build a FACT (or FACT-like) DTE payload."""
286
+ issue_dt, _, _ = gt_now()
287
+
288
+ taxable = doc_type not in NO_IVA_TYPES
289
+ line_items, totals = _build_items(items, taxable=taxable)
290
+
291
+ seller = _build_seller(
292
+ taxid,
293
+ seller_name,
294
+ seller_address,
295
+ afiliacion=afiliacion,
296
+ tipo_frase=tipo_frase,
297
+ escenario=escenario,
298
+ email=seller_email,
299
+ )
300
+
301
+ header: dict[str, Any] = {
302
+ "DocType": doc_type,
303
+ "IssuedDateTime": issue_dt,
304
+ "Currency": "GTQ",
305
+ }
306
+ if extra_header:
307
+ header.update(extra_header)
308
+
309
+ amt = amount_str or totals.f_grand_total()
310
+ adenda = _build_adenda(doc_type, amt, len(items), observaciones=observaciones)
311
+
312
+ payload: dict[str, Any] = {
313
+ "Version": "1.00",
314
+ "CountryCode": "GT",
315
+ "Header": header,
316
+ "Seller": seller,
317
+ "Buyer": buyer,
318
+ "ThirdParties": None,
319
+ "Items": line_items,
320
+ "Totals": _build_totals(totals, taxable=taxable),
321
+ "AdditionalDocumentInfo": adenda,
322
+ }
323
+ return payload
324
+
325
+
326
+ def build_fcam(
327
+ taxid: str,
328
+ seller_name: str,
329
+ seller_address: str,
330
+ buyer: dict,
331
+ items: list[dict],
332
+ payment_terms: list[dict],
333
+ *,
334
+ afiliacion: str = "GEN",
335
+ amount_str: str = "",
336
+ observaciones: str = "-",
337
+ seller_email: str | None = None,
338
+ ) -> dict:
339
+ """Build a FCAM (Factura Cambiaria) DTE payload.
340
+
341
+ payment_terms: list of {"date": "YYYY-MM-DD", "amount": float}
342
+ """
343
+ issue_dt, _, _ = gt_now()
344
+
345
+ line_items, totals = _build_items(items, taxable=True)
346
+
347
+ seller = _build_seller(
348
+ taxid,
349
+ seller_name,
350
+ seller_address,
351
+ afiliacion=afiliacion,
352
+ tipo_frase="1",
353
+ escenario="1",
354
+ email=seller_email,
355
+ )
356
+
357
+ # FCAM complemento
358
+ fcam_data = []
359
+ for idx, pt in enumerate(payment_terms, start=1):
360
+ amount_val = Decimal(str(pt["amount"]))
361
+ fcam_data.append(
362
+ {
363
+ "Info": [
364
+ {"Name": "NumeroAbono", "Data": None, "Value": str(idx)},
365
+ {"Name": "FechaVencimiento", "Data": None, "Value": pt["date"]},
366
+ {"Name": "MontoAbono", "Data": None, "Value": fmt(amount_val, decimals=2)},
367
+ ]
368
+ }
369
+ )
370
+
371
+ payload: dict[str, Any] = {
372
+ "Version": "1.00",
373
+ "CountryCode": "GT",
374
+ "Header": {"DocType": "FCAM", "IssuedDateTime": issue_dt, "Currency": "GTQ"},
375
+ "Seller": seller,
376
+ "Buyer": buyer,
377
+ "ThirdParties": None,
378
+ "Items": line_items,
379
+ "Totals": _build_totals(totals, taxable=True),
380
+ "AdditionalDocumentInfo": {
381
+ "AdditionalInfo": [
382
+ {
383
+ "Code": "FCAMB",
384
+ "Type": "COMPLEMENTO",
385
+ "AditionalData": {"Data": fcam_data},
386
+ }
387
+ ]
388
+ },
389
+ }
390
+ return payload
391
+
392
+
393
+ def build_ndeb(
394
+ taxid: str,
395
+ seller_name: str,
396
+ seller_address: str,
397
+ buyer: dict,
398
+ items: list[dict],
399
+ origin: dict,
400
+ reason: str,
401
+ *,
402
+ afiliacion: str = "GEN",
403
+ seller_email: str | None = None,
404
+ ) -> dict:
405
+ """Build a NDEB (Nota de Débito) DTE payload.
406
+
407
+ origin: {"auth_number": str, "date": "YYYY-MM-DD", "series": str, "number": str}
408
+ """
409
+ issue_dt, _, _ = gt_now()
410
+ line_items, totals = _build_items(items, taxable=True)
411
+
412
+ seller = _build_seller(
413
+ taxid,
414
+ seller_name,
415
+ seller_address,
416
+ afiliacion=afiliacion,
417
+ tipo_frase="1",
418
+ escenario="1",
419
+ email=seller_email,
420
+ )
421
+
422
+ payload: dict[str, Any] = {
423
+ "Version": "1.00",
424
+ "CountryCode": "GT",
425
+ "Header": {"DocType": "NDEB", "IssuedDateTime": issue_dt, "Currency": "GTQ"},
426
+ "Seller": seller,
427
+ "Buyer": buyer,
428
+ "ThirdParties": None,
429
+ "Items": line_items,
430
+ "Totals": _build_totals(totals, taxable=True),
431
+ "AdditionalDocumentInfo": {
432
+ "AdditionalInfo": [
433
+ {
434
+ "Code": "NDEB",
435
+ "Type": "COMPLEMENTO",
436
+ # NDEB uses AditionalInfo (not AditionalData) — intentional API naming
437
+ "AditionalInfo": [
438
+ {
439
+ "Name": "NumeroAutorizacionDocumentoOrigen",
440
+ "Data": None,
441
+ "Value": origin["auth_number"],
442
+ },
443
+ {
444
+ "Name": "FechaEmisionDocumentoOrigen",
445
+ "Data": None,
446
+ "Value": origin["date"],
447
+ },
448
+ {
449
+ "Name": "MotivoAjuste",
450
+ "Data": None,
451
+ "Value": reason,
452
+ },
453
+ {
454
+ "Name": "SerieDocumentoOrigen",
455
+ "Data": None,
456
+ "Value": origin["series"],
457
+ },
458
+ {
459
+ "Name": "NumeroDocumentoOrigen",
460
+ "Data": None,
461
+ "Value": str(origin["number"]),
462
+ },
463
+ ],
464
+ }
465
+ ]
466
+ },
467
+ }
468
+ return payload
469
+
470
+
471
+ def build_ncre(
472
+ taxid: str,
473
+ seller_name: str,
474
+ seller_address: str,
475
+ buyer: dict,
476
+ items: list[dict],
477
+ origin: dict,
478
+ reason: str,
479
+ *,
480
+ afiliacion: str = "GEN",
481
+ seller_email: str | None = None,
482
+ ) -> dict:
483
+ """Build a NCRE (Nota de Crédito) DTE payload.
484
+
485
+ origin: {"auth_number": str, "date": "YYYY-MM-DD", "series": str, "number": str}
486
+ """
487
+ issue_dt, _, _ = gt_now()
488
+ line_items, totals = _build_items(items, taxable=True)
489
+
490
+ seller = _build_seller(
491
+ taxid,
492
+ seller_name,
493
+ seller_address,
494
+ afiliacion=afiliacion,
495
+ tipo_frase="1",
496
+ escenario="1",
497
+ email=seller_email,
498
+ )
499
+
500
+ payload: dict[str, Any] = {
501
+ "Version": "1.00",
502
+ "CountryCode": "GT",
503
+ "Header": {"DocType": "NCRE", "IssuedDateTime": issue_dt, "Currency": "GTQ"},
504
+ "Seller": seller,
505
+ "Buyer": buyer,
506
+ "ThirdParties": None,
507
+ "Items": line_items,
508
+ "Totals": _build_totals(totals, taxable=True),
509
+ "AdditionalDocumentInfo": {
510
+ "AdditionalInfo": [
511
+ {
512
+ "Code": "NCRE",
513
+ "Type": "COMPLEMENTO",
514
+ # NCRE uses AditionalInfo (not AditionalData) — intentional API naming
515
+ "AditionalInfo": [
516
+ {
517
+ "Name": "NumeroAutorizacionDocumentoOrigen",
518
+ "Data": None,
519
+ "Value": origin["auth_number"],
520
+ },
521
+ {
522
+ "Name": "FechaEmisionDocumentoOrigen",
523
+ "Data": None,
524
+ "Value": origin["date"],
525
+ },
526
+ {
527
+ "Name": "MotivoAjuste",
528
+ "Data": None,
529
+ "Value": reason,
530
+ },
531
+ {
532
+ "Name": "NumeroDocumentoOrigen",
533
+ "Data": None,
534
+ "Value": str(origin["number"]),
535
+ },
536
+ {
537
+ "Name": "SerieDocumentoOrigen",
538
+ "Data": None,
539
+ "Value": origin["series"],
540
+ },
541
+ ],
542
+ }
543
+ ]
544
+ },
545
+ }
546
+ return payload
547
+
548
+
549
+ def build_nabn(
550
+ taxid: str,
551
+ seller_name: str,
552
+ seller_address: str,
553
+ buyer: dict,
554
+ items: list[dict],
555
+ *,
556
+ afiliacion: str = "GEN",
557
+ amount_str: str = "",
558
+ observaciones: str = "-",
559
+ seller_email: str | None = None,
560
+ ) -> dict:
561
+ """Build a NABN (Nota de Abono) DTE payload — no IVA."""
562
+ issue_dt, _, _ = gt_now()
563
+ line_items, totals = _build_items(items, taxable=False)
564
+
565
+ # NABN: uses TipoFrase=1, Escenario=1 in seller (matches smoke runner)
566
+ seller = _build_seller(
567
+ taxid,
568
+ seller_name,
569
+ seller_address,
570
+ afiliacion=afiliacion,
571
+ tipo_frase="1",
572
+ escenario="1",
573
+ email=seller_email,
574
+ )
575
+
576
+ amt = amount_str or totals.f_grand_total()
577
+ adenda = _build_adenda("NABN", amt, len(items), observaciones=observaciones)
578
+
579
+ payload: dict[str, Any] = {
580
+ "Version": "1.00",
581
+ "CountryCode": "GT",
582
+ "Header": {"DocType": "NABN", "IssuedDateTime": issue_dt, "Currency": "GTQ"},
583
+ "Seller": seller,
584
+ "Buyer": buyer,
585
+ "ThirdParties": None,
586
+ "Items": line_items,
587
+ "Totals": _build_totals(totals, taxable=False),
588
+ "AdditionalDocumentInfo": adenda,
589
+ }
590
+ return payload
591
+
592
+
593
+ def build_fesp(
594
+ taxid: str,
595
+ seller_name: str,
596
+ seller_address: str,
597
+ buyer: dict,
598
+ items: list[dict],
599
+ *,
600
+ afiliacion: str = "GEN",
601
+ seller_email: str | None = None,
602
+ ) -> dict:
603
+ """Build a FESP (Factura Especial) DTE payload.
604
+
605
+ FESP has no AdditionlInfo in Seller (no TipoFrase/Escenario),
606
+ and includes FESP complemento with retenciones.
607
+ """
608
+ issue_dt, _, _ = gt_now()
609
+ line_items, totals = _build_items(items, taxable=True)
610
+
611
+ # FESP: explicitly no AdditionlInfo
612
+ seller = _build_seller(
613
+ taxid,
614
+ seller_name,
615
+ seller_address,
616
+ afiliacion=afiliacion,
617
+ tipo_frase=None,
618
+ escenario=None,
619
+ email=seller_email,
620
+ )
621
+
622
+ # FESP retenciones: RetencionISR = net_price * 0.05, RetencionIVA = total_iva
623
+ retencion_isr = (totals.total_taxable * Decimal("0.05")).quantize(
624
+ Decimal("0.000001"), rounding=__import__("decimal").ROUND_HALF_UP
625
+ )
626
+ retencion_iva = totals.total_iva
627
+ total_menos = (totals.grand_total - retencion_isr - retencion_iva).quantize(
628
+ Decimal("0.000001"), rounding=__import__("decimal").ROUND_HALF_UP
629
+ )
630
+
631
+ payload: dict[str, Any] = {
632
+ "Version": "1.00",
633
+ "CountryCode": "GT",
634
+ "Header": {"DocType": "FESP", "IssuedDateTime": issue_dt, "Currency": "GTQ"},
635
+ "Seller": seller,
636
+ "Buyer": buyer,
637
+ "ThirdParties": None,
638
+ "Items": line_items,
639
+ "Totals": _build_totals(totals, taxable=True),
640
+ "AdditionalDocumentInfo": {
641
+ "AdditionalInfo": [
642
+ {
643
+ "Code": "FESP",
644
+ "Type": "COMPLEMENTO",
645
+ "AditionalInfo": [
646
+ {"Name": "RetencionISR", "Data": None, "Value": fmt(retencion_isr)},
647
+ {"Name": "RetencionIVA", "Data": None, "Value": fmt(retencion_iva)},
648
+ {"Name": "TotalMenosRetenciones", "Data": None, "Value": fmt(total_menos)},
649
+ ],
650
+ }
651
+ ]
652
+ },
653
+ }
654
+ return payload
655
+
656
+
657
+ def build_rdon(
658
+ taxid: str,
659
+ seller_name: str,
660
+ seller_address: str,
661
+ buyer: dict,
662
+ items: list[dict],
663
+ tipo_personeria: str,
664
+ *,
665
+ afiliacion: str = "GEN",
666
+ amount_str: str = "",
667
+ observaciones: str = "-",
668
+ seller_email: str | None = None,
669
+ ) -> dict:
670
+ """Build a RDON (Recibo por Donación) DTE payload — no IVA."""
671
+ issue_dt, _, _ = gt_now()
672
+ line_items, totals = _build_items(items, taxable=False)
673
+
674
+ seller = _build_seller(
675
+ taxid,
676
+ seller_name,
677
+ seller_address,
678
+ afiliacion=afiliacion,
679
+ tipo_frase="4",
680
+ escenario="4",
681
+ email=seller_email,
682
+ )
683
+
684
+ amt = amount_str or totals.f_grand_total()
685
+ adenda = _build_adenda("RDON", amt, len(items), observaciones=observaciones)
686
+
687
+ payload: dict[str, Any] = {
688
+ "Version": "1.00",
689
+ "CountryCode": "GT",
690
+ "Header": {
691
+ "DocType": "RDON",
692
+ "IssuedDateTime": issue_dt,
693
+ "Currency": "GTQ",
694
+ "AdditionalIssueDocInfo": [
695
+ {"Name": "TipoPersoneria", "Data": None, "Value": tipo_personeria}
696
+ ],
697
+ },
698
+ "Seller": seller,
699
+ "Buyer": buyer,
700
+ "ThirdParties": None,
701
+ "Items": line_items,
702
+ "Totals": _build_totals(totals, taxable=False),
703
+ "AdditionalDocumentInfo": adenda,
704
+ }
705
+ return payload
706
+
707
+
708
+ def build_fpeq(
709
+ taxid: str,
710
+ seller_name: str,
711
+ seller_address: str,
712
+ buyer: dict,
713
+ items: list[dict],
714
+ *,
715
+ amount_str: str = "",
716
+ observaciones: str = "-",
717
+ seller_email: str | None = None,
718
+ ) -> dict:
719
+ """Build a FPEQ (Factura Pequeño Contribuyente) DTE payload — no IVA, AfiliacionIVA=PEQ."""
720
+ issue_dt, _, _ = gt_now()
721
+ line_items, totals = _build_items(items, taxable=False)
722
+
723
+ seller = _build_seller(
724
+ taxid,
725
+ seller_name,
726
+ seller_address,
727
+ afiliacion="PEQ",
728
+ tipo_frase="3",
729
+ escenario="1",
730
+ email=seller_email,
731
+ )
732
+
733
+ amt = amount_str or totals.f_grand_total()
734
+ adenda = _build_adenda("FPEQ", amt, len(items), observaciones=observaciones)
735
+
736
+ payload: dict[str, Any] = {
737
+ "Version": "1.00",
738
+ "CountryCode": "GT",
739
+ "Header": {"DocType": "FPEQ", "IssuedDateTime": issue_dt, "Currency": "GTQ"},
740
+ "Seller": seller,
741
+ "Buyer": buyer,
742
+ "ThirdParties": None,
743
+ "Items": line_items,
744
+ "Totals": _build_totals(totals, taxable=False),
745
+ "AdditionalDocumentInfo": adenda,
746
+ }
747
+ return payload
748
+
749
+
750
+ def build_reci(
751
+ taxid: str,
752
+ seller_name: str,
753
+ seller_address: str,
754
+ buyer: dict,
755
+ items: list[dict],
756
+ *,
757
+ afiliacion: str = "GEN",
758
+ amount_str: str = "",
759
+ observaciones: str = "-",
760
+ student_name: str = "Estudiante",
761
+ student_id: str = "000000000",
762
+ academic_unit: str = "01",
763
+ seller_email: str | None = None,
764
+ ) -> dict:
765
+ """Build a RECI (Recibo) DTE payload — no IVA, universidad ADENDA."""
766
+ issue_dt, _, _ = gt_now()
767
+ line_items, totals = _build_items(items, taxable=False)
768
+
769
+ seller = _build_seller(
770
+ taxid,
771
+ seller_name,
772
+ seller_address,
773
+ afiliacion=afiliacion,
774
+ tipo_frase="4",
775
+ escenario="5",
776
+ email=seller_email,
777
+ )
778
+
779
+ amt = amount_str or totals.f_grand_total()
780
+
781
+ # RECI: extra AditionalInfo entries for university data
782
+ extra_info = [
783
+ {"Name": "Tipo", "Data": None, "Value": "Universidad"},
784
+ {"Name": "NombreAlumno", "Data": None, "Value": student_name},
785
+ {"Name": "Carne", "Data": None, "Value": student_id},
786
+ {"Name": "UnidadAcademica", "Data": None, "Value": academic_unit},
787
+ ]
788
+ adenda = _build_adenda(
789
+ "RECI", amt, len(items), observaciones=observaciones, extra_aditional_info=extra_info
790
+ )
791
+
792
+ payload: dict[str, Any] = {
793
+ "Version": "1.00",
794
+ "CountryCode": "GT",
795
+ "Header": {"DocType": "RECI", "IssuedDateTime": issue_dt, "Currency": "GTQ"},
796
+ "Seller": seller,
797
+ "Buyer": buyer,
798
+ "ThirdParties": None,
799
+ "Items": line_items,
800
+ "Totals": _build_totals(totals, taxable=False),
801
+ "AdditionalDocumentInfo": adenda,
802
+ }
803
+ return payload
804
+
805
+
806
+ def build_cca(
807
+ taxid: str,
808
+ seller_name: str,
809
+ seller_address: str,
810
+ buyer: dict,
811
+ items: list[dict],
812
+ cobros: list[dict],
813
+ *,
814
+ afiliacion: str = "GEN",
815
+ seller_email: str | None = None,
816
+ ) -> dict:
817
+ """Build a CCA (Cobro por Cuenta Ajena) FACT+CCA complemento payload.
818
+
819
+ cobros: list of {
820
+ "nit_tercero": str,
821
+ "numero_documento": str | int,
822
+ "fecha_documento": "YYYY-MM-DD",
823
+ "descripcion": str,
824
+ "base_imponible": float,
825
+ "monto_cobro_dai": float = 0,
826
+ "monto_cobro_iva": float,
827
+ "monto_cobro_otros": float = 0,
828
+ "monto_cobro_total": float,
829
+ }
830
+ """
831
+ issue_dt, _, _ = gt_now()
832
+ line_items, totals = _build_items(items, taxable=True)
833
+
834
+ seller = _build_seller(
835
+ taxid,
836
+ seller_name,
837
+ seller_address,
838
+ afiliacion=afiliacion,
839
+ tipo_frase="1",
840
+ escenario="1",
841
+ email=seller_email,
842
+ )
843
+
844
+ cca_data = []
845
+ for cobro in cobros:
846
+ cca_data.append(
847
+ {
848
+ "Info": [
849
+ {"Name": "NITtercero", "Data": None, "Value": str(cobro["nit_tercero"])},
850
+ {
851
+ "Name": "NumeroDocumento",
852
+ "Data": None,
853
+ "Value": str(cobro["numero_documento"]),
854
+ },
855
+ {
856
+ "Name": "FechaDocumento",
857
+ "Data": None,
858
+ "Value": cobro["fecha_documento"],
859
+ },
860
+ {"Name": "Descripcion", "Data": None, "Value": cobro["descripcion"]},
861
+ {
862
+ "Name": "BaseImponible",
863
+ "Data": None,
864
+ "Value": fmt(Decimal(str(cobro["base_imponible"])), decimals=2),
865
+ },
866
+ {
867
+ "Name": "MontoCobroDAI",
868
+ "Data": None,
869
+ "Value": fmt(Decimal(str(cobro.get("monto_cobro_dai", 0))), decimals=2),
870
+ },
871
+ {
872
+ "Name": "MontoCobroIVA",
873
+ "Data": None,
874
+ "Value": fmt(Decimal(str(cobro["monto_cobro_iva"])), decimals=2),
875
+ },
876
+ {
877
+ "Name": "MontoCobroOtros",
878
+ "Data": None,
879
+ "Value": fmt(
880
+ Decimal(str(cobro.get("monto_cobro_otros", 0))), decimals=2
881
+ ),
882
+ },
883
+ {
884
+ "Name": "MontoCobroTotal",
885
+ "Data": None,
886
+ "Value": fmt(Decimal(str(cobro["monto_cobro_total"])), decimals=2),
887
+ },
888
+ ]
889
+ }
890
+ )
891
+
892
+ payload: dict[str, Any] = {
893
+ "Version": "1.00",
894
+ "CountryCode": "GT",
895
+ "Header": {"DocType": "FACT", "IssuedDateTime": issue_dt, "Currency": "GTQ"},
896
+ "Seller": seller,
897
+ "Buyer": buyer,
898
+ "ThirdParties": None,
899
+ "Items": line_items,
900
+ "Totals": _build_totals(totals, taxable=True),
901
+ "AdditionalDocumentInfo": {
902
+ "AdditionalInfo": [
903
+ {
904
+ "Code": "CCA",
905
+ "Type": "COMPLEMENTO",
906
+ "AditionalData": {"Data": cca_data},
907
+ }
908
+ ]
909
+ },
910
+ }
911
+ return payload