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.
- {digifact_sdk-2.0.4 → digifact_sdk-2.2.0}/PKG-INFO +72 -1
- {digifact_sdk-2.0.4 → digifact_sdk-2.2.0}/README.md +71 -0
- {digifact_sdk-2.0.4 → digifact_sdk-2.2.0}/digifact_sdk/builder.py +173 -1
- {digifact_sdk-2.0.4 → digifact_sdk-2.2.0}/digifact_sdk/client.py +72 -0
- {digifact_sdk-2.0.4 → digifact_sdk-2.2.0}/digifact_sdk/tax.py +46 -0
- {digifact_sdk-2.0.4 → digifact_sdk-2.2.0}/digifact_sdk.egg-info/PKG-INFO +72 -1
- {digifact_sdk-2.0.4 → digifact_sdk-2.2.0}/pyproject.toml +1 -1
- {digifact_sdk-2.0.4 → digifact_sdk-2.2.0}/tests/test_integration.py +133 -0
- {digifact_sdk-2.0.4 → digifact_sdk-2.2.0}/digifact_sdk/__init__.py +0 -0
- {digifact_sdk-2.0.4 → digifact_sdk-2.2.0}/digifact_sdk/exceptions.py +0 -0
- {digifact_sdk-2.0.4 → digifact_sdk-2.2.0}/digifact_sdk.egg-info/SOURCES.txt +0 -0
- {digifact_sdk-2.0.4 → digifact_sdk-2.2.0}/digifact_sdk.egg-info/dependency_links.txt +0 -0
- {digifact_sdk-2.0.4 → digifact_sdk-2.2.0}/digifact_sdk.egg-info/requires.txt +0 -0
- {digifact_sdk-2.0.4 → digifact_sdk-2.2.0}/digifact_sdk.egg-info/top_level.txt +0 -0
- {digifact_sdk-2.0.4 → digifact_sdk-2.2.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: digifact-sdk
|
|
3
|
-
Version: 2.0
|
|
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
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|