stackin-python-sdk 0.1.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.
- stackin_python_sdk-0.1.0/LICENSE +21 -0
- stackin_python_sdk-0.1.0/PKG-INFO +114 -0
- stackin_python_sdk-0.1.0/README.md +89 -0
- stackin_python_sdk-0.1.0/pyproject.toml +67 -0
- stackin_python_sdk-0.1.0/stackin/__init__.py +21 -0
- stackin_python_sdk-0.1.0/stackin/br/__init__.py +42 -0
- stackin_python_sdk-0.1.0/stackin/br/product.py +87 -0
- stackin_python_sdk-0.1.0/stackin/br/tax.py +249 -0
- stackin_python_sdk-0.1.0/stackin/core/__init__.py +1 -0
- stackin_python_sdk-0.1.0/stackin/core/address.py +21 -0
- stackin_python_sdk-0.1.0/stackin/core/client.py +151 -0
- stackin_python_sdk-0.1.0/stackin/core/exceptions.py +19 -0
- stackin_python_sdk-0.1.0/stackin/core/types.py +23 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fernando Celmer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stackin-python-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for issuing, consulting and cancelling electronic invoices.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: nfe,nfse,nota-fiscal,nota-fiscal-eletronica,invoice,fiscal-document,brazil,sefaz,e-invoicing
|
|
8
|
+
Author: Fernando Celmer
|
|
9
|
+
Author-email: email@fernandocelmer.com
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Natural Language :: Portuguese (Brazilian)
|
|
21
|
+
Requires-Dist: pydantic
|
|
22
|
+
Requires-Dist: requests
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
<div align="center">
|
|
26
|
+
|
|
27
|
+
<img src="https://raw.githubusercontent.com/stackin-io/stackin-python-sdk/master/docs/assets/stackin.png" width="120" />
|
|
28
|
+
|
|
29
|
+
**Integrate once. Issue everywhere.**
|
|
30
|
+
|
|
31
|
+
[](pyproject.toml)
|
|
32
|
+
[](https://github.com/stackin-io/stackin-python-sdk)
|
|
33
|
+
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
# stackin
|
|
39
|
+
|
|
40
|
+
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`.
|
|
41
|
+
|
|
42
|
+
**One class, `Invoice`** — `issue()`/`consult()`/`cancel()`, 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.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install stackin-python-sdk
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
Get an `api_key` from the [stackin dashboard](https://app.stackin.io) — select the issuing company, then Settings → API key. One key per issuing company, shown once at creation. The API resolves the issuer (CNPJ, state, address, certificate, environment) entirely from it; nothing about the issuer is ever passed on a call.
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from stackin import Invoice, DocumentType, Address
|
|
56
|
+
from stackin.br import Product # Brazil-specific line item — NCM/CFOP
|
|
57
|
+
|
|
58
|
+
client = Invoice(api_key="COMPANY_API_KEY") # defaults to https://sdk.stackin.io
|
|
59
|
+
|
|
60
|
+
invoice = client.issue(
|
|
61
|
+
document_type=DocumentType.NFSE,
|
|
62
|
+
client_name="John Doe",
|
|
63
|
+
tax_id="00000000000",
|
|
64
|
+
items=[Product(description="Software development", amount=5000.00)],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
status = client.consult("ACCESS_KEY...", document_type=DocumentType.NFSE)
|
|
68
|
+
client.cancel(
|
|
69
|
+
"ACCESS_KEY...",
|
|
70
|
+
document_type=DocumentType.NFSE,
|
|
71
|
+
reason="Typo",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# NFE requires ncm/cfop on every item, and optionally recipient_address.state
|
|
75
|
+
# to get idDest right on interstate sales:
|
|
76
|
+
client.issue(
|
|
77
|
+
document_type=DocumentType.NFE,
|
|
78
|
+
client_name="Buyer Company Ltd",
|
|
79
|
+
tax_id="11111111111111",
|
|
80
|
+
items=[Product(description="Test product", amount=100.00, ncm="84713012", cfop="5102")],
|
|
81
|
+
recipient_address=Address(state="RJ"),
|
|
82
|
+
)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`recipient_address` is an `Address`, but despite the name only `.state` is read — the rest of the fields aren't sent anywhere yet. It's the actual customer's state, used only to set `idDest` (interstate vs internal) on NFE — optional, omitting it always produces `idDest=1` (internal).
|
|
86
|
+
|
|
87
|
+
`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).
|
|
88
|
+
|
|
89
|
+
## Errors
|
|
90
|
+
|
|
91
|
+
- `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.
|
|
92
|
+
- `stackin.ConnectionFailedError` — the API didn't respond (network/DNS/timeout).
|
|
93
|
+
- `ValueError` — `issue()`'s `items` is empty, or missing `ncm`/`cfop` on an item for NFE.
|
|
94
|
+
|
|
95
|
+
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.
|
|
96
|
+
|
|
97
|
+
## Examples
|
|
98
|
+
|
|
99
|
+
Runnable end-to-end scripts in [`examples/`](examples/) — `simple_issue_nfe.py` and `simple_issue_nfse.py`, each with a catalog of realistic line items covering every optional field.
|
|
100
|
+
|
|
101
|
+
## Commit Style
|
|
102
|
+
|
|
103
|
+
| Icon | Type | Description |
|
|
104
|
+
|------|-----------|--------------------------------------------|
|
|
105
|
+
| ⚙️ | FEATURE | New feature |
|
|
106
|
+
| 📝 | PEP8 | Formatting fixes following PEP8 |
|
|
107
|
+
| 📌 | ISSUE | Reference to issue |
|
|
108
|
+
| 🪲 | BUG | Bug fix |
|
|
109
|
+
| 📘 | DOCS | Documentation changes |
|
|
110
|
+
| 📦 | PyPI | PyPI releases |
|
|
111
|
+
| ❤️️ | TEST | Automated tests |
|
|
112
|
+
| ⬆️ | CI/CD | Changes in continuous integration/delivery |
|
|
113
|
+
| ⚠️ | SECURITY | Security improvements |
|
|
114
|
+
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<img src="https://raw.githubusercontent.com/stackin-io/stackin-python-sdk/master/docs/assets/stackin.png" width="120" />
|
|
4
|
+
|
|
5
|
+
**Integrate once. Issue everywhere.**
|
|
6
|
+
|
|
7
|
+
[](pyproject.toml)
|
|
8
|
+
[](https://github.com/stackin-io/stackin-python-sdk)
|
|
9
|
+
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# stackin
|
|
15
|
+
|
|
16
|
+
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`.
|
|
17
|
+
|
|
18
|
+
**One class, `Invoice`** — `issue()`/`consult()`/`cancel()`, 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.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install stackin-python-sdk
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
Get an `api_key` from the [stackin dashboard](https://app.stackin.io) — select the issuing company, then Settings → API key. One key per issuing company, shown once at creation. The API resolves the issuer (CNPJ, state, address, certificate, environment) entirely from it; nothing about the issuer is ever passed on a call.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from stackin import Invoice, DocumentType, Address
|
|
32
|
+
from stackin.br import Product # Brazil-specific line item — NCM/CFOP
|
|
33
|
+
|
|
34
|
+
client = Invoice(api_key="COMPANY_API_KEY") # defaults to https://sdk.stackin.io
|
|
35
|
+
|
|
36
|
+
invoice = client.issue(
|
|
37
|
+
document_type=DocumentType.NFSE,
|
|
38
|
+
client_name="John Doe",
|
|
39
|
+
tax_id="00000000000",
|
|
40
|
+
items=[Product(description="Software development", amount=5000.00)],
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
status = client.consult("ACCESS_KEY...", document_type=DocumentType.NFSE)
|
|
44
|
+
client.cancel(
|
|
45
|
+
"ACCESS_KEY...",
|
|
46
|
+
document_type=DocumentType.NFSE,
|
|
47
|
+
reason="Typo",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# NFE requires ncm/cfop on every item, and optionally recipient_address.state
|
|
51
|
+
# to get idDest right on interstate sales:
|
|
52
|
+
client.issue(
|
|
53
|
+
document_type=DocumentType.NFE,
|
|
54
|
+
client_name="Buyer Company Ltd",
|
|
55
|
+
tax_id="11111111111111",
|
|
56
|
+
items=[Product(description="Test product", amount=100.00, ncm="84713012", cfop="5102")],
|
|
57
|
+
recipient_address=Address(state="RJ"),
|
|
58
|
+
)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`recipient_address` is an `Address`, but despite the name only `.state` is read — the rest of the fields aren't sent anywhere yet. It's the actual customer's state, used only to set `idDest` (interstate vs internal) on NFE — optional, omitting it always produces `idDest=1` (internal).
|
|
62
|
+
|
|
63
|
+
`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).
|
|
64
|
+
|
|
65
|
+
## Errors
|
|
66
|
+
|
|
67
|
+
- `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.
|
|
68
|
+
- `stackin.ConnectionFailedError` — the API didn't respond (network/DNS/timeout).
|
|
69
|
+
- `ValueError` — `issue()`'s `items` is empty, or missing `ncm`/`cfop` on an item for NFE.
|
|
70
|
+
|
|
71
|
+
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.
|
|
72
|
+
|
|
73
|
+
## Examples
|
|
74
|
+
|
|
75
|
+
Runnable end-to-end scripts in [`examples/`](examples/) — `simple_issue_nfe.py` and `simple_issue_nfse.py`, each with a catalog of realistic line items covering every optional field.
|
|
76
|
+
|
|
77
|
+
## Commit Style
|
|
78
|
+
|
|
79
|
+
| Icon | Type | Description |
|
|
80
|
+
|------|-----------|--------------------------------------------|
|
|
81
|
+
| ⚙️ | FEATURE | New feature |
|
|
82
|
+
| 📝 | PEP8 | Formatting fixes following PEP8 |
|
|
83
|
+
| 📌 | ISSUE | Reference to issue |
|
|
84
|
+
| 🪲 | BUG | Bug fix |
|
|
85
|
+
| 📘 | DOCS | Documentation changes |
|
|
86
|
+
| 📦 | PyPI | PyPI releases |
|
|
87
|
+
| ❤️️ | TEST | Automated tests |
|
|
88
|
+
| ⬆️ | CI/CD | Changes in continuous integration/delivery |
|
|
89
|
+
| ⚠️ | SECURITY | Security improvements |
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "stackin-python-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
authors = [
|
|
5
|
+
{ name = "Fernando Celmer", email = "email@fernandocelmer.com" },
|
|
6
|
+
]
|
|
7
|
+
description = "Python SDK for issuing, consulting and cancelling electronic invoices."
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
keywords = [
|
|
12
|
+
"nfe",
|
|
13
|
+
"nfse",
|
|
14
|
+
"nota-fiscal",
|
|
15
|
+
"nota-fiscal-eletronica",
|
|
16
|
+
"invoice",
|
|
17
|
+
"fiscal-document",
|
|
18
|
+
"brazil",
|
|
19
|
+
"sefaz",
|
|
20
|
+
"e-invoicing",
|
|
21
|
+
]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 3 - Alpha",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3.10",
|
|
27
|
+
"Programming Language :: Python :: 3.11",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Programming Language :: Python :: 3.13",
|
|
30
|
+
"Topic :: Office/Business :: Financial :: Accounting",
|
|
31
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
32
|
+
"Natural Language :: Portuguese (Brazilian)",
|
|
33
|
+
]
|
|
34
|
+
dependencies = [
|
|
35
|
+
"requests",
|
|
36
|
+
"pydantic",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[tool.poetry]
|
|
40
|
+
name = "stackin-python-sdk"
|
|
41
|
+
version = "0.1.0"
|
|
42
|
+
description = "Python SDK for issuing, consulting and cancelling electronic invoices."
|
|
43
|
+
authors = ["Fernando Celmer <email@fernandocelmer.com>"]
|
|
44
|
+
readme = "README.md"
|
|
45
|
+
license = "MIT"
|
|
46
|
+
package-mode = true
|
|
47
|
+
packages = [{ include = "stackin" }]
|
|
48
|
+
|
|
49
|
+
[tool.poetry.dependencies]
|
|
50
|
+
python = ">=3.10.0"
|
|
51
|
+
requests = "^2.32.4"
|
|
52
|
+
pydantic = "^2.11.7"
|
|
53
|
+
|
|
54
|
+
[tool.poetry.group.dev.dependencies]
|
|
55
|
+
pytest = "^8.3.4"
|
|
56
|
+
pytest-cov = "^6.0.0"
|
|
57
|
+
python-dotenv = "^1.0.1"
|
|
58
|
+
|
|
59
|
+
[tool.poetry.group.code-quality.dependencies]
|
|
60
|
+
ruff = "^0.8.0"
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
testpaths = ["tests"]
|
|
64
|
+
|
|
65
|
+
[build-system]
|
|
66
|
+
requires = ["poetry-core"]
|
|
67
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Invoice __init__ module."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
__description__ = (
|
|
5
|
+
"Python SDK for issuing, consulting and cancelling electronic invoices."
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
from .core.address import Address
|
|
9
|
+
from .core.client import Invoice
|
|
10
|
+
from .core.exceptions import APIError, ConnectionFailedError, InvoiceError
|
|
11
|
+
from .core.types import DocumentType, Environment
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Invoice",
|
|
15
|
+
"DocumentType",
|
|
16
|
+
"Environment",
|
|
17
|
+
"Address",
|
|
18
|
+
"InvoiceError",
|
|
19
|
+
"APIError",
|
|
20
|
+
"ConnectionFailedError",
|
|
21
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Brazil-specific document fields."""
|
|
2
|
+
|
|
3
|
+
from stackin.br.product import PresumedCredit, Product
|
|
4
|
+
from stackin.br.tax import (
|
|
5
|
+
CofinsAliq,
|
|
6
|
+
CofinsNt,
|
|
7
|
+
CofinsOutr,
|
|
8
|
+
Icms00,
|
|
9
|
+
Icms40,
|
|
10
|
+
Icms60,
|
|
11
|
+
IcmsSn101,
|
|
12
|
+
IcmsSn102,
|
|
13
|
+
IcmsUfDest,
|
|
14
|
+
Ipi,
|
|
15
|
+
IpiNt,
|
|
16
|
+
IpiTrib,
|
|
17
|
+
PisAliq,
|
|
18
|
+
PisNt,
|
|
19
|
+
PisOutr,
|
|
20
|
+
Tax,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Product",
|
|
25
|
+
"PresumedCredit",
|
|
26
|
+
"Tax",
|
|
27
|
+
"Icms00",
|
|
28
|
+
"Icms40",
|
|
29
|
+
"Icms60",
|
|
30
|
+
"IcmsSn101",
|
|
31
|
+
"IcmsSn102",
|
|
32
|
+
"IcmsUfDest",
|
|
33
|
+
"Ipi",
|
|
34
|
+
"IpiTrib",
|
|
35
|
+
"IpiNt",
|
|
36
|
+
"PisAliq",
|
|
37
|
+
"PisNt",
|
|
38
|
+
"PisOutr",
|
|
39
|
+
"CofinsAliq",
|
|
40
|
+
"CofinsNt",
|
|
41
|
+
"CofinsOutr",
|
|
42
|
+
]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Product module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from stackin.br.tax import Tax
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PresumedCredit(BaseModel):
|
|
13
|
+
"""A presumed tax credit applied to this item."""
|
|
14
|
+
|
|
15
|
+
code: str = Field(pattern=r"^[\x21-\xff]{8}$|^[\x21-\xff]{10}$")
|
|
16
|
+
percentage: float
|
|
17
|
+
amount: float
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_CBENEF_PATTERN = r"^(SEM CBENEF|[\x21-\xff]{8}|[\x21-\xff]{10})$"
|
|
21
|
+
|
|
22
|
+
_BR_FIELDS = {
|
|
23
|
+
"ncm",
|
|
24
|
+
"cfop",
|
|
25
|
+
"cest",
|
|
26
|
+
"nve_codes",
|
|
27
|
+
"ind_escala",
|
|
28
|
+
"manufacturer_cnpj",
|
|
29
|
+
"tax_benefit_code",
|
|
30
|
+
"presumed_credits",
|
|
31
|
+
"ex_tipi",
|
|
32
|
+
"import_content_control_number",
|
|
33
|
+
"recopi_number",
|
|
34
|
+
"extra_groups",
|
|
35
|
+
"tax",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Product(BaseModel):
|
|
40
|
+
"""One product or service line item on an invoice."""
|
|
41
|
+
|
|
42
|
+
description: str = Field(..., min_length=1)
|
|
43
|
+
amount: float = Field(..., gt=0)
|
|
44
|
+
unit: str = Field(default="UN")
|
|
45
|
+
quantity: float = Field(default=1.0, gt=0)
|
|
46
|
+
barcode: str | None = Field(default=None)
|
|
47
|
+
freight: float | None = Field(default=None)
|
|
48
|
+
insurance: float | None = Field(default=None)
|
|
49
|
+
discount: float | None = Field(default=None)
|
|
50
|
+
other_expenses: float | None = Field(default=None)
|
|
51
|
+
used_movable_asset: bool = Field(default=False)
|
|
52
|
+
purchase_order: str | None = Field(default=None)
|
|
53
|
+
purchase_order_item: str | None = Field(default=None, pattern=r"^\d{1,6}$")
|
|
54
|
+
|
|
55
|
+
ncm: str | None = Field(default=None, pattern=r"^\d{2}$|^\d{8}$")
|
|
56
|
+
cfop: str | None = Field(default=None, pattern=r"^[123567]\d{3}$")
|
|
57
|
+
cest: str | None = Field(default=None, pattern=r"^\d{7}$")
|
|
58
|
+
nve_codes: list[Annotated[str, Field(pattern=r"^[A-Z]{2}\d{4}$")]] | None = Field(
|
|
59
|
+
default=None
|
|
60
|
+
)
|
|
61
|
+
ind_escala: str | None = Field(default=None)
|
|
62
|
+
manufacturer_cnpj: str | None = Field(default=None, pattern=r"^[0-9A-Z]{12}\d{2}$")
|
|
63
|
+
tax_benefit_code: str | None = Field(default=None, pattern=_CBENEF_PATTERN)
|
|
64
|
+
presumed_credits: list[PresumedCredit] | None = Field(default=None)
|
|
65
|
+
ex_tipi: str | None = Field(default=None, pattern=r"^\d{2,3}$")
|
|
66
|
+
import_content_control_number: str | None = Field(
|
|
67
|
+
default=None,
|
|
68
|
+
pattern=r"^[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{12}$",
|
|
69
|
+
)
|
|
70
|
+
recopi_number: str | None = Field(default=None, pattern=r"^\d{20}$")
|
|
71
|
+
extra_groups: dict[str, Any] | None = Field(default=None)
|
|
72
|
+
tax: Tax | dict[str, Any] | None = Field(default=None)
|
|
73
|
+
|
|
74
|
+
def to_dict(self) -> dict:
|
|
75
|
+
"""Returns the item as a plain dict, ready for the request body."""
|
|
76
|
+
data = self.model_dump(
|
|
77
|
+
exclude_none=True,
|
|
78
|
+
exclude={"description", "amount", "tax", *_BR_FIELDS},
|
|
79
|
+
)
|
|
80
|
+
br = self.model_dump(exclude_none=True, include=_BR_FIELDS - {"tax"})
|
|
81
|
+
if isinstance(self.tax, Tax):
|
|
82
|
+
br["tax"] = self.tax.to_dict()
|
|
83
|
+
elif self.tax is not None:
|
|
84
|
+
br["tax"] = self.tax
|
|
85
|
+
if br:
|
|
86
|
+
data["br"] = br
|
|
87
|
+
return {"description": self.description, "amount": self.amount, "product": data}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Tax module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
_CONFIG = ConfigDict(populate_by_name=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Icms00(BaseModel):
|
|
13
|
+
"""ICMS fully taxed."""
|
|
14
|
+
|
|
15
|
+
model_config = _CONFIG
|
|
16
|
+
|
|
17
|
+
orig: str
|
|
18
|
+
cst: str = Field(default="00", alias="CST")
|
|
19
|
+
mod_bc: str = Field(alias="modBC")
|
|
20
|
+
v_bc: str = Field(alias="vBC")
|
|
21
|
+
p_icms: str = Field(alias="pICMS")
|
|
22
|
+
v_icms: str = Field(alias="vICMS")
|
|
23
|
+
p_fcp: str | None = Field(default=None, alias="pFCP")
|
|
24
|
+
v_fcp: str | None = Field(default=None, alias="vFCP")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Icms40(BaseModel):
|
|
28
|
+
"""ICMS exempt or not taxed."""
|
|
29
|
+
|
|
30
|
+
model_config = _CONFIG
|
|
31
|
+
|
|
32
|
+
orig: str
|
|
33
|
+
cst: str = Field(alias="CST")
|
|
34
|
+
v_icms_deson: str | None = Field(default=None, alias="vICMSDeson")
|
|
35
|
+
mot_des_icms: str | None = Field(default=None, alias="motDesICMS")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Icms60(BaseModel):
|
|
39
|
+
"""ICMS already charged by an earlier substitution."""
|
|
40
|
+
|
|
41
|
+
model_config = _CONFIG
|
|
42
|
+
|
|
43
|
+
orig: str
|
|
44
|
+
cst: str = Field(default="60", alias="CST")
|
|
45
|
+
v_bc_st_ret: str | None = Field(default=None, alias="vBCSTRet")
|
|
46
|
+
p_st: str | None = Field(default=None, alias="pST")
|
|
47
|
+
v_icms_substituto: str | None = Field(default=None, alias="vICMSSubstituto")
|
|
48
|
+
v_icms_st_ret: str | None = Field(default=None, alias="vICMSSTRet")
|
|
49
|
+
v_bc_fcp_st_ret: str | None = Field(default=None, alias="vBCFCPSTRet")
|
|
50
|
+
p_fcp_st_ret: str | None = Field(default=None, alias="pFCPSTRet")
|
|
51
|
+
v_fcp_st_ret: str | None = Field(default=None, alias="vFCPSTRet")
|
|
52
|
+
p_red_bc_efet: str | None = Field(default=None, alias="pRedBCEfet")
|
|
53
|
+
v_bc_efet: str | None = Field(default=None, alias="vBCEfet")
|
|
54
|
+
p_icms_efet: str | None = Field(default=None, alias="pICMSEfet")
|
|
55
|
+
v_icms_efet: str | None = Field(default=None, alias="vICMSEfet")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class IcmsSn101(BaseModel):
|
|
59
|
+
"""Simples Nacional ICMS with a credit."""
|
|
60
|
+
|
|
61
|
+
model_config = _CONFIG
|
|
62
|
+
|
|
63
|
+
orig: str
|
|
64
|
+
csosn: str = Field(default="101", alias="CSOSN")
|
|
65
|
+
p_cred_sn: str = Field(alias="pCredSN")
|
|
66
|
+
v_cred_icms_sn: str = Field(alias="vCredICMSSN")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class IcmsSn102(BaseModel):
|
|
70
|
+
"""Simples Nacional ICMS without a credit."""
|
|
71
|
+
|
|
72
|
+
model_config = _CONFIG
|
|
73
|
+
|
|
74
|
+
orig: str | None = None
|
|
75
|
+
csosn: str = Field(alias="CSOSN")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class IcmsUfDest(BaseModel):
|
|
79
|
+
"""Interstate ICMS share owed to the destination state."""
|
|
80
|
+
|
|
81
|
+
model_config = _CONFIG
|
|
82
|
+
|
|
83
|
+
v_bc_uf_dest: str = Field(alias="vBCUFDest")
|
|
84
|
+
v_bc_fcp_uf_dest: str | None = Field(default=None, alias="vBCFCPUFDest")
|
|
85
|
+
p_fcp_uf_dest: str | None = Field(default=None, alias="pFCPUFDest")
|
|
86
|
+
p_icms_uf_dest: str = Field(alias="pICMSUFDest")
|
|
87
|
+
p_icms_inter: Literal["4.00", "7.00", "12.00"] = Field(alias="pICMSInter")
|
|
88
|
+
p_icms_inter_part: str = Field(alias="pICMSInterPart")
|
|
89
|
+
v_fcp_uf_dest: str | None = Field(default=None, alias="vFCPUFDest")
|
|
90
|
+
v_icms_uf_dest: str = Field(alias="vICMSUFDest")
|
|
91
|
+
v_icms_uf_remet: str = Field(alias="vICMSUFRemet")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class IpiTrib(BaseModel):
|
|
95
|
+
"""IPI taxed by rate."""
|
|
96
|
+
|
|
97
|
+
model_config = _CONFIG
|
|
98
|
+
|
|
99
|
+
cst: Literal["00", "49", "50", "99"] = Field(alias="CST")
|
|
100
|
+
v_bc: str | None = Field(default=None, alias="vBC")
|
|
101
|
+
p_ipi: str | None = Field(default=None, alias="pIPI")
|
|
102
|
+
q_unid: str | None = Field(default=None, alias="qUnid")
|
|
103
|
+
v_unid: str | None = Field(default=None, alias="vUnid")
|
|
104
|
+
v_ipi: str = Field(alias="vIPI")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class IpiNt(BaseModel):
|
|
108
|
+
"""IPI not taxed."""
|
|
109
|
+
|
|
110
|
+
model_config = _CONFIG
|
|
111
|
+
|
|
112
|
+
cst: str = Field(alias="CST")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
IpiVariant = IpiTrib | IpiNt | dict[str, Any]
|
|
116
|
+
_IPI_TAGS = {IpiTrib: "IPITrib", IpiNt: "IPINT"}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Ipi(BaseModel):
|
|
120
|
+
"""This item's IPI."""
|
|
121
|
+
|
|
122
|
+
model_config = _CONFIG
|
|
123
|
+
|
|
124
|
+
c_enq: str = Field(alias="cEnq")
|
|
125
|
+
trib: IpiVariant
|
|
126
|
+
|
|
127
|
+
def to_dict(self) -> dict:
|
|
128
|
+
"""Returns the IPI group as a plain dict."""
|
|
129
|
+
data = {"cEnq": self.c_enq}
|
|
130
|
+
data.update(_wrap(self.trib, _IPI_TAGS))
|
|
131
|
+
return data
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class PisAliq(BaseModel):
|
|
135
|
+
"""PIS taxed by rate."""
|
|
136
|
+
|
|
137
|
+
model_config = _CONFIG
|
|
138
|
+
|
|
139
|
+
cst: str = Field(alias="CST")
|
|
140
|
+
v_bc: str = Field(alias="vBC")
|
|
141
|
+
p_pis: str = Field(alias="pPIS")
|
|
142
|
+
v_pis: str = Field(alias="vPIS")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class PisNt(BaseModel):
|
|
146
|
+
"""PIS not taxed."""
|
|
147
|
+
|
|
148
|
+
model_config = _CONFIG
|
|
149
|
+
|
|
150
|
+
cst: str = Field(alias="CST")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class PisOutr(BaseModel):
|
|
154
|
+
"""PIS taxed some other way."""
|
|
155
|
+
|
|
156
|
+
model_config = _CONFIG
|
|
157
|
+
|
|
158
|
+
cst: str = Field(alias="CST")
|
|
159
|
+
v_bc: str | None = Field(default=None, alias="vBC")
|
|
160
|
+
p_pis: str | None = Field(default=None, alias="pPIS")
|
|
161
|
+
v_pis: str = Field(alias="vPIS")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class CofinsAliq(BaseModel):
|
|
165
|
+
"""COFINS taxed by rate."""
|
|
166
|
+
|
|
167
|
+
model_config = _CONFIG
|
|
168
|
+
|
|
169
|
+
cst: str = Field(alias="CST")
|
|
170
|
+
v_bc: str = Field(alias="vBC")
|
|
171
|
+
p_cofins: str = Field(alias="pCOFINS")
|
|
172
|
+
v_cofins: str = Field(alias="vCOFINS")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class CofinsNt(BaseModel):
|
|
176
|
+
"""COFINS not taxed."""
|
|
177
|
+
|
|
178
|
+
model_config = _CONFIG
|
|
179
|
+
|
|
180
|
+
cst: str = Field(alias="CST")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class CofinsOutr(BaseModel):
|
|
184
|
+
"""COFINS taxed some other way."""
|
|
185
|
+
|
|
186
|
+
model_config = _CONFIG
|
|
187
|
+
|
|
188
|
+
cst: str = Field(alias="CST")
|
|
189
|
+
v_bc: str | None = Field(default=None, alias="vBC")
|
|
190
|
+
p_cofins: str | None = Field(default=None, alias="pCOFINS")
|
|
191
|
+
v_cofins: str = Field(alias="vCOFINS")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
IcmsGroup = Icms00 | Icms40 | Icms60 | IcmsSn101 | IcmsSn102 | dict[str, Any]
|
|
195
|
+
PisGroup = PisAliq | PisNt | PisOutr | dict[str, Any]
|
|
196
|
+
CofinsGroup = CofinsAliq | CofinsNt | CofinsOutr | dict[str, Any]
|
|
197
|
+
|
|
198
|
+
_ICMS_TAGS = {
|
|
199
|
+
Icms00: "ICMS00",
|
|
200
|
+
Icms40: "ICMS40",
|
|
201
|
+
Icms60: "ICMS60",
|
|
202
|
+
IcmsSn101: "ICMSSN101",
|
|
203
|
+
IcmsSn102: "ICMSSN102",
|
|
204
|
+
}
|
|
205
|
+
_PIS_TAGS = {PisAliq: "PISAliq", PisNt: "PISNT", PisOutr: "PISOutr"}
|
|
206
|
+
_COFINS_TAGS = {
|
|
207
|
+
CofinsAliq: "COFINSAliq",
|
|
208
|
+
CofinsNt: "COFINSNT",
|
|
209
|
+
CofinsOutr: "COFINSOutr",
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class Tax(BaseModel):
|
|
214
|
+
"""This item's taxes, already computed by the caller."""
|
|
215
|
+
|
|
216
|
+
model_config = _CONFIG
|
|
217
|
+
|
|
218
|
+
v_tot_trib: str | None = Field(default=None, alias="vTotTrib")
|
|
219
|
+
icms: IcmsGroup | None = None
|
|
220
|
+
icms_uf_dest: IcmsUfDest | None = None
|
|
221
|
+
ipi: Ipi | dict[str, Any] | None = None
|
|
222
|
+
pis: PisGroup | None = None
|
|
223
|
+
cofins: CofinsGroup | None = None
|
|
224
|
+
|
|
225
|
+
def to_dict(self) -> dict:
|
|
226
|
+
"""Returns the taxes as a plain dict."""
|
|
227
|
+
data: dict[str, Any] = {}
|
|
228
|
+
if self.v_tot_trib is not None:
|
|
229
|
+
data["vTotTrib"] = self.v_tot_trib
|
|
230
|
+
if self.icms is not None:
|
|
231
|
+
data["ICMS"] = _wrap(self.icms, _ICMS_TAGS)
|
|
232
|
+
if self.icms_uf_dest is not None:
|
|
233
|
+
data["ICMSUFDest"] = self.icms_uf_dest.model_dump(
|
|
234
|
+
by_alias=True, exclude_none=True
|
|
235
|
+
)
|
|
236
|
+
if self.ipi is not None:
|
|
237
|
+
data["IPI"] = self.ipi.to_dict() if isinstance(self.ipi, Ipi) else self.ipi
|
|
238
|
+
if self.pis is not None:
|
|
239
|
+
data["PIS"] = _wrap(self.pis, _PIS_TAGS)
|
|
240
|
+
if self.cofins is not None:
|
|
241
|
+
data["COFINS"] = _wrap(self.cofins, _COFINS_TAGS)
|
|
242
|
+
return data
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _wrap(group: BaseModel | dict, tags: dict[type, str]) -> dict:
|
|
246
|
+
"""Nests a tax group under its variant name."""
|
|
247
|
+
if isinstance(group, dict):
|
|
248
|
+
return group
|
|
249
|
+
return {tags[type(group)]: group.model_dump(by_alias=True, exclude_none=True)}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core module."""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Address module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Address(BaseModel):
|
|
9
|
+
"""A plain postal address."""
|
|
10
|
+
|
|
11
|
+
state: str | None = Field(default=None)
|
|
12
|
+
city_code: str | None = Field(default=None)
|
|
13
|
+
street: str | None = Field(default=None)
|
|
14
|
+
number: str | None = Field(default=None)
|
|
15
|
+
neighborhood: str | None = Field(default=None)
|
|
16
|
+
city: str | None = Field(default=None)
|
|
17
|
+
zip_code: str | None = Field(default=None)
|
|
18
|
+
|
|
19
|
+
def to_dict(self) -> dict:
|
|
20
|
+
"""Returns the address as a plain dict."""
|
|
21
|
+
return self.model_dump(exclude_none=True)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Client module"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from stackin.core.address import Address
|
|
10
|
+
from stackin.br.product import Product
|
|
11
|
+
from stackin.core.exceptions import APIError, ConnectionFailedError
|
|
12
|
+
from stackin.core.types import DocumentType, Environment
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL = "https://sdk.stackin.io"
|
|
15
|
+
|
|
16
|
+
_ENVIRONMENT_URLS = {
|
|
17
|
+
Environment.LOCAL: "http://localhost:8000",
|
|
18
|
+
Environment.TEST: DEFAULT_BASE_URL,
|
|
19
|
+
Environment.PRODUCTION: DEFAULT_BASE_URL,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _resolve_base_url(
|
|
24
|
+
base_url: str | None, environment: Environment | str | None
|
|
25
|
+
) -> str:
|
|
26
|
+
"""Resolution order, same shape as the AWS CLI: explicit param,
|
|
27
|
+
then env var, then the environment's default — `base_url` always
|
|
28
|
+
wins over `environment` at each step."""
|
|
29
|
+
if base_url:
|
|
30
|
+
return base_url
|
|
31
|
+
if url := os.environ.get("STACKIN_BASE_URL"):
|
|
32
|
+
return url
|
|
33
|
+
|
|
34
|
+
if environment is not None:
|
|
35
|
+
return _ENVIRONMENT_URLS[Environment(environment)]
|
|
36
|
+
if env_name := os.environ.get("STACKIN_ENVIRONMENT"):
|
|
37
|
+
return _ENVIRONMENT_URLS[Environment(env_name)]
|
|
38
|
+
return DEFAULT_BASE_URL
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Invoice:
|
|
42
|
+
"""Client for issuing, consulting, and cancelling fiscal documents."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
base_url: str | None = None,
|
|
47
|
+
environment: Environment | str | None = None,
|
|
48
|
+
api_key: str | None = None,
|
|
49
|
+
timeout: int = 30,
|
|
50
|
+
) -> None:
|
|
51
|
+
resolved_url = _resolve_base_url(base_url, environment)
|
|
52
|
+
self.base_url = resolved_url.rstrip("/")
|
|
53
|
+
self.api_key = api_key or os.environ.get("STACKIN_API_KEY")
|
|
54
|
+
self.timeout = timeout
|
|
55
|
+
|
|
56
|
+
def issue(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
document_type: DocumentType,
|
|
60
|
+
client_name: str,
|
|
61
|
+
tax_id: str,
|
|
62
|
+
items: list[Product],
|
|
63
|
+
recipient_address: Address | None = None,
|
|
64
|
+
) -> dict:
|
|
65
|
+
"""Issues a fiscal document."""
|
|
66
|
+
if not items:
|
|
67
|
+
raise ValueError("items can't be empty")
|
|
68
|
+
|
|
69
|
+
if document_type is DocumentType.NFE:
|
|
70
|
+
for index, item in enumerate(items):
|
|
71
|
+
if not item.ncm:
|
|
72
|
+
raise ValueError(f"items[{index}].ncm is required for NFE")
|
|
73
|
+
if not item.cfop:
|
|
74
|
+
raise ValueError(f"items[{index}].cfop is required for NFE")
|
|
75
|
+
|
|
76
|
+
payload = {
|
|
77
|
+
"document_type": document_type.value,
|
|
78
|
+
"client_name": client_name,
|
|
79
|
+
"tax_id": tax_id,
|
|
80
|
+
"items": [item.to_dict() for item in items],
|
|
81
|
+
}
|
|
82
|
+
if recipient_address and recipient_address.state:
|
|
83
|
+
payload["recipient_state"] = recipient_address.state
|
|
84
|
+
|
|
85
|
+
return self._request("POST", "/invoices", json=payload)
|
|
86
|
+
|
|
87
|
+
def consult(
|
|
88
|
+
self,
|
|
89
|
+
access_key: str,
|
|
90
|
+
*,
|
|
91
|
+
document_type: DocumentType,
|
|
92
|
+
) -> dict:
|
|
93
|
+
"""Consults a fiscal document by its access key."""
|
|
94
|
+
params = {"document_type": document_type.value}
|
|
95
|
+
|
|
96
|
+
return self._request("GET", f"/invoices/{access_key}", params=params)
|
|
97
|
+
|
|
98
|
+
def cancel(
|
|
99
|
+
self,
|
|
100
|
+
access_key: str,
|
|
101
|
+
*,
|
|
102
|
+
document_type: DocumentType,
|
|
103
|
+
reason: str,
|
|
104
|
+
) -> dict:
|
|
105
|
+
"""Cancels a fiscal document by its access key."""
|
|
106
|
+
payload = {
|
|
107
|
+
"document_type": document_type.value,
|
|
108
|
+
"reason": reason,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return self._request("POST", f"/invoices/{access_key}/cancel", json=payload)
|
|
112
|
+
|
|
113
|
+
def _headers(self) -> dict:
|
|
114
|
+
if self.api_key:
|
|
115
|
+
return {"Authorization": f"Bearer {self.api_key}"}
|
|
116
|
+
return {}
|
|
117
|
+
|
|
118
|
+
def _request(
|
|
119
|
+
self,
|
|
120
|
+
method: str,
|
|
121
|
+
path: str,
|
|
122
|
+
*,
|
|
123
|
+
json: dict | None = None,
|
|
124
|
+
params: dict | None = None,
|
|
125
|
+
) -> dict:
|
|
126
|
+
url = f"{self.base_url}/api/v1{path}"
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
response = requests.request(
|
|
130
|
+
method,
|
|
131
|
+
url,
|
|
132
|
+
json=json,
|
|
133
|
+
params=params,
|
|
134
|
+
headers=self._headers(),
|
|
135
|
+
timeout=self.timeout,
|
|
136
|
+
)
|
|
137
|
+
except requests.RequestException as error:
|
|
138
|
+
raise ConnectionFailedError(str(error)) from error
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
body = response.json() if response.content else {}
|
|
142
|
+
except ValueError:
|
|
143
|
+
body = {}
|
|
144
|
+
|
|
145
|
+
if not response.ok:
|
|
146
|
+
raise APIError(
|
|
147
|
+
status_code=response.status_code,
|
|
148
|
+
detail=body.get("detail", response.text),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return body.get("result", body)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Exceptions module."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class InvoiceError(Exception):
|
|
5
|
+
"""Base exception for the invoice SDK."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class APIError(InvoiceError):
|
|
9
|
+
"""Raised when the invoice API responds with an error status."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, status_code: int, detail: str) -> None:
|
|
12
|
+
self.status_code = status_code
|
|
13
|
+
self.detail = detail
|
|
14
|
+
super().__init__(f"[{status_code}] {detail}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConnectionFailedError(InvoiceError):
|
|
18
|
+
"""Raised when the invoice API can't be reached at all
|
|
19
|
+
(network/DNS/timeout)."""
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Types module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DocumentType(str, Enum):
|
|
9
|
+
"""The kind of fiscal document to issue, consult, or cancel."""
|
|
10
|
+
|
|
11
|
+
NFE = "nfe"
|
|
12
|
+
NFSE = "nfse"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Environment(str, Enum):
|
|
16
|
+
"""Which host to talk to — pass to `Invoice(environment=...)`
|
|
17
|
+
instead of a raw `base_url`. `TEST` and `PRODUCTION` resolve to the
|
|
18
|
+
same host: homologation vs. production invoicing is a per-company
|
|
19
|
+
setting on the platform side, not a different SDK host."""
|
|
20
|
+
|
|
21
|
+
LOCAL = "local"
|
|
22
|
+
TEST = "test"
|
|
23
|
+
PRODUCTION = "production"
|