stackin-python-sdk 0.10.0__py3-none-any.whl → 0.11.0__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.
- stackin/__init__.py +1 -1
- stackin/br/product.py +38 -13
- stackin/core/address.py +9 -1
- stackin/core/client.py +153 -45
- stackin/core/reference.py +1 -2
- {stackin_python_sdk-0.10.0.dist-info → stackin_python_sdk-0.11.0.dist-info}/METADATA +15 -6
- stackin_python_sdk-0.11.0.dist-info/RECORD +15 -0
- {stackin_python_sdk-0.10.0.dist-info → stackin_python_sdk-0.11.0.dist-info}/WHEEL +1 -1
- stackin_python_sdk-0.10.0.dist-info/RECORD +0 -15
- {stackin_python_sdk-0.10.0.dist-info → stackin_python_sdk-0.11.0.dist-info}/licenses/LICENSE +0 -0
stackin/__init__.py
CHANGED
stackin/br/product.py
CHANGED
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from decimal import Decimal
|
|
6
6
|
from typing import Annotated, Any
|
|
7
7
|
|
|
8
|
-
from pydantic import BaseModel, Field
|
|
8
|
+
from pydantic import BaseModel, Field, field_validator
|
|
9
9
|
|
|
10
10
|
from stackin.br.tax import Tax
|
|
11
11
|
|
|
@@ -49,6 +49,15 @@ _BR_FIELDS = {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
|
|
52
|
+
def _gtin_check_digit_ok(gtin: str) -> bool:
|
|
53
|
+
"""GS1 modulo 10: weights 3,1,3,1... from the right, check excluded."""
|
|
54
|
+
body = gtin[-2::-1]
|
|
55
|
+
total = sum(
|
|
56
|
+
int(digit) * (3 if index % 2 == 0 else 1) for index, digit in enumerate(body)
|
|
57
|
+
)
|
|
58
|
+
return (10 - total % 10) % 10 == int(gtin[-1])
|
|
59
|
+
|
|
60
|
+
|
|
52
61
|
def _decimal_str(value: Decimal | None) -> str | None:
|
|
53
62
|
"""JSON has no decimal; a float would lose the tenth place."""
|
|
54
63
|
return None if value is None else format(value, "f")
|
|
@@ -62,7 +71,11 @@ class Product(BaseModel):
|
|
|
62
71
|
unit_price: Decimal | None = Field(default=None, gt=0)
|
|
63
72
|
unit: str = Field(default="UN")
|
|
64
73
|
quantity: float = Field(default=1.0, gt=0)
|
|
65
|
-
barcode: str | None = Field(
|
|
74
|
+
barcode: str | None = Field(
|
|
75
|
+
default=None,
|
|
76
|
+
pattern=r"^(SEM GTIN|\d{8}|\d{12,14})$",
|
|
77
|
+
description="cEAN: a GTIN-8/12/13/14, or SEM GTIN.",
|
|
78
|
+
)
|
|
66
79
|
freight: float | None = Field(default=None)
|
|
67
80
|
insurance: float | None = Field(default=None)
|
|
68
81
|
discount: float | None = Field(default=None)
|
|
@@ -71,25 +84,27 @@ class Product(BaseModel):
|
|
|
71
84
|
purchase_order: str | None = Field(default=None)
|
|
72
85
|
purchase_order_item: str | None = Field(default=None, pattern=r"^\d{1,6}$")
|
|
73
86
|
|
|
74
|
-
ncm: str | None = Field(
|
|
87
|
+
ncm: str | None = Field(
|
|
88
|
+
default=None,
|
|
89
|
+
pattern=r"^(00|\d{8})$",
|
|
90
|
+
description=(
|
|
91
|
+
"The full 8 digits, or 00 for a service line. Any other "
|
|
92
|
+
"short code the SEFAZ rejects with 777."
|
|
93
|
+
),
|
|
94
|
+
)
|
|
75
95
|
cfop: str | None = Field(default=None, pattern=r"^[123567]\d{3}$")
|
|
76
96
|
cest: str | None = Field(default=None, pattern=r"^\d{7}$")
|
|
77
|
-
nve_codes: (
|
|
78
|
-
|
|
79
|
-
) = Field(default=None)
|
|
80
|
-
ind_escala: str | None = Field(default=None)
|
|
81
|
-
manufacturer_cnpj: str | None = Field(
|
|
82
|
-
default=None, pattern=r"^[0-9A-Z]{12}\d{2}$"
|
|
97
|
+
nve_codes: list[Annotated[str, Field(pattern=r"^[A-Z]{2}\d{4}$")]] | None = Field(
|
|
98
|
+
default=None
|
|
83
99
|
)
|
|
100
|
+
ind_escala: str | None = Field(default=None)
|
|
101
|
+
manufacturer_cnpj: str | None = Field(default=None, pattern=r"^[0-9A-Z]{12}\d{2}$")
|
|
84
102
|
tax_benefit_code: str | None = Field(default=None, pattern=_CBENEF_PATTERN)
|
|
85
103
|
presumed_credits: list[PresumedCredit] | None = Field(default=None)
|
|
86
104
|
ex_tipi: str | None = Field(default=None, pattern=r"^\d{2,3}$")
|
|
87
105
|
import_content_control_number: str | None = Field(
|
|
88
106
|
default=None,
|
|
89
|
-
pattern=(
|
|
90
|
-
r"^[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}"
|
|
91
|
-
r"-[A-F0-9]{4}-[A-F0-9]{12}$"
|
|
92
|
-
),
|
|
107
|
+
pattern=(r"^[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}" r"-[A-F0-9]{4}-[A-F0-9]{12}$"),
|
|
93
108
|
)
|
|
94
109
|
recopi_number: str | None = Field(default=None, pattern=r"^\d{20}$")
|
|
95
110
|
ibs_cbs: IbsCbs | None = Field(default=None)
|
|
@@ -109,6 +124,16 @@ class Product(BaseModel):
|
|
|
109
124
|
default=None, max_length=2000, description="nfse only."
|
|
110
125
|
)
|
|
111
126
|
|
|
127
|
+
@field_validator("barcode")
|
|
128
|
+
@classmethod
|
|
129
|
+
def _valid_gtin_check_digit(cls, value: str | None) -> str | None:
|
|
130
|
+
"""Runs after the pattern: SEM GTIN or digits by now."""
|
|
131
|
+
if value is None or not value.isdigit():
|
|
132
|
+
return value
|
|
133
|
+
if not _gtin_check_digit_ok(value):
|
|
134
|
+
raise ValueError("barcode has an invalid GTIN check digit")
|
|
135
|
+
return value
|
|
136
|
+
|
|
112
137
|
def to_dict(self) -> dict:
|
|
113
138
|
"""Returns the item as a plain dict, ready for the request body."""
|
|
114
139
|
_NFSE_FIELDS = {
|
stackin/core/address.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from pydantic import BaseModel, Field
|
|
5
|
+
from pydantic import BaseModel, Field, field_validator
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class Address(BaseModel):
|
|
@@ -16,6 +16,14 @@ class Address(BaseModel):
|
|
|
16
16
|
city: str | None = Field(default=None)
|
|
17
17
|
zip_code: str | None = Field(default=None)
|
|
18
18
|
|
|
19
|
+
@field_validator("street", "neighborhood")
|
|
20
|
+
@classmethod
|
|
21
|
+
def _at_least_two_characters(cls, value: str | None, info) -> str | None:
|
|
22
|
+
"""The NF-e XSD sets xLgr and xBairro at 2 characters minimum."""
|
|
23
|
+
if value is not None and len(value.strip()) < 2:
|
|
24
|
+
raise ValueError(f"{info.field_name} must have at least 2 characters")
|
|
25
|
+
return value
|
|
26
|
+
|
|
19
27
|
def to_dict(self) -> dict:
|
|
20
28
|
"""Returns the address as a plain dict."""
|
|
21
29
|
return self.model_dump(exclude_none=True)
|
stackin/core/client.py
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
|
-
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any, cast
|
|
7
8
|
|
|
8
9
|
import requests
|
|
9
10
|
|
|
@@ -37,6 +38,12 @@ def _resolve_base_url(
|
|
|
37
38
|
return DEFAULT_BASE_URL
|
|
38
39
|
|
|
39
40
|
|
|
41
|
+
_NFE_DESCRIPTION_MAX = 120
|
|
42
|
+
|
|
43
|
+
# cUF+AAMM, then the issuer CNPJ (alphanumeric from July 2026: letters
|
|
44
|
+
# in its first 12 positions), then the rest. Same check as the API's.
|
|
45
|
+
_NFE_ACCESS_KEY = re.compile(r"[0-9]{6}[0-9A-Z]{12}[0-9]{26}")
|
|
46
|
+
|
|
40
47
|
_NFE_ADDRESS_FIELDS = (
|
|
41
48
|
"street",
|
|
42
49
|
"number",
|
|
@@ -53,9 +60,7 @@ def _validate_nfe_address(address: Address | None) -> None:
|
|
|
53
60
|
if address is None:
|
|
54
61
|
raise ValueError("recipient_address is required for NFE")
|
|
55
62
|
|
|
56
|
-
missing = [
|
|
57
|
-
field for field in _NFE_ADDRESS_FIELDS if not getattr(address, field)
|
|
58
|
-
]
|
|
63
|
+
missing = [field for field in _NFE_ADDRESS_FIELDS if not getattr(address, field)]
|
|
59
64
|
if missing:
|
|
60
65
|
raise ValueError(
|
|
61
66
|
"recipient_address is missing required fields for NFE: "
|
|
@@ -63,6 +68,77 @@ def _validate_nfe_address(address: Address | None) -> None:
|
|
|
63
68
|
)
|
|
64
69
|
|
|
65
70
|
|
|
71
|
+
# What the API converts before its own latin1 check (stackin-api#228):
|
|
72
|
+
# typographic quotes, dashes, ellipsis, bullet, euro and trademark.
|
|
73
|
+
# Those are not refused here; the SDK does not translate them either.
|
|
74
|
+
_API_CONVERTED = frozenset(
|
|
75
|
+
"\u2018\u2019\u201a\u201c\u201d\u201e\u2013\u2014\u2212\u2026\u2022\u20ac\u2122"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _first_non_latin1(value: Any, path: str) -> tuple[str, str] | None:
|
|
80
|
+
"""The first character in `value` the NF-e cannot carry, with its path.
|
|
81
|
+
|
|
82
|
+
Anything up to U+00FF passes (tab, newline and CR included: the API
|
|
83
|
+
folds them into a space), and so does what the API converts.
|
|
84
|
+
"""
|
|
85
|
+
if isinstance(value, str):
|
|
86
|
+
for char in value:
|
|
87
|
+
if char > "\u00ff" and char not in _API_CONVERTED:
|
|
88
|
+
return path, char
|
|
89
|
+
return None
|
|
90
|
+
if isinstance(value, dict):
|
|
91
|
+
for key, nested in value.items():
|
|
92
|
+
child = f"{path}.{key}" if path else str(key)
|
|
93
|
+
if found := _first_non_latin1(nested, child):
|
|
94
|
+
return found
|
|
95
|
+
return None
|
|
96
|
+
if isinstance(value, (list, tuple)):
|
|
97
|
+
for index, nested in enumerate(value):
|
|
98
|
+
if found := _first_non_latin1(nested, f"{path}[{index}]"):
|
|
99
|
+
return found
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _validate_nfe_latin1(payload: dict) -> None:
|
|
104
|
+
"""NF-e is encoded in ISO-8859-1; the SEFAZ refuses anything else.
|
|
105
|
+
|
|
106
|
+
Walks every string the request would carry, so a free-text field
|
|
107
|
+
added later is covered without being listed here. Refuses only what
|
|
108
|
+
the API would refuse after its own normalisation, never more.
|
|
109
|
+
"""
|
|
110
|
+
if found := _first_non_latin1(payload, ""):
|
|
111
|
+
path, char = found
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"{path} contains a character outside ISO-8859-1 (latin1), "
|
|
114
|
+
f"which NF-e does not accept: '{char}'"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_NFSE_RETAINED_ADDRESS_FIELDS = tuple(
|
|
119
|
+
field for field in _NFE_ADDRESS_FIELDS if field != "city_code"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _validate_nfse_retained_address(address: Address | None) -> None:
|
|
124
|
+
"""Retained ISSQN needs the tomador's address — the authorizer refuses
|
|
125
|
+
the document without it (E0237). Every field but city_code."""
|
|
126
|
+
if address is None:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
"recipient_address is required on an NFS-e when any item "
|
|
129
|
+
"has tax_retained=true (ISSQN retained by the tomador)"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
missing = [
|
|
133
|
+
field for field in _NFSE_RETAINED_ADDRESS_FIELDS if not getattr(address, field)
|
|
134
|
+
]
|
|
135
|
+
if missing:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
"recipient_address is missing required fields for NFSE with "
|
|
138
|
+
"tax_retained=true: " + ", ".join(missing)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
66
142
|
class _Client:
|
|
67
143
|
"""Where an api_key becomes an HTTP call, for every entry point.
|
|
68
144
|
|
|
@@ -172,15 +248,21 @@ class Invoice(_Client):
|
|
|
172
248
|
|
|
173
249
|
if document_type is DocumentType.NFE:
|
|
174
250
|
for index, item in enumerate(items):
|
|
251
|
+
if len(item.description) > _NFE_DESCRIPTION_MAX:
|
|
252
|
+
raise ValueError(
|
|
253
|
+
f"items[{index}].description exceeds "
|
|
254
|
+
f"{_NFE_DESCRIPTION_MAX} characters (NF-e xProd limit)"
|
|
255
|
+
)
|
|
175
256
|
if not item.ncm:
|
|
176
257
|
raise ValueError(f"items[{index}].ncm is required for NFE")
|
|
177
258
|
if not item.cfop:
|
|
178
|
-
raise ValueError(
|
|
179
|
-
f"items[{index}].cfop is required for NFE"
|
|
180
|
-
)
|
|
259
|
+
raise ValueError(f"items[{index}].cfop is required for NFE")
|
|
181
260
|
_validate_nfe_address(recipient_address)
|
|
261
|
+
retained = any(item.tax_retained for item in items)
|
|
262
|
+
if document_type is DocumentType.NFSE and retained:
|
|
263
|
+
_validate_nfse_retained_address(recipient_address)
|
|
182
264
|
|
|
183
|
-
payload = {
|
|
265
|
+
payload: dict[str, Any] = {
|
|
184
266
|
"document_type": document_type.value,
|
|
185
267
|
"client_name": client_name,
|
|
186
268
|
"tax_id": tax_id,
|
|
@@ -192,12 +274,17 @@ class Invoice(_Client):
|
|
|
192
274
|
payload["series"] = series
|
|
193
275
|
if number:
|
|
194
276
|
payload["number"] = number
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
277
|
+
if document_type is DocumentType.NFE:
|
|
278
|
+
_validate_nfe_latin1(payload)
|
|
279
|
+
|
|
280
|
+
return cast(
|
|
281
|
+
dict,
|
|
282
|
+
self._request(
|
|
283
|
+
"POST",
|
|
284
|
+
"/invoices",
|
|
285
|
+
json=payload,
|
|
286
|
+
idempotency_key=idempotency_key,
|
|
287
|
+
),
|
|
201
288
|
)
|
|
202
289
|
|
|
203
290
|
def consult(
|
|
@@ -209,7 +296,9 @@ class Invoice(_Client):
|
|
|
209
296
|
"""Consults a fiscal document by its access key."""
|
|
210
297
|
params = {"document_type": document_type.value}
|
|
211
298
|
|
|
212
|
-
return
|
|
299
|
+
return cast(
|
|
300
|
+
dict, self._request("GET", f"/invoices/{access_key}", params=params)
|
|
301
|
+
)
|
|
213
302
|
|
|
214
303
|
def cancel(
|
|
215
304
|
self,
|
|
@@ -231,11 +320,14 @@ class Invoice(_Client):
|
|
|
231
320
|
"reason": reason,
|
|
232
321
|
}
|
|
233
322
|
|
|
234
|
-
return
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
323
|
+
return cast(
|
|
324
|
+
dict,
|
|
325
|
+
self._request(
|
|
326
|
+
"POST",
|
|
327
|
+
f"/invoices/{access_key}/cancel",
|
|
328
|
+
json=payload,
|
|
329
|
+
idempotency_key=idempotency_key,
|
|
330
|
+
),
|
|
239
331
|
)
|
|
240
332
|
|
|
241
333
|
def invalidate(
|
|
@@ -259,7 +351,9 @@ class Invoice(_Client):
|
|
|
259
351
|
"reason": reason,
|
|
260
352
|
}
|
|
261
353
|
|
|
262
|
-
return
|
|
354
|
+
return cast(
|
|
355
|
+
dict, self._request("POST", "/invoices/invalidations", json=payload)
|
|
356
|
+
)
|
|
263
357
|
|
|
264
358
|
def correct(
|
|
265
359
|
self,
|
|
@@ -268,7 +362,20 @@ class Invoice(_Client):
|
|
|
268
362
|
document_type: DocumentType,
|
|
269
363
|
correction: str,
|
|
270
364
|
) -> dict:
|
|
271
|
-
"""Files a correction letter (CC-e) against an issued
|
|
365
|
+
"""Files a correction letter (CC-e) against an issued NF-e.
|
|
366
|
+
|
|
367
|
+
Only an NF-e has one, so the access key must be an NF-e access
|
|
368
|
+
key (44 characters; the CNPJ slot may carry letters); an NFS-e is
|
|
369
|
+
cancelled and issued again instead. That is checked here. An NF-e
|
|
370
|
+
key sent with DocumentType.NFSE, or one
|
|
371
|
+
for a document the API does not hold, is still answered by the
|
|
372
|
+
API as an APIError with status 409.
|
|
373
|
+
"""
|
|
374
|
+
if not _NFE_ACCESS_KEY.fullmatch(access_key):
|
|
375
|
+
raise ValueError(
|
|
376
|
+
"correct is NFe only. NFSe has no correction letter — "
|
|
377
|
+
"cancel and reissue instead."
|
|
378
|
+
)
|
|
272
379
|
if not 15 <= len(correction) <= 1000:
|
|
273
380
|
raise ValueError("correction must be 15 to 1000 characters")
|
|
274
381
|
|
|
@@ -277,18 +384,20 @@ class Invoice(_Client):
|
|
|
277
384
|
"correction": correction,
|
|
278
385
|
}
|
|
279
386
|
|
|
280
|
-
return
|
|
281
|
-
|
|
387
|
+
return cast(
|
|
388
|
+
dict,
|
|
389
|
+
self._request("POST", f"/invoices/{access_key}/correction", json=payload),
|
|
282
390
|
)
|
|
283
391
|
|
|
284
|
-
def reissue(
|
|
285
|
-
self, invoice_id: str, *, idempotency_key: str | None = None
|
|
286
|
-
) -> dict:
|
|
392
|
+
def reissue(self, invoice_id: str, *, idempotency_key: str | None = None) -> dict:
|
|
287
393
|
"""Retries a previous invoice submission by its local id."""
|
|
288
|
-
return
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
394
|
+
return cast(
|
|
395
|
+
dict,
|
|
396
|
+
self._request(
|
|
397
|
+
"POST",
|
|
398
|
+
f"/invoices/{invoice_id}/reissue",
|
|
399
|
+
idempotency_key=idempotency_key,
|
|
400
|
+
),
|
|
292
401
|
)
|
|
293
402
|
|
|
294
403
|
def pdf(
|
|
@@ -306,9 +415,7 @@ class Invoice(_Client):
|
|
|
306
415
|
unavailable, not that the invoice is wrong. NFS-e only.
|
|
307
416
|
"""
|
|
308
417
|
params = {"document_type": document_type.value}
|
|
309
|
-
response = self._send(
|
|
310
|
-
"GET", f"/invoices/{access_key}/pdf", params=params
|
|
311
|
-
)
|
|
418
|
+
response = self._send("GET", f"/invoices/{access_key}/pdf", params=params)
|
|
312
419
|
return response.content
|
|
313
420
|
|
|
314
421
|
def received(
|
|
@@ -329,7 +436,7 @@ class Invoice(_Client):
|
|
|
329
436
|
if offset is not None:
|
|
330
437
|
params["offset"] = offset
|
|
331
438
|
|
|
332
|
-
return self._request("GET", "/received-invoices", params=params)
|
|
439
|
+
return cast(dict, self._request("GET", "/received-invoices", params=params))
|
|
333
440
|
|
|
334
441
|
def history(
|
|
335
442
|
self,
|
|
@@ -360,7 +467,7 @@ class Invoice(_Client):
|
|
|
360
467
|
if order_by is not None:
|
|
361
468
|
params["order_by"] = order_by
|
|
362
469
|
|
|
363
|
-
return self._request("GET", "/invoices", params=params)
|
|
470
|
+
return cast(dict, self._request("GET", "/invoices", params=params))
|
|
364
471
|
|
|
365
472
|
def submissions(self, invoice_id: str) -> list:
|
|
366
473
|
"""Every attempt made for one invoice, and what came back.
|
|
@@ -368,7 +475,7 @@ class Invoice(_Client):
|
|
|
368
475
|
consult() gives the status; this gives the reason. Takes the
|
|
369
476
|
invoice_id because a rejected document has no access key.
|
|
370
477
|
"""
|
|
371
|
-
return self._request("GET", f"/invoices/{invoice_id}/submissions")
|
|
478
|
+
return cast(list, self._request("GET", f"/invoices/{invoice_id}/submissions"))
|
|
372
479
|
|
|
373
480
|
def manifest(
|
|
374
481
|
self,
|
|
@@ -386,21 +493,22 @@ class Invoice(_Client):
|
|
|
386
493
|
needs_reason = manifestation is Manifestation.OPERACAO_NAO_REALIZADA
|
|
387
494
|
if needs_reason and not reason:
|
|
388
495
|
raise ValueError(
|
|
389
|
-
"manifestation 210240 (operação não realizada) requires "
|
|
390
|
-
"a reason"
|
|
496
|
+
"manifestation 210240 (operação não realizada) requires " "a reason"
|
|
391
497
|
)
|
|
392
498
|
if not needs_reason and reason:
|
|
393
499
|
raise ValueError(
|
|
394
|
-
f"manifestation {manifestation.value} does not take a "
|
|
395
|
-
"reason"
|
|
500
|
+
f"manifestation {manifestation.value} does not take a " "reason"
|
|
396
501
|
)
|
|
397
502
|
|
|
398
503
|
payload = {"manifestation": manifestation.value}
|
|
399
504
|
if reason:
|
|
400
505
|
payload["reason"] = reason
|
|
401
506
|
|
|
402
|
-
return
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
507
|
+
return cast(
|
|
508
|
+
dict,
|
|
509
|
+
self._request(
|
|
510
|
+
"POST",
|
|
511
|
+
f"/received-invoices/{access_key}/manifestation",
|
|
512
|
+
json=payload,
|
|
513
|
+
),
|
|
406
514
|
)
|
stackin/core/reference.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackin-python-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: Python SDK for issuing, consulting and cancelling electronic invoices.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -180,9 +180,9 @@ client.issue(
|
|
|
180
180
|
)
|
|
181
181
|
```
|
|
182
182
|
|
|
183
|
-
`recipient_address` is an `Address` — the buyer's address, **required for NFE
|
|
183
|
+
`recipient_address` is an `Address` — the buyer's address, **required for NFE**. On NFE every field is required, `city_code` (the 7-digit IBGE municipality code) included: it becomes `enderDest` on the wire and the SEFAZ rejects a partial one. `state` is also what resolves `idDest` — a buyer in another state is emitted as an interstate operation automatically. On NFSE it is the tomador's address: optional, except when any item has `tax_retained=True` (the ISSQN retained by the tomador), where the city authorizer refuses the document without it (`E0237`) — then every field except `city_code` is required. In both cases a missing or incomplete address raises a `ValueError` locally, before the request goes out.
|
|
184
184
|
|
|
185
|
-
`items` is a list of `Product` (`stackin.br`) — `description`/`amount` apply to any document type; `ncm`/`cfop` (plus everything else on `Product`: `cest`, tax groups, presumed credits...) are Brazil-specific and required per item for NFE, ignored for NFSE (a service isn't a physical good).
|
|
185
|
+
`items` is a list of `Product` (`stackin.br`) — `description`/`amount` apply to any document type; `ncm`/`cfop` (plus everything else on `Product`: `cest`, tax groups, presumed credits...) are Brazil-specific and required per item for NFE, ignored for NFSE (a service isn't a physical good). `ncm` is the full 8 digits, or `00` for a service line — the SEFAZ rejects any other short code (rejection `777`), so one fails at construction.
|
|
186
186
|
|
|
187
187
|
## Retrying safely
|
|
188
188
|
|
|
@@ -251,8 +251,15 @@ The original document does not change — the CC-e is an event attached to it, a
|
|
|
251
251
|
the authorized XML stays as it was. A document accepts at most 20 of them, and
|
|
252
252
|
they are numbered for you.
|
|
253
253
|
|
|
254
|
-
**NF-e only.** NFS-e has no correction letter
|
|
255
|
-
|
|
254
|
+
**NF-e only.** NFS-e has no correction letter — cancel and reissue it instead.
|
|
255
|
+
The access key says which one it is: an NF-e access key is 44 characters, an
|
|
256
|
+
NFS-e key 50. A key that is not an NF-e access key (44 characters: digits, with
|
|
257
|
+
uppercase letters allowed in the first 12 positions of the issuer CNPJ, which
|
|
258
|
+
can be alphanumeric from July 2026) raises a `ValueError` locally, before the call:
|
|
259
|
+
`correct is NFe only. NFSe has no correction letter — cancel and reissue instead.`
|
|
260
|
+
What only the API can know stays an `APIError` with status `409`: an NF-e key
|
|
261
|
+
sent with `document_type=DocumentType.NFSE`, or a key for a document the API
|
|
262
|
+
does not hold.
|
|
256
263
|
|
|
257
264
|
## Invalidating unused numbers
|
|
258
265
|
|
|
@@ -348,7 +355,9 @@ Three things worth knowing before you loop:
|
|
|
348
355
|
|
|
349
356
|
- `stackin.APIError` — the API responded with a non-2xx status (`status_code`, `detail`) — a 401 here means `api_key` is missing, wrong, or was rotated.
|
|
350
357
|
- `stackin.ConnectionFailedError` — the API didn't respond (network/DNS/timeout).
|
|
351
|
-
- `ValueError` — `issue()`'s `items` is empty, missing `ncm`/`cfop` on an item for NFE, or a missing/incomplete `recipient_address` on
|
|
358
|
+
- `ValueError` — `issue()`'s `items` is empty, missing `ncm`/`cfop` on an item for NFE, an item `description` over 120 characters on NFE (the `xProd` limit; NFSE has no such limit), any string in an NFE request with a character the NF-e cannot carry (outside ISO-8859-1, latin1: an emoji, CJK, Cyrillic…; the error names the field — typographic quotes, dashes, ellipsis, bullet, `€` and `™` are not refused, the API converts them and folds tabs and line breaks into a space), a missing/incomplete `recipient_address` on NFE, or a missing/incomplete `recipient_address` (every field except `city_code`) on NFSE when any item has `tax_retained=True` (the ISSQN retained by the tomador).
|
|
359
|
+
- `ValueError` — `correct()` with a key that is not an NF-e access key (44 characters): a correction letter exists only on NF-e; an NFS-e is cancelled and reissued instead. An NF-e key sent with `DocumentType.NFSE`, or one the API does not hold, is an `APIError` 409, not a `ValueError`.
|
|
360
|
+
- `pydantic.ValidationError` (a `ValueError`) — a model built with a value the NF-e 4.00 XSD refuses: an `ncm` that is not 8 digits (or `00` for a service line), a `barcode` that is not `SEM GTIN` or an 8/12/13/14-digit GTIN with a valid GS1 check digit, or an `Address` `street`/`neighborhood` under 2 characters once trimmed.
|
|
352
361
|
|
|
353
362
|
Building the full fiscal document (issuer data, service code, tax groups, schema-accurate XML) is the API's job — configured once per company, not passed on every call.
|
|
354
363
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
stackin/__init__.py,sha256=Qz1bwPyRFnzXSxcIA3pusIDHpFUlZl4JvQoUI8U5Lno,689
|
|
2
|
+
stackin/br/__init__.py,sha256=-s-2-ZyCmGZA8jTR8KFgNOSoPjjgV57Ss_cdI-FjRRY,1206
|
|
3
|
+
stackin/br/product.py,sha256=OkiT_W1Ov8UHnSkfPqTR57RLY4z8XKvBFdjoJNuF_6w,5802
|
|
4
|
+
stackin/br/tax.py,sha256=UxcYX-kJw1HxUixB0hRABSgoHqT6CLoEjup4xIRFaYg,54747
|
|
5
|
+
stackin/core/__init__.py,sha256=dxtOP1DopMSNFYGh3Hf12RVcs58R9xwLn-gqfKFo-HE,19
|
|
6
|
+
stackin/core/address.py,sha256=PHpu9itOa-zMsRpURO2fIJJJALWteL2iBaaG8s0VYHI,1011
|
|
7
|
+
stackin/core/client.py,sha256=ykHnFuBKpszf2AoixKkWD7clS-JLZfEFzQs1hLeunCo,16676
|
|
8
|
+
stackin/core/exceptions.py,sha256=ELn-uL8rwjAFsD1PgM8Zr-1if3Z5yOWU81chUs4k6tU,505
|
|
9
|
+
stackin/core/reference.py,sha256=uVetU_Wzel0oUm_2SSeYD1KvKRsLV9RTgF2F2m8KzVE,5080
|
|
10
|
+
stackin/core/taxpayer.py,sha256=4YN7Oi5PDHGhLBW4M7eZlw9ARQn5sk4GLrC2_Z7-NhA,1669
|
|
11
|
+
stackin/core/types.py,sha256=NpfYVN58Ug9fEY7VYeQ2TC1eekoFGHZq_habvrqEeTY,604
|
|
12
|
+
stackin_python_sdk-0.11.0.dist-info/METADATA,sha256=CFd_VIJ_M7K99eczW6ZrRbViKYDwB36E_Wspih8Yzig,17191
|
|
13
|
+
stackin_python_sdk-0.11.0.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
|
|
14
|
+
stackin_python_sdk-0.11.0.dist-info/licenses/LICENSE,sha256=D-XHDcKFFhzEo9C5Dp4QPvt00pb_u__hi4Bc08zpxyU,1072
|
|
15
|
+
stackin_python_sdk-0.11.0.dist-info/RECORD,,
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
stackin/__init__.py,sha256=mssUlglaIcPNx4tAm_JRW_NamEYTyRUdUOSwxRXsASY,689
|
|
2
|
-
stackin/br/__init__.py,sha256=-s-2-ZyCmGZA8jTR8KFgNOSoPjjgV57Ss_cdI-FjRRY,1206
|
|
3
|
-
stackin/br/product.py,sha256=4T5urNCi0LJrxs03Kt2vbXs1DBzYlRijBULWxi6bZ44,4830
|
|
4
|
-
stackin/br/tax.py,sha256=UxcYX-kJw1HxUixB0hRABSgoHqT6CLoEjup4xIRFaYg,54747
|
|
5
|
-
stackin/core/__init__.py,sha256=dxtOP1DopMSNFYGh3Hf12RVcs58R9xwLn-gqfKFo-HE,19
|
|
6
|
-
stackin/core/address.py,sha256=-w5R3nzfU5T3VoxIeygu5Dq4DRb_lJHAGc0lmSMwEMU,616
|
|
7
|
-
stackin/core/client.py,sha256=Iipq9V9J4nbQ4LBmxqJeYphIWe1sWcPNVO8RxicAiCc,12374
|
|
8
|
-
stackin/core/exceptions.py,sha256=ELn-uL8rwjAFsD1PgM8Zr-1if3Z5yOWU81chUs4k6tU,505
|
|
9
|
-
stackin/core/reference.py,sha256=xNAcaRuiiHYF5M7l42Jr5W9GFfzqh3CeJaqZAwomkdw,5096
|
|
10
|
-
stackin/core/taxpayer.py,sha256=4YN7Oi5PDHGhLBW4M7eZlw9ARQn5sk4GLrC2_Z7-NhA,1669
|
|
11
|
-
stackin/core/types.py,sha256=NpfYVN58Ug9fEY7VYeQ2TC1eekoFGHZq_habvrqEeTY,604
|
|
12
|
-
stackin_python_sdk-0.10.0.dist-info/METADATA,sha256=8dGhwQSagI7jHluUufFtoW9K3qyU2dq3WK8QDoBuy74,15049
|
|
13
|
-
stackin_python_sdk-0.10.0.dist-info/WHEEL,sha256=eY7nduwzv-ldUxpzbRlxwvC693Hg6PX8bWDjEHjZ_dk,88
|
|
14
|
-
stackin_python_sdk-0.10.0.dist-info/licenses/LICENSE,sha256=D-XHDcKFFhzEo9C5Dp4QPvt00pb_u__hi4Bc08zpxyU,1072
|
|
15
|
-
stackin_python_sdk-0.10.0.dist-info/RECORD,,
|
{stackin_python_sdk-0.10.0.dist-info → stackin_python_sdk-0.11.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|