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.
- vitrin-0.1.0/.gitignore +11 -0
- vitrin-0.1.0/PKG-INFO +184 -0
- vitrin-0.1.0/README.md +156 -0
- vitrin-0.1.0/pyproject.toml +54 -0
- vitrin-0.1.0/src/vitrin/__init__.py +83 -0
- vitrin-0.1.0/src/vitrin/_client.py +191 -0
- vitrin-0.1.0/src/vitrin/errors.py +64 -0
- vitrin-0.1.0/src/vitrin/resources/__init__.py +8 -0
- vitrin-0.1.0/src/vitrin/resources/balance.py +14 -0
- vitrin-0.1.0/src/vitrin/resources/charges.py +65 -0
- vitrin-0.1.0/src/vitrin/resources/customers.py +25 -0
- vitrin-0.1.0/src/vitrin/resources/plans.py +23 -0
- vitrin-0.1.0/src/vitrin/resources/products.py +25 -0
- vitrin-0.1.0/src/vitrin/resources/subscriptions.py +22 -0
- vitrin-0.1.0/src/vitrin/webhooks.py +74 -0
- vitrin-0.1.0/tests/test_client.py +136 -0
- vitrin-0.1.0/tests/test_webhooks.py +82 -0
vitrin-0.1.0/.gitignore
ADDED
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
|
+
)
|