vitrin 0.2.0__tar.gz → 0.2.2__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,44 @@
1
+ # Changelog — vitrin
2
+
3
+ ## 0.2.2 — 2026-08-26
4
+
5
+ ### Corrigido
6
+ - **Retry em timeout deixa de duplicar cobrança.** O client retentava em
7
+ timeout e erro de rede em qualquer método, apesar de a documentação
8
+ prometer "429 e 5xx". Timeout é justamente o caso em que o servidor
9
+ recebeu e processou o pedido — só a resposta se perdeu —, então cada
10
+ retry de um `POST /charges/` criava uma cobrança nova. Sem
11
+ `idempotency_key`, um clique podia virar até 4 débitos.
12
+
13
+ Agora só retentamos quando é seguro: métodos idempotentes por HTTP
14
+ (GET/HEAD/PUT/DELETE) sempre, e POST/PATCH apenas com `idempotency_key`
15
+ — aí o servidor reconhece a repetição e devolve a mesma resposta.
16
+
17
+ **Passe `idempotency_key` nas chamadas que criam cobrança.** Sem ela,
18
+ uma falha de rede agora vira erro em vez de cobrança duplicada, que é o
19
+ comportamento correto mas significa que o retry passa a ser seu.
20
+
21
+ ## 0.2.1 — 2026-06-29
22
+
23
+ ### Documentação
24
+ - README: seção "Organizações LLC (US)" — USD, cartão + PIX cross-border,
25
+ liquidação `auto_payout`, onboarding/KYC. O SDK é passthrough (retorna
26
+ `dict`), então os campos LLC (`currency`, `client_secret`,
27
+ `presentment_*`) já vêm nas respostas sem mudança de código.
28
+
29
+ ## 0.2.0 — 2026-05-16
30
+
31
+ ### Adicionado
32
+ - `customers.save_card()` / `saved_card()` / `delete_saved_card()` —
33
+ tokenização + persistência de cartão preferido (one-click buy).
34
+ Requer `consent` com `accepted_at` + `ip` (LGPD).
35
+ - `customers.saved_card_management_link()` — gera URL com JWT 24h pro
36
+ cliente final ver/remover o próprio cartão.
37
+ - `charges.create_with_saved_card()` — cobrança one-click server-side
38
+ (sem CVV) usando o token salvo.
39
+ - `customers.one_click_link()` — gera link assinado de recompra (JWT
40
+ 15min, uso único) pra o cliente final confirmar a compra com 1 clique
41
+ numa página hospedada pela Vitrin. Aceita `coupon_code` e `bump_ids`
42
+ (só produto).
43
+
44
+ ## 0.1.0 — Release inicial
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vitrin
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: SDK oficial Python para a API da Vitrin Digital
5
5
  Project-URL: Homepage, https://vitrin.digital
6
6
  Project-URL: Documentation, https://vitrin.digital/docs/integracao
@@ -53,6 +53,17 @@ vitrin = Vitrin(
53
53
 
54
54
  Use `vd_test_*` em desenvolvimento, `vd_live_*` em produção.
55
55
 
56
+ ## Organizações LLC (US)
57
+
58
+ Se a org é uma **US LLC** (`entity_type == "llc"`), os valores são em **USD**
59
+ (campo `currency` nas transações); os métodos default são **cartão** e **Pix
60
+ cross-border** (o cliente paga em BRL, a LLC recebe em USD). A liquidação é
61
+ **automática**: os endpoints de saldo/recebíveis/transferências retornam
62
+ `{ "auto_payout": true }` em vez de saldo retido (não há saque/antecipação
63
+ manual; o cronograma "PIX D+1 / Boleto D+2 / Cartão Nx D+30" abaixo é só para
64
+ CNPJ). O onboarding inclui verificação de identidade (KYC). Veja
65
+ `docs/api/onboarding-llc.md` e `docs/api/differences-cnpj-llc.md`.
66
+
56
67
  ## Recursos
57
68
 
58
69
  ### Clientes
@@ -164,6 +175,10 @@ Retry automático em **429** e **5xx** com backoff exponencial (default: 3 tenta
164
175
 
165
176
  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
177
 
178
+ Vale em `charges.create()` e `charges.create_with_saved_card()`. A chave é escopada por organização (a sua nunca colide com a de outro lojista) e aceita até 128 caracteres.
179
+
180
+ Use um identificador **seu** e estável — número do pedido, id do carrinho. A proteção vem de a chave se repetir no retry; um UUID novo a cada tentativa não protege de nada.
181
+
167
182
  ```python
168
183
  vitrin.charges.create(
169
184
  customer_id="cus_1",
@@ -25,6 +25,17 @@ vitrin = Vitrin(
25
25
 
26
26
  Use `vd_test_*` em desenvolvimento, `vd_live_*` em produção.
27
27
 
28
+ ## Organizações LLC (US)
29
+
30
+ Se a org é uma **US LLC** (`entity_type == "llc"`), os valores são em **USD**
31
+ (campo `currency` nas transações); os métodos default são **cartão** e **Pix
32
+ cross-border** (o cliente paga em BRL, a LLC recebe em USD). A liquidação é
33
+ **automática**: os endpoints de saldo/recebíveis/transferências retornam
34
+ `{ "auto_payout": true }` em vez de saldo retido (não há saque/antecipação
35
+ manual; o cronograma "PIX D+1 / Boleto D+2 / Cartão Nx D+30" abaixo é só para
36
+ CNPJ). O onboarding inclui verificação de identidade (KYC). Veja
37
+ `docs/api/onboarding-llc.md` e `docs/api/differences-cnpj-llc.md`.
38
+
28
39
  ## Recursos
29
40
 
30
41
  ### Clientes
@@ -136,6 +147,10 @@ Retry automático em **429** e **5xx** com backoff exponencial (default: 3 tenta
136
147
 
137
148
  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
149
 
150
+ Vale em `charges.create()` e `charges.create_with_saved_card()`. A chave é escopada por organização (a sua nunca colide com a de outro lojista) e aceita até 128 caracteres.
151
+
152
+ Use um identificador **seu** e estável — número do pedido, id do carrinho. A proteção vem de a chave se repetir no retry; um UUID novo a cada tentativa não protege de nada.
153
+
139
154
  ```python
140
155
  vitrin.charges.create(
141
156
  customer_id="cus_1",
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "vitrin"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "SDK oficial Python para a API da Vitrin Digital"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -4,6 +4,10 @@ Wrapper sobre `requests.Session` com:
4
4
  - Headers padrão (Authorization, User-Agent, Content-Type)
5
5
  - Mapeamento de status HTTP → :class:`vitrin.errors.VitrinError`
6
6
  - Retry automático em 429/5xx com backoff exponencial e jitter
7
+ - Retry em timeout/erro de rede SÓ quando é seguro: método idempotente
8
+ (GET/HEAD/PUT/DELETE) ou POST/PATCH com ``idempotency_key``. Sem isso o
9
+ retry de um POST cria uma segunda cobrança — o timeout não diz que o
10
+ servidor deixou de processar, só que a resposta se perdeu.
7
11
  - Idempotency-Key opcional pra POSTs
8
12
  """
9
13
 
@@ -77,13 +81,13 @@ class Client:
77
81
  )
78
82
  except requests.exceptions.Timeout as e:
79
83
  last_exc = VitrinNetworkError(f'Timeout após {self.timeout}s')
80
- if attempt < self.max_retries:
84
+ if attempt < self.max_retries and _pode_retentar(method, idempotency_key):
81
85
  time.sleep(_backoff(attempt))
82
86
  continue
83
87
  raise last_exc from e
84
88
  except requests.exceptions.RequestException as e:
85
89
  last_exc = VitrinNetworkError(str(e))
86
- if attempt < self.max_retries:
90
+ if attempt < self.max_retries and _pode_retentar(method, idempotency_key):
87
91
  time.sleep(_backoff(attempt))
88
92
  continue
89
93
  raise last_exc from e
@@ -186,6 +190,23 @@ def _parse_retry_after(value: str | None) -> float | None:
186
190
  return None
187
191
 
188
192
 
193
+
194
+ _METODOS_IDEMPOTENTES = frozenset({'GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS'})
195
+
196
+
197
+ def _pode_retentar(method: str, idempotency_key: str | None) -> bool:
198
+ """Se é seguro repetir a chamada depois de timeout ou falha de rede.
199
+
200
+ Diferente de um 5xx, que garante que o servidor NÃO concluiu, um timeout
201
+ não diz nada: o pedido pode ter sido processado e só a resposta ter se
202
+ perdido. Repetir um POST nesse estado duplica o efeito — em cobrança,
203
+ duplica o débito. Com `idempotency_key` o servidor reconhece a repetição e
204
+ devolve a mesma resposta, então aí é seguro.
205
+ """
206
+ if method.upper() in _METODOS_IDEMPOTENTES:
207
+ return True
208
+ return bool(idempotency_key)
209
+
189
210
  def _backoff(attempt: int) -> float:
190
211
  base = 0.25 * (2 ** attempt)
191
212
  return base + random.uniform(0, base * 0.5)
@@ -1,11 +1,13 @@
1
1
  """Testes do client HTTP (mocked com responses)."""
2
2
 
3
3
  import pytest
4
+ import requests
4
5
  import responses
5
6
 
6
7
  from vitrin import (
7
8
  Vitrin,
8
9
  VitrinAuthError,
10
+ VitrinNetworkError,
9
11
  VitrinNotFoundError,
10
12
  VitrinRateLimitError,
11
13
  VitrinServerError,
@@ -134,3 +136,62 @@ def test_customers_namespaces_exposed():
134
136
  json={'id': 'cus_x', 'name': 'Maria'}, status=200)
135
137
  v = Vitrin(api_key='k')
136
138
  assert v.customers.retrieve('cus_x')['name'] == 'Maria'
139
+
140
+
141
+ # ── Retry em timeout / erro de rede ───────────────────────────────────
142
+ #
143
+ # Timeout é o caso em que o servidor MUITO PROVAVELMENTE recebeu e processou o
144
+ # pedido — o que não chegou foi a resposta. Retentar um POST assim cria uma
145
+ # segunda cobrança. Medido na produção da Vitrin em 26/08/2026: o POST
146
+ # /charges/ tem p50 de 2,3s e cauda até 16,9s, e integrador com timeout curto
147
+ # gerava cobrança duplicada exatamente por esse caminho.
148
+ #
149
+ # Regra: GET/HEAD/PUT/DELETE são idempotentes por HTTP e podem ser retentados.
150
+ # POST e PATCH só com `idempotency_key`, que faz o servidor devolver a mesma
151
+ # resposta em vez de agir duas vezes.
152
+
153
+
154
+ @responses.activate
155
+ def test_post_sem_chave_nao_retenta_em_timeout():
156
+ responses.add(
157
+ responses.POST, f'{BASE}/charges/',
158
+ body=requests.exceptions.Timeout('estourou'),
159
+ )
160
+ with pytest.raises(VitrinNetworkError):
161
+ Vitrin(api_key='k', max_retries=3).request('/charges/', method='POST', body={})
162
+ assert len(responses.calls) == 1
163
+
164
+
165
+ @responses.activate
166
+ def test_post_com_chave_retenta_em_timeout():
167
+ responses.add(
168
+ responses.POST, f'{BASE}/charges/',
169
+ body=requests.exceptions.Timeout('estourou'),
170
+ )
171
+ with pytest.raises(VitrinNetworkError):
172
+ Vitrin(api_key='k', max_retries=2).request(
173
+ '/charges/', method='POST', body={}, idempotency_key='pedido-1',
174
+ )
175
+ assert len(responses.calls) == 3 # 1 inicial + 2 retries
176
+
177
+
178
+ @responses.activate
179
+ def test_get_retenta_em_timeout():
180
+ responses.add(
181
+ responses.GET, f'{BASE}/transactions/',
182
+ body=requests.exceptions.Timeout('estourou'),
183
+ )
184
+ with pytest.raises(VitrinNetworkError):
185
+ Vitrin(api_key='k', max_retries=2).request('/transactions/')
186
+ assert len(responses.calls) == 3
187
+
188
+
189
+ @responses.activate
190
+ def test_post_sem_chave_nao_retenta_em_erro_de_rede():
191
+ responses.add(
192
+ responses.POST, f'{BASE}/charges/',
193
+ body=requests.exceptions.ConnectionError('conexão caiu'),
194
+ )
195
+ with pytest.raises(VitrinNetworkError):
196
+ Vitrin(api_key='k', max_retries=3).request('/charges/', method='POST', body={})
197
+ assert len(responses.calls) == 1
vitrin-0.2.0/CHANGELOG.md DELETED
@@ -1,18 +0,0 @@
1
- # Changelog — vitrin
2
-
3
- ## 0.2.0 — 2026-05-16
4
-
5
- ### Adicionado
6
- - `customers.save_card()` / `saved_card()` / `delete_saved_card()` —
7
- tokenização + persistência de cartão preferido (one-click buy).
8
- Requer `consent` com `accepted_at` + `ip` (LGPD).
9
- - `customers.saved_card_management_link()` — gera URL com JWT 24h pro
10
- cliente final ver/remover o próprio cartão.
11
- - `charges.create_with_saved_card()` — cobrança one-click server-side
12
- (sem CVV) usando o token salvo.
13
- - `customers.one_click_link()` — gera link assinado de recompra (JWT
14
- 15min, uso único) pra o cliente final confirmar a compra com 1 clique
15
- numa página hospedada pela Vitrin. Aceita `coupon_code` e `bump_ids`
16
- (só produto).
17
-
18
- ## 0.1.0 — Release inicial
File without changes
File without changes
File without changes
File without changes
File without changes