digifact-sdk 2.0.4__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: digifact-sdk
3
- Version: 2.0.4
3
+ Version: 2.2.0
4
4
  Summary: Python SDK for Digifact FEL Guatemala e-invoicing API
5
5
  Author-email: AALONZOLU <noreply@github.com>
6
6
  License: MIT
@@ -72,6 +72,21 @@ result = client.invoice(
72
72
  items=[{"description": "Producto", "qty": 2, "price": 50.00}]
73
73
  )
74
74
 
75
+ # Full NIT buyer with explicit details (no auto-lookup)
76
+ result = client.invoice(
77
+ buyer={
78
+ "taxid": "12345678",
79
+ "name": "EMPRESA EJEMPLO S.A.",
80
+ "address": "6 AV 6-48 ZONA 9",
81
+ "city": "01009",
82
+ "district": "GUATEMALA",
83
+ "state": "GUATEMALA",
84
+ "country": "GT",
85
+ "email": "facturacion@empresa.com", # optional
86
+ },
87
+ items=[{"description": "Producto", "qty": 1, "price": 100.00}]
88
+ )
89
+
75
90
  # FCAM (Factura Cambiaria)
76
91
  result = client.invoice(
77
92
  buyer="12345678",
@@ -164,6 +179,62 @@ All money values are formatted as strings with 6 decimal places.
164
179
  }
165
180
  ```
166
181
 
182
+ ## Fuel invoices (FACT Combustible)
183
+
184
+ Fuel invoices emit IVA **and** a PETROLEO pass-through tax per SAT spec.
185
+ Items without `petroleo_amount` are treated as regular IVA-only items and
186
+ can coexist in the same invoice.
187
+
188
+ ### Option A — rates set once at client init (recommended for gas stations)
189
+
190
+ ```python
191
+ # Set PETROLEO rates once per fuel type (Q/gallon, from MEM or supplier invoice)
192
+ client = DigifactClient(
193
+ taxid="12345678",
194
+ username="FELUSER",
195
+ password="secret",
196
+ petroleo_rates={"1": 4.70, "2": 4.60, "4": 1.30}, # SUPER / REGULAR / DIESEL
197
+ )
198
+
199
+ # Only petroleo_code is needed — petroleo_amount is filled in automatically
200
+ result = client.fuel_invoice(
201
+ buyer="CF",
202
+ items=[
203
+ {"description": "GASOLINA SUPER", "qty": 30, "price": 35.00, "petroleo_code": "1", "type": "Bien"},
204
+ {"description": "GASOLINA REGULAR", "qty": 20, "price": 34.00, "petroleo_code": "2", "type": "Bien"},
205
+ {"description": "GASOLINA DIESEL", "qty": 50, "price": 32.00, "petroleo_code": "4", "type": "Bien"},
206
+ # Regular items (no petroleo_code): IVA only, can coexist
207
+ {"description": "FILTRO DE ACEITE", "qty": 1, "price": 45.00, "type": "Bien"},
208
+ {"description": "SET DE CANDELAS NGK", "qty": 1, "price": 400.00, "type": "Bien"},
209
+ ],
210
+ )
211
+ print(result.auth_number)
212
+ ```
213
+
214
+ ### Option B — explicit per-item amount
215
+
216
+ ```python
217
+ result = client.fuel_invoice(
218
+ buyer="CF",
219
+ items=[
220
+ {"description": "GASOLINA SUPER", "qty": 1, "price": 35.00, "petroleo_amount": 4.70, "petroleo_code": "1", "type": "Bien"},
221
+ {"description": "GASOLINA DIESEL", "qty": 1, "price": 32.00, "petroleo_amount": 1.30, "petroleo_code": "4", "type": "Bien"},
222
+ ],
223
+ )
224
+ ```
225
+
226
+ ### Fuel item dict keys
227
+
228
+ | Key | Type | Default | Description |
229
+ |-----|------|---------|-------------|
230
+ | `description` | `str` | required | Line description |
231
+ | `price` | `float\|Decimal` | required | Full consumer price per unit (PETROLEO + IVA-inclusive). This is what the customer pays at the pump. If the receipt shows a unit price *without* PETROLEO/IDP (e.g. `37.99`), add the per-unit IDP rate: `price = 37.99 + 4.70 = 42.69`. |
232
+ | `qty` | `float\|Decimal` | `1` | Quantity |
233
+ | `type` | `str` | `"Servicio"` | `"Bien"` or `"Servicio"` |
234
+ | `unit_of_measure` | `str` | `"UNI"` | SAT unit code |
235
+ | `petroleo_amount` | `float\|Decimal` | — | Per-unit PETROLEO tax; omit for IVA-only items |
236
+ | `petroleo_code` | `str` | `"1"` | `"1"`=SUPER, `"2"`=REGULAR, `"4"`=DIESEL. Required when `petroleo_amount` is omitted and `petroleo_rates` is set; raises `DigifactValidationError` if the code is not found in the rates dict. |
237
+
167
238
  ## Running tests
168
239
 
169
240
  ```bash
@@ -48,6 +48,21 @@ result = client.invoice(
48
48
  items=[{"description": "Producto", "qty": 2, "price": 50.00}]
49
49
  )
50
50
 
51
+ # Full NIT buyer with explicit details (no auto-lookup)
52
+ result = client.invoice(
53
+ buyer={
54
+ "taxid": "12345678",
55
+ "name": "EMPRESA EJEMPLO S.A.",
56
+ "address": "6 AV 6-48 ZONA 9",
57
+ "city": "01009",
58
+ "district": "GUATEMALA",
59
+ "state": "GUATEMALA",
60
+ "country": "GT",
61
+ "email": "facturacion@empresa.com", # optional
62
+ },
63
+ items=[{"description": "Producto", "qty": 1, "price": 100.00}]
64
+ )
65
+
51
66
  # FCAM (Factura Cambiaria)
52
67
  result = client.invoice(
53
68
  buyer="12345678",
@@ -140,6 +155,62 @@ All money values are formatted as strings with 6 decimal places.
140
155
  }
141
156
  ```
142
157
 
158
+ ## Fuel invoices (FACT Combustible)
159
+
160
+ Fuel invoices emit IVA **and** a PETROLEO pass-through tax per SAT spec.
161
+ Items without `petroleo_amount` are treated as regular IVA-only items and
162
+ can coexist in the same invoice.
163
+
164
+ ### Option A — rates set once at client init (recommended for gas stations)
165
+
166
+ ```python
167
+ # Set PETROLEO rates once per fuel type (Q/gallon, from MEM or supplier invoice)
168
+ client = DigifactClient(
169
+ taxid="12345678",
170
+ username="FELUSER",
171
+ password="secret",
172
+ petroleo_rates={"1": 4.70, "2": 4.60, "4": 1.30}, # SUPER / REGULAR / DIESEL
173
+ )
174
+
175
+ # Only petroleo_code is needed — petroleo_amount is filled in automatically
176
+ result = client.fuel_invoice(
177
+ buyer="CF",
178
+ items=[
179
+ {"description": "GASOLINA SUPER", "qty": 30, "price": 35.00, "petroleo_code": "1", "type": "Bien"},
180
+ {"description": "GASOLINA REGULAR", "qty": 20, "price": 34.00, "petroleo_code": "2", "type": "Bien"},
181
+ {"description": "GASOLINA DIESEL", "qty": 50, "price": 32.00, "petroleo_code": "4", "type": "Bien"},
182
+ # Regular items (no petroleo_code): IVA only, can coexist
183
+ {"description": "FILTRO DE ACEITE", "qty": 1, "price": 45.00, "type": "Bien"},
184
+ {"description": "SET DE CANDELAS NGK", "qty": 1, "price": 400.00, "type": "Bien"},
185
+ ],
186
+ )
187
+ print(result.auth_number)
188
+ ```
189
+
190
+ ### Option B — explicit per-item amount
191
+
192
+ ```python
193
+ result = client.fuel_invoice(
194
+ buyer="CF",
195
+ items=[
196
+ {"description": "GASOLINA SUPER", "qty": 1, "price": 35.00, "petroleo_amount": 4.70, "petroleo_code": "1", "type": "Bien"},
197
+ {"description": "GASOLINA DIESEL", "qty": 1, "price": 32.00, "petroleo_amount": 1.30, "petroleo_code": "4", "type": "Bien"},
198
+ ],
199
+ )
200
+ ```
201
+
202
+ ### Fuel item dict keys
203
+
204
+ | Key | Type | Default | Description |
205
+ |-----|------|---------|-------------|
206
+ | `description` | `str` | required | Line description |
207
+ | `price` | `float\|Decimal` | required | Full consumer price per unit (PETROLEO + IVA-inclusive). This is what the customer pays at the pump. If the receipt shows a unit price *without* PETROLEO/IDP (e.g. `37.99`), add the per-unit IDP rate: `price = 37.99 + 4.70 = 42.69`. |
208
+ | `qty` | `float\|Decimal` | `1` | Quantity |
209
+ | `type` | `str` | `"Servicio"` | `"Bien"` or `"Servicio"` |
210
+ | `unit_of_measure` | `str` | `"UNI"` | SAT unit code |
211
+ | `petroleo_amount` | `float\|Decimal` | — | Per-unit PETROLEO tax; omit for IVA-only items |
212
+ | `petroleo_code` | `str` | `"1"` | `"1"`=SUPER, `"2"`=REGULAR, `"4"`=DIESEL. Required when `petroleo_amount` is omitted and `petroleo_rates` is set; raises `DigifactValidationError` if the code is not found in the rates dict. |
213
+
143
214
  ## Running tests
144
215
 
145
216
  ```bash
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  from decimal import Decimal
11
11
  from typing import Any
12
12
 
13
- from .tax import LineCalc, InvoiceTotals, fmt, gt_now
13
+ from .tax import LineCalc, FuelLineCalc, InvoiceTotals, fmt, gt_now
14
14
 
15
15
  # ── No-IVA document types ─────────────────────────────────────────────────────
16
16
  NO_IVA_TYPES = {"FPEQ", "NABN", "RDON", "RECI"}
@@ -909,3 +909,175 @@ def build_cca(
909
909
  },
910
910
  }
911
911
  return payload
912
+
913
+
914
+ # ───────────────────────────────────────────────────────────────────────────────
915
+ # Combustible (fuel) builder
916
+ # ───────────────────────────────────────────────────────────────────────────────
917
+
918
+
919
+ def _build_fuel_items(
920
+ items: list[dict],
921
+ ) -> tuple[list[dict], str, str, str]:
922
+ """Build Items list for a combustible (fuel) invoice.
923
+
924
+ Items with 'petroleo_amount' are treated as fuel items and receive two
925
+ Tax entries (IVA + PETROLEO). Items without 'petroleo_amount' are treated
926
+ as regular IVA-only items. Both types may coexist in the same invoice.
927
+
928
+ Returns (line_items, grand_total_str, total_iva_str, total_petroleo_str).
929
+ """
930
+ line_items: list[dict] = []
931
+ grand_total = Decimal("0")
932
+ total_iva = Decimal("0")
933
+ total_petroleo = Decimal("0")
934
+
935
+ for i, item in enumerate(items, start=1):
936
+ qty = Decimal(str(item.get("qty", 1)))
937
+ price = Decimal(str(item["price"]))
938
+ item_type = item.get("type", "Bien")
939
+ uom = item.get("unit_of_measure", "UNI")
940
+ desc = item["description"]
941
+
942
+ built: dict[str, Any] = {
943
+ "Number": str(i),
944
+ "Codes": None,
945
+ "Type": item_type,
946
+ "Description": desc,
947
+ "UnitOfMeasure": uom,
948
+ "Discounts": None,
949
+ }
950
+
951
+ if "petroleo_amount" in item:
952
+ petrol_per_unit = Decimal(str(item["petroleo_amount"]))
953
+ petrol_code = str(item.get("petroleo_code", "1"))
954
+
955
+ fc = FuelLineCalc(qty, price, petrol_per_unit)
956
+
957
+ built["Qty"] = fc.f_qty()
958
+ built["Price"] = fc.f_price()
959
+ built["Taxes"] = {
960
+ "Tax": [
961
+ {
962
+ "Code": "1",
963
+ "Description": "IVA",
964
+ "TaxableAmount": fc.f_taxable(),
965
+ "Amount": fc.f_iva(),
966
+ },
967
+ {
968
+ "Code": petrol_code,
969
+ "Description": "PETROLEO",
970
+ "TaxableAmount": fc.f_qty(),
971
+ "Amount": fc.f_petrol(),
972
+ },
973
+ ]
974
+ }
975
+ built["Totals"] = {"TotalItem": fc.f_line_total()}
976
+
977
+ grand_total += fc.line_total
978
+ total_iva += fc.iva_amount
979
+ total_petroleo += fc.petrol_total
980
+ else:
981
+ lc = LineCalc(qty, price, taxable=True)
982
+
983
+ built["Qty"] = lc.f_qty()
984
+ built["Price"] = lc.f_price()
985
+ built["Taxes"] = {
986
+ "Tax": [
987
+ {
988
+ "Code": "1",
989
+ "Description": "IVA",
990
+ "TaxableAmount": lc.f_taxable(),
991
+ "Amount": lc.f_iva(),
992
+ }
993
+ ]
994
+ }
995
+ built["Totals"] = {"TotalItem": lc.f_line_total()}
996
+
997
+ grand_total += lc.line_total
998
+ total_iva += lc.iva_amount
999
+
1000
+ line_items.append(built)
1001
+
1002
+ return (
1003
+ line_items,
1004
+ fmt(grand_total),
1005
+ fmt(total_iva),
1006
+ fmt(total_petroleo),
1007
+ )
1008
+
1009
+
1010
+ def _build_fuel_adenda() -> dict:
1011
+ return {
1012
+ "AdditionalInfo": [
1013
+ {
1014
+ "Code": "00000013",
1015
+ "Type": "ADENDA",
1016
+ "AditionalInfo": [
1017
+ {"Name": "VALIDAR_REFERENCIA_INTERNA", "Data": None, "Value": "NO_VALIDAR"},
1018
+ ],
1019
+ }
1020
+ ]
1021
+ }
1022
+
1023
+
1024
+ def build_fact_combustible(
1025
+ taxid: str,
1026
+ seller_name: str,
1027
+ seller_address: str,
1028
+ buyer: dict,
1029
+ items: list[dict],
1030
+ *,
1031
+ afiliacion: str = "GEN",
1032
+ tipo_frase: str = "1",
1033
+ escenario: str = "1",
1034
+ seller_email: str | None = None,
1035
+ ) -> dict:
1036
+ """Build a FACT payload for combustible (fuel) invoices.
1037
+
1038
+ Fuel items must include ``petroleo_amount`` (per-unit PETROLEO tax amount)
1039
+ and optionally ``petroleo_code`` (SAT code: "1"=SUPER, "2"=REGULAR,
1040
+ "4"=DIESEL; default "1"). Items without ``petroleo_amount`` are treated as
1041
+ regular IVA-only items and may coexist in the same invoice.
1042
+
1043
+ Example fuel item::
1044
+
1045
+ {"description": "GASOLINA SUPER", "qty": 1, "price": 30.30,
1046
+ "petroleo_amount": 4.70, "petroleo_code": "1", "type": "Bien"}
1047
+
1048
+ Example regular item in the same invoice::
1049
+
1050
+ {"description": "FILTRO DE ACEITE", "qty": 1, "price": 45.00, "type": "Bien"}
1051
+ """
1052
+ issue_dt, _, _ = gt_now()
1053
+
1054
+ line_items, grand_total, total_iva, total_petroleo = _build_fuel_items(items)
1055
+
1056
+ seller = _build_seller(
1057
+ taxid,
1058
+ seller_name,
1059
+ seller_address,
1060
+ afiliacion=afiliacion,
1061
+ tipo_frase=tipo_frase,
1062
+ escenario=escenario,
1063
+ email=seller_email,
1064
+ )
1065
+
1066
+ total_tax: list[dict] = [{"Description": "IVA", "Amount": total_iva}]
1067
+ if float(total_petroleo) > 0.0:
1068
+ total_tax.append({"Description": "PETROLEO", "Amount": total_petroleo})
1069
+
1070
+ return {
1071
+ "Version": "1.00",
1072
+ "CountryCode": "GT",
1073
+ "Header": {"DocType": "FACT", "IssuedDateTime": issue_dt, "Currency": "GTQ"},
1074
+ "Seller": seller,
1075
+ "Buyer": buyer,
1076
+ "ThirdParties": None,
1077
+ "Items": line_items,
1078
+ "Totals": {
1079
+ "TotalTaxes": {"TotalTax": total_tax},
1080
+ "GrandTotal": {"InvoiceTotal": grand_total},
1081
+ },
1082
+ "AdditionalDocumentInfo": _build_fuel_adenda(),
1083
+ }
@@ -11,6 +11,7 @@ import requests
11
11
  from .builder import (
12
12
  build_cca,
13
13
  build_fact,
14
+ build_fact_combustible,
14
15
  build_fcam,
15
16
  build_fesp,
16
17
  build_fpeq,
@@ -81,6 +82,12 @@ class DigifactClient:
81
82
  HTTP request timeout in seconds (default 120).
82
83
  session:
83
84
  Optional ``requests.Session`` to use (useful for testing / mocking).
85
+ petroleo_rates:
86
+ Optional mapping of PETROLEO code → per-unit tax amount, e.g.
87
+ ``{"1": 4.70, "2": 4.60, "4": 1.30}`` (SUPER/REGULAR/DIESEL).
88
+ When set, fuel items in :meth:`fuel_invoice` that provide
89
+ ``petroleo_code`` but omit ``petroleo_amount`` will have the amount
90
+ filled in automatically from this table.
84
91
  """
85
92
 
86
93
  def __init__(
@@ -97,6 +104,7 @@ class DigifactClient:
97
104
  tipo_personeria: str = "1",
98
105
  timeout: int = 120,
99
106
  session: requests.Session | None = None,
107
+ petroleo_rates: dict[str, float] | None = None,
100
108
  ) -> None:
101
109
  self.taxid = re.sub(r"\D", "", taxid) # digits only
102
110
  if not self.taxid:
@@ -124,6 +132,7 @@ class DigifactClient:
124
132
  self._seller_name: str = seller_name
125
133
  self._seller_address: str = seller_address
126
134
  self._nit_cache: dict[str, dict] = {}
135
+ self.petroleo_rates: dict[str, float] = dict(petroleo_rates) if petroleo_rates else {}
127
136
 
128
137
  self._session = session or requests.Session()
129
138
 
@@ -404,6 +413,69 @@ class DigifactClient:
404
413
  data = self._certify(payload)
405
414
  return self._parse_result(data)
406
415
 
416
+ def fuel_invoice(
417
+ self,
418
+ buyer: str | dict,
419
+ items: list[dict],
420
+ ) -> DteResult:
421
+ """Emit a combustible (fuel) FACT invoice.
422
+
423
+ Parameters
424
+ ----------
425
+ buyer:
426
+ "CF", NIT string, CUI dict, or full buyer dict.
427
+ items:
428
+ List of item dicts. Fuel items must include:
429
+
430
+ * ``petroleo_amount`` – per-unit PETROLEO tax amount (float).
431
+ * ``petroleo_code`` – SAT code: ``"1"`` SUPER, ``"2"`` REGULAR,
432
+ ``"4"`` DIESEL (default ``"1"``).
433
+
434
+ Items without ``petroleo_amount`` are treated as regular IVA-only items.
435
+
436
+ Example::
437
+
438
+ client.fuel_invoice("CF", [
439
+ {"description": "GASOLINA SUPER", "qty": 1, "price": 30.30,
440
+ "petroleo_amount": 4.70, "petroleo_code": "1", "type": "Bien"},
441
+ {"description": "FILTRO DE ACEITE", "qty": 1, "price": 45.00,
442
+ "type": "Bien"},
443
+ ])
444
+ """
445
+ seller_name, seller_address = self._get_seller_info()
446
+ buyer_dict = self._resolve_buyer(buyer)
447
+ resolved = self._apply_petroleo_rates(items)
448
+ payload = build_fact_combustible(
449
+ self.taxid,
450
+ seller_name,
451
+ seller_address,
452
+ buyer_dict,
453
+ resolved,
454
+ afiliacion=self.afiliacion_iva,
455
+ )
456
+ data = self._certify(payload)
457
+ return self._parse_result(data)
458
+
459
+ def _apply_petroleo_rates(self, items: list[dict]) -> list[dict]:
460
+ """Fill in petroleo_amount from self.petroleo_rates when omitted.
461
+
462
+ Raises DigifactValidationError if an item has petroleo_code but no
463
+ petroleo_amount and no matching rate in petroleo_rates.
464
+ """
465
+ resolved = []
466
+ for item in items:
467
+ code = item.get("petroleo_code")
468
+ if code is not None and "petroleo_amount" not in item:
469
+ rate = self.petroleo_rates.get(str(code)) if self.petroleo_rates else None
470
+ if rate is None:
471
+ raise DigifactValidationError(
472
+ f"Item '{item.get('description', '')}' has petroleo_code='{code}' "
473
+ "but no petroleo_amount and no matching rate in petroleo_rates."
474
+ )
475
+ item = {**item, "petroleo_amount": rate}
476
+ resolved.append(item)
477
+ return resolved
478
+
407
479
  def credit_note(
408
480
  self,
409
481
  buyer: str | dict,
@@ -131,3 +131,49 @@ class InvoiceTotals:
131
131
 
132
132
  def f_total_iva(self) -> str:
133
133
  return fmt(self.total_iva)
134
+
135
+
136
+ class FuelLineCalc:
137
+ """Holds all calculated values for a fuel (combustible) invoice line item.
138
+
139
+ The PETROLEO tax is a fixed pass-through amount supplied by the caller.
140
+ ``unit_price`` is the full consumer price per unit (PETROLEO + IVA-inclusive).
141
+ IVA is calculated on qty × (unit_price − petrol_amount_per_unit).
142
+ TotalItem = qty × unit_price.
143
+ """
144
+
145
+ def __init__(
146
+ self,
147
+ qty: Decimal,
148
+ unit_price: Decimal,
149
+ petrol_amount_per_unit: Decimal,
150
+ ) -> None:
151
+ self.qty = qty
152
+ self.unit_price = unit_price
153
+ net_per_unit = unit_price - petrol_amount_per_unit
154
+ self.gross = (qty * net_per_unit).quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP)
155
+ self.petrol_total = (qty * petrol_amount_per_unit).quantize(
156
+ Decimal("0.000001"), rounding=ROUND_HALF_UP
157
+ )
158
+ self.line_total = (qty * unit_price).quantize(
159
+ Decimal("0.000001"), rounding=ROUND_HALF_UP
160
+ )
161
+ self.taxable_amount, self.iva_amount = calc_iva(self.gross)
162
+
163
+ def f_qty(self) -> str:
164
+ return fmt(self.qty)
165
+
166
+ def f_price(self) -> str:
167
+ return fmt(self.unit_price)
168
+
169
+ def f_line_total(self) -> str:
170
+ return fmt(self.line_total)
171
+
172
+ def f_taxable(self) -> str:
173
+ return fmt(self.taxable_amount)
174
+
175
+ def f_iva(self) -> str:
176
+ return fmt(self.iva_amount)
177
+
178
+ def f_petrol(self) -> str:
179
+ return fmt(self.petrol_total)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: digifact-sdk
3
- Version: 2.0.4
3
+ Version: 2.2.0
4
4
  Summary: Python SDK for Digifact FEL Guatemala e-invoicing API
5
5
  Author-email: AALONZOLU <noreply@github.com>
6
6
  License: MIT
@@ -72,6 +72,21 @@ result = client.invoice(
72
72
  items=[{"description": "Producto", "qty": 2, "price": 50.00}]
73
73
  )
74
74
 
75
+ # Full NIT buyer with explicit details (no auto-lookup)
76
+ result = client.invoice(
77
+ buyer={
78
+ "taxid": "12345678",
79
+ "name": "EMPRESA EJEMPLO S.A.",
80
+ "address": "6 AV 6-48 ZONA 9",
81
+ "city": "01009",
82
+ "district": "GUATEMALA",
83
+ "state": "GUATEMALA",
84
+ "country": "GT",
85
+ "email": "facturacion@empresa.com", # optional
86
+ },
87
+ items=[{"description": "Producto", "qty": 1, "price": 100.00}]
88
+ )
89
+
75
90
  # FCAM (Factura Cambiaria)
76
91
  result = client.invoice(
77
92
  buyer="12345678",
@@ -164,6 +179,62 @@ All money values are formatted as strings with 6 decimal places.
164
179
  }
165
180
  ```
166
181
 
182
+ ## Fuel invoices (FACT Combustible)
183
+
184
+ Fuel invoices emit IVA **and** a PETROLEO pass-through tax per SAT spec.
185
+ Items without `petroleo_amount` are treated as regular IVA-only items and
186
+ can coexist in the same invoice.
187
+
188
+ ### Option A — rates set once at client init (recommended for gas stations)
189
+
190
+ ```python
191
+ # Set PETROLEO rates once per fuel type (Q/gallon, from MEM or supplier invoice)
192
+ client = DigifactClient(
193
+ taxid="12345678",
194
+ username="FELUSER",
195
+ password="secret",
196
+ petroleo_rates={"1": 4.70, "2": 4.60, "4": 1.30}, # SUPER / REGULAR / DIESEL
197
+ )
198
+
199
+ # Only petroleo_code is needed — petroleo_amount is filled in automatically
200
+ result = client.fuel_invoice(
201
+ buyer="CF",
202
+ items=[
203
+ {"description": "GASOLINA SUPER", "qty": 30, "price": 35.00, "petroleo_code": "1", "type": "Bien"},
204
+ {"description": "GASOLINA REGULAR", "qty": 20, "price": 34.00, "petroleo_code": "2", "type": "Bien"},
205
+ {"description": "GASOLINA DIESEL", "qty": 50, "price": 32.00, "petroleo_code": "4", "type": "Bien"},
206
+ # Regular items (no petroleo_code): IVA only, can coexist
207
+ {"description": "FILTRO DE ACEITE", "qty": 1, "price": 45.00, "type": "Bien"},
208
+ {"description": "SET DE CANDELAS NGK", "qty": 1, "price": 400.00, "type": "Bien"},
209
+ ],
210
+ )
211
+ print(result.auth_number)
212
+ ```
213
+
214
+ ### Option B — explicit per-item amount
215
+
216
+ ```python
217
+ result = client.fuel_invoice(
218
+ buyer="CF",
219
+ items=[
220
+ {"description": "GASOLINA SUPER", "qty": 1, "price": 35.00, "petroleo_amount": 4.70, "petroleo_code": "1", "type": "Bien"},
221
+ {"description": "GASOLINA DIESEL", "qty": 1, "price": 32.00, "petroleo_amount": 1.30, "petroleo_code": "4", "type": "Bien"},
222
+ ],
223
+ )
224
+ ```
225
+
226
+ ### Fuel item dict keys
227
+
228
+ | Key | Type | Default | Description |
229
+ |-----|------|---------|-------------|
230
+ | `description` | `str` | required | Line description |
231
+ | `price` | `float\|Decimal` | required | Full consumer price per unit (PETROLEO + IVA-inclusive). This is what the customer pays at the pump. If the receipt shows a unit price *without* PETROLEO/IDP (e.g. `37.99`), add the per-unit IDP rate: `price = 37.99 + 4.70 = 42.69`. |
232
+ | `qty` | `float\|Decimal` | `1` | Quantity |
233
+ | `type` | `str` | `"Servicio"` | `"Bien"` or `"Servicio"` |
234
+ | `unit_of_measure` | `str` | `"UNI"` | SAT unit code |
235
+ | `petroleo_amount` | `float\|Decimal` | — | Per-unit PETROLEO tax; omit for IVA-only items |
236
+ | `petroleo_code` | `str` | `"1"` | `"1"`=SUPER, `"2"`=REGULAR, `"4"`=DIESEL. Required when `petroleo_amount` is omitted and `petroleo_rates` is set; raises `DigifactValidationError` if the code is not found in the rates dict. |
237
+
167
238
  ## Running tests
168
239
 
169
240
  ```bash
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "digifact-sdk"
7
- version = "2.0.4"
7
+ version = "2.2.0"
8
8
  description = "Python SDK for Digifact FEL Guatemala e-invoicing API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -78,6 +78,74 @@ class TestTaxCalcUnit(unittest.TestCase):
78
78
  self.assertEqual(pad_taxid("GT.000012345678"), "000012345678")
79
79
 
80
80
 
81
+ class TestFuelLineCalcUnit(unittest.TestCase):
82
+ def test_fuel_line_math(self):
83
+ from digifact_sdk.tax import FuelLineCalc
84
+ fc = FuelLineCalc(Decimal("1"), Decimal("35.00"), Decimal("4.70"))
85
+ self.assertEqual(fc.f_qty(), "1.000000")
86
+ self.assertEqual(fc.f_price(), "35.000000")
87
+ self.assertEqual(fc.f_line_total(), "35.000000")
88
+ self.assertEqual(fc.f_taxable(), "27.053571")
89
+ self.assertEqual(fc.f_iva(), "3.246429")
90
+ self.assertEqual(fc.f_petrol(), "4.700000")
91
+
92
+ def test_taxable_plus_iva_equals_gross(self):
93
+ from digifact_sdk.tax import FuelLineCalc
94
+ fc = FuelLineCalc(Decimal("2"), Decimal("53.0"), Decimal("3.0"))
95
+ total = Decimal(fc.f_taxable()) + Decimal(fc.f_iva())
96
+ # taxable + iva must equal qty × net (= 100.00)
97
+ self.assertEqual(total, Decimal("100.000000"))
98
+ # lineTotal = qty × price = 2 × 53.00
99
+ self.assertEqual(fc.f_line_total(), "106.000000")
100
+
101
+
102
+ class TestPetroleoRatesAutoFill(unittest.TestCase):
103
+ """Unit tests for DigifactClient._apply_petroleo_rates."""
104
+
105
+ def _make_client(self, rates=None):
106
+ from digifact_sdk.client import DigifactClient
107
+ return DigifactClient(
108
+ taxid="12345678",
109
+ username="U",
110
+ password="P",
111
+ petroleo_rates=rates,
112
+ )
113
+
114
+ def test_fills_amount_from_rates_when_code_set(self):
115
+ client = self._make_client({"1": 4.70, "4": 1.30})
116
+ resolved = client._apply_petroleo_rates([
117
+ {"description": "SUPER", "price": 30.30, "petroleo_code": "1"},
118
+ {"description": "DIESEL", "price": 30.70, "petroleo_code": "4"},
119
+ {"description": "FILTRO", "price": 45.00}, # no code → untouched
120
+ ])
121
+ self.assertEqual(resolved[0]["petroleo_amount"], 4.70)
122
+ self.assertEqual(resolved[1]["petroleo_amount"], 1.30)
123
+ self.assertNotIn("petroleo_amount", resolved[2])
124
+
125
+ def test_explicit_amount_never_overwritten(self):
126
+ client = self._make_client({"1": 4.70})
127
+ resolved = client._apply_petroleo_rates([
128
+ {"description": "SUPER", "price": 30.30, "petroleo_code": "1", "petroleo_amount": 9.99},
129
+ ])
130
+ self.assertEqual(resolved[0]["petroleo_amount"], 9.99)
131
+
132
+ def test_missing_rate_raises_error(self):
133
+ from digifact_sdk.exceptions import DigifactValidationError
134
+ client = self._make_client() # no rates
135
+ with self.assertRaises(DigifactValidationError):
136
+ client._apply_petroleo_rates([
137
+ {"description": "SUPER", "price": 30.30, "petroleo_code": "1"},
138
+ ])
139
+
140
+ def test_code_not_in_rates_raises_error(self):
141
+ from digifact_sdk.exceptions import DigifactValidationError
142
+ client = self._make_client({"1": 4.70}) # only SUPER configured
143
+ with self.assertRaises(DigifactValidationError):
144
+ client._apply_petroleo_rates([
145
+ {"description": "DIESEL", "price": 30.70, "petroleo_code": "4"},
146
+ ])
147
+
148
+
81
149
  class TestBuilderUnit(unittest.TestCase):
82
150
  def setUp(self):
83
151
  from digifact_sdk import builder
@@ -171,6 +239,54 @@ class TestBuilderUnit(unittest.TestCase):
171
239
  names = [d["Name"] for d in doc_info]
172
240
  self.assertIn("TipoPersoneria", names)
173
241
 
242
+ def test_buyer_nit_full_details(self):
243
+ from digifact_sdk.builder import _build_buyer_nit
244
+ buyer = _build_buyer_nit(
245
+ "12345678", "EMPRESA EJEMPLO S.A.",
246
+ address="6 AV 6-48 ZONA 9",
247
+ city="01009",
248
+ district="GUATEMALA",
249
+ state="GUATEMALA",
250
+ country="GT",
251
+ email="test@example.com",
252
+ )
253
+ self.assertEqual(buyer["TaxID"], "12345678")
254
+ self.assertEqual(buyer["Name"], "EMPRESA EJEMPLO S.A.")
255
+ self.assertEqual(buyer["AddressInfo"]["Address"], "6 AV 6-48 ZONA 9")
256
+ self.assertEqual(buyer["AddressInfo"]["City"], "01009")
257
+ self.assertEqual(buyer["Contact"]["EmailList"]["Email"][0], "test@example.com")
258
+
259
+ def test_build_fact_combustible_structure(self):
260
+ from digifact_sdk.builder import build_fact_combustible
261
+ buyer = {"TaxID": "CF", "Name": "CONSUMIDOR FINAL",
262
+ "AddressInfo": {"Address": "CIUDAD", "City": "01010",
263
+ "District": "GUATEMALA", "State": "GUATEMALA", "Country": "GT"}}
264
+ payload = build_fact_combustible(
265
+ "12345678", "TEST", "CALLE",
266
+ buyer=buyer,
267
+ items=[
268
+ {"description": "SUPER", "qty": 1, "price": 35.00,
269
+ "petroleo_amount": 4.70, "petroleo_code": "1", "type": "Bien"},
270
+ {"description": "FILTRO", "qty": 1, "price": 45.00, "type": "Bien"},
271
+ ],
272
+ )
273
+ self.assertEqual(payload["Header"]["DocType"], "FACT")
274
+ # Fuel item: 2 taxes (IVA + PETROLEO)
275
+ fuel_taxes = payload["Items"][0]["Taxes"]["Tax"]
276
+ self.assertEqual(len(fuel_taxes), 2)
277
+ self.assertEqual(fuel_taxes[1]["Description"], "PETROLEO")
278
+ self.assertEqual(fuel_taxes[1]["Code"], "1")
279
+ # Regular item: 1 tax (IVA only)
280
+ reg_taxes = payload["Items"][1]["Taxes"]["Tax"]
281
+ self.assertEqual(len(reg_taxes), 1)
282
+ self.assertEqual(reg_taxes[0]["Description"], "IVA")
283
+ # Adenda code
284
+ adenda_code = payload["AdditionalDocumentInfo"]["AdditionalInfo"][0]["Code"]
285
+ self.assertEqual(adenda_code, "00000013")
286
+ # PETROLEO total appears in TotalTaxes
287
+ total_descs = [t["Description"] for t in payload["Totals"]["TotalTaxes"]["TotalTax"]]
288
+ self.assertIn("PETROLEO", total_descs)
289
+
174
290
 
175
291
  # ── Integration tests (shared client, 1 login total) ─────────────────────────
176
292
 
@@ -428,5 +544,22 @@ class TestGetDte(unittest.TestCase):
428
544
  self.assertIsInstance(info, dict)
429
545
 
430
546
 
547
+ @unittest.skipIf(SKIP, SKIP_REASON)
548
+ class TestFuelInvoice(unittest.TestCase):
549
+ def test_fuel_invoice_cf_mixed_items(self):
550
+ result = _client().fuel_invoice(
551
+ "CF",
552
+ [
553
+ {"description": "GASOLINA SUPER", "qty": 1, "price": 35.00,
554
+ "petroleo_amount": 4.70, "petroleo_code": "1", "type": "Bien"},
555
+ {"description": "GASOLINA REGULAR", "qty": 1, "price": 34.00,
556
+ "petroleo_amount": 4.60, "petroleo_code": "2", "type": "Bien"},
557
+ {"description": "FILTRO DE ACEITE", "qty": 1, "price": 45.00, "type": "Bien"},
558
+ ],
559
+ )
560
+ self.assertTrue(result.auth_number, "Expected auth_number")
561
+ print(f"\n FACT Combustible auth: {result.auth_number}")
562
+
563
+
431
564
  if __name__ == "__main__":
432
565
  unittest.main()
File without changes