nxgate 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: 26aaf6e56c28024006e8b39d22220851836bceaf0dc5a094ca8ba6879323fcd5
4
+ data.tar.gz: b96af5c8e0a12381144bce2d9b2a8df0d5603480898d7607bdecbfe606fdbe4b
5
+ SHA512:
6
+ metadata.gz: 6befe2c59740cd39eb9be1139c294473e440c133dfbb97263308cb60000612a3dc5c4d113dcc030c66ee13f951c2e2adc84b36fe93eb1a3dd255e2824d3dae95
7
+ data.tar.gz: c7339b2ea6be7b4416382d1492d8538ab75ab2e5decf46acea9840791b1d5aa4c66ec87bc5a5f8f624ef9f48c8613202c7786f7c83685cfdd98e4c367ed68f90
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 NXGATE
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,320 @@
1
+ # NXGATE PIX SDK para Ruby
2
+
3
+ SDK oficial da NXGATE para integracoes PIX em Ruby. Permite gerar cobrancas (cash-in), realizar saques (cash-out), consultar saldo e transacoes, alem de processar webhooks.
4
+
5
+ ## Requisitos
6
+
7
+ - Ruby 3.0 ou superior
8
+ - Sem dependencias externas (utiliza apenas `net/http`, `json` e `openssl` da stdlib)
9
+
10
+ ## Instalacao
11
+
12
+ Adicione ao seu `Gemfile`:
13
+
14
+ ```ruby
15
+ gem 'nxgate'
16
+ ```
17
+
18
+ E execute:
19
+
20
+ ```bash
21
+ bundle install
22
+ ```
23
+
24
+ Ou instale diretamente:
25
+
26
+ ```bash
27
+ gem install nxgate
28
+ ```
29
+
30
+ ## Configuracao
31
+
32
+ ```ruby
33
+ require 'nxgate'
34
+
35
+ # Configuracao basica
36
+ nx = NXGate::Client.new(
37
+ client_id: 'nxgate_xxx',
38
+ client_secret: 'secret'
39
+ )
40
+
41
+ # Com assinatura HMAC (opcional)
42
+ nx = NXGate::Client.new(
43
+ client_id: 'nxgate_xxx',
44
+ client_secret: 'secret',
45
+ hmac_secret: 'sua_chave_hmac'
46
+ )
47
+
48
+ # Com URL customizada e timeout
49
+ nx = NXGate::Client.new(
50
+ client_id: 'nxgate_xxx',
51
+ client_secret: 'secret',
52
+ base_url: 'https://sandbox.nxgate.com.br',
53
+ timeout: 60
54
+ )
55
+ ```
56
+
57
+ ## Uso
58
+
59
+ ### Gerar Cobranca PIX (Cash-in)
60
+
61
+ Gera uma cobranca PIX e retorna o QR Code para pagamento.
62
+
63
+ ```ruby
64
+ charge = nx.pix_generate(
65
+ valor: 100.00,
66
+ nome_pagador: 'Joao da Silva',
67
+ documento_pagador: '12345678901',
68
+ webhook: 'https://meusite.com/webhook'
69
+ )
70
+
71
+ puts charge.status # "OK"
72
+ puts charge.id_transaction # "tx_abc123"
73
+ puts charge.payment_code # Codigo PIX copia e cola
74
+ puts charge.payment_code_base64 # QR Code em Base64
75
+ ```
76
+
77
+ #### Parametros opcionais
78
+
79
+ ```ruby
80
+ charge = nx.pix_generate(
81
+ valor: 250.00,
82
+ nome_pagador: 'Maria Santos',
83
+ documento_pagador: '98765432100',
84
+ forcar_pagador: true,
85
+ email_pagador: 'maria@email.com',
86
+ celular: '11999999999',
87
+ descricao: 'Pedido #12345',
88
+ webhook: 'https://meusite.com/webhook',
89
+ magic_id: 'pedido_12345',
90
+ api_key: 'chave_api',
91
+ split_users: [
92
+ { username: 'lojista1', percentage: 70 },
93
+ { username: 'lojista2', percentage: 30 }
94
+ ]
95
+ )
96
+ ```
97
+
98
+ ### Saque PIX (Cash-out)
99
+
100
+ Realiza uma transferencia PIX para a chave informada.
101
+
102
+ ```ruby
103
+ withdrawal = nx.pix_withdraw(
104
+ valor: 50.0,
105
+ chave_pix: 'joao@email.com',
106
+ tipo_chave: 'EMAIL'
107
+ )
108
+
109
+ puts withdrawal.status # "OK"
110
+ puts withdrawal.message # "Saque realizado"
111
+ puts withdrawal.internal_reference # "ref_xyz"
112
+ ```
113
+
114
+ #### Tipos de chave aceitos
115
+
116
+ | Tipo | Descricao |
117
+ |---------|----------------------|
118
+ | `CPF` | CPF do destinatario |
119
+ | `CNPJ` | CNPJ do destinatario |
120
+ | `PHONE` | Telefone celular |
121
+ | `EMAIL` | Endereco de e-mail |
122
+ | `RANDOM`| Chave aleatoria |
123
+
124
+ ### Consultar Saldo
125
+
126
+ ```ruby
127
+ balance = nx.get_balance
128
+
129
+ puts balance.balance # Saldo total
130
+ puts balance.blocked # Saldo bloqueado
131
+ puts balance.available # Saldo disponivel
132
+ ```
133
+
134
+ ### Consultar Transacao
135
+
136
+ ```ruby
137
+ tx = nx.get_transaction(type: 'cash-in', txid: 'tx_abc123')
138
+
139
+ puts tx.id_transaction # "tx_abc123"
140
+ puts tx.status # "PAID"
141
+ puts tx.amount # 100.0
142
+ puts tx.paid_at # "2025-01-15T10:30:00Z"
143
+ puts tx.end_to_end # "e2e_xyz"
144
+ puts tx.raw # Hash completo da resposta
145
+ ```
146
+
147
+ ## Webhooks
148
+
149
+ ### Processar Evento de Webhook
150
+
151
+ ```ruby
152
+ # Em um controller Rails, Sinatra, etc.
153
+ event = NXGate::Webhook.parse(request.body.read)
154
+
155
+ # Ou a partir de um Hash
156
+ event = NXGate::Webhook.parse(params)
157
+
158
+ puts event.type # Tipo do evento
159
+ puts event.data # Dados do evento
160
+
161
+ # Verificacoes de tipo
162
+ event.cash_in? # true se for evento de cash-in
163
+ event.cash_out? # true se for evento de cash-out
164
+ event.success? # true se for pagamento/saque bem-sucedido
165
+ event.refunded? # true se houve reembolso
166
+ event.error? # true se houve erro (apenas cash-out)
167
+ ```
168
+
169
+ ### Tipos de Evento
170
+
171
+ #### Cash-in (QR Code)
172
+
173
+ | Tipo | Descricao |
174
+ |-------------------------------------|-------------------------|
175
+ | `QR_CODE_COPY_AND_PASTE_PAID` | Pagamento confirmado |
176
+ | `QR_CODE_COPY_AND_PASTE_REFUNDED` | Pagamento reembolsado |
177
+
178
+ #### Cash-out (Saque)
179
+
180
+ | Tipo | Descricao |
181
+ |-------------------------|-------------------------|
182
+ | `PIX_CASHOUT_SUCCESS` | Saque realizado |
183
+ | `PIX_CASHOUT_ERROR` | Erro no saque |
184
+ | `PIX_CASHOUT_REFUNDED` | Saque reembolsado |
185
+
186
+ ### Exemplo com Sinatra
187
+
188
+ ```ruby
189
+ require 'sinatra'
190
+ require 'nxgate'
191
+
192
+ post '/webhook' do
193
+ payload = request.body.read
194
+
195
+ begin
196
+ event = NXGate::Webhook.parse(payload)
197
+
198
+ if event.cash_in? && event.success?
199
+ # Pagamento confirmado
200
+ puts "Pagamento recebido: R$ #{event.data[:amount]}"
201
+ puts "Transacao: #{event.data[:tx_id]}"
202
+ elsif event.cash_out? && event.success?
203
+ # Saque realizado
204
+ puts "Saque realizado: R$ #{event.data[:amount]}"
205
+ elsif event.error?
206
+ # Erro no saque
207
+ puts "Erro no saque: #{event.data[:id_transaction]}"
208
+ end
209
+
210
+ status 200
211
+ 'OK'
212
+ rescue NXGate::Error => e
213
+ status 400
214
+ e.message
215
+ end
216
+ end
217
+ ```
218
+
219
+ ## Assinatura HMAC
220
+
221
+ Quando o `hmac_secret` e fornecido na inicializacao do client, todas as requisicoes sao automaticamente assinadas com HMAC-SHA256.
222
+
223
+ ### Headers adicionados
224
+
225
+ | Header | Descricao |
226
+ |---------------------|----------------------------------------------|
227
+ | `X-Client-ID` | ID do cliente |
228
+ | `X-HMAC-Signature` | Assinatura HMAC-SHA256 em Base64 |
229
+ | `X-HMAC-Timestamp` | Timestamp ISO 8601 da requisicao |
230
+ | `X-HMAC-Nonce` | String unica por requisicao (UUID) |
231
+
232
+ ### Payload assinado
233
+
234
+ ```
235
+ METHOD\nPATH\nTIMESTAMP\nNONCE\nBODY
236
+ ```
237
+
238
+ ### Verificacao de assinatura (para webhooks)
239
+
240
+ ```ruby
241
+ signer = NXGate::HmacSigner.new(
242
+ client_id: 'nxgate_xxx',
243
+ hmac_secret: 'sua_chave_hmac'
244
+ )
245
+
246
+ valido = signer.verify(
247
+ method: 'POST',
248
+ path: '/webhook',
249
+ timestamp: request.env['HTTP_X_HMAC_TIMESTAMP'],
250
+ nonce: request.env['HTTP_X_HMAC_NONCE'],
251
+ body: request.body.read,
252
+ signature: request.env['HTTP_X_HMAC_SIGNATURE']
253
+ )
254
+ ```
255
+
256
+ ## Tratamento de Erros
257
+
258
+ Todos os erros herdam de `NXGate::Error` e incluem informacoes detalhadas.
259
+
260
+ ```ruby
261
+ begin
262
+ nx.pix_generate(valor: 100.0, nome_pagador: 'Joao', documento_pagador: '123')
263
+ rescue NXGate::AuthenticationError => e
264
+ # Erro de autenticacao (401)
265
+ puts "Autenticacao falhou: #{e.message}"
266
+ puts "HTTP Status: #{e.http_status}"
267
+ rescue NXGate::ServiceUnavailableError => e
268
+ # Servico indisponivel (503) - apos retentativas
269
+ puts "Servico indisponivel: #{e.message}"
270
+ rescue NXGate::TimeoutError => e
271
+ # Timeout na conexao
272
+ puts "Timeout: #{e.message}"
273
+ rescue NXGate::Error => e
274
+ # Outros erros da API
275
+ puts "Erro: #{e.code} - #{e.title}"
276
+ puts "Detalhe: #{e.description}"
277
+ puts "HTTP: #{e.http_status}"
278
+ end
279
+ ```
280
+
281
+ ### Hierarquia de Erros
282
+
283
+ ```
284
+ NXGate::Error (StandardError)
285
+ |-- NXGate::AuthenticationError # 401 - Falha na autenticacao
286
+ |-- NXGate::TimeoutError # Timeout na conexao
287
+ |-- NXGate::ServiceUnavailableError # 503 - Servico indisponivel
288
+ ```
289
+
290
+ ## Retentativas Automaticas
291
+
292
+ Requisicoes que retornam HTTP 503 sao automaticamente retentadas ate 2 vezes com backoff exponencial:
293
+
294
+ - 1a retentativa: aguarda 1 segundo
295
+ - 2a retentativa: aguarda 2 segundos
296
+ - Apos 2 retentativas falhas, lanca `NXGate::ServiceUnavailableError`
297
+
298
+ ## Gerenciamento de Token
299
+
300
+ O token OAuth2 e gerenciado automaticamente:
301
+
302
+ - Obtido na primeira requisicao
303
+ - Cacheado em memoria
304
+ - Renovado automaticamente 60 segundos antes da expiracao
305
+ - Invalidado e renovado em caso de erro 401
306
+ - Thread-safe (utiliza Mutex)
307
+
308
+ ## Testes
309
+
310
+ ```bash
311
+ # Executar todos os testes
312
+ bundle exec rake test
313
+
314
+ # Ou diretamente
315
+ ruby -Ilib -Itest test/test_client.rb
316
+ ```
317
+
318
+ ## Licenca
319
+
320
+ Distribuido sob a licenca MIT. Consulte o arquivo [LICENSE](LICENSE) para mais informacoes.
@@ -0,0 +1,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module NXGate
8
+ # Cliente principal da SDK NXGATE PIX.
9
+ #
10
+ # Gerencia autenticacao, assinatura HMAC, retentativas e todas
11
+ # as operacoes da API PIX.
12
+ #
13
+ # @example Uso basico
14
+ # client = NXGate::Client.new(
15
+ # client_id: "nxgate_xxx",
16
+ # client_secret: "secret"
17
+ # )
18
+ # charge = client.pix_generate(valor: 100.0, nome_pagador: "Joao", documento_pagador: "12345678901")
19
+ #
20
+ class Client
21
+ DEFAULT_BASE_URL = "https://api.nxgate.com.br"
22
+ DEFAULT_TIMEOUT = 30
23
+ MAX_RETRIES = 2
24
+ RETRY_BASE_DELAY = 1.0 # segundos
25
+
26
+ attr_reader :base_url
27
+
28
+ # @param client_id [String] ID do cliente NXGATE
29
+ # @param client_secret [String] Segredo do cliente NXGATE
30
+ # @param hmac_secret [String, nil] Segredo HMAC (opcional). Quando fornecido, todas as requisicoes serao assinadas.
31
+ # @param base_url [String] URL base da API (padrao: https://api.nxgate.com.br)
32
+ # @param timeout [Integer] Timeout em segundos para requisicoes HTTP (padrao: 30)
33
+ def initialize(client_id:, client_secret:, hmac_secret: nil, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT)
34
+ @client_id = client_id
35
+ @client_secret = client_secret
36
+ @base_url = base_url.chomp("/")
37
+ @timeout = timeout
38
+
39
+ @hmac_signer = if hmac_secret
40
+ HmacSigner.new(client_id: client_id, hmac_secret: hmac_secret)
41
+ end
42
+
43
+ @token_manager = TokenManager.new(
44
+ base_url: @base_url,
45
+ client_id: client_id,
46
+ client_secret: client_secret,
47
+ hmac_signer: @hmac_signer
48
+ )
49
+ end
50
+
51
+ # Gera uma cobranca PIX (cash-in) e retorna o QR Code.
52
+ #
53
+ # @param valor [Float] Valor da cobranca (obrigatorio)
54
+ # @param nome_pagador [String] Nome do pagador (obrigatorio)
55
+ # @param documento_pagador [String] CPF ou CNPJ do pagador (obrigatorio)
56
+ # @param forcar_pagador [Boolean, nil] Forcar dados do pagador
57
+ # @param email_pagador [String, nil] Email do pagador
58
+ # @param celular [String, nil] Celular do pagador
59
+ # @param descricao [String, nil] Descricao da cobranca
60
+ # @param webhook [String, nil] URL de webhook para notificacoes
61
+ # @param magic_id [String, nil] ID magico para rastreamento
62
+ # @param api_key [String, nil] Chave de API adicional
63
+ # @param split_users [Array<Hash>, nil] Lista de split de pagamento [{username:, percentage:}]
64
+ # @return [NXGate::PixChargeResponse] Resposta com QR Code e dados da cobranca
65
+ # @raise [NXGate::Error] Se a requisicao falhar
66
+ def pix_generate(valor:, nome_pagador:, documento_pagador:, forcar_pagador: nil,
67
+ email_pagador: nil, celular: nil, descricao: nil, webhook: nil,
68
+ magic_id: nil, api_key: nil, split_users: nil)
69
+ body = {
70
+ valor: valor,
71
+ nome_pagador: nome_pagador,
72
+ documento_pagador: documento_pagador
73
+ }
74
+
75
+ body[:forcar_pagador] = forcar_pagador unless forcar_pagador.nil?
76
+ body[:email_pagador] = email_pagador if email_pagador
77
+ body[:celular] = celular if celular
78
+ body[:descricao] = descricao if descricao
79
+ body[:webhook] = webhook if webhook
80
+ body[:magic_id] = magic_id if magic_id
81
+ body[:api_key] = api_key if api_key
82
+
83
+ if split_users
84
+ body[:split_users] = split_users.map do |user|
85
+ if user.is_a?(SplitUser)
86
+ user.to_h
87
+ else
88
+ { "username" => user[:username] || user["username"],
89
+ "percentage" => user[:percentage] || user["percentage"] }
90
+ end
91
+ end
92
+ end
93
+
94
+ data = post("/pix/gerar", body)
95
+ PixChargeResponse.from_hash(data)
96
+ end
97
+
98
+ # Realiza um saque PIX (cash-out).
99
+ #
100
+ # @param valor [Float] Valor do saque (obrigatorio)
101
+ # @param chave_pix [String] Chave PIX de destino (obrigatorio)
102
+ # @param tipo_chave [String] Tipo da chave: CPF, CNPJ, PHONE, EMAIL ou RANDOM (obrigatorio)
103
+ # @param documento [String, nil] Documento do destinatario
104
+ # @param webhook [String, nil] URL de webhook para notificacoes
105
+ # @param magic_id [String, nil] ID magico para rastreamento
106
+ # @param api_key [String, nil] Chave de API adicional
107
+ # @return [NXGate::PixWithdrawResponse] Resposta do saque
108
+ # @raise [NXGate::Error] Se a requisicao falhar
109
+ def pix_withdraw(valor:, chave_pix:, tipo_chave:, documento: nil, webhook: nil,
110
+ magic_id: nil, api_key: nil)
111
+ valid_key_types = %w[CPF CNPJ PHONE EMAIL RANDOM]
112
+ unless valid_key_types.include?(tipo_chave.to_s.upcase)
113
+ raise Error.new(
114
+ code: "INVALID_KEY_TYPE",
115
+ title: "Tipo de chave invalido",
116
+ description: "tipo_chave deve ser um de: #{valid_key_types.join(', ')}. Recebido: #{tipo_chave}"
117
+ )
118
+ end
119
+
120
+ body = {
121
+ valor: valor,
122
+ chave_pix: chave_pix,
123
+ tipo_chave: tipo_chave.to_s.upcase
124
+ }
125
+
126
+ body[:documento] = documento if documento
127
+ body[:webhook] = webhook if webhook
128
+ body[:magic_id] = magic_id if magic_id
129
+ body[:api_key] = api_key if api_key
130
+
131
+ data = post("/pix/sacar", body)
132
+ PixWithdrawResponse.from_hash(data)
133
+ end
134
+
135
+ # Consulta o saldo da conta.
136
+ #
137
+ # @return [NXGate::BalanceResponse] Saldo disponivel, bloqueado e total
138
+ # @raise [NXGate::Error] Se a requisicao falhar
139
+ def get_balance
140
+ data = get("/v1/balance")
141
+ BalanceResponse.from_hash(data)
142
+ end
143
+
144
+ # Consulta uma transacao especifica.
145
+ #
146
+ # @param type [String] Tipo da transacao: "cash-in" ou "cash-out"
147
+ # @param txid [String] ID da transacao
148
+ # @return [NXGate::TransactionResponse] Dados da transacao
149
+ # @raise [NXGate::Error] Se a requisicao falhar
150
+ def get_transaction(type:, txid:)
151
+ path = "/v1/transactions?type=#{uri_encode(type)}&txid=#{uri_encode(txid)}"
152
+ data = get(path)
153
+ TransactionResponse.from_hash(data)
154
+ end
155
+
156
+ private
157
+
158
+ def get(path)
159
+ request_with_retry(:get, path)
160
+ end
161
+
162
+ def post(path, body)
163
+ request_with_retry(:post, path, body)
164
+ end
165
+
166
+ def request_with_retry(method, path, body = nil)
167
+ attempts = 0
168
+
169
+ loop do
170
+ attempts += 1
171
+
172
+ begin
173
+ return execute_request(method, path, body)
174
+ rescue ServiceUnavailableError => e
175
+ raise e if attempts > MAX_RETRIES
176
+
177
+ delay = RETRY_BASE_DELAY * (2**(attempts - 1))
178
+ sleep(delay)
179
+ end
180
+ end
181
+ end
182
+
183
+ def execute_request(method, path, body = nil)
184
+ uri = URI("#{@base_url}#{path}")
185
+ http = build_http(uri)
186
+
187
+ request = build_request(method, uri, body)
188
+ response = http.request(request)
189
+
190
+ handle_response(response)
191
+ end
192
+
193
+ def build_http(uri)
194
+ http = Net::HTTP.new(uri.host, uri.port)
195
+ http.use_ssl = (uri.scheme == "https")
196
+ http.open_timeout = @timeout
197
+ http.read_timeout = @timeout
198
+ http
199
+ end
200
+
201
+ def build_request(method, uri, body = nil)
202
+ path = uri.request_uri
203
+ json_body = body ? JSON.generate(body) : ""
204
+
205
+ request = case method
206
+ when :get
207
+ Net::HTTP::Get.new(path)
208
+ when :post
209
+ req = Net::HTTP::Post.new(path)
210
+ req.body = json_body
211
+ req
212
+ else
213
+ raise ArgumentError, "Metodo HTTP nao suportado: #{method}"
214
+ end
215
+
216
+ request["Content-Type"] = "application/json"
217
+ request["Accept"] = "application/json"
218
+ request["Authorization"] = "Bearer #{@token_manager.access_token}"
219
+ request["User-Agent"] = "nxgate-ruby/#{NXGate::VERSION}"
220
+
221
+ if @hmac_signer
222
+ hmac_headers = @hmac_signer.sign(
223
+ method: method.to_s.upcase,
224
+ path: uri.path,
225
+ body: json_body
226
+ )
227
+ hmac_headers.each { |key, value| request[key] = value }
228
+ end
229
+
230
+ request
231
+ end
232
+
233
+ def handle_response(response)
234
+ status = response.code.to_i
235
+
236
+ case status
237
+ when 200..299
238
+ parse_success(response)
239
+ when 401
240
+ @token_manager.invalidate!
241
+ error_data = parse_error_body(response.body)
242
+ raise AuthenticationError.new(
243
+ code: error_data[:code],
244
+ title: error_data[:title],
245
+ description: error_data[:description],
246
+ http_status: status
247
+ )
248
+ when 503
249
+ error_data = parse_error_body(response.body)
250
+ raise ServiceUnavailableError.new(
251
+ code: error_data[:code] || "SERVICE_UNAVAILABLE",
252
+ title: error_data[:title] || "Servico indisponivel",
253
+ description: error_data[:description] || "A API esta temporariamente indisponivel",
254
+ http_status: status
255
+ )
256
+ else
257
+ error_data = parse_error_body(response.body)
258
+ raise Error.new(
259
+ code: error_data[:code],
260
+ title: error_data[:title],
261
+ description: error_data[:description],
262
+ http_status: status
263
+ )
264
+ end
265
+ end
266
+
267
+ def parse_success(response)
268
+ return {} if response.body.nil? || response.body.empty?
269
+
270
+ JSON.parse(response.body)
271
+ rescue JSON::ParserError
272
+ { "raw" => response.body }
273
+ end
274
+
275
+ def parse_error_body(body)
276
+ return { code: nil, title: nil, description: nil } if body.nil? || body.empty?
277
+
278
+ data = JSON.parse(body)
279
+ {
280
+ code: data["code"] || data["error"],
281
+ title: data["title"] || data["error_description"],
282
+ description: data["description"] || data["message"] || body
283
+ }
284
+ rescue JSON::ParserError
285
+ { code: nil, title: nil, description: body }
286
+ end
287
+
288
+ def uri_encode(str)
289
+ URI.encode_www_form_component(str.to_s)
290
+ end
291
+ end
292
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NXGate
4
+ # Erro personalizado para respostas da API NXGATE.
5
+ #
6
+ # Atributos:
7
+ # code - Codigo interno retornado pela API (ex.: "INVALID_TOKEN")
8
+ # title - Titulo curto do erro
9
+ # description - Descricao detalhada
10
+ # http_status - Codigo HTTP da resposta (ex.: 401, 422, 503)
11
+ class Error < StandardError
12
+ attr_reader :code, :title, :description, :http_status
13
+
14
+ def initialize(message = nil, code: nil, title: nil, description: nil, http_status: nil)
15
+ @code = code
16
+ @title = title
17
+ @description = description
18
+ @http_status = http_status
19
+
20
+ full_message = build_message(message)
21
+ super(full_message)
22
+ end
23
+
24
+ def to_h
25
+ {
26
+ code: @code,
27
+ title: @title,
28
+ description: @description,
29
+ http_status: @http_status
30
+ }
31
+ end
32
+
33
+ private
34
+
35
+ def build_message(message)
36
+ return message if message
37
+
38
+ parts = []
39
+ parts << "[#{@code}]" if @code
40
+ parts << @title if @title
41
+ parts << "- #{@description}" if @description
42
+ parts << "(HTTP #{@http_status})" if @http_status
43
+ parts.empty? ? "NXGate API Error" : parts.join(" ")
44
+ end
45
+ end
46
+
47
+ # Erro de autenticacao (401)
48
+ class AuthenticationError < Error; end
49
+
50
+ # Erro de timeout na conexao
51
+ class TimeoutError < Error; end
52
+
53
+ # Erro quando o servico esta indisponivel (503) apos retentativas
54
+ class ServiceUnavailableError < Error; end
55
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "securerandom"
6
+ require "time"
7
+
8
+ module NXGate
9
+ # Responsavel por gerar assinaturas HMAC-SHA256 para requisicoes autenticadas.
10
+ #
11
+ # O payload assinado segue o formato:
12
+ # "METHOD\nPATH\nTIMESTAMP\nNONCE\nBODY"
13
+ #
14
+ # Headers gerados:
15
+ # X-Client-ID - Identificador do cliente
16
+ # X-HMAC-Signature - Assinatura HMAC-SHA256 em Base64
17
+ # X-HMAC-Timestamp - Timestamp ISO 8601
18
+ # X-HMAC-Nonce - String unica por requisicao
19
+ class HmacSigner
20
+ # @param client_id [String] ID do cliente NXGATE
21
+ # @param hmac_secret [String] Chave secreta para HMAC
22
+ def initialize(client_id:, hmac_secret:)
23
+ @client_id = client_id
24
+ @hmac_secret = hmac_secret
25
+ end
26
+
27
+ # Gera os headers de assinatura HMAC para uma requisicao.
28
+ #
29
+ # @param method [String] Metodo HTTP (GET, POST, etc.)
30
+ # @param path [String] Caminho da requisicao (ex.: "/pix/gerar")
31
+ # @param body [String] Corpo da requisicao (string vazia para GET)
32
+ # @return [Hash] Headers de assinatura
33
+ def sign(method:, path:, body: "")
34
+ timestamp = Time.now.utc.iso8601
35
+ nonce = SecureRandom.uuid
36
+
37
+ payload = "#{method.upcase}\n#{path}\n#{timestamp}\n#{nonce}\n#{body}"
38
+
39
+ digest = OpenSSL::Digest.new("SHA256")
40
+ hmac = OpenSSL::HMAC.digest(digest, @hmac_secret, payload)
41
+ signature = Base64.strict_encode64(hmac)
42
+
43
+ {
44
+ "X-Client-ID" => @client_id,
45
+ "X-HMAC-Signature" => signature,
46
+ "X-HMAC-Timestamp" => timestamp,
47
+ "X-HMAC-Nonce" => nonce
48
+ }
49
+ end
50
+
51
+ # Verifica se uma assinatura HMAC recebida e valida.
52
+ # Util para validar webhooks assinados.
53
+ #
54
+ # @param method [String] Metodo HTTP
55
+ # @param path [String] Caminho da requisicao
56
+ # @param timestamp [String] Timestamp ISO 8601 recebido
57
+ # @param nonce [String] Nonce recebido
58
+ # @param body [String] Corpo da requisicao
59
+ # @param signature [String] Assinatura Base64 recebida
60
+ # @return [Boolean] true se a assinatura e valida
61
+ def verify(method:, path:, timestamp:, nonce:, body:, signature:)
62
+ payload = "#{method.upcase}\n#{path}\n#{timestamp}\n#{nonce}\n#{body}"
63
+
64
+ digest = OpenSSL::Digest.new("SHA256")
65
+ hmac = OpenSSL::HMAC.digest(digest, @hmac_secret, payload)
66
+ expected = Base64.strict_encode64(hmac)
67
+
68
+ secure_compare(expected, signature)
69
+ end
70
+
71
+ private
72
+
73
+ # Comparacao em tempo constante para evitar timing attacks.
74
+ def secure_compare(a, b)
75
+ return false unless a.bytesize == b.bytesize
76
+
77
+ result = 0
78
+ a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
79
+ result.zero?
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module NXGate
8
+ # Gerencia tokens OAuth2 com cache automatico e renovacao.
9
+ #
10
+ # O token e cacheado e renovado automaticamente 60 segundos antes
11
+ # da expiracao para evitar falhas por token expirado.
12
+ class TokenManager
13
+ # Margem de seguranca em segundos antes da expiracao para renovar o token.
14
+ EXPIRY_MARGIN = 60
15
+
16
+ # @param base_url [String] URL base da API
17
+ # @param client_id [String] ID do cliente
18
+ # @param client_secret [String] Segredo do cliente
19
+ # @param hmac_signer [HmacSigner, nil] Assinador HMAC opcional
20
+ def initialize(base_url:, client_id:, client_secret:, hmac_signer: nil)
21
+ @base_url = base_url
22
+ @client_id = client_id
23
+ @client_secret = client_secret
24
+ @hmac_signer = hmac_signer
25
+ @token = nil
26
+ @expires_at = nil
27
+ @mutex = Mutex.new
28
+ end
29
+
30
+ # Retorna um token valido, renovando se necessario.
31
+ # Thread-safe via Mutex.
32
+ #
33
+ # @return [String] Token de acesso Bearer
34
+ # @raise [NXGate::AuthenticationError] Se a autenticacao falhar
35
+ def access_token
36
+ @mutex.synchronize do
37
+ fetch_token if token_expired?
38
+ @token
39
+ end
40
+ end
41
+
42
+ # Forca a renovacao do token na proxima chamada.
43
+ def invalidate!
44
+ @mutex.synchronize do
45
+ @token = nil
46
+ @expires_at = nil
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def token_expired?
53
+ @token.nil? || @expires_at.nil? || Time.now >= @expires_at
54
+ end
55
+
56
+ def fetch_token
57
+ uri = URI("#{@base_url}/oauth2/token")
58
+ body = JSON.generate({
59
+ grant_type: "client_credentials",
60
+ client_id: @client_id,
61
+ client_secret: @client_secret
62
+ })
63
+
64
+ http = Net::HTTP.new(uri.host, uri.port)
65
+ http.use_ssl = (uri.scheme == "https")
66
+ http.open_timeout = 10
67
+ http.read_timeout = 15
68
+
69
+ request = Net::HTTP::Post.new(uri.path)
70
+ request["Content-Type"] = "application/json"
71
+ request["Accept"] = "application/json"
72
+
73
+ if @hmac_signer
74
+ hmac_headers = @hmac_signer.sign(method: "POST", path: uri.path, body: body)
75
+ hmac_headers.each { |key, value| request[key] = value }
76
+ end
77
+
78
+ request.body = body
79
+
80
+ response = http.request(request)
81
+
82
+ unless response.is_a?(Net::HTTPSuccess)
83
+ parsed = parse_error_body(response.body)
84
+ raise AuthenticationError.new(
85
+ code: parsed[:code] || "AUTH_FAILED",
86
+ title: parsed[:title] || "Falha na autenticacao",
87
+ description: parsed[:description] || response.body,
88
+ http_status: response.code.to_i
89
+ )
90
+ end
91
+
92
+ data = JSON.parse(response.body)
93
+ @token = data["access_token"]
94
+ expires_in = (data["expires_in"] || 3600).to_i
95
+ @expires_at = Time.now + expires_in - EXPIRY_MARGIN
96
+ end
97
+
98
+ def parse_error_body(body)
99
+ data = JSON.parse(body)
100
+ {
101
+ code: data["code"] || data["error"],
102
+ title: data["title"] || data["error_description"],
103
+ description: data["description"] || data["message"]
104
+ }
105
+ rescue JSON::ParserError
106
+ { code: nil, title: nil, description: body }
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NXGate
4
+ # Resposta de autenticacao OAuth2.
5
+ TokenResponse = Struct.new(
6
+ :access_token,
7
+ :token_type,
8
+ :expires_in,
9
+ keyword_init: true
10
+ )
11
+
12
+ # Resposta de geracao de cobranca PIX (cash-in).
13
+ PixChargeResponse = Struct.new(
14
+ :status,
15
+ :message,
16
+ :payment_code,
17
+ :id_transaction,
18
+ :payment_code_base64,
19
+ keyword_init: true
20
+ ) do
21
+ def self.from_hash(hash)
22
+ new(
23
+ status: hash["status"],
24
+ message: hash["message"],
25
+ payment_code: hash["paymentCode"],
26
+ id_transaction: hash["idTransaction"],
27
+ payment_code_base64: hash["paymentCodeBase64"]
28
+ )
29
+ end
30
+ end
31
+
32
+ # Resposta de saque PIX (cash-out).
33
+ PixWithdrawResponse = Struct.new(
34
+ :status,
35
+ :message,
36
+ :internal_reference,
37
+ keyword_init: true
38
+ ) do
39
+ def self.from_hash(hash)
40
+ new(
41
+ status: hash["status"],
42
+ message: hash["message"],
43
+ internal_reference: hash["internalreference"]
44
+ )
45
+ end
46
+ end
47
+
48
+ # Resposta de consulta de saldo.
49
+ BalanceResponse = Struct.new(
50
+ :balance,
51
+ :blocked,
52
+ :available,
53
+ keyword_init: true
54
+ ) do
55
+ def self.from_hash(hash)
56
+ new(
57
+ balance: hash["balance"],
58
+ blocked: hash["blocked"],
59
+ available: hash["available"]
60
+ )
61
+ end
62
+ end
63
+
64
+ # Resposta de consulta de transacao.
65
+ TransactionResponse = Struct.new(
66
+ :id_transaction,
67
+ :status,
68
+ :amount,
69
+ :paid_at,
70
+ :end_to_end,
71
+ :raw,
72
+ keyword_init: true
73
+ ) do
74
+ def self.from_hash(hash)
75
+ new(
76
+ id_transaction: hash["idTransaction"],
77
+ status: hash["status"],
78
+ amount: hash["amount"],
79
+ paid_at: hash["paidAt"],
80
+ end_to_end: hash["endToEnd"],
81
+ raw: hash
82
+ )
83
+ end
84
+ end
85
+
86
+ # Evento de webhook recebido.
87
+ WebhookEvent = Struct.new(
88
+ :type,
89
+ :data,
90
+ :raw,
91
+ keyword_init: true
92
+ ) do
93
+ # Retorna true se o evento e de cash-in (QR Code).
94
+ def cash_in?
95
+ type&.start_with?("QR_CODE_")
96
+ end
97
+
98
+ # Retorna true se o evento e de cash-out (saque).
99
+ def cash_out?
100
+ type&.start_with?("PIX_CASHOUT_")
101
+ end
102
+
103
+ # Retorna true se o pagamento foi confirmado com sucesso.
104
+ def success?
105
+ type == "QR_CODE_COPY_AND_PASTE_PAID" || type == "PIX_CASHOUT_SUCCESS"
106
+ end
107
+
108
+ # Retorna true se houve reembolso.
109
+ def refunded?
110
+ type == "QR_CODE_COPY_AND_PASTE_REFUNDED" || type == "PIX_CASHOUT_REFUNDED"
111
+ end
112
+
113
+ # Retorna true se houve erro (apenas cash-out).
114
+ def error?
115
+ type == "PIX_CASHOUT_ERROR"
116
+ end
117
+ end
118
+
119
+ # Dados de split de pagamento.
120
+ SplitUser = Struct.new(
121
+ :username,
122
+ :percentage,
123
+ keyword_init: true
124
+ ) do
125
+ def to_h
126
+ { "username" => username, "percentage" => percentage }
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module NXGate
6
+ # Parser de eventos de webhook da NXGATE.
7
+ #
8
+ # Suporta eventos de cash-in (QR Code) e cash-out (saque PIX).
9
+ #
10
+ # Tipos de evento cash-in:
11
+ # - QR_CODE_COPY_AND_PASTE_PAID - Pagamento confirmado
12
+ # - QR_CODE_COPY_AND_PASTE_REFUNDED - Pagamento reembolsado
13
+ #
14
+ # Tipos de evento cash-out:
15
+ # - PIX_CASHOUT_SUCCESS - Saque realizado com sucesso
16
+ # - PIX_CASHOUT_ERROR - Erro no saque
17
+ # - PIX_CASHOUT_REFUNDED - Saque reembolsado
18
+ module Webhook
19
+ CASH_IN_TYPES = %w[
20
+ QR_CODE_COPY_AND_PASTE_PAID
21
+ QR_CODE_COPY_AND_PASTE_REFUNDED
22
+ ].freeze
23
+
24
+ CASH_OUT_TYPES = %w[
25
+ PIX_CASHOUT_SUCCESS
26
+ PIX_CASHOUT_ERROR
27
+ PIX_CASHOUT_REFUNDED
28
+ ].freeze
29
+
30
+ ALL_TYPES = (CASH_IN_TYPES + CASH_OUT_TYPES).freeze
31
+
32
+ class << self
33
+ # Faz o parse de um payload de webhook.
34
+ #
35
+ # @param payload [String, Hash] Payload JSON (string) ou Hash ja parseado
36
+ # @return [NXGate::WebhookEvent] Evento parseado
37
+ # @raise [NXGate::Error] Se o payload for invalido
38
+ def parse(payload)
39
+ data = normalize_payload(payload)
40
+ event_type = data["type"]
41
+
42
+ unless event_type && ALL_TYPES.include?(event_type)
43
+ raise Error.new(
44
+ code: "INVALID_WEBHOOK",
45
+ title: "Webhook invalido",
46
+ description: "Tipo de evento desconhecido: #{event_type.inspect}"
47
+ )
48
+ end
49
+
50
+ if CASH_IN_TYPES.include?(event_type)
51
+ parse_cash_in(event_type, data)
52
+ else
53
+ parse_cash_out(event_type, data)
54
+ end
55
+ end
56
+
57
+ # Verifica se um tipo de evento e valido.
58
+ #
59
+ # @param type [String] Tipo do evento
60
+ # @return [Boolean]
61
+ def valid_type?(type)
62
+ ALL_TYPES.include?(type)
63
+ end
64
+
65
+ private
66
+
67
+ def normalize_payload(payload)
68
+ case payload
69
+ when String
70
+ JSON.parse(payload)
71
+ when Hash
72
+ payload.transform_keys(&:to_s)
73
+ else
74
+ raise Error.new(
75
+ code: "INVALID_PAYLOAD",
76
+ title: "Payload invalido",
77
+ description: "Esperado String ou Hash, recebido #{payload.class}"
78
+ )
79
+ end
80
+ rescue JSON::ParserError => e
81
+ raise Error.new(
82
+ code: "INVALID_JSON",
83
+ title: "JSON invalido",
84
+ description: "Erro ao parsear JSON do webhook: #{e.message}"
85
+ )
86
+ end
87
+
88
+ def parse_cash_in(event_type, raw)
89
+ event_data = raw["data"] || {}
90
+ event_data = event_data.transform_keys(&:to_s) if event_data.is_a?(Hash)
91
+
92
+ WebhookEvent.new(
93
+ type: event_type,
94
+ data: {
95
+ amount: event_data["amount"],
96
+ status: event_data["status"],
97
+ worked: event_data["worked"],
98
+ tag: event_data["tag"],
99
+ tx_id: event_data["tx_id"],
100
+ end_to_end: event_data["end_to_end"]
101
+ },
102
+ raw: raw
103
+ )
104
+ end
105
+
106
+ def parse_cash_out(event_type, raw)
107
+ WebhookEvent.new(
108
+ type: event_type,
109
+ data: {
110
+ amount: raw["amount"],
111
+ status: raw["status"],
112
+ worked: raw["worked"],
113
+ id_transaction: raw["idTransaction"],
114
+ key: raw["key"]
115
+ },
116
+ raw: raw
117
+ )
118
+ end
119
+ end
120
+ end
121
+ end
data/lib/nxgate.rb ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "nxgate/error"
4
+ require_relative "nxgate/types"
5
+ require_relative "nxgate/hmac_signer"
6
+ require_relative "nxgate/token_manager"
7
+ require_relative "nxgate/webhook"
8
+ require_relative "nxgate/client"
9
+
10
+ # SDK oficial NXGATE para operacoes PIX.
11
+ #
12
+ # Oferece integracoes completas para:
13
+ # - Gerar cobranças PIX (cash-in) com QR Code
14
+ # - Realizar saques PIX (cash-out)
15
+ # - Consultar saldo
16
+ # - Consultar transacoes
17
+ # - Receber e processar webhooks
18
+ #
19
+ # @example
20
+ # require 'nxgate'
21
+ #
22
+ # client = NXGate::Client.new(
23
+ # client_id: 'nxgate_xxx',
24
+ # client_secret: 'secret'
25
+ # )
26
+ #
27
+ # charge = client.pix_generate(
28
+ # valor: 100.00,
29
+ # nome_pagador: 'Joao da Silva',
30
+ # documento_pagador: '12345678901'
31
+ # )
32
+ #
33
+ module NXGate
34
+ VERSION = "1.0.0"
35
+ end
metadata ADDED
@@ -0,0 +1,58 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: nxgate
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - NXGATE
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-11 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: SDK Ruby para integracao com a API NXGATE PIX. Suporta geracao de cobranças
14
+ (cash-in), saques (cash-out), consulta de saldo, transacoes e webhooks. Inclui autenticacao
15
+ OAuth2, assinatura HMAC e retentativas automaticas.
16
+ email:
17
+ - dev@nxgate.com.br
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - LICENSE
23
+ - README.md
24
+ - lib/nxgate.rb
25
+ - lib/nxgate/client.rb
26
+ - lib/nxgate/error.rb
27
+ - lib/nxgate/hmac_signer.rb
28
+ - lib/nxgate/token_manager.rb
29
+ - lib/nxgate/types.rb
30
+ - lib/nxgate/webhook.rb
31
+ homepage: https://github.com/nxgate/nxgate-sdk-ruby
32
+ licenses:
33
+ - MIT
34
+ metadata:
35
+ homepage_uri: https://github.com/nxgate/nxgate-sdk-ruby
36
+ source_code_uri: https://github.com/nxgate/nxgate-sdk-ruby
37
+ changelog_uri: https://github.com/nxgate/nxgate-sdk-ruby/blob/main/CHANGELOG.md
38
+ bug_tracker_uri: https://github.com/nxgate/nxgate-sdk-ruby/issues
39
+ post_install_message:
40
+ rdoc_options: []
41
+ require_paths:
42
+ - lib
43
+ required_ruby_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: 3.0.0
48
+ required_rubygems_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ requirements: []
54
+ rubygems_version: 3.4.19
55
+ signing_key:
56
+ specification_version: 4
57
+ summary: SDK oficial NXGATE para operacoes PIX
58
+ test_files: []