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.
@@ -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
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue?style=flat-square)](pyproject.toml)
32
+ [![License](https://img.shields.io/badge/license-MIT-informational?style=flat-square)](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
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue?style=flat-square)](pyproject.toml)
8
+ [![License](https://img.shields.io/badge/license-MIT-informational?style=flat-square)](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"