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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/pairus-product-data.svg)](https://pypi.org/project/pairus-product-data/)
36
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
+ [![PyPI](https://img.shields.io/pypi/v/pairus-product-data.svg)](https://pypi.org/project/pairus-product-data/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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)