fluvpay 1.0.0
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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +268 -0
- data/lib/fluvpay/client.rb +260 -0
- data/lib/fluvpay/errors.rb +124 -0
- data/lib/fluvpay/resources/charges.rb +89 -0
- data/lib/fluvpay/resources/internal_transfers.rb +66 -0
- data/lib/fluvpay/resources/list_objects.rb +65 -0
- data/lib/fluvpay/resources/sandbox.rb +26 -0
- data/lib/fluvpay/resources/transactions.rb +44 -0
- data/lib/fluvpay/resources/withdrawals.rb +64 -0
- data/lib/fluvpay/version.rb +6 -0
- data/lib/fluvpay/webhooks.rb +170 -0
- data/lib/fluvpay.rb +21 -0
- metadata +104 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 336ce87416d2b68762b6c5179c4649d20544be466896a07054a41ecabf90622c
|
|
4
|
+
data.tar.gz: f5d12643b1c4f94428c162b2faf30fd440a1d84c001bcc0a896953158258400c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 487229bdf46c7981f5f445fc8acb37b5c0704f479b65f4f33ef45cacaded33ba8117a0b95a342d06693fe7b3df8137c3b37524ce2f57c869ac75a4c6e8811ba6
|
|
7
|
+
data.tar.gz: a15480e1fd9c33bb090844e89fb9aa0901349a8043043c5414bba49e8413ddb5da0bc523515689aa6c273740518c410668ed0f4109c49ba1693eec559fa976e8
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FluvPay
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# FluvPay Ruby
|
|
2
|
+
|
|
3
|
+
SDK oficial da FluvPay para Ruby. Cobre cobranças PIX, saques, transferências internas e verificação de webhooks, com erros tipados e tratamento idiomático. A interface é estável e previsível, adequada tanto a integrações operadas por pessoas quanto a agentes que consomem a API de forma programática.
|
|
4
|
+
|
|
5
|
+
- Requer Ruby 3.0 ou superior.
|
|
6
|
+
- O cliente HTTP é construído sobre a biblioteca padrão (`net/http`). Não há dependências de runtime.
|
|
7
|
+
- Inclui retentativas automáticas em operações seguras, geração automática de `Idempotency-Key` e erros tipados por classe.
|
|
8
|
+
|
|
9
|
+
## Instalação
|
|
10
|
+
|
|
11
|
+
A publicação no RubyGems está pendente. Por enquanto, instale a partir do repositório, fixando a tag para builds reproduzíveis. No `Gemfile`:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem "fluvpay", git: "https://github.com/fluvpay/fluvpay-ruby", tag: "v1.0.0"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
E execute:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
bundle install
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Substituir `tag:` por `branch: "main"` acompanha o desenvolvimento em curso, com a ressalva de que a `main` pode mudar a qualquer momento.
|
|
24
|
+
|
|
25
|
+
### RubyGems (em breve)
|
|
26
|
+
|
|
27
|
+
Quando a gem for publicada no RubyGems, a instalação passará a ser `gem install fluvpay` (ou `gem "fluvpay"` no `Gemfile`). Até lá, esses comandos não resolvem.
|
|
28
|
+
|
|
29
|
+
Para construir a gem a partir do código-fonte sem um `Gemfile`:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
git clone --branch v1.0.0 https://github.com/fluvpay/fluvpay-ruby.git
|
|
33
|
+
cd fluvpay-ruby
|
|
34
|
+
gem build fluvpay.gemspec
|
|
35
|
+
gem install ./fluvpay-1.0.0.gem
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Início rápido
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
require "fluvpay"
|
|
42
|
+
|
|
43
|
+
client = FluvPay::Client.new(api_key: "fluv_test_sua_chave_de_teste")
|
|
44
|
+
|
|
45
|
+
charge = client.charges.create(
|
|
46
|
+
amount_cents: 5000,
|
|
47
|
+
description: "Pedido 123",
|
|
48
|
+
customer: { name: "Maria", email: "maria@exemplo.com" },
|
|
49
|
+
metadata: { pedido_id: "123" }
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
puts charge["id"]
|
|
53
|
+
puts charge["pix_copy_paste"]
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Autenticação
|
|
57
|
+
|
|
58
|
+
A autenticação usa a API Key informada no construtor do cliente. O ambiente é determinado pelo prefixo da chave: `fluv_live_` seleciona produção e `fluv_test_` seleciona o sandbox.
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
require "fluvpay"
|
|
62
|
+
|
|
63
|
+
client = FluvPay::Client.new(api_key: "fluv_live_sua_chave_aqui")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
A base URL padrão é `https://api.fluvpay.com/api/v1`. Para sobrescrevê-la, informe `base_url:` no construtor.
|
|
67
|
+
|
|
68
|
+
## Exemplo completo
|
|
69
|
+
|
|
70
|
+
O exemplo a seguir cria uma cobrança, recupera o registro, lista com paginação e verifica a assinatura de um webhook recebido.
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
require "fluvpay"
|
|
74
|
+
|
|
75
|
+
client = FluvPay::Client.new(api_key: "fluv_test_sua_chave_de_teste")
|
|
76
|
+
|
|
77
|
+
# Criar uma cobrança PIX. O valor é informado em centavos.
|
|
78
|
+
# A Idempotency-Key é gerada automaticamente quando não fornecida.
|
|
79
|
+
begin
|
|
80
|
+
charge = client.charges.create(
|
|
81
|
+
amount_cents: 5000,
|
|
82
|
+
description: "Pedido 123",
|
|
83
|
+
customer: { name: "Maria", email: "maria@exemplo.com" },
|
|
84
|
+
metadata: { pedido_id: "123" }
|
|
85
|
+
)
|
|
86
|
+
rescue FluvPay::ValidationError => err
|
|
87
|
+
puts "Dados inválidos: #{err.code} #{err.message}"
|
|
88
|
+
err.details.each { |d| puts " - #{d['field']} #{d['message']}" }
|
|
89
|
+
raise
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
puts "Cobrança criada: #{charge['id']} #{charge['status']}"
|
|
93
|
+
puts "Copia e cola PIX: #{charge['pix_copy_paste']}"
|
|
94
|
+
|
|
95
|
+
# Recuperar pela ID.
|
|
96
|
+
mesma = client.charges.retrieve(charge["id"])
|
|
97
|
+
puts "Status atual: #{mesma['status']}"
|
|
98
|
+
|
|
99
|
+
# Listar cobranças com paginação page/per_page.
|
|
100
|
+
pagina = client.charges.list(page: 1, per_page: 20, status: "paid")
|
|
101
|
+
puts "Página #{pagina.page} de #{pagina.total} cobranças, há mais? #{pagina.has_next?}"
|
|
102
|
+
pagina.each { |item| puts " - #{item['id']} #{item['amount_cents']} #{item['status']}" }
|
|
103
|
+
|
|
104
|
+
# Verificar a assinatura de um webhook recebido.
|
|
105
|
+
# A verificação usa o corpo cru da requisição, nunca o JSON re-serializado.
|
|
106
|
+
def handle_webhook(raw_body, headers)
|
|
107
|
+
event = FluvPay::Webhooks.verify_signature(
|
|
108
|
+
raw_body,
|
|
109
|
+
headers["X-FluvPay-Signature"],
|
|
110
|
+
headers["X-FluvPay-Timestamp"],
|
|
111
|
+
"whsec_seu_segredo_do_webhook",
|
|
112
|
+
event_type: headers["X-FluvPay-Event"],
|
|
113
|
+
delivery_id: headers["X-FluvPay-Delivery-Id"],
|
|
114
|
+
tolerance_seconds: 300
|
|
115
|
+
)
|
|
116
|
+
puts "Cobrança paga: #{event.data['id']}" if event.type == "charge.paid"
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Referência de recursos
|
|
121
|
+
|
|
122
|
+
### Charges (cobranças PIX)
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
client.charges.create(amount_cents:, idempotency_key: nil, **campos) # POST /charges/
|
|
126
|
+
client.charges.retrieve(charge_id) # GET /charges/{id}
|
|
127
|
+
client.charges.list(page:, per_page:, sort:, status:) # GET /charges/
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Transactions (extrato)
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
client.transactions.list(page:, per_page:, sort:) # GET /transactions/
|
|
134
|
+
client.transactions.retrieve(tx_id) # GET /transactions/{id}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Withdrawals (saques PIX, somente produção)
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
client.withdrawals.create(amount_cents:, pix_key:, pix_key_type:, idempotency_key: nil) # POST /withdrawals/
|
|
141
|
+
client.withdrawals.list(limit:, offset:, status:) # GET /withdrawals/
|
|
142
|
+
client.withdrawals.retrieve(withdrawal_id) # GET /withdrawals/{id}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Internal Transfers (transferências FluvPay para FluvPay, somente produção)
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
client.internal_transfers.create(amount_cents:, recipient_email:, idempotency_key: nil) # POST /internal-transfers/
|
|
149
|
+
client.internal_transfers.list(direction:, limit:, offset:) # GET /internal-transfers/
|
|
150
|
+
client.internal_transfers.retrieve(transfer_id) # GET /internal-transfers/{id}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Sandbox (somente com chave `fluv_test_`)
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
client.sandbox.reset # POST /test/reset
|
|
157
|
+
client.sandbox.scenarios # GET /test/scenarios
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Campos de `charges.create`
|
|
161
|
+
|
|
162
|
+
O método aceita exatamente os campos do contrato. Os campos `currency` e `method` não são aceitos: a API responde 422 quando enviados.
|
|
163
|
+
|
|
164
|
+
| Campo | Tipo | Observação |
|
|
165
|
+
|---|---|---|
|
|
166
|
+
| `amount_cents` | Integer, obrigatório | 100 a 100000 (R$ 1,00 a R$ 1.000,00) |
|
|
167
|
+
| `description` | String | até 500 caracteres |
|
|
168
|
+
| `customer` | Hash | `{ name:, email:, document:, phone: }` |
|
|
169
|
+
| `expires_in_seconds` | Integer | 60 a 604800 |
|
|
170
|
+
| `affiliate_code` | String | 4 a 24 caracteres |
|
|
171
|
+
| `split_rule_id` | String | 20 a 32 caracteres |
|
|
172
|
+
| `pass_fee_to_payer` | Boolean | padrão `true` |
|
|
173
|
+
| `metadata` | Hash | objeto livre |
|
|
174
|
+
|
|
175
|
+
Os status possíveis de uma cobrança são `pending`, `paid`, `expired`, `cancelled` e `refunded`.
|
|
176
|
+
|
|
177
|
+
## Paginação
|
|
178
|
+
|
|
179
|
+
A API expõe dois formatos de envelope, ambos apresentados como objetos de página iteráveis:
|
|
180
|
+
|
|
181
|
+
- `charges.list` e `transactions.list` expõem `page`, `per_page`, `total`, `has_next?` e `has_prev?`.
|
|
182
|
+
- `withdrawals.list` e `internal_transfers.list` expõem `limit`, `offset` e `total`.
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
page = client.withdrawals.list(limit: 10, offset: 0)
|
|
186
|
+
puts [page.limit, page.offset, page.total].inspect
|
|
187
|
+
page.each { |w| puts "#{w['id']} #{w['status']} #{w['net_cents']}" }
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Idempotência
|
|
191
|
+
|
|
192
|
+
Os POSTs de escrita (`charges.create`, `withdrawals.create` e `internal_transfers.create`) enviam o header `Idempotency-Key`. Quando a chave não é informada, o SDK gera um UUIDv4. Reenviar a mesma chave devolve a resposta original. Reutilizar a chave com um payload diferente resulta em `FluvPay::ConflictError` com código `IDEMPOTENCY_CONFLICT`.
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
chave = FluvPay::Client.new_idempotency_key
|
|
196
|
+
client.charges.create(amount_cents: 5000, idempotency_key: chave)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Webhooks
|
|
200
|
+
|
|
201
|
+
A FluvPay assina cada entrega. O header `X-FluvPay-Signature` contém `v1=<hex>`, calculado da seguinte forma:
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
hex = HMAC_SHA256(secret, "{timestamp}." + corpo_cru)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
O `secret` é o valor `whsec_...` exibido na criação do webhook, o `timestamp` vem do header `X-FluvPay-Timestamp` e `corpo_cru` é o corpo da requisição exatamente como recebido. A verificação exige o corpo cru, nunca o JSON re-serializado.
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
begin
|
|
211
|
+
event = FluvPay::Webhooks.verify_signature(
|
|
212
|
+
raw_body,
|
|
213
|
+
request.headers["X-FluvPay-Signature"],
|
|
214
|
+
request.headers["X-FluvPay-Timestamp"],
|
|
215
|
+
"whsec_...",
|
|
216
|
+
tolerance_seconds: 300
|
|
217
|
+
)
|
|
218
|
+
rescue FluvPay::SignatureVerificationError
|
|
219
|
+
halt 400, "assinatura inválida"
|
|
220
|
+
end
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Os eventos disponíveis são `charge.created`, `charge.paid`, `charge.expired`, `charge.cancelled`, `charge.refunded`, `payout.created`, `payout.completed` e `payout.failed`.
|
|
224
|
+
|
|
225
|
+
## Erros
|
|
226
|
+
|
|
227
|
+
Todos os erros herdam de `FluvPay::Error` e carregam `code`, `message`, `details`, `trace_id` e `status_code`.
|
|
228
|
+
|
|
229
|
+
| Status | Exceção |
|
|
230
|
+
|---|---|
|
|
231
|
+
| 400 / 422 | `FluvPay::ValidationError` |
|
|
232
|
+
| 401 | `FluvPay::AuthenticationError` |
|
|
233
|
+
| 403 | `FluvPay::PermissionError` |
|
|
234
|
+
| 404 | `FluvPay::NotFoundError` |
|
|
235
|
+
| 409 | `FluvPay::ConflictError` |
|
|
236
|
+
| 429 | `FluvPay::RateLimitError` (campo `retry_after`) |
|
|
237
|
+
| 5xx | `FluvPay::ServerError` |
|
|
238
|
+
| rede / timeout | `FluvPay::ConnectionError` |
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
begin
|
|
242
|
+
client.charges.list
|
|
243
|
+
rescue FluvPay::RateLimitError => err
|
|
244
|
+
puts "Rate limit. Tente novamente em #{err.retry_after} segundos."
|
|
245
|
+
end
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Retentativas
|
|
249
|
+
|
|
250
|
+
O SDK executa por padrão 2 retentativas com backoff exponencial e jitter. As retentativas ocorrem apenas em operações seguras: requisições GET e POSTs que carregam `Idempotency-Key`, e somente diante de respostas 429, 5xx ou falha de conexão. Em respostas 429, o header `Retry-After` é respeitado.
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
client = FluvPay::Client.new(api_key: "fluv_live_...", max_retries: 4) # aumentar
|
|
254
|
+
client = FluvPay::Client.new(api_key: "fluv_live_...", max_retries: 0) # desativar
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## Desenvolvimento
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
bundle install
|
|
261
|
+
rake test
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Os testes unitários rodam sem acesso à rede, com `net/http` mockado via WebMock. O smoke test no sandbox roda somente quando a variável de ambiente `FLUVPAY_TEST_KEY` (prefixo `fluv_test_`) está presente; caso contrário, é pulado.
|
|
265
|
+
|
|
266
|
+
## Licença
|
|
267
|
+
|
|
268
|
+
MIT.
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
|
|
9
|
+
require_relative "version"
|
|
10
|
+
require_relative "errors"
|
|
11
|
+
require_relative "resources/charges"
|
|
12
|
+
require_relative "resources/transactions"
|
|
13
|
+
require_relative "resources/withdrawals"
|
|
14
|
+
require_relative "resources/internal_transfers"
|
|
15
|
+
require_relative "resources/sandbox"
|
|
16
|
+
|
|
17
|
+
module FluvPay
|
|
18
|
+
# Cliente principal da FluvPay.
|
|
19
|
+
#
|
|
20
|
+
# Estilo Stripe: um objeto +FluvPay::Client+ configurado com a API key, expondo
|
|
21
|
+
# recursos (+charges+, +transactions+, +withdrawals+, +internal_transfers+ e
|
|
22
|
+
# +sandbox+). O transporte usa +net/http+ da biblioteca padrão, com retries
|
|
23
|
+
# (apenas em GET e POSTs idempotentes), geração automática de Idempotency-Key
|
|
24
|
+
# (UUIDv4) e mapeamento de erro tipado.
|
|
25
|
+
#
|
|
26
|
+
# @example
|
|
27
|
+
# client = FluvPay::Client.new(api_key: "fluv_test_sua_chave")
|
|
28
|
+
# charge = client.charges.create(amount_cents: 4990, description: "Pedido #1042")
|
|
29
|
+
# puts charge["pix_copy_paste"]
|
|
30
|
+
class Client
|
|
31
|
+
DEFAULT_BASE_URL = "https://api.fluvpay.com/api/v1"
|
|
32
|
+
DEFAULT_TIMEOUT = 30
|
|
33
|
+
DEFAULT_OPEN_TIMEOUT = 10
|
|
34
|
+
DEFAULT_MAX_RETRIES = 2
|
|
35
|
+
DEFAULT_BACKOFF_FACTOR = 0.5
|
|
36
|
+
DEFAULT_MAX_BACKOFF = 8.0
|
|
37
|
+
|
|
38
|
+
# @return [String] a API key configurada.
|
|
39
|
+
attr_reader :api_key
|
|
40
|
+
# @return [String] URL base sem barra final.
|
|
41
|
+
attr_reader :base_url
|
|
42
|
+
# @return [Integer] tentativas extras em 429/5xx/conexão.
|
|
43
|
+
attr_reader :max_retries
|
|
44
|
+
|
|
45
|
+
# Recursos expostos.
|
|
46
|
+
attr_reader :charges, :transactions, :withdrawals, :internal_transfers, :sandbox
|
|
47
|
+
|
|
48
|
+
# @param api_key [String] chave da API (+fluv_live_...+ ou +fluv_test_...+).
|
|
49
|
+
# @param base_url [String] URL base (padrão: produção e sandbox unificados).
|
|
50
|
+
# @param timeout [Numeric] timeout de leitura por requisição, em segundos.
|
|
51
|
+
# @param open_timeout [Numeric] timeout de abertura de conexão, em segundos.
|
|
52
|
+
# @param max_retries [Integer] tentativas extras (só GET e POSTs idempotentes).
|
|
53
|
+
# @param backoff_factor [Float] fator do backoff exponencial com jitter.
|
|
54
|
+
def initialize(api_key:, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
|
|
55
|
+
open_timeout: DEFAULT_OPEN_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES,
|
|
56
|
+
backoff_factor: DEFAULT_BACKOFF_FACTOR)
|
|
57
|
+
if !api_key.is_a?(String) || api_key.empty?
|
|
58
|
+
raise ArgumentError, "api_key é obrigatória e deve ser uma string não vazia."
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
@api_key = api_key
|
|
62
|
+
@base_url = base_url.to_s.sub(%r{/+\z}, "")
|
|
63
|
+
@timeout = timeout
|
|
64
|
+
@open_timeout = open_timeout
|
|
65
|
+
@max_retries = max_retries
|
|
66
|
+
@backoff_factor = backoff_factor
|
|
67
|
+
|
|
68
|
+
@charges = Resources::Charges.new(self)
|
|
69
|
+
@transactions = Resources::Transactions.new(self)
|
|
70
|
+
@withdrawals = Resources::Withdrawals.new(self)
|
|
71
|
+
@internal_transfers = Resources::InternalTransfers.new(self)
|
|
72
|
+
@sandbox = Resources::Sandbox.new(self)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Boolean] true se a chave configurada for de sandbox (+fluv_test_+).
|
|
76
|
+
def test_mode?
|
|
77
|
+
self.class.test_key?(@api_key)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Boolean] true se a chave informada tiver prefixo +fluv_test_+.
|
|
81
|
+
def self.test_key?(api_key)
|
|
82
|
+
api_key.to_s.start_with?("fluv_test_")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Gera um Idempotency-Key UUIDv4.
|
|
86
|
+
# @return [String]
|
|
87
|
+
def self.new_idempotency_key
|
|
88
|
+
SecureRandom.uuid
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Executa uma requisição e devolve o JSON já parseado (ou lança erro tipado).
|
|
92
|
+
#
|
|
93
|
+
# @param method [Symbol, String] verbo HTTP (:get, :post).
|
|
94
|
+
# @param path [String] caminho relativo à base_url (ex: "/charges/").
|
|
95
|
+
# @param params [Hash, nil] parâmetros de query (valores nil são removidos).
|
|
96
|
+
# @param body [Hash, nil] corpo JSON (valores nil são removidos no topo).
|
|
97
|
+
# @param idempotency_key [String, nil] valor do header Idempotency-Key.
|
|
98
|
+
# @param retry_request [Boolean, nil] força ou desliga o retry; nil = automático.
|
|
99
|
+
# @return [Object] JSON parseado da resposta.
|
|
100
|
+
def request(method, path, params: nil, body: nil, idempotency_key: nil, retry_request: nil)
|
|
101
|
+
upper = method.to_s.upcase
|
|
102
|
+
uri = build_uri(path, params)
|
|
103
|
+
headers = default_headers
|
|
104
|
+
headers["Idempotency-Key"] = idempotency_key unless idempotency_key.nil?
|
|
105
|
+
|
|
106
|
+
payload = nil
|
|
107
|
+
unless body.nil?
|
|
108
|
+
headers["Content-Type"] = "application/json"
|
|
109
|
+
payload = JSON.generate(clean_body(body))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
retry_request = (upper == "GET" || (upper == "POST" && !idempotency_key.nil?)) if retry_request.nil?
|
|
113
|
+
max_attempts = retry_request ? (@max_retries + 1) : 1
|
|
114
|
+
|
|
115
|
+
attempt = 0
|
|
116
|
+
last_exception = nil
|
|
117
|
+
while attempt < max_attempts
|
|
118
|
+
begin
|
|
119
|
+
status, response, raw = perform(upper, uri, headers, payload)
|
|
120
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
121
|
+
last_exception = e
|
|
122
|
+
if should_retry_connection?(retry_request, attempt, max_attempts)
|
|
123
|
+
sleep_backoff(attempt, nil)
|
|
124
|
+
attempt += 1
|
|
125
|
+
next
|
|
126
|
+
end
|
|
127
|
+
raise ConnectionError.new("Timeout ao conectar na FluvPay: #{e.message}")
|
|
128
|
+
rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
|
|
129
|
+
last_exception = e
|
|
130
|
+
if should_retry_connection?(retry_request, attempt, max_attempts)
|
|
131
|
+
sleep_backoff(attempt, nil)
|
|
132
|
+
attempt += 1
|
|
133
|
+
next
|
|
134
|
+
end
|
|
135
|
+
raise ConnectionError.new("Falha de conexão com a FluvPay: #{e.message}")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
parsed = parse_json_body(raw)
|
|
139
|
+
|
|
140
|
+
return parsed if status < 300
|
|
141
|
+
|
|
142
|
+
if should_retry_status?(retry_request, status, attempt, max_attempts)
|
|
143
|
+
sleep_backoff(attempt, retry_after_seconds(response))
|
|
144
|
+
attempt += 1
|
|
145
|
+
next
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
raise Errors.from_response(
|
|
149
|
+
status,
|
|
150
|
+
parsed.is_a?(Hash) ? parsed : nil,
|
|
151
|
+
retry_after_header: header_value(response, "Retry-After")
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
if last_exception
|
|
156
|
+
raise ConnectionError.new(
|
|
157
|
+
"Falha de conexão com a FluvPay após #{max_attempts} tentativas: #{last_exception.message}"
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
raise ConnectionError.new("Falha de conexão com a FluvPay.")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def default_headers
|
|
166
|
+
{
|
|
167
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
168
|
+
"User-Agent" => "fluvpay-ruby/#{FluvPay::VERSION}",
|
|
169
|
+
"Accept" => "application/json"
|
|
170
|
+
}
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def build_uri(path, params)
|
|
174
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
175
|
+
cleaned = clean_params(params)
|
|
176
|
+
uri.query = URI.encode_www_form(cleaned) if cleaned && !cleaned.empty?
|
|
177
|
+
uri
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def clean_params(params)
|
|
181
|
+
return nil if params.nil?
|
|
182
|
+
|
|
183
|
+
params.reject { |_, v| v.nil? }.transform_keys(&:to_s)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def clean_body(body)
|
|
187
|
+
return body unless body.is_a?(Hash)
|
|
188
|
+
|
|
189
|
+
body.reject { |_, v| v.nil? }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Executa uma única chamada HTTP. Retorna [status, objeto_response, corpo_cru].
|
|
193
|
+
def perform(method, uri, headers, payload)
|
|
194
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
195
|
+
http.use_ssl = uri.scheme == "https"
|
|
196
|
+
http.open_timeout = @open_timeout
|
|
197
|
+
http.read_timeout = @timeout
|
|
198
|
+
|
|
199
|
+
request_class =
|
|
200
|
+
case method
|
|
201
|
+
when "GET" then Net::HTTP::Get
|
|
202
|
+
when "POST" then Net::HTTP::Post
|
|
203
|
+
else raise ArgumentError, "Método HTTP não suportado: #{method}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
req = request_class.new(uri.request_uri)
|
|
207
|
+
headers.each { |k, v| req[k] = v }
|
|
208
|
+
req.body = payload if payload
|
|
209
|
+
|
|
210
|
+
response = http.request(req)
|
|
211
|
+
[response.code.to_i, response, response.body]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def parse_json_body(raw)
|
|
215
|
+
return nil if raw.nil? || raw.to_s.empty?
|
|
216
|
+
|
|
217
|
+
JSON.parse(raw)
|
|
218
|
+
rescue JSON::ParserError
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def header_value(response, name)
|
|
223
|
+
return nil unless response.respond_to?(:[])
|
|
224
|
+
|
|
225
|
+
response[name]
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def should_retry_connection?(retry_request, attempt, max_attempts)
|
|
229
|
+
retry_request && attempt < max_attempts - 1
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def should_retry_status?(retry_request, status, attempt, max_attempts)
|
|
233
|
+
return false unless retry_request
|
|
234
|
+
return false if attempt >= max_attempts - 1
|
|
235
|
+
|
|
236
|
+
status == 429 || status >= 500
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def retry_after_seconds(response)
|
|
240
|
+
raw = header_value(response, "Retry-After")
|
|
241
|
+
return nil if raw.nil?
|
|
242
|
+
|
|
243
|
+
Float(raw)
|
|
244
|
+
rescue ArgumentError, TypeError
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def sleep_backoff(attempt, retry_after)
|
|
249
|
+
delay =
|
|
250
|
+
if !retry_after.nil? && retry_after >= 0
|
|
251
|
+
retry_after
|
|
252
|
+
else
|
|
253
|
+
base = @backoff_factor * (2**attempt)
|
|
254
|
+
jitter = rand * @backoff_factor
|
|
255
|
+
[base + jitter, DEFAULT_MAX_BACKOFF].min
|
|
256
|
+
end
|
|
257
|
+
sleep(delay) if delay.positive?
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FluvPay
|
|
4
|
+
# Exceção base de todos os erros do SDK.
|
|
5
|
+
#
|
|
6
|
+
# Carrega os campos do envelope de erro da API
|
|
7
|
+
# (+{ "error": { code, message, details, trace_id } }+) além do status HTTP.
|
|
8
|
+
# Toda exceção tipada do SDK herda desta classe, então um único
|
|
9
|
+
# +rescue FluvPay::Error+ captura qualquer falha conhecida.
|
|
10
|
+
class Error < StandardError
|
|
11
|
+
# @return [String, nil] código canônico do erro (ex: "VALIDATION_ERROR").
|
|
12
|
+
attr_reader :code
|
|
13
|
+
# @return [Array<Hash>] lista de detalhes do erro (cada item com field/message/type).
|
|
14
|
+
attr_reader :details
|
|
15
|
+
# @return [String, nil] identificador da requisição para correlacionar nos logs.
|
|
16
|
+
attr_reader :trace_id
|
|
17
|
+
# @return [Integer, nil] status HTTP que originou o erro.
|
|
18
|
+
attr_reader :status_code
|
|
19
|
+
|
|
20
|
+
def initialize(message = nil, code: nil, details: nil, trace_id: nil, status_code: nil)
|
|
21
|
+
super(message)
|
|
22
|
+
@code = code
|
|
23
|
+
@details = details || []
|
|
24
|
+
@trace_id = trace_id
|
|
25
|
+
@status_code = status_code
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# 400/422: payload ou parâmetros inválidos (VALIDATION_ERROR) ou estado
|
|
30
|
+
# impeditivo (ex: INSUFFICIENT_BALANCE). Inspecione +#details+ para os campos.
|
|
31
|
+
class ValidationError < Error; end
|
|
32
|
+
|
|
33
|
+
# 401: autenticação ausente ou inválida (AUTHENTICATION_REQUIRED).
|
|
34
|
+
class AuthenticationError < Error; end
|
|
35
|
+
|
|
36
|
+
# 403: escopo insuficiente ou operação não permitida para a conta/ambiente
|
|
37
|
+
# (PERMISSION_DENIED, API_KEY_INSUFFICIENT_SCOPE, SANDBOX_NOT_SUPPORTED_*).
|
|
38
|
+
class PermissionError < Error; end
|
|
39
|
+
|
|
40
|
+
# 404: recurso não encontrado (NOT_FOUND).
|
|
41
|
+
class NotFoundError < Error; end
|
|
42
|
+
|
|
43
|
+
# 409: conflito de idempotência (IDEMPOTENCY_CONFLICT), quando a mesma
|
|
44
|
+
# Idempotency-Key é reutilizada com um payload diferente.
|
|
45
|
+
class ConflictError < Error; end
|
|
46
|
+
|
|
47
|
+
# 429: limite de requisições excedido (RATE_LIMITED).
|
|
48
|
+
# +#retry_after+ traz os segundos sugeridos no header +Retry-After+.
|
|
49
|
+
class RateLimitError < Error
|
|
50
|
+
# @return [Float, nil] segundos a aguardar antes de tentar de novo.
|
|
51
|
+
attr_reader :retry_after
|
|
52
|
+
|
|
53
|
+
def initialize(message = nil, retry_after: nil, **kwargs)
|
|
54
|
+
super(message, **kwargs)
|
|
55
|
+
@retry_after = retry_after
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# 5xx: erro interno da FluvPay (SERVER_ERROR e afins).
|
|
60
|
+
class ServerError < Error; end
|
|
61
|
+
|
|
62
|
+
# Falha de rede, timeout ou conexão recusada antes de obter uma resposta HTTP.
|
|
63
|
+
class ConnectionError < Error; end
|
|
64
|
+
|
|
65
|
+
# A assinatura de um webhook não confere, está ausente, mal formatada ou
|
|
66
|
+
# fora da tolerância de tempo. Lançada por {FluvPay::Webhooks.verify_signature}.
|
|
67
|
+
class SignatureVerificationError < Error; end
|
|
68
|
+
|
|
69
|
+
module Errors
|
|
70
|
+
# Converte uma resposta de erro (status + corpo já parseado) na exceção
|
|
71
|
+
# tipada correspondente. Usado internamente pelo cliente HTTP.
|
|
72
|
+
#
|
|
73
|
+
# @param status_code [Integer] status HTTP da resposta.
|
|
74
|
+
# @param body [Hash, nil] corpo JSON já parseado (espera-se a chave "error").
|
|
75
|
+
# @param retry_after_header [String, nil] valor cru do header Retry-After.
|
|
76
|
+
# @return [FluvPay::Error] a exceção pronta para ser lançada.
|
|
77
|
+
def self.from_response(status_code, body, retry_after_header: nil)
|
|
78
|
+
error_body = body.is_a?(Hash) ? (body["error"] || body[:error]) : nil
|
|
79
|
+
error_body = {} unless error_body.is_a?(Hash)
|
|
80
|
+
|
|
81
|
+
code = error_body["code"] || error_body[:code]
|
|
82
|
+
message = error_body["message"] || error_body[:message] || default_message(status_code)
|
|
83
|
+
details = error_body["details"] || error_body[:details] || []
|
|
84
|
+
trace_id = error_body["trace_id"] || error_body[:trace_id]
|
|
85
|
+
|
|
86
|
+
kwargs = { code: code, details: details, trace_id: trace_id, status_code: status_code }
|
|
87
|
+
|
|
88
|
+
case status_code
|
|
89
|
+
when 400, 422
|
|
90
|
+
ValidationError.new(message, **kwargs)
|
|
91
|
+
when 401
|
|
92
|
+
AuthenticationError.new(message, **kwargs)
|
|
93
|
+
when 403
|
|
94
|
+
PermissionError.new(message, **kwargs)
|
|
95
|
+
when 404
|
|
96
|
+
NotFoundError.new(message, **kwargs)
|
|
97
|
+
when 409
|
|
98
|
+
ConflictError.new(message, **kwargs)
|
|
99
|
+
when 429
|
|
100
|
+
RateLimitError.new(message, retry_after: parse_retry_after(retry_after_header), **kwargs)
|
|
101
|
+
else
|
|
102
|
+
if status_code >= 500
|
|
103
|
+
ServerError.new(message, **kwargs)
|
|
104
|
+
else
|
|
105
|
+
Error.new(message, **kwargs)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.parse_retry_after(raw)
|
|
111
|
+
return nil if raw.nil?
|
|
112
|
+
|
|
113
|
+
Float(raw)
|
|
114
|
+
rescue ArgumentError, TypeError
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.default_message(status_code)
|
|
119
|
+
"A FluvPay retornou o status HTTP #{status_code}."
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private_class_method :parse_retry_after, :default_message
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "list_objects"
|
|
5
|
+
require "erb"
|
|
6
|
+
|
|
7
|
+
module FluvPay
|
|
8
|
+
module Resources
|
|
9
|
+
# Recurso de cobranças PIX: criar, recuperar e listar.
|
|
10
|
+
class Charges
|
|
11
|
+
def initialize(client)
|
|
12
|
+
@client = client
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Cria uma cobrança PIX.
|
|
16
|
+
#
|
|
17
|
+
# Escopo exigido: +payments.create+. O header +Idempotency-Key+ é
|
|
18
|
+
# obrigatório na API; se não for informado, o SDK gera um UUIDv4
|
|
19
|
+
# automaticamente.
|
|
20
|
+
#
|
|
21
|
+
# @param amount_cents [Integer] valor em centavos (100..100000), obrigatório.
|
|
22
|
+
# @param description [String, nil] descrição (até 500 caracteres).
|
|
23
|
+
# @param customer [Hash, nil] dados do pagador (name, email, document, phone).
|
|
24
|
+
# @param expires_in_seconds [Integer, nil] expiração em segundos (60..604800).
|
|
25
|
+
# @param affiliate_code [String, nil] código de afiliado (4..24 caracteres).
|
|
26
|
+
# @param split_rule_id [String, nil] id de regra de split (20..32 caracteres).
|
|
27
|
+
# @param pass_fee_to_payer [Boolean, nil] repassar a taxa ao pagador (default true).
|
|
28
|
+
# @param metadata [Hash, nil] objeto livre de metadados.
|
|
29
|
+
# @param idempotency_key [String, nil] Idempotency-Key; gerado se omitido.
|
|
30
|
+
# @return [Hash] a cobrança criada.
|
|
31
|
+
def create(amount_cents:, description: nil, customer: nil, expires_in_seconds: nil,
|
|
32
|
+
affiliate_code: nil, split_rule_id: nil, pass_fee_to_payer: nil,
|
|
33
|
+
metadata: nil, idempotency_key: nil)
|
|
34
|
+
body = {
|
|
35
|
+
"amount_cents" => amount_cents,
|
|
36
|
+
"description" => description,
|
|
37
|
+
"customer" => clean_customer(customer),
|
|
38
|
+
"expires_in_seconds" => expires_in_seconds,
|
|
39
|
+
"affiliate_code" => affiliate_code,
|
|
40
|
+
"split_rule_id" => split_rule_id,
|
|
41
|
+
"pass_fee_to_payer" => pass_fee_to_payer,
|
|
42
|
+
"metadata" => metadata
|
|
43
|
+
}
|
|
44
|
+
key = idempotency_key || FluvPay::Client.new_idempotency_key
|
|
45
|
+
@client.request(:post, "/charges/", body: body, idempotency_key: key)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Recupera uma cobrança por ID.
|
|
49
|
+
#
|
|
50
|
+
# Escopo exigido: +payments.read+.
|
|
51
|
+
# @param charge_id [String] identificador da cobrança.
|
|
52
|
+
# @return [Hash] a cobrança.
|
|
53
|
+
def retrieve(charge_id)
|
|
54
|
+
@client.request(:get, "/charges/#{escape(charge_id)}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Lista cobranças.
|
|
58
|
+
#
|
|
59
|
+
# Escopo exigido: +payments.read+. Envelope: +page+/+per_page+.
|
|
60
|
+
# @param status [String, nil] filtra por status.
|
|
61
|
+
# @param page [Integer, nil] página (1-based).
|
|
62
|
+
# @param per_page [Integer, nil] itens por página (máx 100).
|
|
63
|
+
# @param sort [String, nil] campo de ordenação (ex: "-created_at").
|
|
64
|
+
# @return [FluvPay::Resources::PageList] página com +.data+ e metadados.
|
|
65
|
+
def list(status: nil, page: nil, per_page: nil, sort: nil)
|
|
66
|
+
params = {
|
|
67
|
+
"status" => status,
|
|
68
|
+
"page" => page,
|
|
69
|
+
"per_page" => per_page,
|
|
70
|
+
"sort" => sort
|
|
71
|
+
}
|
|
72
|
+
payload = @client.request(:get, "/charges/", params: params)
|
|
73
|
+
PageList.new(payload)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def clean_customer(customer)
|
|
79
|
+
return nil if customer.nil?
|
|
80
|
+
|
|
81
|
+
customer.reject { |_, v| v.nil? }.transform_keys(&:to_s)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def escape(value)
|
|
85
|
+
ERB::Util.url_encode(value.to_s)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "list_objects"
|
|
5
|
+
require "erb"
|
|
6
|
+
|
|
7
|
+
module FluvPay
|
|
8
|
+
module Resources
|
|
9
|
+
# Recurso de transferências internas (conta FluvPay para conta FluvPay).
|
|
10
|
+
class InternalTransfers
|
|
11
|
+
def initialize(client)
|
|
12
|
+
@client = client
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Cria uma transferência interna FluvPay para FluvPay.
|
|
16
|
+
#
|
|
17
|
+
# Escopo exigido: +withdrawals.create+. Idempotency-Key gerado se omitido.
|
|
18
|
+
# Não suportado em sandbox: chaves +fluv_test_+ recebem 403
|
|
19
|
+
# (SANDBOX_NOT_SUPPORTED_FOR_TRANSFERS). Informe exatamente um entre
|
|
20
|
+
# +recipient_email+ e +recipient_merchant_id+.
|
|
21
|
+
#
|
|
22
|
+
# @param amount_cents [Integer] valor em centavos (100..10000000).
|
|
23
|
+
# @param recipient_email [String, nil] email do destinatário.
|
|
24
|
+
# @param recipient_merchant_id [String, nil] ULID do merchant destinatário (26 chars).
|
|
25
|
+
# @param description [String, nil] descrição (até 140 caracteres).
|
|
26
|
+
# @param idempotency_key [String, nil] Idempotency-Key; gerado se omitido.
|
|
27
|
+
# @return [Hash] a transferência criada.
|
|
28
|
+
def create(amount_cents:, recipient_email: nil, recipient_merchant_id: nil,
|
|
29
|
+
description: nil, idempotency_key: nil)
|
|
30
|
+
body = {
|
|
31
|
+
"amount_cents" => amount_cents,
|
|
32
|
+
"recipient_email" => recipient_email,
|
|
33
|
+
"recipient_merchant_id" => recipient_merchant_id,
|
|
34
|
+
"description" => description
|
|
35
|
+
}
|
|
36
|
+
key = idempotency_key || FluvPay::Client.new_idempotency_key
|
|
37
|
+
@client.request(:post, "/internal-transfers/", body: body, idempotency_key: key)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Lista transferências internas.
|
|
41
|
+
#
|
|
42
|
+
# Escopo exigido: +transfers.read+. Envelope: +limit+/+offset+.
|
|
43
|
+
# @param direction [String, nil] "sent" (enviadas) ou "received" (recebidas).
|
|
44
|
+
# @param limit [Integer, nil] itens por página (1..100).
|
|
45
|
+
# @param offset [Integer, nil] deslocamento (>= 0).
|
|
46
|
+
# @return [FluvPay::Resources::OffsetList] página com +.data+ e metadados.
|
|
47
|
+
def list(direction: nil, limit: nil, offset: nil)
|
|
48
|
+
params = {
|
|
49
|
+
"direction" => direction,
|
|
50
|
+
"limit" => limit,
|
|
51
|
+
"offset" => offset
|
|
52
|
+
}
|
|
53
|
+
payload = @client.request(:get, "/internal-transfers/", params: params)
|
|
54
|
+
OffsetList.new(payload)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Recupera uma transferência interna por ID.
|
|
58
|
+
#
|
|
59
|
+
# @param transfer_id [String] identificador da transferência.
|
|
60
|
+
# @return [Hash] a transferência.
|
|
61
|
+
def retrieve(transfer_id)
|
|
62
|
+
@client.request(:get, "/internal-transfers/#{ERB::Util.url_encode(transfer_id.to_s)}")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FluvPay
|
|
4
|
+
module Resources
|
|
5
|
+
# Página baseada em +page+/+per_page+ (usada por charges.list e transactions.list).
|
|
6
|
+
#
|
|
7
|
+
# Expõe +#data+ (os itens) e os metadados +page+, +per_page+, +total+,
|
|
8
|
+
# +has_next+ e +has_prev+, exatamente como o backend retorna. Também é
|
|
9
|
+
# iterável: +page.each { |item| ... }+.
|
|
10
|
+
class PageList
|
|
11
|
+
include Enumerable
|
|
12
|
+
|
|
13
|
+
# @return [Array<Hash>] itens da página.
|
|
14
|
+
attr_reader :data
|
|
15
|
+
attr_reader :page, :per_page, :total
|
|
16
|
+
|
|
17
|
+
def initialize(payload)
|
|
18
|
+
payload ||= {}
|
|
19
|
+
@data = payload["data"] || []
|
|
20
|
+
@page = payload["page"]
|
|
21
|
+
@per_page = payload["per_page"]
|
|
22
|
+
@total = payload["total"]
|
|
23
|
+
@has_next = payload["has_next"]
|
|
24
|
+
@has_prev = payload["has_prev"]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Boolean] true se existe próxima página.
|
|
28
|
+
def has_next?
|
|
29
|
+
@has_next ? true : false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Boolean] true se existe página anterior.
|
|
33
|
+
def has_prev?
|
|
34
|
+
@has_prev ? true : false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def each(&block)
|
|
38
|
+
@data.each(&block)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Página baseada em +limit+/+offset+ (usada por withdrawals.list e
|
|
43
|
+
# internal_transfers.list). Expõe +#data+ e os metadados +limit+, +offset+
|
|
44
|
+
# e +total+, exatamente como o backend retorna. Também é iterável.
|
|
45
|
+
class OffsetList
|
|
46
|
+
include Enumerable
|
|
47
|
+
|
|
48
|
+
# @return [Array<Hash>] itens da página.
|
|
49
|
+
attr_reader :data
|
|
50
|
+
attr_reader :limit, :offset, :total
|
|
51
|
+
|
|
52
|
+
def initialize(payload)
|
|
53
|
+
payload ||= {}
|
|
54
|
+
@data = payload["data"] || []
|
|
55
|
+
@limit = payload["limit"]
|
|
56
|
+
@offset = payload["offset"]
|
|
57
|
+
@total = payload["total"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def each(&block)
|
|
61
|
+
@data.each(&block)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FluvPay
|
|
4
|
+
module Resources
|
|
5
|
+
# Utilitários de teste, disponíveis apenas com chave +fluv_test_+.
|
|
6
|
+
class Sandbox
|
|
7
|
+
def initialize(client)
|
|
8
|
+
@client = client
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Apaga todos os dados do sandbox (só chave de teste).
|
|
12
|
+
#
|
|
13
|
+
# @return [Hash] resultado com +reset+, +deleted_charges+ e +merchant_id+.
|
|
14
|
+
def reset
|
|
15
|
+
@client.request(:post, "/test/reset")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Lista os valores mágicos do sandbox.
|
|
19
|
+
#
|
|
20
|
+
# @return [Hash] com +info+ e a lista +scenarios+.
|
|
21
|
+
def scenarios
|
|
22
|
+
@client.request(:get, "/test/scenarios")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "list_objects"
|
|
5
|
+
require "erb"
|
|
6
|
+
|
|
7
|
+
module FluvPay
|
|
8
|
+
module Resources
|
|
9
|
+
# Recurso de extrato financeiro consolidado (entradas e saídas).
|
|
10
|
+
class Transactions
|
|
11
|
+
def initialize(client)
|
|
12
|
+
@client = client
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Lista lançamentos do extrato.
|
|
16
|
+
#
|
|
17
|
+
# Escopos exigidos (qualquer um): +payments.read+, +transfers.read+ ou
|
|
18
|
+
# +withdrawals.read+. Envelope: +page+/+per_page+.
|
|
19
|
+
# Não suportado em sandbox: chaves +fluv_test_+ recebem 403.
|
|
20
|
+
#
|
|
21
|
+
# @param page [Integer, nil] página (1-based).
|
|
22
|
+
# @param per_page [Integer, nil] itens por página (máx 100).
|
|
23
|
+
# @param sort [String, nil] campo de ordenação (ex: "-created_at").
|
|
24
|
+
# @return [FluvPay::Resources::PageList] página com +.data+ e metadados.
|
|
25
|
+
def list(page: nil, per_page: nil, sort: nil)
|
|
26
|
+
params = {
|
|
27
|
+
"page" => page,
|
|
28
|
+
"per_page" => per_page,
|
|
29
|
+
"sort" => sort
|
|
30
|
+
}
|
|
31
|
+
payload = @client.request(:get, "/transactions/", params: params)
|
|
32
|
+
PageList.new(payload)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Recupera um lançamento por ID.
|
|
36
|
+
#
|
|
37
|
+
# @param tx_id [String] identificador do lançamento.
|
|
38
|
+
# @return [Hash] o lançamento.
|
|
39
|
+
def retrieve(tx_id)
|
|
40
|
+
@client.request(:get, "/transactions/#{ERB::Util.url_encode(tx_id.to_s)}")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "list_objects"
|
|
5
|
+
require "erb"
|
|
6
|
+
|
|
7
|
+
module FluvPay
|
|
8
|
+
module Resources
|
|
9
|
+
# Recurso de saques PIX da conta para uma chave PIX.
|
|
10
|
+
class Withdrawals
|
|
11
|
+
def initialize(client)
|
|
12
|
+
@client = client
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Cria um saque PIX.
|
|
16
|
+
#
|
|
17
|
+
# Escopo exigido: +withdrawals.create+. Idempotency-Key gerado se omitido.
|
|
18
|
+
# Não suportado em sandbox: chaves +fluv_test_+ recebem 403
|
|
19
|
+
# (SANDBOX_NOT_SUPPORTED_FOR_WITHDRAWALS).
|
|
20
|
+
#
|
|
21
|
+
# @param amount_cents [Integer] valor bruto em centavos (100..10000000).
|
|
22
|
+
# @param pix_key [String] chave PIX de destino (1..140 caracteres).
|
|
23
|
+
# @param pix_key_type [String] tipo da chave: cpf, cnpj, email, phone ou evp.
|
|
24
|
+
# @param description [String, nil] descrição (até 140 caracteres).
|
|
25
|
+
# @param idempotency_key [String, nil] Idempotency-Key; gerado se omitido.
|
|
26
|
+
# @return [Hash] o saque criado.
|
|
27
|
+
def create(amount_cents:, pix_key:, pix_key_type:, description: nil, idempotency_key: nil)
|
|
28
|
+
body = {
|
|
29
|
+
"amount_cents" => amount_cents,
|
|
30
|
+
"pix_key" => pix_key,
|
|
31
|
+
"pix_key_type" => pix_key_type,
|
|
32
|
+
"description" => description
|
|
33
|
+
}
|
|
34
|
+
key = idempotency_key || FluvPay::Client.new_idempotency_key
|
|
35
|
+
@client.request(:post, "/withdrawals/", body: body, idempotency_key: key)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Lista saques.
|
|
39
|
+
#
|
|
40
|
+
# Escopo exigido: +withdrawals.read+. Envelope: +limit+/+offset+.
|
|
41
|
+
# @param limit [Integer, nil] itens por página (1..100).
|
|
42
|
+
# @param offset [Integer, nil] deslocamento (>= 0).
|
|
43
|
+
# @param status [String, nil] filtra por status.
|
|
44
|
+
# @return [FluvPay::Resources::OffsetList] página com +.data+ e metadados.
|
|
45
|
+
def list(limit: nil, offset: nil, status: nil)
|
|
46
|
+
params = {
|
|
47
|
+
"limit" => limit,
|
|
48
|
+
"offset" => offset,
|
|
49
|
+
"status" => status
|
|
50
|
+
}
|
|
51
|
+
payload = @client.request(:get, "/withdrawals/", params: params)
|
|
52
|
+
OffsetList.new(payload)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Recupera um saque por ID.
|
|
56
|
+
#
|
|
57
|
+
# @param withdrawal_id [String] identificador do saque.
|
|
58
|
+
# @return [Hash] o saque.
|
|
59
|
+
def retrieve(withdrawal_id)
|
|
60
|
+
@client.request(:get, "/withdrawals/#{ERB::Util.url_encode(withdrawal_id.to_s)}")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module FluvPay
|
|
7
|
+
# Verificação de assinatura de webhooks da FluvPay.
|
|
8
|
+
#
|
|
9
|
+
# A FluvPay assina cada entrega com HMAC-SHA256 sobre +"{timestamp}." + corpo_cru+,
|
|
10
|
+
# usando o segredo +whsec_...+ do webhook. O header +X-FluvPay-Signature+ traz
|
|
11
|
+
# +v1=<hex>+. A verificação usa comparação em tempo constante e exige o corpo CRU
|
|
12
|
+
# (a string exatamente como recebida), nunca reserializado.
|
|
13
|
+
module Webhooks
|
|
14
|
+
EVENT_HEADER = "X-FluvPay-Event"
|
|
15
|
+
TIMESTAMP_HEADER = "X-FluvPay-Timestamp"
|
|
16
|
+
DELIVERY_ID_HEADER = "X-FluvPay-Delivery-Id"
|
|
17
|
+
SIGNATURE_HEADER = "X-FluvPay-Signature"
|
|
18
|
+
|
|
19
|
+
# Eventos disponíveis (8). Espelha o catálogo do contrato OpenAPI.
|
|
20
|
+
EVENT_TYPES = %w[
|
|
21
|
+
charge.created
|
|
22
|
+
charge.paid
|
|
23
|
+
charge.expired
|
|
24
|
+
charge.cancelled
|
|
25
|
+
charge.refunded
|
|
26
|
+
payout.created
|
|
27
|
+
payout.completed
|
|
28
|
+
payout.failed
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
# Evento de webhook já verificado e parseado, devolvido por
|
|
32
|
+
# {FluvPay::Webhooks.verify_signature}.
|
|
33
|
+
class Event
|
|
34
|
+
# @return [String, nil] tipo do evento (ex: "charge.paid").
|
|
35
|
+
attr_reader :type
|
|
36
|
+
# @return [String, nil] identificador da entrega (X-FluvPay-Delivery-Id).
|
|
37
|
+
attr_reader :delivery_id
|
|
38
|
+
# @return [String] timestamp cru recebido (X-FluvPay-Timestamp).
|
|
39
|
+
attr_reader :timestamp
|
|
40
|
+
# @return [Hash] objeto "data" do evento (ou o payload inteiro como fallback).
|
|
41
|
+
attr_reader :data
|
|
42
|
+
# @return [Hash] payload completo parseado.
|
|
43
|
+
attr_reader :raw
|
|
44
|
+
|
|
45
|
+
def initialize(type:, delivery_id:, timestamp:, data:, raw:)
|
|
46
|
+
@type = type
|
|
47
|
+
@delivery_id = delivery_id
|
|
48
|
+
@timestamp = timestamp
|
|
49
|
+
@data = data
|
|
50
|
+
@raw = raw
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
module_function
|
|
55
|
+
|
|
56
|
+
# Recalcula o hex da assinatura: HMAC_SHA256(secret, timestamp + "." + corpo_cru).
|
|
57
|
+
#
|
|
58
|
+
# @param secret [String] segredo do webhook (+whsec_...+).
|
|
59
|
+
# @param timestamp [String] valor de +X-FluvPay-Timestamp+.
|
|
60
|
+
# @param raw_body [String] corpo CRU da requisição.
|
|
61
|
+
# @return [String] assinatura em hexadecimal.
|
|
62
|
+
def compute_signature(secret, timestamp, raw_body)
|
|
63
|
+
signed_payload = "#{timestamp}.#{raw_body}"
|
|
64
|
+
OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("SHA256"), secret.to_s, signed_payload)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Verifica a assinatura de um webhook e devolve o evento parseado.
|
|
68
|
+
#
|
|
69
|
+
# @param payload [String] corpo CRU da requisição, exatamente como recebido.
|
|
70
|
+
# @param signature_header [String] valor de +X-FluvPay-Signature+ (formato +v1=<hex>+).
|
|
71
|
+
# @param timestamp [String] valor de +X-FluvPay-Timestamp+.
|
|
72
|
+
# @param secret [String] segredo do webhook (+whsec_...+).
|
|
73
|
+
# @param tolerance_seconds [Integer, nil] se informado e o timestamp for numérico,
|
|
74
|
+
# rejeita entregas mais antigas que esse limite (proteção contra replay).
|
|
75
|
+
# @param event_type [String, nil] valor de +X-FluvPay-Event+ (preenche Event#type).
|
|
76
|
+
# @param delivery_id [String, nil] valor de +X-FluvPay-Delivery-Id+.
|
|
77
|
+
# @return [FluvPay::Webhooks::Event] evento verificado.
|
|
78
|
+
# @raise [FluvPay::SignatureVerificationError] assinatura ausente, inválida ou
|
|
79
|
+
# fora da tolerância de tempo.
|
|
80
|
+
def verify_signature(payload, signature_header, timestamp, secret,
|
|
81
|
+
tolerance_seconds: nil, event_type: nil, delivery_id: nil)
|
|
82
|
+
provided = extract_v1(signature_header.to_s)
|
|
83
|
+
if provided.nil? || provided.empty?
|
|
84
|
+
raise SignatureVerificationError.new(
|
|
85
|
+
"Assinatura ausente ou em formato inválido (esperado 'v1=<hex>')."
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
check_tolerance!(timestamp, tolerance_seconds) unless tolerance_seconds.nil?
|
|
90
|
+
|
|
91
|
+
expected = compute_signature(secret, timestamp, payload)
|
|
92
|
+
unless secure_compare(expected, provided)
|
|
93
|
+
raise SignatureVerificationError.new("Assinatura do webhook não confere.")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
parsed = parse_json(payload)
|
|
97
|
+
resolved_type = event_type || (parsed.is_a?(Hash) ? parsed["event"] : nil)
|
|
98
|
+
data =
|
|
99
|
+
if parsed.is_a?(Hash) && parsed["data"].is_a?(Hash)
|
|
100
|
+
parsed["data"]
|
|
101
|
+
elsif parsed.is_a?(Hash)
|
|
102
|
+
parsed
|
|
103
|
+
else
|
|
104
|
+
{}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
Event.new(
|
|
108
|
+
type: resolved_type,
|
|
109
|
+
delivery_id: delivery_id,
|
|
110
|
+
timestamp: timestamp,
|
|
111
|
+
data: data,
|
|
112
|
+
raw: parsed.is_a?(Hash) ? parsed : {}
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Extrai o hex após +v1=+ (aceita vários esquemas separados por vírgula).
|
|
117
|
+
def extract_v1(signature_header)
|
|
118
|
+
return nil if signature_header.nil? || signature_header.empty?
|
|
119
|
+
|
|
120
|
+
signature_header.split(",").each do |part|
|
|
121
|
+
item = part.strip
|
|
122
|
+
return item[3..].to_s.strip if item.start_with?("v1=")
|
|
123
|
+
end
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Rejeita entregas fora da janela de tolerância (proteção contra replay).
|
|
128
|
+
def check_tolerance!(timestamp, tolerance_seconds)
|
|
129
|
+
ts_int =
|
|
130
|
+
begin
|
|
131
|
+
Integer(timestamp.to_s, 10)
|
|
132
|
+
rescue ArgumentError, TypeError
|
|
133
|
+
raise SignatureVerificationError.new(
|
|
134
|
+
"Timestamp não numérico: impossível validar a tolerância de tempo."
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
age = (Time.now.to_i - ts_int).abs
|
|
139
|
+
return unless age > tolerance_seconds
|
|
140
|
+
|
|
141
|
+
raise SignatureVerificationError.new(
|
|
142
|
+
"Timestamp fora da tolerância (#{age}s > #{tolerance_seconds}s); possível replay."
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Comparação de strings em tempo constante, resistente a timing attacks.
|
|
147
|
+
def secure_compare(expected, provided)
|
|
148
|
+
a = expected.to_s.b
|
|
149
|
+
b = provided.to_s.b
|
|
150
|
+
return false unless a.bytesize == b.bytesize
|
|
151
|
+
|
|
152
|
+
OpenSSL.fixed_length_secure_compare(a, b)
|
|
153
|
+
rescue StandardError
|
|
154
|
+
# Fallback puro Ruby caso fixed_length_secure_compare não esteja disponível.
|
|
155
|
+
bytes = a.unpack("C*")
|
|
156
|
+
res = 0
|
|
157
|
+
b.unpack("C*").each_with_index { |byte, i| res |= byte ^ bytes[i] }
|
|
158
|
+
res.zero?
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def parse_json(payload)
|
|
162
|
+
text = payload.is_a?(String) ? payload : payload.to_s
|
|
163
|
+
JSON.parse(text)
|
|
164
|
+
rescue JSON::ParserError, TypeError
|
|
165
|
+
{}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private_class_method :parse_json, :check_tolerance!
|
|
169
|
+
end
|
|
170
|
+
end
|
data/lib/fluvpay.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# FluvPay: SDK oficial em Ruby para a API de pagamentos PIX da FluvPay.
|
|
4
|
+
#
|
|
5
|
+
# Cobranças, saques, transferências internas, sandbox e verificação de
|
|
6
|
+
# webhooks por uma interface idiomática e previsível, construída apenas sobre
|
|
7
|
+
# a biblioteca padrão (net/http e json).
|
|
8
|
+
#
|
|
9
|
+
# @example Criar uma cobrança
|
|
10
|
+
# require "fluvpay"
|
|
11
|
+
#
|
|
12
|
+
# client = FluvPay::Client.new(api_key: "fluv_test_sua_chave")
|
|
13
|
+
# charge = client.charges.create(amount_cents: 4990, description: "Pedido #1042")
|
|
14
|
+
# puts charge["pix_copy_paste"]
|
|
15
|
+
module FluvPay
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
require_relative "fluvpay/version"
|
|
19
|
+
require_relative "fluvpay/errors"
|
|
20
|
+
require_relative "fluvpay/webhooks"
|
|
21
|
+
require_relative "fluvpay/client"
|
metadata
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: fluvpay
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- FluvPay
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-08 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: minitest
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rake
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '13.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '13.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: webmock
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '3.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.0'
|
|
55
|
+
description: 'Cliente idiomático da FluvPay: cobranças PIX, saques, transferências
|
|
56
|
+
internas, sandbox e verificação de webhooks. Idempotência automática, retries seguros
|
|
57
|
+
e erros tipados, usando apenas a biblioteca padrão (net/http).'
|
|
58
|
+
email:
|
|
59
|
+
executables: []
|
|
60
|
+
extensions: []
|
|
61
|
+
extra_rdoc_files: []
|
|
62
|
+
files:
|
|
63
|
+
- LICENSE
|
|
64
|
+
- README.md
|
|
65
|
+
- lib/fluvpay.rb
|
|
66
|
+
- lib/fluvpay/client.rb
|
|
67
|
+
- lib/fluvpay/errors.rb
|
|
68
|
+
- lib/fluvpay/resources/charges.rb
|
|
69
|
+
- lib/fluvpay/resources/internal_transfers.rb
|
|
70
|
+
- lib/fluvpay/resources/list_objects.rb
|
|
71
|
+
- lib/fluvpay/resources/sandbox.rb
|
|
72
|
+
- lib/fluvpay/resources/transactions.rb
|
|
73
|
+
- lib/fluvpay/resources/withdrawals.rb
|
|
74
|
+
- lib/fluvpay/version.rb
|
|
75
|
+
- lib/fluvpay/webhooks.rb
|
|
76
|
+
homepage: https://docs.fluvpay.com
|
|
77
|
+
licenses:
|
|
78
|
+
- MIT
|
|
79
|
+
metadata:
|
|
80
|
+
homepage_uri: https://fluvpay.com
|
|
81
|
+
documentation_uri: https://docs.fluvpay.com
|
|
82
|
+
source_code_uri: https://github.com/fluvpay/fluvpay-ruby
|
|
83
|
+
changelog_uri: https://github.com/fluvpay/fluvpay-ruby/releases
|
|
84
|
+
rubygems_mfa_required: 'true'
|
|
85
|
+
post_install_message:
|
|
86
|
+
rdoc_options: []
|
|
87
|
+
require_paths:
|
|
88
|
+
- lib
|
|
89
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
90
|
+
requirements:
|
|
91
|
+
- - ">="
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: '3.0'
|
|
94
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
95
|
+
requirements:
|
|
96
|
+
- - ">="
|
|
97
|
+
- !ruby/object:Gem::Version
|
|
98
|
+
version: '0'
|
|
99
|
+
requirements: []
|
|
100
|
+
rubygems_version: 3.5.22
|
|
101
|
+
signing_key:
|
|
102
|
+
specification_version: 4
|
|
103
|
+
summary: SDK oficial em Ruby para a API de pagamentos PIX da FluvPay.
|
|
104
|
+
test_files: []
|