vitrin 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .mypy_cache/
10
+ .coverage
11
+ .DS_Store
vitrin-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,184 @@
1
+ Metadata-Version: 2.4
2
+ Name: vitrin
3
+ Version: 0.1.0
4
+ Summary: SDK oficial Python para a API da Vitrin Digital
5
+ Project-URL: Homepage, https://vitrin.digital
6
+ Project-URL: Documentation, https://vitrin.digital/docs/integracao
7
+ Project-URL: Repository, https://github.com/vitrindigital/vitrin
8
+ Author: Vitrin Digital
9
+ License-Expression: MIT
10
+ Keywords: boleto,checkout,pagamentos,pix,vitrin,vitrin-digital
11
+ Classifier: Development Status :: 4 - Beta
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: Topic :: Office/Business :: Financial
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: requests>=2.31
21
+ Provides-Extra: dev
22
+ Requires-Dist: mypy>=1.10; extra == 'dev'
23
+ Requires-Dist: pytest-mock>=3.12; extra == 'dev'
24
+ Requires-Dist: pytest>=8; extra == 'dev'
25
+ Requires-Dist: responses>=0.25; extra == 'dev'
26
+ Requires-Dist: ruff>=0.7; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # vitrin (Python)
30
+
31
+ SDK oficial Python para a [API da Vitrin Digital](https://api.vitrin.digital).
32
+
33
+ ```bash
34
+ pip install vitrin
35
+ ```
36
+
37
+ > **Python 3.10+** requerido. Única dependência runtime: `requests`.
38
+
39
+ ## Setup
40
+
41
+ ```python
42
+ import os
43
+ from vitrin import Vitrin
44
+
45
+ vitrin = Vitrin(
46
+ api_key=os.environ["VITRIN_API_KEY"],
47
+ # opcionais:
48
+ # base_url="https://api.vitrin.digital/api/v1",
49
+ # timeout=30.0,
50
+ # max_retries=3,
51
+ )
52
+ ```
53
+
54
+ Use `vd_test_*` em desenvolvimento, `vd_live_*` em produção.
55
+
56
+ ## Recursos
57
+
58
+ ### Clientes
59
+
60
+ ```python
61
+ customer = vitrin.customers.create(
62
+ name="Maria Silva",
63
+ email="maria@example.com",
64
+ cpf_cnpj="12345678901",
65
+ )
66
+
67
+ vitrin.customers.list(page=1)
68
+ vitrin.customers.update(customer["id"], phone="11987654321")
69
+ vitrin.customers.delete(customer["id"])
70
+ ```
71
+
72
+ ### Cobranças
73
+
74
+ ```python
75
+ charge = vitrin.charges.create(
76
+ customer_id=customer["id"],
77
+ amount=99.90,
78
+ billing_type="PIX",
79
+ description="Mensalidade abril",
80
+ idempotency_key=f"mensalidade-{customer['id']}-2026-04", # evita duplo-débito em retry
81
+ )
82
+
83
+ print(charge["pix_qr_code"])
84
+ print(charge["pix_copy_paste"])
85
+
86
+ # Reembolso parcial
87
+ vitrin.charges.refund(charge["id"], amount=50.0, pin="123456")
88
+ ```
89
+
90
+ ### Planos & Assinaturas
91
+
92
+ ```python
93
+ plan = vitrin.plans.create(name="Pro Mensal", price=99.0, billing_cycle="monthly")
94
+
95
+ sub = vitrin.subscriptions.create(
96
+ customer_id=customer["id"],
97
+ plan_id=plan["id"],
98
+ billing_type="CREDIT_CARD",
99
+ credit_card_token="tok_xxx",
100
+ )
101
+
102
+ vitrin.subscriptions.cancel(sub["id"], pin="123456")
103
+ ```
104
+
105
+ ### Saldo & Recebíveis
106
+
107
+ ```python
108
+ vitrin.balance.retrieve()
109
+ # → { "available": ..., "total": ..., "pending": ..., "withdrawal_fees": {...} }
110
+
111
+ vitrin.balance.scheduled(90)
112
+ # → cronograma 90d: PIX D+1, Boleto D+2, Cartão Nx D+30·n
113
+ ```
114
+
115
+ ## Webhooks
116
+
117
+ ```python
118
+ from flask import Flask, request, abort
119
+ from vitrin import webhooks, VitrinError
120
+ import os
121
+
122
+ app = Flask(__name__)
123
+
124
+ @app.post("/webhooks/vitrin")
125
+ def handle_webhook():
126
+ try:
127
+ event = webhooks.construct_event(
128
+ payload=request.get_data(), # body cru, NÃO parsed
129
+ signature=request.headers.get("X-Vitrin-Signature"),
130
+ timestamp=request.headers.get("X-Vitrin-Timestamp"),
131
+ event_type=request.headers.get("X-Vitrin-Event"),
132
+ event_id=request.headers.get("X-Vitrin-Event-Id"),
133
+ secret=os.environ["VITRIN_WEBHOOK_SECRET"],
134
+ )
135
+ print(event.type, event.id, event.data)
136
+ return "", 200
137
+ except VitrinError as e:
138
+ return str(e), 400
139
+ ```
140
+
141
+ ## Tratamento de erros
142
+
143
+ ```python
144
+ from vitrin import (
145
+ VitrinError, VitrinAuthError, VitrinValidationError,
146
+ VitrinRateLimitError, VitrinNotFoundError, VitrinServerError,
147
+ )
148
+
149
+ try:
150
+ vitrin.charges.create(...)
151
+ except VitrinValidationError as e:
152
+ print("Campos inválidos:", e.field_errors)
153
+ except VitrinAuthError:
154
+ print("Chave inválida ou sem permissão")
155
+ except VitrinRateLimitError:
156
+ print("Aguarde antes de tentar de novo")
157
+ except VitrinError as e:
158
+ print(f"Erro Vitrin: {e.status_code} {e.request_id} {e.message}")
159
+ ```
160
+
161
+ Retry automático em **429** e **5xx** com backoff exponencial (default: 3 tentativas). 4xx (exceto 429) não são retentados.
162
+
163
+ ## Idempotência
164
+
165
+ Inclua `idempotency_key` em POSTs sensíveis. Se o request chegar duas vezes (retry de rede, deploy etc), a Vitrin reconhece pela chave e devolve a mesma resposta — sem cobrar duas vezes.
166
+
167
+ ```python
168
+ vitrin.charges.create(
169
+ customer_id="cus_1",
170
+ amount=100,
171
+ billing_type="PIX",
172
+ idempotency_key=f"pedido-{order_id}", # único por pedido
173
+ )
174
+ ```
175
+
176
+ ## Acesso bruto
177
+
178
+ ```python
179
+ data = vitrin.request("/some/path/", method="POST", body={"foo": "bar"})
180
+ ```
181
+
182
+ ## Licença
183
+
184
+ MIT
vitrin-0.1.0/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # vitrin (Python)
2
+
3
+ SDK oficial Python para a [API da Vitrin Digital](https://api.vitrin.digital).
4
+
5
+ ```bash
6
+ pip install vitrin
7
+ ```
8
+
9
+ > **Python 3.10+** requerido. Única dependência runtime: `requests`.
10
+
11
+ ## Setup
12
+
13
+ ```python
14
+ import os
15
+ from vitrin import Vitrin
16
+
17
+ vitrin = Vitrin(
18
+ api_key=os.environ["VITRIN_API_KEY"],
19
+ # opcionais:
20
+ # base_url="https://api.vitrin.digital/api/v1",
21
+ # timeout=30.0,
22
+ # max_retries=3,
23
+ )
24
+ ```
25
+
26
+ Use `vd_test_*` em desenvolvimento, `vd_live_*` em produção.
27
+
28
+ ## Recursos
29
+
30
+ ### Clientes
31
+
32
+ ```python
33
+ customer = vitrin.customers.create(
34
+ name="Maria Silva",
35
+ email="maria@example.com",
36
+ cpf_cnpj="12345678901",
37
+ )
38
+
39
+ vitrin.customers.list(page=1)
40
+ vitrin.customers.update(customer["id"], phone="11987654321")
41
+ vitrin.customers.delete(customer["id"])
42
+ ```
43
+
44
+ ### Cobranças
45
+
46
+ ```python
47
+ charge = vitrin.charges.create(
48
+ customer_id=customer["id"],
49
+ amount=99.90,
50
+ billing_type="PIX",
51
+ description="Mensalidade abril",
52
+ idempotency_key=f"mensalidade-{customer['id']}-2026-04", # evita duplo-débito em retry
53
+ )
54
+
55
+ print(charge["pix_qr_code"])
56
+ print(charge["pix_copy_paste"])
57
+
58
+ # Reembolso parcial
59
+ vitrin.charges.refund(charge["id"], amount=50.0, pin="123456")
60
+ ```
61
+
62
+ ### Planos & Assinaturas
63
+
64
+ ```python
65
+ plan = vitrin.plans.create(name="Pro Mensal", price=99.0, billing_cycle="monthly")
66
+
67
+ sub = vitrin.subscriptions.create(
68
+ customer_id=customer["id"],
69
+ plan_id=plan["id"],
70
+ billing_type="CREDIT_CARD",
71
+ credit_card_token="tok_xxx",
72
+ )
73
+
74
+ vitrin.subscriptions.cancel(sub["id"], pin="123456")
75
+ ```
76
+
77
+ ### Saldo & Recebíveis
78
+
79
+ ```python
80
+ vitrin.balance.retrieve()
81
+ # → { "available": ..., "total": ..., "pending": ..., "withdrawal_fees": {...} }
82
+
83
+ vitrin.balance.scheduled(90)
84
+ # → cronograma 90d: PIX D+1, Boleto D+2, Cartão Nx D+30·n
85
+ ```
86
+
87
+ ## Webhooks
88
+
89
+ ```python
90
+ from flask import Flask, request, abort
91
+ from vitrin import webhooks, VitrinError
92
+ import os
93
+
94
+ app = Flask(__name__)
95
+
96
+ @app.post("/webhooks/vitrin")
97
+ def handle_webhook():
98
+ try:
99
+ event = webhooks.construct_event(
100
+ payload=request.get_data(), # body cru, NÃO parsed
101
+ signature=request.headers.get("X-Vitrin-Signature"),
102
+ timestamp=request.headers.get("X-Vitrin-Timestamp"),
103
+ event_type=request.headers.get("X-Vitrin-Event"),
104
+ event_id=request.headers.get("X-Vitrin-Event-Id"),
105
+ secret=os.environ["VITRIN_WEBHOOK_SECRET"],
106
+ )
107
+ print(event.type, event.id, event.data)
108
+ return "", 200
109
+ except VitrinError as e:
110
+ return str(e), 400
111
+ ```
112
+
113
+ ## Tratamento de erros
114
+
115
+ ```python
116
+ from vitrin import (
117
+ VitrinError, VitrinAuthError, VitrinValidationError,
118
+ VitrinRateLimitError, VitrinNotFoundError, VitrinServerError,
119
+ )
120
+
121
+ try:
122
+ vitrin.charges.create(...)
123
+ except VitrinValidationError as e:
124
+ print("Campos inválidos:", e.field_errors)
125
+ except VitrinAuthError:
126
+ print("Chave inválida ou sem permissão")
127
+ except VitrinRateLimitError:
128
+ print("Aguarde antes de tentar de novo")
129
+ except VitrinError as e:
130
+ print(f"Erro Vitrin: {e.status_code} {e.request_id} {e.message}")
131
+ ```
132
+
133
+ Retry automático em **429** e **5xx** com backoff exponencial (default: 3 tentativas). 4xx (exceto 429) não são retentados.
134
+
135
+ ## Idempotência
136
+
137
+ Inclua `idempotency_key` em POSTs sensíveis. Se o request chegar duas vezes (retry de rede, deploy etc), a Vitrin reconhece pela chave e devolve a mesma resposta — sem cobrar duas vezes.
138
+
139
+ ```python
140
+ vitrin.charges.create(
141
+ customer_id="cus_1",
142
+ amount=100,
143
+ billing_type="PIX",
144
+ idempotency_key=f"pedido-{order_id}", # único por pedido
145
+ )
146
+ ```
147
+
148
+ ## Acesso bruto
149
+
150
+ ```python
151
+ data = vitrin.request("/some/path/", method="POST", body={"foo": "bar"})
152
+ ```
153
+
154
+ ## Licença
155
+
156
+ MIT
@@ -0,0 +1,54 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "vitrin"
7
+ version = "0.1.0"
8
+ description = "SDK oficial Python para a API da Vitrin Digital"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "Vitrin Digital" }]
13
+ keywords = ["vitrin", "vitrin-digital", "pagamentos", "pix", "boleto", "checkout"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Office/Business :: Financial",
23
+ ]
24
+ dependencies = [
25
+ "requests>=2.31",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "pytest>=8",
31
+ "pytest-mock>=3.12",
32
+ "responses>=0.25",
33
+ "ruff>=0.7",
34
+ "mypy>=1.10",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://vitrin.digital"
39
+ Documentation = "https://vitrin.digital/docs/integracao"
40
+ Repository = "https://github.com/vitrindigital/vitrin"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/vitrin"]
44
+
45
+ [tool.ruff]
46
+ line-length = 100
47
+ target-version = "py310"
48
+
49
+ [tool.ruff.lint]
50
+ select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM"]
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
54
+ python_files = ["test_*.py"]
@@ -0,0 +1,83 @@
1
+ """SDK oficial Python para a API da Vitrin Digital.
2
+
3
+ Uso típico::
4
+
5
+ from vitrin import Vitrin
6
+
7
+ vitrin = Vitrin(api_key=os.environ["VITRIN_API_KEY"])
8
+
9
+ customer = vitrin.customers.create(
10
+ name="Maria Silva",
11
+ email="maria@example.com",
12
+ cpf_cnpj="12345678901",
13
+ )
14
+ charge = vitrin.charges.create(
15
+ customer_id=customer["id"],
16
+ amount=99.90,
17
+ billing_type="PIX",
18
+ idempotency_key=f"pedido-{order_id}",
19
+ )
20
+ print(charge["pix_qr_code"])
21
+
22
+ Validação de webhook::
23
+
24
+ from vitrin import webhooks
25
+ event = webhooks.construct_event(
26
+ payload=request.body,
27
+ signature=request.headers["X-Vitrin-Signature"],
28
+ secret=os.environ["VITRIN_WEBHOOK_SECRET"],
29
+ )
30
+ """
31
+
32
+ from ._client import Client, DEFAULT_BASE_URL
33
+ from .errors import (
34
+ VitrinError,
35
+ VitrinAuthError,
36
+ VitrinValidationError,
37
+ VitrinRateLimitError,
38
+ VitrinNotFoundError,
39
+ VitrinServerError,
40
+ VitrinNetworkError,
41
+ )
42
+ from .resources import Charges, Customers, Plans, Subscriptions, Products, BalanceAPI
43
+ from . import webhooks
44
+
45
+ __version__ = '0.1.0'
46
+
47
+ __all__ = [
48
+ 'Vitrin',
49
+ 'webhooks',
50
+ 'VitrinError',
51
+ 'VitrinAuthError',
52
+ 'VitrinValidationError',
53
+ 'VitrinRateLimitError',
54
+ 'VitrinNotFoundError',
55
+ 'VitrinServerError',
56
+ 'VitrinNetworkError',
57
+ ]
58
+
59
+
60
+ class Vitrin:
61
+ """Entry-point do SDK. Instanciar com a `api_key` da org."""
62
+
63
+ def __init__(
64
+ self,
65
+ api_key: str,
66
+ *,
67
+ base_url: str = DEFAULT_BASE_URL,
68
+ timeout: float = 30.0,
69
+ max_retries: int = 3,
70
+ ) -> None:
71
+ self.client = Client(
72
+ api_key=api_key, base_url=base_url, timeout=timeout, max_retries=max_retries,
73
+ )
74
+ self.charges = Charges(self.client)
75
+ self.customers = Customers(self.client)
76
+ self.plans = Plans(self.client)
77
+ self.subscriptions = Subscriptions(self.client)
78
+ self.products = Products(self.client)
79
+ self.balance = BalanceAPI(self.client)
80
+
81
+ def request(self, path: str, **kwargs): # type: ignore[no-untyped-def]
82
+ """Acesso bruto pra endpoints não cobertos pelos resources."""
83
+ return self.client.request(path, **kwargs)
@@ -0,0 +1,191 @@
1
+ """HTTP client interno do SDK.
2
+
3
+ Wrapper sobre `requests.Session` com:
4
+ - Headers padrão (Authorization, User-Agent, Content-Type)
5
+ - Mapeamento de status HTTP → :class:`vitrin.errors.VitrinError`
6
+ - Retry automático em 429/5xx com backoff exponencial e jitter
7
+ - Idempotency-Key opcional pra POSTs
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json as _json
13
+ import platform
14
+ import random
15
+ import time
16
+ from typing import Any
17
+ from urllib.parse import urljoin
18
+
19
+ import requests
20
+
21
+ from .errors import (
22
+ VitrinAuthError,
23
+ VitrinError,
24
+ VitrinNetworkError,
25
+ VitrinNotFoundError,
26
+ VitrinRateLimitError,
27
+ VitrinServerError,
28
+ VitrinValidationError,
29
+ )
30
+
31
+ DEFAULT_BASE_URL = 'https://api.vitrin.digital/api/v1'
32
+ SDK_VERSION = '0.1.0'
33
+
34
+
35
+ class Client:
36
+ def __init__(
37
+ self,
38
+ api_key: str,
39
+ *,
40
+ base_url: str = DEFAULT_BASE_URL,
41
+ timeout: float = 30.0,
42
+ max_retries: int = 3,
43
+ session: requests.Session | None = None,
44
+ ) -> None:
45
+ if not api_key:
46
+ raise ValueError('api_key é obrigatório')
47
+ self.api_key = api_key
48
+ self.base_url = base_url.rstrip('/')
49
+ self.timeout = timeout
50
+ self.max_retries = max_retries
51
+ self.session = session or requests.Session()
52
+
53
+ def request(
54
+ self,
55
+ path: str,
56
+ *,
57
+ method: str = 'GET',
58
+ body: Any = None,
59
+ query: dict[str, Any] | None = None,
60
+ idempotency_key: str | None = None,
61
+ ) -> Any:
62
+ url = urljoin(self.base_url + '/', path.lstrip('/'))
63
+ headers = self._build_headers(has_body=body is not None, idempotency_key=idempotency_key)
64
+ params = _filter_query(query)
65
+
66
+ last_exc: VitrinError | None = None
67
+
68
+ for attempt in range(self.max_retries + 1):
69
+ try:
70
+ resp = self.session.request(
71
+ method=method,
72
+ url=url,
73
+ headers=headers,
74
+ params=params,
75
+ data=_json.dumps(body) if body is not None else None,
76
+ timeout=self.timeout,
77
+ )
78
+ except requests.exceptions.Timeout as e:
79
+ last_exc = VitrinNetworkError(f'Timeout após {self.timeout}s')
80
+ if attempt < self.max_retries:
81
+ time.sleep(_backoff(attempt))
82
+ continue
83
+ raise last_exc from e
84
+ except requests.exceptions.RequestException as e:
85
+ last_exc = VitrinNetworkError(str(e))
86
+ if attempt < self.max_retries:
87
+ time.sleep(_backoff(attempt))
88
+ continue
89
+ raise last_exc from e
90
+
91
+ request_id = resp.headers.get('x-request-id')
92
+
93
+ if 200 <= resp.status_code < 300:
94
+ if resp.status_code == 204 or not resp.content:
95
+ return None
96
+ try:
97
+ return resp.json()
98
+ except ValueError:
99
+ return resp.text
100
+
101
+ try:
102
+ parsed = resp.json()
103
+ except ValueError:
104
+ parsed = resp.text
105
+
106
+ err = _map_error(resp.status_code, parsed, request_id)
107
+ retriable = resp.status_code == 429 or resp.status_code >= 500
108
+ if retriable and attempt < self.max_retries:
109
+ retry_after = _parse_retry_after(resp.headers.get('retry-after'))
110
+ time.sleep(retry_after if retry_after is not None else _backoff(attempt))
111
+ last_exc = err
112
+ continue
113
+ raise err
114
+
115
+ raise last_exc or VitrinError('Esgotou tentativas sem erro identificado')
116
+
117
+ def _build_headers(self, *, has_body: bool, idempotency_key: str | None) -> dict[str, str]:
118
+ py_version = platform.python_version()
119
+ ua = f'vitrin-python/{SDK_VERSION} python/{py_version}'
120
+ headers: dict[str, str] = {
121
+ 'Authorization': f'Bearer {self.api_key}',
122
+ 'Accept': 'application/json',
123
+ 'User-Agent': ua,
124
+ }
125
+ if has_body:
126
+ headers['Content-Type'] = 'application/json'
127
+ if idempotency_key:
128
+ headers['Idempotency-Key'] = idempotency_key
129
+ return headers
130
+
131
+
132
+ def _filter_query(query: dict[str, Any] | None) -> dict[str, Any] | None:
133
+ if not query:
134
+ return None
135
+ return {k: v for k, v in query.items() if v is not None and v != ''}
136
+
137
+
138
+ def _map_error(status: int, body: Any, request_id: str | None) -> VitrinError:
139
+ message = _extract_message(body) or f'HTTP {status}'
140
+ common = {'status_code': status, 'body': body, 'request_id': request_id}
141
+
142
+ if status in (401, 403):
143
+ return VitrinAuthError(message, **common)
144
+ if status == 404:
145
+ return VitrinNotFoundError(message, **common)
146
+ if status == 429:
147
+ return VitrinRateLimitError(message, **common)
148
+ if status >= 500:
149
+ return VitrinServerError(message, **common)
150
+ if status in (400, 422):
151
+ return VitrinValidationError(
152
+ message, **common, field_errors=_extract_field_errors(body),
153
+ )
154
+ return VitrinError(message, **common)
155
+
156
+
157
+ def _extract_message(body: Any) -> str | None:
158
+ if not isinstance(body, dict):
159
+ return None
160
+ if isinstance(body.get('error'), str):
161
+ return body['error']
162
+ if isinstance(body.get('detail'), str):
163
+ return body['detail']
164
+ for v in body.values():
165
+ if isinstance(v, list) and v and isinstance(v[0], str):
166
+ return v[0]
167
+ return None
168
+
169
+
170
+ def _extract_field_errors(body: Any) -> dict[str, list[str]]:
171
+ if not isinstance(body, dict):
172
+ return {}
173
+ out: dict[str, list[str]] = {}
174
+ for k, v in body.items():
175
+ if isinstance(v, list) and all(isinstance(x, str) for x in v):
176
+ out[k] = v
177
+ return out
178
+
179
+
180
+ def _parse_retry_after(value: str | None) -> float | None:
181
+ if not value:
182
+ return None
183
+ try:
184
+ return float(value)
185
+ except ValueError:
186
+ return None
187
+
188
+
189
+ def _backoff(attempt: int) -> float:
190
+ base = 0.25 * (2 ** attempt)
191
+ return base + random.uniform(0, base * 0.5)
@@ -0,0 +1,64 @@
1
+ """Hierarquia de erros do SDK Vitrin.
2
+
3
+ Todos descendem de :class:`VitrinError`.
4
+ """
5
+
6
+ from __future__ import annotations
7
+ from typing import Any
8
+
9
+
10
+ class VitrinError(Exception):
11
+ """Erro base do SDK Vitrin. Toda chamada à API que falha levanta isso ou subclasse."""
12
+
13
+ def __init__(
14
+ self,
15
+ message: str,
16
+ *,
17
+ status_code: int | None = None,
18
+ body: Any = None,
19
+ request_id: str | None = None,
20
+ ) -> None:
21
+ super().__init__(message)
22
+ self.message = message
23
+ self.status_code = status_code
24
+ self.body = body
25
+ self.request_id = request_id
26
+
27
+ def __repr__(self) -> str: # pragma: no cover
28
+ return f'<{type(self).__name__}: {self.message} (status={self.status_code})>'
29
+
30
+
31
+ class VitrinAuthError(VitrinError):
32
+ """401/403 — chave inválida, expirada, ou sem permissão."""
33
+
34
+
35
+ class VitrinValidationError(VitrinError):
36
+ """400/422 — DRF retornou erros de campo ou body inválido."""
37
+
38
+ def __init__(
39
+ self,
40
+ message: str,
41
+ *,
42
+ status_code: int | None = None,
43
+ body: Any = None,
44
+ request_id: str | None = None,
45
+ field_errors: dict[str, list[str]] | None = None,
46
+ ) -> None:
47
+ super().__init__(message, status_code=status_code, body=body, request_id=request_id)
48
+ self.field_errors = field_errors or {}
49
+
50
+
51
+ class VitrinRateLimitError(VitrinError):
52
+ """429 — você ultrapassou o rate limit."""
53
+
54
+
55
+ class VitrinNotFoundError(VitrinError):
56
+ """404 — recurso não encontrado."""
57
+
58
+
59
+ class VitrinServerError(VitrinError):
60
+ """5xx — falha do lado do servidor."""
61
+
62
+
63
+ class VitrinNetworkError(VitrinError):
64
+ """Falha antes da resposta HTTP — DNS, timeout, conexão recusada."""
@@ -0,0 +1,8 @@
1
+ from .charges import Charges
2
+ from .customers import Customers
3
+ from .plans import Plans
4
+ from .subscriptions import Subscriptions
5
+ from .products import Products
6
+ from .balance import BalanceAPI
7
+
8
+ __all__ = ['Charges', 'Customers', 'Plans', 'Subscriptions', 'Products', 'BalanceAPI']
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+ from typing import Any
3
+ from .._client import Client
4
+
5
+
6
+ class BalanceAPI:
7
+ def __init__(self, client: Client) -> None:
8
+ self._client = client
9
+
10
+ def retrieve(self) -> dict[str, Any]:
11
+ return self._client.request('/balance/')
12
+
13
+ def scheduled(self, days_ahead: int = 90) -> dict[str, Any]:
14
+ return self._client.request('/balance/scheduled/', query={'days_ahead': days_ahead})
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+ from typing import Any
3
+ from .._client import Client
4
+
5
+
6
+ class Charges:
7
+ """Cobranças avulsas — PIX, Boleto, Cartão."""
8
+
9
+ def __init__(self, client: Client) -> None:
10
+ self._client = client
11
+
12
+ def create(
13
+ self,
14
+ *,
15
+ customer_id: str,
16
+ amount: float | str,
17
+ billing_type: str,
18
+ description: str = '',
19
+ due_date: str | None = None,
20
+ installments: int = 1,
21
+ credit_card_token: str | None = None,
22
+ idempotency_key: str | None = None,
23
+ ) -> dict[str, Any]:
24
+ body = {
25
+ 'customer_id': customer_id,
26
+ 'amount': amount,
27
+ 'billing_type': billing_type,
28
+ 'description': description,
29
+ 'installments': installments,
30
+ }
31
+ if due_date:
32
+ body['due_date'] = due_date
33
+ if credit_card_token:
34
+ body['credit_card_token'] = credit_card_token
35
+ return self._client.request(
36
+ '/charges/', method='POST', body=body, idempotency_key=idempotency_key,
37
+ )
38
+
39
+ def retrieve(self, charge_id: str) -> dict[str, Any]:
40
+ return self._client.request(f'/transactions/{charge_id}/')
41
+
42
+ def list(self, **params: Any) -> dict[str, Any]:
43
+ return self._client.request('/reports/transactions/', query=params)
44
+
45
+ def refund(
46
+ self,
47
+ charge_id: str,
48
+ *,
49
+ pin: str,
50
+ amount: float | str | None = None,
51
+ ) -> dict[str, Any]:
52
+ body: dict[str, Any] = {'pin': pin}
53
+ if amount is not None:
54
+ body['amount'] = amount
55
+ return self._client.request(
56
+ f'/payments/{charge_id}/refund/', method='POST', body=body,
57
+ )
58
+
59
+ def cancel(self, charge_id: str, *, pin: str) -> dict[str, Any]:
60
+ return self._client.request(
61
+ f'/payments/{charge_id}/cancel/', method='POST', body={'pin': pin},
62
+ )
63
+
64
+ def status(self, charge_id: str) -> dict[str, Any]:
65
+ return self._client.request(f'/payments/{charge_id}/status/')
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+ from typing import Any
3
+ from .._client import Client
4
+
5
+
6
+ class Customers:
7
+ def __init__(self, client: Client) -> None:
8
+ self._client = client
9
+
10
+ def create(self, **fields: Any) -> dict[str, Any]:
11
+ return self._client.request('/customers/', method='POST', body=fields)
12
+
13
+ def retrieve(self, customer_id: str) -> dict[str, Any]:
14
+ return self._client.request(f'/customers/{customer_id}/')
15
+
16
+ def list(self, **params: Any) -> dict[str, Any]:
17
+ return self._client.request('/customers/', query=params)
18
+
19
+ def update(self, customer_id: str, **fields: Any) -> dict[str, Any]:
20
+ return self._client.request(
21
+ f'/customers/{customer_id}/', method='PATCH', body=fields,
22
+ )
23
+
24
+ def delete(self, customer_id: str) -> None:
25
+ self._client.request(f'/customers/{customer_id}/', method='DELETE')
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+ from typing import Any
3
+ from .._client import Client
4
+
5
+
6
+ class Plans:
7
+ def __init__(self, client: Client) -> None:
8
+ self._client = client
9
+
10
+ def create(self, **fields: Any) -> dict[str, Any]:
11
+ return self._client.request('/plans/', method='POST', body=fields)
12
+
13
+ def retrieve(self, plan_id: str) -> dict[str, Any]:
14
+ return self._client.request(f'/plans/{plan_id}/')
15
+
16
+ def list(self, **params: Any) -> dict[str, Any]:
17
+ return self._client.request('/plans/', query=params)
18
+
19
+ def update(self, plan_id: str, **fields: Any) -> dict[str, Any]:
20
+ return self._client.request(f'/plans/{plan_id}/', method='PATCH', body=fields)
21
+
22
+ def delete(self, plan_id: str) -> None:
23
+ self._client.request(f'/plans/{plan_id}/', method='DELETE')
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+ from typing import Any
3
+ from .._client import Client
4
+
5
+
6
+ class Products:
7
+ def __init__(self, client: Client) -> None:
8
+ self._client = client
9
+
10
+ def create(self, **fields: Any) -> dict[str, Any]:
11
+ return self._client.request('/products/', method='POST', body=fields)
12
+
13
+ def retrieve(self, product_id: str) -> dict[str, Any]:
14
+ return self._client.request(f'/products/{product_id}/')
15
+
16
+ def list(self, **params: Any) -> dict[str, Any]:
17
+ return self._client.request('/products/', query=params)
18
+
19
+ def update(self, product_id: str, **fields: Any) -> dict[str, Any]:
20
+ return self._client.request(
21
+ f'/products/{product_id}/', method='PATCH', body=fields,
22
+ )
23
+
24
+ def delete(self, product_id: str) -> None:
25
+ self._client.request(f'/products/{product_id}/', method='DELETE')
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+ from typing import Any
3
+ from .._client import Client
4
+
5
+
6
+ class Subscriptions:
7
+ def __init__(self, client: Client) -> None:
8
+ self._client = client
9
+
10
+ def create(self, **fields: Any) -> dict[str, Any]:
11
+ return self._client.request('/subscriptions/', method='POST', body=fields)
12
+
13
+ def retrieve(self, sub_id: str) -> dict[str, Any]:
14
+ return self._client.request(f'/subscriptions/{sub_id}/')
15
+
16
+ def list(self, **params: Any) -> dict[str, Any]:
17
+ return self._client.request('/subscriptions/', query=params)
18
+
19
+ def cancel(self, sub_id: str, *, pin: str) -> dict[str, Any]:
20
+ return self._client.request(
21
+ f'/subscriptions/{sub_id}/cancel/', method='POST', body={'pin': pin},
22
+ )
@@ -0,0 +1,74 @@
1
+ """Validação de webhooks Vitrin → seu backend Python.
2
+
3
+ A Vitrin envia POST com:
4
+ X-Vitrin-Signature: HMAC-SHA256 hex do body cru com o webhook_secret
5
+ X-Vitrin-Timestamp: epoch UTC em segundos
6
+ X-Vitrin-Event: tipo do evento
7
+ X-Vitrin-Event-Id: ID único pra idempotência
8
+
9
+ Use :func:`construct_event` no handler do seu backend antes de processar.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import hmac
16
+ import json
17
+ import time
18
+ from dataclasses import dataclass, field
19
+ from typing import Any
20
+
21
+ from .errors import VitrinError
22
+
23
+
24
+ @dataclass
25
+ class WebhookEvent:
26
+ type: str
27
+ id: str
28
+ timestamp_ms: int
29
+ data: dict[str, Any] = field(default_factory=dict)
30
+
31
+
32
+ def construct_event(
33
+ *,
34
+ payload: bytes | str,
35
+ signature: str | None,
36
+ secret: str,
37
+ timestamp: str | None = None,
38
+ event_type: str | None = None,
39
+ event_id: str | None = None,
40
+ tolerance_ms: int = 300_000,
41
+ ) -> WebhookEvent:
42
+ """Valida a assinatura HMAC + timestamp e retorna o evento parseado."""
43
+ if not signature:
44
+ raise VitrinError('Header X-Vitrin-Signature ausente.')
45
+ if not secret:
46
+ raise VitrinError('webhook secret é obrigatório.')
47
+
48
+ raw = payload.encode('utf-8') if isinstance(payload, str) else payload
49
+ expected = hmac.new(secret.encode('utf-8'), raw, hashlib.sha256).hexdigest()
50
+ if not hmac.compare_digest(expected, signature.strip()):
51
+ raise VitrinError('Assinatura inválida — payload pode ter sido adulterado.')
52
+
53
+ if tolerance_ms > 0 and timestamp:
54
+ try:
55
+ ts_ms = int(timestamp) * 1000
56
+ except ValueError as e:
57
+ raise VitrinError('X-Vitrin-Timestamp inválido.') from e
58
+ drift = abs(int(time.time() * 1000) - ts_ms)
59
+ if drift > tolerance_ms:
60
+ raise VitrinError(
61
+ f'Webhook fora da janela de tolerância (drift {drift}ms > {tolerance_ms}ms).'
62
+ )
63
+
64
+ try:
65
+ data = json.loads(raw.decode('utf-8'))
66
+ except (ValueError, UnicodeDecodeError) as e:
67
+ raise VitrinError('Payload não é JSON válido.') from e
68
+
69
+ return WebhookEvent(
70
+ type=event_type or '',
71
+ id=event_id or '',
72
+ timestamp_ms=int(timestamp) * 1000 if timestamp else int(time.time() * 1000),
73
+ data=data,
74
+ )
@@ -0,0 +1,136 @@
1
+ """Testes do client HTTP (mocked com responses)."""
2
+
3
+ import pytest
4
+ import responses
5
+
6
+ from vitrin import (
7
+ Vitrin,
8
+ VitrinAuthError,
9
+ VitrinNotFoundError,
10
+ VitrinRateLimitError,
11
+ VitrinServerError,
12
+ VitrinValidationError,
13
+ )
14
+
15
+ BASE = 'https://api.vitrin.digital/api/v1'
16
+
17
+
18
+ def test_rejects_construction_without_api_key():
19
+ with pytest.raises(ValueError):
20
+ Vitrin(api_key='')
21
+
22
+
23
+ @responses.activate
24
+ def test_request_sends_authorization_header():
25
+ responses.add(responses.GET, f'{BASE}/whatever/', json={'ok': True}, status=200)
26
+ Vitrin(api_key='vd_test_xyz').request('/whatever/')
27
+ call = responses.calls[0].request
28
+ assert call.headers['Authorization'] == 'Bearer vd_test_xyz'
29
+ assert 'vitrin-python/' in call.headers['User-Agent']
30
+
31
+
32
+ @responses.activate
33
+ def test_query_params_drop_none():
34
+ responses.add(responses.GET, f'{BASE}/list', json=[], status=200)
35
+ Vitrin(api_key='k').request('/list', query={'page': 2, 'unset': None, 'empty': ''})
36
+ url = responses.calls[0].request.url
37
+ assert 'page=2' in url
38
+ assert 'unset' not in url
39
+ assert 'empty' not in url
40
+
41
+
42
+ @responses.activate
43
+ def test_204_returns_none():
44
+ responses.add(responses.DELETE, f'{BASE}/x/', status=204)
45
+ assert Vitrin(api_key='k').request('/x/', method='DELETE') is None
46
+
47
+
48
+ @responses.activate
49
+ def test_401_maps_to_auth_error():
50
+ responses.add(responses.GET, f'{BASE}/x/', json={'error': 'invalid'}, status=401)
51
+ with pytest.raises(VitrinAuthError):
52
+ Vitrin(api_key='k', max_retries=0).request('/x/')
53
+
54
+
55
+ @responses.activate
56
+ def test_404_maps_to_not_found():
57
+ responses.add(responses.GET, f'{BASE}/x/', json={'detail': 'not found'}, status=404)
58
+ with pytest.raises(VitrinNotFoundError):
59
+ Vitrin(api_key='k', max_retries=0).request('/x/')
60
+
61
+
62
+ @responses.activate
63
+ def test_422_maps_to_validation_with_field_errors():
64
+ responses.add(
65
+ responses.POST, f'{BASE}/x/',
66
+ json={'amount': ['positivo'], 'cpf_cnpj': ['inválido']},
67
+ status=422,
68
+ )
69
+ try:
70
+ Vitrin(api_key='k', max_retries=0).request('/x/', method='POST', body={})
71
+ except VitrinValidationError as e:
72
+ assert e.field_errors['amount'] == ['positivo']
73
+ assert e.field_errors['cpf_cnpj'] == ['inválido']
74
+ else:
75
+ pytest.fail('should have raised')
76
+
77
+
78
+ @responses.activate
79
+ def test_429_retries_then_succeeds():
80
+ responses.add(responses.GET, f'{BASE}/x/',
81
+ json={'error': 'rate'}, status=429,
82
+ headers={'retry-after': '0'})
83
+ responses.add(responses.GET, f'{BASE}/x/', json={'ok': True}, status=200)
84
+ result = Vitrin(api_key='k', max_retries=3).request('/x/')
85
+ assert result == {'ok': True}
86
+ assert len(responses.calls) == 2
87
+
88
+
89
+ @responses.activate
90
+ def test_503_retries_and_gives_up():
91
+ for _ in range(3):
92
+ responses.add(responses.GET, f'{BASE}/x/', json={'error': 'down'}, status=503)
93
+ with pytest.raises(VitrinServerError):
94
+ Vitrin(api_key='k', max_retries=2).request('/x/')
95
+ assert len(responses.calls) == 3 # 1 inicial + 2 retries
96
+
97
+
98
+ @responses.activate
99
+ def test_400_does_not_retry():
100
+ responses.add(responses.POST, f'{BASE}/x/', json={'error': 'bad'}, status=400)
101
+ with pytest.raises(VitrinValidationError):
102
+ Vitrin(api_key='k', max_retries=5).request('/x/', method='POST', body={})
103
+ assert len(responses.calls) == 1
104
+
105
+
106
+ @responses.activate
107
+ def test_idempotency_key_forwarded():
108
+ responses.add(responses.POST, f'{BASE}/charges/',
109
+ json={'id': 'tx_1'}, status=201)
110
+ Vitrin(api_key='k').charges.create(
111
+ customer_id='cus_1', amount=100, billing_type='PIX',
112
+ idempotency_key='order-42',
113
+ )
114
+ assert responses.calls[0].request.headers['Idempotency-Key'] == 'order-42'
115
+
116
+
117
+ @responses.activate
118
+ def test_charges_create_posts_body():
119
+ responses.add(responses.POST, f'{BASE}/charges/',
120
+ json={'id': 'tx_1'}, status=201)
121
+ Vitrin(api_key='k').charges.create(
122
+ customer_id='cus_1', amount=99.90, billing_type='PIX', description='x',
123
+ )
124
+ body = responses.calls[0].request.body
125
+ if isinstance(body, bytes):
126
+ body = body.decode('utf-8')
127
+ assert '"customer_id": "cus_1"' in body
128
+ assert '"billing_type": "PIX"' in body
129
+
130
+
131
+ @responses.activate
132
+ def test_customers_namespaces_exposed():
133
+ responses.add(responses.GET, f'{BASE}/customers/cus_x/',
134
+ json={'id': 'cus_x', 'name': 'Maria'}, status=200)
135
+ v = Vitrin(api_key='k')
136
+ assert v.customers.retrieve('cus_x')['name'] == 'Maria'
@@ -0,0 +1,82 @@
1
+ """Testes do helper de webhooks."""
2
+
3
+ import hashlib
4
+ import hmac
5
+ import time
6
+
7
+ import pytest
8
+
9
+ from vitrin import VitrinError, webhooks
10
+
11
+ SECRET = 'whsec_test'
12
+
13
+
14
+ def _sign(payload: bytes) -> str:
15
+ return hmac.new(SECRET.encode(), payload, hashlib.sha256).hexdigest()
16
+
17
+
18
+ def test_constructs_event_with_valid_signature():
19
+ payload = b'{"IdTransaction":"tx_1","amount":100}'
20
+ evt = webhooks.construct_event(
21
+ payload=payload,
22
+ signature=_sign(payload),
23
+ secret=SECRET,
24
+ timestamp=str(int(time.time())),
25
+ event_type='Autorizado',
26
+ event_id='evt_1',
27
+ )
28
+ assert evt.type == 'Autorizado'
29
+ assert evt.id == 'evt_1'
30
+ assert evt.data['IdTransaction'] == 'tx_1'
31
+
32
+
33
+ def test_rejects_invalid_signature():
34
+ with pytest.raises(VitrinError, match='Assinatura'):
35
+ webhooks.construct_event(
36
+ payload=b'{"x":1}',
37
+ signature='a' * 64,
38
+ secret=SECRET,
39
+ )
40
+
41
+
42
+ def test_rejects_missing_signature():
43
+ with pytest.raises(VitrinError, match='Signature'):
44
+ webhooks.construct_event(payload=b'{}', signature=None, secret=SECRET)
45
+
46
+
47
+ def test_rejects_old_timestamp():
48
+ payload = b'{}'
49
+ old = str(int(time.time()) - 600)
50
+ with pytest.raises(VitrinError, match='tolerância'):
51
+ webhooks.construct_event(
52
+ payload=payload, signature=_sign(payload),
53
+ secret=SECRET, timestamp=old,
54
+ )
55
+
56
+
57
+ def test_accepts_old_timestamp_when_tolerance_zero():
58
+ payload = b'{}'
59
+ old = str(int(time.time()) - 600)
60
+ evt = webhooks.construct_event(
61
+ payload=payload, signature=_sign(payload),
62
+ secret=SECRET, timestamp=old, tolerance_ms=0,
63
+ )
64
+ assert evt is not None
65
+
66
+
67
+ def test_accepts_string_payload():
68
+ payload = '{"x":1}'
69
+ evt = webhooks.construct_event(
70
+ payload=payload,
71
+ signature=_sign(payload.encode()),
72
+ secret=SECRET,
73
+ )
74
+ assert evt.data == {'x': 1}
75
+
76
+
77
+ def test_rejects_non_json_payload():
78
+ payload = b'not json'
79
+ with pytest.raises(VitrinError, match='JSON'):
80
+ webhooks.construct_event(
81
+ payload=payload, signature=_sign(payload), secret=SECRET,
82
+ )