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 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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FluvPay
4
+ # Versão atual da gem. Acompanha o cabeçalho User-Agent enviado em cada requisição.
5
+ VERSION = "1.0.0"
6
+ 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: []