stackin-python-sdk 0.2.0__tar.gz → 0.3.1__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.
- {stackin_python_sdk-0.2.0 → stackin_python_sdk-0.3.1}/PKG-INFO +41 -2
- {stackin_python_sdk-0.2.0 → stackin_python_sdk-0.3.1}/README.md +40 -1
- {stackin_python_sdk-0.2.0 → stackin_python_sdk-0.3.1}/pyproject.toml +2 -2
- {stackin_python_sdk-0.2.0 → stackin_python_sdk-0.3.1}/stackin/__init__.py +3 -2
- {stackin_python_sdk-0.2.0 → stackin_python_sdk-0.3.1}/stackin/br/product.py +4 -1
- {stackin_python_sdk-0.2.0 → stackin_python_sdk-0.3.1}/stackin/core/client.py +105 -9
- {stackin_python_sdk-0.2.0 → stackin_python_sdk-0.3.1}/stackin/core/exceptions.py +1 -1
- {stackin_python_sdk-0.2.0 → stackin_python_sdk-0.3.1}/stackin/core/types.py +10 -1
- {stackin_python_sdk-0.2.0 → stackin_python_sdk-0.3.1}/LICENSE +0 -0
- {stackin_python_sdk-0.2.0 → stackin_python_sdk-0.3.1}/stackin/br/__init__.py +0 -0
- {stackin_python_sdk-0.2.0 → stackin_python_sdk-0.3.1}/stackin/br/tax.py +0 -0
- {stackin_python_sdk-0.2.0 → stackin_python_sdk-0.3.1}/stackin/core/__init__.py +0 -0
- {stackin_python_sdk-0.2.0 → stackin_python_sdk-0.3.1}/stackin/core/address.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackin-python-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Python SDK for issuing, consulting and cancelling electronic invoices.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -47,7 +47,7 @@ Description-Content-Type: text/markdown
|
|
|
47
47
|
|
|
48
48
|
Python SDK for fiscal document issuance — a handful of business fields, nothing about certificates, XML, XSD, signing or SOAP. The API resolves all of that from the issuer's own configuration, identified by `api_key`.
|
|
49
49
|
|
|
50
|
-
**One class, `Invoice`** — `issue()`/`consult()`/`cancel()`/`reissue()`, nothing else to instantiate. Each line item is a `Product` (`stackin.br`) — `description`/`amount` are universal, everything else (`ncm`/`cfop`/`cest`/tax groups...) is Brazil-specific and only required for NFE; NFSE ignores it.
|
|
50
|
+
**One class, `Invoice`** — `issue()`/`consult()`/`cancel()`/`reissue()`/`correct()`/`invalidate()`/`pdf()`/`received()`/`manifest()`, nothing else to instantiate. Each line item is a `Product` (`stackin.br`) — `description`/`amount` are universal, everything else (`ncm`/`cfop`/`cest`/tax groups...) is Brazil-specific and only required for NFE; NFSE ignores it.
|
|
51
51
|
|
|
52
52
|
## Install
|
|
53
53
|
|
|
@@ -80,6 +80,11 @@ client.cancel(
|
|
|
80
80
|
)
|
|
81
81
|
client.reissue(invoice["id"]) # retries a rejected/failed submission
|
|
82
82
|
|
|
83
|
+
# The authorizer's PDF, as raw bytes. NFS-e only; the XML stays the
|
|
84
|
+
# legally valid document, and a 502 here means the authorizer is down.
|
|
85
|
+
with open("nota.pdf", "wb") as file:
|
|
86
|
+
file.write(client.pdf(access_key, document_type=DocumentType.NFSE))
|
|
87
|
+
|
|
83
88
|
# NFE requires ncm/cfop on every item, plus the buyer's full recipient_address:
|
|
84
89
|
client.issue(
|
|
85
90
|
document_type=DocumentType.NFE,
|
|
@@ -195,6 +200,40 @@ round trip — and the authorizer checks again for what we can't see from here.
|
|
|
195
200
|
|
|
196
201
|
**NF-e only**, and it takes no access key: there is no document to point at.
|
|
197
202
|
|
|
203
|
+
## Documents issued against you
|
|
204
|
+
|
|
205
|
+
Everything above serves the **issuer**. These two serve the **recipient**: what
|
|
206
|
+
suppliers billed to this CNPJ, and the formal answer to it.
|
|
207
|
+
|
|
208
|
+
Reading the list never calls the SEFAZ. The authorizer caps how many times a CNPJ
|
|
209
|
+
may ask for its distribution per day, so collecting runs on a schedule on the API
|
|
210
|
+
side and a page refresh cannot spend that allowance.
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
from stackin import Manifestation
|
|
214
|
+
|
|
215
|
+
page = client.received(limit=20)
|
|
216
|
+
for document in page["data"]:
|
|
217
|
+
print(document["access_key"], document["issuer_name"], document["amount"])
|
|
218
|
+
|
|
219
|
+
client.manifest(access_key, manifestation=Manifestation.CIENCIA)
|
|
220
|
+
client.manifest(
|
|
221
|
+
access_key,
|
|
222
|
+
manifestation=Manifestation.OPERACAO_NAO_REALIZADA,
|
|
223
|
+
reason="Mercadoria nunca chegou ao endereco",
|
|
224
|
+
)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Before you answer a document the SEFAZ sends only a **summary** (`resNFe`): access
|
|
228
|
+
key, issuer, amount, date. The **full document** (`nfeProc`) arrives after a
|
|
229
|
+
manifestation, and the `schema` field on each row says which one you hold.
|
|
230
|
+
|
|
231
|
+
The four answers are `210200` Confirmação da Operação, `210210` Ciência da
|
|
232
|
+
Operação, `210220` Desconhecimento da Operação and `210240` Operação não
|
|
233
|
+
Realizada. Only the last one takes a reason, and it requires one — both rules are
|
|
234
|
+
checked locally, before the request goes out, because a round trip to be told a
|
|
235
|
+
fixed rule is a round trip wasted.
|
|
236
|
+
|
|
198
237
|
## Errors
|
|
199
238
|
|
|
200
239
|
- `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.
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
Python SDK for fiscal document issuance — a handful of business fields, nothing about certificates, XML, XSD, signing or SOAP. The API resolves all of that from the issuer's own configuration, identified by `api_key`.
|
|
20
20
|
|
|
21
|
-
**One class, `Invoice`** — `issue()`/`consult()`/`cancel()`/`reissue()`, nothing else to instantiate. Each line item is a `Product` (`stackin.br`) — `description`/`amount` are universal, everything else (`ncm`/`cfop`/`cest`/tax groups...) is Brazil-specific and only required for NFE; NFSE ignores it.
|
|
21
|
+
**One class, `Invoice`** — `issue()`/`consult()`/`cancel()`/`reissue()`/`correct()`/`invalidate()`/`pdf()`/`received()`/`manifest()`, nothing else to instantiate. Each line item is a `Product` (`stackin.br`) — `description`/`amount` are universal, everything else (`ncm`/`cfop`/`cest`/tax groups...) is Brazil-specific and only required for NFE; NFSE ignores it.
|
|
22
22
|
|
|
23
23
|
## Install
|
|
24
24
|
|
|
@@ -51,6 +51,11 @@ client.cancel(
|
|
|
51
51
|
)
|
|
52
52
|
client.reissue(invoice["id"]) # retries a rejected/failed submission
|
|
53
53
|
|
|
54
|
+
# The authorizer's PDF, as raw bytes. NFS-e only; the XML stays the
|
|
55
|
+
# legally valid document, and a 502 here means the authorizer is down.
|
|
56
|
+
with open("nota.pdf", "wb") as file:
|
|
57
|
+
file.write(client.pdf(access_key, document_type=DocumentType.NFSE))
|
|
58
|
+
|
|
54
59
|
# NFE requires ncm/cfop on every item, plus the buyer's full recipient_address:
|
|
55
60
|
client.issue(
|
|
56
61
|
document_type=DocumentType.NFE,
|
|
@@ -166,6 +171,40 @@ round trip — and the authorizer checks again for what we can't see from here.
|
|
|
166
171
|
|
|
167
172
|
**NF-e only**, and it takes no access key: there is no document to point at.
|
|
168
173
|
|
|
174
|
+
## Documents issued against you
|
|
175
|
+
|
|
176
|
+
Everything above serves the **issuer**. These two serve the **recipient**: what
|
|
177
|
+
suppliers billed to this CNPJ, and the formal answer to it.
|
|
178
|
+
|
|
179
|
+
Reading the list never calls the SEFAZ. The authorizer caps how many times a CNPJ
|
|
180
|
+
may ask for its distribution per day, so collecting runs on a schedule on the API
|
|
181
|
+
side and a page refresh cannot spend that allowance.
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
from stackin import Manifestation
|
|
185
|
+
|
|
186
|
+
page = client.received(limit=20)
|
|
187
|
+
for document in page["data"]:
|
|
188
|
+
print(document["access_key"], document["issuer_name"], document["amount"])
|
|
189
|
+
|
|
190
|
+
client.manifest(access_key, manifestation=Manifestation.CIENCIA)
|
|
191
|
+
client.manifest(
|
|
192
|
+
access_key,
|
|
193
|
+
manifestation=Manifestation.OPERACAO_NAO_REALIZADA,
|
|
194
|
+
reason="Mercadoria nunca chegou ao endereco",
|
|
195
|
+
)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Before you answer a document the SEFAZ sends only a **summary** (`resNFe`): access
|
|
199
|
+
key, issuer, amount, date. The **full document** (`nfeProc`) arrives after a
|
|
200
|
+
manifestation, and the `schema` field on each row says which one you hold.
|
|
201
|
+
|
|
202
|
+
The four answers are `210200` Confirmação da Operação, `210210` Ciência da
|
|
203
|
+
Operação, `210220` Desconhecimento da Operação and `210240` Operação não
|
|
204
|
+
Realizada. Only the last one takes a reason, and it requires one — both rules are
|
|
205
|
+
checked locally, before the request goes out, because a round trip to be told a
|
|
206
|
+
fixed rule is a round trip wasted.
|
|
207
|
+
|
|
169
208
|
## Errors
|
|
170
209
|
|
|
171
210
|
- `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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "stackin-python-sdk"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.1"
|
|
4
4
|
authors = [
|
|
5
5
|
{ name = "Fernando Celmer", email = "email@fernandocelmer.com" },
|
|
6
6
|
]
|
|
@@ -45,7 +45,7 @@ Changelog = "https://github.com/stackin-io/stackin-python-sdk/releases"
|
|
|
45
45
|
|
|
46
46
|
[tool.poetry]
|
|
47
47
|
name = "stackin-python-sdk"
|
|
48
|
-
version = "0.
|
|
48
|
+
version = "0.3.1"
|
|
49
49
|
description = "Python SDK for issuing, consulting and cancelling electronic invoices."
|
|
50
50
|
authors = ["Fernando Celmer <email@fernandocelmer.com>"]
|
|
51
51
|
readme = "README.md"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Invoice __init__ module."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.
|
|
3
|
+
__version__ = "0.3.1"
|
|
4
4
|
__description__ = (
|
|
5
5
|
"Python SDK for issuing, consulting and cancelling electronic invoices."
|
|
6
6
|
)
|
|
@@ -8,12 +8,13 @@ __description__ = (
|
|
|
8
8
|
from .core.address import Address
|
|
9
9
|
from .core.client import Invoice
|
|
10
10
|
from .core.exceptions import APIError, ConnectionFailedError, InvoiceError
|
|
11
|
-
from .core.types import DocumentType, Environment
|
|
11
|
+
from .core.types import DocumentType, Environment, Manifestation
|
|
12
12
|
|
|
13
13
|
__all__ = [
|
|
14
14
|
"Invoice",
|
|
15
15
|
"DocumentType",
|
|
16
16
|
"Environment",
|
|
17
|
+
"Manifestation",
|
|
17
18
|
"Address",
|
|
18
19
|
"InvoiceError",
|
|
19
20
|
"APIError",
|
|
@@ -67,7 +67,10 @@ class Product(BaseModel):
|
|
|
67
67
|
ex_tipi: str | None = Field(default=None, pattern=r"^\d{2,3}$")
|
|
68
68
|
import_content_control_number: str | None = Field(
|
|
69
69
|
default=None,
|
|
70
|
-
pattern=
|
|
70
|
+
pattern=(
|
|
71
|
+
r"^[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}"
|
|
72
|
+
r"-[A-F0-9]{4}-[A-F0-9]{12}$"
|
|
73
|
+
),
|
|
71
74
|
)
|
|
72
75
|
recopi_number: str | None = Field(default=None, pattern=r"^\d{20}$")
|
|
73
76
|
extra_groups: dict[str, Any] | None = Field(default=None)
|
|
@@ -9,7 +9,7 @@ import requests
|
|
|
9
9
|
from stackin.br.product import Product
|
|
10
10
|
from stackin.core.address import Address
|
|
11
11
|
from stackin.core.exceptions import APIError, ConnectionFailedError
|
|
12
|
-
from stackin.core.types import DocumentType, Environment
|
|
12
|
+
from stackin.core.types import DocumentType, Environment, Manifestation
|
|
13
13
|
|
|
14
14
|
DEFAULT_BASE_URL = "https://sdk.stackin.io"
|
|
15
15
|
|
|
@@ -23,7 +23,7 @@ _ENVIRONMENT_URLS = {
|
|
|
23
23
|
def _resolve_base_url(
|
|
24
24
|
base_url: str | None, environment: Environment | str | None
|
|
25
25
|
) -> str:
|
|
26
|
-
"""
|
|
26
|
+
"""Explicit param, then env var, then the environment's default."""
|
|
27
27
|
if base_url:
|
|
28
28
|
return base_url
|
|
29
29
|
if url := os.environ.get("STACKIN_BASE_URL"):
|
|
@@ -48,7 +48,7 @@ _NFE_ADDRESS_FIELDS = (
|
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
def _validate_nfe_address(address: Address | None) -> None:
|
|
51
|
-
"""NFE needs the
|
|
51
|
+
"""NFE needs the full address — a partial one the SEFAZ rejects."""
|
|
52
52
|
if address is None:
|
|
53
53
|
raise ValueError("recipient_address is required for NFE")
|
|
54
54
|
|
|
@@ -204,6 +204,81 @@ class Invoice:
|
|
|
204
204
|
idempotency_key=idempotency_key,
|
|
205
205
|
)
|
|
206
206
|
|
|
207
|
+
def pdf(
|
|
208
|
+
self,
|
|
209
|
+
access_key: str,
|
|
210
|
+
*,
|
|
211
|
+
document_type: DocumentType,
|
|
212
|
+
) -> bytes:
|
|
213
|
+
"""The authorizer's rendering of an authorized document.
|
|
214
|
+
|
|
215
|
+
Returns raw bytes — the only method that does not return a
|
|
216
|
+
parsed object. The XML is the legally valid document; this is a
|
|
217
|
+
convenience, and the authorizer's endpoint for it is unstable,
|
|
218
|
+
so an APIError with status 502 means the authorizer is
|
|
219
|
+
unavailable, not that the invoice is wrong. NFS-e only.
|
|
220
|
+
"""
|
|
221
|
+
params = {"document_type": document_type.value}
|
|
222
|
+
response = self._send(
|
|
223
|
+
"GET", f"/invoices/{access_key}/pdf", params=params
|
|
224
|
+
)
|
|
225
|
+
return response.content
|
|
226
|
+
|
|
227
|
+
def received(
|
|
228
|
+
self,
|
|
229
|
+
*,
|
|
230
|
+
limit: int | None = None,
|
|
231
|
+
offset: int | None = None,
|
|
232
|
+
) -> dict:
|
|
233
|
+
"""Documents other companies issued against this one.
|
|
234
|
+
|
|
235
|
+
Reads what the API already collected; it does not call the
|
|
236
|
+
authorizer. Collecting runs on a schedule there, because the
|
|
237
|
+
SEFAZ caps how many times a CNPJ may ask per day.
|
|
238
|
+
"""
|
|
239
|
+
params = {}
|
|
240
|
+
if limit is not None:
|
|
241
|
+
params["limit"] = limit
|
|
242
|
+
if offset is not None:
|
|
243
|
+
params["offset"] = offset
|
|
244
|
+
|
|
245
|
+
return self._request("GET", "/received-invoices", params=params)
|
|
246
|
+
|
|
247
|
+
def manifest(
|
|
248
|
+
self,
|
|
249
|
+
access_key: str,
|
|
250
|
+
*,
|
|
251
|
+
manifestation: Manifestation,
|
|
252
|
+
reason: str | None = None,
|
|
253
|
+
) -> dict:
|
|
254
|
+
"""The recipient's formal answer to a received document.
|
|
255
|
+
|
|
256
|
+
Only `OPERACAO_NAO_REALIZADA` takes a reason, and it requires
|
|
257
|
+
one. Both are fixed rules, so they are checked here rather than
|
|
258
|
+
spending a round trip to be told.
|
|
259
|
+
"""
|
|
260
|
+
needs_reason = manifestation is Manifestation.OPERACAO_NAO_REALIZADA
|
|
261
|
+
if needs_reason and not reason:
|
|
262
|
+
raise ValueError(
|
|
263
|
+
"manifestation 210240 (operação não realizada) requires "
|
|
264
|
+
"a reason"
|
|
265
|
+
)
|
|
266
|
+
if not needs_reason and reason:
|
|
267
|
+
raise ValueError(
|
|
268
|
+
f"manifestation {manifestation.value} does not take a "
|
|
269
|
+
"reason"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
payload = {"manifestation": manifestation.value}
|
|
273
|
+
if reason:
|
|
274
|
+
payload["reason"] = reason
|
|
275
|
+
|
|
276
|
+
return self._request(
|
|
277
|
+
"POST",
|
|
278
|
+
f"/received-invoices/{access_key}/manifestation",
|
|
279
|
+
json=payload,
|
|
280
|
+
)
|
|
281
|
+
|
|
207
282
|
def _headers(self, idempotency_key: str | None = None) -> dict:
|
|
208
283
|
headers = {}
|
|
209
284
|
if self.api_key:
|
|
@@ -221,6 +296,28 @@ class Invoice:
|
|
|
221
296
|
params: dict | None = None,
|
|
222
297
|
idempotency_key: str | None = None,
|
|
223
298
|
) -> dict:
|
|
299
|
+
response = self._send(
|
|
300
|
+
method,
|
|
301
|
+
path,
|
|
302
|
+
json=json,
|
|
303
|
+
params=params,
|
|
304
|
+
idempotency_key=idempotency_key,
|
|
305
|
+
)
|
|
306
|
+
try:
|
|
307
|
+
body = response.json() if response.content else {}
|
|
308
|
+
except ValueError:
|
|
309
|
+
body = {}
|
|
310
|
+
return body.get("result", body)
|
|
311
|
+
|
|
312
|
+
def _send(
|
|
313
|
+
self,
|
|
314
|
+
method: str,
|
|
315
|
+
path: str,
|
|
316
|
+
*,
|
|
317
|
+
json: dict | None = None,
|
|
318
|
+
params: dict | None = None,
|
|
319
|
+
idempotency_key: str | None = None,
|
|
320
|
+
) -> requests.Response:
|
|
224
321
|
url = f"{self.base_url}/api/v1{path}"
|
|
225
322
|
|
|
226
323
|
try:
|
|
@@ -235,15 +332,14 @@ class Invoice:
|
|
|
235
332
|
except requests.RequestException as error:
|
|
236
333
|
raise ConnectionFailedError(str(error)) from error
|
|
237
334
|
|
|
238
|
-
try:
|
|
239
|
-
body = response.json() if response.content else {}
|
|
240
|
-
except ValueError:
|
|
241
|
-
body = {}
|
|
242
|
-
|
|
243
335
|
if not response.ok:
|
|
336
|
+
try:
|
|
337
|
+
body = response.json() if response.content else {}
|
|
338
|
+
except ValueError:
|
|
339
|
+
body = {}
|
|
244
340
|
raise APIError(
|
|
245
341
|
status_code=response.status_code,
|
|
246
342
|
detail=body.get("detail", response.text),
|
|
247
343
|
)
|
|
248
344
|
|
|
249
|
-
return
|
|
345
|
+
return response
|
|
@@ -13,8 +13,17 @@ class DocumentType(str, Enum):
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
class Environment(str, Enum):
|
|
16
|
-
"""Which host to talk to
|
|
16
|
+
"""Which host to talk to, instead of a raw `base_url`."""
|
|
17
17
|
|
|
18
18
|
LOCAL = "local"
|
|
19
19
|
TEST = "test"
|
|
20
20
|
PRODUCTION = "production"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Manifestation(str, Enum):
|
|
24
|
+
"""The recipient's four possible answers to a received document."""
|
|
25
|
+
|
|
26
|
+
CONFIRMACAO = "210200"
|
|
27
|
+
CIENCIA = "210210"
|
|
28
|
+
DESCONHECIMENTO = "210220"
|
|
29
|
+
OPERACAO_NAO_REALIZADA = "210240"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|