nodexpay 0.1.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.txt +22 -0
- data/README.md +125 -0
- data/docs/README.md +49 -0
- data/docs/api-contract.md +203 -0
- data/docs/architecture.md +226 -0
- data/docs/http-and-errors.md +157 -0
- data/docs/implementation-plan.md +130 -0
- data/docs/payment-results.md +109 -0
- data/docs/testing.md +175 -0
- data/lib/nodex_pay/amount_serializer.rb +78 -0
- data/lib/nodex_pay/client.rb +240 -0
- data/lib/nodex_pay/errors.rb +56 -0
- data/lib/nodex_pay/models.rb +40 -0
- data/lib/nodex_pay/signer.rb +22 -0
- data/lib/nodex_pay/transport.rb +94 -0
- data/lib/nodex_pay/validator.rb +106 -0
- data/lib/nodex_pay/version.rb +5 -0
- data/lib/nodex_pay.rb +11 -0
- data/rbi/nodex_pay.rbi +243 -0
- data/sig/nodex_pay.rbs +165 -0
- metadata +162 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module NodexPay
|
|
7
|
+
class Client
|
|
8
|
+
BASE_URLS = {
|
|
9
|
+
production: "https://app.nodexpay.com/api/v1",
|
|
10
|
+
testnet: "https://testnet.app.nodexpay.com/api/v1"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
DEFAULT_OPEN_TIMEOUT = 5
|
|
14
|
+
DEFAULT_READ_TIMEOUT = 30
|
|
15
|
+
DEFAULT_WRITE_TIMEOUT = 30
|
|
16
|
+
|
|
17
|
+
attr_reader :environment, :open_timeout, :read_timeout, :write_timeout
|
|
18
|
+
|
|
19
|
+
def initialize(
|
|
20
|
+
identification_token:,
|
|
21
|
+
hash_token:,
|
|
22
|
+
environment:,
|
|
23
|
+
open_timeout: DEFAULT_OPEN_TIMEOUT,
|
|
24
|
+
read_timeout: DEFAULT_READ_TIMEOUT,
|
|
25
|
+
write_timeout: DEFAULT_WRITE_TIMEOUT,
|
|
26
|
+
transport: nil
|
|
27
|
+
)
|
|
28
|
+
unless BASE_URLS.key?(environment)
|
|
29
|
+
raise ConfigurationError, "environment must be :production or :testnet"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@identification_token = Validator.credential(identification_token, "identification_token")
|
|
33
|
+
@hash_token = Validator.credential(hash_token, "hash_token")
|
|
34
|
+
@environment = environment
|
|
35
|
+
@open_timeout = Validator.timeout(open_timeout, "open_timeout")
|
|
36
|
+
@read_timeout = Validator.timeout(read_timeout, "read_timeout")
|
|
37
|
+
@write_timeout = Validator.timeout(write_timeout, "write_timeout")
|
|
38
|
+
@transport = transport || Transport::NetHTTP.new
|
|
39
|
+
|
|
40
|
+
unless @transport.respond_to?(:call)
|
|
41
|
+
raise ConfigurationError, "transport must respond to #call"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def create_payment(
|
|
48
|
+
order_code:,
|
|
49
|
+
amount: nil,
|
|
50
|
+
amount_type: nil,
|
|
51
|
+
color_theme: nil,
|
|
52
|
+
ui_mode: nil,
|
|
53
|
+
ext_description: nil,
|
|
54
|
+
deadline: nil,
|
|
55
|
+
description: nil
|
|
56
|
+
)
|
|
57
|
+
order_code = Validator.order_code(order_code)
|
|
58
|
+
amount = AmountSerializer.serialize(amount) unless amount.nil?
|
|
59
|
+
|
|
60
|
+
fields = authenticated_fields(order_code:, amount:)
|
|
61
|
+
fields["amount"] = amount unless amount.nil?
|
|
62
|
+
add_optional_field(fields, "amount_type", Validator.amount_type(amount_type))
|
|
63
|
+
add_optional_field(fields, "color_theme", Validator.color_theme(color_theme))
|
|
64
|
+
add_optional_field(fields, "uimode", Validator.ui_mode(ui_mode))
|
|
65
|
+
add_optional_field(fields, "ext_description", Validator.text(ext_description, "ext_description"))
|
|
66
|
+
add_optional_field(fields, "deadline", Validator.deadline(deadline))
|
|
67
|
+
add_optional_field(fields, "description", Validator.text(description, "description"))
|
|
68
|
+
|
|
69
|
+
response = perform(:create_payment, "/payment/receive", fields)
|
|
70
|
+
decode_payment(response.body)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def cancel_payment(order_code:)
|
|
74
|
+
order_code = Validator.order_code(order_code)
|
|
75
|
+
fields = authenticated_fields(order_code:, amount: nil)
|
|
76
|
+
|
|
77
|
+
perform(:cancel_payment, "/payment/cancel", fields)
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def verify_payment_result(raw_body)
|
|
82
|
+
unless raw_body.is_a?(String)
|
|
83
|
+
raise InvalidResponseError, "Payment Result body must be a String"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
payload = parse_json_object(raw_body, context: "Payment Result")
|
|
87
|
+
order_code = Validator.webhook_string(payload, "order_code")
|
|
88
|
+
transaction_code = Validator.transaction_code(payload)
|
|
89
|
+
amount = Validator.webhook_string(payload, "amount")
|
|
90
|
+
symbol = Validator.webhook_string(payload, "symbol")
|
|
91
|
+
result = Validator.webhook_result(payload)
|
|
92
|
+
verify_token = Validator.verify_token(payload)
|
|
93
|
+
chain_id = Validator.webhook_string(payload, "chain_id")
|
|
94
|
+
transaction_hash = Validator.webhook_string(payload, "transaction_hash")
|
|
95
|
+
|
|
96
|
+
unless Signer.valid?(order_code:, amount:, hash_token: @hash_token, signature: verify_token)
|
|
97
|
+
raise SignatureVerificationError, "Payment Result signature verification failed"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
PaymentResult.new(
|
|
101
|
+
order_code:,
|
|
102
|
+
transaction_code:,
|
|
103
|
+
amount:,
|
|
104
|
+
symbol:,
|
|
105
|
+
result:,
|
|
106
|
+
chain_id:,
|
|
107
|
+
transaction_hash:
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def inspect
|
|
112
|
+
"#<#{self.class} environment=#{environment.inspect}>"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def authenticated_fields(order_code:, amount:)
|
|
118
|
+
{
|
|
119
|
+
"identification_token" => @identification_token,
|
|
120
|
+
"order_code" => order_code,
|
|
121
|
+
"verify_token" => Signer.generate(order_code:, amount:, hash_token: @hash_token)
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def add_optional_field(fields, name, value)
|
|
126
|
+
fields[name] = value unless value.nil?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def perform(operation, path, fields)
|
|
130
|
+
request = Transport::Request.new(
|
|
131
|
+
method: :post,
|
|
132
|
+
uri: URI("#{BASE_URLS.fetch(environment)}#{path}"),
|
|
133
|
+
headers: request_headers,
|
|
134
|
+
body: URI.encode_www_form(fields),
|
|
135
|
+
open_timeout:,
|
|
136
|
+
read_timeout:,
|
|
137
|
+
write_timeout:
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
response = @transport.call(request)
|
|
141
|
+
validate_transport_response!(response)
|
|
142
|
+
|
|
143
|
+
case response.status
|
|
144
|
+
when 200
|
|
145
|
+
response
|
|
146
|
+
when 400
|
|
147
|
+
raise_api_error(BadRequestError, operation, response)
|
|
148
|
+
when 503
|
|
149
|
+
raise_api_error(ServiceUnavailableError, operation, response)
|
|
150
|
+
else
|
|
151
|
+
raise_api_error(UnexpectedHTTPStatusError, operation, response)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def request_headers
|
|
156
|
+
{
|
|
157
|
+
"Accept" => "application/json",
|
|
158
|
+
"Content-Type" => "application/x-www-form-urlencoded",
|
|
159
|
+
"User-Agent" => "nodexpay/#{VERSION} ruby/#{RUBY_VERSION}"
|
|
160
|
+
}.freeze
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def validate_transport_response!(response)
|
|
164
|
+
valid = response.is_a?(Transport::Response)
|
|
165
|
+
valid &&= response.status.is_a?(Integer)
|
|
166
|
+
valid &&= response.headers.is_a?(Hash)
|
|
167
|
+
valid &&= response.headers.all? do |key, value|
|
|
168
|
+
key.is_a?(String) && key == key.downcase && value.is_a?(String)
|
|
169
|
+
end
|
|
170
|
+
valid &&= response.body.is_a?(String)
|
|
171
|
+
return if valid
|
|
172
|
+
|
|
173
|
+
raise InvalidResponseError, "transport returned an invalid response"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def raise_api_error(error_class, operation, response)
|
|
177
|
+
raise error_class.new(
|
|
178
|
+
operation:,
|
|
179
|
+
status: response.status,
|
|
180
|
+
error_codes: extract_error_codes(response.body),
|
|
181
|
+
response_headers: response.headers,
|
|
182
|
+
response_body: response.body
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def extract_error_codes(body)
|
|
187
|
+
payload = JSON.parse(body)
|
|
188
|
+
return [] unless payload.is_a?(Hash)
|
|
189
|
+
|
|
190
|
+
values = if payload.key?("errors")
|
|
191
|
+
payload["errors"]
|
|
192
|
+
elsif payload["data"].is_a?(Hash)
|
|
193
|
+
payload["data"]["errors"]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
Array(values).filter_map do |value|
|
|
197
|
+
case value
|
|
198
|
+
when Integer then value
|
|
199
|
+
when String then Integer(value, 10) if /\A[0-9]+\z/.match?(value)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
rescue JSON::ParserError
|
|
203
|
+
[]
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def decode_payment(body)
|
|
207
|
+
payload = parse_json_object(body, context: "Payment Request response")
|
|
208
|
+
url = required_response_string(payload, "url")
|
|
209
|
+
token = required_response_string(payload, "token")
|
|
210
|
+
ext_reserved = optional_response_string(payload, "ext_reserved")
|
|
211
|
+
ext_description = optional_response_string(payload, "ext_description")
|
|
212
|
+
|
|
213
|
+
Payment.new(url:, token:, ext_reserved:, ext_description:)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def parse_json_object(body, context:)
|
|
217
|
+
payload = JSON.parse(body)
|
|
218
|
+
return payload if payload.is_a?(Hash)
|
|
219
|
+
|
|
220
|
+
raise InvalidResponseError, "#{context} must be a JSON object"
|
|
221
|
+
rescue JSON::ParserError
|
|
222
|
+
raise InvalidResponseError, "#{context} is not valid JSON"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def required_response_string(payload, field)
|
|
226
|
+
value = payload[field]
|
|
227
|
+
return value.dup.freeze if value.is_a?(String) && !value.empty?
|
|
228
|
+
|
|
229
|
+
raise InvalidResponseError, "Payment Request response field #{field.inspect} must be a non-empty String"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def optional_response_string(payload, field)
|
|
233
|
+
value = payload[field]
|
|
234
|
+
return nil if value.nil?
|
|
235
|
+
return value.dup.freeze if value.is_a?(String)
|
|
236
|
+
|
|
237
|
+
raise InvalidResponseError, "Payment Request response field #{field.inspect} must be a String or null"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NodexPay
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class ConfigurationError < Error; end
|
|
7
|
+
class ValidationError < Error; end
|
|
8
|
+
class SignatureVerificationError < Error; end
|
|
9
|
+
class TransportError < Error; end
|
|
10
|
+
class TimeoutError < TransportError; end
|
|
11
|
+
class InvalidResponseError < Error; end
|
|
12
|
+
|
|
13
|
+
class APIError < Error
|
|
14
|
+
ERROR_NAMES = {
|
|
15
|
+
2010 => "RECEIVED_IDENTIFICATION_TOKEN_EMPTY",
|
|
16
|
+
2011 => "RECEIVED_IDENTIFICATION_TOKEN_INVALID",
|
|
17
|
+
2020 => "RECEIVED_ORDER_CODE_EMPTY",
|
|
18
|
+
2021 => "RECEIVED_ORDER_CODE_DUPLICATE",
|
|
19
|
+
2022 => "RECEIVED_ORDER_CODE_ALREADY_CANCELLED",
|
|
20
|
+
2023 => "PAYMENT_NOT_FOUND",
|
|
21
|
+
2024 => "PAYMENT_NOT_CANCELABLE",
|
|
22
|
+
2030 => "RECEIVED_AMOUNT_INVALID_FORMAT",
|
|
23
|
+
2040 => "RECEIVED_INVALID_CALLBACK_URL",
|
|
24
|
+
2050 => "RECEIVED_COLOR_THEME_INVALID_FORMAT",
|
|
25
|
+
2060 => "RECEIVED_VERIFY_TOKEN_EMPTY",
|
|
26
|
+
2061 => "RECEIVED_VERIFY_TOKEN_INVALID",
|
|
27
|
+
2101 => "RECEIVED_EXT_RESERVED_INVALID",
|
|
28
|
+
2102 => "RECEIVED_EXT_DESCRIPTION_INVALID",
|
|
29
|
+
2370 => "RECEIVE_CONTRACT_UNPUBLISHED",
|
|
30
|
+
2460 => "RECEIVED_INVALID_DEADLINE"
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
attr_reader :operation, :status, :error_codes, :response_headers, :response_body
|
|
34
|
+
|
|
35
|
+
def initialize(operation:, status:, error_codes:, response_headers:, response_body:)
|
|
36
|
+
@operation = operation
|
|
37
|
+
@status = status
|
|
38
|
+
@error_codes = error_codes.map { Integer(_1) }.freeze
|
|
39
|
+
@response_headers = response_headers.to_h.transform_keys(&:to_s).transform_values(&:to_s).freeze
|
|
40
|
+
@response_body = response_body.to_s.dup.freeze
|
|
41
|
+
|
|
42
|
+
details = @error_codes.map { |code| ERROR_NAMES.fetch(code, code.to_s) }
|
|
43
|
+
suffix = details.empty? ? "" : " (#{details.join(", ")})"
|
|
44
|
+
super("Nodex Pay #{operation} failed with HTTP #{status}#{suffix}")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def inspect
|
|
48
|
+
"#<#{self.class} operation=#{operation.inspect} status=#{status.inspect} error_codes=#{error_codes.inspect}>"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class BadRequestError < APIError; end
|
|
53
|
+
class ServiceUnavailableError < APIError; end
|
|
54
|
+
class UnexpectedHTTPStatusError < APIError; end
|
|
55
|
+
end
|
|
56
|
+
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NodexPay
|
|
4
|
+
Payment = Data.define(:url, :token, :ext_reserved, :ext_description) do
|
|
5
|
+
def initialize(url:, token:, ext_reserved:, ext_description:)
|
|
6
|
+
super(
|
|
7
|
+
url: url.dup.freeze,
|
|
8
|
+
token: token.dup.freeze,
|
|
9
|
+
ext_reserved: ext_reserved&.dup&.freeze,
|
|
10
|
+
ext_description: ext_description&.dup&.freeze
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
PaymentResult = Data.define(
|
|
16
|
+
:order_code,
|
|
17
|
+
:transaction_code,
|
|
18
|
+
:amount,
|
|
19
|
+
:symbol,
|
|
20
|
+
:result,
|
|
21
|
+
:chain_id,
|
|
22
|
+
:transaction_hash
|
|
23
|
+
) do
|
|
24
|
+
def initialize(order_code:, transaction_code:, amount:, symbol:, result:, chain_id:, transaction_hash:)
|
|
25
|
+
super(
|
|
26
|
+
order_code: order_code.dup.freeze,
|
|
27
|
+
transaction_code: transaction_code.dup.freeze,
|
|
28
|
+
amount: amount.dup.freeze,
|
|
29
|
+
symbol: symbol.dup.freeze,
|
|
30
|
+
result:,
|
|
31
|
+
chain_id: chain_id.dup.freeze,
|
|
32
|
+
transaction_hash: transaction_hash.dup.freeze
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def success?
|
|
37
|
+
result
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module NodexPay
|
|
7
|
+
module Signer
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def generate(order_code:, amount:, hash_token:)
|
|
11
|
+
Digest::SHA256.hexdigest([order_code, amount, hash_token].join("::"))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def valid?(order_code:, amount:, hash_token:, signature:)
|
|
15
|
+
expected = generate(order_code:, amount:, hash_token:)
|
|
16
|
+
return false unless signature.bytesize == expected.bytesize
|
|
17
|
+
|
|
18
|
+
OpenSSL.fixed_length_secure_compare(expected, signature)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module NodexPay
|
|
8
|
+
module Transport
|
|
9
|
+
Request = Data.define(
|
|
10
|
+
:method,
|
|
11
|
+
:uri,
|
|
12
|
+
:headers,
|
|
13
|
+
:body,
|
|
14
|
+
:open_timeout,
|
|
15
|
+
:read_timeout,
|
|
16
|
+
:write_timeout
|
|
17
|
+
) do
|
|
18
|
+
def initialize(method:, uri:, headers:, body:, open_timeout:, read_timeout:, write_timeout:)
|
|
19
|
+
super(
|
|
20
|
+
method:,
|
|
21
|
+
uri: uri.dup.freeze,
|
|
22
|
+
headers: immutable_headers(headers),
|
|
23
|
+
body: body.dup.freeze,
|
|
24
|
+
open_timeout:,
|
|
25
|
+
read_timeout:,
|
|
26
|
+
write_timeout:
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def immutable_headers(headers)
|
|
33
|
+
headers.to_h.each_with_object({}) do |(key, value), copy|
|
|
34
|
+
copy[key.dup.freeze] = value.dup.freeze
|
|
35
|
+
end.freeze
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Response = Data.define(:status, :headers, :body) do
|
|
40
|
+
def initialize(status:, headers:, body:)
|
|
41
|
+
immutable_headers = headers.to_h.each_with_object({}) do |(key, value), copy|
|
|
42
|
+
copy[key.dup.freeze] = value.dup.freeze
|
|
43
|
+
end.freeze
|
|
44
|
+
|
|
45
|
+
super(status:, headers: immutable_headers, body: body.dup.freeze)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class NetHTTP
|
|
50
|
+
def call(request)
|
|
51
|
+
http = Net::HTTP.new(request.uri.host, request.uri.port)
|
|
52
|
+
http.use_ssl = request.uri.scheme == "https"
|
|
53
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
|
|
54
|
+
http.open_timeout = request.open_timeout
|
|
55
|
+
http.read_timeout = request.read_timeout
|
|
56
|
+
http.write_timeout = request.write_timeout
|
|
57
|
+
http.max_retries = 0
|
|
58
|
+
|
|
59
|
+
net_request = build_request(request)
|
|
60
|
+
net_response = http.request(net_request)
|
|
61
|
+
|
|
62
|
+
Response.new(
|
|
63
|
+
status: net_response.code.to_i,
|
|
64
|
+
headers: response_headers(net_response),
|
|
65
|
+
body: net_response.body.to_s
|
|
66
|
+
)
|
|
67
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => error
|
|
68
|
+
raise TimeoutError.new("Nodex Pay request timed out for #{safe_target(request.uri)}"), cause: error
|
|
69
|
+
rescue SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError => error
|
|
70
|
+
raise TransportError.new("Nodex Pay request failed for #{safe_target(request.uri)}"), cause: error
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def build_request(request)
|
|
76
|
+
unless request.method == :post
|
|
77
|
+
raise ConfigurationError, "NetHTTP transport only supports POST"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
Net::HTTP::Post.new(request.uri.request_uri, request.headers).tap do |net_request|
|
|
81
|
+
net_request.body = request.body
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def response_headers(response)
|
|
86
|
+
response.each_header.to_h.transform_keys(&:downcase).transform_values(&:to_s).freeze
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def safe_target(uri)
|
|
90
|
+
"#{uri.scheme}://#{uri.host}:#{uri.port}#{uri.path}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NodexPay
|
|
4
|
+
module Validator
|
|
5
|
+
AMOUNT_TYPES = %w[USD JPY EUR AED SGD HKD CAD IDR PHP INR KRW].freeze
|
|
6
|
+
COLOR_THEMES = %w[dark light].freeze
|
|
7
|
+
UI_MODES = %w[switchable].freeze
|
|
8
|
+
TRANSACTION_CODE_PATTERN = /\A[A-Za-z0-9]{4}(?:-[A-Za-z0-9]{4}){2}\z/
|
|
9
|
+
VERIFY_TOKEN_PATTERN = /\A[0-9a-f]{64}\z/
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def credential(value, name)
|
|
14
|
+
return value.dup.freeze if value.is_a?(String) && !value.empty?
|
|
15
|
+
|
|
16
|
+
raise ConfigurationError, "#{name} must be a non-empty String"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def timeout(value, name)
|
|
20
|
+
valid = value.is_a?(Numeric) && value.positive?
|
|
21
|
+
valid &&= !value.respond_to?(:finite?) || value.finite?
|
|
22
|
+
return value if valid
|
|
23
|
+
|
|
24
|
+
raise ConfigurationError, "#{name} must be a positive finite number"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def order_code(value)
|
|
28
|
+
unless value.is_a?(String) && !value.empty? && value.ascii_only? && value.bytesize <= 255
|
|
29
|
+
raise ValidationError, "order_code must be 1..255 ASCII bytes"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
value.dup.freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def amount_type(value)
|
|
36
|
+
enum(value, "amount_type", AMOUNT_TYPES, :upcase)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def color_theme(value)
|
|
40
|
+
enum(value, "color_theme", COLOR_THEMES, :downcase)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def ui_mode(value)
|
|
44
|
+
enum(value, "ui_mode", UI_MODES, :downcase)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def text(value, name)
|
|
48
|
+
return nil if value.nil?
|
|
49
|
+
unless value.is_a?(String) && value.encoding == Encoding::UTF_8 && value.valid_encoding? && value.length <= 100
|
|
50
|
+
raise ValidationError, "#{name} must be a valid UTF-8 String of at most 100 characters"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
value.dup.freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def deadline(value)
|
|
57
|
+
return nil if value.nil?
|
|
58
|
+
|
|
59
|
+
seconds = value.is_a?(Time) ? value.to_i : value
|
|
60
|
+
return seconds if seconds.is_a?(Integer) && !seconds.negative?
|
|
61
|
+
|
|
62
|
+
raise ValidationError, "deadline must be a Time or non-negative Integer"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def webhook_string(payload, field)
|
|
66
|
+
value = payload[field]
|
|
67
|
+
return value.dup.freeze if value.is_a?(String) && !value.empty?
|
|
68
|
+
|
|
69
|
+
raise InvalidResponseError, "Payment Result field #{field.inspect} must be a non-empty String"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def transaction_code(payload)
|
|
73
|
+
value = webhook_string(payload, "transaction_code")
|
|
74
|
+
return value if TRANSACTION_CODE_PATTERN.match?(value)
|
|
75
|
+
|
|
76
|
+
raise InvalidResponseError, "Payment Result transaction_code has an invalid format"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def verify_token(payload)
|
|
80
|
+
value = webhook_string(payload, "verify_token")
|
|
81
|
+
return value if VERIFY_TOKEN_PATTERN.match?(value)
|
|
82
|
+
|
|
83
|
+
raise InvalidResponseError, "Payment Result verify_token has an invalid format"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def webhook_result(payload)
|
|
87
|
+
value = payload["result"]
|
|
88
|
+
return value if value.equal?(true) || value.equal?(false)
|
|
89
|
+
|
|
90
|
+
raise InvalidResponseError, "Payment Result field \"result\" must be a Boolean"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def enum(value, name, allowed, transform)
|
|
94
|
+
return nil if value.nil?
|
|
95
|
+
unless value.is_a?(String) || value.is_a?(Symbol)
|
|
96
|
+
raise ValidationError, "#{name} must be one of: #{allowed.join(", ")}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
normalized = value.to_s.public_send(transform)
|
|
100
|
+
return normalized.freeze if allowed.include?(normalized)
|
|
101
|
+
|
|
102
|
+
raise ValidationError, "#{name} must be one of: #{allowed.join(", ")}"
|
|
103
|
+
end
|
|
104
|
+
private_class_method :enum
|
|
105
|
+
end
|
|
106
|
+
end
|
data/lib/nodex_pay.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "nodex_pay/version"
|
|
4
|
+
require_relative "nodex_pay/errors"
|
|
5
|
+
require_relative "nodex_pay/models"
|
|
6
|
+
require_relative "nodex_pay/amount_serializer"
|
|
7
|
+
require_relative "nodex_pay/validator"
|
|
8
|
+
require_relative "nodex_pay/signer"
|
|
9
|
+
require_relative "nodex_pay/transport"
|
|
10
|
+
require_relative "nodex_pay/client"
|
|
11
|
+
|