pairus-product-data 1.0.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.
- pairus_product_data-1.0.0/.gitignore +59 -0
- pairus_product_data-1.0.0/PKG-INFO +104 -0
- pairus_product_data-1.0.0/README.md +74 -0
- pairus_product_data-1.0.0/pairus_product_data/__init__.py +27 -0
- pairus_product_data-1.0.0/pairus_product_data/client.py +77 -0
- pairus_product_data-1.0.0/pairus_product_data/config.py +43 -0
- pairus_product_data-1.0.0/pairus_product_data/exceptions.py +70 -0
- pairus_product_data-1.0.0/pairus_product_data/models/__init__.py +29 -0
- pairus_product_data-1.0.0/pairus_product_data/models/fiscal.py +102 -0
- pairus_product_data-1.0.0/pairus_product_data/models/product.py +29 -0
- pairus_product_data-1.0.0/pairus_product_data/models/webhook.py +19 -0
- pairus_product_data-1.0.0/pairus_product_data/py.typed +0 -0
- pairus_product_data-1.0.0/pairus_product_data/resources/__init__.py +11 -0
- pairus_product_data-1.0.0/pairus_product_data/resources/base.py +194 -0
- pairus_product_data-1.0.0/pairus_product_data/resources/fiscal.py +102 -0
- pairus_product_data-1.0.0/pairus_product_data/resources/products.py +66 -0
- pairus_product_data-1.0.0/pairus_product_data/webhooks.py +75 -0
- pairus_product_data-1.0.0/pyproject.toml +68 -0
- pairus_product_data-1.0.0/tests/test_client.py +29 -0
- pairus_product_data-1.0.0/tests/test_fiscal.py +62 -0
- pairus_product_data-1.0.0/tests/test_products.py +28 -0
- pairus_product_data-1.0.0/tests/test_webhooks.py +23 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Ambiente virtual
|
|
2
|
+
venv/
|
|
3
|
+
.venv/
|
|
4
|
+
env/
|
|
5
|
+
# Credenciais e segredos
|
|
6
|
+
.env
|
|
7
|
+
firebase-key.json
|
|
8
|
+
|
|
9
|
+
# Prompts de IA (contém lógica de negócio proprietária)
|
|
10
|
+
docs/prompts/
|
|
11
|
+
|
|
12
|
+
# Certificados
|
|
13
|
+
*.pfx
|
|
14
|
+
*.p12
|
|
15
|
+
*.pem
|
|
16
|
+
*.key
|
|
17
|
+
*.crt
|
|
18
|
+
cert_base64.txt
|
|
19
|
+
|
|
20
|
+
# Logs
|
|
21
|
+
logs/
|
|
22
|
+
*.log
|
|
23
|
+
|
|
24
|
+
# Cache Python e Build
|
|
25
|
+
__pycache__/
|
|
26
|
+
*.py[cod]
|
|
27
|
+
*$py.class
|
|
28
|
+
.pytest_cache/
|
|
29
|
+
*.egg-info/
|
|
30
|
+
build/
|
|
31
|
+
dist/
|
|
32
|
+
node_modules/
|
|
33
|
+
|
|
34
|
+
# Arquivos de IDE
|
|
35
|
+
.idea/
|
|
36
|
+
.vscode/
|
|
37
|
+
*.swp
|
|
38
|
+
*.swo
|
|
39
|
+
|
|
40
|
+
# Arquivos temporários
|
|
41
|
+
*.tmp
|
|
42
|
+
*.bak
|
|
43
|
+
|
|
44
|
+
# Firebase
|
|
45
|
+
.firebase/
|
|
46
|
+
*.zip
|
|
47
|
+
|
|
48
|
+
# Scratch temporário
|
|
49
|
+
scratch/
|
|
50
|
+
|
|
51
|
+
# Especificação estática OpenAPI pesada (~190KB)
|
|
52
|
+
openapi.json
|
|
53
|
+
|
|
54
|
+
# Downloads temporários do Sentinel
|
|
55
|
+
data/downloads/
|
|
56
|
+
|
|
57
|
+
# Relatórios e dumps pesados (> 50KB)
|
|
58
|
+
*.pdf
|
|
59
|
+
git_*.txt
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pairus-product-data
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: SDK oficial da PAIRUS para consulta de dados de produto (GTIN/EAN), catálogo e predição fiscal da Reforma Tributária (IBS/CBS/IS).
|
|
5
|
+
Project-URL: Homepage, https://pairus.com.br
|
|
6
|
+
Project-URL: Documentation, https://pairus.com.br/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/marcos2r/api-consulta-gtin
|
|
8
|
+
Author-email: PAIRUS Soluções Tecnológicas <suporte@pairus.com.br>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: cbs,ean,fiscal,gtin,ibs,imposto-seletivo,nfe,pairus,reforma-tributaria,sefaz,tributacao
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: httpx>=0.25.0
|
|
23
|
+
Requires-Dist: pydantic>=2.0.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=1.10.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.4.0; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# PAIRUS Product Data SDK para Python (`pairus-product-data`)
|
|
32
|
+
|
|
33
|
+
SDK oficial da **PAIRUS Soluções Tecnológicas** para consulta de dados de catálogo (GTIN/EAN) e predição fiscal completa da **Reforma Tributária (IBS, CBS e Imposto Seletivo)**.
|
|
34
|
+
|
|
35
|
+
[](https://pypi.org/project/pairus-product-data/)
|
|
36
|
+
[](https://opensource.org/licenses/MIT)
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 📦 Instalação
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install pairus-product-data
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## 🚀 Início Rápido
|
|
49
|
+
|
|
50
|
+
### 1. Consulta de Produto por Código de Barras (GTIN)
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from pairus_product_data import PairusProductData
|
|
54
|
+
|
|
55
|
+
# Inicializa o cliente (lê automaticamente a variável de ambiente PAIRUS_API_KEY se omitido)
|
|
56
|
+
client = PairusProductData(api_key="pk_live_sua_chave_aqui")
|
|
57
|
+
|
|
58
|
+
produto = client.products.get("7891000744703")
|
|
59
|
+
print(f"Produto: {produto.xProd}")
|
|
60
|
+
print(f"NCM: {produto.ncm} | CEST: {produto.cest}")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 2. Predição Fiscal da Reforma Tributária (IBS/CBS/IS)
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from pairus_product_data import PairusProductData
|
|
67
|
+
|
|
68
|
+
client = PairusProductData()
|
|
69
|
+
|
|
70
|
+
predicao = client.fiscal.predict(
|
|
71
|
+
xProd="Refrigerante Coca-Cola Lata 350ml",
|
|
72
|
+
gtin="7894900010015",
|
|
73
|
+
regime_tributario="simples_nacional",
|
|
74
|
+
uf_origem="SP",
|
|
75
|
+
uf_destino="RJ",
|
|
76
|
+
finalidade="revenda"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
tributos = predicao.dados_tributarios
|
|
80
|
+
print(f"NCM: {tributos.ncm_sugerido} | CFOP: {tributos.cfop}")
|
|
81
|
+
print(f"CST IBS/CBS: {tributos.ibscbs.cst} | Classificação: {tributos.ibscbs.cClassTrib}")
|
|
82
|
+
print(f"Alíquota Efetiva CBS: {tributos.ibscbs.cbs_aliquota_efetiva}%")
|
|
83
|
+
print(f"Alíquota Efetiva IBS: {tributos.ibscbs.ibs_aliquota_efetiva}%")
|
|
84
|
+
print(f"Alíquota Unificada: {tributos.aliquota_efetiva_unificada}%")
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 3. Validação de Webhooks com Assinatura Criptográfica (HMAC-SHA256)
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from pairus_product_data.webhooks import Webhooks
|
|
91
|
+
|
|
92
|
+
# Em um endpoint FastAPI / Flask / Django:
|
|
93
|
+
raw_body = request.body
|
|
94
|
+
signature_header = request.headers.get("X-Pairus-Signature")
|
|
95
|
+
webhook_secret = "whsec_seu_secret"
|
|
96
|
+
|
|
97
|
+
evento = Webhooks.construct_event(raw_body, signature_header, webhook_secret)
|
|
98
|
+
print(f"Evento autêntico recebido: {evento.event} para o GTIN: {evento.data.get('gtin')}")
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## 🛡️ Licença
|
|
104
|
+
Distribuído sob a licença MIT. Consulte `LICENSE` para mais detalhes.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# PAIRUS Product Data SDK para Python (`pairus-product-data`)
|
|
2
|
+
|
|
3
|
+
SDK oficial da **PAIRUS Soluções Tecnológicas** para consulta de dados de catálogo (GTIN/EAN) e predição fiscal completa da **Reforma Tributária (IBS, CBS e Imposto Seletivo)**.
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/pairus-product-data/)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 📦 Instalação
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install pairus-product-data
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 🚀 Início Rápido
|
|
19
|
+
|
|
20
|
+
### 1. Consulta de Produto por Código de Barras (GTIN)
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from pairus_product_data import PairusProductData
|
|
24
|
+
|
|
25
|
+
# Inicializa o cliente (lê automaticamente a variável de ambiente PAIRUS_API_KEY se omitido)
|
|
26
|
+
client = PairusProductData(api_key="pk_live_sua_chave_aqui")
|
|
27
|
+
|
|
28
|
+
produto = client.products.get("7891000744703")
|
|
29
|
+
print(f"Produto: {produto.xProd}")
|
|
30
|
+
print(f"NCM: {produto.ncm} | CEST: {produto.cest}")
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### 2. Predição Fiscal da Reforma Tributária (IBS/CBS/IS)
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from pairus_product_data import PairusProductData
|
|
37
|
+
|
|
38
|
+
client = PairusProductData()
|
|
39
|
+
|
|
40
|
+
predicao = client.fiscal.predict(
|
|
41
|
+
xProd="Refrigerante Coca-Cola Lata 350ml",
|
|
42
|
+
gtin="7894900010015",
|
|
43
|
+
regime_tributario="simples_nacional",
|
|
44
|
+
uf_origem="SP",
|
|
45
|
+
uf_destino="RJ",
|
|
46
|
+
finalidade="revenda"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
tributos = predicao.dados_tributarios
|
|
50
|
+
print(f"NCM: {tributos.ncm_sugerido} | CFOP: {tributos.cfop}")
|
|
51
|
+
print(f"CST IBS/CBS: {tributos.ibscbs.cst} | Classificação: {tributos.ibscbs.cClassTrib}")
|
|
52
|
+
print(f"Alíquota Efetiva CBS: {tributos.ibscbs.cbs_aliquota_efetiva}%")
|
|
53
|
+
print(f"Alíquota Efetiva IBS: {tributos.ibscbs.ibs_aliquota_efetiva}%")
|
|
54
|
+
print(f"Alíquota Unificada: {tributos.aliquota_efetiva_unificada}%")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 3. Validação de Webhooks com Assinatura Criptográfica (HMAC-SHA256)
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from pairus_product_data.webhooks import Webhooks
|
|
61
|
+
|
|
62
|
+
# Em um endpoint FastAPI / Flask / Django:
|
|
63
|
+
raw_body = request.body
|
|
64
|
+
signature_header = request.headers.get("X-Pairus-Signature")
|
|
65
|
+
webhook_secret = "whsec_seu_secret"
|
|
66
|
+
|
|
67
|
+
evento = Webhooks.construct_event(raw_body, signature_header, webhook_secret)
|
|
68
|
+
print(f"Evento autêntico recebido: {evento.event} para o GTIN: {evento.data.get('gtin')}")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 🛡️ Licença
|
|
74
|
+
Distribuído sob a licença MIT. Consulte `LICENSE` para mais detalhes.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""PAIRUS Product Data - SDK Oficial para Python."""
|
|
2
|
+
|
|
3
|
+
from pairus_product_data.client import PairusProductData, AsyncPairusProductData
|
|
4
|
+
from pairus_product_data.config import ClientConfig
|
|
5
|
+
from pairus_product_data.exceptions import (
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
InvalidSignatureError,
|
|
8
|
+
NetworkError,
|
|
9
|
+
PairusAPIError,
|
|
10
|
+
PairusError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
)
|
|
13
|
+
from pairus_product_data.webhooks import Webhooks
|
|
14
|
+
|
|
15
|
+
__version__ = "1.0.0"
|
|
16
|
+
__all__ = [
|
|
17
|
+
"PairusProductData",
|
|
18
|
+
"AsyncPairusProductData",
|
|
19
|
+
"ClientConfig",
|
|
20
|
+
"Webhooks",
|
|
21
|
+
"PairusError",
|
|
22
|
+
"PairusAPIError",
|
|
23
|
+
"AuthenticationError",
|
|
24
|
+
"RateLimitError",
|
|
25
|
+
"InvalidSignatureError",
|
|
26
|
+
"NetworkError",
|
|
27
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Cliente oficial PAIRUS Product Data."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from pairus_product_data.config import ClientConfig
|
|
7
|
+
from pairus_product_data.resources.fiscal import FiscalResource, AsyncFiscalResource
|
|
8
|
+
from pairus_product_data.resources.products import ProductsResource, AsyncProductsResource
|
|
9
|
+
from pairus_product_data.webhooks import Webhooks
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PairusProductData:
|
|
13
|
+
"""Cliente oficial síncrono para o ecossistema PAIRUS Product Data."""
|
|
14
|
+
|
|
15
|
+
webhooks = Webhooks
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
api_key: str | None = None,
|
|
20
|
+
base_url: str | None = None,
|
|
21
|
+
timeout: float | None = None,
|
|
22
|
+
max_retries: int | None = None,
|
|
23
|
+
client: httpx.Client | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.config = ClientConfig.from_env(
|
|
26
|
+
api_key=api_key,
|
|
27
|
+
base_url=base_url,
|
|
28
|
+
timeout=timeout,
|
|
29
|
+
max_retries=max_retries,
|
|
30
|
+
)
|
|
31
|
+
self._http_client = client or httpx.Client()
|
|
32
|
+
self.products = ProductsResource(self.config, self._http_client)
|
|
33
|
+
self.fiscal = FiscalResource(self.config, self._http_client)
|
|
34
|
+
|
|
35
|
+
def __enter__(self) -> "PairusProductData":
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
39
|
+
self._http_client.close()
|
|
40
|
+
|
|
41
|
+
def close(self) -> None:
|
|
42
|
+
"""Encerra a sessão HTTP."""
|
|
43
|
+
self._http_client.close()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AsyncPairusProductData:
|
|
47
|
+
"""Cliente oficial assíncrono para o ecossistema PAIRUS Product Data."""
|
|
48
|
+
|
|
49
|
+
webhooks = Webhooks
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
api_key: str | None = None,
|
|
54
|
+
base_url: str | None = None,
|
|
55
|
+
timeout: float | None = None,
|
|
56
|
+
max_retries: int | None = None,
|
|
57
|
+
client: httpx.AsyncClient | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
self.config = ClientConfig.from_env(
|
|
60
|
+
api_key=api_key,
|
|
61
|
+
base_url=base_url,
|
|
62
|
+
timeout=timeout,
|
|
63
|
+
max_retries=max_retries,
|
|
64
|
+
)
|
|
65
|
+
self._http_client = client or httpx.AsyncClient()
|
|
66
|
+
self.products = AsyncProductsResource(self.config, self._http_client)
|
|
67
|
+
self.fiscal = AsyncFiscalResource(self.config, self._http_client)
|
|
68
|
+
|
|
69
|
+
async def __aenter__(self) -> "AsyncPairusProductData":
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
73
|
+
await self._http_client.aclose()
|
|
74
|
+
|
|
75
|
+
async def close(self) -> None:
|
|
76
|
+
"""Encerra a sessão HTTP assíncrona."""
|
|
77
|
+
await self._http_client.aclose()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Configurações e opções do cliente PAIRUS."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
DEFAULT_BASE_URL = "https://api.pairus.com.br"
|
|
7
|
+
DEFAULT_TIMEOUT_SECONDS = 15.0
|
|
8
|
+
DEFAULT_MAX_RETRIES = 3
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ClientConfig:
|
|
13
|
+
"""Configurações de inicialização do cliente PAIRUS Product Data."""
|
|
14
|
+
|
|
15
|
+
api_key: str
|
|
16
|
+
base_url: str = DEFAULT_BASE_URL
|
|
17
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS
|
|
18
|
+
max_retries: int = DEFAULT_MAX_RETRIES
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def from_env(
|
|
22
|
+
cls,
|
|
23
|
+
api_key: str | None = None,
|
|
24
|
+
base_url: str | None = None,
|
|
25
|
+
timeout: float | None = None,
|
|
26
|
+
max_retries: int | None = None,
|
|
27
|
+
) -> "ClientConfig":
|
|
28
|
+
resolved_key = api_key or os.getenv("PAIRUS_API_KEY")
|
|
29
|
+
if not resolved_key:
|
|
30
|
+
raise ValueError(
|
|
31
|
+
"A chave de API da PAIRUS é obrigatória. Passe 'api_key' ou defina a variável 'PAIRUS_API_KEY'."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
resolved_url = (base_url or os.getenv("PAIRUS_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
35
|
+
resolved_timeout = timeout if timeout is not None else float(os.getenv("PAIRUS_TIMEOUT", DEFAULT_TIMEOUT_SECONDS))
|
|
36
|
+
resolved_retries = max_retries if max_retries is not None else int(os.getenv("PAIRUS_MAX_RETRIES", DEFAULT_MAX_RETRIES))
|
|
37
|
+
|
|
38
|
+
return cls(
|
|
39
|
+
api_key=resolved_key,
|
|
40
|
+
base_url=resolved_url,
|
|
41
|
+
timeout=resolved_timeout,
|
|
42
|
+
max_retries=resolved_retries,
|
|
43
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Exceções oficiais do SDK PAIRUS Product Data."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PairusError(Exception):
|
|
7
|
+
"""Exceção base para todos os erros do SDK PAIRUS."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str) -> None:
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.message = message
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PairusAPIError(PairusError):
|
|
15
|
+
"""Exceção levantada quando a API retorna um erro HTTP (4xx ou 5xx).
|
|
16
|
+
|
|
17
|
+
Mapeia os campos padronizados do layout SEFAZ/NF-e (cStat e xMotivo).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
status_code: int,
|
|
23
|
+
cStat: str | None = None,
|
|
24
|
+
xMotivo: str | None = None,
|
|
25
|
+
detail: str | None = None,
|
|
26
|
+
raw_response: dict[str, Any] | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.status_code = status_code
|
|
29
|
+
self.cStat = cStat or str(status_code)
|
|
30
|
+
self.xMotivo = xMotivo or detail or f"Erro HTTP {status_code}"
|
|
31
|
+
self.detail = detail
|
|
32
|
+
self.raw_response = raw_response or {}
|
|
33
|
+
|
|
34
|
+
mensagem = f"[{self.cStat}] {self.xMotivo}"
|
|
35
|
+
super().__init__(mensagem)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AuthenticationError(PairusAPIError):
|
|
39
|
+
"""Erro de autenticação (HTTP 401 - Chave de API inválida ou ausente)."""
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RateLimitError(PairusAPIError):
|
|
44
|
+
"""Erro de limite de taxa excedido (HTTP 429)."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
status_code: int = 429,
|
|
49
|
+
retry_after: float | None = None,
|
|
50
|
+
cStat: str | None = None,
|
|
51
|
+
xMotivo: str | None = None,
|
|
52
|
+
raw_response: dict[str, Any] | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.retry_after = retry_after
|
|
55
|
+
super().__init__(
|
|
56
|
+
status_code=status_code,
|
|
57
|
+
cStat=cStat or "429",
|
|
58
|
+
xMotivo=xMotivo or "Limite de requisições excedido. Tente novamente mais tarde.",
|
|
59
|
+
raw_response=raw_response,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class InvalidSignatureError(PairusError):
|
|
64
|
+
"""Erro levantado quando a assinatura criptográfica HMAC de um webhook é inválida."""
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class NetworkError(PairusError):
|
|
69
|
+
"""Erro de conexão ou timeout após esgotadas as tentativas automáticas."""
|
|
70
|
+
pass
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Modelos de dados tipados para a API PAIRUS Product Data."""
|
|
2
|
+
|
|
3
|
+
from pairus_product_data.models.fiscal import (
|
|
4
|
+
FuelData,
|
|
5
|
+
IBSCBSData,
|
|
6
|
+
SelectiveTaxData,
|
|
7
|
+
TaxPredictionData,
|
|
8
|
+
TaxPredictionInput,
|
|
9
|
+
TaxPredictionItemInput,
|
|
10
|
+
TaxPredictionItemResult,
|
|
11
|
+
TaxPredictionResponse,
|
|
12
|
+
)
|
|
13
|
+
from pairus_product_data.models.product import ProductDetails, ProductResponse
|
|
14
|
+
from pairus_product_data.models.webhook import WebhookEvent, WebhookPayload
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"FuelData",
|
|
18
|
+
"IBSCBSData",
|
|
19
|
+
"SelectiveTaxData",
|
|
20
|
+
"TaxPredictionData",
|
|
21
|
+
"TaxPredictionInput",
|
|
22
|
+
"TaxPredictionItemInput",
|
|
23
|
+
"TaxPredictionItemResult",
|
|
24
|
+
"TaxPredictionResponse",
|
|
25
|
+
"ProductDetails",
|
|
26
|
+
"ProductResponse",
|
|
27
|
+
"WebhookEvent",
|
|
28
|
+
"WebhookPayload",
|
|
29
|
+
]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Modelos de dados para predição tributária e Reforma Tributária."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class IBSCBSData(BaseModel):
|
|
7
|
+
"""Dados tributários referentes ao IBS e CBS (Reforma Tributária)."""
|
|
8
|
+
cst: str = Field(..., description="Código de Situação Tributária (CST) com 3 dígitos (ex: '000').")
|
|
9
|
+
cClassTrib: str = Field(..., description="Código de Classificação Tributária com 6 dígitos (ex: '000001').")
|
|
10
|
+
cbs_aliquota: float = Field(default=8.8, description="Alíquota nominal da CBS federal (%).")
|
|
11
|
+
cbs_diferimento: float = Field(default=0.0, description="Percentual de diferimento da CBS (%).")
|
|
12
|
+
cbs_reducao_aliquota: float = Field(default=0.0, description="Percentual de redução da alíquota da CBS (%).")
|
|
13
|
+
cbs_aliquota_efetiva: float = Field(default=8.8, description="Alíquota efetiva da CBS (%).")
|
|
14
|
+
ibs_aliquota_estadual: float = Field(default=10.0, description="Alíquota do IBS estadual da UF de destino (%).")
|
|
15
|
+
ibs_aliquota_municipal: float = Field(default=7.7, description="Alíquota do IBS municipal de destino (%).")
|
|
16
|
+
ibs_diferimento: float = Field(default=0.0, description="Percentual de diferimento do IBS (%).")
|
|
17
|
+
ibs_reducao_aliquota: float = Field(default=0.0, description="Percentual de redução da alíquota do IBS (%).")
|
|
18
|
+
ibs_aliquota_efetiva: float = Field(default=17.7, description="Alíquota efetiva total do IBS (%).")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SelectiveTaxData(BaseModel):
|
|
22
|
+
"""Dados tributários referentes ao Imposto Seletivo (IS)."""
|
|
23
|
+
cst: str = Field(default="001", description="CST do Imposto Seletivo com 3 dígitos.")
|
|
24
|
+
cClassTribIS: str = Field(default="000101", description="Classificação Tributária do IS com 6 dígitos.")
|
|
25
|
+
aliquota: float = Field(default=0.0, description="Alíquota do Imposto Seletivo (%).")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FuelData(BaseModel):
|
|
29
|
+
"""Dados específicos de combustíveis exigidos pela ANP para o XML da NF-e."""
|
|
30
|
+
codigo_anp: str = Field(..., description="Código do produto de 9 dígitos gerado pela ANP.")
|
|
31
|
+
descricao_anp: str = Field(..., description="Descrição oficial do produto na ANP.")
|
|
32
|
+
perc_glp: float = Field(default=0.0, description="Percentual de GLP (%).")
|
|
33
|
+
perc_gas_nacional: float = Field(default=0.0, description="Percentual de Gás Natural Nacional (%).")
|
|
34
|
+
perc_gas_importado: float = Field(default=0.0, description="Percentual de Gás Natural Importado (%).")
|
|
35
|
+
valor_por_kg: float = Field(default=0.0, description="Valor de partida por KG.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TaxPredictionData(BaseModel):
|
|
39
|
+
"""Estrutura consolidada de impostos e parâmetros fiscais sugeridos para NF-e."""
|
|
40
|
+
ncm_sugerido: str = Field(..., description="Código NCM sugerido (8 dígitos numéricos).")
|
|
41
|
+
cest_sugerido: str | None = Field(default=None, description="Código CEST sugerido (7 dígitos numéricos).")
|
|
42
|
+
cfop: str = Field(..., description="Código Fiscal de Operações e Prestações (CFOP de 4 dígitos).")
|
|
43
|
+
cfop_devolucao: str | None = Field(default=None, description="CFOP correspondente de devolução.")
|
|
44
|
+
icms_cst_csosn: str = Field(..., description="CST ou CSOSN do ICMS.")
|
|
45
|
+
icms_aliquota: float = Field(default=0.0, description="Alíquota própria de ICMS da operação (%).")
|
|
46
|
+
icms_aliquota_st: float = Field(default=0.0, description="Alíquota de ICMS ST (%).")
|
|
47
|
+
icms_mva_st: float = Field(default=0.0, description="Margem de Valor Agregado (MVA ST %).")
|
|
48
|
+
icms_reducao_bc: float = Field(default=0.0, description="Percentual de redução da base de cálculo do ICMS (%).")
|
|
49
|
+
icms_reducao_bc_st: float = Field(default=0.0, description="Percentual de redução da base de cálculo do ICMS ST (%).")
|
|
50
|
+
icms_modalidade_bc: str = Field(default="3", description="Modalidade de determinação da BC do ICMS.")
|
|
51
|
+
icms_modalidade_bc_st: str = Field(default="4", description="Modalidade de determinação da BC do ICMS ST.")
|
|
52
|
+
pis_cst: str = Field(..., description="CST do PIS.")
|
|
53
|
+
pis_aliquota: float = Field(default=0.0, description="Alíquota do PIS (%).")
|
|
54
|
+
cofins_cst: str = Field(..., description="CST da COFINS.")
|
|
55
|
+
cofins_aliquota: float = Field(default=0.0, description="Alíquota da COFINS (%).")
|
|
56
|
+
ipi_cst: str | None = Field(default=None, description="CST do IPI.")
|
|
57
|
+
ipi_aliquota: float = Field(default=0.0, description="Alíquota do IPI (%).")
|
|
58
|
+
ibscbs: IBSCBSData = Field(..., description="Detalhamento do IBS e CBS da Reforma Tributária.")
|
|
59
|
+
imposto_seletivo: SelectiveTaxData | None = Field(default=None, description="Detalhamento do Imposto Seletivo.")
|
|
60
|
+
dados_combustivel: FuelData | None = Field(default=None, description="Dados ANP para combustíveis.")
|
|
61
|
+
beneficio_fiscal: str | None = Field(default=None, description="Código de benefício fiscal estadual (cBenef).")
|
|
62
|
+
aliquota_efetiva_unificada: float = Field(..., description="Soma das alíquotas efetivas de CBS e IBS (%).")
|
|
63
|
+
motor_ia: str = Field(default="pairus_fiscal_engine", description="Motor de inteligência artificial utilizado.")
|
|
64
|
+
avisos: list[str] = Field(default_factory=list, description="Avisos e orientações fiscais.")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TaxPredictionItemInput(BaseModel):
|
|
68
|
+
"""Representação de um item para predição em lote."""
|
|
69
|
+
xProd: str | None = None
|
|
70
|
+
gtin: str | None = None
|
|
71
|
+
ncm: str | None = None
|
|
72
|
+
cest: str | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TaxPredictionInput(BaseModel):
|
|
76
|
+
"""Parâmetros de entrada para o endpoint de predição fiscal."""
|
|
77
|
+
regime_tributario: str = Field(..., description="Regime tributário ('simples_nacional', 'lucro_presumido', 'lucro_real').")
|
|
78
|
+
uf_origem: str = Field(..., description="UF de origem da mercadoria (sigla com 2 caracteres).")
|
|
79
|
+
uf_destino: str | None = Field(default=None, description="UF de destino (sigla com 2 caracteres).")
|
|
80
|
+
finalidade: str = Field(default="revenda", description="Finalidade da operação ('revenda', 'consumo_final', 'industrializacao').")
|
|
81
|
+
destinatario_contribuinte: bool = Field(default=True, description="Se o destinatário é contribuinte do ICMS.")
|
|
82
|
+
xProd: str | None = None
|
|
83
|
+
gtin: str | None = None
|
|
84
|
+
ncm: str | None = None
|
|
85
|
+
cest: str | None = None
|
|
86
|
+
itens: list[TaxPredictionItemInput] | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TaxPredictionItemResult(BaseModel):
|
|
90
|
+
"""Resultado individual de um item predito no modo lote."""
|
|
91
|
+
item_index: int
|
|
92
|
+
xProd: str | None = None
|
|
93
|
+
dados_tributarios: TaxPredictionData
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TaxPredictionResponse(BaseModel):
|
|
97
|
+
"""Resposta oficial da rota de predição fiscal /v2/fiscal/predict."""
|
|
98
|
+
status: str
|
|
99
|
+
provider: str
|
|
100
|
+
xProd: str | None = None
|
|
101
|
+
dados_tributarios: TaxPredictionData | None = None
|
|
102
|
+
resultados: list[TaxPredictionItemResult] | None = None
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Modelos de dados para produtos e catálogo GTIN."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ProductDetails(BaseModel):
|
|
8
|
+
"""Detalhes completos de um produto catalogado na base PAIRUS."""
|
|
9
|
+
gtin: str = Field(..., description="Código de barras GTIN/EAN numérico.")
|
|
10
|
+
xProd: str = Field(..., description="Nome comercial / descrição do produto.")
|
|
11
|
+
ncm: str | None = Field(default=None, description="Código NCM de 8 dígitos.")
|
|
12
|
+
cest: str | None = Field(default=None, description="Código CEST de 7 dígitos.")
|
|
13
|
+
marca: str | None = Field(default=None, description="Marca comercial do produto.")
|
|
14
|
+
fabricante: str | None = Field(default=None, description="Razão social ou nome do fabricante.")
|
|
15
|
+
categoria: str | None = Field(default=None, description="Categoria do produto.")
|
|
16
|
+
imagem_url: str | None = Field(default=None, description="URL da imagem oficial do produto.")
|
|
17
|
+
peso_bruto: float | None = Field(default=None, description="Peso bruto em gramas ou kg.")
|
|
18
|
+
peso_liquido: float | None = Field(default=None, description="Peso líquido.")
|
|
19
|
+
quantidade_embalagem: int | None = Field(default=1, description="Quantidade de itens na embalagem.")
|
|
20
|
+
data_cadastro: str | None = None
|
|
21
|
+
data_atualizacao: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ProductResponse(BaseModel):
|
|
25
|
+
"""Resposta de consulta de produto por GTIN."""
|
|
26
|
+
status: str
|
|
27
|
+
provider: str
|
|
28
|
+
produto: ProductDetails | None = None
|
|
29
|
+
raw_data: dict[str, Any] | None = None
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Modelos de dados para Webhooks da PAIRUS."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class WebhookPayload(BaseModel):
|
|
8
|
+
"""Payload base enviado nos eventos de Webhook da PAIRUS."""
|
|
9
|
+
event: str = Field(..., description="Nome do evento (ex: 'pairus.fiscal.update').")
|
|
10
|
+
timestamp: str = Field(..., description="Data/hora do evento no fuso horário do Brasil (ISO 8601).")
|
|
11
|
+
data: dict[str, Any] = Field(..., description="Dados específicos do evento.")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WebhookEvent(BaseModel):
|
|
15
|
+
"""Representação estruturada de um evento de webhook verificado."""
|
|
16
|
+
id: str | None = None
|
|
17
|
+
event: str
|
|
18
|
+
timestamp: str
|
|
19
|
+
data: dict[str, Any]
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Namespaces de recursos da API PAIRUS Product Data."""
|
|
2
|
+
|
|
3
|
+
from pairus_product_data.resources.fiscal import FiscalResource, AsyncFiscalResource
|
|
4
|
+
from pairus_product_data.resources.products import ProductsResource, AsyncProductsResource
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"FiscalResource",
|
|
8
|
+
"AsyncFiscalResource",
|
|
9
|
+
"ProductsResource",
|
|
10
|
+
"AsyncProductsResource",
|
|
11
|
+
]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Classe base para execução de requisições HTTP com retry exponencial."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import random
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from pairus_product_data.config import ClientConfig
|
|
11
|
+
from pairus_product_data.exceptions import (
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
NetworkError,
|
|
14
|
+
PairusAPIError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BaseResource:
|
|
20
|
+
"""Classe base síncrona com lógica de retentativas automáticas e tratamento de erros cStat/xMotivo."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, config: ClientConfig, client: httpx.Client) -> None:
|
|
23
|
+
self.config = config
|
|
24
|
+
self.client = client
|
|
25
|
+
|
|
26
|
+
def _request(
|
|
27
|
+
self,
|
|
28
|
+
method: str,
|
|
29
|
+
path: str,
|
|
30
|
+
params: dict[str, Any] | None = None,
|
|
31
|
+
json_data: dict[str, Any] | None = None,
|
|
32
|
+
headers: dict[str, str] | None = None,
|
|
33
|
+
) -> dict[str, Any]:
|
|
34
|
+
url = f"{self.config.base_url}/{path.lstrip('/')}"
|
|
35
|
+
req_headers = {
|
|
36
|
+
"Accept": "application/json",
|
|
37
|
+
"X-API-Key": self.config.api_key,
|
|
38
|
+
"User-Agent": "Pairus-Python-SDK/1.0.0",
|
|
39
|
+
}
|
|
40
|
+
if headers:
|
|
41
|
+
req_headers.update(headers)
|
|
42
|
+
|
|
43
|
+
last_exception: Exception | None = None
|
|
44
|
+
|
|
45
|
+
for attempt in range(self.config.max_retries):
|
|
46
|
+
try:
|
|
47
|
+
response = self.client.request(
|
|
48
|
+
method=method,
|
|
49
|
+
url=url,
|
|
50
|
+
params=params,
|
|
51
|
+
json=json_data,
|
|
52
|
+
headers=req_headers,
|
|
53
|
+
timeout=self.config.timeout,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if response.status_code == 200:
|
|
57
|
+
return response.json()
|
|
58
|
+
|
|
59
|
+
if response.status_code == 401:
|
|
60
|
+
data = response.json() if "application/json" in response.headers.get("content-type", "") else {}
|
|
61
|
+
raise AuthenticationError(
|
|
62
|
+
status_code=401,
|
|
63
|
+
cStat=data.get("cStat", "401"),
|
|
64
|
+
xMotivo=data.get("xMotivo", "Chave de API inválida ou não autorizada."),
|
|
65
|
+
raw_response=data,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if response.status_code == 429:
|
|
69
|
+
retry_after_str = response.headers.get("Retry-After")
|
|
70
|
+
retry_after = float(retry_after_str) if retry_after_str else 2.0
|
|
71
|
+
if attempt < self.config.max_retries - 1:
|
|
72
|
+
time.sleep(retry_after + random.uniform(0.1, 0.5))
|
|
73
|
+
continue
|
|
74
|
+
data = response.json() if "application/json" in response.headers.get("content-type", "") else {}
|
|
75
|
+
raise RateLimitError(
|
|
76
|
+
status_code=429,
|
|
77
|
+
retry_after=retry_after,
|
|
78
|
+
cStat=data.get("cStat", "429"),
|
|
79
|
+
xMotivo=data.get("xMotivo", "Limite de requisições excedido."),
|
|
80
|
+
raw_response=data,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Erros transitórios de servidor (502, 503, 504) -> aplicar retry
|
|
84
|
+
if response.status_code in (502, 503, 504) and attempt < self.config.max_retries - 1:
|
|
85
|
+
sleep_time = (2 ** attempt) + random.uniform(0.1, 0.5)
|
|
86
|
+
time.sleep(sleep_time)
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
# Erros estruturados 4xx ou 5xx
|
|
90
|
+
data = response.json() if "application/json" in response.headers.get("content-type", "") else {}
|
|
91
|
+
raise PairusAPIError(
|
|
92
|
+
status_code=response.status_code,
|
|
93
|
+
cStat=data.get("cStat", str(response.status_code)),
|
|
94
|
+
xMotivo=data.get("xMotivo") or data.get("detail") or f"Erro na requisição: {response.text[:200]}",
|
|
95
|
+
raw_response=data,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
|
99
|
+
last_exception = exc
|
|
100
|
+
if attempt < self.config.max_retries - 1:
|
|
101
|
+
sleep_time = (2 ** attempt) + random.uniform(0.1, 0.5)
|
|
102
|
+
time.sleep(sleep_time)
|
|
103
|
+
continue
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
raise NetworkError(f"Falha de conexão com a API PAIRUS após {self.config.max_retries} tentativas: {last_exception}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class AsyncBaseResource:
|
|
110
|
+
"""Classe base assíncrona com lógica de retentativas automáticas."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, config: ClientConfig, client: httpx.AsyncClient) -> None:
|
|
113
|
+
self.config = config
|
|
114
|
+
self.client = client
|
|
115
|
+
|
|
116
|
+
async def _request(
|
|
117
|
+
self,
|
|
118
|
+
method: str,
|
|
119
|
+
path: str,
|
|
120
|
+
params: dict[str, Any] | None = None,
|
|
121
|
+
json_data: dict[str, Any] | None = None,
|
|
122
|
+
headers: dict[str, str] | None = None,
|
|
123
|
+
) -> dict[str, Any]:
|
|
124
|
+
url = f"{self.config.base_url}/{path.lstrip('/')}"
|
|
125
|
+
req_headers = {
|
|
126
|
+
"Accept": "application/json",
|
|
127
|
+
"X-API-Key": self.config.api_key,
|
|
128
|
+
"User-Agent": "Pairus-Python-SDK/1.0.0",
|
|
129
|
+
}
|
|
130
|
+
if headers:
|
|
131
|
+
req_headers.update(headers)
|
|
132
|
+
|
|
133
|
+
last_exception: Exception | None = None
|
|
134
|
+
|
|
135
|
+
for attempt in range(self.config.max_retries):
|
|
136
|
+
try:
|
|
137
|
+
response = await self.client.request(
|
|
138
|
+
method=method,
|
|
139
|
+
url=url,
|
|
140
|
+
params=params,
|
|
141
|
+
json=json_data,
|
|
142
|
+
headers=req_headers,
|
|
143
|
+
timeout=self.config.timeout,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if response.status_code == 200:
|
|
147
|
+
return response.json()
|
|
148
|
+
|
|
149
|
+
if response.status_code == 401:
|
|
150
|
+
data = response.json() if "application/json" in response.headers.get("content-type", "") else {}
|
|
151
|
+
raise AuthenticationError(
|
|
152
|
+
status_code=401,
|
|
153
|
+
cStat=data.get("cStat", "401"),
|
|
154
|
+
xMotivo=data.get("xMotivo", "Chave de API inválida ou não autorizada."),
|
|
155
|
+
raw_response=data,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if response.status_code == 429:
|
|
159
|
+
retry_after_str = response.headers.get("Retry-After")
|
|
160
|
+
retry_after = float(retry_after_str) if retry_after_str else 2.0
|
|
161
|
+
if attempt < self.config.max_retries - 1:
|
|
162
|
+
await asyncio.sleep(retry_after + random.uniform(0.1, 0.5))
|
|
163
|
+
continue
|
|
164
|
+
data = response.json() if "application/json" in response.headers.get("content-type", "") else {}
|
|
165
|
+
raise RateLimitError(
|
|
166
|
+
status_code=429,
|
|
167
|
+
retry_after=retry_after,
|
|
168
|
+
cStat=data.get("cStat", "429"),
|
|
169
|
+
xMotivo=data.get("xMotivo", "Limite de requisições excedido."),
|
|
170
|
+
raw_response=data,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if response.status_code in (502, 503, 504) and attempt < self.config.max_retries - 1:
|
|
174
|
+
sleep_time = (2 ** attempt) + random.uniform(0.1, 0.5)
|
|
175
|
+
await asyncio.sleep(sleep_time)
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
data = response.json() if "application/json" in response.headers.get("content-type", "") else {}
|
|
179
|
+
raise PairusAPIError(
|
|
180
|
+
status_code=response.status_code,
|
|
181
|
+
cStat=data.get("cStat", str(response.status_code)),
|
|
182
|
+
xMotivo=data.get("xMotivo") or data.get("detail") or f"Erro na requisição: {response.text[:200]}",
|
|
183
|
+
raw_response=data,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
|
187
|
+
last_exception = exc
|
|
188
|
+
if attempt < self.config.max_retries - 1:
|
|
189
|
+
sleep_time = (2 ** attempt) + random.uniform(0.1, 0.5)
|
|
190
|
+
await asyncio.sleep(sleep_time)
|
|
191
|
+
continue
|
|
192
|
+
break
|
|
193
|
+
|
|
194
|
+
raise NetworkError(f"Falha de conexão assíncrona com a API PAIRUS após {self.config.max_retries} tentativas: {last_exception}")
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Recurso de predição fiscal e Reforma Tributária."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from pairus_product_data.models.fiscal import (
|
|
5
|
+
TaxPredictionItemInput,
|
|
6
|
+
TaxPredictionResponse,
|
|
7
|
+
)
|
|
8
|
+
from pairus_product_data.resources.base import BaseResource, AsyncBaseResource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FiscalResource(BaseResource):
|
|
12
|
+
"""Namespace fiscal (Síncrono)."""
|
|
13
|
+
|
|
14
|
+
def predict(
|
|
15
|
+
self,
|
|
16
|
+
regime_tributario: str,
|
|
17
|
+
uf_origem: str,
|
|
18
|
+
uf_destino: str | None = None,
|
|
19
|
+
finalidade: str = "revenda",
|
|
20
|
+
destinatario_contribuinte: bool = True,
|
|
21
|
+
xProd: str | None = None,
|
|
22
|
+
gtin: str | None = None,
|
|
23
|
+
ncm: str | None = None,
|
|
24
|
+
cest: str | None = None,
|
|
25
|
+
itens: list[dict[str, Any] | TaxPredictionItemInput] | None = None,
|
|
26
|
+
) -> TaxPredictionResponse:
|
|
27
|
+
"""Executa a predição fiscal completa (IBS, CBS, IS, ICMS, IPI, PIS, COFINS e regras anti-rejeição).
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
regime_tributario: 'simples_nacional', 'lucro_presumido' ou 'lucro_real'.
|
|
31
|
+
uf_origem: UF emissora (sigla de 2 letras).
|
|
32
|
+
uf_destino: UF destinatária (sigla de 2 letras).
|
|
33
|
+
finalidade: 'revenda', 'consumo_final' ou 'industrializacao'.
|
|
34
|
+
destinatario_contribuinte: Se o cliente é contribuinte do ICMS.
|
|
35
|
+
xProd: Descrição do produto (modo individual).
|
|
36
|
+
gtin: Código de barras (modo individual).
|
|
37
|
+
ncm: Código NCM (modo individual).
|
|
38
|
+
cest: Código CEST (modo individual).
|
|
39
|
+
itens: Lista de itens para cálculo em lote da Nota Fiscal.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
TaxPredictionResponse: Estrutura tributária completa e validada.
|
|
43
|
+
"""
|
|
44
|
+
payload: dict[str, Any] = {
|
|
45
|
+
"regime_tributario": regime_tributario,
|
|
46
|
+
"uf_origem": uf_origem,
|
|
47
|
+
"uf_destino": uf_destino or uf_origem,
|
|
48
|
+
"finalidade": finalidade,
|
|
49
|
+
"destinatario_contribuinte": destinatario_contribuinte,
|
|
50
|
+
}
|
|
51
|
+
if xProd:
|
|
52
|
+
payload["xProd"] = xProd
|
|
53
|
+
if gtin:
|
|
54
|
+
payload["gtin"] = gtin
|
|
55
|
+
if ncm:
|
|
56
|
+
payload["ncm"] = ncm
|
|
57
|
+
if cest:
|
|
58
|
+
payload["cest"] = cest
|
|
59
|
+
if itens:
|
|
60
|
+
payload["itens"] = [i.model_dump() if hasattr(i, "model_dump") else i for i in itens]
|
|
61
|
+
|
|
62
|
+
data = self._request("POST", "/v2/fiscal/predict", json_data=payload)
|
|
63
|
+
return TaxPredictionResponse(**data)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AsyncFiscalResource(AsyncBaseResource):
|
|
67
|
+
"""Namespace fiscal (Assíncrono)."""
|
|
68
|
+
|
|
69
|
+
async def predict(
|
|
70
|
+
self,
|
|
71
|
+
regime_tributario: str,
|
|
72
|
+
uf_origem: str,
|
|
73
|
+
uf_destino: str | None = None,
|
|
74
|
+
finalidade: str = "revenda",
|
|
75
|
+
destinatario_contribuinte: bool = True,
|
|
76
|
+
xProd: str | None = None,
|
|
77
|
+
gtin: str | None = None,
|
|
78
|
+
ncm: str | None = None,
|
|
79
|
+
cest: str | None = None,
|
|
80
|
+
itens: list[dict[str, Any] | TaxPredictionItemInput] | None = None,
|
|
81
|
+
) -> TaxPredictionResponse:
|
|
82
|
+
"""Executa a predição fiscal assíncrona completa."""
|
|
83
|
+
payload: dict[str, Any] = {
|
|
84
|
+
"regime_tributario": regime_tributario,
|
|
85
|
+
"uf_origem": uf_origem,
|
|
86
|
+
"uf_destino": uf_destino or uf_origem,
|
|
87
|
+
"finalidade": finalidade,
|
|
88
|
+
"destinatario_contribuinte": destinatario_contribuinte,
|
|
89
|
+
}
|
|
90
|
+
if xProd:
|
|
91
|
+
payload["xProd"] = xProd
|
|
92
|
+
if gtin:
|
|
93
|
+
payload["gtin"] = gtin
|
|
94
|
+
if ncm:
|
|
95
|
+
payload["ncm"] = ncm
|
|
96
|
+
if cest:
|
|
97
|
+
payload["cest"] = cest
|
|
98
|
+
if itens:
|
|
99
|
+
payload["itens"] = [i.model_dump() if hasattr(i, "model_dump") else i for i in itens]
|
|
100
|
+
|
|
101
|
+
data = await self._request("POST", "/v2/fiscal/predict", json_data=payload)
|
|
102
|
+
return TaxPredictionResponse(**data)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Recurso de consulta de produtos e catálogo GTIN."""
|
|
2
|
+
|
|
3
|
+
from pairus_product_data.models.product import ProductDetails
|
|
4
|
+
from pairus_product_data.resources.base import BaseResource, AsyncBaseResource
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ProductsResource(BaseResource):
|
|
8
|
+
"""Namespace de produtos (Síncrono)."""
|
|
9
|
+
|
|
10
|
+
def get(self, gtin: str) -> ProductDetails:
|
|
11
|
+
"""Consulta um produto por código de barras GTIN/EAN.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
gtin: Código GTIN numérico (8, 12, 13 ou 14 dígitos).
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
ProductDetails: Detalhes completos do produto catalogado.
|
|
18
|
+
"""
|
|
19
|
+
gtin_limpo = "".join(filter(str.isdigit, str(gtin)))
|
|
20
|
+
data = self._request("GET", f"/api/produtos/{gtin_limpo}")
|
|
21
|
+
|
|
22
|
+
prod_dict = data.get("produto") or data
|
|
23
|
+
return ProductDetails(
|
|
24
|
+
gtin=prod_dict.get("GTIN") or prod_dict.get("gtin") or gtin_limpo,
|
|
25
|
+
xProd=prod_dict.get("xProd") or prod_dict.get("descricao") or "Descrição Indisponível",
|
|
26
|
+
ncm=prod_dict.get("NCM") or prod_dict.get("ncm"),
|
|
27
|
+
cest=prod_dict.get("CEST") or prod_dict.get("cest"),
|
|
28
|
+
marca=prod_dict.get("marca"),
|
|
29
|
+
fabricante=prod_dict.get("fabricante"),
|
|
30
|
+
categoria=prod_dict.get("categoria"),
|
|
31
|
+
imagem_url=prod_dict.get("imagem_url") or prod_dict.get("thumbnail"),
|
|
32
|
+
peso_bruto=float(prod_dict.get("peso_bruto") or 0.0) if prod_dict.get("peso_bruto") else None,
|
|
33
|
+
peso_liquido=float(prod_dict.get("peso_liquido") or 0.0) if prod_dict.get("peso_liquido") else None,
|
|
34
|
+
quantidade_embalagem=int(prod_dict.get("quantidade_embalagem") or 1),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AsyncProductsResource(AsyncBaseResource):
|
|
39
|
+
"""Namespace de produtos (Assíncrono)."""
|
|
40
|
+
|
|
41
|
+
async def get(self, gtin: str) -> ProductDetails:
|
|
42
|
+
"""Consulta um produto por código de barras GTIN/EAN de forma assíncrona.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
gtin: Código GTIN numérico (8, 12, 13 ou 14 dígitos).
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
ProductDetails: Detalhes completos do produto catalogado.
|
|
49
|
+
"""
|
|
50
|
+
gtin_limpo = "".join(filter(str.isdigit, str(gtin)))
|
|
51
|
+
data = await self._request("GET", f"/api/produtos/{gtin_limpo}")
|
|
52
|
+
|
|
53
|
+
prod_dict = data.get("produto") or data
|
|
54
|
+
return ProductDetails(
|
|
55
|
+
gtin=prod_dict.get("GTIN") or prod_dict.get("gtin") or gtin_limpo,
|
|
56
|
+
xProd=prod_dict.get("xProd") or prod_dict.get("descricao") or "Descrição Indisponível",
|
|
57
|
+
ncm=prod_dict.get("NCM") or prod_dict.get("ncm"),
|
|
58
|
+
cest=prod_dict.get("CEST") or prod_dict.get("cest"),
|
|
59
|
+
marca=prod_dict.get("marca"),
|
|
60
|
+
fabricante=prod_dict.get("fabricante"),
|
|
61
|
+
categoria=prod_dict.get("categoria"),
|
|
62
|
+
imagem_url=prod_dict.get("imagem_url") or prod_dict.get("thumbnail"),
|
|
63
|
+
peso_bruto=float(prod_dict.get("peso_bruto") or 0.0) if prod_dict.get("peso_bruto") else None,
|
|
64
|
+
peso_liquido=float(prod_dict.get("peso_liquido") or 0.0) if prod_dict.get("peso_liquido") else None,
|
|
65
|
+
quantidade_embalagem=int(prod_dict.get("quantidade_embalagem") or 1),
|
|
66
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Helper oficial para validação criptográfica de Webhooks HMAC-SHA256."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pairus_product_data.exceptions import InvalidSignatureError
|
|
9
|
+
from pairus_product_data.models.webhook import WebhookPayload
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Webhooks:
|
|
13
|
+
"""Helper estático para validação de assinaturas de Webhooks da PAIRUS."""
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def compute_signature(payload_bytes: bytes, secret: str) -> str:
|
|
17
|
+
"""Calcula a assinatura HMAC-SHA256 esperada para o payload informado."""
|
|
18
|
+
return hmac.new(
|
|
19
|
+
key=secret.encode("utf-8"),
|
|
20
|
+
msg=payload_bytes,
|
|
21
|
+
digestmod=hashlib.sha256,
|
|
22
|
+
).hexdigest()
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def verify_signature(
|
|
26
|
+
cls,
|
|
27
|
+
payload: bytes | str | dict[str, Any],
|
|
28
|
+
signature: str | None,
|
|
29
|
+
secret: str,
|
|
30
|
+
) -> bool:
|
|
31
|
+
"""Verifica de forma segura (resistente a timing attacks) a assinatura de um webhook.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
payload: Corpo bruto da requisição (bytes ou string) ou dicionário já decodificado.
|
|
35
|
+
signature: Valor do cabeçalho 'X-Pairus-Signature'.
|
|
36
|
+
secret: Segredo do webhook (webhook_secret) configurado no painel PAIRUS.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
bool: True se a assinatura for válida e autêntica, False caso contrário.
|
|
40
|
+
"""
|
|
41
|
+
if not signature or not secret:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
if isinstance(payload, bytes):
|
|
45
|
+
payload_bytes = payload
|
|
46
|
+
elif isinstance(payload, str):
|
|
47
|
+
payload_bytes = payload.encode("utf-8")
|
|
48
|
+
elif isinstance(payload, dict):
|
|
49
|
+
payload_bytes = json.dumps(payload, sort_keys=True).encode("utf-8")
|
|
50
|
+
else:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
expected_signature = cls.compute_signature(payload_bytes, secret)
|
|
54
|
+
# Comparação constante no tempo contra timing attacks
|
|
55
|
+
return hmac.compare_digest(expected_signature, signature.strip())
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def construct_event(
|
|
59
|
+
cls,
|
|
60
|
+
payload: bytes | str,
|
|
61
|
+
signature: str | None,
|
|
62
|
+
secret: str,
|
|
63
|
+
) -> WebhookPayload:
|
|
64
|
+
"""Verifica a assinatura e converte o payload bruto em um modelo WebhookPayload tipado.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
InvalidSignatureError: Se a assinatura criptográfica for inválida.
|
|
68
|
+
ValueError: Se o payload JSON estiver corrompido.
|
|
69
|
+
"""
|
|
70
|
+
if not cls.verify_signature(payload, signature, secret):
|
|
71
|
+
raise InvalidSignatureError("A assinatura do webhook ('X-Pairus-Signature') é inválida ou expirada.")
|
|
72
|
+
|
|
73
|
+
raw_str = payload.decode("utf-8") if isinstance(payload, bytes) else payload
|
|
74
|
+
data = json.loads(raw_str)
|
|
75
|
+
return WebhookPayload(**data)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pairus-product-data"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "SDK oficial da PAIRUS para consulta de dados de produto (GTIN/EAN), catálogo e predição fiscal da Reforma Tributária (IBS/CBS/IS)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "PAIRUS Soluções Tecnológicas", email = "suporte@pairus.com.br" }
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"pairus",
|
|
17
|
+
"gtin",
|
|
18
|
+
"ean",
|
|
19
|
+
"fiscal",
|
|
20
|
+
"tributacao",
|
|
21
|
+
"reforma-tributaria",
|
|
22
|
+
"ibs",
|
|
23
|
+
"cbs",
|
|
24
|
+
"imposto-seletivo",
|
|
25
|
+
"nfe",
|
|
26
|
+
"sefaz"
|
|
27
|
+
]
|
|
28
|
+
classifiers = [
|
|
29
|
+
"Development Status :: 5 - Production/Stable",
|
|
30
|
+
"Intended Audience :: Developers",
|
|
31
|
+
"License :: OSI Approved :: MIT License",
|
|
32
|
+
"Programming Language :: Python :: 3",
|
|
33
|
+
"Programming Language :: Python :: 3.10",
|
|
34
|
+
"Programming Language :: Python :: 3.11",
|
|
35
|
+
"Programming Language :: Python :: 3.12",
|
|
36
|
+
"Programming Language :: Python :: 3.13",
|
|
37
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
38
|
+
"Typing :: Typed",
|
|
39
|
+
]
|
|
40
|
+
dependencies = [
|
|
41
|
+
"httpx>=0.25.0",
|
|
42
|
+
"pydantic>=2.0.0"
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[project.optional-dependencies]
|
|
46
|
+
dev = [
|
|
47
|
+
"pytest>=8.0.0",
|
|
48
|
+
"pytest-asyncio>=0.23.0",
|
|
49
|
+
"ruff>=0.4.0",
|
|
50
|
+
"mypy>=1.10.0"
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[project.urls]
|
|
54
|
+
Homepage = "https://pairus.com.br"
|
|
55
|
+
Documentation = "https://pairus.com.br/docs"
|
|
56
|
+
Repository = "https://github.com/marcos2r/api-consulta-gtin"
|
|
57
|
+
|
|
58
|
+
[tool.hatch.build.targets.wheel]
|
|
59
|
+
packages = ["pairus_product_data"]
|
|
60
|
+
|
|
61
|
+
[tool.ruff]
|
|
62
|
+
line-length = 120
|
|
63
|
+
target-version = "py310"
|
|
64
|
+
|
|
65
|
+
[tool.mypy]
|
|
66
|
+
python_version = "3.10"
|
|
67
|
+
strict = true
|
|
68
|
+
plugins = ["pydantic.mypy"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from unittest.mock import MagicMock
|
|
2
|
+
import pytest
|
|
3
|
+
from pairus_product_data import PairusProductData, AuthenticationError
|
|
4
|
+
|
|
5
|
+
def test_client_init_env(monkeypatch):
|
|
6
|
+
monkeypatch.setenv("PAIRUS_API_KEY", "pk_test_12345")
|
|
7
|
+
client = PairusProductData()
|
|
8
|
+
assert client.config.api_key == "pk_test_12345"
|
|
9
|
+
|
|
10
|
+
def test_client_init_explicit():
|
|
11
|
+
client = PairusProductData(api_key="pk_explicit_abc", timeout=10.0)
|
|
12
|
+
assert client.config.api_key == "pk_explicit_abc"
|
|
13
|
+
assert client.config.timeout == 10.0
|
|
14
|
+
|
|
15
|
+
def test_client_auth_error():
|
|
16
|
+
mock_http = MagicMock()
|
|
17
|
+
mock_resp = MagicMock()
|
|
18
|
+
mock_resp.status_code = 401
|
|
19
|
+
mock_resp.headers = {"content-type": "application/json"}
|
|
20
|
+
mock_resp.json.return_value = {"cStat": "401", "xMotivo": "Chave inválida"}
|
|
21
|
+
mock_http.request.return_value = mock_resp
|
|
22
|
+
|
|
23
|
+
client = PairusProductData(api_key="invalid_key", client=mock_http)
|
|
24
|
+
with pytest.raises(AuthenticationError) as exc_info:
|
|
25
|
+
client.products.get("7891000744703")
|
|
26
|
+
|
|
27
|
+
assert exc_info.value.status_code == 401
|
|
28
|
+
assert exc_info.value.cStat == "401"
|
|
29
|
+
assert "Chave inválida" in str(exc_info.value)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from unittest.mock import MagicMock
|
|
2
|
+
from pairus_product_data import PairusProductData
|
|
3
|
+
|
|
4
|
+
def test_fiscal_predict_individual():
|
|
5
|
+
mock_http = MagicMock()
|
|
6
|
+
mock_resp = MagicMock()
|
|
7
|
+
mock_resp.status_code = 200
|
|
8
|
+
mock_resp.json.return_value = {
|
|
9
|
+
"status": "success",
|
|
10
|
+
"provider": "PAIRUS Soluções Tecnológicas",
|
|
11
|
+
"xProd": "Refrigerante Coca-Cola 350ml",
|
|
12
|
+
"dados_tributarios": {
|
|
13
|
+
"ncm_sugerido": "22021000",
|
|
14
|
+
"cest_sugerido": "1701100",
|
|
15
|
+
"cfop": "5102",
|
|
16
|
+
"cfop_devolucao": "1202",
|
|
17
|
+
"icms_cst_csosn": "102",
|
|
18
|
+
"icms_aliquota": 0.0,
|
|
19
|
+
"icms_aliquota_st": 18.0,
|
|
20
|
+
"icms_mva_st": 40.0,
|
|
21
|
+
"icms_reducao_bc": 0.0,
|
|
22
|
+
"icms_reducao_bc_st": 0.0,
|
|
23
|
+
"icms_modalidade_bc": "3",
|
|
24
|
+
"icms_modalidade_bc_st": "4",
|
|
25
|
+
"pis_cst": "01",
|
|
26
|
+
"pis_aliquota": 1.65,
|
|
27
|
+
"cofins_cst": "01",
|
|
28
|
+
"cofins_aliquota": 7.6,
|
|
29
|
+
"ibscbs": {
|
|
30
|
+
"cst": "000",
|
|
31
|
+
"cClassTrib": "000001",
|
|
32
|
+
"cbs_aliquota": 8.8,
|
|
33
|
+
"cbs_diferimento": 0.0,
|
|
34
|
+
"cbs_reducao_aliquota": 0.0,
|
|
35
|
+
"cbs_aliquota_efetiva": 8.8,
|
|
36
|
+
"ibs_aliquota_estadual": 10.0,
|
|
37
|
+
"ibs_aliquota_municipal": 7.7,
|
|
38
|
+
"ibs_diferimento": 0.0,
|
|
39
|
+
"ibs_reducao_aliquota": 0.0,
|
|
40
|
+
"ibs_aliquota_efetiva": 17.7
|
|
41
|
+
},
|
|
42
|
+
"aliquota_efetiva_unificada": 26.5,
|
|
43
|
+
"motor_ia": "pairus_ai_rag_v3",
|
|
44
|
+
"avisos": []
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
mock_http.request.return_value = mock_resp
|
|
48
|
+
|
|
49
|
+
client = PairusProductData(api_key="pk_test_123", client=mock_http)
|
|
50
|
+
res = client.fiscal.predict(
|
|
51
|
+
xProd="Refrigerante Coca-Cola 350ml",
|
|
52
|
+
regime_tributario="simples_nacional",
|
|
53
|
+
uf_origem="SP",
|
|
54
|
+
uf_destino="RJ"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
assert res.status == "success"
|
|
58
|
+
assert res.dados_tributarios is not None
|
|
59
|
+
assert res.dados_tributarios.ncm_sugerido == "22021000"
|
|
60
|
+
assert res.dados_tributarios.ibscbs.cst == "000"
|
|
61
|
+
assert res.dados_tributarios.ibscbs.cbs_aliquota_efetiva == 8.8
|
|
62
|
+
assert res.dados_tributarios.aliquota_efetiva_unificada == 26.5
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from unittest.mock import MagicMock
|
|
2
|
+
from pairus_product_data import PairusProductData
|
|
3
|
+
|
|
4
|
+
def test_products_get_success():
|
|
5
|
+
mock_http = MagicMock()
|
|
6
|
+
mock_resp = MagicMock()
|
|
7
|
+
mock_resp.status_code = 200
|
|
8
|
+
mock_resp.json.return_value = {
|
|
9
|
+
"status": "success",
|
|
10
|
+
"provider": "PAIRUS Soluções Tecnológicas",
|
|
11
|
+
"produto": {
|
|
12
|
+
"GTIN": "7891000744703",
|
|
13
|
+
"xProd": "Cerveja Skol Lata 269ml",
|
|
14
|
+
"NCM": "22030000",
|
|
15
|
+
"CEST": "0300100",
|
|
16
|
+
"marca": "Skol"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
mock_http.request.return_value = mock_resp
|
|
20
|
+
|
|
21
|
+
client = PairusProductData(api_key="pk_test_123", client=mock_http)
|
|
22
|
+
prod = client.products.get("7891000744703")
|
|
23
|
+
|
|
24
|
+
assert prod.gtin == "7891000744703"
|
|
25
|
+
assert prod.xProd == "Cerveja Skol Lata 269ml"
|
|
26
|
+
assert prod.ncm == "22030000"
|
|
27
|
+
assert prod.cest == "0300100"
|
|
28
|
+
assert prod.marca == "Skol"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from pairus_product_data.webhooks import Webhooks
|
|
2
|
+
from pairus_product_data.exceptions import InvalidSignatureError
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
def test_webhook_signature_verification():
|
|
6
|
+
payload = b'{"data":{"gtin":"7891000744703"},"event":"pairus.fiscal.update","timestamp":"2026-08-29T19:00:00"}'
|
|
7
|
+
secret = "whsec_test_secret_12345678"
|
|
8
|
+
|
|
9
|
+
signature = Webhooks.compute_signature(payload, secret)
|
|
10
|
+
assert Webhooks.verify_signature(payload, signature, secret) is True
|
|
11
|
+
assert Webhooks.verify_signature(payload, "invalid_signature", secret) is False
|
|
12
|
+
|
|
13
|
+
def test_webhook_construct_event():
|
|
14
|
+
payload = b'{"data":{"gtin":"7891000744703"},"event":"pairus.fiscal.update","timestamp":"2026-08-29T19:00:00"}'
|
|
15
|
+
secret = "whsec_test_secret_12345678"
|
|
16
|
+
signature = Webhooks.compute_signature(payload, secret)
|
|
17
|
+
|
|
18
|
+
event = Webhooks.construct_event(payload, signature, secret)
|
|
19
|
+
assert event.event == "pairus.fiscal.update"
|
|
20
|
+
assert event.data["gtin"] == "7891000744703"
|
|
21
|
+
|
|
22
|
+
with pytest.raises(InvalidSignatureError):
|
|
23
|
+
Webhooks.construct_event(payload, "invalid_signature", secret)
|