vitrin 0.1.0__py3-none-any.whl

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.
vitrin/__init__.py ADDED
@@ -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)
vitrin/_client.py ADDED
@@ -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)
vitrin/errors.py ADDED
@@ -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
+ )
vitrin/webhooks.py ADDED
@@ -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,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
@@ -0,0 +1,14 @@
1
+ vitrin/__init__.py,sha256=0AUat6eX5M_LLPjMTw6qNUiH1Zia_wFKcFO996FLsv8,2216
2
+ vitrin/_client.py,sha256=rzJUmPJFN1-44sqH2ggVVGTxeUBq63QOyQqG1YOtn2o,6120
3
+ vitrin/errors.py,sha256=RJdDJKyn_jtSxKmmyH-1Z_lwoNvasl-MG1LDgfLAn10,1740
4
+ vitrin/webhooks.py,sha256=0weud42f4O50Zvz4RXgnhzFzAmj0OaUQvaLaJxE9zrI,2287
5
+ vitrin/resources/__init__.py,sha256=Q2jNfQkBbZvFDmNJ0Z2lk_yMfkSRs0KdG_erVkBDFCk,279
6
+ vitrin/resources/balance.py,sha256=F8g74BDO7v6Uj0STh2PtgpJDb0NKJ36jPv8PGgCBsdo,436
7
+ vitrin/resources/charges.py,sha256=ftN4RDzYucaum4Cvi73XL_h6FE11-o9dp3WxxaI5omI,2039
8
+ vitrin/resources/customers.py,sha256=tUr51aYgBWvWYzf3YNuWgahDTAaIX-wBMW642eHpkpg,882
9
+ vitrin/resources/plans.py,sha256=ZhTl2ff9f7oUeGySCXMayBvT7niwDEV-aUvrNRTSOno,811
10
+ vitrin/resources/products.py,sha256=2RenNKxdDUJsjSDYIZpdXat1iWk5OQnUylklVNX759g,870
11
+ vitrin/resources/subscriptions.py,sha256=VOvBzl-Rm2Hm7Xm-K7SgOpoM_e67D1JI266253nuJ24,767
12
+ vitrin-0.1.0.dist-info/METADATA,sha256=B4t2XpJkXw52zPh_7hc3kn9tcb871cLfN212kgG3pNk,4862
13
+ vitrin-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
14
+ vitrin-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any